mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 12:20:43 +00:00
288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
import { readFile, writeFile, mkdir } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { homedir } from 'os'
|
|
import snapshotData from './data/litellm-snapshot.json'
|
|
|
|
export type ModelCosts = {
|
|
inputCostPerToken: number
|
|
outputCostPerToken: number
|
|
cacheWriteCostPerToken: number
|
|
cacheReadCostPerToken: number
|
|
webSearchCostPerRequest: number
|
|
fastMultiplier: number
|
|
}
|
|
|
|
type LiteLLMEntry = {
|
|
input_cost_per_token?: number
|
|
output_cost_per_token?: number
|
|
cache_creation_input_token_cost?: number
|
|
cache_read_input_token_cost?: number
|
|
provider_specific_entry?: { fast?: number }
|
|
}
|
|
|
|
type SnapshotEntry = [number, number, number | null, number | null]
|
|
|
|
const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
|
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|
const WEB_SEARCH_COST = 0.01
|
|
|
|
const FAST_MULTIPLIERS: Record<string, number> = {
|
|
'claude-opus-4-7': 6,
|
|
'claude-opus-4-6': 6,
|
|
}
|
|
|
|
function loadSnapshot(): Map<string, ModelCosts> {
|
|
const map = new Map<string, ModelCosts>()
|
|
for (const [name, raw] of Object.entries(snapshotData as unknown as Record<string, SnapshotEntry>)) {
|
|
const [input, output, cacheWrite, cacheRead] = raw
|
|
map.set(name, {
|
|
inputCostPerToken: input,
|
|
outputCostPerToken: output,
|
|
cacheWriteCostPerToken: cacheWrite ?? input * 1.25,
|
|
cacheReadCostPerToken: cacheRead ?? input * 0.1,
|
|
webSearchCostPerRequest: WEB_SEARCH_COST,
|
|
fastMultiplier: FAST_MULTIPLIERS[name] ?? 1,
|
|
})
|
|
}
|
|
return map
|
|
}
|
|
|
|
let pricingCache: Map<string, ModelCosts> = loadSnapshot()
|
|
|
|
function getCacheDir(): string {
|
|
return join(homedir(), '.cache', 'codeburn')
|
|
}
|
|
|
|
function getCachePath(): string {
|
|
return join(getCacheDir(), 'litellm-pricing.json')
|
|
}
|
|
|
|
function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null {
|
|
if (entry.input_cost_per_token === undefined || entry.output_cost_per_token === undefined) return null
|
|
return {
|
|
inputCostPerToken: entry.input_cost_per_token,
|
|
outputCostPerToken: entry.output_cost_per_token,
|
|
cacheWriteCostPerToken: entry.cache_creation_input_token_cost ?? entry.input_cost_per_token * 1.25,
|
|
cacheReadCostPerToken: entry.cache_read_input_token_cost ?? entry.input_cost_per_token * 0.1,
|
|
webSearchCostPerRequest: WEB_SEARCH_COST,
|
|
fastMultiplier: entry.provider_specific_entry?.fast ?? 1,
|
|
}
|
|
}
|
|
|
|
async function fetchAndCachePricing(): Promise<Map<string, ModelCosts>> {
|
|
const response = await fetch(LITELLM_URL)
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
const data = await response.json() as Record<string, LiteLLMEntry>
|
|
const pricing = new Map<string, ModelCosts>()
|
|
|
|
for (const [name, entry] of Object.entries(data)) {
|
|
const costs = parseLiteLLMEntry(entry)
|
|
if (!costs) continue
|
|
pricing.set(name, costs)
|
|
// Also index by stripped name so lookups work without provider prefix:
|
|
// 'anthropic/claude-opus-4-6' is also queryable as 'claude-opus-4-6'.
|
|
// First write wins so direct-provider entries take precedence over re-hosters.
|
|
const stripped = name.replace(/^[^/]+\//, '')
|
|
if (stripped !== name && !pricing.has(stripped)) pricing.set(stripped, costs)
|
|
}
|
|
|
|
await mkdir(getCacheDir(), { recursive: true })
|
|
await writeFile(getCachePath(), JSON.stringify({
|
|
timestamp: Date.now(),
|
|
data: Object.fromEntries(pricing),
|
|
}))
|
|
|
|
return pricing
|
|
}
|
|
|
|
async function loadCachedPricing(): Promise<Map<string, ModelCosts> | null> {
|
|
try {
|
|
const raw = await readFile(getCachePath(), 'utf-8')
|
|
const cached = JSON.parse(raw) as { timestamp: number; data: Record<string, ModelCosts> }
|
|
if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null
|
|
return new Map(Object.entries(cached.data))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function loadPricing(): Promise<void> {
|
|
const cached = await loadCachedPricing()
|
|
if (cached) {
|
|
pricingCache = cached
|
|
return
|
|
}
|
|
|
|
try {
|
|
pricingCache = await fetchAndCachePricing()
|
|
} catch {
|
|
// snapshot already loaded at init; nothing more to do
|
|
}
|
|
}
|
|
|
|
// Known model name variants that providers emit but LiteLLM/fallback don't index under.
|
|
// OMP emits 'anthropic--claude-4.6-opus' (double-dash, dot version, tier-last).
|
|
// getCanonicalName strips any 'provider/' prefix first, so only the post-strip
|
|
// forms need to be listed here.
|
|
const BUILTIN_ALIASES: Record<string, string> = {
|
|
'anthropic--claude-4.6-opus': 'claude-opus-4-6',
|
|
'anthropic--claude-4.6-sonnet': 'claude-sonnet-4-6',
|
|
'anthropic--claude-4.5-opus': 'claude-opus-4-5',
|
|
'anthropic--claude-4.5-sonnet': 'claude-sonnet-4-5',
|
|
'anthropic--claude-4.5-haiku': 'claude-haiku-4-5',
|
|
'cursor-auto': 'claude-sonnet-4-5',
|
|
'cursor-agent-auto': 'claude-sonnet-4-5',
|
|
'copilot-auto': 'claude-sonnet-4-5',
|
|
'copilot-openai-auto': 'gpt-5.3-codex',
|
|
'copilot-anthropic-auto': 'claude-sonnet-4-5',
|
|
'kiro-auto': 'claude-sonnet-4-5',
|
|
'cline-auto': 'claude-sonnet-4-5',
|
|
'openclaw-auto': 'claude-sonnet-4-5',
|
|
'qwen-auto': 'claude-sonnet-4-5',
|
|
// Cursor emits dot-version tier-last names
|
|
'claude-4.6-sonnet': 'claude-sonnet-4-6',
|
|
'claude-4.5-sonnet-thinking': 'claude-sonnet-4-5',
|
|
'claude-4-sonnet-thinking': 'claude-sonnet-4-5',
|
|
'claude-4-opus': 'claude-opus-4-5',
|
|
'claude-4.5-opus-high-thinking': 'claude-opus-4-5',
|
|
'gpt-4.1': 'gpt-4.1',
|
|
'gpt-5.2-low': 'gpt-5',
|
|
'gpt-5.1-codex-high': 'gpt-5.3-codex',
|
|
// Antigravity Gemini model IDs resolve to preview-priced entries.
|
|
'gemini-3.1-pro': 'gemini-3.1-pro-preview',
|
|
'gemini-3-flash': 'gemini-3-flash-preview',
|
|
'gemini-3.1-pro-high': 'gemini-3.1-pro-preview',
|
|
'gemini-3.1-pro-low': 'gemini-3.1-pro-preview',
|
|
'gemini-3-flash-agent': 'gemini-3-flash-preview',
|
|
'gemini-3-pro': 'gemini-3-pro-preview',
|
|
'gemini-3.1-flash-image': 'gemini-3.1-flash-image-preview',
|
|
'gemini-3.1-flash-lite': 'gemini-3.1-flash-lite-preview',
|
|
}
|
|
|
|
let userAliases: Record<string, string> = {}
|
|
|
|
// Called once during CLI startup after config is loaded.
|
|
// User aliases take precedence over built-ins.
|
|
export function setModelAliases(aliases: Record<string, string>): void {
|
|
userAliases = aliases
|
|
}
|
|
|
|
function resolveAlias(model: string): string {
|
|
if (Object.hasOwn(userAliases, model)) return userAliases[model]!
|
|
if (Object.hasOwn(BUILTIN_ALIASES, model)) return BUILTIN_ALIASES[model]!
|
|
return model
|
|
}
|
|
function getCanonicalName(model: string): string {
|
|
return model
|
|
.replace(/@.*$/, '') // strip pin: claude-sonnet-4-6@20250929 -> claude-sonnet-4-6
|
|
.replace(/-\d{8}$/, '') // strip date: claude-sonnet-4-20250514 -> claude-sonnet-4
|
|
.replace(/^[^/]+\//, '') // strip provider prefix: anthropic/foo -> foo
|
|
}
|
|
|
|
export function getModelCosts(model: string): ModelCosts | null {
|
|
// Try with provider prefix preserved (azure/gpt-5.4, openrouter/anthropic/claude-opus-4.6)
|
|
const withPrefix = model.replace(/@.*$/, '').replace(/-\d{8}$/, '')
|
|
if (pricingCache.has(withPrefix)) return pricingCache.get(withPrefix)!
|
|
|
|
const canonical = resolveAlias(getCanonicalName(model))
|
|
if (pricingCache.has(canonical)) return pricingCache.get(canonical)!
|
|
|
|
for (const [key, costs] of pricingCache) {
|
|
if (canonical.startsWith(key + '-') || canonical.startsWith(key)) return costs
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export function calculateCost(
|
|
model: string,
|
|
inputTokens: number,
|
|
outputTokens: number,
|
|
cacheCreationTokens: number,
|
|
cacheReadTokens: number,
|
|
webSearchRequests: number,
|
|
speed: 'standard' | 'fast' = 'standard',
|
|
): number {
|
|
const costs = getModelCosts(model)
|
|
if (!costs) return 0
|
|
|
|
const multiplier = speed === 'fast' ? costs.fastMultiplier : 1
|
|
|
|
return multiplier * (
|
|
inputTokens * costs.inputCostPerToken +
|
|
outputTokens * costs.outputCostPerToken +
|
|
cacheCreationTokens * costs.cacheWriteCostPerToken +
|
|
cacheReadTokens * costs.cacheReadCostPerToken +
|
|
webSearchRequests * costs.webSearchCostPerRequest
|
|
)
|
|
}
|
|
|
|
const autoModelNames: Record<string, string> = {
|
|
'cursor-auto': 'Cursor (auto)',
|
|
'cursor-agent-auto': 'Cursor (auto)',
|
|
'copilot-auto': 'Copilot (auto)',
|
|
'copilot-openai-auto': 'Copilot (OpenAI)',
|
|
'copilot-anthropic-auto': 'Copilot (Anthropic)',
|
|
'kiro-auto': 'Kiro (auto)',
|
|
'cline-auto': 'Cline (auto)',
|
|
'openclaw-auto': 'OpenClaw (auto)',
|
|
'qwen-auto': 'Qwen (auto)',
|
|
}
|
|
|
|
export function getShortModelName(model: string): string {
|
|
if (autoModelNames[model]) return autoModelNames[model]
|
|
const canonical = resolveAlias(getCanonicalName(model))
|
|
const shortNames: Record<string, string> = {
|
|
'claude-opus-4-7': 'Opus 4.7',
|
|
'claude-opus-4-6': 'Opus 4.6',
|
|
'claude-opus-4-5': 'Opus 4.5',
|
|
'claude-opus-4-1': 'Opus 4.1',
|
|
'claude-opus-4': 'Opus 4',
|
|
'claude-sonnet-4-6': 'Sonnet 4.6',
|
|
'claude-sonnet-4-5': 'Sonnet 4.5',
|
|
'claude-sonnet-4': 'Sonnet 4',
|
|
'claude-3-7-sonnet': 'Sonnet 3.7',
|
|
'claude-3-5-sonnet': 'Sonnet 3.5',
|
|
'claude-haiku-4-5': 'Haiku 4.5',
|
|
'claude-3-5-haiku': 'Haiku 3.5',
|
|
'gpt-4o-mini': 'GPT-4o Mini',
|
|
'gpt-4o': 'GPT-4o',
|
|
'gpt-4.1-nano': 'GPT-4.1 Nano',
|
|
'gpt-4.1-mini': 'GPT-4.1 Mini',
|
|
'gpt-4.1': 'GPT-4.1',
|
|
'codex-auto-review': 'Codex Auto Review',
|
|
'gpt-5.5-pro': 'GPT-5.5 Pro',
|
|
'gpt-5.5': 'GPT-5.5',
|
|
'gpt-5.4-pro': 'GPT-5.4 Pro',
|
|
'gpt-5.4-nano': 'GPT-5.4 Nano',
|
|
'gpt-5.4-mini': 'GPT-5.4 Mini',
|
|
'gpt-5.4': 'GPT-5.4',
|
|
'gpt-5.3-codex': 'GPT-5.3 Codex',
|
|
'gpt-5.3': 'GPT-5.3',
|
|
'gpt-5.2-pro': 'GPT-5.2 Pro',
|
|
'gpt-5.2-low': 'GPT-5.2 Low',
|
|
'gpt-5.2': 'GPT-5.2',
|
|
'gpt-5.1-codex-mini': 'GPT-5.1 Codex Mini',
|
|
'gpt-5.1-codex': 'GPT-5.1 Codex',
|
|
'gpt-5.1': 'GPT-5.1',
|
|
'gpt-5-pro': 'GPT-5 Pro',
|
|
'gpt-5-nano': 'GPT-5 Nano',
|
|
'gpt-5-mini': 'GPT-5 Mini',
|
|
'gpt-5': 'GPT-5',
|
|
'gemini-3.1-pro-preview': 'Gemini 3.1 Pro',
|
|
'gemini-3-flash-preview': 'Gemini 3 Flash',
|
|
'gemini-2.5-pro': 'Gemini 2.5 Pro',
|
|
'gemini-2.5-flash': 'Gemini 2.5 Flash',
|
|
'deepseek-coder-max': 'DeepSeek Coder Max',
|
|
'deepseek-coder': 'DeepSeek Coder',
|
|
'deepseek-r1': 'DeepSeek R1',
|
|
'o4-mini': 'o4-mini',
|
|
'o3': 'o3',
|
|
'MiniMax-M2.7-highspeed': 'MiniMax M2.7 Highspeed',
|
|
'MiniMax-M2.7': 'MiniMax M2.7',
|
|
}
|
|
for (const [key, name] of Object.entries(shortNames)) {
|
|
if (canonical.startsWith(key)) return name
|
|
}
|
|
return canonical
|
|
}
|