Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Scripts/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
<key>CFBundleIdentifier</key>
<string>com.gnonymous.codingbar</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<string>1.2.1</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>1.2.1</string>
<key>CFBundleExecutable</key>
<string>CodingBar</string>
<key>CFBundleIconFile</key>
Expand Down
16 changes: 15 additions & 1 deletion Sources/CodingBar/SelfTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ enum SelfTest {
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"))
check("GPT-6.1 Sol and Sonnet 5.5 cache rates",
Pricing.priceIsExact(model: "gpt-6.1-sol") && Pricing.priceIsExact(model: "claude-sonnet-5-5")
&& abs(Pricing.cost(model: "gpt-6.1-sol", tokens: .init(cacheRead: 10_000), at: september) - 0.001) < 1e-9
&& abs(Pricing.cost(model: "claude-sonnet-5-5", tokens: .init(cacheRead: 10_000), at: september) - 0.001) < 1e-9)
check("Haiku 5.5 prompt-length pricing",
Pricing.priceIsExact(model: "claude-haiku-5-5")
&& abs(Pricing.cost(model: "claude-haiku-5-5", tokens: .init(input: 100_000), at: september) - 0.01) < 1e-9
&& abs(Pricing.cost(model: "claude-haiku-5-5", tokens: .init(input: 100_001), at: september) - 0.0500005) < 1e-9)

// 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
Expand Down Expand Up @@ -117,7 +125,8 @@ enum SelfTest {
- Pricing.cost(model: "claude-fable-5", tokens: millionTokens, at: july)) < 0.000_001)
check("bare family selectors mean the current model",
Pricing.normalize(model: "opus") == "anthropic/claude-opus-5-5"
&& Pricing.normalize(model: "sonnet") == "anthropic/claude-sonnet-5")
&& Pricing.normalize(model: "sonnet") == "anthropic/claude-sonnet-5-5"
&& Pricing.normalize(model: "haiku") == "anthropic/claude-haiku-5-5")
// 1M each of input/output/cacheRead/cacheWrite at $5 / $25 / $0.5 / $6.25 = $36.75.
check("Opus 5 priced at the Opus tier, not the generic fallback",
Pricing.priceIsExact(model: "claude-opus-5")
Expand All @@ -134,6 +143,11 @@ enum SelfTest {
let monthModels = snap.overviews.first { $0.range == .month }?.models.count ?? 0
let todayModels = snap.overviews.first { $0.range == .today }?.models.count ?? 0
check("month composition ⊇ today", monthModels >= todayModels)
check("all range projects retained", snap.overviews.allSatisfy { $0.projects.count == $0.spend.sessions })
check("range trends match totals", snap.overviews.allSatisfy {
$0.trend.reduce(0) { $0 + $1.tokens } == $0.spend.tokens.total
&& abs($0.trend.reduce(0) { $0 + $1.cost } - $0.spend.cost) < 0.000_001
})
let projectDetailsAgree = snap.overviews.flatMap(\.projects).allSatisfy { project in
let modelCost = project.models.reduce(0) { $0 + $1.cost }
let modelTokens = project.models.reduce(0) { $0 + $1.tokens.total }
Expand Down
28 changes: 19 additions & 9 deletions Sources/CodingBarCore/Aggregator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,31 @@ public enum Aggregator {
/// network-fetched quota each run. All three overview ranges are precomputed.
public static func run(now: Date = Date(), quota: [QuotaWindow] = [], menuQuotaProvider: Provider? = nil,
language: AppLanguage = .en) -> Snapshot {
let cal = Calendar.current

// One shared Scanner so the on-disk cache is loaded (and saved) once per run,
// not once per provider — the cache decode is one of the heaviest steps in
// boot-time memory because it goes through `JSONSerialization`.
let scanner = Scanner()
// token/cost/behavior aggregation is 100% local
let (claudeRecords, _) = ClaudeScanner.scan(scanner: scanner)
let codexRecords = CodexScanner.scan(scanner: scanner)
let forecastInsight = Forecaster.recordAndForecast(quota: quota, now: now, language: language)
let quotaForecast = Forecaster.forecastByProvider(quota: quota, now: now, language: language)
return aggregate(claudeRecords: claudeRecords, codexRecords: codexRecords, now: now,
quota: quota, menuQuotaProvider: menuQuotaProvider, language: language,
forecastInsight: forecastInsight, quotaForecast: quotaForecast)
}

/// Accepts parsed records and optional git output so accounting can be checked
/// without reading user logs, repositories, or persisted quota history.
static func aggregate(claudeRecords: [RawRecord], codexRecords: [RawRecord], now: Date,
quota: [QuotaWindow] = [], menuQuotaProvider: Provider? = nil,
language: AppLanguage = .en, gitOutput: GitCorrelator.RangeOutputs? = nil,
forecastInsight: Insight? = nil, quotaForecast: [String: String] = [:]) -> Snapshot {
let cal = Calendar.current
// A scan may observe records written after its start. Keep every snapshot
// surface on that same cutoff, including the current hour/day's trend bucket.
let claudeRecords = claudeRecords.filter { $0.timestamp <= now }
let codexRecords = codexRecords.filter { $0.timestamp <= now }
let allRecords = claudeRecords + codexRecords
func recordCost(_ record: RawRecord) -> Double {
Pricing.cost(model: record.model, tokens: record.tokens,
Expand Down Expand Up @@ -102,7 +118,7 @@ public enum Aggregator {
if seenCwd.insert(r.cwd).inserted { todayCwds.append(r.cwd) }
}
let scanCwds = todayCwds + monthCwds.prefix(10).filter { !seenCwd.contains($0) }
let gitRanges = GitCorrelator.buildRanges(cwds: scanCwds, now: now)
let gitRanges = gitOutput ?? GitCorrelator.buildRanges(cwds: scanCwds, now: now)

// The trend sparkline follows the range pill: Today buckets by hour (since
// 00:00), 7d/30d by calendar day. Each overview builds its own series so the
Expand Down Expand Up @@ -198,8 +214,6 @@ public enum Aggregator {
trend: trendDates.map { entry.buckets[$0] ?? DayPoint(date: $0, cost: 0, tokens: 0) })
}
.sorted { $0.cost > $1.cost }
.prefix(8)
.map { $0 }
return (models, projects)
}

Expand Down Expand Up @@ -279,10 +293,6 @@ public enum Aggregator {
let (liveSessions, burnPerMin) = FuelCalculator.liveSessions(
claudeRecords: claudeRecords, codexRecords: codexRecords, now: now)

// Pillar ④ — Forecast (records history, returns coach insight)
let forecastInsight = Forecaster.recordAndForecast(quota: quota, now: now, language: language)
let quotaForecast = Forecaster.forecastByProvider(quota: quota, now: now, language: language)

// Pillar ④b — Coach tips
var coach: [Insight] = Coach.build(from: todayRecords, language: language)
if let fi = forecastInsight { coach.append(fi) }
Expand Down
9 changes: 7 additions & 2 deletions Sources/CodingBarCore/CodexScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,15 @@ public enum CodexScanner {
// token_count delta must be priced/attributed under the model in
// effect at that turn — freezing on the first one mis-prices every
// turn after a switch and skews model composition + fuel classification.
if let payload = obj["payload"] as? [String: Any],
let m = payload["model"] as? String, !m.isEmpty {
guard let payload = obj["payload"] as? [String: Any] else { return }
if let m = payload["model"] as? String, !m.isEmpty {
model = m
}
// Handoffs can move a running session to another checkout. The
// turn's directory is authoritative for both usage and git attribution.
if let c = payload["cwd"] as? String, !c.isEmpty {
cwd = c
}

case "response_item":
if let payload = obj["payload"] as? [String: Any],
Expand Down
16 changes: 14 additions & 2 deletions Sources/CodingBarCore/Pricing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,20 @@ public enum Pricing {
// land on the generic $3/$15 fallback, i.e. 3.3x underpriced.
"anthropic/claude-mythos-5-1": ModelPrice(input: 10, output: 50, cacheRead: 0.25, cacheWrite5m: 12.5, cacheWrite1h: 20),
"anthropic/claude-mythos-5": ModelPrice(input: 10, output: 50, cacheRead: 1, cacheWrite5m: 12.5, cacheWrite1h: 20),
"anthropic/claude-sonnet-5-5": ModelPrice(input: 2, output: 10, cacheRead: 0.1, cacheWrite5m: 2.5, cacheWrite1h: 4),
"anthropic/claude-sonnet-5": ModelPrice(input: 2, output: 10, cacheRead: 0.2, cacheWrite5m: 2.5, cacheWrite1h: 4),
"anthropic/claude-sonnet-4-6": ModelPrice(input: 3, output: 15, cacheRead: 0.3, cacheWrite5m: 3.75, cacheWrite1h: 6),
// Haiku 5.5 applies the higher tier to the whole request above 100K input
// tokens, including cache reads and writes: https://platform.claude.com/docs/en/about-claude/pricing
"anthropic/claude-haiku-5-5": ModelPrice(input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite5m: 0.125, cacheWrite1h: 0.2,
longContextThreshold: 100_000,
longContextInputMultiplier: 5, longContextOutputMultiplier: 5),
Comment on lines +58 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retarget the Haiku family fallback to 5.5

When a log contains an unrecognized Haiku version such as claude-haiku-5-6, exactCanonicalKey preserves the unknown ID and price(forCanonicalKey:) still routes every claude-haiku-* ID to Haiku 4.5. After making 5.5 the current Haiku tier here, these approximate rows use 4.5's $1/$5 rates instead of 5.5's $0.10/$0.50 rates and miss the new 100K threshold, overestimating a short request by 10×. The family fallback should be advanced to the newly current tier as well.

Useful? React with 👍 / 👎.

"anthropic/claude-haiku-4-5": ModelPrice(input: 1, output: 5, cacheRead: 0.1, cacheWrite5m: 1.25, cacheWrite1h: 2),
// OpenAI standard pay-as-you-go rates. Pro models publish no cached-input
// discount. GPT-6 and GPT-5.6 cache writes use 1.25x input; both TTL fields
// use that one tier. Source: https://developers.openai.com/api/docs/pricing
"openai/gpt-6-astra": openAI(input: 10, cachedInput: 1, output: 50, cacheWrite: 12.5, longContext: true),
"openai/gpt-6.1-sol": openAI(input: 2, cachedInput: 0.1, output: 10, cacheWrite: 2.5, longContext: true),
"openai/gpt-6-sol": openAI(input: 2, cachedInput: 0.2, output: 10, cacheWrite: 2.5, longContext: true),
"openai/gpt-6-luna": openAI(input: 0.1, cachedInput: 0.01, output: 0.5, cacheWrite: 0.125,longContext: true),
// Promotional standard rate confirmed through at least 2026-11-21.
Expand Down Expand Up @@ -124,9 +131,11 @@ public enum Pricing {
for alias in ["fable-5", "claude-fable-5"] { m[alias] = "anthropic/claude-fable-5" }
for alias in ["mythos-5.1", "claude-mythos-5-1", "mythos"] { m[alias] = "anthropic/claude-mythos-5-1" }
for alias in ["mythos-5", "claude-mythos-5"] { m[alias] = "anthropic/claude-mythos-5" }
for alias in ["sonnet-5", "claude-sonnet-5", "sonnet"] { m[alias] = "anthropic/claude-sonnet-5" }
for alias in ["sonnet-5.5", "claude-sonnet-5-5", "sonnet"] { m[alias] = "anthropic/claude-sonnet-5-5" }
for alias in ["sonnet-5", "claude-sonnet-5"] { m[alias] = "anthropic/claude-sonnet-5" }
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",
for alias in ["haiku-5.5", "claude-haiku-5-5", "haiku"] { m[alias] = "anthropic/claude-haiku-5-5" }
for alias in ["haiku-4.5", "claude-haiku-4-5",
"claude-haiku-4-5-20251001"] { m[alias] = "anthropic/claude-haiku-4-5" }
// 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.
Expand Down Expand Up @@ -215,10 +224,13 @@ public enum Pricing {
"anthropic/claude-fable-5": "Fable 5",
"anthropic/claude-mythos-5-1": "Mythos 5.1",
"anthropic/claude-mythos-5": "Mythos 5",
"anthropic/claude-sonnet-5-5": "Sonnet 5.5",
"anthropic/claude-sonnet-5": "Sonnet 5",
"anthropic/claude-sonnet-4-6": "Sonnet 4.6",
"anthropic/claude-haiku-5-5": "Haiku 5.5",
"anthropic/claude-haiku-4-5": "Haiku 4.5",
"openai/gpt-6-astra": "GPT-6 Astra",
"openai/gpt-6.1-sol": "GPT-6.1 Sol",
"openai/gpt-6-sol": "GPT-6 Sol",
"openai/gpt-6-luna": "GPT-6 Luna",
"openai/gpt-5.6-sol": "GPT-5.6 Sol",
Expand Down
5 changes: 3 additions & 2 deletions Sources/CodingBarCore/Scanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ final class Scanner {
/// 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.
/// v9: Codex prefers per-response usage records and no longer counts a prior
/// session's cumulative counter as the first turn of each rollout file.
private static let cacheVersion = 9
/// session's cumulative counter as the first turn of each rollout file. v10:
/// Codex records follow per-turn working-directory changes.
private static let cacheVersion = 10

private struct CacheFile: Codable {
var version: Int
Expand Down
2 changes: 1 addition & 1 deletion Tests/CodingBarCoreTests/SmokeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,7 @@ final class SmokeTests: XCTestCase {

// The bare selectors Claude Code writes when you pick a family, not a version.
XCTAssertEqual(Pricing.normalize(model: "opus"), "anthropic/claude-opus-5-5")
XCTAssertEqual(Pricing.normalize(model: "sonnet"), "anthropic/claude-sonnet-5")
XCTAssertEqual(Pricing.normalize(model: "sonnet"), "anthropic/claude-sonnet-5-5")
}

/// "Unknown → newest" is only safe while every known tier is enumerated. Opus 4.1 costs
Expand Down
Loading
Loading