/** * Per-model pricing in USD per million tokens. Used to estimate the * cost of an AIGeneration row from its tokensIn/tokensOut. * * Prices change. This table is a best-effort starting point for * common models as of mid-2026; users on other models will see * `null` cost (we still surface token counts). Updating: edit this * file and ship — no schema change needed. * * Matching strategy: case-insensitive prefix lookup against the * user's configured model string. Model names like * "claude-sonnet-4-5-20251022" match the "claude-sonnet-4-5" prefix. * * Keys are organized by provider for readability but the lookup is * provider-agnostic — the model string is the key. */ interface PriceEntry { inputPerM: number; // USD per 1M input tokens outputPerM: number; // USD per 1M output tokens } const PRICES: Record = { // Anthropic Claude (Messages API) — opus tier $15/$75, sonnet $3/$15, // haiku $0.80/$4. New point releases inherit their tier's pricing. 'claude-opus-4-7': { inputPerM: 15, outputPerM: 75 }, 'claude-opus-4-6': { inputPerM: 15, outputPerM: 75 }, 'claude-opus-4-5': { inputPerM: 15, outputPerM: 75 }, 'claude-opus-4': { inputPerM: 15, outputPerM: 75 }, 'claude-sonnet-4-6': { inputPerM: 3, outputPerM: 15 }, 'claude-sonnet-4-5': { inputPerM: 3, outputPerM: 15 }, 'claude-sonnet-4': { inputPerM: 3, outputPerM: 15 }, 'claude-haiku-4-5': { inputPerM: 0.8, outputPerM: 4 }, 'claude-haiku-4': { inputPerM: 0.8, outputPerM: 4 }, 'claude-3-7-sonnet': { inputPerM: 3, outputPerM: 15 }, 'claude-3-5-sonnet': { inputPerM: 3, outputPerM: 15 }, 'claude-3-5-haiku': { inputPerM: 0.8, outputPerM: 4 }, // OpenAI — gpt-5.x flagships ~$1.25-$2/$10-$15, mini/nano cheaper 'gpt-5.5': { inputPerM: 2, outputPerM: 15 }, 'gpt-5.4': { inputPerM: 1.5, outputPerM: 12 }, 'gpt-5.4-mini': { inputPerM: 0.3, outputPerM: 2.4 }, 'gpt-5.4-nano': { inputPerM: 0.06, outputPerM: 0.5 }, 'gpt-5.3': { inputPerM: 1.5, outputPerM: 12 }, 'gpt-5.2': { inputPerM: 1.5, outputPerM: 12 }, 'gpt-5.1': { inputPerM: 1.25, outputPerM: 10 }, 'gpt-5': { inputPerM: 1.25, outputPerM: 10 }, 'gpt-5-mini': { inputPerM: 0.25, outputPerM: 2 }, 'gpt-5-nano': { inputPerM: 0.05, outputPerM: 0.4 }, 'gpt-4o': { inputPerM: 2.5, outputPerM: 10 }, 'gpt-4o-mini': { inputPerM: 0.15, outputPerM: 0.6 }, 'o1': { inputPerM: 15, outputPerM: 60 }, 'o3': { inputPerM: 2, outputPerM: 8 }, 'o3-mini': { inputPerM: 1.1, outputPerM: 4.4 }, 'o4-mini': { inputPerM: 1.1, outputPerM: 4.4 }, // Google Gemini — Gemini 3.1 Pro is $2/$12 standard; >200K ctx is 2x. 'gemini-3.1-pro-preview': { inputPerM: 2, outputPerM: 12 }, 'gemini-3.1-pro': { inputPerM: 2, outputPerM: 12 }, 'gemini-3-pro-preview': { inputPerM: 2, outputPerM: 12 }, 'gemini-3-pro': { inputPerM: 2, outputPerM: 12 }, 'gemini-2.5-pro': { inputPerM: 1.25, outputPerM: 10 }, 'gemini-2.5-flash': { inputPerM: 0.3, outputPerM: 2.5 }, 'gemini-2.0-flash': { inputPerM: 0.1, outputPerM: 0.4 }, 'gemini-2.0-pro': { inputPerM: 1.25, outputPerM: 5 }, 'gemini-1.5-pro': { inputPerM: 1.25, outputPerM: 5 }, 'gemini-1.5-flash': { inputPerM: 0.075, outputPerM: 0.3 }, }; /** * Per-provider model menus — source of truth for the "Model" dropdown * in Settings → AI integration. `recommended` floats to the top. Users * can still type a custom model name (the dropdown has an "Other" * option that switches to free-text input). Order = display order. * * Update these when new models ship. Keys correspond to provider IDs * in lib/ai/providers/index.ts. */ export interface ModelOption { /** Exact API model identifier */ id: string; /** Human-readable label shown in the dropdown */ label: string; /** Floats to the top + gets a "★" mark */ recommended?: boolean; } export const MODEL_MENU: Record = { claude: [ { id: 'claude-opus-4-7', label: 'Claude Opus 4.7 (most capable)', recommended: true }, { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (1M context, fast)', recommended: true }, { id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5 (cheapest, fastest)', recommended: true }, { id: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, { id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }, { id: 'claude-3-7-sonnet-latest', label: 'Claude 3.7 Sonnet' }, ], openai: [ { id: 'gpt-5.5', label: 'GPT-5.5 (most capable)', recommended: true }, { id: 'gpt-5.4', label: 'GPT-5.4', recommended: true }, { id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini (cheap, fast)', recommended: true }, { id: 'gpt-5.4-nano', label: 'GPT-5.4 Nano (cheapest)' }, { id: 'gpt-5', label: 'GPT-5' }, { id: 'gpt-4o', label: 'GPT-4o (legacy)' }, { id: 'o3', label: 'o3 (reasoning)' }, ], gemini: [ { id: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview (most capable)', recommended: true }, { id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', recommended: true }, { id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash (cheap, fast)', recommended: true }, { id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, { id: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro (legacy)' }, ], // openai-compatible + ollama: no curated menu — model names are // gateway- or host-specific. Ollama auto-detects via /api/tags. 'openai-compatible': [], ollama: [], }; /** Find the price entry whose key is a (case-insensitive) prefix of the model string. */ export function findPrice(model: string): PriceEntry | null { const m = model.toLowerCase(); // Longest-prefix-first so e.g. "claude-sonnet-4-5" beats "claude-sonnet-4". const sortedKeys = Object.keys(PRICES).sort((a, b) => b.length - a.length); for (const key of sortedKeys) { if (m.startsWith(key.toLowerCase())) return PRICES[key]; } return null; } /** * Estimate the USD cost of a generation. Returns null if the model * isn't in the price table or if either token count is missing. * Ollama and openai-compatible custom gateways always return null * (they're either free or self-priced). */ export function estimateCost(opts: { provider: string; model: string; tokensIn: number | null; tokensOut: number | null; }): number | null { if (opts.provider === 'ollama') return 0; // self-hosted, no per-token cost if (opts.provider === 'openai-compatible') return null; // we don't know the gateway's pricing if (opts.tokensIn == null || opts.tokensOut == null) return null; const price = findPrice(opts.model); if (!price) return null; return ( (opts.tokensIn / 1_000_000) * price.inputPerM + (opts.tokensOut / 1_000_000) * price.outputPerM ); } /** Format USD to a string suitable for a UI label. Below $0.01 -> "<$0.01". */ export function formatCost(usd: number | null): string { if (usd == null) return '—'; if (usd === 0) return 'free'; if (usd < 0.01) return '<$0.01'; if (usd < 1) return `$${usd.toFixed(3)}`; return `$${usd.toFixed(2)}`; }