From 9b84c186cbe90fc57a8b5a5ac018b1326f6cd65c Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:23:47 +0800 Subject: [PATCH 1/2] feat(core): expand pi session attribution and reported cost handling - Prefer pi's exact per-message usage.cost over models.dev estimates, falling back only when absent or zero. - Attribute pi providers to their CodexBar counterparts (DeepSeek, Gemini, xAI, OpenRouter, Kimi, MiniMax, Qwen, Zai, OpenCode, Copilot, Mistral, Groq, Bedrock, Azure OpenAI, MiMo, ...). - Use message.responseModel for OpenRouter auto-style routing and price the actual model. - Bucket tool-result, compaction, and branch-summary usage under Tools/summaries. - Merge pi reports into every mapped provider on the local token snapshot pipeline, not just Claude/Codex. - Bump pi session cache schema to v9 and cost formula version to 2. - Add docs/pi.md and Linux scanner coverage. --- Sources/CodexBarCLI/CLIHelp.swift | 2 + Sources/CodexBarCore/CostUsageFetcher.swift | 2 +- Sources/CodexBarCore/PiSessionCostCache.swift | 4 +- .../CodexBarCore/PiSessionCostScanner.swift | 285 ++++++++++-- .../PiSessionCostScannerTests.swift | 430 +++++++++++++++++- .../PiSessionCostScannerLinuxTests.swift | 55 +++ docs/claude.md | 4 +- docs/codex.md | 5 +- docs/pi.md | 78 ++++ 9 files changed, 803 insertions(+), 62 deletions(-) create mode 100644 TestsLinux/PiSessionCostScannerLinuxTests.swift create mode 100644 docs/pi.md diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 3eb27e8bca..8dc64b2686 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -103,6 +103,8 @@ extension CodexBarCLI { Description: Print local token cost usage from Claude/Codex native logs plus supported pi and OMP sessions. + Pi provider usage is attributed to its CodexBar counterpart (Codex, Claude, DeepSeek, Gemini, xAI, ...) + and uses pi's reported per-message cost when available. This does not require web or CLI access and uses cached scan results unless --refresh is provided. Examples: diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 226d061364..02d2f27e21 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -334,7 +334,7 @@ public struct CostUsageFetcher: Sendable { modelsDevCacheRoot: scanOptions.cacheRoot, sessionRoots: roots) } - if includePiSessions, provider == .claude || (provider == .codex && shouldMergePiUsage) { + if includePiSessions, PiSessionCostScanner.mappedTargetProviders.contains(provider), shouldMergePiUsage { let piReport = try PiSessionCostScanner.loadDailyReportCancellable( provider: provider, since: since, diff --git a/Sources/CodexBarCore/PiSessionCostCache.swift b/Sources/CodexBarCore/PiSessionCostCache.swift index 7c4582a815..655947048c 100644 --- a/Sources/CodexBarCore/PiSessionCostCache.swift +++ b/Sources/CodexBarCore/PiSessionCostCache.swift @@ -2,7 +2,7 @@ import Foundation enum PiSessionCostCacheIO { /// Artifact schema version. Pricing changes are tracked separately by `pricingKey`. - private static let artifactVersion = 8 + private static let artifactVersion = 9 private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! @@ -63,7 +63,7 @@ struct PiSessionCostCache: Codable { var daysByProvider: [String: [String: [String: PiPackedUsage]]] = [:] var files: [String: PiSessionFileUsage] = [:] - init(version: Int = 8) { + init(version: Int = 9) { self.version = version } } diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 4ecdbd3d63..e8984fce79 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -15,6 +15,7 @@ private final class PiSessionISO8601FormatterBox: @unchecked Sendable { }() } +// swiftlint:disable:next type_body_length enum PiSessionCostScanner { struct Options { var piSessionsRoot: URL? @@ -75,12 +76,15 @@ enum PiSessionCostScanner { private static let costScale = 1_000_000_000.0 /// Bump for Pi-only cost formula changes not represented by the parser or pricing fingerprints. - private static let costFormulaVersion = 1 + private static let costFormulaVersion = 2 private static let maxLineBytes = 16 * 1024 * 1024 private static let maxSafeRoundedInt = Double(Int.max) - 1 private static let sessionStartFilenameRegex = try? NSRegularExpression( pattern: "^(\\d{4}-\\d{2}-\\d{2})T(\\d{2})-(\\d{2})-(\\d{2})-(\\d{3})Z_") private static let isoFormatterBox = PiSessionISO8601FormatterBox() + /// Bucket for usage recorded on tool results, compactions, and branch summaries. Matches + /// pi's own `Tools/summaries` attribution so session totals reconcile with `/session`. + private static let toolsSummariesModelName = "Tools/summaries" static func loadDailyReport( provider: UsageProvider, @@ -107,7 +111,7 @@ enum PiSessionCostScanner { options: Options = Options(), checkCancellation: CostUsageScanner.CancellationCheck?) throws -> CostUsageDailyReport { - guard provider == .codex || provider == .claude else { + guard mappedTargetProviders.contains(provider) else { return CostUsageDailyReport(data: [], summary: nil) } @@ -224,7 +228,7 @@ enum PiSessionCostScanner { cacheRoot: URL? = nil, calendar: Calendar = .current) -> CachedDailyReportResult? { - guard provider == .codex || provider == .claude else { return nil } + guard mappedTargetProviders.contains(provider) else { return nil } let range = CostUsageScanner.CostUsageDayRange(since: since, until: until, calendar: calendar) let cache = PiSessionCostCacheIO.load(cacheRoot: cacheRoot) @@ -255,7 +259,7 @@ enum PiSessionCostScanner { modelsDevArtifact: modelsDevArtifact, formulaVersion: Self.costFormulaVersion, parserHash: CodexParserHash.value, - modelsDevProviderIDs: ["anthropic", "openai"])) + modelsDevProviderIDs: Self.modelsDevProviderIDsForPricing)) } private static func requestedWindowExpandsCache( @@ -518,40 +522,62 @@ enum PiSessionCostScanner { else { return } guard let type = object["type"] as? String else { return } - if type == "session" { + switch type { + case "session": sessionID = sessionID ?? self.sessionIdentifier(from: object) - return - } - - if type == "model_change" { + case "model_change": currentModelContext = self.modelContext(from: object) + case "message": + guard let message = object["message"] as? [String: Any] else { return } + let role = (message["role"] as? String) ?? "" + if role == "assistant" { + let identity = self.resolveAssistantIdentity( + entry: object, + message: message, + fallback: currentModelContext) + guard let identity else { return } + guard let date = self.timestampDate(entry: object, message: message) else { return } + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey( + from: date, + calendar: range.calendar) + let usage = self.extractUsage( + provider: identity.provider, + modelName: identity.modelName, + message: message, + pricingDate: date, + pricingContext: pricingContext) + add( + provider: identity.provider, + dayKey: dayKey, + modelName: identity.modelName, + usage: usage, + entryID: self.entryIdentifier(from: object)) + } else if role == "toolResult", + let usageDict = message["usage"] as? [String: Any], + let context = currentModelContext + { + self.addAncillaryUsage( + entry: object, + usage: usageDict, + context: context, + range: range, + pricingContext: pricingContext, + add: add) + } + case "compaction", "branch_summary": + guard let usageDict = object["usage"] as? [String: Any], + let context = currentModelContext + else { return } + self.addAncillaryUsage( + entry: object, + usage: usageDict, + context: context, + range: range, + pricingContext: pricingContext, + add: add) + default: return } - - guard type == "message", let message = object["message"] as? [String: Any] else { return } - guard (message["role"] as? String) == "assistant" else { return } - - let identity = self.resolveAssistantIdentity( - entry: object, - message: message, - fallback: currentModelContext) - guard let identity else { return } - guard let date = self.timestampDate(entry: object, message: message) else { return } - let dayKey = CostUsageScanner.CostUsageDayRange.dayKey( - from: date, - calendar: range.calendar) - let usage = self.extractUsage( - provider: identity.provider, - modelName: identity.modelName, - message: message, - pricingDate: date, - pricingContext: pricingContext) - add( - provider: identity.provider, - dayKey: dayKey, - modelName: identity.modelName, - usage: usage, - entryID: self.entryIdentifier(from: object)) } }) } catch is CancellationError { @@ -584,6 +610,27 @@ enum PiSessionCostScanner { return candidate } + // swiftlint:disable:next function_parameter_count + private static func addAncillaryUsage( + entry: [String: Any], + usage: [String: Any], + context: PiModelContext, + range: CostUsageScanner.CostUsageDayRange, + pricingContext: ModelsDevPricingContext?, + add: (UsageProvider, String, String, PiPackedUsage, String?) -> Void) + { + guard let date = self.timestampDate(entry: entry, message: [:]) else { return } + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: date, calendar: range.calendar) + guard let provider = UsageProvider(rawValue: context.providerRawValue) else { return } + let packed = self.extractUsage( + provider: provider, + modelName: Self.toolsSummariesModelName, + message: ["usage": usage], + pricingDate: date, + pricingContext: pricingContext) + add(provider, dayKey, Self.toolsSummariesModelName, packed, self.entryIdentifier(from: entry)) + } + private static func rebuildDailyUsage( cache: inout PiSessionCostCache, files: [SessionFileCandidate], @@ -700,7 +747,13 @@ enum PiSessionCostScanner { } private static func extractModelText(entry: [String: Any], message: [String: Any]) -> String? { - for value in [message["model"], entry["model"], message["modelId"], entry["modelId"]] { + for value in [ + message["responseModel"], + message["model"], + entry["model"], + message["modelId"], + entry["modelId"], + ] { if let model = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty { return model } @@ -801,14 +854,20 @@ enum PiSessionCostScanner { cacheWriteTokens: cacheWrite, outputTokens: output, totalTokens: totalTokens) - // Pi-compatible JSONL does not record Anthropic cache retention, so use Pi's persisted default tariff. - let costUSD = self.computedCostUSD( - provider: provider, - modelName: modelName, - usage: rawUsage, - pricingDate: pricingDate, - pricingContext: pricingContext) - let costNanos = costUSD.map { Int64(($0 * self.costScale).rounded()) } ?? 0 + // Pi records exact per-message costs. Prefer that over our own estimate; fall back to + // the models.dev catalog when the session omits cost (or reports a zero total). + let reportedCostNanos = self.reportedCostNanos(usage: usage) + // Pi-compatible JSONL does not record Anthropic cache retention, so use Pi's persisted + // default tariff when the session omitted a per-message cost. + let costUSD = reportedCostNanos > 0 + ? Double(reportedCostNanos) / self.costScale + : self.computedCostUSD( + provider: provider, + modelName: modelName, + usage: rawUsage, + pricingDate: pricingDate, + pricingContext: pricingContext) + let costNanos = costUSD.map { Int64(($0 * self.costScale).rounded()) } ?? reportedCostNanos return PiPackedUsage( inputTokens: rawUsage.inputTokens, @@ -821,6 +880,24 @@ enum PiSessionCostScanner { usageSampleCount: 1) } + private static func reportedCostNanos(usage: [String: Any]) -> Int64 { + guard let cost = usage["cost"] as? [String: Any] else { return 0 } + let total = self.readNonNegativeDouble(cost["total"]) + if let total { + return Int64((total * Self.costScale).rounded()) + } + let components = [ + cost["input"], + cost["output"], + cost["cacheRead"], + cost["cacheWrite"], + ] + let sum = components.reduce(0.0) { partial, value in + partial + (self.readNonNegativeDouble(value) ?? 0) + } + return Int64((sum * Self.costScale).rounded()) + } + private static func computedCostUSD( provider: UsageProvider, modelName: String, @@ -833,7 +910,7 @@ enum PiSessionCostScanner { // Pi records input, cache reads, and cache writes as disjoint counts. Codex pricing // expects cached/write tokens to be subsets of total input, so reconstruct that total // here and pass writes separately (1.25x input for GPT-5.6 when rates are known). - CostUsagePricing.codexCostUSD( + return CostUsagePricing.codexCostUSD( model: modelName, inputTokens: usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens, cachedInputTokens: usage.cacheReadTokens, @@ -842,7 +919,7 @@ enum PiSessionCostScanner { modelsDevCatalog: pricingContext?.catalog, modelsDevCacheRoot: pricingContext?.cacheRoot) case .claude: - CostUsagePricing.claudeCostUSD( + return CostUsagePricing.claudeCostUSD( model: modelName, inputTokens: usage.inputTokens, cacheReadInputTokens: usage.cacheReadTokens, @@ -852,7 +929,48 @@ enum PiSessionCostScanner { modelsDevCatalog: pricingContext?.catalog, modelsDevCacheRoot: pricingContext?.cacheRoot) default: - nil + guard let providerID = modelsDevProviderID(for: provider), + let lookup = pricingContext?.catalog?.pricing(providerID: providerID, modelID: modelName) + ?? ModelsDevPricingPipeline.lookup( + providerID: providerID, + modelID: modelName, + cacheRoot: pricingContext?.cacheRoot) + else { return nil } + let pricing = lookup.pricing + let inputRate = pricing.inputCostPerToken + let cacheReadRate = pricing.cacheReadInputCostPerToken ?? inputRate + let cacheWriteRate = pricing.cacheCreationInputCostPerToken ?? inputRate + return Double(usage.inputTokens) * inputRate + + Double(usage.cacheReadTokens) * cacheReadRate + + Double(usage.cacheWriteTokens) * cacheWriteRate + + Double(usage.outputTokens) * pricing.outputCostPerToken + } + } + + // swiftlint:disable:next cyclomatic_complexity + private static func modelsDevProviderID(for provider: UsageProvider) -> String? { + switch provider { + case .codex: "openai" + case .claude: "anthropic" + case .deepseek: "deepseek" + case .gemini: "google" + case .vertexai: "google-vertex" + case .xai: "xai" + case .openrouter: "openrouter" + case .kimi: "kimi" + case .minimax: "minimax" + case .moonshot: "moonshot" + case .qwencloud: "qwen" + case .zai: "zai" + case .opencode: "opencode" + case .opencodego: "opencode-go" + case .copilot: "github" + case .mistral: "mistral" + case .groq: "groq" + case .bedrock: "amazon-bedrock" + case .azureopenai: "azure" + case .mimo: "xiaomi" + default: nil } } @@ -872,20 +990,91 @@ enum PiSessionCostScanner { } return 0 } + + private static func readNonNegativeDouble(_ value: Any?) -> Double? { + guard let value else { return nil } + if let number = value as? NSNumber { + let numeric = number.doubleValue + guard numeric.isFinite, numeric >= 0, numeric <= self.maxSafeRoundedInt else { return nil } + return numeric + } + if let string = value as? String, + let numeric = Double(string), + numeric.isFinite, + numeric >= 0, + numeric <= self.maxSafeRoundedInt + { + return numeric + } + return nil + } } extension PiSessionCostScanner { + // swiftlint:disable:next cyclomatic_complexity private static func mappedProvider(fromPiProvider provider: String) -> UsageProvider? { switch provider.lowercased() { - case "openai-codex": - .codex case "anthropic": .claude + case "openai-codex": + .codex + case "openai": + .openai + case "deepseek": + .deepseek + case "google": + .gemini + case "google-vertex": + .vertexai + case "xai": + .xai + case "openrouter": + .openrouter + case "kimi-coding": + .kimi + case "minimax", "minimax-cn": + .minimax + case "moonshotai", "moonshotai-cn": + .moonshot + case "qwen-token-plan", "qwen-token-plan-cn": + .qwencloud + case "zai", "zai-coding-cn": + .zai + case "opencode": + .opencode + case "opencode-go": + .opencodego + case "github-copilot": + .copilot + case "mistral": + .mistral + case "groq": + .groq + case "amazon-bedrock": + .bedrock + case "azure-openai-responses": + .azureopenai + case "xiaomi", "xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp": + .mimo default: nil } } + /// Every CodexBar provider that pi sessions can be attributed to. Used to decide whether a + /// pi report exists for a provider and whether `loadTokenSnapshot` should merge it. + static let mappedTargetProviders: Set = [ + .codex, .openai, .claude, .deepseek, .gemini, .vertexai, .xai, .openrouter, .kimi, .minimax, + .moonshot, .qwencloud, .zai, .opencode, .opencodego, .copilot, .mistral, .groq, + .bedrock, .azureopenai, .mimo, + ] + + private static let modelsDevProviderIDsForPricing: Set = [ + "anthropic", "openai", "deepseek", "google", "google-vertex", "xai", "openrouter", + "moonshot", "kimi", "minimax", "qwen", "zai", "opencode", "opencode-go", "github", + "mistral", "groq", "amazon-bedrock", "azure", "xiaomi", + ] + private static func buildReport( provider: UsageProvider, cache: PiSessionCostCache, diff --git a/Tests/CodexBarTests/PiSessionCostScannerTests.swift b/Tests/CodexBarTests/PiSessionCostScannerTests.swift index 677b807fbd..d058503580 100644 --- a/Tests/CodexBarTests/PiSessionCostScannerTests.swift +++ b/Tests/CodexBarTests/PiSessionCostScannerTests.swift @@ -1,7 +1,10 @@ +// swiftlint:disable file_length + import Foundation import Testing @testable import CodexBarCore +// swiftlint:disable:next type_body_length struct PiSessionCostScannerTests { @Test func `pi scanner maps assistant usage to codex and claude reports`() throws { @@ -388,7 +391,7 @@ struct PiSessionCostScannerTests { } @Test - func `pi scanner ignores explicit unsupported provider even with fallback context`() throws { + func `pi scanner ignores explicit unmapped provider even with fallback context`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } @@ -404,7 +407,7 @@ struct PiSessionCostScannerTests { "timestamp": env.isoString(for: day), "message": [ "role": "assistant", - "provider": "openrouter", + "provider": "nvidia", "model": "gpt-5.4", "timestamp": Int(day.timeIntervalSince1970 * 1000), "usage": [ @@ -653,6 +656,329 @@ struct PiSessionCostScannerTests { #expect(abs((report.data.first?.modelBreakdowns?.first?.costUSD ?? 0) - expectedCost) < 0.000001) } + @Test + func `pi scanner prefers official per-message cost over estimates`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 8, day: 1) + let assistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 120, + "output": 30, + "totalTokens": 150, + "cost": [ + "input": 0.01, + "output": 0.02, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.03, + ], + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-08-01T10-00-00-000Z_official-cost.jsonl", + contents: env.jsonl([assistant])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 150) + #expect(abs((report.data.first?.costUSD ?? 0) - 0.03) < 0.0000001) + } + + @Test + func `pi scanner falls back to estimates when official cost is zero`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 8, day: 2) + let assistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "claude-sonnet-4-6", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 70, + "output": 19, + "totalTokens": 89, + "cost": [ + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0, + ], + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-08-02T10-00-00-000Z_zero-cost.jsonl", + contents: env.jsonl([assistant])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + let expectedCost = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 70, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 19) + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 89) + #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + func `pi scanner uses response model for openrouter auto routing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 8, day: 3) + let assistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openrouter", + "model": "auto", + "responseModel": "anthropic/claude-sonnet-4-6", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 100, + "output": 10, + "totalTokens": 110, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-08-03T10-00-00-000Z_openrouter-auto.jsonl", + contents: env.jsonl([assistant])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .openrouter, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 110) + #expect(report.data.first?.modelBreakdowns?.map(\.modelName) == ["anthropic/claude-sonnet-4-6"]) + } + + @Test + func `pi scanner buckets tool result and summary usage under tools summaries`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 8, day: 4) + let session: [String: Any] = [ + "type": "session", + "id": "tools-session", + "timestamp": env.isoString(for: day), + ] + let modelChange: [String: Any] = [ + "type": "model_change", + "timestamp": env.isoString(for: day), + "provider": "openai-codex", + "modelId": "gpt-5.4", + ] + let toolResult: [String: Any] = [ + "type": "message", + "id": "tool-1", + "timestamp": env.isoString(for: day), + "message": [ + "role": "toolResult", + "toolName": "bash", + "isError": false, + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 10, + "output": 5, + "totalTokens": 15, + ], + ], + ] + let compaction: [String: Any] = [ + "type": "compaction", + "id": "compaction-1", + "timestamp": env.isoString(for: day), + "summary": "Earlier context", + "tokensBefore": 100, + "usage": [ + "input": 20, + "output": 8, + "totalTokens": 28, + ], + ] + let branchSummary: [String: Any] = [ + "type": "branch_summary", + "id": "branch-1", + "timestamp": env.isoString(for: day), + "fromId": "tool-1", + "summary": "Abandoned branch", + "usage": [ + "input": 5, + "output": 2, + "totalTokens": 7, + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-08-04T10-00-00-000Z_tools.jsonl", + contents: env.jsonl([session, modelChange, toolResult, compaction, branchSummary])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 50) + #expect(report.data.first?.modelBreakdowns?.map(\.modelName) == ["Tools/summaries"]) + } + + @Test + func `pi scanner attributes deepseek sessions to deepseek provider`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 8, day: 5) + let assistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "deepseek", + "model": "deepseek-chat", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 40, + "output": 10, + "totalTokens": 50, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-08-05T10-00-00-000Z_deepseek.jsonl", + contents: env.jsonl([assistant])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .deepseek, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 50) + #expect(report.data.first?.modelBreakdowns?.map(\.modelName) == ["deepseek-chat"]) + } + + @Test + func `pi scanner attributes xai and gemini sessions to their providers`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let xaiDay = try env.makeLocalNoon(year: 2026, month: 8, day: 6) + let geminiDay = try env.makeLocalNoon(year: 2026, month: 8, day: 7) + let xaiEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: xaiDay), + "message": [ + "role": "assistant", + "provider": "xai", + "model": "grok-4", + "timestamp": Int(xaiDay.timeIntervalSince1970 * 1000), + "usage": ["input": 30, "output": 7, "totalTokens": 37], + ], + ] + let geminiEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: geminiDay), + "message": [ + "role": "assistant", + "provider": "google", + "model": "gemini-2.5-pro", + "timestamp": Int(geminiDay.timeIntervalSince1970 * 1000), + "usage": ["input": 25, "output": 5, "totalTokens": 30], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-08-06T10-00-00-000Z_multi.jsonl", + contents: env.jsonl([xaiEntry, geminiEntry])) + + let xaiReport = PiSessionCostScanner.loadDailyReport( + provider: .xai, + since: xaiDay, + until: geminiDay, + now: geminiDay, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + #expect(xaiReport.data.count == 1) + #expect(xaiReport.data.first?.date == "2026-08-06") + #expect(xaiReport.data.first?.totalTokens == 37) + + let geminiReport = PiSessionCostScanner.loadDailyReport( + provider: .gemini, + since: xaiDay, + until: geminiDay, + now: geminiDay, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + #expect(geminiReport.data.count == 1) + #expect(geminiReport.data.first?.date == "2026-08-07") + #expect(geminiReport.data.first?.totalTokens == 30) + } + @Test func `pi scanner ignores v3 cache with stale codex cached input pricing`() throws { let env = try CostUsageTestEnvironment() @@ -750,8 +1076,8 @@ struct PiSessionCostScannerTests { #expect(FileManager.default.fileExists(atPath: newCacheURL.path)) let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let rebuilt = newCache.daysByProvider[UsageProvider.codex.rawValue]?[dayKey]?[model] - #expect(newCacheURL.lastPathComponent == "pi-sessions-v8.json") - #expect(newCache.version == 8) + #expect(newCacheURL.lastPathComponent == "pi-sessions-v9.json") + #expect(newCache.version == 9) #expect(rebuilt?.usageSampleCount == 1) #expect(rebuilt?.costSampleCount == 1) #expect(rebuilt?.costNanos == Int64((expectedCost * 1_000_000_000).rounded())) @@ -859,7 +1185,7 @@ struct PiSessionCostScannerTests { let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let rebuilt = newCache.daysByProvider[UsageProvider.codex.rawValue]?[dayKey]?[model] - #expect(newCache.version == 8) + #expect(newCache.version == 9) #expect(rebuilt?.costNanos == Int64((expectedCost * 1_000_000_000).rounded())) } } @@ -1159,8 +1485,8 @@ extension PiSessionCostScannerTests { } } }, - "google": { - "id": "google", + "ollama": { + "id": "ollama", "models": { "gemini-test": { "id": "gemini-test", @@ -1209,8 +1535,8 @@ extension PiSessionCostScannerTests { } } }, - "google": { - "id": "google", + "ollama": { + "id": "ollama", "models": { "gemini-test": { "id": "gemini-test", @@ -1236,6 +1562,92 @@ extension PiSessionCostScannerTests { #expect(secondCache.lastScanUnixMs == firstCache.lastScanUnixMs) } + @Test + func `pi pricing key reprices when mapped third party rates change`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 11) + let firstCatalog = try Self.modelsDevCatalog(""" + { + "google": { + "id": "google", + "models": { + "gemini-2.5-pro": { + "id": "gemini-2.5-pro", + "cost": { "input": 1.25, "output": 10 } + } + } + } + } + """) + #expect(ModelsDevCache.save(catalog: firstCatalog, fetchedAt: day, cacheRoot: env.cacheRoot)) + let assistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "google", + "model": "gemini-2.5-pro", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 1000, "output": 100, "totalTokens": 1100], + ], + ] + _ = try env.writePiSessionFile( + relativePath: "2026-07-11T10-00-00-000Z_google-rates.jsonl", + contents: env.jsonl([assistant])) + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600) + let firstReport = PiSessionCostScanner.loadDailyReport( + provider: .gemini, + since: day, + until: day, + now: day, + options: options) + #expect(firstReport.data.first?.totalTokens == 1100) + let firstExpectedCost = 1000.0 * 1.25 / 1_000_000 + 100.0 * 10 / 1_000_000 + #expect(abs((firstReport.data.first?.costUSD ?? 0) - firstExpectedCost) < 0.0000001) + let firstCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + let firstPricingKey = try #require(firstCache.pricingKey) + + let secondCatalog = try Self.modelsDevCatalog(""" + { + "google": { + "id": "google", + "models": { + "gemini-2.5-pro": { + "id": "gemini-2.5-pro", + "cost": { "input": 2.5, "output": 15 } + } + } + } + } + """) + #expect(ModelsDevCache.save( + catalog: secondCatalog, + fetchedAt: day.addingTimeInterval(1), + cacheRoot: env.cacheRoot)) + #expect(PiSessionCostScanner.loadCachedDailyReport( + provider: .gemini, + since: day, + until: day, + now: day.addingTimeInterval(1), + cacheRoot: env.cacheRoot) == nil) + + let secondReport = PiSessionCostScanner.loadDailyReport( + provider: .gemini, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + let secondCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + #expect(secondCache.pricingKey != firstPricingKey) + let secondExpectedCost = 1000.0 * 2.5 / 1_000_000 + 100.0 * 15 / 1_000_000 + #expect(abs((secondReport.data.first?.costUSD ?? 0) - secondExpectedCost) < 0.0000001) + } + @Test func `pi scanner reparses unchanged cached file when scan window expands`() throws { let env = try CostUsageTestEnvironment() diff --git a/TestsLinux/PiSessionCostScannerLinuxTests.swift b/TestsLinux/PiSessionCostScannerLinuxTests.swift new file mode 100644 index 0000000000..ec1cbc8ce7 --- /dev/null +++ b/TestsLinux/PiSessionCostScannerLinuxTests.swift @@ -0,0 +1,55 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCore + +struct PiSessionCostScannerLinuxTests { + @Test + func `maps deepseek pi session without network`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("pi-linux-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let cacheRoot = root.appendingPathComponent("cache", isDirectory: true) + try FileManager.default.createDirectory(at: sessionsRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + + let day = Date(timeIntervalSince1970: 1_752_192_000) // 2026-07-10 00:00 UTC + let iso = ISO8601DateFormatter().string(from: day) + let entry: [String: Any] = [ + "type": "message", + "timestamp": iso, + "message": [ + "role": "assistant", + "provider": "deepseek", + "model": "deepseek-chat", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 40, + "output": 10, + "totalTokens": 50, + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: entry) + let file = sessionsRoot.appendingPathComponent("2026-07-10T00-00-00-000Z_test.jsonl") + let text = try #require(String(bytes: data, encoding: .utf8)) + try (text + "\n").write(to: file, atomically: true, encoding: .utf8) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .deepseek, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: sessionsRoot, + cacheRoot: cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 50) + #expect(report.data.first?.modelBreakdowns?.map(\.modelName) == ["deepseek-chat"]) + } +} +#endif diff --git a/docs/claude.md b/docs/claude.md index 55d5c5f973..0e9729b60c 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -221,10 +221,12 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential - Deduplicates streaming chunks by `message.id + requestId` (usage is cumulative per chunk). - pi and OMP sessions attribute `anthropic` assistant usage to Claude and bucket it by assistant-turn timestamp, so a single pi-compatible session can contribute to multiple models/days. + - Pi's reported per-message cost is preferred over the models.dev estimate; tool-result, compaction, and + branch-summary usage is bucketed under `Tools/summaries`. See [pi.md](pi.md). - Matching assistant entry IDs within the same session are counted once across roots; distinct turns are retained. - Cache: - Native + merged provider cache: `~/Library/Caches/CodexBar/cost-usage/claude-v2.json` - - pi-compatible session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v7.json` + - pi-compatible session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v9.json` ## Key files - OAuth: `Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/*` diff --git a/docs/codex.md b/docs/codex.md index bf75aea5d1..71d44ca1c6 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -161,14 +161,17 @@ Example: - Native Codex logs parse `event_msg` token_count entries and `turn_context` model markers; when both are present, `turn_context` is authoritative for the model bucket. - pi and OMP sessions count assistant-message usage rows and attribute `openai-codex` assistant usage to Codex. + - Other pi providers map onto their CodexBar counterparts (DeepSeek, Gemini, xAI, OpenRouter, ...); see + [pi.md](pi.md). Pi's reported per-message cost is preferred over the models.dev estimate. - pi-compatible assistant usage is bucketed by assistant-turn timestamp, so mixed-model sessions can contribute to multiple days/models correctly. + - Tool-result, compaction, and branch-summary usage is bucketed under `Tools/summaries`. - Matching assistant entry IDs within the same session are counted once across roots; distinct turns are retained. - Native conversation rows reuse the corrected cached per-file totals and existing pricing tables. They are hidden when pi-compatible usage joins the aggregate because the native-only rows would not reconcile with the merged total. - Cache: - Native + merged provider cache: `~/Library/Caches/CodexBar/cost-usage/codex-v11.json` - - pi-compatible session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v7.json` + - pi-compatible session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v9.json` - Window: configurable 1-365 day rolling history, with a 60s minimum refresh interval. ### Usage & Spend account rows diff --git a/docs/pi.md b/docs/pi.md new file mode 100644 index 0000000000..dd69309704 --- /dev/null +++ b/docs/pi.md @@ -0,0 +1,78 @@ +--- +summary: "Pi coding agent local session cost usage: source paths, provider attribution, and pricing behavior." +read_when: + - Reviewing pi or OMP local session cost scanning + - Changing pi provider attribution or pricing + - Adjusting the pi session cache schema +--- + +# Pi local session costs + +CodexBar scans [Pi](https://github.com/earendil-works/pi) and OMP session JSONL files so coding-agent usage that +flows through those harnesses shows up in the existing local cost history without needing web or CLI access. +The scanner lives in `Sources/CodexBarCore/PiSessionCostScanner.swift` with its cache in +`Sources/CodexBarCore/PiSessionCostCache.swift`. + +## Source paths + +- `~/.pi/agent/sessions/**/*.jsonl` +- `~/.omp/agent/sessions/**/*.jsonl` +- Cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v9.json` + +Files are JSONL with a `type` field (`session`, `model_change`, `message`, `compaction`, `branch_summary`, ...). +Only sessions from the configured window are scanned, with a 60s minimum refresh interval; appended file tails are +re-parsed incrementally and matching assistant entry IDs are counted once across pi and OMP roots. + +## Provider attribution + +Pi assistant messages carry a `provider` field. CodexBar maps supported providers onto existing CodexBar providers: + +| Pi provider | CodexBar provider | +| --- | --- | +| `anthropic` | Claude | +| `openai-codex` | Codex | +| `openai` | OpenAI | +| `deepseek` | DeepSeek | +| `google` | Gemini | +| `google-vertex` | Vertex AI | +| `xai` | xAI | +| `openrouter` | OpenRouter | +| `kimi-coding` | Kimi | +| `minimax`, `minimax-cn` | MiniMax | +| `moonshotai`, `moonshotai-cn` | Moonshot | +| `qwen-token-plan`, `qwen-token-plan-cn` | Qwen Cloud | +| `zai`, `zai-coding-cn` | z.ai | +| `opencode` | OpenCode | +| `opencode-go` | OpenCode Go | +| `github-copilot` | Copilot | +| `mistral` | Mistral | +| `groq` | Groq | +| `amazon-bedrock` | Bedrock | +| `azure-openai-responses` | Azure OpenAI | +| `xiaomi`, `xiaomi-token-plan-*` | MiMo | + +Unmapped providers (`nvidia`, `cerebras`, `together`, `fireworks`, `huggingface`, `cloudflare-*`, +`vercel-ai-gateway`, `ant-ling`, `radius`, ...) are ignored rather than miscounted. + +## Pricing + +Pi records an exact per-message cost in `usage.cost.total`. CodexBar prefers that reported cost and falls back to +models.dev catalog rates only when the session omits cost or reports a zero total. Unknown models stay unpriced. +When a message's `responseModel` is present (for example OpenRouter `auto` routing), it is used as the model for +pricing and breakdowns instead of the requested model name. + +Usage recorded on tool results, compactions, and branch summaries is attributed to the session's current provider +under the `Tools/summaries` model bucket, matching pi's own session totals. + +## Merge behavior + +`CostUsageFetcher.loadTokenSnapshot` merges the pi report into providers that both map from pi and support the local +token snapshot pipeline (Codex, Claude, Vertex AI, Bedrock). Other mapped providers keep their pi buckets in the +shared pi cache for future enablement but do not merge yet. + +## Key files + +- `Sources/CodexBarCore/PiSessionCostScanner.swift` +- `Sources/CodexBarCore/PiSessionCostCache.swift` +- `Sources/CodexBarCore/CostUsageFetcher.swift` +- `Tests/CodexBarTests/PiSessionCostScannerTests.swift` From 59b8e75b65ad69b804ebce61011277c5abb0c2c7 Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:50:42 +0800 Subject: [PATCH 2/2] fix(test): expect current pi session cache version in predecessor test --- Tests/CodexBarTests/CostUsageCacheTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index d06d66c09c..a4f81fdbee 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -104,7 +104,7 @@ struct CostUsageCacheTests { let loaded = PiSessionCostCacheIO.load(cacheRoot: root) - #expect(loaded.version == 8) + #expect(loaded.version == 9) #expect(loaded.lastScanUnixMs == 0) #expect(loaded.files.isEmpty) }