diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index e1d0e74703..eedbe993f2 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -616,9 +616,13 @@ public struct CostUsageFetcher: Sendable { } } - private struct UnknownPricingRefreshRequest: Sendable { + private struct ModelsDevPricingTarget: Hashable, Sendable { let providerID: String - let modelIDs: Set + let modelID: String + } + + private struct UnknownPricingRefreshRequest: Sendable { + let targets: Set let now: Date let cacheRoot: URL? let client: ModelsDevClient @@ -632,22 +636,24 @@ public struct CostUsageFetcher: Sendable { client: ModelsDevClient) -> UnknownPricingRefreshRequest? { guard provider == .codex || provider == .claude else { return nil } - let unknownModelIDs = Set(daily.data.flatMap { entry in - entry.modelBreakdowns?.compactMap { breakdown -> String? in - guard breakdown.costUSD == nil else { return nil } - if provider == .codex, - CostUsagePricing.isCodexUnattributedModel(breakdown.modelName) - { - return nil + var targets = Set() + for entry in daily.data { + for breakdown in entry.modelBreakdowns ?? [] { + guard breakdown.costUSD == nil else { continue } + if provider == .codex { + guard !CostUsagePricing.isCodexUnattributedModel(breakdown.modelName) else { continue } + for target in CostUsagePricing.codexModelsDevPricingTargets(for: breakdown.modelName) { + targets.insert(ModelsDevPricingTarget(providerID: target.providerID, modelID: target.modelID)) + } + } else { + targets.insert(ModelsDevPricingTarget(providerID: "anthropic", modelID: breakdown.modelName)) } - return breakdown.modelName - } ?? [] - }) - guard !unknownModelIDs.isEmpty else { return nil } + } + } + guard !targets.isEmpty else { return nil } return UnknownPricingRefreshRequest( - providerID: provider == .codex ? "openai" : "anthropic", - modelIDs: unknownModelIDs, + targets: targets, now: now, cacheRoot: cacheRoot, client: client) @@ -657,23 +663,30 @@ public struct CostUsageFetcher: Sendable { _ request: UnknownPricingRefreshRequest, inBackground: Bool) async -> Bool { - if inBackground { - Task.detached(priority: .utility) { - _ = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( - providerID: request.providerID, - modelIDs: request.modelIDs, + func refreshTargets() async -> Bool { + let targetsByProvider = Dictionary(grouping: request.targets, by: \.providerID) + for providerID in targetsByProvider.keys.sorted() { + let modelIDs = Set(targetsByProvider[providerID, default: []].map(\.modelID)) + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: providerID, + modelIDs: modelIDs, now: request.now, cacheRoot: request.cacheRoot, client: request.client) + if outcome == .pricingAvailable { + return true + } + } + return false + } + + if inBackground { + Task.detached(priority: .utility) { + _ = await refreshTargets() } return false } - return await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( - providerID: request.providerID, - modelIDs: request.modelIDs, - now: request.now, - cacheRoot: request.cacheRoot, - client: request.client) == .pricingAvailable + return await refreshTargets() } static func loadCachedCodexTokenSnapshot( diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 40e7d25b1b..7d75ccf273 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "e2899fcb0234e5c1" + static let value = "3c1ec2b780582978" } diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 3845ae5931..72008ea6d5 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -75,7 +75,7 @@ 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( @@ -256,7 +256,7 @@ enum PiSessionCostScanner { modelsDevArtifact: modelsDevArtifact, formulaVersion: Self.costFormulaVersion, parserHash: CodexParserHash.value, - modelsDevProviderIDs: ["anthropic", "openai"])) + modelsDevProviderIDs: CostUsagePricing.codexModelsDevProviderIDs.union(["anthropic"]))) } private static func requestedWindowExpandsCache( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 4c04113d89..4ea8da01ee 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -421,8 +421,62 @@ enum CostUsagePricing { ] private static let codexModelsDevProviderID = "openai" + /// Provider IDs emitted by Codex-compatible clients that have matching entries in models.dev. + /// + /// The route prefix is part of the model identity for local usage estimates. Keep both the + /// client-facing aliases and their models.dev provider IDs here so pricing-cache fingerprints + /// invalidate when any supported route's rates change. + static let codexModelsDevProviderIDs: Set = [ + "deepseek", + "kimi-coding", + "kimi-for-coding", + "openai", + "opencode", + "opencode-free", + "opencode-go", + ] private static let claudeModelsDevProviderID = "anthropic" + /// Returns the provider/model identities that may price a Codex model. Keep this mapping + /// shared by direct lookup and unknown-price refresh so a newly downloaded catalog is checked + /// under the same identity that was used to resolve the model. + static func codexModelsDevPricingTargets(for rawModel: String) -> [(providerID: String, modelID: String)] { + let trimmed = rawModel.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + if let slash = trimmed.firstIndex(of: "/") { + let routeID = String(trimmed[.. String { var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("openai/") { @@ -541,18 +595,12 @@ enum CostUsagePricing { { let key = self.normalizeCodexModel(model) guard key != self.codexUnattributedModel else { return nil } - let modelsDevLookup = self.modelsDevLookup( - providerID: self.codexModelsDevProviderID, + let modelsDevLookup = self.codexModelsDevLookup( model: model, catalog: modelsDevCatalog, cacheRoot: modelsDevCacheRoot) - ?? (model == key ? nil : self.modelsDevLookup( - providerID: self.codexModelsDevProviderID, - model: key, - catalog: modelsDevCatalog, - cacheRoot: modelsDevCacheRoot)) if let lookup = modelsDevLookup { - let bundled = self.codex[key] + let bundled = lookup.pricing.providerID == self.codexModelsDevProviderID ? self.codex[key] : nil // A missing catalog context block means models.dev has no long-context opinion, so use // the bundled tuple. Once the block exists, preserve its omissions and normal fallback // semantics instead of filling individual fields from a different pricing source. @@ -590,6 +638,27 @@ enum CostUsagePricing { return pricing } + /// Resolves the provider-qualified model IDs written by Codex-compatible clients without + /// falling back to OpenAI pricing for an unrelated route. Unqualified model IDs retain the + /// historical OpenAI behavior, including the gpt-5.6 alias lookup. + private static func codexModelsDevLookup( + model rawModel: String, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> ModelsDevPricingLookup? + { + for target in self.codexModelsDevPricingTargets(for: rawModel) { + if let lookup = self.modelsDevLookup( + providerID: target.providerID, + model: target.modelID, + catalog: catalog, + cacheRoot: cacheRoot) + { + return lookup + } + } + return nil + } + static func codexPriorityCostUSD( model: String, inputTokens: Int, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift index 1e3e8d565e..7148b861c8 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift @@ -10,7 +10,7 @@ enum CostUsagePricingKey { modelsDevArtifact: ModelsDevCacheArtifact?, formulaVersion: Int, parserHash: String? = nil, - modelsDevProviderIDs: Set = ["openai"]) -> String + modelsDevProviderIDs: Set = CostUsagePricing.codexModelsDevProviderIDs) -> String { var parts = [ "costFormulaVersion=\(formulaVersion)", diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index c36513fe23..744f2b7288 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -2081,7 +2081,7 @@ enum CostUsageScanner { /// Bump when the report pricing formula changes. Rates are resolved when reports are read; /// this fingerprint only invalidates downstream presentation caches such as Workspaces snapshots. - private static let codexCostFormulaVersion = 2 + private static let codexCostFormulaVersion = 3 static func codexPricingKey(modelsDevArtifact: ModelsDevCacheArtifact?) -> String { CostUsagePricingKey.codex( diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index 4ea858f6c4..0bf5b20d9f 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -24,6 +24,48 @@ struct CostUsageFetcherUnknownModelPricingTests { #expect(abs((breakdown.costUSD ?? 0) - 0.00028) < 0.0000001) } + @Test + func `fetcher reprices a provider qualified model after an on demand catalog refresh`() async throws { + let fixture = try UnknownModelPricingFixture() + defer { fixture.environment.cleanup() } + let qualifiedTurnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": fixture.environment.isoString(for: fixture.day), + "payload": ["model": "opencode-go/deepseek-v4-flash"], + ] + let qualifiedTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": fixture.environment.isoString(for: fixture.day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + _ = try fixture.environment.writeCodexSessionFile( + day: fixture.day, + filename: "unknown-qualified-model.jsonl", + contents: fixture.environment.jsonl([qualifiedTurnContext, qualifiedTokenCount])) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: fixture.day, + refreshPricingInBackground: false, + scannerOptions: fixture.options, + modelsDevClient: ModelsDevClient(transport: CostUsageFetcherModelsDevTransport( + data: fixture.refreshedCatalog))) + + let breakdown = try #require(snapshot.daily + .flatMap { $0.modelBreakdowns ?? [] } + .first { $0.modelName == "opencode-go/deepseek-v4-flash" }) + #expect(abs((breakdown.costUSD ?? 0) - 0.0000084) < 0.0000001) + } + @Test func `pricing retry preserves disabled pi session merging`() async throws { let fixture = try UnknownModelPricingFixture() @@ -222,6 +264,15 @@ private struct UnknownModelPricingFixture { "id": "openai", "models": { "gpt-new": { "id": "gpt-new", "cost": { "input": 2, "output": 8 } } } }, + "opencode-go": { + "id": "opencode-go", + "models": { + "deepseek-v4-flash": { + "id": "deepseek-v4-flash", + "cost": { "input": 0.07, "output": 0.14 } + } + } + }, "anthropic": { "id": "anthropic", "models": { "claude-new": { "id": "claude-new", "cost": { "input": 3, "output": 15 } } } diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 96ce407a6a..fda9c2b3dc 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -50,6 +50,115 @@ struct CostUsagePricingTests { #expect(cost == nil) } + @Test + func `codex cost resolves OpenCodex provider qualified models`() throws { + let root = try Self.seedModelsDevCache(""" + { + "deepseek": { + "id": "deepseek", + "models": { + "deepseek-v4-flash": { + "id": "deepseek-v4-flash", + "cost": { "input": 0.14, "output": 0.28 } + } + } + }, + "kimi-for-coding": { + "id": "kimi-for-coding", + "models": { + "k3": { + "id": "k3", + "cost": { "input": 0, "output": 0 } + } + } + }, + "opencode": { + "id": "opencode", + "models": { + "deepseek-v4-flash-free": { + "id": "deepseek-v4-flash-free", + "cost": { "input": 0, "output": 0 } + } + } + }, + "opencode-go": { + "id": "opencode-go", + "models": { + "deepseek-v4-flash": { + "id": "deepseek-v4-flash", + "cost": { "input": 0.07, "output": 0.14 } + } + } + } + } + """) + + let opencodeGo = CostUsagePricing.codexCostUSD( + model: "opencode-go/deepseek-v4-flash", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5, + modelsDevCacheRoot: root) + let opencodeFree = CostUsagePricing.codexCostUSD( + model: "opencode-free/deepseek-v4-flash-free", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5, + modelsDevCacheRoot: root) + let kimi = CostUsagePricing.codexCostUSD( + model: "kimi-coding/k3", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5, + modelsDevCacheRoot: root) + let deepseek = CostUsagePricing.codexCostUSD( + model: "deepseek/deepseek-v4-flash", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5, + modelsDevCacheRoot: root) + + #expect(opencodeGo == (100.0 * 0.07e-6) + (5.0 * 0.14e-6)) + #expect(opencodeFree == 0) + #expect(kimi == 0) + #expect(deepseek == (100.0 * 0.14e-6) + (5.0 * 0.28e-6)) + } + + @Test + func `codex cost does not cross charge an unknown provider prefix`() throws { + let root = try Self.seedModelsDevCache(""" + { + "openai": { + "id": "openai", + "models": { + "deepseek-v4-flash": { + "id": "deepseek-v4-flash", + "cost": { "input": 99, "output": 199 } + } + } + }, + "unlisted-route": { + "id": "unlisted-route", + "models": { + "deepseek-v4-flash": { + "id": "deepseek-v4-flash", + "cost": { "input": 1, "output": 1 } + } + } + } + } + """) + + let cost = CostUsagePricing.codexCostUSD( + model: "unlisted-route/deepseek-v4-flash", + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 5, + modelsDevCacheRoot: root) + + #expect(cost == nil) + } + @Test func `codex cost supports gpt51 codex max`() { let cost = CostUsagePricing.codexCostUSD( diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 8b0e156cc4..e03bd74add 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1353,19 +1353,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 708, + line: 721, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 783, + line: 796, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 858, + line: 871, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -3409,15 +3409,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 634, + line: 638, anchor: "guard provider == .codex || provider == .claude else { return nil }", - expectedProviderIDs: ["claude", "codex", "openai"], - expectedReferenceCount: 5, - expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@4", "codex@15", "openai@15"], + expectedProviderIDs: ["claude", "codex"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@5"], reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1115, + line: 1128, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3425,7 +3425,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1470, + line: 1483, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3487,14 +3487,6 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 2, expectedReferenceFingerprint: ["claude@0", "codex@0"], reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), - AllowedProviderConstruct( - path: "Sources/CodexBarCore/PiSessionCostScanner.swift", - line: 259, - anchor: "modelsDevProviderIDs: [\"anthropic\", \"openai\"]))", - expectedProviderIDs: ["openai"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["openai@0"], - reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/PiSessionCostScanner.swift", line: 834, @@ -3622,13 +3614,13 @@ struct ProviderArchitectureGatekeeperTests { path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", line: 423, anchor: "private static let codexModelsDevProviderID = \"openai\"", - expectedProviderIDs: ["openai"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["openai@0"], + expectedProviderIDs: ["deepseek", "openai", "opencode"], + expectedReferenceCount: 4, + expectedReferenceFingerprint: ["openai@0", "deepseek@7", "openai@10", "opencode@11"], reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 437, + line: 491, anchor: "if self.codex[trimmed] != nil {", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -3636,7 +3628,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 480, + line: 534, anchor: "if self.claude[base] != nil {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3644,7 +3636,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 589, + line: 603, + anchor: "let bundled = lookup.pricing.providerID == self.codexModelsDevProviderID ? self.codex[key] : nil", + expectedProviderIDs: ["codex"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["codex@0"], + reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), + AllowedProviderConstruct( + path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", + line: 637, anchor: "guard let pricing = self.codex[key] else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3652,19 +3652,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 708, + line: 777, anchor: "guard let pricing = self.claude[key] else { return nil }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, expectedReferenceFingerprint: ["claude@0"], reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( - path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift", - line: 13, - anchor: "modelsDevProviderIDs: Set = [\"openai\"]) -> String", - expectedProviderIDs: ["openai"], + path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", + line: 458, + anchor: "providerIDs.append(\"opencode\")", + expectedProviderIDs: ["opencode"], expectedReferenceCount: 1, - expectedReferenceFingerprint: ["openai@0"], + expectedReferenceFingerprint: ["opencode@0"], reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift",