diff --git a/Sources/CodingBar/SelfTest.swift b/Sources/CodingBar/SelfTest.swift index 0baff91..0eebd5c 100644 --- a/Sources/CodingBar/SelfTest.swift +++ b/Sources/CodingBar/SelfTest.swift @@ -35,6 +35,32 @@ enum SelfTest { check("Sonnet 5 standard pricing", abs(Pricing.cost(model: "claude-sonnet-5", tokens: millionTokens, at: september, cacheWrite1h: 1_000_000) - 24.3) < 0.000_001) + let openAIBaseTokens = TokenBreakdown(input: 100_000, output: 100_000, + cacheRead: 100_000, cacheWrite: 100_000) + check("GPT-5.6 tiers resolve exactly", + Pricing.normalize(model: "gpt-5.6") == "openai/gpt-5.6-sol" + && Pricing.normalize(model: "gpt-5.6-sol") == "openai/gpt-5.6-sol" + && Pricing.normalize(model: "gpt-5.6-terra") == "openai/gpt-5.6-terra" + && Pricing.normalize(model: "gpt-5.6-luna") == "openai/gpt-5.6-luna" + && Pricing.priceIsExact(model: "gpt-5.6")) + check("GPT-5.6 Sol base and long-context pricing", + abs(Pricing.cost(model: "gpt-5.6-sol", tokens: openAIBaseTokens, at: july, + billingInputTokens: 272_000) - 4.175) < 0.000_001 + && abs(Pricing.cost(model: "gpt-5.6-sol", tokens: openAIBaseTokens, at: july, + billingInputTokens: 272_001) - 6.85) < 0.000_001) + check("GPT prices cover current and historical IDs", + abs(Pricing.cost(model: "gpt-5.4-mini", tokens: TokenBreakdown(output: 1_000_000), + at: july) - 4.5) < 0.000_001 + && abs(Pricing.cost(model: "gpt-5.3-codex", tokens: TokenBreakdown(output: 1_000_000), + at: july) - 14) < 0.000_001 + && Pricing.priceIsExact(model: "gpt-5.1") + && Pricing.priceIsExact(model: "gpt-4o-mini") + && Pricing.normalize(model: "gpt-5.4-nano-2026-03-17") == "openai/gpt-5.4-nano") + check("unknown Codex IDs remain approximate", + Pricing.normalize(model: "gpt-5.6-codex") == "gpt-5.6-codex" + && !Pricing.priceIsExact(model: "gpt-5.6-codex") + && !Pricing.priceIsExact(model: "gpt-5.5-codex")) + // Regression: the family-keyword fallback used to funnel every Opus into 4.8, so a // real `claude-opus-5` record was renamed and merged into the 4.8 row. Each tier // must resolve to itself, and an unrecognized version to the newest — not a pinned diff --git a/Sources/CodingBarCore/Aggregator.swift b/Sources/CodingBarCore/Aggregator.swift index 11eb36b..c0c40d6 100644 --- a/Sources/CodingBarCore/Aggregator.swift +++ b/Sources/CodingBarCore/Aggregator.swift @@ -2,6 +2,32 @@ import Foundation public enum Aggregator { + /// Cache stats span every local provider because the UI presents one all-time total. + /// Keeping this pure also pins the pricing semantics without scanning real user logs. + static func cacheStat(from records: [RawRecord]) -> CacheStat { + var totalCacheRead = 0 + var totalCacheWrite = 0 + var totalInput = 0 + var totalSavedWeightedRead = 0.0 + + for r in records { + totalCacheRead += r.tokens.cacheRead + totalCacheWrite += r.tokens.cacheWrite + totalInput += r.tokens.input + let key = Pricing.normalize(model: r.model) + let promptTokens = r.billingInputTokens ?? (r.tokens.input + r.tokens.cacheRead + r.tokens.cacheWrite) + let inputPrice = Pricing.inputPrice(forCanonicalKey: key, at: r.timestamp, + billingInputTokens: promptTokens) + let cacheReadPrice = Pricing.cacheReadPrice(forCanonicalKey: key, at: r.timestamp, + billingInputTokens: promptTokens) + totalSavedWeightedRead += Double(r.tokens.cacheRead) * (inputPrice - cacheReadPrice) + } + + let denominator = totalCacheRead + totalCacheWrite + totalInput + let hitRate = denominator > 0 ? Double(totalCacheRead) / Double(denominator) : 0 + return CacheStat(hitRate: hitRate, savedUSD: totalSavedWeightedRead / 1_000_000) + } + /// `quota` is supplied by the online `QuotaService` (Claude + Codex usage /// APIs). It is a parameter rather than scanned here so the local-log /// aggregation stays synchronous and offline; the UI injects the latest @@ -20,7 +46,8 @@ public enum Aggregator { let allRecords = claudeRecords + codexRecords func recordCost(_ record: RawRecord) -> Double { Pricing.cost(model: record.model, tokens: record.tokens, - at: record.timestamp, cacheWrite1h: record.cacheWrite1h) + at: record.timestamp, cacheWrite1h: record.cacheWrite1h, + billingInputTokens: record.billingInputTokens) } let todayStart = cal.startOfDay(for: now) @@ -198,27 +225,7 @@ public enum Aggregator { let (models, projects) = breakdown(from: allRecords) - // cache stats are Claude only - var totalCacheRead = 0 - var totalCacheWrite = 0 - var totalInput = 0 - var totalSavedWeightedRead = 0.0 - - for r in claudeRecords { - totalCacheRead += r.tokens.cacheRead - totalCacheWrite += r.tokens.cacheWrite - totalInput += r.tokens.input - let key = Pricing.normalize(model: r.model) - let iPrice = Pricing.inputPrice(forCanonicalKey: key, at: r.timestamp) - let crPrice = Pricing.cacheReadPrice(forCanonicalKey: key, at: r.timestamp) - totalSavedWeightedRead += Double(r.tokens.cacheRead) * (iPrice - crPrice) - } - - let denominator = totalCacheRead + totalCacheWrite + totalInput - let hitRate = denominator > 0 ? Double(totalCacheRead) / Double(denominator) : 0 - let savedUSD = totalSavedWeightedRead / 1_000_000 - - let cache = CacheStat(hitRate: hitRate, savedUSD: savedUSD) + let cache = cacheStat(from: allRecords) let totalTodayTokens = todayTokens.total let primaryText: String diff --git a/Sources/CodingBarCore/Coach.swift b/Sources/CodingBarCore/Coach.swift index 2db705f..ccecbf9 100644 --- a/Sources/CodingBarCore/Coach.swift +++ b/Sources/CodingBarCore/Coach.swift @@ -82,12 +82,16 @@ enum Coach { totalWrite += r.tokens.cacheWrite totalRead += r.tokens.cacheRead let key = Pricing.normalize(model: r.model) - let writePrice = Pricing.inputPrice(forCanonicalKey: key, at: r.timestamp) - let readPrice = Pricing.cacheReadPrice(forCanonicalKey: key, at: r.timestamp) + let promptTokens = r.billingInputTokens ?? (r.tokens.input + r.tokens.cacheRead + r.tokens.cacheWrite) + let writePrice = Pricing.inputPrice(forCanonicalKey: key, at: r.timestamp, + billingInputTokens: promptTokens) + let readPrice = Pricing.cacheReadPrice(forCanonicalKey: key, at: r.timestamp, + billingInputTokens: promptTokens) totalWriteCost += Pricing.cost(model: r.model, tokens: TokenBreakdown(cacheWrite: r.tokens.cacheWrite), at: r.timestamp, - cacheWrite1h: r.cacheWrite1h) + cacheWrite1h: r.cacheWrite1h, + billingInputTokens: promptTokens) totalReadSavings += Double(r.tokens.cacheRead) * (writePrice - readPrice) / 1_000_000 } diff --git a/Sources/CodingBarCore/CodexScanner.swift b/Sources/CodingBarCore/CodexScanner.swift index 9a6dc3f..a5712fc 100644 --- a/Sources/CodingBarCore/CodexScanner.swift +++ b/Sources/CodingBarCore/CodexScanner.swift @@ -42,7 +42,7 @@ public enum CodexScanner { // (measured ~1.3–1.8× across this machine's logs). Taking the positive delta // of `total_token_usage` reconstructs each turn's true increment, drops // duplicate snapshots (Δ≤0), and preserves per-turn timestamps for bucketing. - var prevInput = 0, prevCached = 0, prevOutput = 0, prevReasoning = 0 + var prevInput = 0, prevCached = 0, prevCacheWrite = 0, prevOutput = 0, prevReasoning = 0 // Codex tool calls (`function_call` response items, e.g. exec_command) arrive // before the turn's `token_count`; buffer their names and attach them to the // next emitted record so the habits tool-mix counts Codex, not just Claude. @@ -96,25 +96,31 @@ public enum CodexScanner { } // Every non-null `info` carries `total_token_usage` (verified across - // every real event); the per-turn `last_token_usage` is no longer used. + // every real event). Its positive delta remains the billable token count; + // `last_token_usage.input_tokens` is retained only as the absolute prompt + // size needed to select OpenAI's >272K long-context price tier. guard let info = payload["info"] as? [String: Any], let total = info["total_token_usage"] as? [String: Any] else { return } + let last = info["last_token_usage"] as? [String: Any] + let billingInputTokens = (last?["input_tokens"] as? Int).map { max(0, $0) } - let curInput = total["input_tokens"] as? Int ?? 0 - let curCached = total["cached_input_tokens"] as? Int ?? 0 - let curOutput = total["output_tokens"] as? Int ?? 0 - let curReasoning = total["reasoning_output_tokens"] as? Int ?? 0 + let curInput = total["input_tokens"] as? Int ?? 0 + let curCached = total["cached_input_tokens"] as? Int ?? 0 + let curCacheWrite = total["cache_write_input_tokens"] as? Int ?? 0 + let curOutput = total["output_tokens"] as? Int ?? 0 + let curReasoning = total["reasoning_output_tokens"] as? Int ?? 0 // Δ of the cumulative counter. A counter that *drops* (post-compaction // reset) starts a fresh baseline so those turns aren't lost. let reset = curInput < prevInput || curOutput < prevOutput - let dInput = reset ? curInput : curInput - prevInput - let dCached = reset ? curCached : curCached - prevCached - let dOutput = reset ? curOutput : curOutput - prevOutput - let dReasoning = reset ? curReasoning : curReasoning - prevReasoning - prevInput = curInput; prevCached = curCached + let dInput = reset ? curInput : curInput - prevInput + let dCached = reset ? curCached : curCached - prevCached + let dCacheWrite = reset ? curCacheWrite : curCacheWrite - prevCacheWrite + let dOutput = reset ? curOutput : curOutput - prevOutput + let dReasoning = reset ? curReasoning : curReasoning - prevReasoning + prevInput = curInput; prevCached = curCached; prevCacheWrite = curCacheWrite prevOutput = curOutput; prevReasoning = curReasoning // No forward progress → a replayed/duplicate snapshot, nothing billed. @@ -128,17 +134,23 @@ public enum CodexScanner { pendingTools.removeAll(keepingCapacity: true); return } - // Codex: input_tokens INCLUDES cached; net fresh input = input − cached. - // Clamp the cached delta at 0 first so a (data-wise unreachable) cached - // dip without a full reset can never inflate net input above dInput. - let netInput = max(0, dInput - max(0, dCached)) - + // Codex input_tokens includes both cached reads and cache writes. Keep all + // three buckets disjoint so both total tokens and model-specific cache rates + // remain correct. Negative subset deltas are treated as zero after a reset. + let cacheRead = max(0, dCached) + let cacheWrite = max(0, dCacheWrite) + let netInput = max(0, dInput - cacheRead - cacheWrite) + + // Codex's output_tokens already includes reasoning_output_tokens. Split + // the subset into its own bucket so TokenBreakdown.total and Pricing.cost + // count it once rather than adding the same reasoning tokens twice. + let reasoning = min(max(0, dReasoning), max(0, dOutput)) let tokens = TokenBreakdown( input: netInput, - output: dOutput, - cacheRead: max(0, dCached), - cacheWrite: 0, - reasoning: max(0, dReasoning) + output: max(0, dOutput - reasoning), + cacheRead: cacheRead, + cacheWrite: cacheWrite, + reasoning: reasoning ) let record = RawRecord( @@ -147,6 +159,7 @@ public enum CodexScanner { timestamp: timestamp, cwd: cwd, tokens: tokens, + billingInputTokens: billingInputTokens, toolName: pendingTools.first, toolNames: pendingTools, messageId: nil, diff --git a/Sources/CodingBarCore/Fuel.swift b/Sources/CodingBarCore/Fuel.swift index 4aff1be..3432b86 100644 --- a/Sources/CodingBarCore/Fuel.swift +++ b/Sources/CodingBarCore/Fuel.swift @@ -182,11 +182,13 @@ enum FuelCalculator { var burn: Double = 0 for r in claudeRecords where r.timestamp >= minuteAgo && r.timestamp <= now { burn += Pricing.cost(model: r.model, tokens: r.tokens, - at: r.timestamp, cacheWrite1h: r.cacheWrite1h) + at: r.timestamp, cacheWrite1h: r.cacheWrite1h, + billingInputTokens: r.billingInputTokens) } for r in codexRecords where r.timestamp >= minuteAgo && r.timestamp <= now { burn += Pricing.cost(model: r.model, tokens: r.tokens, - at: r.timestamp, cacheWrite1h: r.cacheWrite1h) + at: r.timestamp, cacheWrite1h: r.cacheWrite1h, + billingInputTokens: r.billingInputTokens) } // Group Claude records by session; surface those active within 90s. diff --git a/Sources/CodingBarCore/Pricing.swift b/Sources/CodingBarCore/Pricing.swift index b3b4bb1..0654909 100644 --- a/Sources/CodingBarCore/Pricing.swift +++ b/Sources/CodingBarCore/Pricing.swift @@ -12,9 +12,26 @@ public enum Pricing { var cacheRead: Double var cacheWrite5m: Double var cacheWrite1h: Double + var longContextThreshold: Int? = nil + var longContextInputMultiplier: Double = 1 + var longContextOutputMultiplier: Double = 1 + var isExact = true } - private static let fallback = ModelPrice(input: 3, output: 15, cacheRead: 0.3, cacheWrite5m: 3.75, cacheWrite1h: 6) + private static func openAI(input: Double, cachedInput: Double? = nil, output: Double, + cacheWrite: Double = 0, longContext: Bool = false, + isExact: Bool = true) -> ModelPrice { + ModelPrice(input: input, output: output, cacheRead: cachedInput ?? input, + cacheWrite5m: cacheWrite, cacheWrite1h: cacheWrite, + longContextThreshold: longContext ? 272_000 : nil, + longContextInputMultiplier: longContext ? 2 : 1, + longContextOutputMultiplier: longContext ? 1.5 : 1, + isExact: isExact) + } + + private static let fallback = ModelPrice(input: 3, output: 15, cacheRead: 0.3, + cacheWrite5m: 3.75, cacheWrite1h: 6, + isExact: false) private static let priceTable: [String: ModelPrice] = [ // Anthropic Claude — official models @@ -33,16 +50,48 @@ public enum Pricing { "anthropic/claude-sonnet-5": ModelPrice(input: 3, output: 15, cacheRead: 0.3, cacheWrite5m: 3.75, cacheWrite1h: 6), "anthropic/claude-sonnet-4-6": ModelPrice(input: 3, output: 15, cacheRead: 0.3, cacheWrite5m: 3.75, cacheWrite1h: 6), "anthropic/claude-haiku-4-5": ModelPrice(input: 1, output: 5, cacheRead: 0.1, cacheWrite5m: 1.25, cacheWrite1h: 2), - // OpenAI models (accessed via Claude Code remote MCP or Codex) - "openai/gpt-5.5": ModelPrice(input: 1.25, output: 10, cacheRead: 0.125, cacheWrite5m: 0, cacheWrite1h: 0), - "openai/gpt-5.4": ModelPrice(input: 1.25, output: 10, cacheRead: 0.125, cacheWrite5m: 0, cacheWrite1h: 0), - "openai/gpt-5.4-mini": ModelPrice(input: 0.15, output: 0.6, cacheRead: 0.075, cacheWrite5m: 0, cacheWrite1h: 0), - // Codex CLI model variants (gpt-5.x-codex) — priced as the gpt-5.x family - "openai/gpt-5.5-codex": ModelPrice(input: 1.25, output: 10, cacheRead: 0.125, cacheWrite5m: 0, cacheWrite1h: 0), - "openai/gpt-5.4-codex": ModelPrice(input: 1.25, output: 10, cacheRead: 0.125, cacheWrite5m: 0, cacheWrite1h: 0), - "openai/gpt-5.3-codex": ModelPrice(input: 1.25, output: 10, cacheRead: 0.125, cacheWrite5m: 0, cacheWrite1h: 0), - "openai/gpt-5.2-codex": ModelPrice(input: 1.25, output: 10, cacheRead: 0.125, cacheWrite5m: 0, cacheWrite1h: 0), - "openai/o1": ModelPrice(input: 15, output: 60, cacheRead: 7.5, cacheWrite5m: 0, cacheWrite1h: 0), + // OpenAI pay-as-you-go rates, current 2026-08-09. Pro models publish no + // cached-input discount, so their cache reads are billed at the full input rate. + // GPT-5.6 also charges cache writes at 1.25x input; both TTL fields use that one tier. + "openai/gpt-5.6-sol": openAI(input: 5, cachedInput: 0.5, output: 30, cacheWrite: 6.25, longContext: true), + "openai/gpt-5.6-terra": openAI(input: 2, cachedInput: 0.2, output: 12, cacheWrite: 2.5, longContext: true), + "openai/gpt-5.6-luna": openAI(input: 0.2, cachedInput: 0.02, output: 1.2, cacheWrite: 0.25, longContext: true), + "openai/gpt-5.5": openAI(input: 5, cachedInput: 0.5, output: 30, longContext: true), + "openai/gpt-5.5-pro": openAI(input: 30, output: 180), + "openai/gpt-5.4": openAI(input: 2.5, cachedInput: 0.25, output: 15, longContext: true), + "openai/gpt-5.4-pro": openAI(input: 30, output: 180, longContext: true), + "openai/gpt-5.4-mini": openAI(input: 0.75, cachedInput: 0.075, output: 4.5), + "openai/gpt-5.4-nano": openAI(input: 0.2, cachedInput: 0.02, output: 1.25), + "openai/gpt-5.3-codex": openAI(input: 1.75, cachedInput: 0.175, output: 14), + "openai/gpt-5.2": openAI(input: 1.75, cachedInput: 0.175, output: 14), + "openai/gpt-5.2-pro": openAI(input: 21, output: 168), + "openai/gpt-5.2-codex": openAI(input: 1.75, cachedInput: 0.175, output: 14), + "openai/gpt-5.1": openAI(input: 1.25, cachedInput: 0.125, output: 10), + "openai/gpt-5.1-codex": openAI(input: 1.25, cachedInput: 0.125, output: 10), + "openai/gpt-5.1-codex-max": openAI(input: 1.25, cachedInput: 0.125, output: 10), + "openai/gpt-5.1-codex-mini": openAI(input: 0.25, cachedInput: 0.025, output: 2), + "openai/gpt-5": openAI(input: 1.25, cachedInput: 0.125, output: 10), + "openai/gpt-5-pro": openAI(input: 15, output: 120), + "openai/gpt-5-mini": openAI(input: 0.25, cachedInput: 0.025, output: 2), + "openai/gpt-5-nano": openAI(input: 0.05, cachedInput: 0.005, output: 0.4), + "openai/gpt-5-codex": openAI(input: 1.25, cachedInput: 0.125, output: 10), + "openai/codex-mini-latest": openAI(input: 1.5, cachedInput: 0.375, output: 6), + "openai/gpt-4.1": openAI(input: 2, cachedInput: 0.5, output: 8), + "openai/gpt-4.1-mini": openAI(input: 0.4, cachedInput: 0.1, output: 1.6), + "openai/gpt-4.1-nano": openAI(input: 0.1, cachedInput: 0.025, output: 0.4), + "openai/gpt-4o": openAI(input: 2.5, cachedInput: 1.25, output: 10), + "openai/gpt-4o-mini": openAI(input: 0.15, cachedInput: 0.075, output: 0.6), + "openai/o3-pro": openAI(input: 20, output: 80), + "openai/o3": openAI(input: 2, cachedInput: 0.5, output: 8), + "openai/o4-mini": openAI(input: 1.1, cachedInput: 0.275, output: 4.4), + "openai/o1-pro": openAI(input: 150, output: 600), + "openai/o1": openAI(input: 15, cachedInput: 7.5, output: 60), + "openai/o1-mini": openAI(input: 1.1, cachedInput: 0.55, output: 4.4), + "openai/o3-mini": openAI(input: 1.1, cachedInput: 0.55, output: 4.4), + // These IDs occur in local proxy/Codex logs but are not current official model IDs. + // Keep their family estimate visible while marking it approximate in the UI. + "openai/gpt-5.5-codex": openAI(input: 5, cachedInput: 0.5, output: 30, longContext: true, isExact: false), + "openai/gpt-5.4-codex": openAI(input: 2.5, cachedInput: 0.25, output: 15, longContext: true, isExact: false), // Other providers seen in logs (best-effort pricing) "deepseek/deepseek-v4-flash": ModelPrice(input: 0.27, output: 1.1, cacheRead: 0.07, cacheWrite5m: 0, cacheWrite1h: 0), "deepseek/deepseek-v4-pro": ModelPrice(input: 0.55, output: 2.19,cacheRead: 0.14, cacheWrite5m: 0, cacheWrite1h: 0), @@ -69,11 +118,40 @@ public enum Pricing { for alias in ["sonnet-4.6", "claude-sonnet-4-6"] { m[alias] = "anthropic/claude-sonnet-4-6" } for alias in ["haiku-4.5", "claude-haiku-4-5", "haiku", "claude-haiku-4-5-20251001"] { m[alias] = "anthropic/claude-haiku-4-5" } - // OpenAI aliases - for alias in ["gpt-5.5", "gpt5.5"] { m[alias] = "openai/gpt-5.5" } - for alias in ["gpt-5.4"] { m[alias] = "openai/gpt-5.4" } - for alias in ["gpt-5.4-mini", "gpt-5.4-mini-2026-03-17"] { m[alias] = "openai/gpt-5.4-mini" } - m["o1"] = "openai/o1" + // Every canonical OpenAI row accepts its bare API model ID. Keeping this generated + // from priceTable prevents a model from being priced but unreachable from real logs. + for key in priceTable.keys where key.hasPrefix("openai/") { + m[String(key.dropFirst("openai/".count))] = key + } + m["gpt-5.6"] = "openai/gpt-5.6-sol" + m["gpt5.5"] = "openai/gpt-5.5" + for (snapshot, canonical) in [ + "gpt-5.5-2026-04-23": "openai/gpt-5.5", + "gpt-5.5-pro-2026-04-23": "openai/gpt-5.5-pro", + "gpt-5.4-2026-03-05": "openai/gpt-5.4", + "gpt-5.4-pro-2026-03-05": "openai/gpt-5.4-pro", + "gpt-5.4-mini-2026-03-17": "openai/gpt-5.4-mini", + "gpt-5.4-nano-2026-03-17": "openai/gpt-5.4-nano", + "gpt-5.2-2025-12-11": "openai/gpt-5.2", + "gpt-5.2-pro-2025-12-11": "openai/gpt-5.2-pro", + "gpt-5.1-2025-11-13": "openai/gpt-5.1", + "gpt-5-2025-08-07": "openai/gpt-5", + "gpt-5-pro-2025-10-06": "openai/gpt-5-pro", + "gpt-5-mini-2025-08-07": "openai/gpt-5-mini", + "gpt-5-nano-2025-08-07": "openai/gpt-5-nano", + "gpt-4.1-2025-04-14": "openai/gpt-4.1", + "gpt-4.1-mini-2025-04-14": "openai/gpt-4.1-mini", + "gpt-4.1-nano-2025-04-14": "openai/gpt-4.1-nano", + "gpt-4o-2024-05-13": "openai/gpt-4o", + "gpt-4o-2024-08-06": "openai/gpt-4o", + "gpt-4o-2024-11-20": "openai/gpt-4o", + "gpt-4o-mini-2024-07-18": "openai/gpt-4o-mini", + "o1-2024-12-17": "openai/o1", + "o1-mini-2024-09-12": "openai/o1-mini", + "o3-2025-04-16": "openai/o3", + "o3-mini-2025-01-31": "openai/o3-mini", + "o4-mini-2025-04-16": "openai/o4-mini", + ] { m[snapshot] = canonical } // Other for alias in ["deepseek-v4-flash"] { m[alias] = "deepseek/deepseek-v4-flash" } for alias in ["deepseek-v4-pro"] { m[alias] = "deepseek/deepseek-v4-pro" } @@ -89,8 +167,14 @@ public enum Pricing { // Direct canonical key match if priceTable[lower] != nil { return lower } - // Exact alias lookup + // Exact alias lookup. Provider/router prefixes are allowed only when the final + // path component is a complete known model ID; substring matching made unrelated + // route names ("sonnet-proxy/gpt-…") silently select the wrong provider and price. if let canonical = aliasMap[lower] { return canonical } + if let modelID = lower.split(separator: "/").last, + let canonical = aliasMap[String(modelID)] { + return canonical + } // Family keyword fallback (ordered most-specific first). // @@ -114,23 +198,11 @@ public enum Pricing { return "anthropic/claude-sonnet-5" } if lower.contains("haiku") { return "anthropic/claude-haiku-4-5" } - // Codex variants (gpt-5.x-codex) before the plain gpt-5.x rules - if lower.contains("codex") { - if lower.contains("5.5") { return "openai/gpt-5.5-codex" } - if lower.contains("5.4") { return "openai/gpt-5.4-codex" } - if lower.contains("5.3") { return "openai/gpt-5.3-codex" } - if lower.contains("5.2") { return "openai/gpt-5.2-codex" } - return "openai/gpt-5.5-codex" // unknown codex → latest codex pricing - } - if lower.contains("gpt-5.4-mini") { return "openai/gpt-5.4-mini" } - if lower.contains("gpt-5.5") || lower.contains("gpt5.5") { return "openai/gpt-5.5" } - if lower.contains("gpt-5.4") { return "openai/gpt-5.4" } if lower.contains("deepseek-v4-flash") { return "deepseek/deepseek-v4-flash" } if lower.contains("deepseek-v4-pro") { return "deepseek/deepseek-v4-pro" } if lower.contains("deepseek") { return "deepseek/deepseek-v4-flash" } if lower.contains("mimo-v2.5-pro") { return "mimo/mimo-v2.5-pro" } if lower.contains("mimo") { return "mimo/mimo-v2.5" } - if lower.contains("o1") { return "openai/o1" } // Unknown model: keep its own (lowercased) id rather than collapsing every // unmatched model into one "_fallback" bucket. It still prices at the @@ -139,12 +211,11 @@ public enum Pricing { return lower } - /// False when the model isn't in the price table even after normalization, so it - /// prices at the generic fallback rate ($3/$15) — i.e. a genuinely unknown model - /// whose cost is a guess. Family-keyword matches that land on a real table entry - /// (a dated variant of a known model) still count as priced, to avoid noise. + /// False for the generic fallback and for observed provider aliases whose public + /// price is only a family estimate. Official canonical IDs, snapshots, and provider- + /// prefixed forms of those exact IDs remain exact. public static func priceIsExact(model: String) -> Bool { - priceTable[normalize(model: model)] != nil + priceTable[normalize(model: model)]?.isExact ?? false } // MARK: - Display names @@ -162,14 +233,43 @@ public enum Pricing { "anthropic/claude-sonnet-5": "Sonnet 5", "anthropic/claude-sonnet-4-6": "Sonnet 4.6", "anthropic/claude-haiku-4-5": "Haiku 4.5", + "openai/gpt-5.6-sol": "GPT-5.6 Sol", + "openai/gpt-5.6-terra": "GPT-5.6 Terra", + "openai/gpt-5.6-luna": "GPT-5.6 Luna", "openai/gpt-5.5": "GPT-5.5", + "openai/gpt-5.5-pro": "GPT-5.5 Pro", "openai/gpt-5.4": "GPT-5.4", + "openai/gpt-5.4-pro": "GPT-5.4 Pro", "openai/gpt-5.4-mini": "GPT-5.4 mini", - "openai/gpt-5.5-codex": "GPT-5.5 Codex", - "openai/gpt-5.4-codex": "GPT-5.4 Codex", + "openai/gpt-5.4-nano": "GPT-5.4 nano", "openai/gpt-5.3-codex": "GPT-5.3 Codex", + "openai/gpt-5.2": "GPT-5.2", + "openai/gpt-5.2-pro": "GPT-5.2 Pro", "openai/gpt-5.2-codex": "GPT-5.2 Codex", + "openai/gpt-5.1": "GPT-5.1", + "openai/gpt-5.1-codex": "GPT-5.1 Codex", + "openai/gpt-5.1-codex-max": "GPT-5.1 Codex Max", + "openai/gpt-5.1-codex-mini": "GPT-5.1 Codex mini", + "openai/gpt-5": "GPT-5", + "openai/gpt-5-pro": "GPT-5 Pro", + "openai/gpt-5-mini": "GPT-5 mini", + "openai/gpt-5-nano": "GPT-5 nano", + "openai/gpt-5-codex": "GPT-5 Codex", + "openai/codex-mini-latest": "Codex mini", + "openai/gpt-4.1": "GPT-4.1", + "openai/gpt-4.1-mini": "GPT-4.1 mini", + "openai/gpt-4.1-nano": "GPT-4.1 nano", + "openai/gpt-4o": "GPT-4o", + "openai/gpt-4o-mini": "GPT-4o mini", + "openai/o3-pro": "o3 Pro", + "openai/o3": "o3", + "openai/o4-mini": "o4-mini", + "openai/o1-pro": "o1 Pro", "openai/o1": "o1", + "openai/o1-mini": "o1-mini", + "openai/o3-mini": "o3-mini", + "openai/gpt-5.5-codex": "GPT-5.5 Codex", + "openai/gpt-5.4-codex": "GPT-5.4 Codex", "deepseek/deepseek-v4-flash": "DeepSeek Flash", "deepseek/deepseek-v4-pro": "DeepSeek Pro", "mimo/mimo-v2.5-pro": "MiMo v2.5 Pro", @@ -197,18 +297,30 @@ public enum Pricing { return priceTable[key] ?? fallback } - /// Compute USD cost for one log record at its historical price point. - public static func cost(model: String, tokens: TokenBreakdown, at timestamp: Date, cacheWrite1h: Int = 0) -> Double { + private static func multipliers(for price: ModelPrice, billingInputTokens: Int) -> (input: Double, output: Double) { + guard let threshold = price.longContextThreshold, billingInputTokens > threshold else { + return (1, 1) + } + return (price.longContextInputMultiplier, price.longContextOutputMultiplier) + } + + /// Compute USD cost for one log record at its historical price point. Claude usage + /// carries the absolute prompt size in the token fields; Codex passes last_token_usage's + /// input count because its billable TokenBreakdown is a delta of cumulative counters. + public static func cost(model: String, tokens: TokenBreakdown, at timestamp: Date, + cacheWrite1h: Int = 0, billingInputTokens: Int? = nil) -> Double { let key = normalize(model: model) let p = price(forCanonicalKey: key, at: timestamp) let oneHourWrites = min(max(cacheWrite1h, 0), tokens.cacheWrite) let fiveMinuteWrites = tokens.cacheWrite - oneHourWrites + let promptTokens = max(0, billingInputTokens ?? (tokens.input + tokens.cacheRead + tokens.cacheWrite)) + let multiplier = multipliers(for: p, billingInputTokens: promptTokens) - let c = (Double(tokens.input) * p.input - + Double(tokens.output + tokens.reasoning) * p.output - + Double(tokens.cacheRead) * p.cacheRead - + Double(fiveMinuteWrites) * p.cacheWrite5m - + Double(oneHourWrites) * p.cacheWrite1h) / 1_000_000 + let c = (Double(tokens.input) * p.input * multiplier.input + + Double(tokens.output + tokens.reasoning) * p.output * multiplier.output + + Double(tokens.cacheRead) * p.cacheRead * multiplier.input + + Double(fiveMinuteWrites) * p.cacheWrite5m * multiplier.input + + Double(oneHourWrites) * p.cacheWrite1h * multiplier.input) / 1_000_000 return c } @@ -224,12 +336,16 @@ public enum Pricing { } /// Input price (per 1M) for a canonical key — used for cache savings calculation. - public static func inputPrice(forCanonicalKey key: String, at timestamp: Date = Date()) -> Double { - price(forCanonicalKey: key, at: timestamp).input + public static func inputPrice(forCanonicalKey key: String, at timestamp: Date = Date(), + billingInputTokens: Int = 0) -> Double { + let p = price(forCanonicalKey: key, at: timestamp) + return p.input * multipliers(for: p, billingInputTokens: billingInputTokens).input } /// Cache read price (per 1M) for a canonical key. - public static func cacheReadPrice(forCanonicalKey key: String, at timestamp: Date = Date()) -> Double { - price(forCanonicalKey: key, at: timestamp).cacheRead + public static func cacheReadPrice(forCanonicalKey key: String, at timestamp: Date = Date(), + billingInputTokens: Int = 0) -> Double { + let p = price(forCanonicalKey: key, at: timestamp) + return p.cacheRead * multipliers(for: p, billingInputTokens: billingInputTokens).input } } diff --git a/Sources/CodingBarCore/Scanner.swift b/Sources/CodingBarCore/Scanner.swift index f301abd..433fefb 100644 --- a/Sources/CodingBarCore/Scanner.swift +++ b/Sources/CodingBarCore/Scanner.swift @@ -23,6 +23,9 @@ struct RawRecord { /// Portion of `tokens.cacheWrite` created with Claude's 1-hour cache TTL. /// The remainder is the 5-minute default cache write tier. var cacheWrite1h: Int = 0 + /// Absolute prompt size used only to choose a provider's long-context price tier. + /// Nil for Claude because its TokenBreakdown already carries the absolute prompt. + var billingInputTokens: Int? = nil var toolName: String? // first tool in this turn (backwards compat) var toolNames: [String] // ALL tool_use names in this turn var messageId: String? @@ -77,8 +80,10 @@ final class Scanner { /// the `attribution*` fields (skill / agent / plugin / MCP server). v5: cache moved /// from JSON to binary property list (smaller, decodes without an NSDictionary tree /// intermediate — see loadCache for the peak-memory rationale). v6: Claude records - /// preserve the 1-hour prompt-cache portion for duration-aware billing. - private static let cacheVersion = 6 + /// preserve the 1-hour prompt-cache portion for duration-aware billing. v7: Codex + /// records preserve the absolute last-turn input size for long-context pricing. v8: + /// Codex cache-write tokens are split from fresh input instead of being discarded. + private static let cacheVersion = 8 private struct CacheFile: Codable { var version: Int @@ -96,6 +101,7 @@ final class Scanner { var cacheRead: Int var cacheWrite: Int var cacheWrite1h: Int + var billingInputTokens: Int? var reasoning: Int var toolName: String? var toolNames: [String] @@ -191,6 +197,7 @@ final class Scanner { cwd: cached.cwd, tokens: TokenBreakdown(input: cached.input, output: cached.output, cacheRead: cached.cacheRead, cacheWrite: cached.cacheWrite, reasoning: cached.reasoning), cacheWrite1h: cached.cacheWrite1h, + billingInputTokens: cached.billingInputTokens, toolName: cached.toolName, toolNames: cached.toolNames, messageId: cached.messageId, @@ -211,6 +218,7 @@ final class Scanner { cacheRead: raw.tokens.cacheRead, cacheWrite: raw.tokens.cacheWrite, cacheWrite1h: raw.cacheWrite1h, + billingInputTokens: raw.billingInputTokens, reasoning: raw.tokens.reasoning, toolName: raw.toolName, toolNames: raw.toolNames, diff --git a/Tests/CodingBarCoreTests/SmokeTests.swift b/Tests/CodingBarCoreTests/SmokeTests.swift index dcad640..b6f6690 100644 --- a/Tests/CodingBarCoreTests/SmokeTests.swift +++ b/Tests/CodingBarCoreTests/SmokeTests.swift @@ -129,20 +129,21 @@ final class SmokeTests: XCTestCase { func line(_ obj: [String: Any]) -> String { String(data: try! JSONSerialization.data(withJSONObject: obj), encoding: .utf8)! } - func tc(ts: String, input: Int, cached: Int, output: Int) -> [String: Any] { + func tc(ts: String, input: Int, cached: Int, cacheWrite: Int = 0, output: Int) -> [String: Any] { ["type": "event_msg", "timestamp": ts, "payload": ["type": "token_count", "info": ["total_token_usage": ["input_tokens": input, "cached_input_tokens": cached, + "cache_write_input_tokens": cacheWrite, "output_tokens": output, "reasoning_output_tokens": 0]]]] } let lines = [ line(["type": "session_meta", "payload": ["cwd": "/tmp/proj"]]), line(["type": "turn_context", "payload": ["model": "gpt-5.5-codex"]]), - line(tc(ts: "2026-06-18T13:00:00.000Z", input: 100, cached: 0, output: 10)), // A - line(tc(ts: "2026-06-18T13:00:00.000Z", input: 100, cached: 0, output: 10)), // dup → skip - line(tc(ts: "2026-06-18T13:05:00.000Z", input: 300, cached: 50, output: 30)), // C: Δ net150 cache50 out20 - line(tc(ts: "garbage", input: 450, cached: 50, output: 40)), // bad ts → drop, baseline→450 - line(tc(ts: "2026-06-18T13:10:00.000Z", input: 600, cached: 50, output: 50)), // E: Δ net150 cache0 out10 + line(tc(ts: "2026-06-18T13:00:00.000Z", input: 100, cached: 0, cacheWrite: 20, output: 10)), // A: fresh80 write20 + line(tc(ts: "2026-06-18T13:00:00.000Z", input: 100, cached: 0, cacheWrite: 20, output: 10)), // dup → skip + line(tc(ts: "2026-06-18T13:05:00.000Z", input: 300, cached: 50, cacheWrite: 40, output: 30)), // C: Δ fresh130 read50 write20 out20 + line(tc(ts: "garbage", input: 450, cached: 50, cacheWrite: 40, output: 40)), // bad ts → drop, baseline→450 + line(tc(ts: "2026-06-18T13:10:00.000Z", input: 600, cached: 50, cacheWrite: 50, output: 50)), // E: Δ fresh140 write10 out10 ] let url = FileManager.default.temporaryDirectory.appendingPathComponent("rollout-\(UUID().uuidString).jsonl") try lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8) @@ -150,12 +151,46 @@ final class SmokeTests: XCTestCase { let records = CodexScanner.parseFile(url) XCTAssertEqual(records.count, 3, "duplicate must be skipped and the bad-timestamp record dropped") - XCTAssertEqual(records.reduce(0) { $0 + $1.tokens.input }, 100 + 150 + 150) // net input + XCTAssertEqual(records.reduce(0) { $0 + $1.tokens.input }, 80 + 130 + 140) XCTAssertEqual(records.reduce(0) { $0 + $1.tokens.cacheRead }, 0 + 50 + 0) + XCTAssertEqual(records.reduce(0) { $0 + $1.tokens.cacheWrite }, 20 + 20 + 10) XCTAssertEqual(records.reduce(0) { $0 + $1.tokens.output }, 10 + 20 + 10) XCTAssertEqual(records.first?.model, "gpt-5.5-codex") } + func testCodexScannerSeparatesReasoningAndPreservesBillingContext() throws { + let event: [String: Any] = [ + "type": "event_msg", "timestamp": "2026-08-09T13:00:00.000Z", + "payload": ["type": "token_count", "info": [ + "total_token_usage": [ + "input_tokens": 300_000, "cached_input_tokens": 100_000, + "cache_write_input_tokens": 50_000, + "output_tokens": 100, "reasoning_output_tokens": 40, + ], + "last_token_usage": ["input_tokens": 300_001], + ]], + ] + let lines = [ + ["type": "turn_context", "payload": ["model": "gpt-5.6-sol"]], + event, + ] + let data = try lines.map { + String(data: try JSONSerialization.data(withJSONObject: $0), encoding: .utf8)! + }.joined(separator: "\n").data(using: .utf8)! + let url = FileManager.default.temporaryDirectory.appendingPathComponent("rollout-\(UUID().uuidString).jsonl") + try data.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + let record = try XCTUnwrap(CodexScanner.parseFile(url).first) + XCTAssertEqual(record.tokens.input, 150_000) + XCTAssertEqual(record.tokens.cacheRead, 100_000) + XCTAssertEqual(record.tokens.cacheWrite, 50_000) + XCTAssertEqual(record.tokens.output, 60, "output_tokens already contains reasoning") + XCTAssertEqual(record.tokens.reasoning, 40) + XCTAssertEqual(record.tokens.total, 300_100) + XCTAssertEqual(record.billingInputTokens, 300_001) + } + /// Codex `function_call` items (exec_command, view_image, …) buffered before a /// turn's token_count must attach to that turn's record so the tool-mix counts Codex. func testCodexScannerAttachesFunctionCallToolNames() throws { @@ -220,6 +255,30 @@ final class SmokeTests: XCTestCase { XCTAssertTrue(recs[3].attribution.isEmpty) } + func testClaudeScannerPreservesGPTModelAndUsage() throws { + let record: [String: Any] = [ + "type": "assistant", "timestamp": "2026-08-09T13:00:00.000Z", "cwd": "/p", + "message": [ + "id": "gpt-turn", "model": "gpt-5.6-sol", "content": [], + "usage": [ + "input_tokens": 100, "output_tokens": 10, + "cache_read_input_tokens": 250_000, "cache_creation_input_tokens": 30_000, + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: record) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).jsonl") + try data.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + let parsed = try XCTUnwrap(ClaudeScanner.parseFile(url).first) + XCTAssertEqual(parsed.provider, .claude) + XCTAssertEqual(parsed.model, "gpt-5.6-sol") + XCTAssertEqual(parsed.tokens, TokenBreakdown(input: 100, output: 10, + cacheRead: 250_000, cacheWrite: 30_000)) + XCTAssertNil(parsed.billingInputTokens, "Claude prompt size is derived from its absolute usage fields") + } + func testClaudeScannerPreservesOneHourCacheWrites() throws { let record: [String: Any] = [ "type": "assistant", "timestamp": "2026-07-01T13:00:00.000Z", "cwd": "/p", @@ -359,9 +418,11 @@ final class SmokeTests: XCTestCase { func testPriceIsExactFlagsOnlyFallbackModels() { XCTAssertTrue(Pricing.priceIsExact(model: "claude-opus-4-8")) XCTAssertTrue(Pricing.priceIsExact(model: "claude-sonnet-5")) - XCTAssertTrue(Pricing.priceIsExact(model: "gpt-5.5-codex")) - XCTAssertFalse(Pricing.priceIsExact(model: "gpt-5.1")) // no table/family match - XCTAssertFalse(Pricing.priceIsExact(model: "totally-unknown-model")) // generic fallback rate + XCTAssertTrue(Pricing.priceIsExact(model: "gpt-5.6-sol")) + XCTAssertTrue(Pricing.priceIsExact(model: "openrouter/openai/gpt-5.4-mini")) + XCTAssertFalse(Pricing.priceIsExact(model: "gpt-5.5-codex")) // observed alias, family estimate + XCTAssertFalse(Pricing.priceIsExact(model: "gpt-5.6-codex")) // unknown model, generic fallback + XCTAssertFalse(Pricing.priceIsExact(model: "totally-unknown-model")) } func testPricingUsesCacheDurationAndSonnetFiveEffectiveDates() { @@ -381,6 +442,127 @@ final class SmokeTests: XCTestCase { at: september, cacheWrite1h: 1_000_000), 24.3, accuracy: 0.000_001) } + func testOpenAIModelPricesAndAliasesMatchCurrentTable() { + let date = Date(timeIntervalSince1970: 1_786_233_600) // 2026-08-09 UTC + let hundredK = 100_000 + let cases: [(raw: String, canonical: String, input: Double, cached: Double, output: Double)] = [ + ("gpt-5.6-sol", "openai/gpt-5.6-sol", 5, 0.5, 30), + ("gpt-5.6-terra", "openai/gpt-5.6-terra", 2, 0.2, 12), + ("gpt-5.6-luna", "openai/gpt-5.6-luna", 0.2, 0.02, 1.2), + ("gpt-5.5", "openai/gpt-5.5", 5, 0.5, 30), + ("gpt-5.5-pro", "openai/gpt-5.5-pro", 30, 30, 180), + ("gpt-5.4", "openai/gpt-5.4", 2.5, 0.25, 15), + ("gpt-5.4-pro", "openai/gpt-5.4-pro", 30, 30, 180), + ("gpt-5.4-mini", "openai/gpt-5.4-mini", 0.75, 0.075, 4.5), + ("gpt-5.4-nano", "openai/gpt-5.4-nano", 0.2, 0.02, 1.25), + ("gpt-5.3-codex", "openai/gpt-5.3-codex", 1.75, 0.175, 14), + ("gpt-5.2", "openai/gpt-5.2", 1.75, 0.175, 14), + ("gpt-5.2-pro", "openai/gpt-5.2-pro", 21, 21, 168), + ("gpt-5.2-codex", "openai/gpt-5.2-codex", 1.75, 0.175, 14), + ("gpt-5.1", "openai/gpt-5.1", 1.25, 0.125, 10), + ("gpt-5.1-codex", "openai/gpt-5.1-codex", 1.25, 0.125, 10), + ("gpt-5.1-codex-max", "openai/gpt-5.1-codex-max", 1.25, 0.125, 10), + ("gpt-5.1-codex-mini", "openai/gpt-5.1-codex-mini", 0.25, 0.025, 2), + ("gpt-5", "openai/gpt-5", 1.25, 0.125, 10), + ("gpt-5-pro", "openai/gpt-5-pro", 15, 15, 120), + ("gpt-5-mini", "openai/gpt-5-mini", 0.25, 0.025, 2), + ("gpt-5-nano", "openai/gpt-5-nano", 0.05, 0.005, 0.4), + ("gpt-5-codex", "openai/gpt-5-codex", 1.25, 0.125, 10), + ("codex-mini-latest", "openai/codex-mini-latest", 1.5, 0.375, 6), + ("gpt-4.1", "openai/gpt-4.1", 2, 0.5, 8), + ("gpt-4.1-mini", "openai/gpt-4.1-mini", 0.4, 0.1, 1.6), + ("gpt-4.1-nano", "openai/gpt-4.1-nano", 0.1, 0.025, 0.4), + ("gpt-4o", "openai/gpt-4o", 2.5, 1.25, 10), + ("gpt-4o-mini", "openai/gpt-4o-mini", 0.15, 0.075, 0.6), + ("o3-pro", "openai/o3-pro", 20, 20, 80), + ("o3", "openai/o3", 2, 0.5, 8), + ("o4-mini", "openai/o4-mini", 1.1, 0.275, 4.4), + ("o1-pro", "openai/o1-pro", 150, 150, 600), + ("o1", "openai/o1", 15, 7.5, 60), + ("o1-mini", "openai/o1-mini", 1.1, 0.55, 4.4), + ("o3-mini", "openai/o3-mini", 1.1, 0.55, 4.4), + ] + + for c in cases { + XCTAssertEqual(Pricing.normalize(model: c.raw), c.canonical, c.raw) + XCTAssertTrue(Pricing.priceIsExact(model: c.raw), c.raw) + XCTAssertEqual(Pricing.cost(model: c.raw, tokens: .init(input: hundredK), at: date, + billingInputTokens: hundredK), c.input / 10, accuracy: 0.000_001, c.raw) + XCTAssertEqual(Pricing.cost(model: c.raw, tokens: .init(cacheRead: hundredK), at: date, + billingInputTokens: hundredK), c.cached / 10, accuracy: 0.000_001, c.raw) + XCTAssertEqual(Pricing.cost(model: c.raw, tokens: .init(output: hundredK), at: date, + billingInputTokens: hundredK), c.output / 10, accuracy: 0.000_001, c.raw) + } + + XCTAssertEqual(Pricing.normalize(model: "gpt-5.6"), "openai/gpt-5.6-sol") + XCTAssertTrue(Pricing.priceIsExact(model: "gpt-5.6")) + XCTAssertEqual(Pricing.normalize(model: "openrouter/openai/gpt-5.6-sol"), "openai/gpt-5.6-sol") + XCTAssertEqual(Pricing.normalize(model: "my-sonnet-proxy/gpt-5.5"), "openai/gpt-5.5") + for (snapshot, canonical) in [ + "gpt-5.5-2026-04-23": "openai/gpt-5.5", + "gpt-5.5-pro-2026-04-23": "openai/gpt-5.5-pro", + "gpt-5.4-2026-03-05": "openai/gpt-5.4", + "gpt-5.4-pro-2026-03-05": "openai/gpt-5.4-pro", + "gpt-5.4-mini-2026-03-17": "openai/gpt-5.4-mini", + "gpt-5.4-nano-2026-03-17": "openai/gpt-5.4-nano", + "gpt-5.2-2025-12-11": "openai/gpt-5.2", + "gpt-5.2-pro-2025-12-11": "openai/gpt-5.2-pro", + "gpt-5.1-2025-11-13": "openai/gpt-5.1", + "gpt-5-2025-08-07": "openai/gpt-5", + "gpt-5-pro-2025-10-06": "openai/gpt-5-pro", + "gpt-5-mini-2025-08-07": "openai/gpt-5-mini", + "gpt-5-nano-2025-08-07": "openai/gpt-5-nano", + "gpt-4o-2024-11-20": "openai/gpt-4o", + ] { + XCTAssertEqual(Pricing.normalize(model: snapshot), canonical, snapshot) + XCTAssertTrue(Pricing.priceIsExact(model: snapshot), snapshot) + } + XCTAssertEqual(Pricing.normalize(model: "gpt-5.6-codex"), "gpt-5.6-codex") + } + + func testOpenAILongContextAndCacheWritePricing() { + let date = Date(timeIntervalSince1970: 1_786_233_600) + let tokens = TokenBreakdown(input: 100_000, output: 100_000, + cacheRead: 100_000, cacheWrite: 100_000) + + XCTAssertEqual(Pricing.cost(model: "gpt-5.6-sol", tokens: tokens, at: date, + billingInputTokens: 272_000), 4.175, accuracy: 0.000_001) + XCTAssertEqual(Pricing.cost(model: "gpt-5.6-sol", tokens: tokens, at: date, + billingInputTokens: 272_001), 6.85, accuracy: 0.000_001) + XCTAssertEqual(Pricing.cost(model: "gpt-5.4-mini", tokens: tokens, at: date, + billingInputTokens: 500_000), 0.5325, accuracy: 0.000_001, + "models without a long-context surcharge must keep their base price") + } + + func testLiveBurnUsesCodexAbsolutePromptForLongContextPricing() { + let now = Date(timeIntervalSince1970: 1_786_233_600) + let record = RawRecord( + provider: .codex, model: "gpt-5.6-sol", timestamp: now, cwd: "/p", + tokens: TokenBreakdown(input: 10_000, output: 200), + billingInputTokens: 300_001, + toolName: nil, toolNames: [], messageId: nil, + sessionKey: "codex-live", hasInterrupt: false + ) + let result = FuelCalculator.liveSessions(claudeRecords: [], codexRecords: [record], now: now) + let expected = Pricing.cost(model: record.model, tokens: record.tokens, at: now, + billingInputTokens: record.billingInputTokens) + XCTAssertEqual(result.burnPerMin, expected, accuracy: 0.000_001) + } + + func testCacheStatsIncludeCodexAndLongContextSavings() { + let now = Date(timeIntervalSince1970: 1_786_233_600) + let record = RawRecord( + provider: .codex, model: "gpt-5.6-terra", timestamp: now, cwd: "/p", + tokens: TokenBreakdown(input: 100_000, cacheRead: 900_000), + billingInputTokens: 300_001, + toolName: nil, toolNames: [], messageId: nil, + sessionKey: "codex-cache", hasInterrupt: false + ) + let cache = Aggregator.cacheStat(from: [record]) + XCTAssertEqual(cache.hitRate, 0.9, accuracy: 0.000_001) + XCTAssertEqual(cache.savedUSD, 3.24, accuracy: 0.000_001) + } + /// `normalize` resolved the Opus family with a bare `contains("opus")` that returned /// 4.8, so every `claude-opus-5` record was renamed and merged into the 4.8 row — /// 11,616 turns and ~$1,127 hidden on one real machine. The *cost* stayed right only diff --git a/release-notes/v1.1.9.md b/release-notes/v1.1.9.md new file mode 100644 index 0000000..f5897a3 --- /dev/null +++ b/release-notes/v1.1.9.md @@ -0,0 +1,6 @@ +- OpenAI pricing now covers the current GPT-5.6 Sol, Terra, and Luna tiers plus the active GPT-5.x, GPT-4.x, Codex, and o-series models. Existing GPT-5.5, GPT-5.4, GPT-5.4 mini, and Codex rates have been corrected against OpenAI's current pay-as-you-go prices. +- GPT-5.6, GPT-5.5, and GPT-5.4 requests now apply OpenAI's long-context pricing when the absolute prompt exceeds 272K tokens. GPT-5.6 cache writes also use the published 1.25x input rate. +- Codex token accounting no longer counts reasoning tokens twice, and unknown Codex-like model IDs stay visibly approximate instead of silently inheriting an unrelated model's price. +- Live burn rate and all-time cache hit/savings now use the same provider-aware pricing path as the main spend totals, including Codex cached input and long-context requests. + +**Full Changelog**: https://github.com/Gnonymous/CodingBar/compare/v1.1.8...v1.1.9