From a05c315ed5fc40d475946d5ca6d9cdf102493a79 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Thu, 9 Jul 2026 20:04:46 -0400 Subject: [PATCH 1/5] Refresh pricing for unknown models --- Sources/CodexBarCore/CostUsageFetcher.swift | 27 ++++++++ .../Vendored/CostUsage/ModelsDevPricing.swift | 51 ++++++++++++++ .../CodexBarTests/ModelsDevPricingTests.swift | 66 +++++++++++++++++++ 3 files changed, 144 insertions(+) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index aefd885a4b..bab3da5a74 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -239,6 +239,33 @@ public struct CostUsageFetcher: Sendable { return (daily: daily, projects: projects) } + if provider == .codex || provider == .claude { + let unknownModelIDs = Set(scanResult.daily.data.flatMap { entry in + entry.modelBreakdowns?.compactMap { breakdown in + breakdown.costUSD == nil ? breakdown.modelName : nil + } ?? [] + }) + let providerID = provider == .codex ? "openai" : "anthropic" + if await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: providerID, + modelIDs: unknownModelIDs, + now: now, + cacheRoot: options.cacheRoot) + { + return try await self.loadTokenSnapshot( + provider: provider, + environment: environment, + now: now, + forceRefresh: forceRefresh, + allowVertexClaudeFallback: allowVertexClaudeFallback, + codexHomePath: codexHomePath, + historyDays: historyDays, + refreshPricingInBackground: false, + scannerOptions: options, + piScannerOptions: piOptions) + } + } + return Self.tokenSnapshot( from: scanResult.daily, now: now, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift index 282c6e55c3..7c0b7fd1fc 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -578,6 +578,8 @@ struct ModelsDevClient { } enum ModelsDevPricingPipeline { + private static let unknownModelRefreshGate = ModelsDevUnknownModelRefreshGate() + static func lookup( providerID: String, modelID: String, @@ -608,4 +610,53 @@ enum ModelsDevPricingPipeline { // Best-effort refresh only. Future scanner integration should keep using the last valid cache. } } + + static func refreshForUnknownModelsIfNeeded( + providerID: String, + modelIDs: Set, + now: Date = Date(), + cacheRoot: URL? = nil, + client: ModelsDevClient = ModelsDevClient()) async -> Bool + { + let load = ModelsDevCache.load(now: now, cacheRoot: cacheRoot) + let unknownModelIDs = modelIDs.filter { + load.artifact?.catalog.pricing(providerID: providerID, modelID: $0) == nil + } + guard !unknownModelIDs.isEmpty else { return false } + + let cachePath = ModelsDevCache.cacheFileURL(cacheRoot: cacheRoot).path + let shouldRefresh = await self.unknownModelRefreshGate.reserve( + providerID: providerID, + cachePath: cachePath, + now: now) + guard shouldRefresh else { return false } + + do { + let catalog = try await client.fetchCatalog() + guard catalog.isPlausibleRefresh() else { return false } + let refreshedCatalog = load.artifact.map { + catalog.mergingFallbackPricing(from: $0.catalog) + } ?? catalog + ModelsDevCache.save(catalog: refreshedCatalog, fetchedAt: now, cacheRoot: cacheRoot) + return true + } catch { + return false + } + } +} + +private actor ModelsDevUnknownModelRefreshGate { + private static let retryInterval: TimeInterval = 15 * 60 + private var lastAttemptByKey: [String: Date] = [:] + + func reserve(providerID: String, cachePath: String, now: Date) -> Bool { + let key = "\(cachePath)|\(providerID)" + if let lastAttempt = self.lastAttemptByKey[key], + now.timeIntervalSince(lastAttempt) < Self.retryInterval + { + return false + } + self.lastAttemptByKey[key] = now + return true + } } diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index d1a2fdcf91..3aca4e78e3 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -153,6 +153,72 @@ struct ModelsDevPricingTests { } extension ModelsDevPricingTests { + @Test + func `unknown model refresh makes newly published pricing available`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 10000) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: now, cacheRoot: root) + let refreshed = Data(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-new": { "id": "gpt-new", "cost": { "input": 2, "output": 8 } } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "claude-new": { "id": "claude-new", "cost": { "input": 3, "output": 15 } } + } + } + } + """.utf8) + let transport = TrackingTransport(result: .success((refreshed, Self.response(status: 200)))) + let client = ModelsDevClient(transport: transport) + + let didRefresh = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["gpt-new"], + now: now, + cacheRoot: root, + client: client) + #expect(didRefresh) + #expect(transport.calls == 1) + #expect(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-new", + cacheRoot: root) != nil) + } + + @Test + func `unknown model refresh is bounded per provider cache`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 20000) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: now, cacheRoot: root) + let transport = try TrackingTransport(result: .success(( + JSONEncoder().encode(Self.fixtureCatalog()), + Self.response(status: 200)))) + let client = ModelsDevClient(transport: transport) + + let first = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["still-unknown"], + now: now, + cacheRoot: root, + client: client) + let second = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["another-unknown-model"], + now: now.addingTimeInterval(60), + cacheRoot: root, + client: client) + + #expect(first) + #expect(!second) + #expect(transport.calls == 1) + } + @Test func `refresh accepts model churn and preserves removed pricing as fallback`() async throws { let root = try Self.cacheRoot() From 4d5a846bf2f2e229b025150f3a59fbfdcbdaeeb1 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Thu, 9 Jul 2026 20:22:22 -0400 Subject: [PATCH 2/5] Coalesce model pricing refreshes --- Sources/CodexBarCore/CostUsageFetcher.swift | 81 +++++++++--- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/ModelsDevPricing.swift | 120 +++++++++++++----- ...UsageFetcherUnknownModelPricingTests.swift | 94 ++++++++++++++ .../CodexBarTests/ModelsDevPricingTests.swift | 109 +++++++++++++++- 5 files changed, 344 insertions(+), 62 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index bab3da5a74..8135754094 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -117,7 +117,9 @@ public struct CostUsageFetcher: Sendable { refreshPricingInBackground: Bool = true, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, piScannerOptions overridePiScannerOptions: PiSessionCostScanner - .Options? = nil) async throws -> CostUsageTokenSnapshot + .Options? = nil, + modelsDevClient: ModelsDevClient = ModelsDevClient(), + retryUnknownPricing: Bool = true) async throws -> CostUsageTokenSnapshot { guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { throw CostUsageError.unsupportedProvider(provider) @@ -148,14 +150,20 @@ public struct CostUsageFetcher: Sendable { options.codexSessionsRoot = URL(fileURLWithPath: codexHomePath, isDirectory: true) .appendingPathComponent("sessions", isDirectory: true) } - if provider == .codex || provider == .claude { + if retryUnknownPricing, provider == .codex || provider == .claude { let pricingCacheRoot = options.cacheRoot if refreshPricingInBackground { Task.detached(priority: .utility) { - await ModelsDevPricingPipeline.refreshIfNeeded(now: now, cacheRoot: pricingCacheRoot) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: now, + cacheRoot: pricingCacheRoot, + client: modelsDevClient) } } else { - await ModelsDevPricingPipeline.refreshIfNeeded(now: now, cacheRoot: pricingCacheRoot) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: now, + cacheRoot: pricingCacheRoot, + client: modelsDevClient) } } @@ -239,7 +247,7 @@ public struct CostUsageFetcher: Sendable { return (daily: daily, projects: projects) } - if provider == .codex || provider == .claude { + if retryUnknownPricing, provider == .codex || provider == .claude { let unknownModelIDs = Set(scanResult.daily.data.flatMap { entry in entry.modelBreakdowns?.compactMap { breakdown in breakdown.costUSD == nil ? breakdown.modelName : nil @@ -250,7 +258,8 @@ public struct CostUsageFetcher: Sendable { providerID: providerID, modelIDs: unknownModelIDs, now: now, - cacheRoot: options.cacheRoot) + cacheRoot: options.cacheRoot, + client: modelsDevClient) == .pricingAvailable { return try await self.loadTokenSnapshot( provider: provider, @@ -262,7 +271,9 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, refreshPricingInBackground: false, scannerOptions: options, - piScannerOptions: piOptions) + piScannerOptions: piOptions, + modelsDevClient: modelsDevClient, + retryUnknownPricing: false) } } @@ -497,10 +508,14 @@ public struct CostUsageFetcher: Sendable { .sorted { lhs, rhs in let lhsCost = lhs.totalCostUSD ?? -1 let rhsCost = rhs.totalCostUSD ?? -1 - if lhsCost != rhsCost { return lhsCost > rhsCost } + if lhsCost != rhsCost { + return lhsCost > rhsCost + } let lhsTokens = lhs.totalTokens ?? -1 let rhsTokens = rhs.totalTokens ?? -1 - if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + if lhsTokens != rhsTokens { + return lhsTokens > rhsTokens + } return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending } } @@ -522,10 +537,14 @@ public struct CostUsageFetcher: Sendable { .sorted { lhs, rhs in let lhsCost = lhs.totalCostUSD ?? -1 let rhsCost = rhs.totalCostUSD ?? -1 - if lhsCost != rhsCost { return lhsCost > rhsCost } + if lhsCost != rhsCost { + return lhsCost > rhsCost + } let lhsTokens = lhs.totalTokens ?? -1 let rhsTokens = rhs.totalTokens ?? -1 - if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + if lhsTokens != rhsTokens { + return lhsTokens > rhsTokens + } return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending } } @@ -573,10 +592,14 @@ public struct CostUsageFetcher: Sendable { .sorted { lhs, rhs in let lhsCost = lhs.costUSD ?? -1 let rhsCost = rhs.costUSD ?? -1 - if lhsCost != rhsCost { return lhsCost > rhsCost } + if lhsCost != rhsCost { + return lhsCost > rhsCost + } let lhsTokens = lhs.totalTokens ?? -1 let rhsTokens = rhs.totalTokens ?? -1 - if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + if lhsTokens != rhsTokens { + return lhsTokens > rhsTokens + } return lhs.modelName > rhs.modelName } } @@ -584,17 +607,25 @@ public struct CostUsageFetcher: Sendable { static func selectCurrentSession(from sessions: [CostUsageSessionReport.Entry]) -> CostUsageSessionReport.Entry? { - if sessions.isEmpty { return nil } + if sessions.isEmpty { + return nil + } return sessions.max { lhs, rhs in let lDate = CostUsageDateParser.parse(lhs.lastActivity) ?? .distantPast let rDate = CostUsageDateParser.parse(rhs.lastActivity) ?? .distantPast - if lDate != rDate { return lDate < rDate } + if lDate != rDate { + return lDate < rDate + } let lCost = lhs.costUSD ?? -1 let rCost = rhs.costUSD ?? -1 - if lCost != rCost { return lCost < rCost } + if lCost != rCost { + return lCost < rCost + } let lTokens = lhs.totalTokens ?? -1 let rTokens = rhs.totalTokens ?? -1 - if lTokens != rTokens { return lTokens < rTokens } + if lTokens != rTokens { + return lTokens < rTokens + } return lhs.session < rhs.session } } @@ -602,17 +633,25 @@ public struct CostUsageFetcher: Sendable { static func selectMostRecentMonth(from months: [CostUsageMonthlyReport.Entry]) -> CostUsageMonthlyReport.Entry? { - if months.isEmpty { return nil } + if months.isEmpty { + return nil + } return months.max { lhs, rhs in let lDate = CostUsageDateParser.parseMonth(lhs.month) ?? .distantPast let rDate = CostUsageDateParser.parseMonth(rhs.month) ?? .distantPast - if lDate != rDate { return lDate < rDate } + if lDate != rDate { + return lDate < rDate + } let lCost = lhs.costUSD ?? -1 let rCost = rhs.costUSD ?? -1 - if lCost != rCost { return lCost < rCost } + if lCost != rCost { + return lCost < rCost + } let lTokens = lhs.totalTokens ?? -1 let rTokens = rhs.totalTokens ?? -1 - if lTokens != rTokens { return lTokens < rTokens } + if lTokens != rTokens { + return lTokens < rTokens + } return lhs.month < rhs.month } } diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index b5ac76cffc..eab703d03e 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 = "1745966d77d9dae8" + static let value = "a60487885fea6d70" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift index 7c0b7fd1fc..8191ee099a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -499,22 +499,24 @@ enum ModelsDevCache { } } - static func save(catalog: ModelsDevCatalog, fetchedAt: Date = Date(), cacheRoot: URL? = nil) { + @discardableResult + static func save(catalog: ModelsDevCatalog, fetchedAt: Date = Date(), cacheRoot: URL? = nil) -> Bool { let artifact = ModelsDevCacheArtifact( version: Self.artifactVersion, fetchedAt: fetchedAt, catalog: catalog) - self.save(artifact: artifact, cacheRoot: cacheRoot) + return self.save(artifact: artifact, cacheRoot: cacheRoot) } - static func save(artifact: ModelsDevCacheArtifact, cacheRoot: URL? = nil) { + @discardableResult + static func save(artifact: ModelsDevCacheArtifact, cacheRoot: URL? = nil) -> Bool { let url = self.cacheFileURL(cacheRoot: cacheRoot) let dir = url.deletingLastPathComponent() try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 - guard let data = try? encoder.encode(artifact) else { return } + guard let data = try? encoder.encode(artifact) else { return false } let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) do { @@ -526,8 +528,10 @@ enum ModelsDevCache { } // The on-disk catalog changed; drop the memo so the next load decodes the fresh file. Self.memo.invalidate(path: url.path) + return true } catch { try? FileManager.default.removeItem(at: tmp) + return false } } } @@ -542,7 +546,7 @@ struct URLSessionModelsDevTransport: ModelsDevHTTPTransport { } } -struct ModelsDevClient { +struct ModelsDevClient: Sendable { enum Error: Swift.Error, Equatable { case invalidResponse case httpStatus(Int) @@ -577,8 +581,13 @@ struct ModelsDevClient { } } +enum ModelsDevUnknownModelRefreshOutcome: Equatable { + case pricingAvailable + case unavailable +} + enum ModelsDevPricingPipeline { - private static let unknownModelRefreshGate = ModelsDevUnknownModelRefreshGate() + private static let refreshCoordinator = ModelsDevRefreshCoordinator() static func lookup( providerID: String, @@ -600,14 +609,13 @@ enum ModelsDevPricingPipeline { let load = ModelsDevCache.load(now: now, cacheRoot: cacheRoot) guard load.isStale else { return } - do { - let catalog = try await client.fetchCatalog() - let oldCatalog = load.artifact?.catalog - guard catalog.isPlausibleRefresh() else { return } - let refreshedCatalog = oldCatalog.map { catalog.mergingFallbackPricing(from: $0) } ?? catalog - ModelsDevCache.save(catalog: refreshedCatalog, fetchedAt: now, cacheRoot: cacheRoot) - } catch { - // Best-effort refresh only. Future scanner integration should keep using the last valid cache. + let cachePath = ModelsDevCache.cacheFileURL(cacheRoot: cacheRoot).standardizedFileURL.path + _ = await self.refreshCoordinator.refresh( + cachePath: cachePath, + reason: .ttl, + now: now) + { + await self.performRefresh(now: now, cacheRoot: cacheRoot, client: client) } } @@ -616,47 +624,93 @@ enum ModelsDevPricingPipeline { modelIDs: Set, now: Date = Date(), cacheRoot: URL? = nil, - client: ModelsDevClient = ModelsDevClient()) async -> Bool + client: ModelsDevClient = ModelsDevClient()) async -> ModelsDevUnknownModelRefreshOutcome { + guard !modelIDs.isEmpty else { return .unavailable } let load = ModelsDevCache.load(now: now, cacheRoot: cacheRoot) let unknownModelIDs = modelIDs.filter { load.artifact?.catalog.pricing(providerID: providerID, modelID: $0) == nil } - guard !unknownModelIDs.isEmpty else { return false } + guard !unknownModelIDs.isEmpty else { return .pricingAvailable } - let cachePath = ModelsDevCache.cacheFileURL(cacheRoot: cacheRoot).path - let shouldRefresh = await self.unknownModelRefreshGate.reserve( - providerID: providerID, + let cachePath = ModelsDevCache.cacheFileURL(cacheRoot: cacheRoot).standardizedFileURL.path + _ = await self.refreshCoordinator.refresh( cachePath: cachePath, + reason: .unknownModel, now: now) - guard shouldRefresh else { return false } + { + await self.performRefresh(now: now, cacheRoot: cacheRoot, client: client) + } + + let refreshedCatalog = ModelsDevCache.load(now: now, cacheRoot: cacheRoot).artifact?.catalog + let pricingBecameAvailable = modelIDs.contains { + refreshedCatalog?.pricing(providerID: providerID, modelID: $0) != nil + } + return pricingBecameAvailable ? .pricingAvailable : .unavailable + } + private static func performRefresh( + now: Date, + cacheRoot: URL?, + client: ModelsDevClient) async -> Bool + { do { let catalog = try await client.fetchCatalog() guard catalog.isPlausibleRefresh() else { return false } - let refreshedCatalog = load.artifact.map { - catalog.mergingFallbackPricing(from: $0.catalog) - } ?? catalog - ModelsDevCache.save(catalog: refreshedCatalog, fetchedAt: now, cacheRoot: cacheRoot) - return true + let oldCatalog = ModelsDevCache.load(now: now, cacheRoot: cacheRoot).artifact?.catalog + let refreshedCatalog = oldCatalog.map { catalog.mergingFallbackPricing(from: $0) } ?? catalog + return ModelsDevCache.save(catalog: refreshedCatalog, fetchedAt: now, cacheRoot: cacheRoot) } catch { return false } } } -private actor ModelsDevUnknownModelRefreshGate { - private static let retryInterval: TimeInterval = 15 * 60 - private var lastAttemptByKey: [String: Date] = [:] +private actor ModelsDevRefreshCoordinator { + private struct InFlightRefresh { + let id: UUID + let task: Task + } + + enum Reason: Sendable, Equatable { + case ttl + case unknownModel + } - func reserve(providerID: String, cachePath: String, now: Date) -> Bool { - let key = "\(cachePath)|\(providerID)" - if let lastAttempt = self.lastAttemptByKey[key], + private static let retryInterval: TimeInterval = 15 * 60 + private var inFlightByCachePath: [String: InFlightRefresh] = [:] + private var lastUnknownAttemptByCachePath: [String: Date] = [:] + + func refresh( + cachePath: String, + reason: Reason, + now: Date, + operation: @escaping @Sendable () async -> Bool) async -> Bool + { + if let inFlight = self.inFlightByCachePath[cachePath] { + if reason == .unknownModel { + self.lastUnknownAttemptByCachePath[cachePath] = now + } + return await inFlight.task.value + } + if reason == .unknownModel, + let lastAttempt = self.lastUnknownAttemptByCachePath[cachePath], now.timeIntervalSince(lastAttempt) < Self.retryInterval { return false } - self.lastAttemptByKey[key] = now - return true + if reason == .unknownModel { + self.lastUnknownAttemptByCachePath[cachePath] = now + } + + let inFlight = InFlightRefresh( + id: UUID(), + task: Task { await operation() }) + self.inFlightByCachePath[cachePath] = inFlight + let result = await inFlight.task.value + if self.inFlightByCachePath[cachePath]?.id == inFlight.id { + self.inFlightByCachePath[cachePath] = nil + } + return result } } diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift new file mode 100644 index 0000000000..808a22be22 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -0,0 +1,94 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct CostUsageFetcherUnknownModelPricingTests { + @Test + func `fetcher reprices an unknown model after an on demand catalog refresh`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 12) + let oldCatalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(""" + { + "openai": { + "id": "openai", + "models": { "gpt-old": { "id": "gpt-old", "cost": { "input": 1, "output": 4 } } } + }, + "anthropic": { + "id": "anthropic", + "models": { "claude-old": { "id": "claude-old", "cost": { "input": 3, "output": 15 } } } + } + } + """.utf8)) + try ModelsDevCache.save(catalog: oldCatalog, fetchedAt: day, cacheRoot: env.cacheRoot) + + let refreshedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { "gpt-new": { "id": "gpt-new", "cost": { "input": 2, "output": 8 } } } + }, + "anthropic": { + "id": "anthropic", + "models": { "claude-new": { "id": "claude-new", "cost": { "input": 3, "output": 15 } } } + } + } + """.utf8) + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": "gpt-new"], + ] + let tokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + _ = try env.writeCodexSessionFile( + day: day, + filename: "unknown-model.jsonl", + contents: env.jsonl([turnContext, tokenCount])) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + refreshPricingInBackground: false, + scannerOptions: options, + modelsDevClient: ModelsDevClient(transport: CostUsageFetcherModelsDevTransport( + data: refreshedCatalog))) + + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == "gpt-new") + #expect(abs((breakdown.costUSD ?? 0) - 0.00028) < 0.0000001) + } +} + +private struct CostUsageFetcherModelsDevTransport: ModelsDevHTTPTransport { + let data: Data + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (self.data, response) + } +} diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index 3aca4e78e3..cfb93846d6 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -177,13 +177,13 @@ extension ModelsDevPricingTests { let transport = TrackingTransport(result: .success((refreshed, Self.response(status: 200)))) let client = ModelsDevClient(transport: transport) - let didRefresh = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( providerID: "openai", modelIDs: ["gpt-new"], now: now, cacheRoot: root, client: client) - #expect(didRefresh) + #expect(outcome == .pricingAvailable) #expect(transport.calls == 1) #expect(ModelsDevPricingPipeline.lookup( providerID: "openai", @@ -214,11 +214,96 @@ extension ModelsDevPricingTests { cacheRoot: root, client: client) - #expect(first) - #expect(!second) + #expect(first == .unavailable) + #expect(second == .unavailable) #expect(transport.calls == 1) } + @Test + func `pricing added by a completed background refresh requests a rescan`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 30000) + let refreshed = Data(""" + { + "openai": { + "id": "openai", + "models": { "gpt-new": { "id": "gpt-new", "cost": { "input": 2, "output": 8 } } } + }, + "anthropic": { + "id": "anthropic", + "models": { "claude-new": { "id": "claude-new", "cost": { "input": 3, "output": 15 } } } + } + } + """.utf8) + let refreshedCatalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: refreshed) + try ModelsDevCache.save(catalog: refreshedCatalog, fetchedAt: now, cacheRoot: root) + let transport = TrackingTransport(result: .failure(MockError.failed)) + + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["gpt-new"], + now: now, + cacheRoot: root, + client: ModelsDevClient(transport: transport)) + + #expect(outcome == .pricingAvailable) + #expect(transport.calls == 0) + } + + @Test + func `ttl and unknown model refreshes share one download`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let now = Date(timeIntervalSince1970: 40000) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + let transport = try TrackingTransport( + result: .success((JSONEncoder().encode(Self.fixtureCatalog()), Self.response(status: 200))), + delayNanoseconds: 100_000_000) + let client = ModelsDevClient(transport: transport) + + async let ttl: Void = ModelsDevPricingPipeline.refreshIfNeeded( + now: now, + cacheRoot: root, + client: client) + async let unknown = ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["still-unknown"], + now: now, + cacheRoot: root, + client: client) + _ = await (ttl, unknown) + + #expect(transport.calls == 1) + } + + @Test + func `failed cache save does not report pricing available`() async { + let root = URL(fileURLWithPath: "/dev/null", isDirectory: true) + let now = Date(timeIntervalSince1970: 50000) + let refreshed = Data(""" + { + "openai": { + "id": "openai", + "models": { "gpt-new": { "id": "gpt-new", "cost": { "input": 2, "output": 8 } } } + }, + "anthropic": { + "id": "anthropic", + "models": { "claude-new": { "id": "claude-new", "cost": { "input": 3, "output": 15 } } } + } + } + """.utf8) + + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["gpt-new"], + now: now, + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((refreshed, Self.response(status: 200)))))) + + #expect(outcome == .unavailable) + } + @Test func `refresh accepts model churn and preserves removed pricing as fallback`() async throws { let root = try Self.cacheRoot() @@ -1110,15 +1195,25 @@ private struct MockTransport: ModelsDevHTTPTransport { } private final class TrackingTransport: ModelsDevHTTPTransport, @unchecked Sendable { - private(set) var calls = 0 + private let lock = NSLock() + private var callCount = 0 let result: Result<(Data, URLResponse), Error> + let delayNanoseconds: UInt64 + + var calls: Int { + self.lock.withLock { self.callCount } + } - init(result: Result<(Data, URLResponse), Error>) { + init(result: Result<(Data, URLResponse), Error>, delayNanoseconds: UInt64 = 0) { self.result = result + self.delayNanoseconds = delayNanoseconds } func data(for _: URLRequest) async throws -> (Data, URLResponse) { - self.calls += 1 + self.lock.withLock { self.callCount += 1 } + if self.delayNanoseconds > 0 { + try await Task.sleep(nanoseconds: self.delayNanoseconds) + } return try self.result.get() } } From 1564a566d343d2e7f9994183b5b3afd461d8975a Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Thu, 9 Jul 2026 20:23:18 -0400 Subject: [PATCH 3/5] Update Codex parser hash --- Sources/CodexBarCore/Generated/CodexParserHash.generated.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index eab703d03e..e256d76b25 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 = "a60487885fea6d70" + static let value = "0c8d0b94816d68d5" } From f43fce910e2efd0a181c862bdd8a96709219237c Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Thu, 9 Jul 2026 21:06:27 -0400 Subject: [PATCH 4/5] Bound sequential pricing refreshes --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/ModelsDevPricing.swift | 21 +++---- ...UsageFetcherUnknownModelPricingTests.swift | 5 +- .../CodexBarTests/ModelsDevPricingTests.swift | 60 ++++++++++++++++++- 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index e256d76b25..8b2f81b2a4 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 = "0c8d0b94816d68d5" + static let value = "2369406c4e083469" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift index 8191ee099a..e0d712750f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -586,6 +586,8 @@ enum ModelsDevUnknownModelRefreshOutcome: Equatable { case unavailable } +private let modelsDevUnknownModelRetryInterval: TimeInterval = 15 * 60 + enum ModelsDevPricingPipeline { private static let refreshCoordinator = ModelsDevRefreshCoordinator() @@ -632,6 +634,11 @@ enum ModelsDevPricingPipeline { load.artifact?.catalog.pricing(providerID: providerID, modelID: $0) == nil } guard !unknownModelIDs.isEmpty else { return .pricingAvailable } + if let fetchedAt = load.artifact?.fetchedAt, + now.timeIntervalSince(fetchedAt) < modelsDevUnknownModelRetryInterval + { + return .unavailable + } let cachePath = ModelsDevCache.cacheFileURL(cacheRoot: cacheRoot).standardizedFileURL.path _ = await self.refreshCoordinator.refresh( @@ -677,9 +684,8 @@ private actor ModelsDevRefreshCoordinator { case unknownModel } - private static let retryInterval: TimeInterval = 15 * 60 private var inFlightByCachePath: [String: InFlightRefresh] = [:] - private var lastUnknownAttemptByCachePath: [String: Date] = [:] + private var lastCatalogAttemptByCachePath: [String: Date] = [:] func refresh( cachePath: String, @@ -688,20 +694,15 @@ private actor ModelsDevRefreshCoordinator { operation: @escaping @Sendable () async -> Bool) async -> Bool { if let inFlight = self.inFlightByCachePath[cachePath] { - if reason == .unknownModel { - self.lastUnknownAttemptByCachePath[cachePath] = now - } return await inFlight.task.value } if reason == .unknownModel, - let lastAttempt = self.lastUnknownAttemptByCachePath[cachePath], - now.timeIntervalSince(lastAttempt) < Self.retryInterval + let lastAttempt = self.lastCatalogAttemptByCachePath[cachePath], + now.timeIntervalSince(lastAttempt) < modelsDevUnknownModelRetryInterval { return false } - if reason == .unknownModel { - self.lastUnknownAttemptByCachePath[cachePath] = now - } + self.lastCatalogAttemptByCachePath[cachePath] = now let inFlight = InFlightRefresh( id: UUID(), diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index 808a22be22..b65b204042 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -24,7 +24,10 @@ struct CostUsageFetcherUnknownModelPricingTests { } } """.utf8)) - try ModelsDevCache.save(catalog: oldCatalog, fetchedAt: day, cacheRoot: env.cacheRoot) + try ModelsDevCache.save( + catalog: oldCatalog, + fetchedAt: day.addingTimeInterval(-901), + cacheRoot: env.cacheRoot) let refreshedCatalog = Data(""" { diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index cfb93846d6..10229fd77e 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -157,7 +157,10 @@ extension ModelsDevPricingTests { func `unknown model refresh makes newly published pricing available`() async throws { let root = try Self.cacheRoot() let now = Date(timeIntervalSince1970: 10000) - try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: now, cacheRoot: root) + try ModelsDevCache.save( + catalog: Self.fixtureCatalog(), + fetchedAt: now.addingTimeInterval(-901), + cacheRoot: root) let refreshed = Data(""" { "openai": { @@ -195,7 +198,10 @@ extension ModelsDevPricingTests { func `unknown model refresh is bounded per provider cache`() async throws { let root = try Self.cacheRoot() let now = Date(timeIntervalSince1970: 20000) - try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: now, cacheRoot: root) + try ModelsDevCache.save( + catalog: Self.fixtureCatalog(), + fetchedAt: now.addingTimeInterval(-901), + cacheRoot: root) let transport = try TrackingTransport(result: .success(( JSONEncoder().encode(Self.fixtureCatalog()), Self.response(status: 200)))) @@ -276,6 +282,56 @@ extension ModelsDevPricingTests { #expect(transport.calls == 1) } + @Test + func `completed ttl refresh bounds a following unknown model refresh`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let now = Date(timeIntervalSince1970: 45000) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + let transport = try TrackingTransport(result: .success(( + JSONEncoder().encode(Self.fixtureCatalog()), + Self.response(status: 200)))) + let client = ModelsDevClient(transport: transport) + + await ModelsDevPricingPipeline.refreshIfNeeded( + now: now, + cacheRoot: root, + client: client) + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["still-unknown"], + now: now, + cacheRoot: root, + client: client) + + #expect(outcome == .unavailable) + #expect(transport.calls == 1) + } + + @Test + func `failed ttl refresh bounds a following unknown model refresh in process`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let now = Date(timeIntervalSince1970: 46000) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + let transport = TrackingTransport(result: .failure(MockError.failed)) + let client = ModelsDevClient(transport: transport) + + await ModelsDevPricingPipeline.refreshIfNeeded( + now: now, + cacheRoot: root, + client: client) + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["still-unknown"], + now: now, + cacheRoot: root, + client: client) + + #expect(outcome == .unavailable) + #expect(transport.calls == 1) + } + @Test func `failed cache save does not report pricing available`() async { let root = URL(fileURLWithPath: "/dev/null", isDirectory: true) From 0970fcf70f5496ab2f65ebb356034ad26f03faa2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 9 Jul 2026 20:17:10 -0700 Subject: [PATCH 5/5] fix: keep pricing refreshes nonblocking Co-authored-by: Bryan Font --- CHANGELOG.md | 1 + Sources/CodexBarCore/CostUsageFetcher.swift | 102 ++++++++--- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/ModelsDevPricing.swift | 30 ++-- ...UsageFetcherUnknownModelPricingTests.swift | 170 +++++++++++++++--- .../CodexBarTests/ModelsDevPricingTests.swift | 108 ++++++++++- 6 files changed, 335 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13e8db1952..ea9c5764f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed - CLI server: retain timed-out route and provider work until it actually exits, preventing repeated requests or config changes from stacking background fetches. Thanks @Yuxin-Qiao! +- Token costs: coalesce bounded pricing-catalog refreshes when a newly observed model is still unpriced, preserving its exact usage until pricing arrives. Thanks @iam-brain! - Ollama: validate API keys against an authenticated endpoint instead of the public model catalog while preserving refresh cancellation. Thanks @joeVenner! - Claude CLI: resolve yearless and time-only reset timestamps against their quota window and exact calendar occurrence, keeping recently stale resets current without moving future, leap-day, or repeated-hour resets into the past. Thanks @fanwenlin! - Catalan: complete current strings, align instructional voice, and enforce catalog parity. Thanks @pmontp19! diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 8135754094..09c7a63655 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -247,34 +247,28 @@ public struct CostUsageFetcher: Sendable { return (daily: daily, projects: projects) } - if retryUnknownPricing, provider == .codex || provider == .claude { - let unknownModelIDs = Set(scanResult.daily.data.flatMap { entry in - entry.modelBreakdowns?.compactMap { breakdown in - breakdown.costUSD == nil ? breakdown.modelName : nil - } ?? [] - }) - let providerID = provider == .codex ? "openai" : "anthropic" - if await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( - providerID: providerID, - modelIDs: unknownModelIDs, + if retryUnknownPricing, + let request = Self.unknownPricingRefreshRequest( + provider: provider, + daily: scanResult.daily, + now: now, + cacheRoot: options.cacheRoot, + client: modelsDevClient), + await Self.refreshUnknownPricingIfNeeded(request, inBackground: refreshPricingInBackground) + { + return try await self.loadTokenSnapshot( + provider: provider, + environment: environment, now: now, - cacheRoot: options.cacheRoot, - client: modelsDevClient) == .pricingAvailable - { - return try await self.loadTokenSnapshot( - provider: provider, - environment: environment, - now: now, - forceRefresh: forceRefresh, - allowVertexClaudeFallback: allowVertexClaudeFallback, - codexHomePath: codexHomePath, - historyDays: historyDays, - refreshPricingInBackground: false, - scannerOptions: options, - piScannerOptions: piOptions, - modelsDevClient: modelsDevClient, - retryUnknownPricing: false) - } + forceRefresh: forceRefresh, + allowVertexClaudeFallback: allowVertexClaudeFallback, + codexHomePath: codexHomePath, + historyDays: historyDays, + refreshPricingInBackground: false, + scannerOptions: options, + piScannerOptions: piOptions, + modelsDevClient: modelsDevClient, + retryUnknownPricing: false) } return Self.tokenSnapshot( @@ -284,6 +278,60 @@ public struct CostUsageFetcher: Sendable { projects: scanResult.projects) } + private struct UnknownPricingRefreshRequest: Sendable { + let providerID: String + let modelIDs: Set + let now: Date + let cacheRoot: URL? + let client: ModelsDevClient + } + + private static func unknownPricingRefreshRequest( + provider: UsageProvider, + daily: CostUsageDailyReport, + now: Date, + cacheRoot: URL?, + client: ModelsDevClient) -> UnknownPricingRefreshRequest? + { + guard provider == .codex || provider == .claude else { return nil } + let unknownModelIDs = Set(daily.data.flatMap { entry in + entry.modelBreakdowns?.compactMap { breakdown in + breakdown.costUSD == nil ? breakdown.modelName : nil + } ?? [] + }) + guard !unknownModelIDs.isEmpty else { return nil } + + return UnknownPricingRefreshRequest( + providerID: provider == .codex ? "openai" : "anthropic", + modelIDs: unknownModelIDs, + now: now, + cacheRoot: cacheRoot, + client: client) + } + + private static func refreshUnknownPricingIfNeeded( + _ request: UnknownPricingRefreshRequest, + inBackground: Bool) async -> Bool + { + if inBackground { + Task.detached(priority: .utility) { + _ = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: request.providerID, + modelIDs: request.modelIDs, + now: request.now, + cacheRoot: request.cacheRoot, + client: request.client) + } + return false + } + return await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: request.providerID, + modelIDs: request.modelIDs, + now: request.now, + cacheRoot: request.cacheRoot, + client: request.client) == .pricingAvailable + } + static func loadCachedCodexTokenSnapshot( now: Date = Date(), codexHomePath: String? = nil, diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 8b2f81b2a4..488fa95e8b 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 = "2369406c4e083469" + static let value = "3edc97b8139ae59e" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift index e0d712750f..6915086291 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -586,7 +586,7 @@ enum ModelsDevUnknownModelRefreshOutcome: Equatable { case unavailable } -private let modelsDevUnknownModelRetryInterval: TimeInterval = 15 * 60 +private let modelsDevCatalogRetryInterval: TimeInterval = 15 * 60 enum ModelsDevPricingPipeline { private static let refreshCoordinator = ModelsDevRefreshCoordinator() @@ -614,10 +614,9 @@ enum ModelsDevPricingPipeline { let cachePath = ModelsDevCache.cacheFileURL(cacheRoot: cacheRoot).standardizedFileURL.path _ = await self.refreshCoordinator.refresh( cachePath: cachePath, - reason: .ttl, now: now) { - await self.performRefresh(now: now, cacheRoot: cacheRoot, client: client) + await self.refreshStaleCache(now: now, cacheRoot: cacheRoot, client: client) } } @@ -635,7 +634,7 @@ enum ModelsDevPricingPipeline { } guard !unknownModelIDs.isEmpty else { return .pricingAvailable } if let fetchedAt = load.artifact?.fetchedAt, - now.timeIntervalSince(fetchedAt) < modelsDevUnknownModelRetryInterval + now.timeIntervalSince(fetchedAt) < modelsDevCatalogRetryInterval { return .unavailable } @@ -643,14 +642,13 @@ enum ModelsDevPricingPipeline { let cachePath = ModelsDevCache.cacheFileURL(cacheRoot: cacheRoot).standardizedFileURL.path _ = await self.refreshCoordinator.refresh( cachePath: cachePath, - reason: .unknownModel, now: now) { await self.performRefresh(now: now, cacheRoot: cacheRoot, client: client) } let refreshedCatalog = ModelsDevCache.load(now: now, cacheRoot: cacheRoot).artifact?.catalog - let pricingBecameAvailable = modelIDs.contains { + let pricingBecameAvailable = unknownModelIDs.contains { refreshedCatalog?.pricing(providerID: providerID, modelID: $0) != nil } return pricingBecameAvailable ? .pricingAvailable : .unavailable @@ -671,6 +669,15 @@ enum ModelsDevPricingPipeline { return false } } + + static func refreshStaleCache( + now: Date, + cacheRoot: URL?, + client: ModelsDevClient) async -> Bool + { + guard ModelsDevCache.load(now: now, cacheRoot: cacheRoot).isStale else { return true } + return await self.performRefresh(now: now, cacheRoot: cacheRoot, client: client) + } } private actor ModelsDevRefreshCoordinator { @@ -679,26 +686,19 @@ private actor ModelsDevRefreshCoordinator { let task: Task } - enum Reason: Sendable, Equatable { - case ttl - case unknownModel - } - private var inFlightByCachePath: [String: InFlightRefresh] = [:] private var lastCatalogAttemptByCachePath: [String: Date] = [:] func refresh( cachePath: String, - reason: Reason, now: Date, operation: @escaping @Sendable () async -> Bool) async -> Bool { if let inFlight = self.inFlightByCachePath[cachePath] { return await inFlight.task.value } - if reason == .unknownModel, - let lastAttempt = self.lastCatalogAttemptByCachePath[cachePath], - now.timeIntervalSince(lastAttempt) < modelsDevUnknownModelRetryInterval + if let lastAttempt = self.lastCatalogAttemptByCachePath[cachePath], + now.timeIntervalSince(lastAttempt) < modelsDevCatalogRetryInterval { return false } diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index b65b204042..29828dd8d6 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -8,10 +8,83 @@ import Testing struct CostUsageFetcherUnknownModelPricingTests { @Test func `fetcher reprices an unknown model after an on demand catalog refresh`() async throws { - let env = try CostUsageTestEnvironment() - defer { env.cleanup() } + let fixture = try UnknownModelPricingFixture() + defer { fixture.environment.cleanup() } - let day = try env.makeLocalNoon(year: 2026, month: 4, day: 12) + 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.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == "gpt-new") + #expect(abs((breakdown.costUSD ?? 0) - 0.00028) < 0.0000001) + } + + @Test + func `background pricing refresh returns unpriced usage before catalog download finishes`() async throws { + let fixture = try UnknownModelPricingFixture() + defer { fixture.environment.cleanup() } + let gate = UnknownModelPricingTransportGate() + let completion = UnknownModelPricingCompletionProbe() + let task = Task { + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: fixture.day, + refreshPricingInBackground: true, + scannerOptions: fixture.options, + modelsDevClient: ModelsDevClient(transport: CostUsageFetcherGatedModelsDevTransport( + data: fixture.refreshedCatalog, + gate: gate))) + await completion.markCompleted() + return snapshot + } + + await gate.waitUntilStarted() + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while await !(completion.isCompleted), clock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + let returnedBeforeRelease = await completion.isCompleted + await gate.release() + let snapshot = try await task.value + + #expect(returnedBeforeRelease) + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == "gpt-new") + #expect(breakdown.totalTokens == 110) + #expect(breakdown.costUSD == nil) + + let refreshDeadline = clock.now.advanced(by: .seconds(1)) + while ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-new", + cacheRoot: fixture.environment.cacheRoot) == nil, + clock.now < refreshDeadline + { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-new", + cacheRoot: fixture.environment.cacheRoot) != nil) + } +} + +private struct UnknownModelPricingFixture { + let environment: CostUsageTestEnvironment + let day: Date + let options: CostUsageScanner.Options + let refreshedCatalog: Data + + init() throws { + let environment = try CostUsageTestEnvironment() + self.environment = environment + self.day = try environment.makeLocalNoon(year: 2026, month: 4, day: 12) let oldCatalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(""" { "openai": { @@ -24,12 +97,12 @@ struct CostUsageFetcherUnknownModelPricingTests { } } """.utf8)) - try ModelsDevCache.save( + ModelsDevCache.save( catalog: oldCatalog, - fetchedAt: day.addingTimeInterval(-901), - cacheRoot: env.cacheRoot) + fetchedAt: self.day.addingTimeInterval(-901), + cacheRoot: environment.cacheRoot) - let refreshedCatalog = Data(""" + self.refreshedCatalog = Data(""" { "openai": { "id": "openai", @@ -43,12 +116,12 @@ struct CostUsageFetcherUnknownModelPricingTests { """.utf8) let turnContext: [String: Any] = [ "type": "turn_context", - "timestamp": env.isoString(for: day), + "timestamp": environment.isoString(for: self.day), "payload": ["model": "gpt-new"], ] let tokenCount: [String: Any] = [ "type": "event_msg", - "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "timestamp": environment.isoString(for: self.day.addingTimeInterval(1)), "payload": [ "type": "token_count", "info": [ @@ -60,33 +133,36 @@ struct CostUsageFetcherUnknownModelPricingTests { ], ], ] - _ = try env.writeCodexSessionFile( - day: day, + _ = try environment.writeCodexSessionFile( + day: self.day, filename: "unknown-model.jsonl", - contents: env.jsonl([turnContext, tokenCount])) - let options = CostUsageScanner.Options( - codexSessionsRoot: env.codexSessionsRoot, - claudeProjectsRoots: [env.claudeProjectsRoot], - cacheRoot: env.cacheRoot) + contents: environment.jsonl([turnContext, tokenCount])) + self.options = CostUsageScanner.Options( + codexSessionsRoot: environment.codexSessionsRoot, + claudeProjectsRoots: [environment.claudeProjectsRoot], + cacheRoot: environment.cacheRoot) + } +} - let snapshot = try await CostUsageFetcher.loadTokenSnapshot( - provider: .codex, - now: day, - refreshPricingInBackground: false, - scannerOptions: options, - modelsDevClient: ModelsDevClient(transport: CostUsageFetcherModelsDevTransport( - data: refreshedCatalog))) +private struct CostUsageFetcherModelsDevTransport: ModelsDevHTTPTransport { + let data: Data - let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) - #expect(breakdown.modelName == "gpt-new") - #expect(abs((breakdown.costUSD ?? 0) - 0.00028) < 0.0000001) + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (self.data, response) } } -private struct CostUsageFetcherModelsDevTransport: ModelsDevHTTPTransport { +private struct CostUsageFetcherGatedModelsDevTransport: ModelsDevHTTPTransport { let data: Data + let gate: UnknownModelPricingTransportGate func data(for request: URLRequest) async throws -> (Data, URLResponse) { + await self.gate.markStartedAndWaitForRelease() let response = HTTPURLResponse( url: request.url!, statusCode: 200, @@ -95,3 +171,43 @@ private struct CostUsageFetcherModelsDevTransport: ModelsDevHTTPTransport { return (self.data, response) } } + +private actor UnknownModelPricingTransportGate { + private var started = false + private var released = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func markStartedAndWaitForRelease() async { + self.started = true + let startWaiters = self.startWaiters + self.startWaiters.removeAll() + startWaiters.forEach { $0.resume() } + guard !self.released else { return } + await withCheckedContinuation { continuation in + self.releaseWaiters.append(continuation) + } + } + + func waitUntilStarted() async { + guard !self.started else { return } + await withCheckedContinuation { continuation in + self.startWaiters.append(continuation) + } + } + + func release() { + self.released = true + let releaseWaiters = self.releaseWaiters + self.releaseWaiters.removeAll() + releaseWaiters.forEach { $0.resume() } + } +} + +private actor UnknownModelPricingCompletionProbe { + private(set) var isCompleted = false + + func markCompleted() { + self.isCompleted = true + } +} diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index 10229fd77e..a0da2f6370 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -225,6 +225,45 @@ extension ModelsDevPricingTests { #expect(transport.calls == 1) } + @Test + func `known requested model does not mask an unresolved unknown model`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 25000) + let catalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "already-priced": { "id": "already-priced", "cost": { "input": 1, "output": 2 } } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "catalog-anchor": { "id": "catalog-anchor", "cost": { "input": 3, "output": 4 } } + } + } + } + """) + ModelsDevCache.save( + catalog: catalog, + fetchedAt: now.addingTimeInterval(-901), + cacheRoot: root) + let transport = try TrackingTransport(result: .success(( + JSONEncoder().encode(catalog), + Self.response(status: 200)))) + + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["already-priced", "still-unknown"], + now: now, + cacheRoot: root, + client: ModelsDevClient(transport: transport)) + + #expect(outcome == .unavailable) + #expect(transport.calls == 1) + } + @Test func `pricing added by a completed background refresh requests a rescan`() async throws { let root = try Self.cacheRoot() @@ -242,7 +281,7 @@ extension ModelsDevPricingTests { } """.utf8) let refreshedCatalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: refreshed) - try ModelsDevCache.save(catalog: refreshedCatalog, fetchedAt: now, cacheRoot: root) + ModelsDevCache.save(catalog: refreshedCatalog, fetchedAt: now, cacheRoot: root) let transport = TrackingTransport(result: .failure(MockError.failed)) let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( @@ -259,9 +298,11 @@ extension ModelsDevPricingTests { @Test func `ttl and unknown model refreshes share one download`() async throws { let root = try Self.cacheRoot() - let old = Date(timeIntervalSince1970: 1) let now = Date(timeIntervalSince1970: 40000) - try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + try ModelsDevCache.save( + catalog: Self.fixtureCatalog(), + fetchedAt: now.addingTimeInterval(-ModelsDevCache.ttlSeconds - 1), + cacheRoot: root) let transport = try TrackingTransport( result: .success((JSONEncoder().encode(Self.fixtureCatalog()), Self.response(status: 200))), delayNanoseconds: 100_000_000) @@ -285,9 +326,11 @@ extension ModelsDevPricingTests { @Test func `completed ttl refresh bounds a following unknown model refresh`() async throws { let root = try Self.cacheRoot() - let old = Date(timeIntervalSince1970: 1) let now = Date(timeIntervalSince1970: 45000) - try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + try ModelsDevCache.save( + catalog: Self.fixtureCatalog(), + fetchedAt: now.addingTimeInterval(-ModelsDevCache.ttlSeconds - 1), + cacheRoot: root) let transport = try TrackingTransport(result: .success(( JSONEncoder().encode(Self.fixtureCatalog()), Self.response(status: 200)))) @@ -309,11 +352,13 @@ extension ModelsDevPricingTests { } @Test - func `failed ttl refresh bounds a following unknown model refresh in process`() async throws { + func `failed ttl refresh bounds a following unknown model refresh within cooldown`() async throws { let root = try Self.cacheRoot() - let old = Date(timeIntervalSince1970: 1) let now = Date(timeIntervalSince1970: 46000) - try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + try ModelsDevCache.save( + catalog: Self.fixtureCatalog(), + fetchedAt: now.addingTimeInterval(-ModelsDevCache.ttlSeconds - 1), + cacheRoot: root) let transport = TrackingTransport(result: .failure(MockError.failed)) let client = ModelsDevClient(transport: transport) @@ -332,6 +377,53 @@ extension ModelsDevPricingTests { #expect(transport.calls == 1) } + @Test + func `failed unknown model refresh bounds a following ttl refresh within cooldown`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 47000) + try ModelsDevCache.save( + catalog: Self.fixtureCatalog(), + fetchedAt: now.addingTimeInterval(-ModelsDevCache.ttlSeconds - 1), + cacheRoot: root) + let transport = TrackingTransport(result: .failure(MockError.failed)) + let client = ModelsDevClient(transport: transport) + + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["still-unknown"], + now: now, + cacheRoot: root, + client: client) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: now, + cacheRoot: root, + client: client) + + #expect(outcome == .unavailable) + #expect(transport.calls == 1) + } + + @Test + func `ttl refresh rechecks cache freshness after coordination`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 48000) + try ModelsDevCache.save( + catalog: Self.fixtureCatalog(), + fetchedAt: now.addingTimeInterval(-ModelsDevCache.ttlSeconds - 1), + cacheRoot: root) + #expect(ModelsDevCache.load(now: now, cacheRoot: root).isStale) + + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: now, cacheRoot: root) + let transport = TrackingTransport(result: .failure(MockError.failed)) + let cacheIsCurrent = await ModelsDevPricingPipeline.refreshStaleCache( + now: now, + cacheRoot: root, + client: ModelsDevClient(transport: transport)) + + #expect(cacheIsCurrent) + #expect(transport.calls == 0) + } + @Test func `failed cache save does not report pricing available`() async { let root = URL(fileURLWithPath: "/dev/null", isDirectory: true)