diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4bb4498b3..6891c19688e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.77.1 — Unreleased +### Added + +- Pi: retain valid token-only history from unsupported Pi/OMP backends without borrowing native-provider prices (#4605). Thanks @h3x89! + - Overview: optionally show every Claude adapter, managed Codex, and saved token account using cached usage, while retaining the six-provider selection limit (#4594). Thanks @pedrozimmermannsoares! - Settings: edit saved token-account labels and credentials in place and reorder accounts without changing the active account (#4612). Thanks @lghsigma597! ### Fixed diff --git a/Sources/CodexBarCore/PiSessionCostScanner+Aggregation.swift b/Sources/CodexBarCore/PiSessionCostScanner+Aggregation.swift index 201ba75e533..4d6e86b7bc7 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner+Aggregation.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner+Aggregation.swift @@ -6,12 +6,12 @@ extension PiSessionCostScanner { range: CostUsageScanner.CostUsageDayRange) -> (codex: CostUsageDailyReport, claude: CostUsageDailyReport, pi: CostUsageDailyReport)? { - // Provider-specific by design: Pi has native mirrors and a standalone Bedrock partition. + // Provider-specific by design: Pi has native mirrors and a standalone backend partition. guard let codex = self.buildReport(provider: .codex, cache: cache, range: range), let claude = self.buildReport(provider: .claude, cache: cache, range: range), - let bedrock = self.buildReport(provider: .pi, cache: cache, range: range) + let standalone = self.buildReport(provider: .pi, cache: cache, range: range) else { return nil } - return (codex, claude, CostUsageDailyReport.merged([codex, claude, bedrock], calendar: range.calendar)) + return (codex, claude, CostUsageDailyReport.merged([codex, claude, standalone], calendar: range.calendar)) } static func buildReport( diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index ff52ba36ebc..d334647524d 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -85,6 +85,7 @@ enum PiSessionCostScanner { private struct AssistantIdentity { let provider: UsageProvider let modelName: String + let pricingProvider: UsageProvider? } private struct ModelsDevPricingContext { @@ -102,7 +103,7 @@ enum PiSessionCostScanner { 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 = 4 + private static let costFormulaVersion = 5 private static let maxLineBytes = 16 * 1024 * 1024 private static let sessionStartFilenameRegex = try? NSRegularExpression( pattern: "^(\\d{4}-\\d{2}-\\d{2})T(\\d{2})-(\\d{2})-(\\d{2})-(\\d{3})Z_") @@ -297,7 +298,7 @@ enum PiSessionCostScanner { try checkCancellation?() PiSessionCostCacheIO.save(cache: cache, cacheRoot: options.cacheRoot, calendar: range.calendar) } - // Provider-specific by design: Pi aggregates native vendor mirrors and standalone Bedrock sessions. + // Provider-specific by design: Pi aggregates native vendor mirrors and standalone backend sessions. let lastScanAt = cache.lastScanUnixMs > 0 ? Date(timeIntervalSince1970: TimeInterval(cache.lastScanUnixMs) / 1000) : nil @@ -921,62 +922,54 @@ enum PiSessionCostScanner { entry: object, message: message, fallback: currentModelContext) - guard let identity else { - let unsupported = if let explicit = self.extractProviderText( - entry: object, - message: message) + let date = self.timestampDate(entry: object, message: message) + if let identity, let date { + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey( + from: date, + calendar: range.calendar) + guard CostUsageScanner.CostUsageDayRange.isInRange( + dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) + else { return } + if let usage = self.extractUsage( + provider: identity.pricingProvider, + modelName: identity.modelName, + message: message, + pricingDate: date, + pricingContext: pricingContext) { - self.mappedProvider(fromPiProvider: explicit) == nil - } else { - currentModelContext?.isUnsupportedBackend == true - } - guard unsupported else { - isComplete = false + add( + provider: identity.provider, + dayKey: dayKey, + modelName: identity.modelName, + usage: usage, + entryID: self.entryIdentifier(from: object)) return } - if let date = self.timestampDate(entry: object, message: message) { - let day = CostUsageScanner.CostUsageDayRange.dayKey( - from: date, - calendar: range.calendar) - if CostUsageScanner.CostUsageDayRange.isInRange( - dayKey: day, since: range.scanSinceKey, until: range.scanUntilKey) - { - unsupportedAssistantDayKeys.insert(day) - } - } else { - hasUndatedUnsupportedAssistant = true - } - return } - guard let date = self.timestampDate(entry: object, message: message) else { - // A recognized assistant row without a usable timestamp cannot be - // assigned to a day. Keep the scan incomplete so cache advancement - // never permanently hides its usage. - isComplete = false - return + + // Incomplete unsupported rows affect only standalone history; native failures + // must prevent cache advancement even when their date or model is unknown. + let unsupported = if let identity { + identity.pricingProvider == nil + } else if let explicit = self.extractProviderText(entry: object, message: message) { + self.mappedProvider(fromPiProvider: explicit) == nil + } else { + currentModelContext?.isUnsupportedBackend == true } - let dayKey = CostUsageScanner.CostUsageDayRange.dayKey( - from: date, - calendar: range.calendar) - guard CostUsageScanner.CostUsageDayRange.isInRange( - dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) - else { return } - guard let usage = self.extractUsage( - provider: identity.provider, - modelName: identity.modelName, - message: message, - pricingDate: date, - pricingContext: pricingContext) - else { + guard unsupported else { isComplete = false return } - add( - provider: identity.provider, - dayKey: dayKey, - modelName: identity.modelName, - usage: usage, - entryID: self.entryIdentifier(from: object)) + if let date { + let day = CostUsageScanner.CostUsageDayRange.dayKey(from: date, calendar: range.calendar) + if CostUsageScanner.CostUsageDayRange.isInRange( + dayKey: day, since: range.scanSinceKey, until: range.scanUntilKey) + { + unsupportedAssistantDayKeys.insert(day) + } + } else { + hasUndatedUnsupportedAssistant = true + } } }) // A scan can stop at the last complete newline while an active writer leaves a @@ -1058,11 +1051,13 @@ enum PiSessionCostScanner { } private static func modelContext(from object: [String: Any]) -> PiModelContext? { - guard let providerText = object["provider"] as? String else { return nil } + guard let providerText = (object["provider"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + else { return nil } + let rawModelName = (object["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard let provider = self.mappedProvider(fromPiProvider: providerText) else { - return PiModelContext(providerRawValue: "", modelName: "", isUnsupportedBackend: true) + return PiModelContext( + providerRawValue: providerText, modelName: rawModelName, isUnsupportedBackend: true) } - let rawModelName = (object["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard let modelName = self.normalizeModelName(rawModelName, provider: provider) else { return nil } return PiModelContext(providerRawValue: provider.rawValue, modelName: modelName) } @@ -1076,17 +1071,22 @@ enum PiSessionCostScanner { let explicitProvider = explicitProviderText.flatMap(self.mappedProvider(fromPiProvider:)) let explicitModelText = self.extractModelText(entry: entry, message: message) - if explicitProviderText != nil, explicitProvider == nil { - return nil + if let backend = explicitProviderText ?? + (fallback?.isUnsupportedBackend == true ? fallback?.providerRawValue : nil), + self.mappedProvider(fromPiProvider: backend) == nil + { + let model = explicitModelText ?? (fallback?.providerRawValue == backend ? fallback?.modelName : nil) + guard !backend.isEmpty, let model, !model.isEmpty else { return nil } + return AssistantIdentity(provider: .pi, modelName: "\(backend)/\(model)", pricingProvider: nil) } guard let provider = explicitProvider ?? fallback.flatMap({ UsageProvider(rawValue: $0.providerRawValue) }), let rawModel = explicitModelText ?? fallback.flatMap({ - $0.providerRawValue == provider.rawValue ? $0.modelName : nil + !$0.isUnsupportedBackend && $0.providerRawValue == provider.rawValue ? $0.modelName : nil }), let model = self.normalizeModelName(rawModel, provider: provider) else { return nil } - return AssistantIdentity(provider: provider, modelName: model) + return AssistantIdentity(provider: provider, modelName: model, pricingProvider: provider) } private static func extractProviderText(entry: [String: Any], message: [String: Any]) -> String? { @@ -1149,7 +1149,7 @@ enum PiSessionCostScanner { } private static func extractUsage( - provider: UsageProvider, + provider: UsageProvider?, modelName: String, message: [String: Any], pricingDate: Date? = nil, @@ -1234,13 +1234,15 @@ enum PiSessionCostScanner { } private static func computedCostUSD( - provider: UsageProvider, + provider: UsageProvider?, modelName: String, usage: PiPackedUsage, cacheWrite1h: Int, pricingDate: Date? = nil, pricingContext: ModelsDevPricingContext? = nil) -> Double? { + // Unknown backends contribute standalone tokens without borrowing another provider's prices. + guard let provider else { return nil } // Provider-specific by design: Pi delegates disjoint token costs to the shared tariff calculators. switch provider { case .codex: diff --git a/Tests/CodexBarTests/PiSessionCostReliabilityTests.swift b/Tests/CodexBarTests/PiSessionCostReliabilityTests.swift index a98ae83ecf8..5d0472bb932 100644 --- a/Tests/CodexBarTests/PiSessionCostReliabilityTests.swift +++ b/Tests/CodexBarTests/PiSessionCostReliabilityTests.swift @@ -131,7 +131,7 @@ struct PiSessionCostReliabilityTests { } @Test(arguments: [false, true]) - func `unsupported backends stay incomplete across cached reads and recover after a growing rewrite`( + func `unsupported backends retain unpriced tokens across cached reads and growing rewrites`( mixed: Bool) throws { let env = try CostUsageTestEnvironment() @@ -142,8 +142,14 @@ struct PiSessionCostReliabilityTests { if mixed { rows.append(self.row(env, day, id: "supported", input: 10)) } let path = try env.writePiSessionFile(relativePath: "coverage.jsonl", contents: env.jsonl(rows)) let first = try self.scan(day, options: options) - #expect(!first.isComplete) - #expect(first.report.summary?.totalTokens == (mixed ? 10 : nil)) + #expect(first.isComplete) + #expect(first.report.summary?.totalTokens == (mixed ? 40 : 30)) + #expect(first.report.data.first?.coverageCounts.unpriced == 1) + let unpriced = try #require(first.report.data.first?.modelBreakdowns?.first { + $0.modelName == "openrouter/gpt-5.4" + }) + #expect(unpriced.totalTokens == 30) + #expect(unpriced.costUSD == nil) #expect(first.lastScanAt == day) #expect(PiSessionCostScanner.loadCachedDailyReportResult( provider: .pi, @@ -152,7 +158,7 @@ struct PiSessionCostReliabilityTests { now: day, cacheRoot: env.cacheRoot, options: options, - allowEstablishedEmpty: true) == nil) + allowEstablishedEmpty: true)?.report.summary?.totalTokens == (mixed ? 40 : 30)) let native = try self.scan(day, options: options, provider: .codex) #expect(native.isComplete) #expect(native.report.summary?.totalTokens == (mixed ? 10 : nil)) @@ -165,21 +171,64 @@ struct PiSessionCostReliabilityTests { #expect(restored.report.summary?.totalTokens == (mixed ? 47 : 37)) } + @Test(arguments: [false, true]) + func `malformed unsupported usage remains incomplete without poisoning native history`(undated: Bool) throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = try self.options(env) + let input: Any = undated ? 10 : "invalid" + var invalid = self.row(env, day, id: "invalid", input: input, provider: "openrouter") + if undated { invalid.removeValue(forKey: "timestamp") } + _ = try env.writePiSessionFile(relativePath: "invalid-backend.jsonl", contents: env.jsonl([ + self.row(env, day, id: "valid", input: 10), invalid, + ])) + #expect(try !self.scan(day, options: options).isComplete) + let native = try self.scan(day, options: options, provider: .codex) + #expect(native.isComplete) + #expect(native.report.summary?.totalTokens == 10) + } + + @Test + func `unsupported backend context cannot supply a native provider model`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = try self.options(env) + _ = try env.writePiSessionFile(relativePath: "context-collision.jsonl", contents: env.jsonl([ + ["type": "model_change", "provider": "claude", "modelId": "fixture-model"], + [ + "type": "message", "timestamp": env.isoString(for: day), + "message": ["role": "assistant", "provider": "anthropic", "usage": ["input": 10, "output": 1]], + ], + ])) + let native = try self.scan(day, options: options, provider: .claude) + #expect(!native.isComplete) + #expect(native.report.summary?.totalTokens == nil) + } + @Test func `unsupported model change context survives an append without poisoning native partitions`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) let options = try self.options(env) - let change: [String: Any] = ["type": "model_change", "provider": "google", "modelId": "gemini-2.5-pro"] + let change: [String: Any] = [ + "type": "model_change", "provider": " google ", "modelId": "gemini-2.5-pro", + ] let message: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": ["role": "assistant", "usage": ["input": 10, "output": 1]], ] - let file = try env.writePiSessionFile(relativePath: "context.jsonl", contents: env.jsonl([change, message])) - #expect(try !self.scan(day, options: options).isComplete) + let file = try env.writePiSessionFile(relativePath: "context.jsonl", contents: env.jsonl([change])) + _ = try self.scan(day, options: options) + try self.append(env.jsonl([message]), to: file) + #expect(try self.scan(day, options: options).isComplete) + #expect(try self.scan(day, options: options).report.summary?.totalTokens == 11) + #expect(try self.scan(day, options: options).report.data.first?.modelsUsed == ["google/gemini-2.5-pro"]) try self.append(env.jsonl([message]), to: file) - #expect(try !self.scan(day.addingTimeInterval(1), options: options).isComplete) + #expect(try self.scan(day.addingTimeInterval(1), options: options).isComplete) + #expect(try self.scan(day, options: options).report.summary?.totalTokens == 22) #expect(try self.scan(day, options: options, provider: .claude).isComplete) } diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 2083d33df84..022f899005f 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -3456,6 +3456,13 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 1, expectedReferenceFingerprint: ["codex@0"], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), + AllowedProviderConstruct( + path: "Sources/CodexBarCore/PiSessionCostScanner.swift", + anchor: "guard !backend.isEmpty, let model, !model.isEmpty else { return nil }", + expectedProviderIDs: ["pi"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["pi@0"], + reason: "Unsupported backends retain tokens only in standalone Pi history without native-provider pricing."), AllowedProviderConstruct( path: "Sources/CodexBarCore/PiSessionCostScanner.swift", anchor: "guard provider == .codex || provider == .claude || provider == .pi else {", diff --git a/docs/pi.md b/docs/pi.md index b46e51762d3..7b28d12731f 100644 --- a/docs/pi.md +++ b/docs/pi.md @@ -9,7 +9,7 @@ read_when: Enable Pi in Settings → Providers to show Pi and OMP token history as a separate local source in the menu, Usage & Spend, Overview, and widgets. Pi has no subscription quota or account balance. Costs are API-rate estimates from recorded assistant usage, not a billing statement. No provider login or credential is required to read the transcripts. -The scanner supports the `openai-codex`, `anthropic`, and `amazon-bedrock` backends. Bedrock sessions stay in standalone Pi history and use the exact model ID in the cached models.dev Bedrock catalog, preserving regional and global prices. They are never mirrored into native Claude or Codex history. Other backends keep standalone Pi history incomplete; they do not become measured zero or invalidate supported partitions. Supported usage with an unknown model retains its recorded tokens and is marked unpriced. Mixed priced and unpriced history preserves the known subtotal without presenting it as a complete cost. +The scanner prices the `openai-codex`, `anthropic`, and `amazon-bedrock` backends. Bedrock sessions stay in standalone Pi history and use the exact model ID in the cached models.dev Bedrock catalog, preserving regional and global prices. They are never mirrored into native Claude or Codex history. Other backends retain valid timestamped usage as unpriced tokens in standalone Pi history, labeled with their backend and model; they never borrow native-provider prices or enter native Claude/Codex history. Missing or malformed usage keeps standalone history incomplete without invalidating supported partitions. Supported usage with an unknown model retains its recorded tokens and is marked unpriced. Mixed priced and unpriced history preserves the known subtotal without presenting it as a complete cost. The `cacheWrite1h` counter (`cttl.ephemeral1h` on OMP) is the one-hour subset of `cacheWrite`, priced at twice the input rate; remaining writes use the default cache-write tariff. The first present field wins in this order: `cacheWrite1h`, `cache_write_1h`, `cttl.ephemeral1h`, `cttl.ephemeral_1h`. Missing one-hour counters retain the default tariff. Invalid counts, including a one-hour count larger than total writes, keep history incomplete; a malformed first-present counter cannot fall through to another spelling. Pi's recorded `usage.cost` is not used to replace the independent estimate. @@ -50,3 +50,5 @@ Incomplete refreshes can preserve a previously valid report with its original so Files replaced while being read leave the refresh incomplete. A pricing-catalog change requires a complete reparse before new estimates replace the prior report. An explicit refresh reparses Pi history even when file size and modification time are unchanged, including Pi usage shown under Claude or Codex. The one-hour counter correction invalidates the Pi pricing key once, including estimates mirrored into Claude or Codex reports. Existing Pi cache rows do not retain the one-hour split, so all Pi sessions in the scan window must be reparsed before publishing corrected prices. The cache schema and native Claude/Codex caches are unchanged; subsequent refreshes resume normal cache reuse. + +The addition of unsupported-backend token history also invalidates the Pi pricing key once so previously ignored rows are collected. The cache schema stays unchanged.