diff --git a/app/api/activity/route.ts b/app/api/activity/route.ts index e225725..fa91157 100644 --- a/app/api/activity/route.ts +++ b/app/api/activity/route.ts @@ -1,8 +1,8 @@ /** * GET /api/activity — the dock's one poll: every running background * process, every view (chat, home, everywhere). Composition only — the - * feed lives in server/observability.ts; cost folds client-side via - * core/prices.ts. See that module's header for why /api/jobs stays + * feed lives in server/observability.ts; costs are projected server-side + * through Pi's catalog and public calculator. See that module's header for why /api/jobs stays * chat-scoped and untouched. */ import { activityFeed } from "@/src/server"; diff --git a/app/api/chats/[id]/route.ts b/app/api/chats/[id]/route.ts index 9ae3fda..1b19ce5 100644 --- a/app/api/chats/[id]/route.ts +++ b/app/api/chats/[id]/route.ts @@ -1,4 +1,4 @@ -import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server"; +import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, type ChatDeleteMode } from "@/src/server"; export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -17,6 +17,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str return Response.json({ ...chat, messages: chat.messages, + pricing: sessionCost(chat), turnInFlight: isTurnRunning(id), compacting: compactionInFlight(id), coverage: sessionCoverage(id), diff --git a/app/api/graph/costs/route.ts b/app/api/graph/costs/route.ts index eaa8459..a2f209f 100644 --- a/app/api/graph/costs/route.ts +++ b/app/api/graph/costs/route.ts @@ -4,7 +4,7 @@ * three usage channels (chat extraction runs, notes-source runs, * cluster-naming runs). Thin transport over src/server's graphCosts(); * the fold itself is pure core (core/cost-summary.ts). Dollars are - * inferred from PRICES at read time — never stored, so a price sync + * inferred from Pi's catalog/calculator at read time — never stored, so a catalog update * re-prices the whole history for free. * * Scope (user-decided): the graph pipeline ONLY — teaching turns and diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index f619042..32d7469 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -4,7 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import type { CallUsage, JobKind, JobRecord } from "@/src/core"; -import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core"; +import { isModelsRefusal, isProviderKeyRefusal } from "@/src/core"; import { formatTokens, planCostLabel } from "@/components/usage-format"; import { useServerEvent, useServerResync } from "@/components/server-events"; @@ -20,7 +20,7 @@ import { useServerEvent, useServerResync } from "@/components/server-events"; * Data: ONE feed of GET /api/activity (jobs across ALL chats, title-joined, * oldest-first + extraction runs), fetched when truth moves (events * channel, S5) and folded client-side with core/prices.ts. - * Cost is INFERRED (jobCost); the context line is the LAST model call's + * Cost is INFERRED server-side through Pi; the context line is the LAST model call's * input+output against the model's limit (JobUsage.lastStep — pending * contract) — never a cumulative sum, which would be the wrong number. * Absent data renders "—", never zero (the doctrine). @@ -48,6 +48,8 @@ interface FeedUsage extends NonNullable { } export interface FeedJob extends JobRecord { + /** Read-time server estimate; absent on older transport payloads. */ + costUsd?: number | null; title: string; usage?: FeedUsage; /** Server-joined live catalog limit (owner amendment 2026-09-23) — @@ -200,7 +202,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] { // blob shape's call — here the line simply hides). const limit = job.contextLimit; if (usage && lastStep && limit !== null && lastStep.inputTokens !== undefined) { - // Same metric as the session meter (prices.ts stepFootprint — the + // Same metric as the session meter (four normalized buckets — the // context-meter audit, owner 2026-09-23): all four directions. // Provider "input" EXCLUDES cacheRead/cacheWrite, so input+output // alone reads wildly low on cache-heavy jobs. @@ -220,7 +222,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] { title: job.title, chatId: job.chatId, startedAt: Date.parse(job.createdAt) || 0, - cost: jobCost(job), + cost: job.costUsd ?? null, context, // BYOK (owner, 2026-09-22): a refusal is info, not failure — // the honest line the runner left ("failed") reads as setup diff --git a/components/chat.tsx b/components/chat.tsx index 719a4d0..4986f3f 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -57,7 +57,7 @@ import { PracticeDock } from "@/components/practice-dock"; import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards"; import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card"; import { TopBar } from "@/components/top-bar"; -import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core"; +import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage, SessionPricing } from "@/src/core"; import type { LhViewMessage } from "@/src/core"; import { PhaseChip, PhaseDivider } from "@/components/phase-divider"; import { @@ -236,7 +236,7 @@ export function Chat({ // (core/view.ts projectView); sends are ONE explicit input event each // ({chatId, input} — the stash indirection died with the SDK), and the // turn stream is the VERBATIM AgentEvent passthrough the server emits. - const { messages, nativeMessages, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = + const { messages, nativeMessages, contextTokens, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = useChatSession({ chatId, initialMessages, initiallyInFlight }); const sessionFindScopeRef = useRef(null); @@ -717,8 +717,9 @@ export function Chat({ const syncFromServer = useCallback(async () => { const res = await fetch(`/api/chats/${chatId}`); if (!res.ok) return; - const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[] }; + const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing }; if (Array.isArray(record.turns)) setTurns(record.turns); + setPricing(record.pricing); if (!Array.isArray(record.messages)) return; setMessages(record.messages); // The continuation seam (contracts.ts protocol §4): a job result @@ -845,12 +846,13 @@ export function Chat({ const planTree = useMemo(() => latestTree(messages), [messages]); // ── Session usage (wave-4 stats bar) ── - // The money channel: the record's turns log, folded client-side by - // core/prices.ts. Seeded once on mount (the page doesn't carry turns), + // The money channel: the record's turns log and read-time server Pi + // pricing projection. Seeded once on mount (the page doesn't carry turns), // refreshed when the local stream settles (each turn appends to the log // server-side), and kept fresh by syncFromServer whenever it already // fetches the record. No turns yet → the bar renders "—" everywhere. const [turns, setTurns] = useState([]); + const [pricing, setPricing] = 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). // The dock's reserved tail height (DockOverlay reports it; the spacer at @@ -864,8 +866,9 @@ export function Chat({ try { const res = await fetch(`/api/chats/${chatId}`); if (!res.ok) return; - const record = (await res.json()) as { turns?: TurnUsage[] }; + const record = (await res.json()) as { turns?: TurnUsage[]; pricing?: SessionPricing }; if (Array.isArray(record.turns)) setTurns(record.turns); + setPricing(record.pricing); } catch { // transient blip: stats stay on the last known figures } @@ -1604,6 +1607,8 @@ export function Chat({ onReadingThinking={applyReadingThinking} phase={phases.at(-1) ?? "probe"} turns={turns} + contextTokens={contextTokens} + pricing={pricing} onReserve={setDockReserve} dictationSlot={} dictationCancel={dictation.cancel} @@ -1824,6 +1829,8 @@ function DockOverlay({ onReadingThinking, phase, turns, + contextTokens, + pricing, composer, onSubmitAnswer, chatId, @@ -1855,6 +1862,8 @@ function DockOverlay({ phase: Phase3; /** The session's usage log — the stats bar beneath the composer folds it. */ turns: TurnUsage[]; + contextTokens: number | null; + pricing?: SessionPricing; composer: React.ReactNode; onSubmitAnswer: (output: QuestionAnswerData) => void; /** The model control's pass-through (the stats bar hosts it). */ @@ -2327,6 +2336,8 @@ function DockOverlay({ morphing shell so it never joins a height animation. */} diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 25acc8c..293bf44 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -303,7 +303,7 @@ function graphUsageLabel(costs: Pick): string { const plan = stages.some((stage) => (stage.subscriptionRuns ?? 0) > 0); const priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0); const dollars = displayCostTotals(costs).totalDollars; - return plan ? (priced ? `$${dollars.toFixed(2)} API + ChatGPT plan` : "ChatGPT plan") : `$${dollars.toFixed(2)}`; + return plan ? (priced ? `$${dollars.toFixed(2)} API + ChatGPT plan` : "ChatGPT plan") : priced ? `$${dollars.toFixed(2)}` : "—"; } /** diff --git a/components/model-picker.tsx b/components/model-picker.tsx index a442d69..1ae092d 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -81,7 +81,7 @@ export function catalogLimits(catalog: Pick): Ma * changing the new-session default. Unpicked reads "—" (the * no-factory-fallback language: the UI never presents a default model). */ -export function ModelControl({ chatId }: { chatId?: string }) { +export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) { const { catalog, refresh } = useModelCatalog(); const [models, setModels] = useState(); const [session, setSession] = useState(); @@ -136,6 +136,9 @@ export function ModelControl({ chatId }: { chatId?: string }) { : strong; const effectiveId = override?.model?.id; const effectiveProvider = override?.model?.provider; + useEffect(() => { + onModelChange?.(effectiveProvider && effectiveId ? `${effectiveProvider}:${effectiveId}` : undefined); + }, [effectiveProvider, effectiveId, onModelChange]); const storedLevel = override?.thinkingLevel; const entry = catalog?.models.find( (m) => m.id === effectiveId && m.provider === effectiveProvider, diff --git a/components/session-core.ts b/components/session-core.ts index af3c252..2d765fa 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -17,6 +17,7 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core"; import type { ToolResultMessage } from "@earendil-works/pi-ai"; import { needsContinuation, + measuredContextTokens, newAnswerResult, newNextMessage, newProblemDismissedMarker, @@ -163,6 +164,8 @@ function sameDetails(a: unknown, b: unknown): boolean { export interface SessionState { messages: LhViewMessage[]; nativeMessages: LhMessage[]; + /** Completed conversation usage; null after compaction or before first usage. */ + contextTokens: number | null; status: SessionStatus; error: { message: string } | null; /** Steers sent but not yet drained (composer steering, 2026-10-03): @@ -220,6 +223,7 @@ export class SessionModel { // The live turn's execution layer (view model mid-turn states): private executions = new Map(); private streamingIds = new Set(); + private incompleteUsageIds = new Set(); /** The in-flight assistant message's client id (see applyWireEvent). */ private streamingMessageId: string | null = null; private statusValue: SessionStatus = "ready"; @@ -257,6 +261,7 @@ export class SessionModel { return { messages: projectView([...this.base, ...this.tail], live), nativeMessages: [...this.base, ...this.tail], + contextTokens: measuredContextTokens([...this.base, ...this.tail], this.incompleteUsageIds), status: this.statusValue, error: this.errorValue, steerQueue: this.steerQueueValue, @@ -364,6 +369,8 @@ export class SessionModel { // the wire) still wins. const id = message.id ?? this.streamingMessageId ?? localId(); const withId = { ...message, id } as LhMessage; + if (agent.type === "message_end") this.incompleteUsageIds.delete(id); + else this.incompleteUsageIds.add(id); if (agent.type === "message_start") { this.streamingMessageId = id; this.streamingIds.add(id); @@ -487,6 +494,7 @@ export class SessionModel { // every path that actually replays onto a stale tail). if (!refetchRecordFirst) { this.mutate(() => { + this.incompleteUsageIds.clear(); this.tail = []; this.streamingMessageId = null; }); @@ -502,6 +510,7 @@ export class SessionModel { if (Array.isArray(record.messages)) { this.mutate(() => { this.base = record.messages!; + this.incompleteUsageIds.clear(); this.tail = []; }); } @@ -509,6 +518,7 @@ export class SessionModel { // The record did not land — restore the clean-slate guard // before the replay (no atomic swap is coming). this.mutate(() => { + this.incompleteUsageIds.clear(); this.tail = []; this.streamingMessageId = null; }); @@ -714,6 +724,7 @@ export class SessionModel { setMessages(record: LhMessage[]): void { this.mutate(() => { this.base = record; + this.incompleteUsageIds.clear(); this.tail = []; this.streamingMessageId = null; // the tail (and its slot) is gone }); diff --git a/components/session.ts b/components/session.ts index 2fb1e06..e0652cf 100644 --- a/components/session.ts +++ b/components/session.ts @@ -124,6 +124,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U return { messages: state.messages, nativeMessages: state.nativeMessages, + contextTokens: state.contextTokens, status: state.status, error: state.error, steerQueue: state.steerQueue, diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index cf7a940..2b9adc6 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -1,10 +1,15 @@ "use client"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core"; +import type { SessionPricing, TurnUsage } from "@/src/core"; import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format"; import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker"; +export function formatContextMeasurement(tokens: number | null, limit: number | null): string { + const numerator = tokens === null ? "?" : formatTokens(tokens); + return limit === null ? numerator : `${numerator} / ${formatTokens(limit)}`; +} + /** * The below-composer stats bar (wave 4 observability): the session's * money/context footprint as one quiet row beneath the composer — @@ -16,23 +21,23 @@ import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model * there died with the injection; a "— · —" summary over an empty * popover is pure noise) — the dictation strip's middle simply widens. * - * All folds come from core/prices.ts (sessionCost, contextWindow, turnCost) - * — 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 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. + * Spending arrives already priced by the server through Pi's catalog and + * calculator. Missing cost stays "—", never fake zero. Context is a separate + * completed-conversation measurement, never a money-log/summarizer fold or + * transcript estimate. Its catalog denominator follows the selected model; + * model changes preserve the numerator. Successful compaction clears it to ?. */ export function StatsBar({ turns, + contextTokens = null, + pricing, dictationSlot, chatId, leadingSlot, }: { turns: TurnUsage[]; + contextTokens?: number | null; + pricing?: SessionPricing; /** The bound session (per-session models, owner 2026-09-28): present * on the in-session bar — the model control binds THE SESSION'S own * record; absent on the draft surfaces, where it binds the strong @@ -49,6 +54,7 @@ export function StatsBar({ leadingSlot?: ReactNode; }) { const [open, setOpen] = useState(false); + const [selectedModel, setSelectedModel] = useState(); // Same dropdown discipline as the model control (owner, df4433f treatment, // stats round 1): close on any pointerdown outside the bar or on Escape. const barRef = useRef(null); @@ -73,22 +79,17 @@ export function StatsBar({ document.removeEventListener("keydown", onKeyDown); }; }, [open]); - // The live-limits join (context-meter amendment, owner 2026-09-23): - // contextWindow's denominator resolves ONLY from pi's live catalog — - // built client-side over the picker's cached /api/models fetch (the - // same fetch ModelControl below rides; module-level dedupe). Catalog - // pending or failed → undefined index → limit null → USAGE-ONLY (the - // honest-render precedent, never a fake window); the usage-only render - // IS the null path. + // Catalog-only denominator; the picker reports its effective selection. + // Missing/pending catalog means usage-only, never a hardcoded maximum. const { catalog } = useModelCatalog(); const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]); - const ctx = contextWindow({ turns }, limits); - const { cost, unpriced, subscription = 0 } = sessionCost({ turns }); + const limit = selectedModel ? limits?.get(selectedModel) ?? null : null; + const contextLabel = formatContextMeasurement(contextTokens, limit); + const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {}; // Honest cost: show dollars only when at least one turn priced; // otherwise "—" (never a fake $0.0000). const pricedCount = turns.length - unpriced - subscription; const costLabel = subscription ? (pricedCount > 0 ? `${formatUsd(cost)} API + ChatGPT plan` : "ChatGPT plan") : pricedCount > 0 ? formatUsd(cost) : "—"; - const lastModel = turns.at(-1)?.model; return (
0 && ( + {(chatId || turns.length > 0) && (
{/* No tooltip — the numbers describe themselves and the popover speaks on click (owner, subtlety round 2026-09-25). */} @@ -122,7 +123,7 @@ export function StatsBar({ className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground" > - {ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"} + {contextLabel} · {costLabel} @@ -139,7 +140,7 @@ export function StatsBar({
Context (last call) - {ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"} + {contextLabel}
@@ -161,7 +162,7 @@ export function StatsBar({
    {turns.map((turn, i) => { - const c = turnCost(turn); + const c = pricing?.turnCosts[turn.id] ?? null; return (
  • @@ -190,7 +191,7 @@ export function StatsBar({ {/* 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. */} - +
); } diff --git a/docs/compaction-accounting.md b/docs/compaction-accounting.md new file mode 100644 index 0000000..f3b20d6 --- /dev/null +++ b/docs/compaction-accounting.md @@ -0,0 +1,84 @@ +# Compaction and usage accounting + +## Retention + +`src/core/compaction.ts` locally ports the cutoff-selection behavior and relevant +message-size heuristic from **@earendil-works/pi-coding-agent 1.4.0**. There is no +coding-agent dependency. Upgrade this port deliberately; the native golden +fixtures are in `tests/compaction-native-parity.test.ts`. + +Walk backward to the approximately 20,000-token retained-tail budget, then select +the nearest valid user/assistant boundary at or after the crossing. Never start +at a tool result. If trailing results alone exceed the budget, keep the preceding +assistant call. Preserve adjacent context-invisible metadata. LearnHarness adapts +its visible custom markers to user-like entries, excludes UI-only markers, and +uses the active compaction's retained history rather than discarded messages. + +Text, visible thinking, tool-call names/arguments use characters divided by four; +images receive the native fixed 1,200-token allowance. Encrypted signatures are +not measured or stripped. Whole-message boundaries can overshoot. This does +**not** guarantee a measured 20k tail or a fixed post-compaction request size. + +Only cutoff selection is ported. LearnHarness retains its educational summary +prompt, plan continuity, persistence, threshold, scheduling, and authorization +lifecycle. It does not adopt Pi's coding summary or separate split-turn summary +calls. A cut that drops no active history does not launch a summarizer. + +## Conversation meter + +The numerator is the latest qualifying completed conversation response's native +Pi `input + output + cacheRead + cacheWrite`. Reasoning is already inside output; +`totalTokens` is not an override. Partial stream updates, errors, aborts, invalid +counts, and all-zero usage do not replace the last measurement. A successful +compaction marker invalidates earlier measurements: show `? / maximum` until a +new qualifying response completes. Summarizer usage never supplies this meter. + +The denominator follows the selected model's catalog limit, independently of the +last numeric measurement. Changing models preserves that numerator. No catalog +limit means no invented denominator; no percentage display is added. + +## Spending + +`src/server/pricing.ts` uses Pi AI's full, key-independent catalog and public +`calculateCost` on a new usage object. The browser receives derived costs, not a +second calculator/rate table. Totals are estimates using the currently shipped +catalog, not provider invoices; catalog updates can reprice history. + +Saved input is Pi-normalized **fresh input**, disjoint from cache buckets. Never +subtract cached reads from it again or migrate old logs as raw inclusive prompt +tokens. New captures preserve the one-hour subset of cache writes and individual +background-job calls. Older write-bearing records with a lost retention split, +and tier-priced multi-call jobs with only aggregate usage, remain unpriced when +the lost detail can change the answer. They are not reconstructed from totals. +Flat-rate historical jobs with sufficient counts remain priceable. Missing models +or required usage stay unpriced; ChatGPT subscriptions remain plan usage, not +API dollars. Summarizer costs continue to count toward session spending. + +## Upstream attribution + +The cutoff/estimator port adapts MIT-licensed code from +`@earendil-works/pi-coding-agent` 1.4.0, author Mario Zechner (published package +metadata). The inspected `dist/core/compaction/compaction.js` SHA-256 is +`f51e78ef1da66b5803fc91e185f07b968f8f989ab9326b7744958416aad3f6f6`. + +MIT License + +Copyright (c) Mario Zechner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/docs/development.md b/docs/development.md index edd42d0..062a72e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -30,7 +30,6 @@ npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY key npx tsx --env-file=.env.local scripts/import-notes.ts # bulk-import notes → graph npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extract every chat npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers -npx tsx scripts/sync-pricing.ts # regenerate core/prices.ts tables ``` For Windows/Linux packages and the three-OS CI/release flow, see [`desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS. @@ -144,7 +143,7 @@ src/ electron/ desktop launcher + security guard (spawns the standalone server; 1 IPC channel; LH_DATA_DIR + BYOK env) scripts/ build-server.mjs (packaging guards), import-notes, - backfill-graph, replay-layout, sync-pricing + backfill-graph, replay-layout data/ chats/ · graph/ (nodes/chains/sessions/coverage ledgers + positions/sources/render-settings maps) · jobs/ · settings.json — runtime state, git-ignored @@ -152,6 +151,10 @@ data/ chats/ · graph/ (nodes/chains/sessions/coverage ## Key mechanisms +See [compaction and accounting](compaction-accounting.md) for the local Pi cutoff port, +measurement-only context meter, and server-side current-catalog pricing. There is no +pricing-generation step: Pi AI's catalog and public calculator are the authority. + **Client-side question tools.** `quiz` and `ask_user_question` have no server `execute`: the tool call streams to the browser, the learner answers in the dock, the client grades via `question-core` and returns the verdict with `addToolOutput` — which fires the SDK continuation so the teacher resumes mid-turn. Options are shuffled deterministically (seeded by toolCallId) so server and client renders match. A pending quiz persisted in the chat record is exactly what makes **resume mid-question free**. **The formal plan tree (author-once).** The teacher authors the session's dependency tree with one schema-validated tool — `plan_init`, whose mermaid-subset input is parsed deterministically (line-numbered errors) and validated as a DAG. The tree persists in the chat record; progress is never mutated — `current`/`visited`/upcoming are **derived** from tagged quiz calls in the transcript, and the panel renders that derivation. diff --git a/electron-builder.yml b/electron-builder.yml index 8c27305..bd28156 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -84,6 +84,9 @@ files: # Main-process ID-token signature verification (no bundled OAuth implementation). - node_modules/jose/**/* extraResources: + # Attribution/license for the bounded local Pi cutoff-selector port. + - from: docs/compaction-accounting.md + to: licenses/pi-compaction.md # Copy from the PARENT dir (.next), not .next/standalone itself: # electron-builder hard-excludes a copy's root node_modules and the # default no-filter pattern is a literal path match (empirically both diff --git a/scripts/sync-pricing.ts b/scripts/sync-pricing.ts deleted file mode 100644 index 9625ec8..0000000 --- a/scripts/sync-pricing.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Regenerate src/core/prices.ts tables from pi-ai's shipped per-provider - * catalogs (providers/data/*.json). - * - * Usage: npx tsx scripts/sync-pricing.ts - * - * The catalogs are pi-ai's curated, versioned snapshots — 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 - * catalogs gets no entry — cost renders as absent ("—"), never zero. - * - * Key format (model slots wave, 2026-09-20): uniformly provider-qualified - * ("provider:id", incl. "openrouter:"). Owner decision: no legacy - * compat — records stamped with pre-wave bare slugs simply render unpriced. - */ - -import * as fs from "node:fs"; -import * as path from "node:path"; - -const DATA_DIR = path.join( - import.meta.dirname, - "..", - "node_modules", - "@earendil-works", - "pi-ai", - "dist", - "providers", - "data", -); -const OUT = path.join(import.meta.dirname, "..", "src", "core", "prices.ts"); - -/** One raw catalog entry (fields we consume; the files carry more). */ -interface CatalogModel { - id: string; - name: string; - /** pi-ai 1.0 (catalog schema v6): every entry is type-tagged. */ - type?: "chat" | "image" | "classifier"; - cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; -} - -/** The pure walk: every catalog entry that carries pricing, keyed - * "provider:id". Exported for the drift-guard test (tests/prices-sync) - * so the guard asserts EXACTLY what this script would generate — the - * test and the generator can never disagree (owner, 2026-09-29). */ -export function buildPrices(): Map { - const priced = new Map(); - for (const file of fs.readdirSync(DATA_DIR).filter((f) => f.endsWith(".json"))) { - const provider = file.slice(0, -".json".length); - const catalog = JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), "utf-8")) as Record< - string, - Record - >; - for (const models of Object.values(catalog)) { - for (const [id, m] of Object.entries(models)) { - // pi-ai 1.0 (catalog schema v6): keys are type-qualified - // ("chat:", "image:", "classifier:") and entries - // carry their own `type`. We price CHAT models only — our - // usage records are chat — and keys stay "provider:id" - // (owner format, 2026-09-20): strip the chat: prefix, skip the - // rest. - if (m.type && m.type !== "chat") continue; - const plainId = id.startsWith("chat:") ? id.slice("chat:".length) : id; - // A catalog entry without pricing data gets no key at all - // (cost renders "—", never zero). - if (!m.cost) continue; - // First writer wins (an id can appear under two apis within one - // file with identical pricing; cross-file collisions cannot - // happen — keys are provider-qualified). - priced.set(`${provider}:${plainId}`, m.cost); - } - } - } - return priced; -} - -function main(): void { - const priced = new Map( - [...buildPrices()].map(([k, cost]) => [k, { cost }] as const), - ); - - // Sort by key for a stable, diff-friendly generated table. - const rows = [...priced.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, m]) => ({ - price: `\t${JSON.stringify(key)}: { input: ${m.cost.input}, output: ${m.cost.output}, cacheRead: ${m.cost.cacheRead}, cacheWrite: ${m.cost.cacheWrite} },`, - })); - - const tables = `/** Token prices, USD per 1M tokens. A missing model makes cost render - * as absent ("—"), never zero. Keys are uniformly provider-qualified - * ("provider:id") — NO legacy bare-slug compat (owner decision, sync - * script 2026-09-20): pre-wave records stamped with bare slugs render - * unpriced. - * Context-window limits are NOT a table (owner amendment, 2026-09-23): - * every meter's denominator reads pi's LIVE catalog (GET /api/models / - * the activity feed's server-side join) — missing model, usage-only. - * Refresh: bump @earendil-works/pi-ai, then npx tsx scripts/sync-pricing.ts - * (reads pi-ai's shipped catalogs — the same source the engine and the - * picker use). Forgetting the re-run is exactly how models go unpriced - * (deepseek-v4.1-flash, 2026-09-29) — the drift-guard test - * (tests/prices-sync.test.ts) fails the gates until you do. */ -export const PRICES: Record = { -${rows.map((r) => r.price).join("\n")} -}; -`; - - const src = fs.readFileSync(OUT, "utf-8"); - const start = src.indexOf("/** Token prices"); - const end = src.indexOf("// ─── Pure helpers"); - 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 from pi-ai's catalogs: ${priced.size} keys priced.`); -} - -// Run only as a script, not when imported (the drift-guard test). -if (process.argv[1] && import.meta.filename && path.resolve(process.argv[1]) === import.meta.filename) { - main(); -} diff --git a/src/agent/chatgpt-bridge.ts b/src/agent/chatgpt-bridge.ts index 7bf1b8c..5412549 100644 --- a/src/agent/chatgpt-bridge.ts +++ b/src/agent/chatgpt-bridge.ts @@ -14,6 +14,12 @@ interface BridgeState { const key = Symbol.for("lh.chatgpt.bridge"); const unavailable = (): ChatGPTStatus => ({ available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null }); const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision].every((v) => Number.isSafeInteger(v) && v >= 0); +/** Next dev also uses parent/child IPC. IPC alone is not our desktop + * transport: require the same launch markers set by Electron startServer. + * These identify the transport, not a substitute for admission/lease checks. */ +function desktopIPCConnected(): boolean { + return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true; +} function bridge(): BridgeState { const global = globalThis as typeof globalThis & { [key]?: BridgeState }; if (!global[key]) { @@ -21,7 +27,7 @@ function bridge(): BridgeState { global[key] = state; process.on("message", (raw: unknown) => { const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string }; - if (m?.channel !== "lh:chatgpt") return; + if (!desktopIPCConnected() || m?.channel !== "lh:chatgpt") return; if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) { acceptStatus(state, { ...m.stamp, status: m.status }); if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); } @@ -57,7 +63,7 @@ function acceptStatus(state: BridgeState, value: StatusValue) { /** Runtime-only inherited IPC. No HTTP credential route or renderer method. */ export function chatGPTRequest(op: "status" | "models" | "lease", fields: Record = {}): Promise { const state = bridge(); - if (!process.send || !process.connected) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection.")); + if (!desktopIPCConnected()) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection.")); return new Promise((resolve, reject) => { const id = `${process.pid}:${++state.counter}`; const timer = setTimeout(() => { state.pending.delete(id); reject(new Error("The ChatGPT desktop connection timed out.")); }, 45000); @@ -68,7 +74,7 @@ export function chatGPTRequest(op: "status" | "models" | "lease", fields: Rec }); } export function chatGPTStatus(): ChatGPTStatus { return bridge().status; } -export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); } +export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return desktopIPCConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); } export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; } export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; } export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; } @@ -80,7 +86,7 @@ export function chatGPTScope(selectedModelId: string): AdmissionScope { } export function chatGPTScopeCurrent(scope: AdmissionScope): boolean { const s = bridge(); - return !!process.connected && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId); + return desktopIPCConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId); } export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise { if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed"); @@ -102,7 +108,7 @@ export async function chatGPTDenial(scope: AdmissionScope | undefined, controlle /** Preserve account order; unknown capabilities/windows are never fabricated. */ export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise { const state = bridge(); - if (!process.send || !process.connected) { clearCatalog(state); return; } + if (!desktopIPCConnected()) { clearCatalog(state); return; } const sequence = ++state.catalogRequest; try { const value = await chatGPTRequest("models"); diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index e011869..aca547f 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -777,6 +777,7 @@ export function callUsage(usage: Usage): CallUsage { outputTokens: usage.output, cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite, + cacheWrite1hTokens: usage.cacheWrite1h ?? 0, // Optional in pi's Usage (providers that never emit reasoning // tokens leave it unset) — persisted as 0, never dropped. reasoningTokens: usage.reasoning ?? 0, diff --git a/src/core/compaction.ts b/src/core/compaction.ts index 85bc682..8e3f768 100644 --- a/src/core/compaction.ts +++ b/src/core/compaction.ts @@ -1,7 +1,8 @@ /** * COMPACTION (owner round 14, 2026-10-05): the context-fitting mechanic, - * mirroring pi's design (docs aside, this is OUR implementation — pi's - * compaction lives in the coding-agent package we don't depend on). + * with a bounded local port of Pi coding-agent 1.4.0 cutoff selection. + * No coding-agent dependency. See docs/compaction-accounting.md for the + * behavior boundary, native golden fixtures, and upstream MIT notice. * * THE CONTRACT: full history stays persisted in the record — extraction * and the user's transcript keep reading everything. A compaction marker @@ -38,7 +39,7 @@ import { COMPACTED_HISTORY_HEADER } from "./contracts/chat.ts"; import type { LhMessage, LhMarkerMessage } from "./native-messages.ts"; import { newMessageId } from "./native-messages.ts"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage, ToolResultMessage, SystemMessage } from "@earendil-works/pi-ai"; // ─── constants (the tunables, owner-approved) ──────────────────────────────── @@ -77,9 +78,8 @@ export function calculateContextTokens(usage: { ); } -/** Estimate a message's context tokens from its WIRE-relevant text only - * (pi's estimator): text + thinking + toolCall name/args for assistants, - * content text for user/toolResult/custom. NEVER JSON.stringify of the +/** Pi 1.4.0's heuristic: text + thinking + toolCall name/args for + * assistants, content text plus a fixed image allowance for other roles. NEVER JSON.stringify of the * whole message — record scaffolding (ids, usage, responseId, app-side * details, escaped quotes/backslashes) inflates ~3x (measured on the * 2026-10-05 relational-databases incident: 84k estimated vs ~30k real). */ @@ -100,23 +100,32 @@ export function estimateMessageTokens(message: LhMessage | AgentMessage): number } } else { chars += textCharsOfContent(content); + if (message.role === "system") { + const system = message as SystemMessage; + for (const section of Object.values(system.sections ?? {})) chars += section?.length ?? 0; + if (system.toolsAdded) chars += JSON.stringify(system.toolsAdded).length; + } } return Math.ceil(chars / 4); } +// Pi coding-agent 1.4.0 estimateTokens: fixed image allowance, NOT a +// measurement of encrypted reasoning or actual provider vision tokens. +const ESTIMATED_IMAGE_CHARS = 4_800; function textCharsOfContent(content: unknown): number { if (typeof content === "string") return content.length; if (!Array.isArray(content)) return 0; let chars = 0; for (const block of content as Array) { if (typeof block === "string") chars += block.length; - else if (block?.type === "text" || "text" in block) chars += block.text?.length ?? 0; + else if (block?.type === "image") chars += ESTIMATED_IMAGE_CHARS; + else if (block?.type === "text") chars += block.text?.length ?? 0; } return chars; } /** The newest assistant message carrying VALID provider-reported usage - * (pi's rule: skip aborted messages and all-zero usage — they carry no + * (pi's rule: skip aborted/error messages and all-zero usage — they carry no * truth about the wire). The scan STOPS at the newest compaction * marker: usage captured before a compaction described the pre-cut * context — the next request carries the summary + kept tail instead, @@ -131,7 +140,7 @@ export function lastAssistantUsageIndex( if ((m as { role?: string }).role === "custom" && (m as LhMarkerMessage).customType === "compaction") return null; if ((m as { role?: string }).role !== "assistant") continue; const a = m as AssistantMessage & { stopReason?: string }; - if (a.stopReason === "aborted") continue; + if (a.stopReason === "aborted" || a.stopReason === "error") continue; if (calculateContextTokens((a.usage ?? {}) as Parameters[0]) === 0) continue; return i; } @@ -180,51 +189,53 @@ export function estimateTokens(messages: readonly (LhMessage | AgentMessage)[]): // ─── the cut walk ──────────────────────────────────────────────────────────── -/** - * Find the compaction cut index: walk BACKWARDS from the end accumulating - * estimates until ~keepRecentTokens are kept; the cut lands there. - * - * HARD INVARIANT — a tool result is never separated from its call: if the - * walk stops on a toolResult, the KEPT region extends to the assistant - * message carrying its call (keeping the result means keeping the call). - * This is the provider protocol's shape law: dropping a call while - * keeping its result strands an orphan the provider rejects. A PENDING - * question/problem call (no result yet — the pose→answer window) sits at - * the transcript end and is always kept: the walk never crosses it. - * - * Returns 0 when everything must be summarized (the whole history is the - * span — valid), or messages.length when there is nothing meaningful to - * cut (kept budget covers everything — caller checks before compacting). - */ -export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number { - let kept = 0; - for (let i = messages.length - 1; i >= 0; i--) { - if (kept >= keepRecentTokens) { - // The cut candidate: extend past orphaned tool results to the - // assistant message carrying their calls. - let cut = i + 1; - while (cut < messages.length && (messages[cut] as { role?: string }).role === "toolResult") { - const callIndex = assistantIndexOfCall(messages, (messages[cut] as ToolResultMessage).toolCallId, cut); - if (callIndex === -1) break; // no call found (defensive): stop extending - cut = callIndex; - } - return cut; - } - kept += estimateMessageTokens(messages[i]!); - } - return 0; // the whole history fits inside the kept budget — summarize all +/** Model-visible entry adapter for the local Pi 1.4.0 cutoff port. + * Compaction entries are boundaries, not ordinary cut candidates; UI-only + * markers have no context, just like native metadata entries. */ +function cutMessage(message: LhMessage): LhMessage | null { + if (message.role !== "custom") return message; + if (["compaction", "plan-block", "problem-dismissed"].includes(message.customType)) return null; + return message; } -/** The newest assistant message at or before `before` whose content - * carries the tool call `toolCallId` — the call a result answers. */ -function assistantIndexOfCall(messages: readonly LhMessage[], toolCallId: string, before: number): number { - for (let i = before - 1; i >= 0; i--) { - const m = messages[i]!; - if ((m as { role?: string }).role !== "assistant") continue; - const assistant = m as AssistantMessage; - if (assistant.content.some((b) => b.type === "toolCall" && b.id === toolCallId)) return i; +/** Local port of pi-coding-agent 1.4.0 findCutPoint (MIT). + * Walk backwards to >= budget, then choose the nearest valid cut AT OR + * AFTER that entry. If oversized trailing results exhaust the budget, + * retain their preceding assistant call, not the entire session. + * No encrypted-signature sizing and no exact retained-token guarantee. + * Returns the first kept record index; a zero/no-progress cut drops nothing. + * Keep this and estimateMessageTokens aligned deliberately when upgrading Pi. + */ +export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number { + const previous = activeCompactionOf(messages); + const previousKept = previous ? keptIndexFor(messages, previous) : -1; + const start = Math.max(0, previousKept); + const points: number[] = []; + for (let i = start; i < messages.length; i++) { + const message = cutMessage(messages[i]!); + if (message && (message.role === "user" || message.role === "assistant" || message.role === "custom")) points.push(i); } - return -1; + if (!points.length) return start; + let cut = points[0]!; + let accumulated = 0; + for (let i = messages.length - 1; i >= start; i--) { + const message = cutMessage(messages[i]!); + const tokens = message ? estimateMessageTokens(message) : 0; + if (tokens === 0) continue; + accumulated += tokens; + if (accumulated >= keepRecentTokens) { + cut = points.find((candidate) => candidate >= i) ?? points.at(-1)!; + break; + } + } + // Native entries preserve adjacent non-context metadata, but never walk + // backwards across a previous compaction entry. + while (cut > start) { + const before = messages[cut - 1]!; + if ((before.role === "custom" && before.customType === "compaction") || cutMessage(before)) break; + cut--; + } + return cut; } // ─── the marker ────────────────────────────────────────────────────────────── diff --git a/src/core/context-usage.ts b/src/core/context-usage.ts new file mode 100644 index 0000000..128d339 --- /dev/null +++ b/src/core/context-usage.ts @@ -0,0 +1,27 @@ +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { LhMessage } from "./native-messages.ts"; + +/** Latest completed conversation measurement, never a request-size estimate. + * Ordered compaction markers invalidate every earlier reading. Live callers + * must exclude assistant slots that have not received message_end. + * Reasoning is already inside output; totalTokens is deliberately not used. + */ +export function measuredContextTokens( + messages: readonly LhMessage[], + incompleteIds: ReadonlySet = new Set(), +): number | null { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]!; + if (message.role === "custom" && message.customType === "compaction") return null; + if (message.role !== "assistant" || incompleteIds.has(message.id)) continue; + const assistant = message as AssistantMessage; + if (!["stop", "length", "toolUse"].includes(assistant.stopReason)) continue; + const usage = assistant.usage; + if (!usage) continue; + const buckets = [usage.input, usage.output, usage.cacheRead, usage.cacheWrite]; + if (buckets.some((value) => !Number.isFinite(value) || value < 0)) continue; + const tokens = buckets.reduce((sum, value) => sum + value, 0); + if (tokens > 0) return tokens; + } + return null; +} diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts index 2fb70e2..cf67a6b 100644 --- a/src/core/contracts/chat.ts +++ b/src/core/contracts/chat.ts @@ -87,8 +87,8 @@ export interface ChatSettings { * billed (CallUsage per agent step). Steps, not a sum: cost and context * folds happen at read time (core/prices.ts). */ export interface ExtractionStageUsage { - /** The model that ran this stage, provider-qualified ("provider:id" — - * model slots wave, 2026-09-20; matches PRICES' uniform keys). */ + /** The model that ran this stage, provider-qualified ("provider:id") + * for key-independent Pi catalog lookup at read time. */ model: string; steps: CallUsage[]; } @@ -118,7 +118,7 @@ export interface ExtractionRunUsage { * flattened to the fields we persist (undefined = provider didn't report; * renderers show absent, never zero). */ export interface CallUsage { - /** Total input (prompt) tokens, cached portion included. */ + /** Pi-normalized fresh input tokens; cache buckets are disjoint. */ inputTokens: number | undefined; outputTokens: number | undefined; /** Cached input tokens READ (billed at the cache-read rate, if any). */ @@ -126,6 +126,9 @@ export interface CallUsage { /** Input tokens written to the cache this call (OpenRouter bills writes * for some providers; undefined = not reported/not billed). */ cacheWriteTokens: number | undefined; + /** Subset of cacheWriteTokens written with one-hour retention. Missing + * on older logs; new native captures explicitly record zero if absent. */ + cacheWrite1hTokens?: number; /** The reasoning portion of the output tokens. */ reasoningTokens: number | undefined; } @@ -144,8 +147,8 @@ export interface TurnUsage { * entry to the transcript (the block this turn "belongs" to). */ messageId: string; at: string; - /** The model that ran the turn, provider-qualified ("provider:id" — - * model slots wave, 2026-09-20; matches PRICES' uniform keys). */ + /** The model that ran the turn, provider-qualified ("provider:id") + * for key-independent Pi catalog lookup at read time. */ model: string; /** One entry per agent step (model call) in this turn. */ steps: CallUsage[]; diff --git a/src/core/contracts/jobs.ts b/src/core/contracts/jobs.ts index dd55d0c..1b14519 100644 --- a/src/core/contracts/jobs.ts +++ b/src/core/contracts/jobs.ts @@ -43,7 +43,7 @@ export interface JobRecord { * money-channel doctrine; cost renders "—", never $0). */ export interface JobUsage { /** The model that ran the sub-agent, provider-qualified ("provider:id" — - * model slots wave, 2026-09-20; matches PRICES' uniform keys). */ + * model slots wave, 2026-09-20; matches Pi catalog lookup keys). */ model: string; /** Running totals summed over the run's model calls so far. */ inputTokens: number | undefined; @@ -51,6 +51,10 @@ export interface JobUsage { cacheReadTokens: number | undefined; cacheWriteTokens: number | undefined; reasoningTokens: number | undefined; + cacheWrite1hTokens?: number; + /** Individual calls for current-catalog per-call tier pricing. Absent on + * older jobs; never backfill them from cumulative totals. */ + calls?: CallUsage[]; /** How many model calls the run has made (each bills separately). */ steps: number; /** The MOST RECENT model call's accounting (2026-09 dock contract): the @@ -67,7 +71,7 @@ export interface JobUsage { * object with these fields folds, including the real thing and test fakes). */ export interface UsageStepLike { inputTokens?: number | undefined; - inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } | undefined; + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number; cacheWrite1hTokens?: number } | undefined; outputTokens?: number | undefined; outputTokenDetails?: { reasoningTokens?: number } | undefined; } @@ -80,12 +84,13 @@ export function piUsageToStep(usage: { output: number; cacheRead: number; cacheWrite: number; + cacheWrite1h?: number; reasoning?: number; }): UsageStepLike { return { inputTokens: usage.input, outputTokens: usage.output, - inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite }, + inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite, cacheWrite1hTokens: usage.cacheWrite1h ?? 0 }, outputTokenDetails: usage.reasoning !== undefined ? { reasoningTokens: usage.reasoning } : undefined, }; } @@ -101,6 +106,14 @@ export function accumulateJobUsage( ): JobUsage { const add = (a: number | undefined, b: number | undefined): number | undefined => a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0); + const call: CallUsage = { + inputTokens: step.inputTokens, + outputTokens: step.outputTokens, + cacheReadTokens: step.inputTokenDetails?.cacheReadTokens, + cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens, + cacheWrite1hTokens: step.inputTokenDetails?.cacheWrite1hTokens, + reasoningTokens: step.outputTokenDetails?.reasoningTokens, + }; return { model, inputTokens: add(total?.inputTokens, step.inputTokens), @@ -108,14 +121,12 @@ export function accumulateJobUsage( cacheReadTokens: add(total?.cacheReadTokens, step.inputTokenDetails?.cacheReadTokens), cacheWriteTokens: add(total?.cacheWriteTokens, step.inputTokenDetails?.cacheWriteTokens), reasoningTokens: add(total?.reasoningTokens, step.outputTokenDetails?.reasoningTokens), + cacheWrite1hTokens: ((total?.cacheWriteTokens ?? 0) > 0 && total?.cacheWrite1hTokens === undefined) || + ((call.cacheWriteTokens ?? 0) > 0 && call.cacheWrite1hTokens === undefined) + ? undefined : add(total?.cacheWrite1hTokens, call.cacheWrite1hTokens), steps: (total?.steps ?? 0) + 1, - lastStep: { - inputTokens: step.inputTokens, - outputTokens: step.outputTokens, - cacheReadTokens: step.inputTokenDetails?.cacheReadTokens, - cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens, - reasoningTokens: step.outputTokenDetails?.reasoningTokens, - }, + ...(!total || total.calls ? { calls: [...(total?.calls ?? []), call] } : {}), + lastStep: call, }; } diff --git a/src/core/contracts/models.ts b/src/core/contracts/models.ts index b2af34b..2819bb5 100644 --- a/src/core/contracts/models.ts +++ b/src/core/contracts/models.ts @@ -10,7 +10,7 @@ import type { BackgroundModelRole, ModelSlotName } from "../settings.ts"; /** 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). */ + * units as Pi's server-side pricing calculator). */ export interface ModelCatalogEntry { id: string; name: string; diff --git a/src/core/cost-summary.ts b/src/core/cost-summary.ts index 5932179..27045b1 100644 --- a/src/core/cost-summary.ts +++ b/src/core/cost-summary.ts @@ -9,7 +9,7 @@ * the same ruling as wave 7's stages.embedding.) * * MONEY CHANNEL LAW: dollars are INFERRED here at read time via - * core/prices.ts — never stored anywhere. An unpriced model contributes + * an injected server-side Pi pricer — never stored anywhere. An unpriced model contributes * ZERO dollars but is counted in `unpricedRuns` so the UI can render * "— unpriced" honestly, never a silent zero. Tokens always sum. * @@ -18,7 +18,7 @@ */ import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts"; -import { callCost, sumSteps, isChatGPTPlan } from "./prices.ts"; +import { sumSteps, isChatGPTPlan, type CallPricer } from "./prices.ts"; /** One pipeline stage's cumulative cost across every run. */ export interface StageCost { @@ -26,7 +26,7 @@ export interface StageCost { subscriptionRuns?: number; /** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */ dollars: number; - /** Stage-runs whose model is missing from PRICES (render "—", never zero). */ + /** Stage-runs whose model is missing from Pi's catalog (render "—", never zero). */ unpricedRuns: number; /** Runs that billed this stage. */ runs: number; @@ -92,11 +92,11 @@ const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tok /** One stage's dollars over its steps; null when the model is unpriced * (all-or-nothing: a stage runs one model). */ -function stageDollars(model: string, steps: readonly CallUsage[]): number | null { - if (isChatGPTPlan(model)) return null; +function stageDollars(model: string, steps: readonly CallUsage[], price: CallPricer): number | null { + if (isChatGPTPlan(model) || steps.length === 0) return null; let total = 0; for (const step of steps) { - const c = callCost(step, model); + const c = price(step, model); if (c === null) return null; total += c; } @@ -106,6 +106,9 @@ function stageDollars(model: string, steps: readonly CallUsage[]): number | null /** Merge one step-set's tokens into an accumulating CallUsage. */ function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void { const summed = sumSteps(steps); + // Fold this before updating total writes, so an older write-bearing + // aggregate with a missing split remains unknown even after fresh data. + into.cacheWrite1hTokens = sumSteps([into, summed]).cacheWrite1hTokens; into.inputTokens = (into.inputTokens ?? 0) + (summed.inputTokens ?? 0); into.outputTokens = (into.outputTokens ?? 0) + (summed.outputTokens ?? 0); into.cacheReadTokens = (into.cacheReadTokens ?? 0) + (summed.cacheReadTokens ?? 0); @@ -115,7 +118,7 @@ function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void { /** Fold every usage channel into the pill's payload. Pure: same input, * same output — no clock, no fs, no store reads. */ -export function graphCostSummary(input: CostSummaryInput): GraphCostSummary { +export function graphCostSummary(input: CostSummaryInput, price: CallPricer): GraphCostSummary { const extraction = emptyStage(); const audit = emptyStage(); const runs: RecentRun[] = []; @@ -125,14 +128,14 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary { // Per-stage: tokens always count; dollars only when priced. extraction.runs++; mergeTokens(extraction.tokens, stages.extractor.steps); - const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps); + const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps, price); if (isChatGPTPlan(stages.extractor.model)) extraction.subscriptionRuns = (extraction.subscriptionRuns ?? 0) + 1; else if (extDollars === null) extraction.unpricedRuns++; else extraction.dollars += extDollars; audit.runs++; mergeTokens(audit.tokens, stages.auditor.steps); - const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps); + const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps, price); if (isChatGPTPlan(stages.auditor.model)) audit.subscriptionRuns = (audit.subscriptionRuns ?? 0) + 1; else if (audDollars === null) audit.unpricedRuns++; else audit.dollars += audDollars; diff --git a/src/core/index.ts b/src/core/index.ts index c08e795..08b2140 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -48,6 +48,7 @@ export * from "./input-events.ts"; export * from "./view.ts"; export * from "./tool-parts.ts"; export * from "./prices.ts"; +export * from "./context-usage.ts"; export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts"; export * from "./cost-summary.ts"; export * from "./fuzzy.ts"; diff --git a/src/core/prices.ts b/src/core/prices.ts index 244c1f7..a4a06b3 100644 --- a/src/core/prices.ts +++ b/src/core/prices.ts @@ -1,1651 +1,22 @@ -/** - * Price + context-limit registry — the money channel's lookup table (pure core). - * - * Every model the harness runs gets an entry: per-1M-token USD prices and - * the model's context window. Usage is CAPTURED in the records (chat turns, - * jobs); cost is INFERRED here at read time — nothing stores dollars, so a - * price correction re-prices the whole history for free. - * - * The table is GENERATED, not hand-maintained: scripts/sync-pricing.ts pulls - * OpenRouter's published per-token pricing and rewrites this file. Stale - * prices are possible between syncs (they change rarely); a MISSING model - * makes cost render as absent ("—"), never zero — the honest presentation. - * - * Client-safe: pure types + numbers, no node imports; exposed via the core - * barrel (@/src/core) so shell components fold usage without cost math - * living in components. - */ - +/** Client-safe accounting folds. Rates and calculation live on the server, + * using Pi's catalog and calculateCost; no copied pricing table lives here. */ import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "./contracts.ts"; -/** Per-1M-token USD prices for one model. Fields absent where the provider - * doesn't bill them (cacheWrite is rare). */ -export interface ModelPrice { - input: number; - output: number; - cacheRead?: number; - cacheWrite?: number; +export type CallPricer = (usage: Partial, model: string) => number | null; +export interface SessionPricing { + cost: number; + unpriced: number; + subscription?: number; + /** Derived at read time, keyed by the persisted turn's id. Never stored. */ + turnCosts: Record; } -/** Token prices, USD per 1M tokens. A missing model makes cost render - * as absent ("—"), never zero. Keys are uniformly provider-qualified - * ("provider:id") — NO legacy bare-slug compat (owner decision, sync - * script 2026-09-20): pre-wave records stamped with bare slugs render - * unpriced. - * Context-window limits are NOT a table (owner amendment, 2026-09-23): - * every meter's denominator reads pi's LIVE catalog (GET /api/models / - * the activity feed's server-side join) — missing model, usage-only. - * Refresh: bump @earendil-works/pi-ai, then npx tsx scripts/sync-pricing.ts - * (reads pi-ai's shipped catalogs — the same source the engine and the - * picker use). Forgetting the re-run is exactly how models go unpriced - * (deepseek-v4.1-flash, 2026-09-29) — the drift-guard test - * (tests/prices-sync.test.ts) fails the gates until you do. */ -export const PRICES: Record = { - "amazon-bedrock:amazon.nova-2-lite-v1:0": { input: 0.33, output: 2.75, cacheRead: 0.0825, cacheWrite: 0.33 }, - "amazon-bedrock:amazon.nova-lite-v1:0": { input: 0.06, output: 0.24, cacheRead: 0.015, cacheWrite: 0.06 }, - "amazon-bedrock:amazon.nova-micro-v1:0": { input: 0.035, output: 0.14, cacheRead: 0.00875, cacheWrite: 0.035 }, - "amazon-bedrock:amazon.nova-pro-v1:0": { input: 0.8, output: 3.2, cacheRead: 0.2, cacheWrite: 0.8 }, - "amazon-bedrock:anthropic.claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "amazon-bedrock:anthropic.claude-fable-5-1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }, - "amazon-bedrock:anthropic.claude-haiku-4-5-20251001-v1:0": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "amazon-bedrock:anthropic.claude-haiku-5-5": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "amazon-bedrock:anthropic.claude-opus-4-1-20250805-v1:0": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, - "amazon-bedrock:anthropic.claude-opus-4-5-20251101-v1:0": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "amazon-bedrock:anthropic.claude-opus-4-6-v1": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:anthropic.claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "amazon-bedrock:anthropic.claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "amazon-bedrock:anthropic.claude-opus-5-5": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }, - "amazon-bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "amazon-bedrock:anthropic.claude-sonnet-4-6": { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 }, - "amazon-bedrock:anthropic.claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "amazon-bedrock:anthropic.claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "amazon-bedrock:apac.amazon.nova-lite-v1:0": { input: 0.063, output: 0.252, cacheRead: 0.01575, cacheWrite: 0.063 }, - "amazon-bedrock:apac.amazon.nova-micro-v1:0": { input: 0.037, output: 0.148, cacheRead: 0.00925, cacheWrite: 0.037 }, - "amazon-bedrock:apac.amazon.nova-pro-v1:0": { input: 0.84, output: 3.36, cacheRead: 0.21, cacheWrite: 0.84 }, - "amazon-bedrock:apac.anthropic.claude-sonnet-4-20250514-v1:0": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "amazon-bedrock:au.anthropic.claude-haiku-4-5-20251001-v1:0": { input: 1.1, output: 5.5, cacheRead: 0.11, cacheWrite: 1.375 }, - "amazon-bedrock:au.anthropic.claude-haiku-5-5": { input: 0.11, output: 0.55, cacheRead: 0.011, cacheWrite: 0.1375 }, - "amazon-bedrock:au.anthropic.claude-opus-4-6-v1": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:au.anthropic.claude-opus-4-7": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:au.anthropic.claude-opus-4-8": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:au.anthropic.claude-opus-5": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:au.anthropic.claude-opus-5-5": { input: 4.4, output: 22, cacheRead: 0.22, cacheWrite: 5.5 }, - "amazon-bedrock:au.anthropic.claude-sonnet-4-5-20250929-v1:0": { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 }, - "amazon-bedrock:au.anthropic.claude-sonnet-4-6": { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 }, - "amazon-bedrock:au.anthropic.claude-sonnet-5": { input: 2.2, output: 11, cacheRead: 0.22, cacheWrite: 2.75 }, - "amazon-bedrock:ca.amazon.nova-lite-v1:0": { input: 0.064, output: 0.256, cacheRead: 0.016, cacheWrite: 0.064 }, - "amazon-bedrock:deepseek.v3-v1:0": { input: 0.58, output: 1.68, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:deepseek.v3.2": { input: 0.62, output: 1.85, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:eu.amazon.nova-2-lite-v1:0": { input: 0.374, output: 3.157, cacheRead: 0.0935, cacheWrite: 0.374 }, - "amazon-bedrock:eu.amazon.nova-lite-v1:0": { input: 0.069, output: 0.276, cacheRead: 0.01725, cacheWrite: 0.069 }, - "amazon-bedrock:eu.amazon.nova-micro-v1:0": { input: 0.04, output: 0.16, cacheRead: 0.01, cacheWrite: 0.04 }, - "amazon-bedrock:eu.amazon.nova-pro-v1:0": { input: 0.92, output: 3.68, cacheRead: 0.23, cacheWrite: 0.92 }, - "amazon-bedrock:eu.anthropic.claude-fable-5": { input: 11, output: 55, cacheRead: 1.1, cacheWrite: 13.75 }, - "amazon-bedrock:eu.anthropic.claude-haiku-4-5-20251001-v1:0": { input: 1.1, output: 5.5, cacheRead: 0.11, cacheWrite: 1.375 }, - "amazon-bedrock:eu.anthropic.claude-haiku-5-5": { input: 0.11, output: 0.55, cacheRead: 0.011, cacheWrite: 0.1375 }, - "amazon-bedrock:eu.anthropic.claude-opus-4-5-20251101-v1:0": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:eu.anthropic.claude-opus-4-6-v1": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:eu.anthropic.claude-opus-4-7": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:eu.anthropic.claude-opus-4-8": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:eu.anthropic.claude-opus-5": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:eu.anthropic.claude-opus-5-5": { input: 4.4, output: 22, cacheRead: 0.22, cacheWrite: 5.5 }, - "amazon-bedrock:eu.anthropic.claude-sonnet-4-20250514-v1:0": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "amazon-bedrock:eu.anthropic.claude-sonnet-4-5-20250929-v1:0": { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 }, - "amazon-bedrock:eu.anthropic.claude-sonnet-4-6": { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 }, - "amazon-bedrock:eu.anthropic.claude-sonnet-5": { input: 2.2, output: 11, cacheRead: 0.22, cacheWrite: 2.75 }, - "amazon-bedrock:eu.anthropic.claude-sonnet-5-5": { input: 2.2, output: 11, cacheRead: 0.11, cacheWrite: 2.75 }, - "amazon-bedrock:eu.mistral.pixtral-large-2502-v1:0": { input: 2, output: 6, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:global.amazon.nova-2-lite-v1:0": { input: 0.3, output: 2.5, cacheRead: 0.075, cacheWrite: 0.3 }, - "amazon-bedrock:global.anthropic.claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "amazon-bedrock:global.anthropic.claude-fable-5-1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }, - "amazon-bedrock:global.anthropic.claude-haiku-4-5-20251001-v1:0": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "amazon-bedrock:global.anthropic.claude-haiku-5-5": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "amazon-bedrock:global.anthropic.claude-opus-4-5-20251101-v1:0": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "amazon-bedrock:global.anthropic.claude-opus-4-6-v1": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "amazon-bedrock:global.anthropic.claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "amazon-bedrock:global.anthropic.claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "amazon-bedrock:global.anthropic.claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "amazon-bedrock:global.anthropic.claude-opus-5-5": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }, - "amazon-bedrock:global.anthropic.claude-sonnet-4-20250514-v1:0": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "amazon-bedrock:global.anthropic.claude-sonnet-4-5-20250929-v1:0": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "amazon-bedrock:global.anthropic.claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "amazon-bedrock:global.anthropic.claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "amazon-bedrock:global.anthropic.claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "amazon-bedrock:global.moonshotai.kimi-k3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "amazon-bedrock:global.openai.gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "amazon-bedrock:global.openai.gpt-5.6-sol": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "amazon-bedrock:global.openai.gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, - "amazon-bedrock:global.openai.gpt-6-astra": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "amazon-bedrock:global.openai.gpt-6-luna": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "amazon-bedrock:global.openai.gpt-6-sol": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "amazon-bedrock:global.openai.gpt-6.1-sol": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "amazon-bedrock:global.xai.grok-4.6": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "amazon-bedrock:global.xai.grok-4.7": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "amazon-bedrock:global.zai.glm-5.3": { input: 1.68, output: 5.28, cacheRead: 0.312, cacheWrite: 2.1 }, - "amazon-bedrock:google.gemma-4-26b-a4b": { input: 0.13, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:google.gemma-4-31b": { input: 0.14, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:google.gemma-4-e2b": { input: 0.04, output: 0.08, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:in.anthropic.claude-haiku-4-5-20251001-v1:0": { input: 1.1, output: 5.5, cacheRead: 0.11, cacheWrite: 1.375 }, - "amazon-bedrock:in.anthropic.claude-opus-5": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:in.anthropic.claude-sonnet-5": { input: 2.2, output: 11, cacheRead: 0.22, cacheWrite: 2.75 }, - "amazon-bedrock:in.openai.gpt-5.6-luna": { input: 0.22, output: 1.32, cacheRead: 0.022, cacheWrite: 0.275 }, - "amazon-bedrock:in.openai.gpt-5.6-terra": { input: 2.2, output: 13.2, cacheRead: 0.22, cacheWrite: 2.75 }, - "amazon-bedrock:jp.amazon.nova-2-lite-v1:0": { input: 0.396, output: 3.311, cacheRead: 0.099, cacheWrite: 0.396 }, - "amazon-bedrock:jp.anthropic.claude-haiku-4-5-20251001-v1:0": { input: 1.1, output: 5.5, cacheRead: 0.11, cacheWrite: 1.375 }, - "amazon-bedrock:jp.anthropic.claude-haiku-5-5": { input: 0.11, output: 0.55, cacheRead: 0.011, cacheWrite: 0.1375 }, - "amazon-bedrock:jp.anthropic.claude-opus-4-7": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:jp.anthropic.claude-opus-4-8": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:jp.anthropic.claude-opus-5": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:jp.anthropic.claude-opus-5-5": { input: 4.4, output: 22, cacheRead: 0.22, cacheWrite: 5.5 }, - "amazon-bedrock:jp.anthropic.claude-sonnet-4-5-20250929-v1:0": { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 }, - "amazon-bedrock:jp.anthropic.claude-sonnet-4-6": { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 }, - "amazon-bedrock:jp.anthropic.claude-sonnet-5": { input: 2.2, output: 11, cacheRead: 0.22, cacheWrite: 2.75 }, - "amazon-bedrock:meta.llama3-1-70b-instruct-v1:0": { input: 0.72, output: 0.72, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:meta.llama3-1-8b-instruct-v1:0": { input: 0.22, output: 0.22, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:meta.llama3-3-70b-instruct-v1:0": { input: 0.72, output: 0.72, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:meta.llama4-maverick-17b-instruct-v1:0": { input: 0.24, output: 0.97, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:meta.llama4-scout-17b-instruct-v1:0": { input: 0.17, output: 0.66, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:minimax.minimax-m2": { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:minimax.minimax-m2.1": { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:minimax.minimax-m2.5": { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:mistral.devstral-2-123b": { input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:mistral.magistral-small-2509": { input: 0.5, output: 1.5, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:mistral.ministral-3-14b-instruct": { input: 0.2, output: 0.2, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:mistral.ministral-3-3b-instruct": { input: 0.1, output: 0.1, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:mistral.ministral-3-8b-instruct": { input: 0.15, output: 0.15, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:mistral.mistral-large-3-675b-instruct": { input: 0.5, output: 1.5, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:mistral.pixtral-large-2502-v1:0": { input: 2, output: 6, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:mistral.voxtral-mini-3b-2507": { input: 0.04, output: 0.04, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:mistral.voxtral-small-24b-2507": { input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:moonshot.kimi-k2-thinking": { input: 0.6, output: 2.5, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:moonshotai.kimi-k2.5": { input: 0.6, output: 3, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:nvidia.nemotron-nano-12b-v2": { input: 0.2, output: 0.6, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:nvidia.nemotron-nano-3-30b": { input: 0.06, output: 0.24, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:nvidia.nemotron-nano-9b-v2": { input: 0.06, output: 0.23, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:nvidia.nemotron-super-3-120b": { input: 0.15, output: 0.65, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:openai.gpt-5.4": { input: 2.75, output: 16.5, cacheRead: 0.275, cacheWrite: 0 }, - "amazon-bedrock:openai.gpt-5.5": { input: 5.5, output: 33, cacheRead: 0.55, cacheWrite: 0 }, - "amazon-bedrock:openai.gpt-5.6-luna": { input: 0.22, output: 1.32, cacheRead: 0.022, cacheWrite: 0.275 }, - "amazon-bedrock:openai.gpt-5.6-sol": { input: 4.4, output: 22, cacheRead: 0.44, cacheWrite: 5.5 }, - "amazon-bedrock:openai.gpt-5.6-terra": { input: 2.2, output: 13.2, cacheRead: 0.22, cacheWrite: 2.75 }, - "amazon-bedrock:openai.gpt-6-astra": { input: 11, output: 55, cacheRead: 1.1, cacheWrite: 13.75 }, - "amazon-bedrock:openai.gpt-6-luna": { input: 0.11, output: 0.55, cacheRead: 0.011, cacheWrite: 0.1375 }, - "amazon-bedrock:openai.gpt-6-sol": { input: 2.2, output: 11, cacheRead: 0.22, cacheWrite: 2.75 }, - "amazon-bedrock:openai.gpt-6.1-sol": { input: 2.2, output: 11, cacheRead: 0.11, cacheWrite: 2.75 }, - "amazon-bedrock:openai.gpt-oss-120b": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:openai.gpt-oss-120b-1:0": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:openai.gpt-oss-20b": { input: 0.07, output: 0.3, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:openai.gpt-oss-20b-1:0": { input: 0.07, output: 0.3, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:openai.gpt-oss-safeguard-120b": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:openai.gpt-oss-safeguard-20b": { input: 0.07, output: 0.2, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:qwen.qwen3-235b-a22b-2507-v1:0": { input: 0.22, output: 0.88, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:qwen.qwen3-32b-v1:0": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:qwen.qwen3-coder-30b-a3b-v1:0": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:qwen.qwen3-coder-480b-a35b-v1:0": { input: 0.45, output: 1.8, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:qwen.qwen3-coder-next": { input: 0.5, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:qwen.qwen3-next-80b-a3b": { input: 0.15, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:qwen.qwen3-vl-235b-a22b": { input: 0.53, output: 2.66, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:us-gov.openai.gpt-oss-120b-1:0": { input: 0.18, output: 0.72, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:us-gov.openai.gpt-oss-20b-1:0": { input: 0.084, output: 0.36, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:us.amazon.nova-2-lite-v1:0": { input: 0.33, output: 2.75, cacheRead: 0.0825, cacheWrite: 0.33 }, - "amazon-bedrock:us.amazon.nova-lite-v1:0": { input: 0.06, output: 0.24, cacheRead: 0.015, cacheWrite: 0.06 }, - "amazon-bedrock:us.amazon.nova-micro-v1:0": { input: 0.035, output: 0.14, cacheRead: 0.00875, cacheWrite: 0.035 }, - "amazon-bedrock:us.amazon.nova-premier-v1:0": { input: 2.5, output: 12.5, cacheRead: 0.625, cacheWrite: 2.5 }, - "amazon-bedrock:us.amazon.nova-pro-v1:0": { input: 0.8, output: 3.2, cacheRead: 0.2, cacheWrite: 0.8 }, - "amazon-bedrock:us.anthropic.claude-fable-5": { input: 11, output: 55, cacheRead: 1.1, cacheWrite: 13.75 }, - "amazon-bedrock:us.anthropic.claude-fable-5-1": { input: 11, output: 55, cacheRead: 0.275, cacheWrite: 13.75 }, - "amazon-bedrock:us.anthropic.claude-haiku-4-5-20251001-v1:0": { input: 1.1, output: 5.5, cacheRead: 0.11, cacheWrite: 1.375 }, - "amazon-bedrock:us.anthropic.claude-haiku-5-5": { input: 0.11, output: 0.55, cacheRead: 0.011, cacheWrite: 0.1375 }, - "amazon-bedrock:us.anthropic.claude-opus-4-1-20250805-v1:0": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, - "amazon-bedrock:us.anthropic.claude-opus-4-5-20251101-v1:0": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:us.anthropic.claude-opus-4-6-v1": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:us.anthropic.claude-opus-4-7": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:us.anthropic.claude-opus-4-8": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:us.anthropic.claude-opus-5": { input: 5.5, output: 27.5, cacheRead: 0.55, cacheWrite: 6.875 }, - "amazon-bedrock:us.anthropic.claude-opus-5-5": { input: 4.4, output: 22, cacheRead: 0.22, cacheWrite: 5.5 }, - "amazon-bedrock:us.anthropic.claude-sonnet-4-20250514-v1:0": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "amazon-bedrock:us.anthropic.claude-sonnet-4-5-20250929-v1:0": { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 }, - "amazon-bedrock:us.anthropic.claude-sonnet-4-6": { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 }, - "amazon-bedrock:us.anthropic.claude-sonnet-5": { input: 2.2, output: 11, cacheRead: 0.22, cacheWrite: 2.75 }, - "amazon-bedrock:us.anthropic.claude-sonnet-5-5": { input: 2.2, output: 11, cacheRead: 0.11, cacheWrite: 2.75 }, - "amazon-bedrock:us.meta.llama3-1-70b-instruct-v1:0": { input: 0.72, output: 0.72, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:us.meta.llama3-1-8b-instruct-v1:0": { input: 0.22, output: 0.22, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:us.meta.llama3-3-70b-instruct-v1:0": { input: 0.72, output: 0.72, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:us.meta.llama4-maverick-17b-instruct-v1:0": { input: 0.24, output: 0.97, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:us.meta.llama4-scout-17b-instruct-v1:0": { input: 0.17, output: 0.66, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:us.mistral.pixtral-large-2502-v1:0": { input: 2, output: 6, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:us.moonshotai.kimi-k3": { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 }, - "amazon-bedrock:us.openai.gpt-5.6-luna": { input: 0.22, output: 1.32, cacheRead: 0.022, cacheWrite: 0.275 }, - "amazon-bedrock:us.openai.gpt-5.6-sol": { input: 4.4, output: 22, cacheRead: 0.44, cacheWrite: 5.5 }, - "amazon-bedrock:us.openai.gpt-5.6-terra": { input: 2.2, output: 13.2, cacheRead: 0.22, cacheWrite: 2.75 }, - "amazon-bedrock:us.openai.gpt-6-astra": { input: 11, output: 55, cacheRead: 1.1, cacheWrite: 13.75 }, - "amazon-bedrock:us.openai.gpt-6-luna": { input: 0.11, output: 0.55, cacheRead: 0.011, cacheWrite: 0.1375 }, - "amazon-bedrock:us.openai.gpt-6-sol": { input: 2.2, output: 11, cacheRead: 0.22, cacheWrite: 2.75 }, - "amazon-bedrock:us.openai.gpt-6.1-sol": { input: 2.2, output: 11, cacheRead: 0.11, cacheWrite: 2.75 }, - "amazon-bedrock:us.writer.palmyra-x4-v1:0": { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:us.writer.palmyra-x5-v1:0": { input: 0.6, output: 6, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:us.xai.grok-4.6": { input: 2.2, output: 6.6, cacheRead: 0.55, cacheWrite: 0 }, - "amazon-bedrock:us.xai.grok-4.7": { input: 2.2, output: 6.6, cacheRead: 0.55, cacheWrite: 0 }, - "amazon-bedrock:us.zai.glm-5.3": { input: 1.848, output: 5.808, cacheRead: 0.3432, cacheWrite: 2.31 }, - "amazon-bedrock:writer.palmyra-x4-v1:0": { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:writer.palmyra-x5-v1:0": { input: 0.6, output: 6, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:xai.grok-4.3": { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, - "amazon-bedrock:xai.grok-4.6": { input: 2.2, output: 6.6, cacheRead: 0.55, cacheWrite: 0 }, - "amazon-bedrock:zai.glm-4.7": { input: 0.6, output: 2.2, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:zai.glm-4.7-flash": { input: 0.07, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "amazon-bedrock:zai.glm-5": { input: 1, output: 3.2, cacheRead: 0, cacheWrite: 0 }, - "ant-ling:Ling-2.6-1T": { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 }, - "ant-ling:Ling-2.6-flash": { input: 0.01, output: 0.02, cacheRead: 0, cacheWrite: 0 }, - "ant-ling:Ring-2.6-1T": { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 }, - "anthropic:claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "anthropic:claude-fable-5-1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }, - "anthropic:claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "anthropic:claude-haiku-4-5-20251001": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "anthropic:claude-haiku-5-5": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "anthropic:claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "anthropic:claude-opus-4-5-20251101": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "anthropic:claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "anthropic:claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "anthropic:claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "anthropic:claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "anthropic:claude-opus-5-5": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }, - "anthropic:claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "anthropic:claude-sonnet-4-5-20250929": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "anthropic:claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "anthropic:claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "anthropic:claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "azure:deepseek-v4-pro": { input: 1.925, output: 3.828, cacheRead: 0.165, cacheWrite: 0 }, - "azure:gpt-4": { input: 30, output: 60, cacheRead: 0, cacheWrite: 0 }, - "azure:gpt-4-turbo": { input: 10, output: 30, cacheRead: 0, cacheWrite: 0 }, - "azure:gpt-4.1": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, - "azure:gpt-4.1-mini": { input: 0.4, output: 1.6, cacheRead: 0.1, cacheWrite: 0 }, - "azure:gpt-4.1-nano": { input: 0.1, output: 0.4, cacheRead: 0.025, cacheWrite: 0 }, - "azure:gpt-4o": { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 0 }, - "azure:gpt-4o-2024-05-13": { input: 5, output: 15, cacheRead: 0, cacheWrite: 0 }, - "azure:gpt-4o-2024-08-06": { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 0 }, - "azure:gpt-4o-2024-11-20": { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 0 }, - "azure:gpt-4o-mini": { input: 0.15, output: 0.6, cacheRead: 0.075, cacheWrite: 0 }, - "azure:gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "azure:gpt-5-chat-latest": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "azure:gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 }, - "azure:gpt-5-nano": { input: 0.05, output: 0.4, cacheRead: 0.005, cacheWrite: 0 }, - "azure:gpt-5-pro": { input: 15, output: 120, cacheRead: 0, cacheWrite: 0 }, - "azure:gpt-5.1": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "azure:gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "azure:gpt-5.2-pro": { input: 21, output: 168, cacheRead: 0, cacheWrite: 0 }, - "azure:gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "azure:gpt-5.3-codex-spark": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "azure:gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, - "azure:gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - "azure:gpt-5.4-nano": { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 }, - "azure:gpt-5.4-pro": { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 }, - "azure:gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "azure:gpt-5.5-pro": { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 }, - "azure:gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "azure:gpt-5.6-sol": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "azure:gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, - "azure:gpt-6-astra": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "azure:gpt-6-luna": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "azure:gpt-6-sol": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "azure:gpt-6.1-sol": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "azure:gpt-daybreak-blue-latest": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "azure:gpt-daybreak-red-latest": { input: 12.5, output: 75, cacheRead: 1.25, cacheWrite: 15.625 }, - "azure:gpt-realtime-2.1": { input: 4, output: 24, cacheRead: 0.4, cacheWrite: 0 }, - "azure:o1": { input: 15, output: 60, cacheRead: 7.5, cacheWrite: 0 }, - "azure:o1-pro": { input: 150, output: 600, cacheRead: 0, cacheWrite: 0 }, - "azure:o3": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, - "azure:o3-mini": { input: 1.1, output: 4.4, cacheRead: 0.55, cacheWrite: 0 }, - "azure:o3-pro": { input: 20, output: 80, cacheRead: 0, cacheWrite: 0 }, - "azure:o4-mini": { input: 1.1, output: 4.4, cacheRead: 0.275, cacheWrite: 0 }, - "baseten:deepseek-ai/DeepSeek-V4-Flash-0731": { input: 0.13, output: 0.26, cacheRead: 0.028, cacheWrite: 0 }, - "baseten:deepseek-ai/DeepSeek-V4-Pro-0813": { input: 1.32, output: 3.96, cacheRead: 0.132, cacheWrite: 0 }, - "baseten:deepseek-ai/DeepSeek-V4.1-Flash": { input: 0.3, output: 1.2, cacheRead: 0.007, cacheWrite: 0 }, - "baseten:deepseek-ai/DeepSeek-V4.1-Flash-1017": { input: 0.3, output: 1.2, cacheRead: 0.007, cacheWrite: 0 }, - "baseten:deepseek-ai/DeepSeek-V4.1-Flash-1017-Fast": { input: 0.6, output: 2.4, cacheRead: 0.014, cacheWrite: 0 }, - "baseten:deepseek-ai/DeepSeek-V4.1-Flash-Fast": { input: 0.6, output: 2.4, cacheRead: 0.014, cacheWrite: 0 }, - "baseten:moonshotai/Kimi-K3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "baseten:nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B": { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0 }, - "baseten:openai/gpt-oss-120b": { input: 0.1, output: 0.5, cacheRead: 0.1, cacheWrite: 0 }, - "baseten:zai-org/GLM-5.2": { input: 1.4, output: 4.4, cacheRead: 0.14, cacheWrite: 0 }, - "baseten:zai-org/GLM-5.2-Fast": { input: 2.1, output: 6.6, cacheRead: 0.21, cacheWrite: 0 }, - "baseten:zai-org/GLM-5.3": { input: 1.4, output: 4.4, cacheRead: 0.14, cacheWrite: 0 }, - "baseten:zai-org/GLM-5.3-Fast": { input: 2.1, output: 6.6, cacheRead: 0.21, cacheWrite: 0 }, - "baseten:zai-org/GLM-5.3-Flash": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, - "cerebras:gpt-oss-120b": { input: 0.35, output: 0.75, cacheRead: 0.35, cacheWrite: 0 }, - "cerebras:qwen-3.8-27b": { input: 0.99, output: 1.49, cacheRead: 0.99, cacheWrite: 0 }, - "cloudflare-ai-gateway:claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "cloudflare-ai-gateway:claude-fable-5-1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }, - "cloudflare-ai-gateway:claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "cloudflare-ai-gateway:claude-haiku-5-5": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "cloudflare-ai-gateway:claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "cloudflare-ai-gateway:claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "cloudflare-ai-gateway:claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "cloudflare-ai-gateway:claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "cloudflare-ai-gateway:claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "cloudflare-ai-gateway:claude-opus-5-5": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }, - "cloudflare-ai-gateway:claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "cloudflare-ai-gateway:claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "cloudflare-ai-gateway:claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "cloudflare-ai-gateway:claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "cloudflare-ai-gateway:gpt-4.1": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-4.1-mini": { input: 0.4, output: 1.6, cacheRead: 0.1, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-4.1-nano": { input: 0.1, output: 0.4, cacheRead: 0.025, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-4o": { input: 1.25, output: 5, cacheRead: 0.625, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-4o-mini": { input: 0.075, output: 0.3, cacheRead: 0.0375, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-5-nano": { input: 0.05, output: 0.4, cacheRead: 0.005, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-5.1": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-5.4-nano": { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-5.4-pro": { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-5.5": { input: 5, output: 30, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-5.5-pro": { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-ai-gateway:gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "cloudflare-ai-gateway:gpt-5.6-sol": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "cloudflare-ai-gateway:gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, - "cloudflare-ai-gateway:gpt-6-astra": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "cloudflare-ai-gateway:gpt-6-luna": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "cloudflare-ai-gateway:gpt-6-sol": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "cloudflare-ai-gateway:gpt-6.1-sol": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "cloudflare-ai-gateway:o3": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, - "cloudflare-ai-gateway:o3-mini": { input: 1.1, output: 4.4, cacheRead: 0.55, cacheWrite: 0 }, - "cloudflare-ai-gateway:o4-mini": { input: 1.1, output: 4.4, cacheRead: 0.275, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/deepseek-ai/deepseek-v4-flash-0731": { input: 0.44, output: 1.32, cacheRead: 0.014, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/deepseek-ai/deepseek-v4-pro-0813": { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/deepseek-ai/deepseek-v4.1-flash-1017": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/google/gemma-4-26b-a4b-it": { input: 0.1, output: 0.3, cacheRead: 0.05, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/ibm-granite/granite-4.0-h-micro": { input: 0.017, output: 0.112, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast": { input: 0.293, output: 2.253, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/meta/llama-4-scout-17b-16e-instruct": { input: 0.27, output: 0.85, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/mistralai/mistral-small-3.1-24b-instruct": { input: 0.351, output: 0.555, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/moonshotai/kimi-k2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/moonshotai/kimi-k2.7-code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/nvidia/nemotron-3-120b-a12b": { input: 0.5, output: 1.5, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/openai/gpt-oss-120b": { input: 0.35, output: 0.75, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/openai/gpt-oss-20b": { input: 0.2, output: 0.3, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/qwen/qwen3-30b-a3b-fp8": { input: 0.0509, output: 0.335, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/qwen/qwen3.8-27b": { input: 0.45, output: 3.2, cacheRead: 0.05, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/zai-org/glm-4.7-flash": { input: 0.0605, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/zai-org/glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/zai-org/glm-5.3": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "cloudflare-ai-gateway:workers-ai/@cf/zai-org/glm-5.3-flash": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/deepseek-ai/deepseek-v4-flash-0731": { input: 0.44, output: 1.32, cacheRead: 0.014, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/deepseek-ai/deepseek-v4-pro-0813": { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/deepseek-ai/deepseek-v4.1-flash-1017": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/google/gemma-4-26b-a4b-it": { input: 0.1, output: 0.3, cacheRead: 0.05, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/ibm-granite/granite-4.0-h-micro": { input: 0.017, output: 0.112, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/meta/llama-3.3-70b-instruct-fp8-fast": { input: 0.293, output: 2.253, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/meta/llama-4-scout-17b-16e-instruct": { input: 0.27, output: 0.85, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/mistralai/mistral-small-3.1-24b-instruct": { input: 0.351, output: 0.555, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/moonshotai/kimi-k2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/moonshotai/kimi-k2.7-code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/nvidia/nemotron-3-120b-a12b": { input: 0.5, output: 1.5, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/openai/gpt-oss-120b": { input: 0.35, output: 0.75, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/openai/gpt-oss-20b": { input: 0.2, output: 0.3, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/qwen/qwen3-30b-a3b-fp8": { input: 0.0509, output: 0.335, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/qwen/qwen3.8-27b": { input: 0.45, output: 3.2, cacheRead: 0.05, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/zai-org/glm-4.7-flash": { input: 0.0605, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/zai-org/glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/zai-org/glm-5.3": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "cloudflare-workers-ai:@cf/zai-org/glm-5.3-flash": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, - "deepseek:deepseek-flash": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, - "deepseek:deepseek-v4-pro": { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0 }, - "fireworks:accounts/fireworks/models/deepseek-v4p1-flash": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, - "fireworks:accounts/fireworks/models/deepseek-v4p1-flash-1017": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, - "fireworks:accounts/fireworks/models/ember-1": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "fireworks:accounts/fireworks/models/glm-5p3": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "fireworks:accounts/fireworks/models/glm-5p3-flash": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, - "fireworks:accounts/fireworks/models/gpt-oss-120b": { input: 0.15, output: 0.6, cacheRead: 0.015, cacheWrite: 0 }, - "fireworks:accounts/fireworks/models/inkling": { input: 1, output: 4.05, cacheRead: 0.17, cacheWrite: 0 }, - "fireworks:accounts/fireworks/models/kimi-k3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "fireworks:accounts/fireworks/models/minimax-m3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "fireworks:accounts/fireworks/models/muse-spark-open": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, - "fireworks:accounts/fireworks/models/nemotron-3-ultra-nvfp4": { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0 }, - "fireworks:accounts/fireworks/models/nemotron-lightning-3p5-30b-a3b": { input: 0.05, output: 0.2, cacheRead: 0.01, cacheWrite: 0 }, - "fireworks:accounts/fireworks/models/qwen3p8-2p4t-a95b": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 0 }, - "fireworks:accounts/fireworks/models/qwen3p8-max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 0 }, - "fireworks:accounts/fireworks/routers/deepseek-flash-latest": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, - "fireworks:accounts/fireworks/routers/glm-5p3-fast": { input: 2.1, output: 6.6, cacheRead: 0.39, cacheWrite: 0 }, - "fireworks:accounts/fireworks/routers/glm-fast-latest": { input: 2.1, output: 6.6, cacheRead: 0.39, cacheWrite: 0 }, - "fireworks:accounts/fireworks/routers/glm-flash-latest": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, - "fireworks:accounts/fireworks/routers/glm-latest": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "fireworks:accounts/fireworks/routers/kimi-fast-latest": { input: 4.5, output: 22.5, cacheRead: 0.45, cacheWrite: 0 }, - "fireworks:accounts/fireworks/routers/kimi-k3-fast": { input: 4.5, output: 22.5, cacheRead: 0.45, cacheWrite: 0 }, - "fireworks:accounts/fireworks/routers/kimi-latest": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "fireworks:accounts/fireworks/routers/minimax-latest": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "fireworks:accounts/fireworks/routers/qwen-max-latest": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 0 }, - "github-copilot:claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "github-copilot:claude-fable-5.1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }, - "github-copilot:claude-haiku-4.5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "github-copilot:claude-haiku-5.5": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "github-copilot:claude-opus-4.8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "github-copilot:claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "github-copilot:claude-opus-5.5": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }, - "github-copilot:claude-sonnet-4.6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "github-copilot:claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "github-copilot:claude-sonnet-5.5": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "github-copilot:gemini-3.8-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }, - "github-copilot:gemini-4-argon": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 0 }, - "github-copilot:gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "github-copilot:gpt-5.4-nano": { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 }, - "github-copilot:gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "github-copilot:gpt-5.6-sol": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "github-copilot:gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, - "github-copilot:gpt-6-astra": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "github-copilot:gpt-6-luna": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "github-copilot:gpt-6-sol": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "github-copilot:gpt-6.1-sol": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "github-copilot:grok-4.5": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "github-copilot:grok-4.6": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "github-copilot:grok-4.7": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "github-copilot:kimi-k3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "github-copilot:mai-code-1.1-flash": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0 }, - "google-vertex:gemini-2.5-flash": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, - "google-vertex:gemini-2.5-flash-lite": { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0 }, - "google-vertex:gemini-2.5-pro": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "google-vertex:gemini-3-flash-preview": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 }, - "google-vertex:gemini-3.1-flash-lite": { input: 0.25, output: 1.5, cacheRead: 0.025, cacheWrite: 0 }, - "google-vertex:gemini-3.1-flash-lite-image": { input: 0.25, output: 30, cacheRead: 0.025, cacheWrite: 0 }, - "google-vertex:gemini-3.1-pro-preview": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - "google-vertex:gemini-3.1-pro-preview-customtools": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - "google-vertex:gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, - "google-vertex:gemini-3.5-flash-lite": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, - "google-vertex:gemini-3.6-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }, - "google-vertex:gemini-3.7-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }, - "google-vertex:gemini-3.8-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }, - "google-vertex:gemini-4-argon": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 0 }, - "google:deep-research-max-preview-04-2026": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - "google:deep-research-preview-04-2026": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - "google:gemini-2.5-computer-use-preview-10-2025": { input: 1.25, output: 10, cacheRead: 0, cacheWrite: 0 }, - "google:gemini-2.5-flash": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, - "google:gemini-2.5-flash-lite": { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0 }, - "google:gemini-2.5-pro": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "google:gemini-3-flash-preview": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 }, - "google:gemini-3.1-flash-lite": { input: 0.25, output: 1.5, cacheRead: 0.025, cacheWrite: 0 }, - "google:gemini-3.1-flash-lite-image": { input: 0.25, output: 30, cacheRead: 0, cacheWrite: 0 }, - "google:gemini-3.1-flash-lite-preview": { input: 0.25, output: 1.5, cacheRead: 0.025, cacheWrite: 0 }, - "google:gemini-3.1-flash-live-preview": { input: 0.75, output: 4.5, cacheRead: 0, cacheWrite: 0 }, - "google:gemini-3.1-pro-preview": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - "google:gemini-3.1-pro-preview-customtools": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - "google:gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, - "google:gemini-3.5-flash-lite": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, - "google:gemini-3.6-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }, - "google:gemini-3.7-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }, - "google:gemini-3.8-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }, - "google:gemini-4-argon": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 0 }, - "google:gemini-flash-latest": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, - "google:gemini-flash-lite-latest": { input: 0.25, output: 1.5, cacheRead: 0.025, cacheWrite: 0 }, - "google:gemini-pro-latest": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - "google:gemma-4-26b-a4b-it": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "google:gemma-4-31b-it": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "groq:llama-3.1-8b-instant": { input: 0.05, output: 0.08, cacheRead: 0, cacheWrite: 0 }, - "groq:llama-3.3-70b-versatile": { input: 0.59, output: 0.79, cacheRead: 0, cacheWrite: 0 }, - "groq:openai/gpt-oss-120b": { input: 0.15, output: 0.6, cacheRead: 0.075, cacheWrite: 0 }, - "groq:openai/gpt-oss-20b": { input: 0.075, output: 0.3, cacheRead: 0.0375, cacheWrite: 0 }, - "groq:openai/gpt-oss-safeguard-20b": { input: 0.075, output: 0.3, cacheRead: 0.0375, cacheWrite: 0 }, - "groq:qwen/qwen3.6-27b": { input: 0.6, output: 3, cacheRead: 0.3, cacheWrite: 0 }, - "groq:qwen/qwen3.8-27b": { input: 0.8, output: 4, cacheRead: 0, cacheWrite: 0 }, - "huggingface:deepseek-ai/DeepSeek-R1": { input: 0.7, output: 2.5, cacheRead: 0, cacheWrite: 0 }, - "huggingface:deepseek-ai/DeepSeek-R1-0528": { input: 3, output: 5, cacheRead: 0, cacheWrite: 0 }, - "huggingface:deepseek-ai/DeepSeek-V3": { input: 0.4, output: 1.3, cacheRead: 0, cacheWrite: 0 }, - "huggingface:deepseek-ai/DeepSeek-V3-0324": { input: 0.27, output: 1.12, cacheRead: 0, cacheWrite: 0 }, - "huggingface:deepseek-ai/DeepSeek-V3.1": { input: 0.27, output: 1, cacheRead: 0, cacheWrite: 0 }, - "huggingface:deepseek-ai/DeepSeek-V3.2": { input: 0.28, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "huggingface:deepseek-ai/DeepSeek-V4-Flash": { input: 0.14, output: 0.28, cacheRead: 0, cacheWrite: 0 }, - "huggingface:deepseek-ai/DeepSeek-V4-Flash-0731": { input: 0.14, output: 0.28, cacheRead: 0, cacheWrite: 0 }, - "huggingface:deepseek-ai/DeepSeek-V4-Flash-Vision-Exp": { input: 0.44, output: 1.32, cacheRead: 0, cacheWrite: 0 }, - "huggingface:deepseek-ai/DeepSeek-V4-Pro": { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 }, - "huggingface:deepseek-ai/DeepSeek-V4-Pro-0813": { input: 1.32, output: 3.96, cacheRead: 0, cacheWrite: 0 }, - "huggingface:deepseek-ai/DeepSeek-V4.1-Flash": { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:deepseek-ai/DeepSeek-V4.1-Flash-1017": { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:google/gemma-3-12b-it": { input: 0.05, output: 0.15, cacheRead: 0, cacheWrite: 0 }, - "huggingface:google/gemma-3-27b-it": { input: 0.08, output: 0.16, cacheRead: 0, cacheWrite: 0 }, - "huggingface:google/gemma-3-4b-it": { input: 0.05, output: 0.1, cacheRead: 0, cacheWrite: 0 }, - "huggingface:google/gemma-4-26B-A4B-it": { input: 0.13, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "huggingface:google/gemma-4-31B-it": { input: 0.14, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "huggingface:meta-llama/Llama-3.1-8B-Instruct": { input: 0.06, output: 0.06, cacheRead: 0, cacheWrite: 0 }, - "huggingface:meta-llama/Llama-3.3-70B-Instruct": { input: 0.59, output: 0.79, cacheRead: 0, cacheWrite: 0 }, - "huggingface:MiniMaxAI/MiniMax-M1-80k": { input: 0.55, output: 2.2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:MiniMaxAI/MiniMax-M2": { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:MiniMaxAI/MiniMax-M2.1": { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:MiniMaxAI/MiniMax-M2.5": { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0 }, - "huggingface:MiniMaxAI/MiniMax-M2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "huggingface:MiniMaxAI/MiniMax-M3": { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:moonshotai/Kimi-K2-Instruct": { input: 1, output: 3, cacheRead: 0, cacheWrite: 0 }, - "huggingface:moonshotai/Kimi-K2-Instruct-0905": { input: 1, output: 3, cacheRead: 0, cacheWrite: 0 }, - "huggingface:moonshotai/Kimi-K2-Thinking": { input: 0.6, output: 2.5, cacheRead: 0.15, cacheWrite: 0 }, - "huggingface:moonshotai/Kimi-K2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 }, - "huggingface:moonshotai/Kimi-K2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, - "huggingface:moonshotai/Kimi-K2.7-Code": { input: 0.95, output: 4, cacheRead: 0, cacheWrite: 0 }, - "huggingface:moonshotai/Kimi-K3": { input: 3, output: 15, cacheRead: 0, cacheWrite: 0 }, - "huggingface:openai/gpt-oss-120b": { input: 0.25, output: 0.69, cacheRead: 0, cacheWrite: 0 }, - "huggingface:openai/gpt-oss-20b": { input: 0.1, output: 0.5, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen2.5-Coder-32B-Instruct": { input: 0.06, output: 0.2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3-235B-A22B": { input: 0.2, output: 0.8, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3-235B-A22B-Instruct-2507": { input: 0.855, output: 2.565, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3-235B-A22B-Thinking-2507": { input: 0.3, output: 3, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3-30B-A3B": { input: 0.12, output: 0.5, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3-32B": { input: 0.29, output: 0.59, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3-8B": { input: 0.07, output: 0.18, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3-Coder-30B-A3B-Instruct": { input: 0.07, output: 0.26, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3-Coder-480B-A35B-Instruct": { input: 2, output: 2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3-Coder-Next": { input: 0.2, output: 1.5, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3-Next-80B-A3B-Instruct": { input: 0.25, output: 1, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3-Next-80B-A3B-Thinking": { input: 0.3, output: 2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3-VL-235B-A22B-Instruct": { input: 0.3, output: 1.5, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3-VL-235B-A22B-Thinking": { input: 0.98, output: 3.95, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3-VL-30B-A3B-Instruct": { input: 0.2, output: 0.7, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3.5-122B-A10B": { input: 0.4, output: 3.2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3.5-27B": { input: 0.3, output: 2.4, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3.5-35B-A3B": { input: 0.25, output: 2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3.5-397B-A17B": { input: 0.6, output: 3.6, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3.5-9B": { input: 0.17, output: 0.25, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3.6-27B": { input: 0.47, output: 3.19, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3.6-35B-A3B": { input: 0.15, output: 0.95, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3.8-2.4T-A95B": { input: 2.5, output: 6.25, cacheRead: 0, cacheWrite: 0 }, - "huggingface:Qwen/Qwen3.8-27B": { input: 0.4, output: 3, cacheRead: 0, cacheWrite: 0 }, - "huggingface:stepfun-ai/Step-3.5-Flash": { input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0 }, - "huggingface:stepfun-ai/Step-3.7-Flash": { input: 0.2, output: 1.15, cacheRead: 0, cacheWrite: 0 }, - "huggingface:tencent/Hy3": { input: 0.14, output: 0.58, cacheRead: 0, cacheWrite: 0 }, - "huggingface:tencent/Hy4-preview": { input: 0.834, output: 2.501, cacheRead: 0, cacheWrite: 0 }, - "huggingface:thinkingmachines/Inkling": { input: 1, output: 4.05, cacheRead: 0, cacheWrite: 0 }, - "huggingface:thinkingmachines/Inkling-Small": { input: 0.5, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:XiaomiMiMo/MiMo-V2-Flash": { input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0 }, - "huggingface:XiaomiMiMo/MiMo-V2.5": { input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:XiaomiMiMo/MiMo-V2.5-Pro": { input: 1, output: 3, cacheRead: 0, cacheWrite: 0 }, - "huggingface:zai-org/GLM-4.5": { input: 0.6, output: 2.2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:zai-org/GLM-4.5-Air": { input: 0.13, output: 0.85, cacheRead: 0, cacheWrite: 0 }, - "huggingface:zai-org/GLM-4.5V": { input: 0.6, output: 1.8, cacheRead: 0, cacheWrite: 0 }, - "huggingface:zai-org/GLM-4.6": { input: 0.55, output: 2.2, cacheRead: 0, cacheWrite: 0 }, - "huggingface:zai-org/GLM-4.6V-Flash": { input: 0.3, output: 0.9, cacheRead: 0, cacheWrite: 0 }, - "huggingface:zai-org/GLM-4.7": { input: 0.6, output: 2.2, cacheRead: 0.11, cacheWrite: 0 }, - "huggingface:zai-org/GLM-4.7-Flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "huggingface:zai-org/GLM-5": { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 }, - "huggingface:zai-org/GLM-5.1": { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 }, - "huggingface:zai-org/GLM-5.2": { input: 1.4, output: 4.4, cacheRead: 0, cacheWrite: 0 }, - "huggingface:zai-org/GLM-5.3": { input: 1.4, output: 4.4, cacheRead: 0, cacheWrite: 0 }, - "huggingface:zai-org/GLM-5.3-Flash": { input: 0.15, output: 0.5, cacheRead: 0, cacheWrite: 0 }, - "kimi-coding:k3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "kimi-coding:k3-256k": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "kimi-coding:kimi-for-coding": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, - "kimi-coding:kimi-for-coding-highspeed": { input: 1.9, output: 8, cacheRead: 0.38, 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 }, - "meta:muse-wonder-1.0": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 0 }, - "minimax-cn:MiniMax-M2.7": { input: 0.3137, output: 1.2549, cacheRead: 0.0627, cacheWrite: 0.3922 }, - "minimax-cn:MiniMax-M2.7-highspeed": { input: 0.6275, output: 2.5098, cacheRead: 0.0627, cacheWrite: 0.3922 }, - "minimax-cn:MiniMax-M3": { input: 0.3137, output: 1.2549, cacheRead: 0.0627, cacheWrite: 0 }, - "minimax-cn:MiniMax-M3.1-Flash": { input: 0.1494, output: 0.5976, cacheRead: 0.0149, cacheWrite: 0 }, - "minimax:MiniMax-M2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 }, - "minimax:MiniMax-M2.7-highspeed": { input: 0.6, output: 2.4, cacheRead: 0.06, cacheWrite: 0.375 }, - "minimax:MiniMax-M3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "minimax:MiniMax-M3.1-Flash": { input: 0.15, output: 0.6, cacheRead: 0.015, cacheWrite: 0 }, - "mistral:codestral-2508": { input: 0.3, output: 0.9, cacheRead: 0.03, cacheWrite: 0 }, - "mistral:codestral-latest": { input: 0.3, output: 0.9, cacheRead: 0.03, cacheWrite: 0 }, - "mistral:devstral-2512": { input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0 }, - "mistral:devstral-latest": { input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0 }, - "mistral:devstral-medium-2507": { input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0 }, - "mistral:devstral-medium-latest": { input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0 }, - "mistral:devstral-small-2505": { input: 0.1, output: 0.3, cacheRead: 0.01, cacheWrite: 0 }, - "mistral:devstral-small-2507": { input: 0.1, output: 0.3, cacheRead: 0.01, cacheWrite: 0 }, - "mistral:glm-5-2": { input: 1.4, output: 4.4, cacheRead: 0.14, cacheWrite: 0 }, - "mistral:labs-devstral-small-2512": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "mistral:labs-leanstral-1-5-1": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "mistral:magistral-medium-latest": { input: 2, output: 5, cacheRead: 0.2, cacheWrite: 0 }, - "mistral:ministral-14b-2512": { input: 0.2, output: 0.2, cacheRead: 0.02, cacheWrite: 0 }, - "mistral:ministral-3b-2512": { input: 0.1, output: 0.1, cacheRead: 0.01, cacheWrite: 0 }, - "mistral:ministral-3b-latest": { input: 0.1, output: 0.1, cacheRead: 0.01, cacheWrite: 0 }, - "mistral:ministral-8b-2512": { input: 0.15, output: 0.15, cacheRead: 0.015, cacheWrite: 0 }, - "mistral:ministral-8b-latest": { input: 0.15, output: 0.15, cacheRead: 0.015, cacheWrite: 0 }, - "mistral:mistral-large-2411": { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 0 }, - "mistral:mistral-large-2512": { input: 0.5, output: 1.5, cacheRead: 0.05, cacheWrite: 0 }, - "mistral:mistral-large-4": { input: 0.68, output: 2.09, cacheRead: 0.07, cacheWrite: 0 }, - "mistral:mistral-large-latest": { input: 0.5, output: 1.5, cacheRead: 0.05, cacheWrite: 0 }, - "mistral:mistral-medium-2505": { input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0 }, - "mistral:mistral-medium-2508": { input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0 }, - "mistral:mistral-medium-2604": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, - "mistral:mistral-medium-3.5": { input: 1.5, output: 7.5, cacheRead: 0, cacheWrite: 0 }, - "mistral:mistral-medium-latest": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, - "mistral:mistral-nemo": { input: 0.15, output: 0.15, cacheRead: 0.015, cacheWrite: 0 }, - "mistral:mistral-small-2506": { input: 0.1, output: 0.3, cacheRead: 0.01, cacheWrite: 0 }, - "mistral:mistral-small-2603": { input: 0.15, output: 0.6, cacheRead: 0.015, cacheWrite: 0 }, - "mistral:mistral-small-latest": { input: 0.15, output: 0.6, cacheRead: 0.015, cacheWrite: 0 }, - "mistral:open-mistral-nemo": { input: 0.15, output: 0.15, cacheRead: 0.015, cacheWrite: 0 }, - "mistral:pixtral-12b": { input: 0.15, output: 0.15, cacheRead: 0.015, cacheWrite: 0 }, - "mistral:pixtral-large-latest": { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 0 }, - "mistral:voxtral-small-2507": { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0 }, - "mistral:voxtral-small-latest": { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0 }, - "mistral:zai-glm-5-2": { input: 1.4, output: 4.4, cacheRead: 0.14, cacheWrite: 0 }, - "mistral:zai-glm-5-3": { input: 1.4, output: 4.4, cacheRead: 0.14, cacheWrite: 0 }, - "moonshotai-cn:kimi-k2.6": { input: 0.9711, output: 4.0337, cacheRead: 0.1643, cacheWrite: 0 }, - "moonshotai-cn:kimi-k2.7-code": { input: 0.9711, output: 4.0337, cacheRead: 0.1942, cacheWrite: 0 }, - "moonshotai-cn:kimi-k2.7-code-highspeed": { input: 1.9421, output: 8.0673, cacheRead: 0.3884, cacheWrite: 0 }, - "moonshotai-cn:kimi-k3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "moonshotai:kimi-k2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, - "moonshotai:kimi-k2.7-code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, - "moonshotai:kimi-k2.7-code-highspeed": { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 0 }, - "moonshotai:kimi-k3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "nvidia:deepseek-ai/deepseek-v4.1-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:google/diffusiongemma-26b-a4b-it": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:google/gemma-3-12b-it": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:google/gemma-3-4b-it": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:meta/llama-3.2-11b-vision-instruct": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:meta/llama-3.2-90b-vision-instruct": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:meta/muse-glimmer-30b": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:mistralai/mistral-7b-instruct-v0.3": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:moonshotai/kimi-k2.6": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:nvidia/cosmos-reason2-8b": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:nvidia/llama-3.1-nemotron-70b-instruct": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:nvidia/llama-3.1-nemotron-ultra-253b-v1": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:nvidia/nemotron-3-nano-omni-30b-a3b-reasoning": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:nvidia/nemotron-3-ultra-550b-a55b": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:nvidia/nemotron-3.5-lightning-30b-a3b": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:nvidia/nemotron-3.5-super-vl-120b-a12b": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:openai/gpt-oss-20b": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:poolside/laguna-xs-2.1": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:stepfun-ai/step-5-preview": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:z-ai/glm-5.3": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "nvidia:z-ai/glm-5.3-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openai-codex:gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "openai-codex:gpt-5.6-sol": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "openai-codex:gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, - "openai-codex:gpt-6-astra": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "openai-codex:gpt-6-luna": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "openai-codex:gpt-6-sol": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "openai-codex:gpt-6.1-sol": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "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.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-nano": { input: 0.1, output: 0.4, cacheRead: 0.025, 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-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "openai:gpt-5-chat-latest": { 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-nano": { input: 0.05, output: 0.4, cacheRead: 0.005, cacheWrite: 0 }, - "openai:gpt-5-pro": { input: 15, output: 120, cacheRead: 0, cacheWrite: 0 }, - "openai:gpt-5.1": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "openai:gpt-5.2": { 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.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "openai:gpt-5.3-codex-spark": { 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-nano": { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 }, - "openai:gpt-5.4-pro": { input: 30, output: 180, cacheRead: 0, 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.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "openai:gpt-5.6-sol": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "openai:gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, - "openai:gpt-6-astra": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "openai:gpt-6-luna": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "openai:gpt-6-sol": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "openai:gpt-6.1-sol": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "openai:gpt-daybreak-blue-latest": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "openai:gpt-daybreak-red-latest": { input: 12.5, output: 75, cacheRead: 1.25, cacheWrite: 15.625 }, - "openai:gpt-realtime-2.1": { input: 4, output: 24, cacheRead: 0.4, cacheWrite: 0 }, - "openai:o1": { input: 15, output: 60, cacheRead: 7.5, cacheWrite: 0 }, - "openai:o1-pro": { input: 150, output: 600, cacheRead: 0, 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-pro": { input: 20, output: 80, cacheRead: 0, cacheWrite: 0 }, - "openai:o4-mini": { input: 1.1, output: 4.4, cacheRead: 0.275, cacheWrite: 0 }, - "opencode-go:beam-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "opencode-go:claude-haiku-5-5": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "opencode-go:deepseek-v4-pro": { input: 0.66, output: 1.98, cacheRead: 0.022, cacheWrite: 0 }, - "opencode-go:deepseek-v4.1-flash": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, - "opencode-go:gemini-4-argon-contributor": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 0 }, - "opencode-go:glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "opencode-go:glm-5.3": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "opencode-go:glm-5.3-flash": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, - "opencode-go:gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "opencode-go:gpt-6-luna": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "opencode-go:grok-4.7": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "opencode-go:hy3": { input: 0.14, output: 0.58, cacheRead: 0.035, cacheWrite: 0 }, - "opencode-go:hy4-preview": { input: 0.834, output: 2.501, cacheRead: 0.042, cacheWrite: 0 }, - "opencode-go:kimi-k2.7-code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, - "opencode-go:kimi-k3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "opencode-go:longcat-2.0": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, - "opencode-go:longcat-2.5-preview-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "opencode-go:mimo-v2.5": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, - "opencode-go:mimo-v2.5-pro": { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 }, - "opencode-go:mimo-v2.6-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, - "opencode-go:mimo-v2.6-pro": { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 }, - "opencode-go:minimax-m3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "opencode-go:minimax-m3.1-flash-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "opencode-go:muse-spark-1.2-contributor": { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 }, - "opencode-go:muse-spark-1.3-contributor": { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 }, - "opencode-go:qwen3.7-plus": { input: 0.4, output: 1.6, cacheRead: 0.04, cacheWrite: 0.5 }, - "opencode-go:qwen3.8-flash": { input: 0.15, output: 0.47, cacheRead: 0.016, cacheWrite: 0.2 }, - "opencode-go:qwen3.8-max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 2.5 }, - "opencode-go:step-5-preview": { input: 1, output: 2.7, cacheRead: 0.1, cacheWrite: 0 }, - "opencode:beam-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "opencode:big-pickle": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "opencode:claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "opencode:claude-fable-5-1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }, - "opencode:claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "opencode:claude-haiku-5-5": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "opencode:claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "opencode:claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "opencode:claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "opencode:claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "opencode:claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "opencode:claude-opus-5-5": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }, - "opencode:claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "opencode:claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "opencode:claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "opencode:claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "opencode:deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.028, cacheWrite: 0 }, - "opencode:deepseek-v4-pro": { input: 1.74, output: 3.48, cacheRead: 0.145, cacheWrite: 0 }, - "opencode:deepseek-v4.1-flash": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, - "opencode:deepseek-v4.1-flash-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "opencode:flint-40b-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "opencode:gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 }, - "opencode:gemini-3.1-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - "opencode:gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, - "opencode:gemini-3.5-flash-lite": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, - "opencode:gemini-3.6-flash": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, - "opencode:gemini-3.7-flash": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, - "opencode:gemini-3.8-flash": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, - "opencode:gemini-4-argon": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 0 }, - "opencode:glm-5": { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 }, - "opencode:glm-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "opencode:glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "opencode:glm-5.3": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "opencode:glm-5.3-flash": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, - "opencode:glm-5.3-flash-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "opencode:gpt-5": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 }, - "opencode:gpt-5-codex": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 }, - "opencode:gpt-5-nano": { input: 0.05, output: 0.4, cacheRead: 0.005, cacheWrite: 0 }, - "opencode:gpt-5.1": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 }, - "opencode:gpt-5.1-codex": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 }, - "opencode:gpt-5.1-codex-max": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "opencode:gpt-5.1-codex-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 }, - "opencode:gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "opencode:gpt-5.2-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "opencode:gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "opencode:gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, - "opencode:gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - "opencode:gpt-5.4-nano": { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 }, - "opencode:gpt-5.4-pro": { input: 30, output: 180, cacheRead: 30, cacheWrite: 0 }, - "opencode:gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "opencode:gpt-5.5-pro": { input: 30, output: 180, cacheRead: 30, cacheWrite: 0 }, - "opencode:gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "opencode:gpt-5.6-sol": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "opencode:gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, - "opencode:gpt-6-astra": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "opencode:gpt-6-luna": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "opencode:gpt-6-sol": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "opencode:gpt-6.1-sol": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "opencode:grok-4.5": { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, - "opencode:grok-4.6": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "opencode:grok-4.7": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "opencode:grok-build-0.1": { input: 1, output: 2, cacheRead: 0.2, cacheWrite: 0 }, - "opencode:kimi-k2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 }, - "opencode:kimi-k2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, - "opencode:kimi-k2.7-code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, - "opencode:kimi-k3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "opencode:longcat-2.5-preview-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "opencode:mimo-v2.6-flash-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "opencode:minimax-m2.5": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "opencode:minimax-m2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "opencode:minimax-m3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "opencode:minimax-m3.1-flash-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "opencode:mistral-large-4": { input: 0.68, output: 2.09, cacheRead: 0.07, cacheWrite: 0 }, - "opencode:muse-spark-1.2": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, - "opencode:muse-spark-1.3": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, - "opencode:muse-wonder-1.0": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 0 }, - "opencode:nemotron-3-ultra-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "opencode:nemotron-3.5-lightning-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "opencode:nemotron-3.5-super-vl-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "opencode:qwen3.5-plus": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "opencode:qwen3.6-plus": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0.625 }, - "opencode:qwen3.8-flash": { input: 0.15, output: 0.47, cacheRead: 0.016, cacheWrite: 0.2 }, - "opencode:qwen3.8-max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 2.5 }, - "openrouter:~anthropic/claude-fable-latest": { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }, - "openrouter:~anthropic/claude-haiku-latest": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "openrouter:~anthropic/claude-opus-latest": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }, - "openrouter:~anthropic/claude-sonnet-latest": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "openrouter:~deepseek/deepseek-flash-latest": { input: 0.07, output: 0.5, cacheRead: 0.008, cacheWrite: 0 }, - "openrouter:~deepseek/deepseek-pro-latest": { input: 0.1307, output: 8, cacheRead: 0.13, cacheWrite: 0 }, - "openrouter:~deepseek/deepseek-v4-flash-latest": { input: 0.0422, output: 0.1729, cacheRead: 0.0275, cacheWrite: 0 }, - "openrouter:~google/gemini-flash-latest": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0.041667 }, - "openrouter:~google/gemini-pro-latest": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 0.1875 }, - "openrouter:~moonshotai/kimi-latest": { input: 0.238, output: 16, cacheRead: 0.228, cacheWrite: 0 }, - "openrouter:~openai/gpt-astra-latest": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "openrouter:~openai/gpt-luna-latest": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "openrouter:~openai/gpt-mini-latest": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - "openrouter:~openai/gpt-sol-latest": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "openrouter:~openai/gpt-terra-latest": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, - "openrouter:~unbiased/pareto-latest": { input: 0.8, output: 3.2, cacheRead: 0.03, cacheWrite: 0 }, - "openrouter:~x-ai/grok-latest": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "openrouter:~z-ai/glm-flash-latest": { input: 0.0413, output: 0.0773, cacheRead: 0.03, cacheWrite: 0 }, - "openrouter:~z-ai/glm-latest": { input: 0.079, output: 1.4, cacheRead: 0.078, cacheWrite: 0 }, - "openrouter:aion-labs/aion-2.0": { input: 0.8, output: 1.6, cacheRead: 0.2, cacheWrite: 0 }, - "openrouter:aion-labs/aion-3.0": { input: 3, output: 6, cacheRead: 0.75, cacheWrite: 0 }, - "openrouter:aion-labs/aion-3.0-mini": { input: 0.7, output: 1.4, cacheRead: 0.18, cacheWrite: 0 }, - "openrouter:aion-labs/aion-3.5": { input: 3, output: 6, cacheRead: 0.75, cacheWrite: 0 }, - "openrouter:aion-labs/aion-3.5-mini": { input: 0.7, output: 1.4, cacheRead: 0.18, cacheWrite: 0 }, - "openrouter:amazon/nova-2-lite-v1": { input: 0.3, output: 2.5, cacheRead: 0, cacheWrite: 0 }, - "openrouter:amazon/nova-lite-v1": { input: 0.06, output: 0.24, cacheRead: 0, cacheWrite: 0 }, - "openrouter:amazon/nova-micro-v1": { input: 0.035, output: 0.14, cacheRead: 0, cacheWrite: 0 }, - "openrouter:amazon/nova-premier-v1": { input: 2.5, output: 12.5, cacheRead: 0.625, cacheWrite: 0 }, - "openrouter:amazon/nova-pro-v1": { input: 0.8, output: 3.2, cacheRead: 0, cacheWrite: 0 }, - "openrouter:anthropic/claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "openrouter:anthropic/claude-fable-5:batch": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "openrouter:anthropic/claude-fable-5.1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }, - "openrouter:anthropic/claude-fable-5.1:batch": { input: 5, output: 25, cacheRead: 0.125, cacheWrite: 6.25 }, - "openrouter:anthropic/claude-haiku-4.5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "openrouter:anthropic/claude-haiku-4.5:batch": { input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite: 0.625 }, - "openrouter:anthropic/claude-haiku-5.5": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "openrouter:anthropic/claude-haiku-5.5:batch": { input: 0.05, output: 0.25, cacheRead: 0.005, cacheWrite: 0.0625 }, - "openrouter:anthropic/claude-opus-4.1": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, - "openrouter:anthropic/claude-opus-4.1:batch": { input: 7.5, output: 37.5, cacheRead: 0.75, cacheWrite: 9.375 }, - "openrouter:anthropic/claude-opus-4.5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "openrouter:anthropic/claude-opus-4.5:batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 }, - "openrouter:anthropic/claude-opus-4.6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "openrouter:anthropic/claude-opus-4.6:batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 }, - "openrouter:anthropic/claude-opus-4.7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "openrouter:anthropic/claude-opus-4.7:batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 }, - "openrouter:anthropic/claude-opus-4.8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "openrouter:anthropic/claude-opus-4.8:batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 }, - "openrouter:anthropic/claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "openrouter:anthropic/claude-opus-5:batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 }, - "openrouter:anthropic/claude-opus-5.5": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }, - "openrouter:anthropic/claude-opus-5.5:batch": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "openrouter:anthropic/claude-sonnet-4.5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "openrouter:anthropic/claude-sonnet-4.5:batch": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 1.875 }, - "openrouter:anthropic/claude-sonnet-4.6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "openrouter:anthropic/claude-sonnet-4.6:batch": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 1.875 }, - "openrouter:anthropic/claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "openrouter:anthropic/claude-sonnet-5:batch": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "openrouter:anthropic/claude-sonnet-5.5": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "openrouter:anthropic/claude-sonnet-5.5:batch": { input: 1, output: 5, cacheRead: 0.05, cacheWrite: 1.25 }, - "openrouter:arcee-ai/trinity-large-thinking": { input: 0.25, output: 0.8, cacheRead: 0.06, cacheWrite: 0 }, - "openrouter:auto": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:bytedance-seed/seed-1.6": { input: 0.25, output: 2, cacheRead: 0, cacheWrite: 0 }, - "openrouter:bytedance-seed/seed-1.6-flash": { input: 0.075, output: 0.3, cacheRead: 0, cacheWrite: 0 }, - "openrouter:bytedance-seed/seed-2-1-turbo": { input: 0.5, output: 2.5, cacheRead: 0, cacheWrite: 0 }, - "openrouter:bytedance-seed/seed-2.0-code": { input: 0.5, output: 3, cacheRead: 0, cacheWrite: 0 }, - "openrouter:bytedance-seed/seed-2.0-lite": { input: 0.25, output: 2, cacheRead: 0, cacheWrite: 0 }, - "openrouter:bytedance-seed/seed-2.0-mini": { input: 0.1, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "openrouter:cohere/command-a-plus": { input: 0.3, output: 1.5, cacheRead: 0.15, cacheWrite: 0 }, - "openrouter:cohere/command-r-08-2024": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, - "openrouter:cohere/command-r-plus-08-2024": { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, - "openrouter:cohere/north-mini-code:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-chat": { input: 0.2574, output: 1.0287, cacheRead: 0, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-chat-v3-0324": { input: 0.29, output: 1.14, cacheRead: 0.11, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-chat-v3.1": { input: 0.25, output: 0.95, cacheRead: 0.13, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-r1-0528": { input: 0.5, output: 2.15, cacheRead: 0.35, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-v3.1-terminus": { input: 0.27, output: 1, cacheRead: 0, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-v3.2": { input: 0.28, output: 0.42, cacheRead: 0.028, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-v3.2-exp": { input: 0.27, output: 0.41, cacheRead: 0, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-v4-flash": { input: 0.091, output: 0.182, cacheRead: 0.0182, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-v4-flash-0731": { input: 0.04444, output: 0.13332, cacheRead: 0.001414, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-v4-flash-vision-exp": { input: 0.2156, output: 0.6468, cacheRead: 0.00686, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-v4-pro": { input: 0.388716, output: 0.777432, cacheRead: 0.032393, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-v4-pro-0813": { input: 0.66, output: 1.98, cacheRead: 0.022, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-v4.1-flash": { input: 0.0342, output: 0.1368, cacheRead: 0.000684, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-v4.1-flash-1017": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, - "openrouter:deepseek/deepseek-v4.1-flash:batch": { input: 0.05, output: 0.15, cacheRead: 0.004, cacheWrite: 0 }, - "openrouter:dots-studio/dots-3-note-preview:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:elorian/glimpse": { input: 0.6, output: 3, cacheRead: 0.06, cacheWrite: 0 }, - "openrouter:fireworks/ember-1": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "openrouter:google/gemini-2.5-flash": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0.083333 }, - "openrouter:google/gemini-2.5-flash-lite": { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0.083333 }, - "openrouter:google/gemini-2.5-pro": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0.375 }, - "openrouter:google/gemini-2.5-pro-preview": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0.375 }, - "openrouter:google/gemini-3-flash-preview": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0.083333 }, - "openrouter:google/gemini-3-flash-preview:batch": { input: 0.25, output: 1.5, cacheRead: 0, cacheWrite: 0 }, - "openrouter:google/gemini-3-pro-image": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0.375 }, - "openrouter:google/gemini-3.1-flash-lite": { input: 0.25, output: 1.5, cacheRead: 0.025, cacheWrite: 0.083333 }, - "openrouter:google/gemini-3.1-flash-lite-preview": { input: 0.25, output: 1.5, cacheRead: 0.025, cacheWrite: 0.083333 }, - "openrouter:google/gemini-3.1-flash-lite:batch": { input: 0.125, output: 0.75, cacheRead: 0.0125, cacheWrite: 0 }, - "openrouter:google/gemini-3.1-pro-preview": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0.375 }, - "openrouter:google/gemini-3.1-pro-preview-customtools": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0.375 }, - "openrouter:google/gemini-3.1-pro-preview:batch": { input: 1, output: 6, cacheRead: 0, cacheWrite: 0 }, - "openrouter:google/gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0.083333 }, - "openrouter:google/gemini-3.5-flash-lite": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0.083333 }, - "openrouter:google/gemini-3.5-flash-lite:batch": { input: 0.15, output: 1.25, cacheRead: 0.015, cacheWrite: 0 }, - "openrouter:google/gemini-3.5-flash:batch": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - "openrouter:google/gemini-3.6-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0.041667 }, - "openrouter:google/gemini-3.6-flash:batch": { input: 0.375, output: 1.875, cacheRead: 0.0375, cacheWrite: 0.041667 }, - "openrouter:google/gemini-3.7-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0.041667 }, - "openrouter:google/gemini-3.7-flash:batch": { input: 0.375, output: 1.875, cacheRead: 0.0375, cacheWrite: 0.041667 }, - "openrouter:google/gemini-3.8-flash": { input: 0.825, output: 4.125, cacheRead: 0.0825, cacheWrite: 0.041667 }, - "openrouter:google/gemini-3.8-flash:batch": { input: 0.375, output: 1.875, cacheRead: 0.0375, cacheWrite: 0.041667 }, - "openrouter:google/gemini-4-argon": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 0.1875 }, - "openrouter:google/gemini-4-argon:batch": { input: 1, output: 5, cacheRead: 0.05, cacheWrite: 0.1875 }, - "openrouter:google/gemini-nano-banana-2.1": { input: 1.5, output: 7.5, cacheRead: 0, cacheWrite: 0 }, - "openrouter:google/gemma-3-12b-it": { input: 0.05, output: 0.15, cacheRead: 0, cacheWrite: 0 }, - "openrouter:google/gemma-3-27b-it": { input: 0.08, output: 0.35, cacheRead: 0.04, cacheWrite: 0 }, - "openrouter:google/gemma-4-26b-a4b-it": { input: 0.0765, output: 0.255, cacheRead: 0.0425, cacheWrite: 0 }, - "openrouter:google/gemma-4-26b-a4b-it:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:google/gemma-4-31b-it": { input: 0.2, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "openrouter:google/gemma-4-31b-it:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:ibm-granite/granite-4.2-8b": { input: 0.06, output: 0.25, cacheRead: 0.015, cacheWrite: 0 }, - "openrouter:inception/mercury-2": { input: 0.25, output: 0.75, cacheRead: 0.025, cacheWrite: 0 }, - "openrouter:inception/mercury-2.5": { input: 0.04, output: 0.15, cacheRead: 0.004, cacheWrite: 0 }, - "openrouter:inception/mercury-3": { input: 0.1, output: 0.35, cacheRead: 0.01, cacheWrite: 0 }, - "openrouter:inclusionai/ling-3.0-flash": { input: 0.021, output: 0.063, cacheRead: 0.0042, cacheWrite: 0 }, - "openrouter:inclusionai/ling-3.0-flash-vl": { input: 0.06, output: 0.18, cacheRead: 0.012, cacheWrite: 0 }, - "openrouter:inclusionai/ling-3.1-flash": { input: 0.075, output: 0.225, cacheRead: 0.015, cacheWrite: 0 }, - "openrouter:liquid/lfm-2.5-2.6b:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:liquid/lfm-2.5-24b-a2b": { input: 0.05, output: 0.7, cacheRead: 0.025, cacheWrite: 0 }, - "openrouter:meituan/longcat-2.0": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, - "openrouter:meta-llama/llama-3.1-70b-instruct": { input: 0.4, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "openrouter:meta-llama/llama-3.1-8b-instruct": { input: 0.05, output: 0.08, cacheRead: 0.025, cacheWrite: 0 }, - "openrouter:meta-llama/llama-3.3-70b-instruct": { input: 0.1, output: 0.32, cacheRead: 0, cacheWrite: 0 }, - "openrouter:meta-llama/llama-4-maverick": { input: 0.1875, output: 0.6525, cacheRead: 0.05, cacheWrite: 0 }, - "openrouter:meta-llama/llama-4-scout": { input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0 }, - "openrouter:meta/muse-glimmer-30b": { input: 0.3, output: 1.2, cacheRead: 0.04, cacheWrite: 0 }, - "openrouter:meta/muse-spark-1.1": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, - "openrouter:meta/muse-spark-1.2": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, - "openrouter:meta/muse-spark-1.2-contributor": { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 }, - "openrouter:meta/muse-spark-1.3": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, - "openrouter:meta/muse-spark-1.3-contributor": { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 }, - "openrouter:meta/muse-spark-open": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, - "openrouter:meta/muse-wonder": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 0 }, - "openrouter:minimax/minimax-m1": { input: 0.55, output: 2.2, cacheRead: 0, cacheWrite: 0 }, - "openrouter:minimax/minimax-m2": { input: 0.255, output: 1.02, cacheRead: 0, cacheWrite: 0 }, - "openrouter:minimax/minimax-m2.1": { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0 }, - "openrouter:minimax/minimax-m2.5": { input: 0.27, output: 0.95, cacheRead: 0.03, cacheWrite: 0 }, - "openrouter:minimax/minimax-m2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "openrouter:minimax/minimax-m3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "openrouter:minimax/minimax-m3.1-flash:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:mistralai/codestral-2508": { input: 0.3, output: 0.9, cacheRead: 0.03, cacheWrite: 0 }, - "openrouter:mistralai/codestral-2508:batch": { input: 0.15, output: 0.45, cacheRead: 0.015, cacheWrite: 0 }, - "openrouter:mistralai/devstral-2512": { input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0 }, - "openrouter:mistralai/ministral-14b-2512": { input: 0.2, output: 0.2, cacheRead: 0.02, cacheWrite: 0 }, - "openrouter:mistralai/ministral-3b-2512": { input: 0.1, output: 0.1, cacheRead: 0.01, cacheWrite: 0 }, - "openrouter:mistralai/ministral-8b-2512": { input: 0.15, output: 0.15, cacheRead: 0.015, cacheWrite: 0 }, - "openrouter:mistralai/ministral-8b-2512:batch": { input: 0.075, output: 0.075, cacheRead: 0.0075, cacheWrite: 0 }, - "openrouter:mistralai/mistral-large": { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 0 }, - "openrouter:mistralai/mistral-large-2407": { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 0 }, - "openrouter:mistralai/mistral-large-2512": { input: 0.5, output: 1.5, cacheRead: 0.05, cacheWrite: 0 }, - "openrouter:mistralai/mistral-large-2512:batch": { input: 0.25, output: 0.75, cacheRead: 0.025, cacheWrite: 0 }, - "openrouter:mistralai/mistral-large-4-0": { input: 1.36, output: 4.18, cacheRead: 0.14, cacheWrite: 0 }, - "openrouter:mistralai/mistral-medium-3": { input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0 }, - "openrouter:mistralai/mistral-medium-3-5": { input: 1.5, output: 7.5, cacheRead: 0, cacheWrite: 0 }, - "openrouter:mistralai/mistral-medium-3-5:batch": { input: 0.75, output: 3.75, cacheRead: 0, cacheWrite: 0 }, - "openrouter:mistralai/mistral-medium-3.1": { input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0 }, - "openrouter:mistralai/mistral-medium-3.1:batch": { input: 0.2, output: 1, cacheRead: 0.02, cacheWrite: 0 }, - "openrouter:mistralai/mistral-nemo": { input: 0.029, output: 0.03, cacheRead: 0, cacheWrite: 0 }, - "openrouter:mistralai/mistral-saba": { input: 0.2, output: 0.6, cacheRead: 0.02, cacheWrite: 0 }, - "openrouter:mistralai/mistral-small-2603": { input: 0.15, output: 0.6, cacheRead: 0.015, cacheWrite: 0 }, - "openrouter:mistralai/mistral-small-2603:batch": { input: 0.075, output: 0.3, cacheRead: 0.0075, cacheWrite: 0 }, - "openrouter:mistralai/mistral-small-3.1-24b-instruct": { input: 0.351, output: 0.555, cacheRead: 0, cacheWrite: 0 }, - "openrouter:mistralai/mistral-small-3.2-24b-instruct": { input: 0.09375, output: 0.25, cacheRead: 0, cacheWrite: 0 }, - "openrouter:mistralai/mixtral-8x22b-instruct": { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 0 }, - "openrouter:mistralai/voxtral-small-24b-2507": { input: 0.1, output: 0.3, cacheRead: 0.01, cacheWrite: 0 }, - "openrouter:moonshotai/kimi-k2": { input: 0.57, output: 2.3, cacheRead: 0, cacheWrite: 0 }, - "openrouter:moonshotai/kimi-k2-0905": { input: 0.6, output: 2.5, cacheRead: 0, cacheWrite: 0 }, - "openrouter:moonshotai/kimi-k2-thinking": { input: 0.6, output: 2.5, cacheRead: 0.15, cacheWrite: 0 }, - "openrouter:moonshotai/kimi-k2.5": { input: 0.41, output: 2.06, cacheRead: 0.07, cacheWrite: 0 }, - "openrouter:moonshotai/kimi-k2.6": { input: 0.65, output: 3.41, cacheRead: 0.15, cacheWrite: 0 }, - "openrouter:moonshotai/kimi-k2.7-code": { input: 0.6712, output: 3.35, cacheRead: 0.18, cacheWrite: 0 }, - "openrouter:moonshotai/kimi-k3": { input: 0.239, output: 11.936, cacheRead: 0.21, cacheWrite: 0 }, - "openrouter:moonshotai/kimi-k3:batch": { input: 2.28, output: 11.4, cacheRead: 0.228, cacheWrite: 0 }, - "openrouter:nex-agi/nex-n2.5-pro": { input: 0.075, output: 0.25, cacheRead: 0.015, cacheWrite: 0 }, - "openrouter:nvidia/nemotron-3-nano-30b-a3b": { input: 0.06, output: 0.24, cacheRead: 0, cacheWrite: 0 }, - "openrouter:nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:nvidia/nemotron-3-super-120b-a12b": { input: 0.3, output: 2.4, cacheRead: 0.15, cacheWrite: 0 }, - "openrouter:nvidia/nemotron-3-super-120b-a12b:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:nvidia/nemotron-3-ultra-550b-a55b": { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0 }, - "openrouter:nvidia/nemotron-3-ultra-550b-a55b:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:nvidia/nemotron-3.5-lightning": { input: 0.07, output: 0.2, cacheRead: 0.04, cacheWrite: 0 }, - "openrouter:nvidia/nemotron-3.5-lightning:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:nvidia/nemotron-3.5-super-vl": { input: 0.15, output: 0.5, cacheRead: 0.02, cacheWrite: 0 }, - "openrouter:nvidia/nemotron-3.5-super-vl:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-3.5-turbo": { input: 0.5, output: 1.5, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-3.5-turbo-0613": { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-3.5-turbo-16k": { input: 3, output: 4, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-3.5-turbo:batch": { input: 0.25, output: 0.75, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-4": { input: 30, output: 60, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-4-turbo": { input: 10, output: 30, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-4-turbo:batch": { input: 5, output: 15, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-4.1": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, - "openrouter:openai/gpt-4.1-mini": { input: 0.4, output: 1.6, cacheRead: 0.1, cacheWrite: 0 }, - "openrouter:openai/gpt-4.1-mini:batch": { input: 0.2, output: 0.8, cacheRead: 0.05, cacheWrite: 0 }, - "openrouter:openai/gpt-4.1-nano": { input: 0.1, output: 0.4, cacheRead: 0.025, cacheWrite: 0 }, - "openrouter:openai/gpt-4.1-nano:batch": { input: 0.05, output: 0.2, cacheRead: 0.0125, cacheWrite: 0 }, - "openrouter:openai/gpt-4.1:batch": { input: 1, output: 4, cacheRead: 0.25, cacheWrite: 0 }, - "openrouter:openai/gpt-4o": { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 0 }, - "openrouter:openai/gpt-4o-2024-05-13": { input: 5, output: 15, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-4o-2024-08-06": { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 0 }, - "openrouter:openai/gpt-4o-2024-11-20": { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 0 }, - "openrouter:openai/gpt-4o-mini": { input: 0.15, output: 0.6, cacheRead: 0.075, cacheWrite: 0 }, - "openrouter:openai/gpt-4o-mini-2024-07-18": { input: 0.15, output: 0.6, cacheRead: 0.075, cacheWrite: 0 }, - "openrouter:openai/gpt-4o-mini:batch": { input: 0.075, output: 0.3, cacheRead: 0.0375, cacheWrite: 0 }, - "openrouter:openai/gpt-4o:batch": { input: 1.25, output: 5, cacheRead: 0.625, cacheWrite: 0 }, - "openrouter:openai/gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "openrouter:openai/gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 }, - "openrouter:openai/gpt-5-mini:batch": { input: 0.125, output: 1, cacheRead: 0.0125, cacheWrite: 0 }, - "openrouter:openai/gpt-5-nano": { input: 0.05, output: 0.4, cacheRead: 0.005, cacheWrite: 0 }, - "openrouter:openai/gpt-5-nano:batch": { input: 0.025, output: 0.2, cacheRead: 0.0025, cacheWrite: 0 }, - "openrouter:openai/gpt-5-pro": { input: 15, output: 120, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-5-pro:batch": { input: 7.5, output: 60, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-5:batch": { input: 0.625, output: 5, cacheRead: 0.0625, cacheWrite: 0 }, - "openrouter:openai/gpt-5.1": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "openrouter:openai/gpt-5.1-codex": { input: 1.25, output: 10, cacheRead: 0.13, cacheWrite: 0 }, - "openrouter:openai/gpt-5.1-codex-max": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "openrouter:openai/gpt-5.1-codex-mini": { input: 0.25, output: 2, cacheRead: 0.03, cacheWrite: 0 }, - "openrouter:openai/gpt-5.1:batch": { input: 0.625, output: 5, cacheRead: 0.0625, cacheWrite: 0 }, - "openrouter:openai/gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "openrouter:openai/gpt-5.2-chat": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "openrouter:openai/gpt-5.2-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "openrouter:openai/gpt-5.2-pro": { input: 21, output: 168, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-5.2-pro:batch": { input: 10.5, output: 84, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-5.2:batch": { input: 0.875, output: 7, cacheRead: 0.0875, cacheWrite: 0 }, - "openrouter:openai/gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "openrouter:openai/gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, - "openrouter:openai/gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - "openrouter:openai/gpt-5.4-mini:batch": { input: 0.375, output: 2.25, cacheRead: 0.0375, cacheWrite: 0 }, - "openrouter:openai/gpt-5.4-nano": { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 }, - "openrouter:openai/gpt-5.4-nano:batch": { input: 0.1, output: 0.625, cacheRead: 0.01, cacheWrite: 0 }, - "openrouter:openai/gpt-5.4-pro": { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-5.4-pro:batch": { input: 15, output: 90, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-5.4:batch": { input: 1.25, output: 7.5, cacheRead: 0.125, cacheWrite: 0 }, - "openrouter:openai/gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "openrouter:openai/gpt-5.5-pro": { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-5.5-pro:batch": { input: 15, output: 90, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-5.5:batch": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, - "openrouter:openai/gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "openrouter:openai/gpt-5.6-luna-pro": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "openrouter:openai/gpt-5.6-luna-pro:batch": { input: 0.1, output: 0.6, cacheRead: 0.01, cacheWrite: 0 }, - "openrouter:openai/gpt-5.6-luna:batch": { input: 0.1, output: 0.6, cacheRead: 0.01, cacheWrite: 0 }, - "openrouter:openai/gpt-5.6-sol": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "openrouter:openai/gpt-5.6-sol-pro": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "openrouter:openai/gpt-5.6-sol-pro:batch": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "openrouter:openai/gpt-5.6-sol:batch": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "openrouter:openai/gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, - "openrouter:openai/gpt-5.6-terra-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, - "openrouter:openai/gpt-5.6-terra-pro:batch": { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 0 }, - "openrouter:openai/gpt-5.6-terra:batch": { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 0 }, - "openrouter:openai/gpt-6-astra": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "openrouter:openai/gpt-6-astra-pro": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "openrouter:openai/gpt-6-astra-pro:batch": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "openrouter:openai/gpt-6-astra:batch": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "openrouter:openai/gpt-6-luna": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "openrouter:openai/gpt-6-luna-pro": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "openrouter:openai/gpt-6-luna-pro:batch": { input: 0.05, output: 0.25, cacheRead: 0.005, cacheWrite: 0.0625 }, - "openrouter:openai/gpt-6-luna:batch": { input: 0.05, output: 0.25, cacheRead: 0.005, cacheWrite: 0.0625 }, - "openrouter:openai/gpt-6-sol": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "openrouter:openai/gpt-6-sol-pro": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "openrouter:openai/gpt-6-sol-pro:batch": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "openrouter:openai/gpt-6-sol:batch": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "openrouter:openai/gpt-6.1-sol": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "openrouter:openai/gpt-6.1-sol-pro": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "openrouter:openai/gpt-6.1-sol-pro:batch": { input: 1, output: 5, cacheRead: 0.05, cacheWrite: 1.25 }, - "openrouter:openai/gpt-6.1-sol:batch": { input: 1, output: 5, cacheRead: 0.05, cacheWrite: 1.25 }, - "openrouter:openai/gpt-audio": { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-audio-mini": { input: 0.6, output: 2.4, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-chat-latest": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "openrouter:openai/gpt-oss-120b": { input: 0.15, output: 0.6, cacheRead: 0.075, cacheWrite: 0 }, - "openrouter:openai/gpt-oss-120b:batch": { input: 0.0296, output: 0.136, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-oss-20b": { input: 0.018, output: 0.09, cacheRead: 0.009, cacheWrite: 0 }, - "openrouter:openai/gpt-oss-20b:batch": { input: 0.024, output: 0.112, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/gpt-oss-safeguard-20b": { input: 0.075, output: 0.3, cacheRead: 0.0375, cacheWrite: 0 }, - "openrouter:openai/o1": { input: 15, output: 60, cacheRead: 7.5, cacheWrite: 0 }, - "openrouter:openai/o3": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, - "openrouter:openai/o3-mini": { input: 1.1, output: 4.4, cacheRead: 0.55, cacheWrite: 0 }, - "openrouter:openai/o3-mini-high": { input: 1.1, output: 4.4, cacheRead: 0.55, cacheWrite: 0 }, - "openrouter:openai/o3-mini:batch": { input: 0.55, output: 2.2, cacheRead: 0.275, cacheWrite: 0 }, - "openrouter:openai/o3-pro": { input: 20, output: 80, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openai/o3:batch": { input: 1, output: 4, cacheRead: 0.25, cacheWrite: 0 }, - "openrouter:openai/o4-mini": { input: 1.1, output: 4.4, cacheRead: 0.275, cacheWrite: 0 }, - "openrouter:openai/o4-mini-high": { input: 1.1, output: 4.4, cacheRead: 0.275, cacheWrite: 0 }, - "openrouter:openai/o4-mini:batch": { input: 0.55, output: 2.2, cacheRead: 0.1375, cacheWrite: 0 }, - "openrouter:openrouter/auto": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openrouter/auto-beta": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openrouter/free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openrouter/fusion": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:openrouter/pareto-code": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:perceptron/perceptron-mk1.5": { input: 0.15, output: 1.5, cacheRead: 0, cacheWrite: 0 }, - "openrouter:poolside/laguna-s-2.1": { input: 0.09, output: 0.18, cacheRead: 0.009, cacheWrite: 0 }, - "openrouter:poolside/laguna-s-2.1:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:poolside/laguna-xs-2.1": { input: 0.06, output: 0.12, cacheRead: 0.03, cacheWrite: 0 }, - "openrouter:poolside/laguna-xs-2.1:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:prism-ml/ternary-bonsai-2-27b": { input: 0.075, output: 0.5, cacheRead: 0.0375, cacheWrite: 0 }, - "openrouter:qwen/qwen-2.5-72b-instruct": { input: 0.36, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen-2.5-7b-instruct": { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen-plus": { input: 0.26, output: 0.78, cacheRead: 0.052, cacheWrite: 0.325 }, - "openrouter:qwen/qwen3-14b": { input: 0.12, output: 0.24, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3-235b-a22b-2507": { input: 0.09, output: 0.55, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3-235b-a22b-thinking-2507": { input: 0.45, output: 3.5, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3-30b-a3b": { input: 0.12, output: 0.5, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3-30b-a3b-instruct-2507": { input: 0.09, output: 0.3, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3-32b": { input: 0.08, output: 0.28, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3-coder": { input: 0.3, output: 1, cacheRead: 0.1, cacheWrite: 0 }, - "openrouter:qwen/qwen3-coder-30b-a3b-instruct": { input: 0.07, output: 0.28, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3-coder-flash": { input: 0.195, output: 0.975, cacheRead: 0.039, cacheWrite: 0.24375 }, - "openrouter:qwen/qwen3-coder-next": { input: 0.12, output: 0.8, cacheRead: 0.07, cacheWrite: 0 }, - "openrouter:qwen/qwen3-next-80b-a3b-instruct": { input: 0.09, output: 1.1, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3-next-80b-a3b-thinking": { input: 0.15, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3-vl-235b-a22b-instruct": { input: 0.21, output: 1.9, cacheRead: 0.1, cacheWrite: 0 }, - "openrouter:qwen/qwen3-vl-30b-a3b-instruct": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3-vl-30b-a3b-thinking": { input: 0.29, output: 1, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3-vl-8b-instruct": { input: 0.25, output: 0.75, cacheRead: 0.12, cacheWrite: 0 }, - "openrouter:qwen/qwen3.5-122b-a10b": { input: 0.26, output: 2.08, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3.5-27b": { input: 0.27, output: 2.16, cacheRead: 0.27, cacheWrite: 0 }, - "openrouter:qwen/qwen3.5-35b-a3b": { input: 0.14, output: 1, cacheRead: 0.05, cacheWrite: 0 }, - "openrouter:qwen/qwen3.5-397b-a17b": { input: 0.39, output: 2.34, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3.5-9b": { input: 0.08, output: 0.13, cacheRead: 0.04, cacheWrite: 0 }, - "openrouter:qwen/qwen3.5-flash-02-23": { input: 0.065, output: 0.26, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3.5-plus-02-15": { input: 0.26, output: 1.56, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3.5-plus-20260420": { input: 0.3, output: 1.8, cacheRead: 0, cacheWrite: 0.375 }, - "openrouter:qwen/qwen3.6-27b": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 }, - "openrouter:qwen/qwen3.6-35b-a3b": { input: 0.1, output: 0.95, cacheRead: 0.05, cacheWrite: 0 }, - "openrouter:qwen/qwen3.6-flash": { input: 0.1875, output: 1.125, cacheRead: 0, cacheWrite: 0.234375 }, - "openrouter:qwen/qwen3.6-plus": { input: 0.325, output: 1.95, cacheRead: 0, cacheWrite: 0.40625 }, - "openrouter:qwen/qwen3.7-flash": { input: 0.03, output: 0.13, cacheRead: 0.006, cacheWrite: 0.038 }, - "openrouter:qwen/qwen3.7-max": { input: 1.475, output: 4.425, cacheRead: 0.295, cacheWrite: 1.84375 }, - "openrouter:qwen/qwen3.7-plus": { input: 0.32, output: 1.28, cacheRead: 0.064, cacheWrite: 0.4 }, - "openrouter:qwen/qwen3.7-plus:batch": { input: 0.23, output: 0.92, cacheRead: 0, cacheWrite: 0 }, - "openrouter:qwen/qwen3.8-2.4t-a95b": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 0 }, - "openrouter:qwen/qwen3.8-27b": { input: 0.04, output: 1.35, cacheRead: 0.018, cacheWrite: 0 }, - "openrouter:qwen/qwen3.8-flash": { input: 0.15, output: 0.47, cacheRead: 0.016, cacheWrite: 0 }, - "openrouter:qwen/qwen3.8-max-0902": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 0 }, - "openrouter:qwen/qwen3.8-max-prime": { input: 4, output: 12, cacheRead: 0.5, cacheWrite: 0 }, - "openrouter:qwen/qwen3.8-omni-flash": { input: 0.15, output: 0.47, cacheRead: 0.016, cacheWrite: 0 }, - "openrouter:recursive/flint-v1.0:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:reflection/beam-1.0-501b-a23b": { input: 0.3, output: 1, cacheRead: 0.04, cacheWrite: 0 }, - "openrouter:rekaai/reka-edge": { input: 0.1, output: 0.1, cacheRead: 0, cacheWrite: 0 }, - "openrouter:relace/relace-search": { input: 1, output: 3, cacheRead: 0, cacheWrite: 0 }, - "openrouter:sakana/fugu-max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 0 }, - "openrouter:sakana/fugu-ultra": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "openrouter:sakana/fugu-ultra-v2": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "openrouter:sakana/sakana-namazu": { input: 0.95, output: 4, cacheRead: 0.15, cacheWrite: 0 }, - "openrouter:stepfun/step-3.5-flash": { input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0 }, - "openrouter:stepfun/step-3.7-flash": { input: 0.2, output: 1.15, cacheRead: 0.04, cacheWrite: 0 }, - "openrouter:stepfun/step-5-preview": { input: 1, output: 2.7, cacheRead: 0.1, cacheWrite: 0 }, - "openrouter:tencent/hy3": { input: 0.0825, output: 0.33, cacheRead: 0.020625, cacheWrite: 0 }, - "openrouter:tencent/hy3-preview": { input: 0.18, output: 0.6, cacheRead: 0.06, cacheWrite: 0 }, - "openrouter:tencent/hy4-preview": { input: 0.7506, output: 2.2509, cacheRead: 0.0378, cacheWrite: 0 }, - "openrouter:thinkingmachines/inkling": { input: 1, output: 4.05, cacheRead: 0.17, cacheWrite: 0 }, - "openrouter:thinkingmachines/inkling-small": { input: 0.45, output: 1.2, cacheRead: 0.1, cacheWrite: 0 }, - "openrouter:thinkingmachines/inkling-small:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:thinkingmachines/inkling:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:typesafe/jev-router": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:unbiased/pareto-26.10": { input: 0.8, output: 3.2, cacheRead: 0.03, cacheWrite: 0 }, - "openrouter:unbiased/pareto-26.9": { input: 2.5, output: 7.5, cacheRead: 0.25, cacheWrite: 0 }, - "openrouter:upstage/solar-mini4": { input: 0.05, output: 0.2, cacheRead: 0.005, cacheWrite: 0 }, - "openrouter:upstage/solar-pro-3": { input: 0.15, output: 0.6, cacheRead: 0.015, cacheWrite: 0 }, - "openrouter:upstage/solar-pro4": { input: 0.15, output: 0.6, cacheRead: 0.03, cacheWrite: 0 }, - "openrouter:x-ai/grok-4.20": { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, - "openrouter:x-ai/grok-4.3": { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, - "openrouter:x-ai/grok-4.3:batch": { input: 1, output: 2, cacheRead: 0.16, cacheWrite: 0 }, - "openrouter:x-ai/grok-4.5": { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, - "openrouter:x-ai/grok-4.6": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "openrouter:x-ai/grok-4.7": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "openrouter:x-ai/grok-build-0.1": { input: 1, output: 2, cacheRead: 0.2, cacheWrite: 0 }, - "openrouter:xiaomi/mimo-v2.5": { input: 0.119, output: 0.238, cacheRead: 0.00255, cacheWrite: 0 }, - "openrouter:xiaomi/mimo-v2.5-pro": { input: 0.3045, output: 0.609, cacheRead: 0.0028, cacheWrite: 0 }, - "openrouter:xiaomi/mimo-v2.6-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, - "openrouter:xiaomi/mimo-v2.6-pro": { input: 0.435, output: 0.87, cacheRead: 0.0036, cacheWrite: 0 }, - "openrouter:xiaomi/mimo-v2.6-pro-ultraspeed": { input: 4.35, output: 8.7, cacheRead: 0.036, cacheWrite: 0 }, - "openrouter:xingchen-agi/xing4.0-29b-a4b:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "openrouter:z-ai/glm-4.5": { input: 0.6, output: 2.2, cacheRead: 0.11, cacheWrite: 0 }, - "openrouter:z-ai/glm-4.5-air": { input: 0.13, output: 0.85, cacheRead: 0.025, cacheWrite: 0 }, - "openrouter:z-ai/glm-4.5v": { input: 0.6, output: 1.8, cacheRead: 0.11, cacheWrite: 0 }, - "openrouter:z-ai/glm-4.6": { input: 0.55, output: 2.2, cacheRead: 0.11, cacheWrite: 0 }, - "openrouter:z-ai/glm-4.6v": { input: 0.3, output: 0.9, cacheRead: 0.055, cacheWrite: 0 }, - "openrouter:z-ai/glm-4.7": { input: 0.54, output: 1.98, cacheRead: 0.099, cacheWrite: 0 }, - "openrouter:z-ai/glm-4.7-flash": { input: 0.0605, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "openrouter:z-ai/glm-5": { input: 0.6, output: 1.9, cacheRead: 0.119, cacheWrite: 0 }, - "openrouter:z-ai/glm-5-turbo": { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 }, - "openrouter:z-ai/glm-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "openrouter:z-ai/glm-5.2": { input: 0.0341, output: 7, cacheRead: 0.033, cacheWrite: 0 }, - "openrouter:z-ai/glm-5.3": { input: 0.079, output: 1.4, cacheRead: 0.078, cacheWrite: 0 }, - "openrouter:z-ai/glm-5.3-flash": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, - "openrouter:z-ai/glm-5.3-flash:batch": { input: 0.05, output: 0.18, cacheRead: 0.01, cacheWrite: 0 }, - "openrouter:z-ai/glm-5.3-flashx": { input: 0.37, output: 1.25, cacheRead: 0.09, cacheWrite: 0 }, - "openrouter:z-ai/glm-5.3-prime": { input: 2.8, output: 8.8, cacheRead: 0.56, cacheWrite: 0 }, - "openrouter:z-ai/glm-5.3:batch": { input: 0.45, output: 2, cacheRead: 0.1, cacheWrite: 0 }, - "openrouter:z-ai/glm-5v-turbo": { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 }, - "qwen-token-plan-cn:deepseek-v3.2": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:deepseek-v4-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:deepseek-v4-flash-0731": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:deepseek-v4-pro": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:deepseek-v4-pro-0813": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:deepseek-v4.1-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:deepseek-v4.1-flash-1017": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:glm-5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:glm-5.1": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:glm-5.2": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:glm-5.3": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:kimi-k2.5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:kimi-k2.6": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:kimi-k2.7-code": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:MiniMax-M2.5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:qwen3.6-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:qwen3.6-plus": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:qwen3.7-max": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:qwen3.7-plus": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:qwen3.8-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-cn:qwen3.8-max": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-individual:deepseek-v4-flash-0731": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-individual:deepseek-v4-pro": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-individual:deepseek-v4-pro-0813": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-individual:glm-5.2": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-individual:qwen3.6-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-individual:qwen3.7-max": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-individual:qwen3.7-plus": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-individual:qwen3.8-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan-individual:qwen3.8-max": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:deepseek-v3.2": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:deepseek-v4-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:deepseek-v4-flash-0731": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:deepseek-v4-pro": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:deepseek-v4-pro-0813": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:deepseek-v4.1-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:deepseek-v4.1-flash-1017": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:glm-5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:glm-5.1": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:glm-5.2": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:glm-5.3": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:kimi-k2.5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:kimi-k2.6": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:kimi-k2.7-code": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:MiniMax-M2.5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:qwen3.6-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:qwen3.6-plus": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:qwen3.7-max": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:qwen3.7-plus": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:qwen3.8-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "qwen-token-plan:qwen3.8-max": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "radius:balanced": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "radius:cheap": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, - "radius:claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "radius:claude-fable-5-1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }, - "radius:claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "radius:claude-haiku-5-5": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "radius:claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "radius:claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "radius:claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "radius:claude-opus-5-5": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }, - "radius:claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "radius:claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "radius:claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "radius:deepseek-v4.1-flash": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, - "radius:glm-5.3": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "radius:glm-5.3-flash": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, - "radius:gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "radius:gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, - "radius:gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - "radius:gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "radius:gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "radius:gpt-5.6-sol": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "radius:gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, - "radius:gpt-6-astra": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "radius:gpt-6-luna": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "radius:gpt-6-sol": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "radius:gpt-6.1-sol": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "radius:grok-4.7": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "radius:kimi-k3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "radius:minimax-m3.1-flash": { input: 0.15, output: 0.6, cacheRead: 0.015, cacheWrite: 0 }, - "radius:precise": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "radius:recursive/flint": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "together:deepseek-ai/DeepSeek-V4-Pro-0813": { input: 1.32, output: 3.96, cacheRead: 0.13, cacheWrite: 0 }, - "together:deepseek-ai/DeepSeek-V4.1-Flash": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, - "together:meta-models/Muse-Spark-Open-816B-A42B": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, - "together:MiniMaxAI/MiniMax-M3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "together:moonshotai/Kimi-K3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "together:nvidia/NVIDIA-Nemotron-3.5-Super-VL": { input: 0.15, output: 0.5, cacheRead: 0.02, cacheWrite: 0 }, - "together:openai/gpt-oss-120b": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, - "together:Qwen/Qwen3.5-9B": { input: 0.17, output: 0.25, cacheRead: 0, cacheWrite: 0 }, - "together:Qwen/Qwen3.6-Plus": { input: 0.5, output: 3, cacheRead: 0, cacheWrite: 0 }, - "together:Qwen/Qwen3.7-Max": { input: 2.5, output: 7.5, cacheRead: 0.5, cacheWrite: 0 }, - "together:reflection/Beam-1.0-501B-A23B": { input: 0.4, output: 1.2, cacheRead: 0.04, cacheWrite: 0 }, - "together:thinkingmachines/Inkling": { input: 1, output: 4.05, cacheRead: 0.17, cacheWrite: 0 }, - "together:zai-org/GLM-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "together:zai-org/GLM-5.3": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "together:zai-org/GLM-5.3-Flash": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen-3-14b": { input: 0.12, output: 0.24, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen-3-235b": { input: 0.22, output: 0.88, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen-3-30b": { input: 0.12, output: 0.5, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen-3-32b": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen3-coder": { input: 0.22, output: 1.8, cacheRead: 0.022, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen3-coder-30b-a3b": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen3-coder-next": { input: 0.5, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen3-max-thinking": { input: 1.2, output: 6, cacheRead: 0.24, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen3-next-80b-a3b-instruct": { input: 0.15, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen3-next-80b-a3b-thinking": { input: 0.15, output: 1.2, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen3-vl-instruct": { input: 0.2, output: 0.88, cacheRead: 0.11, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen3.5-flash": { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0.125 }, - "vercel-ai-gateway:alibaba/qwen3.5-plus": { input: 0.4, output: 2.4, cacheRead: 0.04, cacheWrite: 0.5 }, - "vercel-ai-gateway:alibaba/qwen3.6-27b": { input: 0.6, output: 3.6, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen3.6-plus": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0.625 }, - "vercel-ai-gateway:alibaba/qwen3.7-flash": { input: 0.03, output: 0.13, cacheRead: 0.006, cacheWrite: 0.038 }, - "vercel-ai-gateway:alibaba/qwen3.7-max": { input: 2.5, output: 7.5, cacheRead: 0.5, cacheWrite: 3.125 }, - "vercel-ai-gateway:alibaba/qwen3.7-plus": { input: 0.4, output: 1.6, cacheRead: 0.08, cacheWrite: 0.5 }, - "vercel-ai-gateway:alibaba/qwen3.8-2.4t-a95b": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen3.8-27b": { input: 0.5, output: 3, cacheRead: 0.1, cacheWrite: 0.625 }, - "vercel-ai-gateway:alibaba/qwen3.8-flash": { input: 0.15, output: 0.47, cacheRead: 0.016, cacheWrite: 0.2 }, - "vercel-ai-gateway:alibaba/qwen3.8-max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 0 }, - "vercel-ai-gateway:alibaba/qwen3.8-max-0902": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 2.5 }, - "vercel-ai-gateway:alibaba/qwen3.8-max-prime": { input: 4, output: 12, cacheRead: 0.5, cacheWrite: 5 }, - "vercel-ai-gateway:alibaba/qwen3.8-omni-flash": { input: 0.15, output: 0.47, cacheRead: 0.016, cacheWrite: 0 }, - "vercel-ai-gateway:amazon/nova-2-lite": { input: 0.3, output: 2.5, cacheRead: 0.075, cacheWrite: 0 }, - "vercel-ai-gateway:amazon/nova-lite": { input: 0.06, output: 0.24, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:amazon/nova-micro": { input: 0.035, output: 0.14, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:amazon/nova-pro": { input: 0.8, output: 3.2, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:anthropic/claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "vercel-ai-gateway:anthropic/claude-fable-5-batch": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "vercel-ai-gateway:anthropic/claude-fable-5.1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }, - "vercel-ai-gateway:anthropic/claude-fable-5.1-batch": { input: 5, output: 25, cacheRead: 0.125, cacheWrite: 6.25 }, - "vercel-ai-gateway:anthropic/claude-haiku-4.5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "vercel-ai-gateway:anthropic/claude-haiku-4.5-batch": { input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite: 0.625 }, - "vercel-ai-gateway:anthropic/claude-haiku-5.5": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "vercel-ai-gateway:anthropic/claude-haiku-5.5-batch": { input: 0.05, output: 0.25, cacheRead: 0.005, cacheWrite: 0.0625 }, - "vercel-ai-gateway:anthropic/claude-opus-4.5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "vercel-ai-gateway:anthropic/claude-opus-4.5-batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 }, - "vercel-ai-gateway:anthropic/claude-opus-4.6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "vercel-ai-gateway:anthropic/claude-opus-4.6-batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 }, - "vercel-ai-gateway:anthropic/claude-opus-4.7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "vercel-ai-gateway:anthropic/claude-opus-4.7-batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 }, - "vercel-ai-gateway:anthropic/claude-opus-4.8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "vercel-ai-gateway:anthropic/claude-opus-4.8-batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 }, - "vercel-ai-gateway:anthropic/claude-opus-4.8-fast": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "vercel-ai-gateway:anthropic/claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "vercel-ai-gateway:anthropic/claude-opus-5-batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 }, - "vercel-ai-gateway:anthropic/claude-opus-5-fast": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "vercel-ai-gateway:anthropic/claude-opus-5.5": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }, - "vercel-ai-gateway:anthropic/claude-opus-5.5-batch": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "vercel-ai-gateway:anthropic/claude-opus-5.5-fast": { input: 8, output: 40, cacheRead: 0.4, cacheWrite: 10 }, - "vercel-ai-gateway:anthropic/claude-sonnet-4.5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "vercel-ai-gateway:anthropic/claude-sonnet-4.5-batch": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 1.875 }, - "vercel-ai-gateway:anthropic/claude-sonnet-4.6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "vercel-ai-gateway:anthropic/claude-sonnet-4.6-batch": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 1.875 }, - "vercel-ai-gateway:anthropic/claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "vercel-ai-gateway:anthropic/claude-sonnet-5-batch": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "vercel-ai-gateway:anthropic/claude-sonnet-5.5": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "vercel-ai-gateway:anthropic/claude-sonnet-5.5-batch": { input: 1, output: 5, cacheRead: 0.05, cacheWrite: 1.25 }, - "vercel-ai-gateway:arcee-ai/trinity-large-thinking": { input: 0.25, output: 0.8, cacheRead: 0.06, cacheWrite: 0 }, - "vercel-ai-gateway:bytedance/seed-1.6": { input: 0.25, output: 2, cacheRead: 0.05, cacheWrite: 0 }, - "vercel-ai-gateway:bytedance/seed-1.8": { input: 0.25, output: 2, cacheRead: 0.05, cacheWrite: 0 }, - "vercel-ai-gateway:bytedance/seed-2.1-turbo": { input: 0.5, output: 2.5, cacheRead: 0.1, cacheWrite: 0 }, - "vercel-ai-gateway:callstack/apex": { input: 0.5, output: 3, cacheRead: 0.2, cacheWrite: 0 }, - "vercel-ai-gateway:cohere/command-a": { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:deepseek/deepseek-r1": { input: 1.35, output: 5.4, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:deepseek/deepseek-v3.1": { input: 0.25, output: 0.95, cacheRead: 0.13, cacheWrite: 0 }, - "vercel-ai-gateway:deepseek/deepseek-v3.2": { input: 0.62, output: 1.85, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:deepseek/deepseek-v3.2-thinking": { input: 0.62, output: 1.85, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:deepseek/deepseek-v4-flash": { input: 0.13, output: 0.26, cacheRead: 0.028, cacheWrite: 0 }, - "vercel-ai-gateway:deepseek/deepseek-v4-flash-0731": { input: 0.076, output: 0.153, cacheRead: 0.014, cacheWrite: 0 }, - "vercel-ai-gateway:deepseek/deepseek-v4-flash-vision-exp": { input: 0.2156, output: 0.6468, cacheRead: 0.0068, cacheWrite: 0 }, - "vercel-ai-gateway:deepseek/deepseek-v4-pro": { input: 0.66, output: 1.98, cacheRead: 0.022, cacheWrite: 0 }, - "vercel-ai-gateway:deepseek/deepseek-v4-pro-0813": { input: 0.66, output: 1.98, cacheRead: 0.066, cacheWrite: 0 }, - "vercel-ai-gateway:deepseek/deepseek-v4.1-flash": { input: 0.075, output: 0.3, cacheRead: 0.0015, cacheWrite: 0 }, - "vercel-ai-gateway:deepseek/deepseek-v4.1-flash-1017": { input: 0.075, output: 0.3, cacheRead: 0.0015, cacheWrite: 0 }, - "vercel-ai-gateway:deepseek/deepseek-v4.1-flash-1017-fast": { input: 0.6, output: 2.4, cacheRead: 0.014, cacheWrite: 0 }, - "vercel-ai-gateway:deepseek/deepseek-v4.1-flash-fast": { input: 0.6, output: 2.4, cacheRead: 0.014, cacheWrite: 0 }, - "vercel-ai-gateway:fireworks/ember-1": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-2.5-flash": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-2.5-flash-batch": { input: 0.15, output: 1.25, cacheRead: 0.03, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-2.5-flash-lite": { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-2.5-flash-lite-batch": { input: 0.05, output: 0.2, cacheRead: 0.01, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-2.5-pro": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-2.5-pro-batch": { input: 0.625, output: 5, cacheRead: 0.125, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3-flash-batch": { input: 0.25, output: 1.5, cacheRead: 0.05, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3.1-flash-lite": { input: 0.25, output: 1.5, cacheRead: 0.03, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3.1-flash-lite-batch": { input: 0.125, output: 0.75, cacheRead: 0.015, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3.1-pro-preview": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3.1-pro-preview-batch": { input: 1, output: 6, cacheRead: 0.2, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3.5-flash-batch": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3.5-flash-lite": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3.5-flash-lite-batch": { input: 0.15, output: 1.25, cacheRead: 0.015, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3.6-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3.6-flash-batch": { input: 0.375, output: 1.875, cacheRead: 0.0375, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3.7-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3.7-flash-batch": { input: 0.375, output: 1.875, cacheRead: 0.0375, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-3.8-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemini-4-argon": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemma-4-26b-a4b-it": { input: 0.15, output: 0.6, cacheRead: 0.015, cacheWrite: 0 }, - "vercel-ai-gateway:google/gemma-4-31b-it": { input: 0.14, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:inception/mercury-2": { input: 0.25, output: 0.75, cacheRead: 0.025, cacheWrite: 0 }, - "vercel-ai-gateway:inception/mercury-2.5": { input: 0.04, output: 0.15, cacheRead: 0.004, cacheWrite: 0 }, - "vercel-ai-gateway:inception/mercury-3": { input: 0.1, output: 0.35, cacheRead: 0.01, cacheWrite: 0 }, - "vercel-ai-gateway:inception/mercury-coder-small": { input: 0.25, output: 1, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:inclusionai/ling-3.0-flash": { input: 0.021, output: 0.063, cacheRead: 0.0042, cacheWrite: 0 }, - "vercel-ai-gateway:inclusionai/ling-3.0-flash-fin": { input: 0.075, output: 0.22, cacheRead: 0.015, cacheWrite: 0 }, - "vercel-ai-gateway:inclusionai/ling-3.0-flash-sante": { input: 0.075, output: 0.22, cacheRead: 0.015, cacheWrite: 0 }, - "vercel-ai-gateway:inclusionai/ling-3.0-flash-vl": { input: 0.075, output: 0.22, cacheRead: 0.015, cacheWrite: 0 }, - "vercel-ai-gateway:inclusionai/ling-3.1-flash": { input: 0.3, output: 0.9, cacheRead: 0.06, cacheWrite: 0 }, - "vercel-ai-gateway:interfaze/interfaze-beta": { input: 1.5, output: 3.5, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:liquid/lfm2.5-24b-a2b": { input: 0.05, output: 0.7, cacheRead: 0.025, cacheWrite: 0 }, - "vercel-ai-gateway:meituan/longcat-2.5-preview": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, - "vercel-ai-gateway:meta/llama-3.1-70b": { input: 0.72, output: 0.72, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:meta/llama-3.1-8b": { input: 0.22, output: 0.22, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:meta/llama-3.3-70b": { input: 0.72, output: 0.72, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:meta/llama-4-maverick": { input: 0.24, output: 0.97, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:meta/llama-4-scout": { input: 0.17, output: 0.66, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:meta/muse-glimmer-30b": { input: 0.35, output: 1.5, cacheRead: 0.04, cacheWrite: 0 }, - "vercel-ai-gateway:meta/muse-spark-1.1": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, - "vercel-ai-gateway:meta/muse-spark-1.2": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, - "vercel-ai-gateway:meta/muse-spark-1.2-contributor": { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 }, - "vercel-ai-gateway:meta/muse-spark-1.3": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, - "vercel-ai-gateway:meta/muse-spark-1.3-contributor": { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 }, - "vercel-ai-gateway:meta/muse-spark-open": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, - "vercel-ai-gateway:meta/muse-wonder-1.0": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 0 }, - "vercel-ai-gateway:minimax/minimax-m2": { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.375 }, - "vercel-ai-gateway:minimax/minimax-m2.1": { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.375 }, - "vercel-ai-gateway:minimax/minimax-m2.1-lightning": { input: 0.3, output: 2.4, cacheRead: 0.03, cacheWrite: 0.375 }, - "vercel-ai-gateway:minimax/minimax-m2.5": { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.375 }, - "vercel-ai-gateway:minimax/minimax-m2.5-highspeed": { input: 0.6, output: 2.4, cacheRead: 0.03, cacheWrite: 0.375 }, - "vercel-ai-gateway:minimax/minimax-m2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 }, - "vercel-ai-gateway:minimax/minimax-m2.7-highspeed": { input: 0.6, output: 2.4, cacheRead: 0.06, cacheWrite: 0.375 }, - "vercel-ai-gateway:minimax/minimax-m3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "vercel-ai-gateway:minimax/minimax-m3.1-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:mistral/codestral": { input: 0.3, output: 0.9, cacheRead: 0.03, cacheWrite: 0 }, - "vercel-ai-gateway:mistral/ministral-14b": { input: 0.2, output: 0.2, cacheRead: 0.02, cacheWrite: 0 }, - "vercel-ai-gateway:mistral/ministral-3b": { input: 0.1, output: 0.1, cacheRead: 0.01, cacheWrite: 0 }, - "vercel-ai-gateway:mistral/ministral-8b": { input: 0.15, output: 0.15, cacheRead: 0.015, cacheWrite: 0 }, - "vercel-ai-gateway:mistral/mistral-large-3": { input: 0.5, output: 1.5, cacheRead: 0.05, cacheWrite: 0 }, - "vercel-ai-gateway:mistral/mistral-large-4": { input: 1.36, output: 4.18, cacheRead: 0.14, cacheWrite: 0 }, - "vercel-ai-gateway:mistral/mistral-medium-3.5": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, - "vercel-ai-gateway:mistral/mistral-nemo": { input: 0.029, output: 0.03, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:mistral/mistral-small": { input: 0.15, output: 0.6, cacheRead: 0.015, cacheWrite: 0 }, - "vercel-ai-gateway:mixedbread/toast-1": { input: 0.3, output: 0.72, cacheRead: 0.036, cacheWrite: 0 }, - "vercel-ai-gateway:moonshotai/kimi-k2": { input: 0.57, output: 2.3, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:moonshotai/kimi-k2.5": { input: 0.6, output: 3, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:moonshotai/kimi-k2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, - "vercel-ai-gateway:moonshotai/kimi-k2.7-code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, - "vercel-ai-gateway:moonshotai/kimi-k2.7-code-highspeed": { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 0 }, - "vercel-ai-gateway:moonshotai/kimi-k3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "vercel-ai-gateway:moonshotai/kimi-k3-fast": { input: 4.5, output: 22.5, cacheRead: 0.45, cacheWrite: 0 }, - "vercel-ai-gateway:nvidia/nemotron-3-nano-30b-a3b": { input: 0.05, output: 0.2, cacheRead: 0.025, cacheWrite: 0 }, - "vercel-ai-gateway:nvidia/nemotron-3-super-120b-a12b": { input: 0.15, output: 0.65, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:nvidia/nemotron-3-ultra-550b-a55b": { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0 }, - "vercel-ai-gateway:nvidia/nemotron-3.5-lightning": { input: 0.05, output: 0.2, cacheRead: 0.01, cacheWrite: 0 }, - "vercel-ai-gateway:nvidia/nemotron-3.5-lightning-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:nvidia/nemotron-3.5-super-vl": { input: 0.2, output: 0.5, cacheRead: 0.04, cacheWrite: 0 }, - "vercel-ai-gateway:nvidia/nemotron-nano-12b-v2-vl": { input: 0.2, output: 0.6, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:nvidia/nemotron-nano-9b-v2": { input: 0.06, output: 0.23, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-3.5-turbo": { input: 0.5, output: 1.5, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4-turbo": { input: 10, output: 30, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4.1": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4.1-batch": { input: 1, output: 4, cacheRead: 0.25, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4.1-fast": { input: 3.5, output: 14, cacheRead: 0.875, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4.1-mini": { input: 0.4, output: 1.6, cacheRead: 0.1, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4.1-mini-batch": { input: 0.2, output: 0.8, cacheRead: 0.05, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4.1-mini-fast": { input: 0.7, output: 2.8, cacheRead: 0.175, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4.1-nano": { input: 0.1, output: 0.4, cacheRead: 0.025, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4.1-nano-batch": { input: 0.05, output: 0.2, cacheRead: 0.0125, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4.1-nano-fast": { input: 0.2, output: 0.8, cacheRead: 0.05, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4o": { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4o-batch": { input: 1.25, output: 5, cacheRead: 0.625, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4o-fast": { input: 4.25, output: 17, cacheRead: 2.125, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4o-mini": { input: 0.15, output: 0.6, cacheRead: 0.075, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4o-mini-batch": { input: 0.075, output: 0.3, cacheRead: 0.0375, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-4o-mini-fast": { input: 0.25, output: 1, cacheRead: 0.125, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5-batch": { input: 0.625, output: 5, cacheRead: 0.0625, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5-codex": { input: 1.25, output: 10, cacheRead: 0.13, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5-fast": { input: 2.5, output: 20, cacheRead: 0.25, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5-mini-batch": { input: 0.125, output: 1, cacheRead: 0.0125, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5-mini-fast": { input: 0.45, output: 3.6, cacheRead: 0.045, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5-nano": { input: 0.05, output: 0.4, cacheRead: 0.005, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5-nano-batch": { input: 0.025, output: 0.2, cacheRead: 0.0025, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5-pro": { input: 15, output: 120, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5-pro-batch": { input: 7.5, output: 60, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.1-codex": { input: 1.25, output: 10, cacheRead: 0.13, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.1-codex-max": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.1-codex-mini": { input: 0.25, output: 2, cacheRead: 0.03, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.1-thinking": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.1-thinking-fast": { input: 2.5, output: 20, cacheRead: 0.25, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.2-batch": { input: 0.875, output: 7, cacheRead: 0.0875, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.2-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.2-fast": { input: 3.5, output: 28, cacheRead: 0.35, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.2-pro": { input: 21, output: 168, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.3-codex-fast": { input: 3.5, output: 28, cacheRead: 0.35, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.4-batch": { input: 1.25, output: 7.5, cacheRead: 0.125, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.4-fast": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.4-mini-batch": { input: 0.375, output: 2.25, cacheRead: 0.0375, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.4-mini-fast": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.4-nano": { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.4-nano-batch": { input: 0.1, output: 0.625, cacheRead: 0.01, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.4-pro": { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.5-batch": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.5-fast": { input: 12.5, output: 75, cacheRead: 1.25, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.5-pro": { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.5-pro-batch": { input: 15, output: 90, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "vercel-ai-gateway:openai/gpt-5.6-luna-batch": { input: 0.1, output: 0.6, cacheRead: 0.01, cacheWrite: 0.125 }, - "vercel-ai-gateway:openai/gpt-5.6-luna-fast": { input: 0.4, output: 2.4, cacheRead: 0.04, cacheWrite: 0.5 }, - "vercel-ai-gateway:openai/gpt-5.6-sol": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "vercel-ai-gateway:openai/gpt-5.6-sol-batch": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "vercel-ai-gateway:openai/gpt-5.6-sol-fast": { input: 8, output: 40, cacheRead: 0.8, cacheWrite: 10 }, - "vercel-ai-gateway:openai/gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, - "vercel-ai-gateway:openai/gpt-5.6-terra-batch": { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25 }, - "vercel-ai-gateway:openai/gpt-5.6-terra-fast": { input: 4, output: 24, cacheRead: 0.4, cacheWrite: 5 }, - "vercel-ai-gateway:openai/gpt-6-astra": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "vercel-ai-gateway:openai/gpt-6-astra-batch": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "vercel-ai-gateway:openai/gpt-6-astra-fast": { input: 20, output: 100, cacheRead: 2, cacheWrite: 25 }, - "vercel-ai-gateway:openai/gpt-6-luna": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }, - "vercel-ai-gateway:openai/gpt-6-luna-batch": { input: 0.05, output: 0.25, cacheRead: 0.005, cacheWrite: 0.0625 }, - "vercel-ai-gateway:openai/gpt-6-luna-fast": { input: 0.2, output: 1, cacheRead: 0.02, cacheWrite: 0.25 }, - "vercel-ai-gateway:openai/gpt-6-sol": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "vercel-ai-gateway:openai/gpt-6-sol-batch": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "vercel-ai-gateway:openai/gpt-6-sol-fast": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "vercel-ai-gateway:openai/gpt-6.1-sol": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 }, - "vercel-ai-gateway:openai/gpt-6.1-sol-batch": { input: 1, output: 5, cacheRead: 0.05, cacheWrite: 1.25 }, - "vercel-ai-gateway:openai/gpt-6.1-sol-fast": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }, - "vercel-ai-gateway:openai/gpt-oss-120b": { input: 0.1, output: 0.5, cacheRead: 0.1, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-oss-20b": { input: 0.03, output: 0.14, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-oss-safeguard-120b": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:openai/gpt-oss-safeguard-20b": { input: 0.07, output: 0.2, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:openai/o1": { input: 15, output: 60, cacheRead: 7.5, cacheWrite: 0 }, - "vercel-ai-gateway:openai/o3": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, - "vercel-ai-gateway:openai/o3-fast": { input: 3.5, output: 14, cacheRead: 0.875, cacheWrite: 0 }, - "vercel-ai-gateway:openai/o3-mini": { input: 1.1, output: 4.4, cacheRead: 0.55, cacheWrite: 0 }, - "vercel-ai-gateway:openai/o3-pro": { input: 20, output: 80, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:openai/o4-mini": { input: 1.1, output: 4.4, cacheRead: 0.275, cacheWrite: 0 }, - "vercel-ai-gateway:openai/o4-mini-fast": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, - "vercel-ai-gateway:poolside/laguna-s-2.1": { input: 0.09, output: 0.18, cacheRead: 0.009, cacheWrite: 0 }, - "vercel-ai-gateway:poolside/laguna-s-2.1-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:quiverai/arrow-2": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }, - "vercel-ai-gateway:quiverai/arrow-2-telos": { input: 6, output: 30, cacheRead: 0.6, cacheWrite: 7.5 }, - "vercel-ai-gateway:reflection/beam": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:reflection/beam-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:sakana/fugu-max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 0 }, - "vercel-ai-gateway:sakana/fugu-ultra": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "vercel-ai-gateway:sakana/fugu-ultra-v2": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "vercel-ai-gateway:sakana/namazu": { input: 0.95, output: 4, cacheRead: 0.15, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.1-fast-non-reasoning": { input: 0.2, output: 0.5, cacheRead: 0.05, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.1-fast-reasoning": { input: 0.2, output: 0.5, cacheRead: 0.05, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.20-multi-agent": { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.20-multi-agent-batch": { input: 1, output: 2, cacheRead: 0.16, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.20-multi-agent-beta": { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.20-non-reasoning": { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.20-non-reasoning-batch": { input: 1, output: 2, cacheRead: 0.16, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.20-non-reasoning-beta": { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.20-reasoning": { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.20-reasoning-batch": { input: 1, output: 2, cacheRead: 0.16, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.20-reasoning-beta": { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.3": { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.3-batch": { input: 1, output: 2, cacheRead: 0.16, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.5": { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.6": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-4.7": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "vercel-ai-gateway:spacexai/grok-build-0.1": { input: 1, output: 2, cacheRead: 0.2, cacheWrite: 0 }, - "vercel-ai-gateway:stealth/glyph-cluster": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:stepfun/step-3.7-flash": { input: 0.2, output: 1.15, cacheRead: 0.04, cacheWrite: 0 }, - "vercel-ai-gateway:stepfun/step-5-preview": { input: 1, output: 2.7, cacheRead: 0.1, cacheWrite: 0 }, - "vercel-ai-gateway:tencent/hy3": { input: 0.14, output: 0.58, cacheRead: 0.035, cacheWrite: 0 }, - "vercel-ai-gateway:tencent/hy4-preview": { input: 0.834, output: 2.501, cacheRead: 0.042, cacheWrite: 0 }, - "vercel-ai-gateway:thinkingmachines/inkling": { input: 1, output: 4.05, cacheRead: 0.17, cacheWrite: 0 }, - "vercel-ai-gateway:thinkingmachines/inkling-small": { input: 0.45, output: 1.2, cacheRead: 0.1, cacheWrite: 0 }, - "vercel-ai-gateway:xiaomi/mimo-v2.5": { input: 0.119, output: 0.238, cacheRead: 0.0025, cacheWrite: 0 }, - "vercel-ai-gateway:xiaomi/mimo-v2.5-pro": { input: 0.3045, output: 0.609, cacheRead: 0.0028, cacheWrite: 0 }, - "vercel-ai-gateway:xiaomi/mimo-v2.6-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, - "vercel-ai-gateway:xiaomi/mimo-v2.6-pro": { input: 0.435, output: 0.87, cacheRead: 0.0036, cacheWrite: 0 }, - "vercel-ai-gateway:xiaomi/mimo-v2.6-pro-ultraspeed": { input: 4.35, output: 8.7, cacheRead: 0.036, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-4.5": { input: 0.6, output: 2.2, cacheRead: 0.11, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-4.5-air": { input: 0.2, output: 1.1, cacheRead: 0.03, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-4.5v": { input: 0.6, output: 1.8, cacheRead: 0.11, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-4.6": { input: 0.6, output: 2.2, cacheRead: 0.11, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-4.7": { input: 0.6, output: 2.2, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-4.7-flash": { input: 0.07, output: 0.4, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-4.7-flashx": { input: 0.06, output: 0.4, cacheRead: 0.01, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-5": { input: 1, output: 3.2, cacheRead: 0, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-5-turbo": { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-5.2": { input: 0.8, output: 2.55, cacheRead: 0.16, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-5.2-fast": { input: 2.8, output: 8.8, cacheRead: 0.56, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-5.3": { input: 1.4, output: 4.4, cacheRead: 0.14, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-5.3-fast": { input: 2.1, output: 6.6, cacheRead: 0.21, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-5.3-flash": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-5.3-flashx": { input: 0.37, output: 1.25, cacheRead: 0.075, cacheWrite: 0 }, - "vercel-ai-gateway:zai/glm-5v-turbo": { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 }, - "xai:grok-4.3": { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, - "xai:grok-4.5": { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, - "xai:grok-4.6": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "xai:grok-4.7": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, - "xiaomi-token-plan-ams:mimo-v2.6-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "xiaomi-token-plan-ams:mimo-v2.6-pro": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "xiaomi-token-plan-cn:mimo-v2.6-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "xiaomi-token-plan-cn:mimo-v2.6-pro": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "xiaomi-token-plan-sgp:mimo-v2.6-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "xiaomi-token-plan-sgp:mimo-v2.6-pro": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "xiaomi:mimo-v2.6-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, - "xiaomi:mimo-v2.6-pro": { input: 0.435, output: 0.87, cacheRead: 0.0036, cacheWrite: 0 }, - "xiaomi:mimo-v2.6-pro-ultraspeed": { input: 4.35, output: 8.7, cacheRead: 0.036, cacheWrite: 0 }, - "zai-coding-cn:glm-4.6v": { input: 0.3, output: 0.9, cacheRead: 0.05, cacheWrite: 0 }, - "zai-coding-cn:glm-5.3": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "zai-coding-cn:glm-5.3-flash": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, - "zai-coding-cn:glm-5.3-highspeed": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "zai:glm-4.7": { input: 0.6, output: 2.2, cacheRead: 0.11, cacheWrite: 0 }, - "zai:glm-5-turbo": { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 }, - "zai:glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "zai:glm-5.2-highspeed": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "zai:glm-5.3": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "zai:glm-5.3-flash": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, - "zai:glm-5.3-highspeed": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, -}; +export function isChatGPTPlan(model: string | undefined): boolean { + return model?.startsWith("chatgpt:") === true; +} -// ─── Pure helpers (folds + cost inference) ─────────────────────────────────── - -/** Sum a list of per-step usages into one CallUsage. A field stays - * undefined when EVERY step left it unreported — absent is not zero. */ +/** Absent stays absent. Long-cache precision is unknown if any write-bearing + * step lacks the split; never silently turn that missing detail into zero. */ export function sumSteps(steps: readonly CallUsage[]): CallUsage { const sumIfAny = (vals: (number | undefined)[]): number | undefined => vals.every((v) => v === undefined) ? undefined : vals.reduce((a, v) => a + (v ?? 0), 0); @@ -1654,121 +25,46 @@ export function sumSteps(steps: readonly CallUsage[]): CallUsage { outputTokens: sumIfAny(steps.map((s) => s.outputTokens)), cacheReadTokens: sumIfAny(steps.map((s) => s.cacheReadTokens)), cacheWriteTokens: sumIfAny(steps.map((s) => s.cacheWriteTokens)), + cacheWrite1hTokens: steps.some((s) => (s.cacheWriteTokens ?? 0) > 0 && s.cacheWrite1hTokens === undefined) + ? undefined : sumIfAny(steps.map((s) => s.cacheWrite1hTokens)), reasoningTokens: sumIfAny(steps.map((s) => s.reasoningTokens)), }; } -/** One model call's inferred cost in USD — null when the model is unknown - * or the usage fields are absent (never guess a price). Cached input bills - * at the cache-read rate; cache writes bill separately where priced. */ -export function isChatGPTPlan(model: string | undefined): boolean { return model?.startsWith("chatgpt:") === true; } - -export function callCost( - usage: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number }, - model: string, -): number | null { - const price = PRICES[model]; - if (!price) return null; - if (usage.inputTokens === undefined || usage.outputTokens === undefined) return null; - const cacheRead = usage.cacheReadTokens ?? 0; - const freshInput = Math.max(0, usage.inputTokens - cacheRead); - const cacheWriteCost = (usage.cacheWriteTokens ?? 0) / 1e6 * (price.cacheWrite ?? 0); - return ( - (freshInput / 1e6) * price.input + - (cacheRead / 1e6) * (price.cacheRead ?? price.input) + - (usage.outputTokens / 1e6) * price.output + - cacheWriteCost - ); -} - -/** One turn's inferred cost — the sum over its steps; null when unpriceable. */ -export function turnCost(turn: TurnUsage): number | null { - if (isChatGPTPlan(turn.model)) return null; +export function turnCost(turn: Pick, price: CallPricer): number | null { + if (isChatGPTPlan(turn.model) || turn.steps.length === 0) return null; let total = 0; for (const step of turn.steps) { - const c = callCost(step, turn.model); - if (c === null) return null; - total += c; + const cost = price(step, turn.model); + if (cost === null) return null; + total += cost; } return total; } -/** Fold a chat record's turn log: total inferred session cost. Unknown-model - * or partial-usage turns don't poison the total — they count as unpriced - * and are reported alongside, so the UI can show "n turns unpriced". */ -export function sessionCost(record: Pick): { cost: number; unpriced: number; subscription?: number } { +export function sessionCost(record: Pick, price: CallPricer): SessionPricing { let cost = 0; let unpriced = 0; let subscription = 0; + const turnCosts: Record = {}; for (const turn of record.turns ?? []) { - if (isChatGPTPlan(turn.model)) { subscription++; continue; } - const c = turnCost(turn); - if (c === null) unpriced++; - else cost += c; + const dollars = turnCost(turn, price); + turnCosts[turn.id] = dollars; + if (isChatGPTPlan(turn.model)) subscription++; + else if (dollars === null) unpriced++; + else cost += dollars; } - return { cost, unpriced, ...(subscription ? { subscription } : {}) }; + return { cost, unpriced, ...(subscription ? { subscription } : {}), turnCosts }; } -/** The session's context-window footprint: the LAST model call's - * token footprint against the model's limit (the next turn re-sends - * the whole conversation — the last call's input IS the running - * transcript size). Null when the record has no usage yet or the - * model's limit is unknown. - * - * NUMERATOR (owner audit, 2026-09-23): cache tokens count. Provider - * `input` EXCLUDES cacheRead/cacheWrite, so folding input+output alone - * reads wildly low on cache-heavy sessions — include all four - * (= pi's own totalTokens semantics; the fields are persisted in every - * CallUsage). - * - * DENOMINATOR (owner ruling, 2026-09-23 — the amendment): the live - * catalog is the ONLY source — `limits`, when given, is the index of - * provider:id → contextWindow straight from pi's catalog (GET - * /api/models serves it per model; the server's catalogContextLimits - * builds the same index). No hardcoded fallback: a model missing from - * the live index (historical session, package update removed it, a - * failed catalog fetch) resolves limit null and the meter renders - * usage-only — the honest-render precedent (never a fake window, - * same shape as unpriced models never showing $0). - * - * Zeroed-step fallback: an aborted/failed last call reports zeroed - * usage — that is NOT the session footprint; fall back to the previous - * step's usage (the honest last-known footprint, never a bogus tiny - * meter). */ -export function contextWindow( - record: Pick, - limits?: ReadonlyMap, -): { model: string; usedTokens: number; limit: number | null } | null { - const turns = record.turns ?? []; - const last = turns[turns.length - 1]; - if (!last) return null; - const limit = limits?.get(last.model) ?? null; - // The last step with a real (non-zero) footprint — zeroed usage (an - // abort artifact) is not a reading; fall back to the previous step. - for (let i = last.steps.length - 1; i >= 0; i--) { - const step = last.steps[i]!; - const footprint = stepFootprint(step); - if (footprint === 0) continue; // zeroed artifact — no reading - return { model: last.model, usedTokens: footprint, limit }; +export function jobCost(job: Pick, price: CallPricer): number | null { + const usage = job.usage; + if (!usage || isChatGPTPlan(usage.model)) return null; + if (usage.calls && usage.calls.length === usage.steps) { + return turnCost({ model: usage.model, steps: usage.calls }, price); } - return null; -} - -/** One call's full context footprint: input + output + both cache - * directions (pi's totalTokens semantics — provider `input` excludes - * the cached portion). */ -function stepFootprint(step: CallUsage): number { - return ( - (step.inputTokens ?? 0) + - (step.outputTokens ?? 0) + - (step.cacheReadTokens ?? 0) + - (step.cacheWriteTokens ?? 0) - ); -} - -/** A job's inferred cost — null until the runner's capture lands / for - * unknown models (render "—", never zero). */ -export function jobCost(job: Pick): number | null { - if (!job.usage) return null; - return callCost(job.usage, job.usage.model); + // An old one-call job is fully reconstructible from its total. For a + // multi-call job, the server resolver decides whether aggregate pricing + // is safe for this model (tiered models need the lost per-call sizes). + return price(usage, usage.model); } diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 78558cd..147953f 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -333,6 +333,7 @@ export async function handleChatInput( outputTokens: usage.output, cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite, + cacheWrite1hTokens: usage.cacheWrite1h ?? 0, // Optional in pi's Usage — persisted as 0, never dropped. reasoningTokens: usage.reasoning ?? 0, }); diff --git a/src/server/compaction.ts b/src/server/compaction.ts index 95aabbd..cc62aa6 100644 --- a/src/server/compaction.ts +++ b/src/server/compaction.ts @@ -175,7 +175,7 @@ async function compact(deps: CompactionDeps, chatId: string): Promise { if (tokensBefore <= deps.thresholdTokens) return; // settled beneath while we waited const cut = compactionCutIndex(messages); - if (cut >= messages.length) return; // nothing to drop (defensive) + if (cut <= 0 || cut >= messages.length) return; // no history can be dropped // The previous compaction's kept boundary (chained compactions // summarize the old summary + what rode after it). @@ -240,6 +240,7 @@ async function compact(deps: CompactionDeps, chatId: string): Promise { outputTokens: usage.output, cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite, + cacheWrite1hTokens: usage.cacheWrite1h ?? 0, reasoningTokens: usage.reasoning ?? 0, }, ], diff --git a/src/server/graph.ts b/src/server/graph.ts index 7596c95..b01bb6a 100644 --- a/src/server/graph.ts +++ b/src/server/graph.ts @@ -11,6 +11,7 @@ import { } from "../core/graph-deletion.ts"; import { extractionStatuses } from "../agent/subagents/extraction.ts"; import { stores } from "./stores.ts"; +import { callCost } from "./pricing.ts"; /** * Server-side graph entry for the home view (GET /api/graph). A PURE READ: @@ -41,7 +42,7 @@ export function graphCosts(): GraphCostSummary { .readSources() .filter((s) => s.extractions?.length) .map((s) => ({ filePath: s.filePath, extractions: s.extractions! })); - return graphCostSummary({ chats, sources }); + return graphCostSummary({ chats, sources }, callCost); } /** The graph's version stamp (session-deletion wave 6): the max mtime diff --git a/src/server/index.ts b/src/server/index.ts index bf0212b..49968ca 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -43,3 +43,4 @@ export * from "./compaction.ts"; export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts"; export * from "./observability.ts"; export * from "./model-catalog.ts"; +export { sessionCost } from "./pricing.ts"; diff --git a/src/server/observability.ts b/src/server/observability.ts index a7d0edf..3050bde 100644 --- a/src/server/observability.ts +++ b/src/server/observability.ts @@ -24,6 +24,7 @@ import { chatStore } from "../store/chats.ts"; import { graphRevision } from "./graph.ts"; import { catalogContextLimits } from "./model-catalog.ts"; import { listJobs } from "./jobs.ts"; +import { jobCost } from "./pricing.ts"; import type { JobRecord } from "../core/contracts.ts"; export { abortExtraction, extractionStatuses, type ExtractionPhase, type RunningExtraction } from "../agent/subagents/extraction.ts"; @@ -36,6 +37,7 @@ export { chatStore } from "../store/chats.ts"; * model is missing from the catalog (the blob then renders no context * line — never a fake window). */ export interface ActivityJob extends JobRecord { + costUsd: number | null; title: string; contextLimit: number | null; } @@ -56,9 +58,8 @@ export interface ActivityExtraction { /** The dock's whole world in one payload: every running process, every view. * Jobs are RUNNING-ONLY — finished jobs belong to their chat's transcript; - * the blob disappears when the process ends. Cost math is NOT done here: - * raw usage rides on the records and the client folds it with the pure - * helpers in core/prices.ts (transport stays thin). */ + * the blob disappears when the process ends. Costs are joined here through + * the server-side Pi pricing adapter; clients never reconstruct rates. */ /** The dock's whole world + the graph's version stamp (wave 6 session * deletion): the payload carries graphRevision (max mtime across the * graph data files — see server/graph.ts) so any view can detect "the @@ -78,6 +79,7 @@ export function activityFeed(): { if (job.status === "running") { jobs.push({ ...job, + costUsd: jobCost(job), title: chat.title ?? chat.id, contextLimit: job.usage ? (limits.get(job.usage.model) ?? null) : null, }); diff --git a/src/server/pricing.ts b/src/server/pricing.ts new file mode 100644 index 0000000..5745760 --- /dev/null +++ b/src/server/pricing.ts @@ -0,0 +1,50 @@ +/** Read-time API-dollar estimates. Pi owns both rates and arithmetic. + * Catalog resolution is key-independent; no provider dispatch/auth is needed. + * Never mutate saved native messages: calculateCost mutates its usage.cost. + */ +import { calculateCost, type Usage } from "@earendil-works/pi-ai"; +import { piModels } from "../agent/models.ts"; +import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts"; +import { isChatGPTPlan, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts"; + +function catalogModel(id: string) { + const colon = id.indexOf(":"); + if (colon < 0 || isChatGPTPlan(id)) return undefined; + return piModels().getModel(id.slice(0, colon), id.slice(colon + 1)); +} + +export function callCost(call: Partial, modelId: string): number | null { + const model = catalogModel(modelId); + if (!model || call.inputTokens === undefined || call.outputTokens === undefined) return null; + const input = call.inputTokens; + const output = call.outputTokens; + const cacheRead = call.cacheReadTokens ?? 0; + const cacheWrite = call.cacheWriteTokens ?? 0; + const cacheWrite1h = call.cacheWrite1hTokens ?? 0; + if ([input, output, cacheRead, cacheWrite, cacheWrite1h].some((v) => !Number.isFinite(v) || v < 0) || cacheWrite1h > cacheWrite) return null; + // Old records discarded this split. Only price them if it cannot affect + // the result; do not invent short-retention precision from a missing field. + if (cacheWrite > 0 && call.cacheWrite1hTokens === undefined && + [model.cost, ...(model.cost.tiers ?? [])].some((rate) => rate.cacheWrite !== 2 * rate.input)) return null; + const usage: Usage = { + input, output, cacheRead, cacheWrite, cacheWrite1h, + totalTokens: input + output + cacheRead + cacheWrite, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + const total = calculateCost(model, usage).total; + return Number.isFinite(total) && total >= 0 ? total : null; +} + +export function turnCost(turn: TurnUsage): number | null { + return foldTurnCost(turn, callCost); +} +export function sessionCost(record: Pick) { + return foldSessionCost(record, callCost); +} +export function jobCost(job: Pick): number | null { + const usage = job.usage; + if (!usage || usage.steps < 1) return null; + const hasCalls = usage.calls?.length === usage.steps; + if (!hasCalls && usage.steps > 1 && catalogModel(usage.model)?.cost.tiers?.length) return null; + return foldJobCost(job, callCost); +} diff --git a/tests/activity-feed.test.ts b/tests/activity-feed.test.ts index 0cfe96c..dbd4593 100644 --- a/tests/activity-feed.test.ts +++ b/tests/activity-feed.test.ts @@ -88,10 +88,13 @@ test("activityFeed: contextLimit is the server's live-catalog join (owner amendm // pi 1.0.2's catalog: glm-5.3-flash contextWindow 1_048_575 (was // 1_048_576 at 0.87.1 — upstream models.dev refresh). assert.equal(knownJob.contextLimit, 1_048_575, "pi's catalog, joined server-side onto the feed"); + assert.ok(knownJob.costUsd !== null && Math.abs(knownJob.costUsd - 0.0002) < 1e-12, "Pi's price is projected on the server"); const unknownJob = feed.jobs.find((j) => j.id === unknown.id)!; assert.equal(unknownJob.contextLimit, null, "missing from the live catalog → null, never a guessed window"); + assert.equal(unknownJob.costUsd, null); const bareJob = feed.jobs.find((j) => j.id === bare.id)!; assert.equal(bareJob.contextLimit, null, "no usage yet → no context line"); + assert.equal(bareJob.costUsd, null); } finally { fs.unlinkSync(`${PATHS.chats}/${chat.id}.json`); const jobsDir = path.join(PATHS.data, "jobs"); diff --git a/tests/chatgpt-background.test.ts b/tests/chatgpt-background.test.ts index 9cbc2f1..7bdf602 100644 --- a/tests/chatgpt-background.test.ts +++ b/tests/chatgpt-background.test.ts @@ -1,4 +1,4 @@ -import { test, after } from "node:test"; +import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; @@ -21,7 +21,20 @@ let revision = 1; function connection(background: boolean) { ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: revision++ }, invalidate: background ? "none" : "background", status: { available: true, pending: false, allowBackgroundUse: background, activeId: "scratch-account", accounts: [{ id: "scratch-account", connected: true, planEnabled: true, background: !background }], error: null } }); } -after(() => { fs.rmSync(dir, { recursive: true, force: true }); }); +const originalSend = Object.getOwnPropertyDescriptor(process, "send"), originalConnected = Object.getOwnPropertyDescriptor(process, "connected"); +const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET; +before(() => { + process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64); + Object.defineProperty(process, "connected", { configurable: true, value: true }); + Object.defineProperty(process, "send", { configurable: true, value: (_message: unknown, callback?: (error: Error) => void) => { callback?.(new Error("unexpected fixture desktop request")); return false; } }); +}); +after(() => { + fs.rmSync(dir, { recursive: true, force: true }); + if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send; + if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected; + if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker; + if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret; +}); for (const revokedAfterQueue of [false, true]) { test(`mixed learner+automatic batch: foreground runs, automatic result stays owned/deferred without consent (${revokedAfterQueue ? "revoked after handoff" : "initially off"})`, { timeout: 5000 }, async () => { diff --git a/tests/chatgpt-catalog.test.ts b/tests/chatgpt-catalog.test.ts index 975588e..5ff5dad 100644 --- a/tests/chatgpt-catalog.test.ts +++ b/tests/chatgpt-catalog.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { piModels } from "../src/agent/models.ts"; import { refreshChatGPTModels, chatGPTModels, chatGPTStatus, chatGPTScope, chatGPTScopeCurrent, chatGPTLease, chatGPTAbortController } from "../src/agent/chatgpt-bridge.ts"; const send = Object.getOwnPropertyDescriptor(process, "send"), connected = Object.getOwnPropertyDescriptor(process, "connected"); +const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET; const pending: any[] = []; const model = piModels().getModels("openai").find((m) => m.api === "openai-responses")!; const ipc = (m: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", m); @@ -10,10 +11,13 @@ const status = (account: string, background = true) => ({ available: true, pendi const value = (name: string, revision: number, account = "A", authorityRevision = 1) => ({ epoch: account === "A" ? 1 : 2, authorityRevision, revision, status: status(account), accountId: account, models: [{ id: model.id, name }] }); function reply(request: any, v: unknown) { ipc({ channel: "lh:chatgpt", id: request.id, op: request.op, ok: true, value: v }); } before(() => { + process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64); Object.defineProperty(process, "connected", { configurable: true, value: true }); Object.defineProperty(process, "send", { configurable: true, value: (m: unknown, cb?: () => void) => { pending.push(m); cb?.(); return true; } }); }); after(() => { + if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker; + if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret; if (send) Object.defineProperty(process, "send", send); else delete process.send; if (connected) Object.defineProperty(process, "connected", connected); else delete (process as { connected?: boolean }).connected; }); diff --git a/tests/chatgpt-dev-ipc.test.ts b/tests/chatgpt-dev-ipc.test.ts new file mode 100644 index 0000000..fcb0e44 --- /dev/null +++ b/tests/chatgpt-dev-ipc.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test, { after } from "node:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-dev-ipc-")); +process.env.LH_DATA_DIR = scratch; +process.env.LH_AUTH_FILE = path.join(scratch, "auth.json"); +fs.writeFileSync(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 }); +process.env.OPENAI_API_KEY = "sk-fixture-not-used-for-generation"; +const { GET } = await import("../app/api/models/route.ts"); +const { chatGPTRequest, chatGPTConnected, chatGPTModels } = await import("../src/agent/chatgpt-bridge.ts"); +after(() => fs.rmSync(scratch, { recursive: true, force: true })); + +test("Next-style connected IPC is not desktop transport and never delays keyed API catalogs", async () => { + const send = Object.getOwnPropertyDescriptor(process, "send"); + const connected = Object.getOwnPropertyDescriptor(process, "connected"); + const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET; + const originalFetch = globalThis.fetch; + let sends = 0; + try { + globalThis.fetch = async () => { throw new Error("provider/network calls forbidden in catalog fixture"); }; + Object.defineProperty(process, "send", { configurable: true, value: (_message: unknown, callback?: (error: Error) => void) => { sends++; callback?.(new Error("not a desktop parent")); return false; } }); + Object.defineProperty(process, "connected", { configurable: true, value: true }); + for (const [mode, capability] of [[undefined, undefined], ["1", undefined], [undefined, "d".repeat(64)], ["1", "short"]]) { + if (mode === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = mode; + if (capability === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = capability; + // A framework parent cannot establish ChatGPT state by sharing only IPC. + (process.emit as (name: string, value: unknown) => boolean)("message", { channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 1 }, status: { available: true, activeId: "fixture", accounts: [{ id: "fixture", connected: true, planEnabled: true }] } }); + const response = await GET(); + assert.equal(response.status, 200); + const catalog = await response.json(); + assert.ok(catalog.models.some((m: { provider: string }) => m.provider === "openai")); + assert.equal(catalog.providers.find((p: { id: string }) => p.id === "openai").keyPresent, true); + assert.equal(catalog.providers.find((p: { id: string }) => p.id === "chatgpt").keyPresent, false); + assert.equal(chatGPTConnected(), false); + assert.deepEqual(chatGPTModels(), []); + await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/); + assert.equal(sends, 0, "no desktop request/timer may be started over framework IPC"); + } + process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64); + Object.defineProperty(process, "connected", { configurable: true, value: false }); + await GET(); + await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/); + assert.equal(sends, 0, "desktop markers without connected IPC do not suffice"); + } finally { + globalThis.fetch = originalFetch; + if (send) Object.defineProperty(process, "send", send); else delete process.send; + if (connected) Object.defineProperty(process, "connected", connected); else delete (process as { connected?: boolean }).connected; + if (marker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = marker; + if (secret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = secret; + } +}); diff --git a/tests/chatgpt-request-cycle.test.ts b/tests/chatgpt-request-cycle.test.ts index a78421b..412c35d 100644 --- a/tests/chatgpt-request-cycle.test.ts +++ b/tests/chatgpt-request-cycle.test.ts @@ -20,6 +20,7 @@ import { newUserMessage, newNextMessage, newThinkingMessage, newAnswerResult, re const originalSend = Object.getOwnPropertyDescriptor(process, "send"); const originalConnected = Object.getOwnPropertyDescriptor(process, "connected"); const realFetch = globalThis.fetch; +const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET; const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">; let epoch = 1, revision = 1, background = true; let cut: "lease" | "payload" | "full" | "afterFetch" | "none" = "none"; @@ -31,6 +32,7 @@ function revoke(full = false) { ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch, authorityRevision: epoch, revision: ++revision }, status: status(), invalidate: full ? "all" : "background" }); } before(() => { + process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64); Object.defineProperty(process, "connected", { configurable: true, value: true }); Object.defineProperty(process, "send", { configurable: true, value: (m: any, callback?: () => void) => { if (m.op === "lease") { @@ -59,6 +61,8 @@ before(() => { }; }); after(() => { + if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker; + if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret; globalThis.fetch = realFetch; fs.rmSync(scratch, { recursive: true, force: true }); if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send; diff --git a/tests/chatgpt-stream.test.ts b/tests/chatgpt-stream.test.ts index b91b023..a54521a 100644 --- a/tests/chatgpt-stream.test.ts +++ b/tests/chatgpt-stream.test.ts @@ -9,6 +9,7 @@ import { chatGPTHistory, chatGPTPayload } from "../src/agent/chatgpt-stream.ts"; const originalSend = Object.getOwnPropertyDescriptor(process, "send"); const originalConnected = Object.getOwnPropertyDescriptor(process, "connected"); const realFetch = globalThis.fetch; +const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET; const originalKey = process.env.OPENAI_API_KEY; let leaseError: string | null = null; let requests: { authorization: string | null; body: Record; url: string }[] = []; @@ -17,6 +18,7 @@ const ipc = (value: unknown) => (process.emit as (name: string, value: unknown) const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">; const status = { available: true, pending: false, allowBackgroundUse: false, activeId: "scratch-account", accounts: [{ id: "scratch-account", email: "dummy@example.invalid", registration: "oaiapp_scratch", connected: true, planEnabled: true, background: false }], error: null }; before(async () => { + process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64); assert(model); process.env.OPENAI_API_KEY = "sk-scratch-must-never-be-used"; Object.defineProperty(process, "connected", { configurable: true, value: true }); @@ -36,6 +38,8 @@ before(async () => { }; }); after(() => { + if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker; + if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret; globalThis.fetch = realFetch; if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send; if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected; diff --git a/tests/chatgpt-ui.test.tsx b/tests/chatgpt-ui.test.tsx index 878200f..17787a7 100644 --- a/tests/chatgpt-ui.test.tsx +++ b/tests/chatgpt-ui.test.tsx @@ -10,7 +10,8 @@ import { notifySettingsSaved } from "../components/settings-signal.ts"; import { SidebarProvider } from "../components/sidebar.tsx"; import { ChatGPTConnection, ChatGPTUIProvider } from "../components/settings/chatgpt-connection.tsx"; import { GraphCostControl } from "../components/graph-view.tsx"; -import { graphCostSummary } from "../src/core/cost-summary.ts"; +import { graphCostSummary as foldGraphCostSummary, type CostSummaryInput } from "../src/core/cost-summary.ts"; +const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, () => null); import type { ModelCatalogResponse, ChatGPTStatus } from "../src/core/index.ts"; const dom = parseHTML(""); Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, localStorage: { getItem: () => null, setItem() {} } }); diff --git a/tests/chatgpt-usage.test.ts b/tests/chatgpt-usage.test.ts index 3c828a0..3c462a4 100644 --- a/tests/chatgpt-usage.test.ts +++ b/tests/chatgpt-usage.test.ts @@ -1,7 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { callCost, sessionCost, turnCost, jobCost } from "../src/core/prices.ts"; -import { graphCostSummary } from "../src/core/cost-summary.ts"; +import { callCost, sessionCost, turnCost, jobCost } from "../src/server/pricing.ts"; +import { graphCostSummary as foldGraphCostSummary, type CostSummaryInput } from "../src/core/cost-summary.ts"; +const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, callCost); import type { TurnUsage } from "../src/core/contracts.ts"; import { planCostLabel } from "../components/usage-format.ts"; const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined }; @@ -12,7 +13,7 @@ test("subscription usage is neither API dollars nor an unpriced-model mistake, i assert.equal(callCost(step, plan.model), null); assert.equal(turnCost(turn(plan.model, [])), null); assert.equal(jobCost({ usage: { model: plan.model, ...step, steps: 1 } }), null); - assert.deepEqual(sessionCost({ turns: [plan, turn(plan.model, [])] }), { cost: 0, unpriced: 0, subscription: 2 }); + assert.deepEqual(sessionCost({ turns: [plan, turn(plan.model, [])] }), { cost: 0, unpriced: 0, subscription: 2, turnCosts: { "scratch-turn": null } }); const api = turn("openai:gpt-5"); const mixed = sessionCost({ turns: [plan, api, turn("unknown:missing")] }); assert.equal(mixed.cost, turnCost(api)); assert.equal(mixed.unpriced, 1); assert.equal(mixed.subscription, 1); diff --git a/tests/compaction-native-parity.test.ts b/tests/compaction-native-parity.test.ts new file mode 100644 index 0000000..0bd9d0e --- /dev/null +++ b/tests/compaction-native-parity.test.ts @@ -0,0 +1,55 @@ +/** Golden outputs from the verified published pi-coding-agent 1.4.0 + * findCutPoint + estimateTokens helpers, executed in isolation on synthetic + * message/metadata entries. No coding-agent dependency or private data. + * Source compaction.js SHA256: + * f51e78ef1da66b5803fc91e185f07b968f8f989ab9326b7744958416aad3f6f6 + * Update deliberately alongside the documented local port, not automatically. + */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { compactionCutIndex, estimateMessageTokens, newCompactionMarker, KEEP_RECENT_TOKENS, type LhMessage } from "../src/core/index.ts"; + +let serial = 0; +const user = (chars: number): LhMessage => ({ id: `u${serial++}`, role: "user", content: "u".repeat(chars), timestamp: 1 }); +const assistant = (chars: number): LhMessage => ({ id: `a${serial++}`, role: "assistant", content: [{ type: "text", text: "a".repeat(chars) }], timestamp: 1 }) as LhMessage; +const call = (chars: number): LhMessage => ({ ...assistant(chars), content: [{ type: "text", text: "a".repeat(chars) }, { type: "toolCall", name: "quiz", arguments: {}, id: "c" }] }) as LhMessage; +const result = (chars: number): LhMessage => ({ id: `r${serial++}`, role: "toolResult", content: [{ type: "text", text: "r".repeat(chars) }], toolCallId: "c", toolName: "quiz", isError: false, timestamp: 1 }); +const image = (): LhMessage => ({ id: `i${serial++}`, role: "user", content: [{ type: "image", data: "fixture", mimeType: "image/png" }], timestamp: 1 }); +const metadata = (): LhMessage => ({ id: `m${serial++}`, role: "custom", customType: "plan-block", content: "invisible".repeat(1000), display: false, details: {}, timestamp: 1 }); + +test("local cutoff matches published native golden boundaries and estimator values", () => { + const cases: [string, LhMessage[], number, number, number[]][] = [ + ["exact threshold", [user(40), user(40), user(40)], 20, 1, [10, 10, 10]], + ["under budget", [user(4), assistant(4)], 20, 0, [1, 1]], + ["split turn", [user(400), assistant(40), assistant(40)], 15, 1, [100, 10, 10]], + ["completed result boundary", [user(400), call(4), result(400), user(40)], 30, 3, [100, 3, 100, 10]], + ["oversized trailing result", [user(400), user(40), call(4), result(400)], 30, 2, [100, 10, 3, 100]], + ["multiple trailing results", [user(400), call(4), result(200), result(200)], 30, 1, [100, 3, 50, 50]], + ["image allowance", [user(400), image(), user(40)], 1000, 1, [100, 1200, 10]], + ["empty trailing text", [user(400), assistant(0), user(0)], 30, 0, [100, 0, 0]], + ["pending call", [user(400), user(400), call(4)], 10, 1, [100, 100, 3]], + ]; + for (const [name, messages, budget, expected, tokens] of cases) { + assert.equal(compactionCutIndex(messages, budget), expected, name); + assert.deepEqual(messages.map(estimateMessageTokens), tokens, name); + } + assert.equal(compactionCutIndex([user(400), metadata(), assistant(40), metadata(), user(40)], 15), 1, "adjacent invisible metadata stays with the cut"); + assert.equal(KEEP_RECENT_TOKENS, 20_000); +}); + +test("chained cutoff operates on active retained history, never the previous summary or discarded prefix", () => { + const old = user(400_000); + const kept = user(40); + const marker = newCompactionMarker("summary".repeat(100_000), { firstKeptMessageId: kept.id, tokensBefore: 100_000, at: "fixture" }); + const messages = [old, kept, assistant(40), marker, user(40)]; + assert.equal(compactionCutIndex(messages, 100), 1, "retained tail fits; no progress"); + assert.equal(compactionCutIndex(messages, 15), 2, "old summary does not consume the kept-tail budget"); +}); + +test("opaque signatures remain unmeasured and preserved; system/image allowances match native", () => { + const signed = { ...assistant(4), content: [{ type: "thinking", thinking: "", thinkingSignature: "opaque".repeat(100_000) }] } as LhMessage; + assert.equal(estimateMessageTokens(signed), 0); + assert.equal((signed as { content: { thinkingSignature: string }[] }).content[0]!.thinkingSignature.length, 600_000); + const system = { role: "system", content: [{ type: "image", data: "fixture", mimeType: "image/png" }], sections: { a: "abcd" }, toolsAdded: [{ name: "fixture" }] }; + assert.equal(estimateMessageTokens(system as never), Math.ceil((4800 + 4 + JSON.stringify(system.toolsAdded).length) / 4)); +}); diff --git a/tests/compaction-service.test.ts b/tests/compaction-service.test.ts index 34a1f6d..ee5b4d4 100644 --- a/tests/compaction-service.test.ts +++ b/tests/compaction-service.test.ts @@ -32,7 +32,7 @@ const { } = await import("../src/server/compaction.ts"); import type { CompactionDeps } from "../src/server/compaction.ts"; const { subscribe } = await import("../src/core/events.ts"); -const { activeCompactionOf, newUserMessage, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts"); +const { activeCompactionOf, newUserMessage, measuredContextTokens, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts"); import type { LhMessage } from "../src/core/index.ts"; after(() => { @@ -204,6 +204,37 @@ describe("the compaction service", () => { kickCompaction(() => null, chatId); }); + it("a usage-heavy zero cut does not spend or append a marker", async () => { + const chat = chatStore.createChat("no-progress fixture"); + const assistant = { id: "measured", role: "assistant", content: [{ type: "text", text: "ok" }], stopReason: "stop", usage: { input: 120_000, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 120_001 }, timestamp: 1 } as LhMessage; + chatStore.saveMessages(chat.id, [newUserMessage("tiny visible history"), assistant]); + const before = summarizeCalls; + await ensureCompacted(depsOf(chat.id, 40_000), chat.id); + assert.equal(summarizeCalls, before); + assert.equal(activeCompactionOf(chatStore.getChat(chat.id)!.messages), null); + assert.equal(measuredContextTokens(chatStore.getChat(chat.id)!.messages), 120_001); + }); + + it("starting/failing compaction preserves the meter; committing its marker clears it, regardless of summary usage", async () => { + const id = overThresholdChat("meter validity fixture"); + const assistant = { id: "measured", role: "assistant", content: [{ type: "text", text: "ok" }], stopReason: "stop", usage: { input: 120_000, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 120_001 }, timestamp: 1 } as LhMessage; + chatStore.saveMessages(id, [...chatStore.getChat(id)!.messages, assistant]); + let release!: () => void; + const gate = new Promise((done) => { release = done; }); + const deps = depsOf(id, 40_000); + const running = ensureCompacted({ ...deps, summarize: async () => { + await gate; + throw new Error("fixture failure"); + } }, id); + assert.equal(measuredContextTokens(chatStore.getChat(id)!.messages), 120_001); + release(); await running; + assert.equal(measuredContextTokens(chatStore.getChat(id)!.messages), 120_001); + await ensureCompacted(deps, id); + const record = chatStore.getChat(id)!; + assert.ok(record.turns!.at(-1)!.steps[0]!.inputTokens! > 0); + assert.equal(measuredContextTokens(record.messages), null); + }); + it("a summarizer failure leaves the record untouched and the gate re-arms", async () => { const chatId = overThresholdChat("compact: fail"); const before = summarizeCalls; diff --git a/tests/compaction.test.ts b/tests/compaction.test.ts index d673710..5139558 100644 --- a/tests/compaction.test.ts +++ b/tests/compaction.test.ts @@ -156,7 +156,7 @@ describe("compactionCutIndex", () => { assert.equal((messages[cut] as { content: string }).content.startsWith("m2"), true, "the oldest kept message sits at the cut"); }); - it("NEVER separates a tool result from its call — the cut extends to the call's assistant message", () => { + it("native cut after an oversized result drops both call and result, never keeps an orphan", () => { const messages = [ big("m1"), big("m2"), @@ -165,13 +165,13 @@ describe("compactionCutIndex", () => { big("m3"), big("m4"), ]; - // Budget lands such that the naive stop is ON the toolResult (m2 kept, - // result next): the walk must extend to a1's assistant message. + // Budget lands on the result. Pi chooses the next valid cut AT OR + // AFTER it, dropping the complete exchange rather than extending. const cut = compactionCutIndex(messages, estimateTokens([big("m3"), big("m4")]) + 1); - assert.ok(cut <= 2, `the cut must not sit between the call (idx 2) and its result (idx 3) — got ${cut}`); - const kept = messages.slice(cut); - const keptIds = new Set(kept.map((m) => m.id)); - assert.ok(keptIds.has("a1") && keptIds.has("r_call_1"), "call and result stay together"); + assert.equal(cut, 4); + const keptIds = new Set(messages.slice(cut).map((m) => m.id)); + assert.equal(keptIds.has("a1"), false); + assert.equal(keptIds.has("r_call_1"), false); }); it("a PENDING question call (no result — the pose→answer window) always survives", () => { @@ -181,7 +181,7 @@ describe("compactionCutIndex", () => { assert.ok(kept.some((m) => m.id === "a_pending"), "the pending pose is never dropped"); }); - it("zero budget summarizes everything; whole-history-in-budget returns 0 only when nothing precedes", () => { + it("whole history within budget keeps everything (zero cut)", () => { assert.equal(compactionCutIndex([newUserMessage("hi")], 100), 0); }); }); diff --git a/tests/context-usage.test.ts b/tests/context-usage.test.ts new file mode 100644 index 0000000..8060f5d --- /dev/null +++ b/tests/context-usage.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { measuredContextTokens, newCompactionMarker, type LhMessage } from "../src/core/index.ts"; +import { SessionModel } from "../components/session-core.ts"; + +function assistant(id: string, stopReason: AssistantMessage["stopReason"] = "stop", total = 999_999): LhMessage { + return { id, role: "assistant", content: [{ type: "text", text: "fixture" }], api: "openai-completions", provider: "openrouter", model: "fixture", stopReason, timestamp: 1, + usage: { input: 10_000, output: 1_000, cacheRead: 70_000, cacheWrite: 2_000, reasoning: 500, totalTokens: total, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } } as LhMessage; +} +const marker = () => newCompactionMarker("educational summary", { firstKeptMessageId: "old", tokensBefore: 110_001, at: "fixture" }); + +test("meter sums four disjoint fields, ignoring reasoning subset and totalTokens overrides", () => { + assert.equal(measuredContextTokens([assistant("old")]), 83_000); + assert.equal(measuredContextTokens([]), null); + for (const stop of ["stop", "length", "toolUse"] as const) assert.equal(measuredContextTokens([assistant("a", stop)]), 83_000); +}); + +test("newest marker invalidates retained pre-cut usage; summarizer money log cannot supply a measurement", () => { + const old = assistant("old"); + assert.equal(measuredContextTokens([old, marker()]), null); + assert.equal(measuredContextTokens([old, marker(), assistant("fresh")]), 83_000); + assert.equal(measuredContextTokens([old, marker(), assistant("fresh"), marker()]), null); + // The summarizer is logged as a money-channel turn, never an assistant + // in the native conversation list. No money-channel input exists here. +}); + +test("errors, aborts, all-zero/invalid usage and unfinished slots preserve the previous valid reading", () => { + const old = assistant("old"); + for (const stop of ["error", "aborted"] as const) assert.equal(measuredContextTokens([old, assistant("bad", stop)]), 83_000); + const zero = assistant("zero") as AssistantMessage & { id: string }; + zero.usage = { ...zero.usage, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + assert.equal(measuredContextTokens([old, zero]), 83_000); + assert.equal(measuredContextTokens([zero]), null); + assert.equal(measuredContextTokens([old, assistant("partial")], new Set(["partial"])), 83_000); + assert.equal(measuredContextTokens([marker(), assistant("bad", "error")]), null); +}); + +test("live terminal-looking message_update is not completion; each message_end updates immediately", () => { + const model = new SessionModel({ chatId: "fixture", initialMessages: [assistant("old")] }); + const fresh = assistant("live") as AssistantMessage & { id: string }; + fresh.usage = { ...fresh.usage, input: 20_000 }; + model.applyWireEvent({ type: "message_start", message: fresh }); + model.applyWireEvent({ type: "message_update", message: fresh }); + assert.equal(model.getState().contextTokens, 83_000); + model.applyWireEvent({ type: "message_end", message: fresh }); + assert.equal(model.getState().contextTokens, 93_000); + model.applyWireEvent({ type: "message_start", message: assistant("next") }); + assert.equal(model.getState().contextTokens, 93_000); + model.applyWireEvent({ type: "message_end", message: assistant("next", "toolUse") }); + assert.equal(model.getState().contextTokens, 83_000); + model.setMessages([assistant("old"), marker()]); + assert.equal(model.getState().contextTokens, null); +}); diff --git a/tests/cost-summary.test.ts b/tests/cost-summary.test.ts index 6fa98fb..88e3515 100644 --- a/tests/cost-summary.test.ts +++ b/tests/cost-summary.test.ts @@ -8,16 +8,17 @@ import assert from "node:assert/strict"; import { describe, it as test } from "node:test"; import { - graphCostSummary, + graphCostSummary as foldGraphCostSummary, type CostSummaryInput, type GraphCostSummary, } from "../src/core/cost-summary.ts"; import type { CallUsage, ExtractionRunUsage } from "../src/core/contracts.ts"; -import { PRICES, callCost } from "../src/core/prices.ts"; +import { callCost } from "../src/server/pricing.ts"; +const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, callCost); /** A model with full published pricing (from the generated registry). */ const PRICED = "openrouter:z-ai/glm-5.3-flash"; -assert.ok(PRICES[PRICED], "fixture model must exist in PRICES"); +assert.notEqual(callCost({ inputTokens: 1, outputTokens: 1 }, PRICED), null); const step = (input: number, output: number): CallUsage => ({ inputTokens: input, diff --git a/tests/graph-pricing.test.tsx b/tests/graph-pricing.test.tsx new file mode 100644 index 0000000..ee1aa05 --- /dev/null +++ b/tests/graph-pricing.test.tsx @@ -0,0 +1,60 @@ +import React from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { GraphCostControl } from "../components/graph-view.tsx"; +import { graphCostSummary } from "../src/core/cost-summary.ts"; +import { callCost } from "../src/server/pricing.ts"; +import type { CallUsage, ExtractionRunUsage } from "../src/core/contracts.ts"; + +const usage = (over: Partial = {}): CallUsage => ({ inputTokens: 1000, outputTokens: 100, cacheReadTokens: 0, cacheWriteTokens: 20_000, reasoningTokens: 0, ...over }); +const run = (steps: CallUsage[], model = "anthropic:claude-haiku-4-5", at = 1): ExtractionRunUsage => ({ startedAt: at, at: "fixture", durationMs: 1, stages: { extractor: { model, steps }, auditor: { model, steps } } }); +const summary = (runs: ExtractionRunUsage[]) => graphCostSummary({ chats: [{ title: "fixture", extractions: runs }], sources: [] }, callCost); +const headline = (runs: ExtractionRunUsage[]) => renderToStaticMarkup(); + +test("wholly unpriced historical graph costs render unknown, not known-zero dollars", () => { + const historical = run([usage()]); // missing long-cache split + const costs = summary([historical]); + assert.equal(costs.totalDollars, 0); + assert.equal(costs.unpricedRuns, 1); + assert.equal(costs.stages.extraction.unpricedRuns, 1); + const html = headline([historical]); + assert.match(html, />—<\/button>/); + assert.doesNotMatch(html, /\$0\.00/); +}); + +test("known zero, mixed priced/unknown, and subscription headline semantics stay distinct", () => { + const zero = run([usage({ inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0 })]); + assert.match(headline([zero]), />\$0\.00<\/button>/, "known zero is not unknown"); + const priced = run([usage({ cacheWrite1hTokens: 0 })]); + const historical = run([usage()], undefined, 2); + const mixed = summary([priced, historical]); + assert.equal(mixed.unpricedRuns, 1); + assert.ok(mixed.totalDollars > 0); + assert.match(headline([priced, historical]), new RegExp(`>\\$${mixed.totalDollars.toFixed(2).replace(".", "\\.")}<\\/button>`)); + const plan = run([usage()], "chatgpt:fixture"); + assert.match(headline([plan]), />ChatGPT plan<\/button>/); + assert.match(headline([plan, zero]), />\$0\.00 API \+ ChatGPT plan<\/button>/); +}); + +test("graph stage and recent-run aggregates preserve known one-hour cache writes", () => { + const costs = summary([run([usage({ cacheWrite1hTokens: 20_000 })]), run([usage({ cacheWrite1hTokens: 5000 })], undefined, 2)]); + assert.equal(costs.stages.extraction.tokens.cacheWrite1hTokens, 25_000); + assert.equal(costs.stages.audit.tokens.cacheWrite1hTokens, 25_000); + assert.deepEqual(costs.runs.map((r) => r.tokens.cacheWrite1hTokens), [10_000, 40_000]); +}); + +test("unknown historical long-cache splits stay unknown regardless of merge order", () => { + const known = run([usage({ cacheWrite1hTokens: 20_000 })]); + const historical = run([usage()], undefined, 2); + for (const runs of [[known, historical], [historical, known]]) { + const costs = summary(runs); + assert.equal(costs.stages.extraction.tokens.cacheWrite1hTokens, undefined); + assert.equal(costs.stages.audit.tokens.cacheWrite1hTokens, undefined); + } + const mixedWithinRun = summary([run([usage({ cacheWrite1hTokens: 20_000 }), usage()])]); + assert.equal(mixedWithinRun.stages.extraction.tokens.cacheWrite1hTokens, undefined); + const absentNoWrites = run([usage({ cacheWriteTokens: 0 })], undefined, 2); + assert.equal(summary([absentNoWrites, known]).stages.extraction.tokens.cacheWrite1hTokens, 20_000, "absence without writes must not poison known write detail"); +}); diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts index b740023..af9387a 100644 --- a/tests/jobs.test.ts +++ b/tests/jobs.test.ts @@ -346,8 +346,11 @@ describe("steer helpers (contracts.ts)", () => { cacheReadTokens: 4, cacheWriteTokens: undefined, reasoningTokens: undefined, + cacheWrite1hTokens: undefined, + calls: [{ inputTokens: 10, outputTokens: 5, cacheReadTokens: 4, cacheWriteTokens: undefined, cacheWrite1hTokens: undefined, reasoningTokens: undefined }], steps: 1, lastStep: { + cacheWrite1hTokens: undefined, inputTokens: 10, outputTokens: 5, cacheReadTokens: 4, @@ -364,7 +367,9 @@ describe("steer helpers (contracts.ts)", () => { assert.equal(total.cacheWriteTokens, undefined, "never reported stays absent — never zero"); assert.equal(total.reasoningTokens, undefined); assert.equal(total.steps, 2, "one step per model call, reported or not"); + assert.equal(total.calls?.length, 2); assert.deepEqual(total.lastStep, { + cacheWrite1hTokens: undefined, inputTokens: 20, outputTokens: 0, cacheReadTokens: undefined, diff --git a/tests/openrouter-free-models.test.ts b/tests/openrouter-free-models.test.ts index 8336237..adcd2ea 100644 --- a/tests/openrouter-free-models.test.ts +++ b/tests/openrouter-free-models.test.ts @@ -4,7 +4,7 @@ import { normalizeContext } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { piEngineForSpec, piModels } from "../src/agent/models.ts"; import { modelCatalog, validateChatSettings, validateModelsSettings } from "../src/server/model-catalog.ts"; -import { callCost } from "../src/core/prices.ts"; +import { callCost } from "../src/server/pricing.ts"; const specimens = [ { id: "recursive/flint-v1.0:free", input: ["text"] }, diff --git a/tests/prices-sync.test.ts b/tests/prices-sync.test.ts deleted file mode 100644 index 918c669..0000000 --- a/tests/prices-sync.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * THE PRICE-TABLE DRIFT GUARD (owner, 2026-09-29). - * - * Failure mode this kills: bumping @earendil-works/pi-ai adds new models - * (and price corrections) to the shipped catalogs, but nobody re-runs - * scripts/sync-pricing.ts — src/core/prices.ts stays a stale generation - * and every run on a new model renders cost as "—" (deepseek-v4.1-flash, - * 2026-09-29, commit df654fb). The table must stay a generated static - * file (core is client-safe: activity-dock and stats-bar fold costs in - * the renderer, and reading the catalog JSON needs fs), so staleness is - * guarded instead of eliminated. - * - * The test asserts PRICES equals EXACTLY what the sync script would - * generate right now — it imports the script's own builder, so the guard - * and the generator can never disagree: - * - a catalog model with pricing missing from PRICES → "run the sync" - * - a price field that drifted in the catalog → "run the sync" - * - a PRICES key whose model left the catalog → "run the sync" - * - * On failure: npx tsx scripts/sync-pricing.ts, commit the regenerated - * file, re-derive any pinned-cost tests. - */ - -import test from "node:test"; -import assert from "node:assert/strict"; -import { PRICES } from "../src/core/prices.ts"; -import { buildPrices } from "../scripts/sync-pricing.ts"; - -test("PRICES matches pi-ai's shipped catalogs exactly — the sync was run after the last bump", () => { - const catalog = buildPrices(); - - const missing = [...catalog.keys()].filter((k) => !(k in PRICES)); - const stale = Object.keys(PRICES).filter((k) => !catalog.has(k)); - const drifted: string[] = []; - for (const [key, cost] of catalog) { - const price = PRICES[key]; - if (!price) continue; - if ( - price.input !== cost.input || - price.output !== cost.output || - price.cacheRead !== cost.cacheRead || - price.cacheWrite !== cost.cacheWrite - ) { - drifted.push(key); - } - } - - assert.equal( - `${missing.length} / ${stale.length} / ${drifted.length}`, - "0 / 0 / 0", - `price table is stale: ${missing.length} unpriced catalog models (e.g. ${missing[0] ?? "—"}), ` + - `${stale.length} keys gone from the catalog (e.g. ${stale[0] ?? "—"}), ` + - `${drifted.length} drifted prices (e.g. ${drifted[0] ?? "—"}). ` + - "Run: npx tsx scripts/sync-pricing.ts, commit the regenerated file, re-derive pinned-cost tests.", - ); -}); diff --git a/tests/pricing-native-parity.test.ts b/tests/pricing-native-parity.test.ts new file mode 100644 index 0000000..80056bc --- /dev/null +++ b/tests/pricing-native-parity.test.ts @@ -0,0 +1,45 @@ +/** Catalog/calculator parity replaces the old generated-table drift test. */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { calculateCost, type Usage } from "@earendil-works/pi-ai"; +import { piModels } from "../src/agent/models.ts"; +import { callCost, jobCost } from "../src/server/pricing.ts"; +import { accumulateJobUsage, piUsageToStep } from "../src/core/contracts/jobs.ts"; + +const models = piModels().getProviders().flatMap((p) => piModels().getModels(p.id)); +const nativeUsage = (input: number): Usage => ({ input, output: 1_000, cacheRead: 10_000, cacheWrite: 2_000, cacheWrite1h: 500, totalTokens: input + 13_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }); +const saved = (u: Usage) => ({ inputTokens: u.input, outputTokens: u.output, cacheReadTokens: u.cacheRead, cacheWriteTokens: u.cacheWrite, cacheWrite1hTokens: u.cacheWrite1h, reasoningTokens: 500 }); + +test("all catalog models price through Pi, including every tier boundary; saved usage is never mutated", () => { + assert.ok(models.length > 100); + for (const model of models) { + const sizes = [1_000, ...(model.cost.tiers ?? []).flatMap((t) => [Math.max(0, t.inputTokensAbove - 12_000), Math.max(0, t.inputTokensAbove - 12_000 + 1)])]; + for (const input of sizes) { + const usage = nativeUsage(input); + const call = Object.freeze(saved(usage)); + const before = structuredClone(call); + const expected = calculateCost(model, usage).total; + assert.equal(callCost(call, `${model.provider}:${model.id}`), expected, `${model.provider}:${model.id} input=${input}`); + assert.deepEqual(call, before); + } + } +}); + +test("job capture preserves individual tier sizes and one-hour writes; old tier aggregates are unknown", () => { + const model = models.find((m) => m.cost.tiers?.length)!; + assert.ok(model); + const id = `${model.provider}:${model.id}`; + const threshold = model.cost.tiers![0]!.inputTokensAbove; + const first = nativeUsage(Math.ceil(threshold / 2)); + const second = nativeUsage(Math.ceil(threshold / 2)); + const total = accumulateJobUsage(accumulateJobUsage(undefined, id, piUsageToStep(first)), id, piUsageToStep(second)); + assert.equal(total.calls?.length, 2); + assert.equal(total.cacheWrite1hTokens, 1000); + assert.equal(total.lastStep?.cacheWrite1hTokens, 500); + assert.equal(jobCost({ usage: total }), calculateCost(model, first).total + calculateCost(model, second).total); + assert.equal(jobCost({ usage: { ...total, calls: undefined } }), null); + const legacyOne = { ...total, ...saved(first), calls: undefined, steps: 1 }; + assert.equal(jobCost({ usage: legacyOne }), calculateCost(model, first).total); + const resumedLegacy = accumulateJobUsage({ ...total, calls: undefined }, id, piUsageToStep(first)); + assert.equal(resumedLegacy.calls, undefined, "never pretend the missing old calls were reconstructed"); +}); diff --git a/tests/pricing-transport.test.ts b/tests/pricing-transport.test.ts new file mode 100644 index 0000000..bc828a7 --- /dev/null +++ b/tests/pricing-transport.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test, { after } from "node:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pricing-transport-")); +process.env.LH_DATA_DIR = scratch; +process.env.LH_AUTH_FILE = path.join(scratch, "empty-auth.json"); +fs.writeFileSync(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 }); +const { chatStore } = await import("../src/store/chats.ts"); +const { GET } = await import("../app/api/chats/[id]/route.ts"); +const { sessionCost } = await import("../src/server/pricing.ts"); +after(() => fs.rmSync(scratch, { recursive: true, force: true })); + +test("chat GET supplies derived Pi costs without persisting dollars or changing native history", async () => { + const record = chatStore.createChat("price projection fixture"); + chatStore.saveMessages(record.id, [{ id: "fixture-input", role: "user", content: "synthetic", timestamp: 1 }]); + const step = { inputTokens: 10_000, outputTokens: 1_000, cacheReadTokens: 70_000, cacheWriteTokens: 0, cacheWrite1hTokens: 0, reasoningTokens: 500 }; + for (const [id, model] of [["api", "openrouter:openai/gpt-6.1-sol"], ["plan", "chatgpt:fixture"], ["unknown", "missing:fixture"]]) { + chatStore.appendTurnUsage(record.id, { id: id!, model: model!, messageId: "fixture-input", at: "fixture", steps: [step] }); + } + const file = path.join(scratch, "chats", `${record.id}.json`); + const before = fs.readFileSync(file, "utf8"); + const response = await GET(new Request(`http://localhost/api/chats/${record.id}`), { params: Promise.resolve({ id: record.id }) }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.deepEqual(body.messages, chatStore.getChat(record.id)!.messages); + assert.deepEqual(body.pricing, sessionCost(chatStore.getChat(record.id)!)); + assert.equal(body.pricing.cost, 0.037); + assert.equal(body.pricing.subscription, 1); + assert.equal(body.pricing.unpriced, 1); + assert.deepEqual(body.pricing.turnCosts, { api: 0.037, plan: null, unknown: null }); + assert.equal(fs.readFileSync(file, "utf8"), before, "read-time estimates must never rewrite the usage log"); +}); diff --git a/tests/render-activity-dock.test.tsx b/tests/render-activity-dock.test.tsx index a0182f4..f5ce8b3 100644 --- a/tests/render-activity-dock.test.tsx +++ b/tests/render-activity-dock.test.tsx @@ -3,6 +3,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { ActivitySection, formatElapsed, toBlobs, type ActivityFeed, type FeedExtraction } from "../components/activity-dock.tsx"; import { formatTokens, formatUsd } from "../components/usage-format.ts"; +import { jobCost } from "../src/server/pricing.ts"; // The section's pure layer: folds, formatting, and blob order — then the SSR // render (blobs, "—" doctrine, the abort affordance, the always-present @@ -27,6 +28,7 @@ function job(over: Partial = {}): ActivityFeed["jo updatedAt: new Date(NOW - 1000).toISOString(), title: "Limits lesson", contextLimit: null, + costUsd: jobCost({ usage: over.usage }), ...over, }; } @@ -57,7 +59,7 @@ describe("activity dock pure layer", () => { assert.ok(blobs.every((b) => b.chatId === "src_a"), "abort/link identity stays source-scoped"); }); - it("folds cost with jobCost; absent usage is null, never zero", () => { + it("uses the server's Pi price projection; absent pricing is null, never zero", () => { const priced = job({ usage: { model: "openrouter:z-ai/glm-5.3-flash", @@ -70,10 +72,10 @@ 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 - // (pi-ai catalog prices — glm-5.3-flash cacheRead corrected - // 0.05 → 0.03 in the pi 1.0.2 catalog refresh) - assert.ok(blob.cost !== null && blob.cost > 0.0013 && blob.cost < 0.0015); + // Pi-normalized fresh input is priced directly, never reduced by reads. + assert.equal(blob.cost, 0.00215); + const missingProjection = toBlobs({ jobs: [{ ...priced, costUsd: undefined }], extractions: [] })[0]!; + assert.equal(missingProjection.cost, null, "the browser must not invent a fallback price"); const absent = toBlobs({ jobs: [job()], extractions: [] })[0]!; assert.equal(absent.cost, null); }); diff --git a/tests/render-stats-bar.test.tsx b/tests/render-stats-bar.test.tsx index 829770c..329f186 100644 --- a/tests/render-stats-bar.test.tsx +++ b/tests/render-stats-bar.test.tsx @@ -1,7 +1,8 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { StatsBar } from "../components/stats-bar.tsx"; +import { StatsBar, formatContextMeasurement } from "../components/stats-bar.tsx"; +import { sessionCost } from "../src/server/pricing.ts"; import { catalogLimits } from "../components/model-picker.tsx"; import type { ModelCatalogEntry, ModelCatalogResponse } from "../src/core/contracts.ts"; import { prettyModel } from "../components/usage-format.ts"; @@ -50,7 +51,7 @@ describe("stats bar", () => { it("folds context + cost from the turns log (core/prices, no local math)", () => { const html = renderToStaticMarkup( - , + , ); // last turn's last call: 10k in + 1k out = 11k. The LIMIT is the // live catalog's — and the bar doesn't hold the index yet (the @@ -68,7 +69,7 @@ describe("stats bar", () => { it("shows — for cost when every turn is unpriceable, never a fake $0", () => { const html = renderToStaticMarkup( - , + , ); assert.match(html, /data-testid="stats-cost">— { assert.match(html, /data-testid="stats-context">11k { + assert.equal(formatContextMeasurement(null, 100_000), "? / 100k"); + assert.equal(formatContextMeasurement(83_000, 100_000), "83k / 100k"); + assert.equal(formatContextMeasurement(83_000, 200_000), "83k / 200k"); + assert.equal(formatContextMeasurement(83_000, null), "83k"); + const html = renderToStaticMarkup(); + assert.match(html, /data-testid="stats-context">\?); + assert.match(empty, /data-testid="stats-context">\? { assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5) assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max"); diff --git a/tests/stats-meter-interaction.test.tsx b/tests/stats-meter-interaction.test.tsx new file mode 100644 index 0000000..985b022 --- /dev/null +++ b/tests/stats-meter-interaction.test.tsx @@ -0,0 +1,46 @@ +import React, { act } from "react"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRoot } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { StatsBar } from "../components/stats-bar.tsx"; +import { notifySettingsSaved } from "../components/settings-signal.ts"; +import type { ModelCatalogResponse, TurnUsage } from "../src/core/index.ts"; + +(globalThis as Record).React = React; +const dom = parseHTML(""); +Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true }); + +test("meter keeps its measured numerator across selected-model/catalog changes, and renders post-compaction unknown", async () => { + const originalFetch = globalThis.fetch; + let selected = "a"; + const catalog: ModelCatalogResponse = { + models: ["a", "b"].map((id, i) => ({ id, provider: "fixture", name: id, input: ["text"], reasoning: false, supportedLevels: ["off"], maxTokens: 1000, contextWindow: 100_000 * (i + 1), cost: { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 } })), + providers: [], defaults: null, slots: { strong: null, cheap: null, vision: null }, rolePins: {} as ModelCatalogResponse["rolePins"], + }; + globalThis.fetch = async (input) => { + if (input === "/api/models") return Response.json(catalog); + if (input === "/api/settings") return Response.json({ settings: { models: {} } }); + if (input === "/api/chats/fixture") return Response.json({ settings: { model: { provider: "fixture", id: selected }, thinkingLevel: "off" } }); + throw new Error(`unexpected fixture request ${String(input)}`); + }; + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + const turn: TurnUsage = { id: "summary", messageId: "marker", at: "fixture", model: "fixture:a", steps: [{ inputTokens: 4300, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 }] }; + const pricing = { cost: 0.003, unpriced: 0, turnCosts: { summary: 0.003 } }; + try { + await act(async () => root.render()); + const context = () => host.querySelector('[data-testid="stats-context"]')?.textContent; + assert.equal(context(), "83k / 100k", "summarizer's 4.3k money log is not the meter source"); + selected = "b"; + await act(async () => notifySettingsSaved("models")); + assert.equal(context(), "83k / 200k", "only denominator changes"); + await act(async () => root.render()); + assert.equal(context(), "? / 200k"); + assert.equal(host.querySelector('[data-testid="stats-cost"]')?.textContent, "$0.0030", "summary spending is still visible"); + await act(async () => root.render()); + assert.equal(context(), "72k / 200k"); + } finally { + await act(async () => root.unmount()); host.remove(); globalThis.fetch = originalFetch; + } +}); diff --git a/tests/usage.test.ts b/tests/usage.test.ts index ad23efe..78d8984 100644 --- a/tests/usage.test.ts +++ b/tests/usage.test.ts @@ -1,307 +1,114 @@ -/** - * Usage-accounting tests — the money channel's pure helpers (core/prices.ts) - * and the ChatStore usage-log seam (appendTurnUsage). - * - * Pure helper tests are pure. The store test uses the real fs chatStore with - * a fixture chat, cleaned up in after(). - */ import assert from "node:assert/strict"; import test from "node:test"; import * as fs from "node:fs"; - import { chatStore } from "../src/store/chats.ts"; import { graphStore } from "../src/store/graph.ts"; import { PATHS, SOURCES_FILE } from "../config.ts"; -import { callCost, contextWindow, jobCost, sessionCost, sumSteps, turnCost } from "../src/core/prices.ts"; +import { callCost, jobCost, sessionCost, turnCost } from "../src/server/pricing.ts"; +import { sumSteps } from "../src/core/prices.ts"; import type { CallUsage, ExtractionRunUsage, JobRecord, TurnUsage } from "../src/core/contracts.ts"; const call = (over: Partial = {}): CallUsage => ({ - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - reasoningTokens: undefined, - ...over, + inputTokens: 0, outputTokens: 0, cacheReadTokens: undefined, + cacheWriteTokens: undefined, reasoningTokens: undefined, ...over, }); -test("sumSteps folds steps; absent stays absent until a value appears", () => { - const sum = sumSteps([ - call({ inputTokens: 100, outputTokens: 20 }), - call({ inputTokens: 50, outputTokens: 10, cacheReadTokens: 30 }), - ]); +test("sumSteps: absent stays absent; unknown long-cache detail stays unknown", () => { + const sum = sumSteps([call({ inputTokens: 100, outputTokens: 20 }), call({ inputTokens: 50, outputTokens: 10, cacheReadTokens: 30 })]); assert.equal(sum.inputTokens, 150); assert.equal(sum.outputTokens, 30); assert.equal(sum.cacheReadTokens, 30); - assert.equal(sum.cacheWriteTokens, undefined); // never reported → stays undefined - // all-absent → all-undefined (helper defaults input/output to 0, so - // explicit undefined here) - const empty = sumSteps([call({ inputTokens: undefined, outputTokens: undefined }), call({ inputTokens: undefined, outputTokens: undefined })]); - assert.equal(empty.inputTokens, undefined); + assert.equal(sum.cacheWriteTokens, undefined); + assert.equal(sumSteps([call({ inputTokens: undefined, outputTokens: undefined })]).inputTokens, undefined); + assert.equal(sumSteps([call({ cacheWriteTokens: 10 }), call({ cacheWriteTokens: 20, cacheWrite1hTokens: 5 })]).cacheWrite1hTokens, undefined); }); -test("callCost: glm-5.3-flash math — fresh input, cached read, output", () => { +test("callCost prices normalized fresh input directly, without subtracting caches again", () => { const cost = callCost(call({ inputTokens: 100_000, outputTokens: 20_000, cacheReadTokens: 10_000 }), "openrouter:z-ai/glm-5.3-flash"); assert.ok(cost !== null); - // fresh 90k @ $0.15/M + cached 10k @ $0.03/M + output 20k @ $0.5/M - // (pi-ai catalog prices — regenerated tables, scripts/sync-pricing; - // glm-5.3-flash cacheRead corrected 0.05 → 0.03 in pi 1.0.2) - assert.ok(Math.abs(cost! - (0.0135 + 0.0003 + 0.01)) < 1e-9); + assert.ok(Math.abs(cost - (0.015 + 0.0003 + 0.01)) < 1e-9); + assert.equal(callCost(call({ inputTokens: 10_000, outputTokens: 1_000, cacheReadTokens: 70_000 }), "openrouter:openai/gpt-6.1-sol"), 0.037); }); -test("callCost: cache writes bill at the cacheWrite rate", () => { - const cost = callCost( - call({ inputTokens: 1_000_000, outputTokens: 0, cacheWriteTokens: 500_000 }), - "anthropic:claude-haiku-4-5", - ); - assert.ok(cost !== null); - // Native 1.4 catalog: fresh 1M @ $1/M + cache write 500k @ $1.25/M. - assert.ok(Math.abs(cost! - (1 + 0.625)) < 1e-9); +test("cache writes: precise short/one-hour costs and honest unknown historical splits", () => { + const usage = call({ inputTokens: 1_000_000, cacheWriteTokens: 500_000, cacheWrite1hTokens: 0 }); + assert.equal(callCost(usage, "anthropic:claude-haiku-4-5"), 1.625); + assert.equal(callCost({ ...usage, cacheWrite1hTokens: 200_000 }, "anthropic:claude-haiku-4-5"), 1.775); + assert.equal(callCost({ ...usage, cacheWrite1hTokens: undefined }, "anthropic:claude-haiku-4-5"), null); }); -test("callCost: unknown model and absent fields are null, never zero", () => { - assert.equal(callCost(call({ inputTokens: 100, outputTokens: 5 }), "not/a-model"), null); - assert.equal(callCost(call({ inputTokens: 100, outputTokens: undefined }), "openrouter:z-ai/glm-5.3-flash"), null); +test("unknown model, absent or invalid required usage is null, never fake zero", () => { + assert.equal(callCost(call(), "not/a-model"), null); + assert.equal(callCost(call({ outputTokens: undefined }), "openai:gpt-5"), null); + for (const value of [-1, NaN, Infinity]) assert.equal(callCost(call({ inputTokens: value }), "openai:gpt-5"), null); }); -test("turnCost sums steps; sessionCost reports unpriced turns", () => { - const turn: TurnUsage = { - id: "t1", - messageId: "m1", - at: "2026-09-09T00:00:00Z", - model: "openrouter:z-ai/glm-5.3-flash", - steps: [call({ inputTokens: 10_000, outputTokens: 2_000 }), call({ inputTokens: 12_000, outputTokens: 1_000 })], - }; - assert.ok(turnCost(turn) !== null); - const totals = sessionCost({ - turns: [turn, { id: "t2", messageId: "m2", at: "x", model: "not/a-model", steps: [call({ inputTokens: 1, outputTokens: 1 })] }], - }); +test("turn/session costs sum calls and count unpriced separately", () => { + const turn: TurnUsage = { id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 10_000, outputTokens: 2_000 }), call({ inputTokens: 12_000, outputTokens: 1_000 })] }; + const totals = sessionCost({ turns: [turn, { ...turn, id: "t2", model: "not/a-model" }] }); assert.equal(totals.unpriced, 1); - assert.ok(totals.cost > 0); + assert.equal(totals.cost, turnCost(turn)); + assert.equal(totals.turnCosts.t1, totals.cost); + assert.equal(totals.turnCosts.t2, null); }); -test("contextWindow: last call's footprint against the live index's limit", () => { - const limits = new Map([["openrouter:z-ai/glm-5.3-flash", 1_048_576]]); - const cw = contextWindow( - { - turns: [ - { id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 9_000, outputTokens: 1_000 })] }, - { id: "t2", messageId: "m2", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 90_000, outputTokens: 10_000 })] }, - ], - }, - limits, - ); - assert.ok(cw); - assert.equal(cw.model, "openrouter:z-ai/glm-5.3-flash"); - assert.equal(cw.usedTokens, 100_000); - assert.equal(cw.limit, 1_048_576); - assert.equal(contextWindow({ turns: [] }, limits), null); -}); - -test("contextWindow NUMERATOR: cache tokens count (owner audit, 2026-09-23)", () => { - // The audit's live case: a cache-heavy session read 40k on the meter - // while the real footprint was ~190k — provider `input` EXCLUDES the - // cached portion, so input+output alone is wildly low. The fold is - // pi's totalTokens semantics: all four fields. - const limits = new Map([["openrouter:z-ai/glm-5.3-flash", 1_048_576]]); - const cw = contextWindow( - { - turns: [ - { - id: "t1", - messageId: "m1", - at: "x", - model: "openrouter:z-ai/glm-5.3-flash", - steps: [call({ inputTokens: 40_000, outputTokens: 1_000, cacheReadTokens: 145_000, cacheWriteTokens: 4_000 })], - }, - ], - }, - limits, - ); - assert.ok(cw); - assert.equal(cw.usedTokens, 40_000 + 1_000 + 145_000 + 4_000, "input + output + cacheRead + cacheWrite"); - // Cache fields absent (non-caching providers): the fold is unchanged. - const plain = contextWindow( - { turns: [{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 9_000, outputTokens: 1_000 })] }] }, - limits, - ); - assert.ok(plain); - assert.equal(plain.usedTokens, 10_000); -}); - -test("contextWindow DENOMINATOR: the live index is the ONLY source — no fallback (owner amendment, 2026-09-23)", () => { - const turns = [ - { id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 10_000, outputTokens: 1_000 })] }, - ]; - const live = new Map([["openrouter:z-ai/glm-5.3-flash", 65_536]]); - const cw = contextWindow({ turns }, live); - assert.ok(cw); - assert.equal(cw.limit, 65_536, "pi's catalog, via the index, is the one source"); - // No index at all (a client that hasn't fetched the catalog): the - // meter renders usage-only — limit null, never a guessed window. - const noIndex = contextWindow({ turns }); - assert.ok(noIndex); - assert.equal(noIndex.usedTokens, 11_000); - assert.equal(noIndex.limit, null); - const emptyIndex = contextWindow({ turns }, new Map()); - assert.ok(emptyIndex); - assert.equal(emptyIndex.limit, null, "an empty index (failed fetch) is usage-only too"); - // A model missing from the live catalog (historical session, package - // update removed it): usage-only, never a fake window. - const missing = contextWindow({ turns: [{ ...turns[0]!, model: "nope/gone-model" }] }, live); - assert.ok(missing); - assert.equal(missing.limit, null); - assert.equal(missing.usedTokens, 11_000, "the footprint still reads honestly"); -}); - -test("contextWindow ride-along: a zeroed/aborted last step falls back to the previous step", () => { - // An aborted last call reports zeroed usage — that is not the session - // footprint. The honest reading is the previous step's usage. - const limits = new Map([["openrouter:z-ai/glm-5.3-flash", 1_048_576]]); - const cw = contextWindow( - { - turns: [ - { - id: "t1", - messageId: "m1", - at: "x", - model: "openrouter:z-ai/glm-5.3-flash", - steps: [ - call({ inputTokens: 80_000, outputTokens: 5_000 }), - call({ inputTokens: 0, outputTokens: 0 }), // aborted — zeroed - ], - }, - ], - }, - limits, - ); - assert.ok(cw); - assert.equal(cw.usedTokens, 85_000, "the previous step's footprint, not a bogus tiny meter"); - // ALL steps zeroed (a first-call abort): no reading at all — null. - assert.equal( - contextWindow( - { turns: [{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 0, outputTokens: 0 })] }] }, - limits, - ), - null, - ); -}); - -test("jobCost: null without usage, computed with it", () => { +test("legacy flat-rate jobs remain priceable; missing usage stays absent", () => { assert.equal(jobCost({}), null); - const job: Pick = { - usage: { - model: "openrouter:z-ai/glm-5.3-flash", - inputTokens: 1_000_000, - outputTokens: 100_000, - cacheReadTokens: 0, - cacheWriteTokens: 0, - reasoningTokens: 0, - steps: 4, - }, - }; - assert.ok(jobCost(job) !== null); + const job: Pick = { usage: { model: "openrouter:z-ai/glm-5.3-flash", inputTokens: 1_000_000, outputTokens: 100_000, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, steps: 4 } }; assert.ok(jobCost(job)! > 0); }); -// ─── the store seam ────────────────────────────────────────────────────────── - -test("appendTurnUsage: appends, idempotent per turn id, continuation turns (same message) all kept, survives saveMessages", () => { - const record = chatStore.createChat("usage test chat"); - const entry: TurnUsage = { - id: "turn_1", - messageId: "msg_a", - at: "2026-09-09T00:00:00Z", - model: "openrouter:z-ai/glm-5.3-flash", - steps: [call({ inputTokens: 100, outputTokens: 10 })], - }; - chatStore.appendTurnUsage(record.id, entry); - chatStore.appendTurnUsage(record.id, entry); // replay (abort/retry) → no-op - // continuation turn: SAME response message (SDK coalesces), new turn id — - // usage must survive, not collapse into the first entry - chatStore.appendTurnUsage(record.id, { ...entry, id: "turn_2" }); - let reread = chatStore.getChat(record.id); - assert.equal(reread?.turns?.length, 2); - - // saveMessages must not clobber the log (read-mutate-write record) — - // Wave B: a NATIVE message list (the record holds agent history). - chatStore.saveMessages(record.id, [ - { id: "msg_a", role: "user", content: "native", timestamp: Date.now() } as never, - ]); - reread = chatStore.getChat(record.id); - assert.equal(reread?.turns?.length, 2); +test("appendTurnUsage: idempotent per turn; continuations survive saveMessages; list omits log", () => { + const record = chatStore.createChat("usage fixture"); + try { + const entry: TurnUsage = { id: "turn_1", messageId: "msg_a", at: "x", model: "openai:gpt-5", steps: [call({ inputTokens: 100, outputTokens: 10, cacheWrite1hTokens: 0 })] }; + chatStore.appendTurnUsage(record.id, entry); + chatStore.appendTurnUsage(record.id, entry); + chatStore.appendTurnUsage(record.id, { ...entry, id: "turn_2" }); + assert.equal(chatStore.getChat(record.id)?.turns?.length, 2); + chatStore.saveMessages(record.id, [{ id: "msg_a", role: "user", content: "fixture", timestamp: 1 }]); + assert.deepEqual(chatStore.getChat(record.id)?.turns?.[0]?.steps, JSON.parse(JSON.stringify(entry.steps))); + assert.equal(chatStore.getChat(record.id)?.turns?.length, 2); + assert.equal("turns" in chatStore.listChats().find((c) => c.id === record.id)!, false); + } finally { fs.unlinkSync(`${PATHS.chats}/${record.id}.json`); } +}); - // and the list payload must NOT leak turns - const listed = chatStore.listChats().find((c) => c.id === record.id); - assert.ok(listed); - assert.equal("turns" in listed, false); - - fs.unlinkSync(`${PATHS.chats}/${record.id}.json`); +const runEntry = (startedAt: number): ExtractionRunUsage => ({ + startedAt, at: "2026-09-09T00:00:00Z", durationMs: 42_000, + stats: { nodes: 1, chains: 2 }, stages: { + extractor: { model: "openai:gpt-5", steps: [call({ inputTokens: 100, outputTokens: 10 })] }, + auditor: { model: "openai:gpt-5", steps: [call({ inputTokens: 50, outputTokens: 5 })] }, + }, }); - -test("appendExtractionUsage: appends, idempotent per startedAt, not in list payload", () => { - const record = chatStore.createChat("extraction usage test chat"); - const runEntry = (startedAt: number): ExtractionRunUsage => ({ - startedAt, - at: "2026-09-09T00:00:00Z", - durationMs: 42_000, - stats: { nodes: 1, chains: 2 }, - stages: { - extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] }, - auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] }, - }, - }); - chatStore.appendExtractionUsage(record.id, runEntry(1)); - chatStore.appendExtractionUsage(record.id, runEntry(1)); // same run appended twice → no-op - chatStore.appendExtractionUsage(record.id, runEntry(2)); // a retried run is a NEW spend - let reread = chatStore.getChat(record.id); - assert.equal(reread?.extractions?.length, 2); - // failed runs (stats absent) persist with usage — money was spent - chatStore.appendExtractionUsage(record.id, { ...runEntry(3), stats: undefined }); - reread = chatStore.getChat(record.id); - assert.equal(reread?.extractions?.length, 3); - assert.equal(reread?.extractions?.at(-1)?.stats, undefined); - - // the list payload must NOT leak the extractions log - const listed = chatStore.listChats().find((c) => c.id === record.id); - assert.ok(listed); - assert.equal("extractions" in listed, false); - fs.unlinkSync(`${PATHS.chats}/${record.id}.json`); +test("appendExtractionUsage: idempotent; failed runs persist; list omits log", () => { + const record = chatStore.createChat("extraction usage fixture"); + try { + chatStore.appendExtractionUsage(record.id, runEntry(1)); + chatStore.appendExtractionUsage(record.id, runEntry(1)); + chatStore.appendExtractionUsage(record.id, runEntry(2)); + chatStore.appendExtractionUsage(record.id, { ...runEntry(3), stats: undefined }); + assert.equal(chatStore.getChat(record.id)?.extractions?.length, 3); + assert.equal(chatStore.getChat(record.id)?.extractions?.at(-1)?.stats, undefined); + assert.equal("extractions" in chatStore.listChats().find((c) => c.id === record.id)!, false); + } finally { fs.unlinkSync(`${PATHS.chats}/${record.id}.json`); } }); -test("appendSourceUsage: appends to the source record, idempotent per startedAt, upsert keeps history", () => { - // The real fs graphStore — sources.json backed up and restored around - // this test (same discipline as the chat fixture files). +test("appendSourceUsage: idempotent; reimport keeps history; absent source is a no-op", () => { fs.mkdirSync(PATHS.graph, { recursive: true }); - let bak: string | undefined; - if (fs.existsSync(SOURCES_FILE)) bak = `${SOURCES_FILE}.w5test.bak`; - if (bak) fs.renameSync(SOURCES_FILE, bak); + const backup = fs.existsSync(SOURCES_FILE) ? fs.readFileSync(SOURCES_FILE) : undefined; try { - const runEntry = (startedAt: number): ExtractionRunUsage => ({ - startedAt, - at: "2026-09-13T00:00:00Z", - durationMs: 42_000, - stats: { nodes: 1, chains: 2 }, - stages: { - extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] }, - auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] }, - }, - }); const id = "src_usagetest01"; - graphStore.upsertSource({ id, filePath: "/vault/usagetest.md", importedAt: "2026-09-13T00:00:00Z" }); + graphStore.upsertSource({ id, filePath: "/fixture/notes.md", importedAt: "2026-09-13T00:00:00Z" }); graphStore.appendSourceUsage(id, runEntry(1)); - graphStore.appendSourceUsage(id, runEntry(1)); // same run appended twice → no-op - graphStore.appendSourceUsage(id, runEntry(2)); // a retried run is a NEW spend + graphStore.appendSourceUsage(id, runEntry(1)); + graphStore.appendSourceUsage(id, runEntry(2)); assert.equal(graphStore.readSources().find((s) => s.id === id)?.extractions?.length, 2); - - // An upsert (re-import) KEEPS the usage history. - graphStore.upsertSource({ id, filePath: "/vault/usagetest.md", importedAt: "2026-09-14T00:00:00Z" }); + graphStore.upsertSource({ id, filePath: "/fixture/notes.md", importedAt: "2026-09-14T00:00:00Z" }); assert.equal(graphStore.readSources().find((s) => s.id === id)?.extractions?.length, 2); - - // Unknown ids are a silent no-op (nothing to bill against). - graphStore.appendSourceUsage("src_nope", runEntry(1)); + graphStore.appendSourceUsage("src_missing", runEntry(1)); } finally { - fs.rmSync(SOURCES_FILE, { force: true }); - if (bak) fs.renameSync(bak, SOURCES_FILE); + if (backup) fs.writeFileSync(SOURCES_FILE, backup); + else fs.rmSync(SOURCES_FILE, { force: true }); } });