diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index a855120352..ea0279552d 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -570,7 +570,7 @@ enum SpendDashboardSource { failedSourceIDs.formUnion(lateInvalidatedSourceIDs) invalidatedSourceIDs.formUnion(lateInvalidatedSourceIDs) inputs.removeAll { lateInvalidatedSourceIDs.contains($0.id) } - let openCodex = self.mergingOpenCodexInputsWithObservation(inputs, request: request) + let openCodex = await self.mergingOpenCodexInputsAfterRefreshingPricing(inputs, request: request) return SpendDashboardLoadResult( inputs: openCodex.inputs, failedSourceIDs: failedSourceIDs, diff --git a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift index 21a56f035f..6042dd2f43 100644 --- a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift +++ b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift @@ -2,6 +2,46 @@ import CodexBarCore import Foundation extension SpendDashboardSource { + static func mergingOpenCodexInputsAfterRefreshingPricing( + _ inputs: [SpendDashboardModel.ProviderInput], + request: SpendDashboardLoadRequest, + environment: [String: String] = ProcessInfo.processInfo.environment, + entryLoader: (@Sendable (URL) throws -> [OpenCodexUsageEntry])? = nil, + pricingRefresher: @escaping @Sendable ([OpenCodexUsageEntry], Date) async -> Void = { entries, now in + await OpenCodexUsageStore.refreshPricingIfNeeded(entries: entries, now: now) + }) async -> ( + inputs: [SpendDashboardModel.ProviderInput], + observation: SpendDashboardLoadResult.OpenCodexObservation) + { + guard request.configuration.openCodexUsageLogsEnabled, + !request.configuration.hiddenSourceIDs.contains(SpendDashboardModel.openCodexSourceID) + else { + return ( + inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, + .disabled) + } + guard let logURL = OpenCodexUsageLog.usageLogURL(environment: environment) else { + return (inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, .unavailable) + } + let store = OpenCodexUsageStore(cacheRoot: OpenCodexUsageLog.cacheRoot()) + let entries: [OpenCodexUsageEntry] + do { + entries = try entryLoader?(logURL) ?? store.loadEntries(logURL: logURL) + } catch { + return (inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, .unavailable) + } + guard !entries.isEmpty else { + return (inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, .confirmedEmpty) + } + + await pricingRefresher(entries, request.now) + return self.mergingOpenCodexInputsWithObservation( + inputs, + request: request, + environment: environment, + entryLoader: { _ in entries }) + } + static func mergingOpenCodexInputsWithObservation( _ inputs: [SpendDashboardModel.ProviderInput], request: SpendDashboardLoadRequest, diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index bcb35a60e0..77edad2cdd 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -124,7 +124,7 @@ extension CodexBarCLI { } if format == .json, - let openCodex = Self.loadOpenCodexCostPayload( + let openCodex = await Self.loadOpenCodexCostPayload( historyDays: historyDays, calendar: bucketCalendar) { @@ -653,12 +653,14 @@ extension CodexBarCLI { private static func loadOpenCodexCostPayload( historyDays: Int, calendar: Calendar, - now: Date = Date()) -> CostPayload? + now: Date = Date()) async -> CostPayload? { guard boolFromAppDefaults("openCodexUsageLogsEnabled") == true else { return nil } let environment = ProcessInfo.processInfo.environment guard let logURL = OpenCodexUsageLog.usageLogURL(environment: environment) else { return nil } let store = OpenCodexUsageStore(cacheRoot: OpenCodexUsageLog.cacheRoot()) + guard let entries = try? store.loadEntries(logURL: logURL), !entries.isEmpty else { return nil } + await OpenCodexUsageStore.refreshPricingIfNeeded(entries: entries, now: now) guard let snapshot = try? store.loadSnapshot( logURL: logURL, now: now, diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 0dbf0b31fd..3f2788d397 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -707,11 +707,6 @@ public struct CostUsageFetcher: Sendable { } } - private struct ModelsDevPricingTarget: Hashable, Sendable { - let providerID: String - let modelID: String - } - private struct UnknownPricingRefreshRequest: Sendable { let targets: Set let now: Date diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 7515f48f89..17121108eb 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 = "aa57b010b3c0bee4" + static let value = "710f475c3d1cfb61" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Provider.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Provider.swift new file mode 100644 index 0000000000..253e91d434 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Provider.swift @@ -0,0 +1,82 @@ +import Foundation + +extension CostUsagePricing { + // Price a recorded billing route without interpreting a model's vendor namespace as its host. + // Catalog prices use independent token classes; OpenAI and application overrides retain historical input semantics. + // swiftlint:disable:next function_parameter_count + static func providerCostUSD( + providerID: String, + model: String, + inputTokens: Int, + cachedInputTokens: Int, + cacheWriteInputTokens: Int, + outputTokens: Int, + pricingDate: Date?, + catalog: ModelsDevCatalog, + customPricing: CostUsageCustomPricing) -> Double? + { + let targets = ModelsDevPricingTargetResolver.targets(providerID: providerID, modelID: model) + guard let target = targets.first else { return nil } + let customRates = customPricing.rates(providerID: providerID, model: model) + ?? targets.lazy.compactMap { customPricing.rates(providerID: $0.providerID, model: $0.modelID) }.first + if let customRates { + let uncachedInput = max(0, max(0, inputTokens) - max(0, cachedInputTokens)) + return CostUsageCustomPricing.costUSD( + rates: customRates, + inputTokens: max(0, uncachedInput - max(0, cacheWriteInputTokens)), + outputTokens: outputTokens, + cacheReadTokens: max(0, cachedInputTokens), + cacheWriteTokens: max(0, cacheWriteInputTokens)) + } + // Retain OpenAI's historical rates, model aliases and long-context overrides only on its own route. + if target.providerID == self.codexModelsDevProviderID, + !target.modelID.contains("/") + { + return self.codexCostUSD( + model: target.modelID, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, + pricingDate: pricingDate, + modelsDevCatalog: catalog, + customPricing: .empty) + } + for target in targets { + guard let lookup = catalog.pricing( + providerID: target.providerID, + modelID: target.modelID, + exactModelID: true) + else { continue } + let rate = lookup.pricing + // A new provider must not inherit OpenAI's legacy assumption that unspecified cache rates + // equal ordinary input. Keep rows with consumed but unpriced token classes unknown. + guard cachedInputTokens <= 0 || rate.cacheReadInputCostPerToken != nil, + cacheWriteInputTokens <= 0 || rate.cacheCreationInputCostPerToken != nil + else { return nil } + let (inputWithCache, cacheOverflow) = max(0, inputTokens) + .addingReportingOverflow(max(0, cachedInputTokens)) + let (inclusiveInput, writeOverflow) = inputWithCache + .addingReportingOverflow(max(0, cacheWriteInputTokens)) + guard !cacheOverflow, !writeOverflow else { return nil } + let pricing = CodexPricing( + inputCostPerToken: rate.inputCostPerToken, + outputCostPerToken: rate.outputCostPerToken, + cacheReadInputCostPerToken: rate.cacheReadInputCostPerToken, + displayLabel: nil, + cacheWriteInputCostPerToken: rate.cacheCreationInputCostPerToken, + thresholdTokens: rate.thresholdTokens, + inputCostPerTokenAboveThreshold: rate.inputCostPerTokenAboveThreshold, + outputCostPerTokenAboveThreshold: rate.outputCostPerTokenAboveThreshold, + cacheReadInputCostPerTokenAboveThreshold: rate.cacheReadInputCostPerTokenAboveThreshold, + cacheWriteInputCostPerTokenAboveThreshold: rate.cacheCreationInputCostPerTokenAboveThreshold) + return self.codexCostUSD( + pricing: pricing, + inputTokens: inclusiveInput, + cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, + outputTokens: outputTokens) + } + return nil + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 67c76ebf02..396f377fc4 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -493,16 +493,8 @@ enum CostUsagePricing { self.codexModelsDevProviderIDs.contains(routeID) else { return [] } - var providerIDs = [routeID] - switch routeID { - case "kimi-coding": - providerIDs.append("kimi-for-coding") - case "opencode-free": - providerIDs.append("opencode") - default: - break - } - var targets = providerIDs.map { ($0, modelID) } + var targets = ModelsDevPricingTargetResolver.targets(providerID: routeID, modelID: trimmed) + .map { ($0.providerID, $0.modelID) } if routeID == self.codexModelsDevProviderID { let normalized = self.normalizeCodexModel(modelID) if normalized != modelID { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift index 548778a595..67c40c883d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -80,6 +80,7 @@ actor CostUsageStore { parserHash: CodexParserHash.value) static let cacheGeneration = "sqlite:\(CostUsageStore.schemaVersion)" static let compatiblePredecessorParserHashes: Set = [ + "aa57b010b3c0bee4", // Provider-aware pricing preserves native rows and scan checkpoints. "aef0df6c73f8052c", // 0.60.1 rows and checkpoints survive routine rescan repairs. "4969a789db679c93", // 0.58.0 native rows, checkpoints, and reports survive queue reordering. "c4fa7db2cf54bc41", // Parser revisions reparse older native files while preserving stored rows and checkpoints. diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift index 3e7e843bef..08570f9125 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -68,9 +68,13 @@ struct ModelsDevCatalog: Codable, Equatable { try container.encode(self.providers, forKey: ModelsDevAnyCodingKey(stringValue: "providers")!) } - func pricing(providerID rawProviderID: String, modelID rawModelID: String) -> ModelsDevPricingLookup? { + func pricing( + providerID rawProviderID: String, + modelID rawModelID: String, + exactModelID: Bool = false) -> ModelsDevPricingLookup? + { let providerID = ModelsDevProvider.normalizeProviderID(rawProviderID) - return self.providers[providerID]?.pricing(modelID: rawModelID) + return self.providers[providerID]?.pricing(modelID: rawModelID, exactModelID: exactModelID) } func isPlausibleRefresh() -> Bool { @@ -165,8 +169,10 @@ struct ModelsDevProvider: Codable, Equatable { raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - func pricing(modelID rawModelID: String) -> ModelsDevPricingLookup? { - let candidates = ModelsDevModelIDNormalizer.candidates(rawModelID) + func pricing(modelID rawModelID: String, exactModelID: Bool = false) -> ModelsDevPricingLookup? { + let candidates = exactModelID + ? [ModelsDevModelIDNormalizer.normalize(rawModelID)] + : ModelsDevModelIDNormalizer.candidates(rawModelID) for candidate in candidates { if let model = self.models[candidate], let pricing = model.pricing(providerID: self.id ?? self.mapKey ?? "", providerName: self.name) @@ -678,6 +684,7 @@ enum ModelsDevPricingPipeline { static func refreshForUnknownModelsIfNeeded( providerID: String, modelIDs: Set, + exactModelIDs: Bool = false, now: Date = Date(), cacheRoot: URL? = nil, client: ModelsDevClient = ModelsDevClient()) async -> ModelsDevUnknownModelRefreshOutcome @@ -685,7 +692,7 @@ enum ModelsDevPricingPipeline { 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 + load.artifact?.catalog.pricing(providerID: providerID, modelID: $0, exactModelID: exactModelIDs) == nil } guard !unknownModelIDs.isEmpty else { return .pricingAvailable } if let fetchedAt = load.artifact?.fetchedAt, @@ -704,7 +711,7 @@ enum ModelsDevPricingPipeline { let refreshedCatalog = ModelsDevCache.load(now: now, cacheRoot: cacheRoot).artifact?.catalog let pricingBecameAvailable = unknownModelIDs.contains { - refreshedCatalog?.pricing(providerID: providerID, modelID: $0) != nil + refreshedCatalog?.pricing(providerID: providerID, modelID: $0, exactModelID: exactModelIDs) != nil } return pricingBecameAvailable ? .pricingAvailable : .unavailable } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricingTargetResolver.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricingTargetResolver.swift new file mode 100644 index 0000000000..2d3549ef3e --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricingTargetResolver.swift @@ -0,0 +1,54 @@ +import Foundation + +struct ModelsDevPricingTarget: Hashable, Sendable { + let providerID: String + let modelID: String +} + +enum ModelsDevPricingTargetResolver { + static func targets(providerID rawProviderID: String, modelID rawModelID: String) -> [ModelsDevPricingTarget] { + let providerID = self.normalizedProviderID(rawProviderID) + let modelID = rawModelID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !providerID.isEmpty, self.isValidModelID(modelID) else { return [] } + + let resolvedModelID = self.modelID(modelID, for: providerID) + guard self.isValidModelID(resolvedModelID) else { return [] } + + var providerIDs = [providerID] + switch providerID { + case "kimi-coding": + providerIDs.append("kimi-for-coding") + case "opencode-free": + providerIDs.append("opencode") + default: + break + } + return providerIDs.map { ModelsDevPricingTarget(providerID: $0, modelID: resolvedModelID) } + } + + private static func normalizedProviderID(_ rawProviderID: String) -> String { + switch ModelsDevProvider.normalizeProviderID(rawProviderID) { + case "x-ai": + "xai" + default: + ModelsDevProvider.normalizeProviderID(rawProviderID) + } + } + + private static func modelID(_ modelID: String, for providerID: String) -> String { + guard let slash = modelID.firstIndex(of: "/") else { return modelID } + let prefix = String(modelID[.. Bool { + !modelID.isEmpty && !modelID.hasPrefix("/") && !modelID.hasSuffix("/") + } +} diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift index aa80374dcc..ef35a7c1f8 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift @@ -43,8 +43,11 @@ public enum OpenCodexRouteDispatcher { } public static func route(provider: String, modelName: String) -> OpenCodexRouteTarget { + let provider = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let trimmedModel = modelName.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedModel.contains("/") { + // Only legacy openai transport labels delegate attribution to an explicit route prefix. + // A model such as openai/gpt-5.4 served by OpenRouter does not consume a Codex subscription. + if provider == "openai", trimmedModel.contains("/") { let modelRoute = self.route(modelName: trimmedModel) if modelRoute != .unknown { return modelRoute diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift index e1623502aa..0851d95876 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift @@ -165,7 +165,7 @@ enum OpenCodexUsageAggregator { return CostUsageTokenSnapshot( sessionTokens: todayEntry == nil && !daily.isEmpty ? 0 : todayEntry?.totalTokens, - sessionCostUSD: todayEntry?.costUSD ?? (daily.isEmpty ? nil : 0), + sessionCostUSD: todayEntry == nil && !daily.isEmpty ? 0 : todayEntry?.costUSD, sessionRequests: todayEntry?.requestCount ?? (daily.isEmpty ? nil : 0), last30DaysTokens: windowTokens.value, last30DaysCostUSD: windowSummary.totalCostUSD, @@ -284,9 +284,8 @@ enum OpenCodexUsageAggregator { /// List-price estimate for one entry. Precedence is unchanged from the per-merge pricing it replaces: /// 1. `customPricing` — the snapshot's own overlay (provider-scoped rates passed by the caller); - /// 2. `CostUsagePricing.codexCostUSD` with the pre-resolved `customPricingOverlay` (the app-level overlay file, - /// which `codexCostUSD` would otherwise re-load per call) and the pre-resolved models.dev `modelsDevCatalog` - /// (otherwise `ModelsDevCache.load` per call), then the bundled/historical tables. + /// 2. App-level exact overrides, then the observed provider's models.dev rates. Only the OpenAI route + /// uses OpenAI bundled/historical tables. Catalog and overlay are resolved once per snapshot. private static func listPriceUSD( entry: OpenCodexUsageEntry, customPricing: CostUsageCustomPricing, @@ -301,28 +300,38 @@ enum OpenCodexUsageAggregator { || usage?.cacheReadTokens != nil || usage?.cacheCreationInputTokens != nil guard hasTokenData else { return nil } - let input = usage?.inputTokens ?? 0 - let output = usage?.outputTokens ?? 0 + guard let input = usage?.inputTokens, let output = usage?.outputTokens else { return nil } let cacheRead = usage?.cacheReadTokens ?? 0 let cacheWrite = usage?.cacheCreationInputTokens ?? 0 - if let overlay = customPricing.costUSD( - providerID: entry.provider, - model: entry.model, - inputTokens: input, - outputTokens: output, - cacheReadTokens: cacheRead, - cacheWriteTokens: cacheWrite) - { - return overlay + if customPricing.rates(providerID: entry.provider, model: entry.model) != nil { + return customPricing.costUSD( + providerID: entry.provider, + model: entry.model, + inputTokens: input, + outputTokens: output, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite) } - return CostUsagePricing.codexCostUSD( + let pricingProvider = OpenCodexUsagePricing.providerID(for: entry) + // Legacy OpenAI transport rows can carry a billing route in the model name. Preserve + // application overrides keyed by the recorded identity before resolving that route. + if let recordedRates = customPricingOverlay.rates(providerID: entry.provider, model: entry.model) { + return CostUsageCustomPricing.costUSD( + rates: recordedRates, + inputTokens: max(0, max(0, input - cacheRead) - cacheWrite), + outputTokens: output, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite) + } + return CostUsagePricing.providerCostUSD( + providerID: pricingProvider, model: entry.model, inputTokens: input, cachedInputTokens: cacheRead, - outputTokens: output, cacheWriteInputTokens: cacheWrite, + outputTokens: output, pricingDate: entry.timestamp, - modelsDevCatalog: modelsDevCatalog, + catalog: modelsDevCatalog, customPricing: customPricingOverlay) } diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsagePricing.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsagePricing.swift new file mode 100644 index 0000000000..9d6f8888d5 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsagePricing.swift @@ -0,0 +1,60 @@ +import Foundation + +enum OpenCodexUsagePricing { + static func targets(for entry: OpenCodexUsageEntry) -> [ModelsDevPricingTarget] { + let provider = self.providerID(for: entry) + let targets = ModelsDevPricingTargetResolver.targets(providerID: provider, modelID: entry.model) + guard let first = targets.first, + first.providerID == CostUsagePricing.codexModelsDevProviderID, + !first.modelID.contains("/") + else { return targets } + return CostUsagePricing.codexModelsDevPricingTargets(for: first.modelID).map { + ModelsDevPricingTarget(providerID: $0.providerID, modelID: $0.modelID) + } + } + + static func providerID(for entry: OpenCodexUsageEntry) -> String { + let provider = entry.provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + // Legacy OpenCodex logs used openai as the transport label for explicit subscription routes. + // Preserve those routes, but never interpret a router's model namespace as its billing provider. + if provider == "openai", + let slash = entry.model.firstIndex(of: "/") + { + let prefix = String(entry.model[.. [ModelsDevPricingTarget] { + ModelsDevPricingTargetResolver.targets(providerID: providerID, modelID: modelID) + } + + private static func target(_ providerID: String, _ modelID: String) -> ModelsDevPricingTarget { + ModelsDevPricingTarget(providerID: providerID, modelID: modelID) + } +} diff --git a/Tests/CodexBarTests/OpenCodexProviderPricingTests.swift b/Tests/CodexBarTests/OpenCodexProviderPricingTests.swift new file mode 100644 index 0000000000..af2d23bdea --- /dev/null +++ b/Tests/CodexBarTests/OpenCodexProviderPricingTests.swift @@ -0,0 +1,532 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct OpenCodexProviderPricingTests { + private static let now = Date(timeIntervalSince1970: 2_000_000_000) + + @Test + func `same model is priced by its recorded provider across every snapshot breakdown`() throws { + let catalog = try Self.catalog() + let direct = Self.snapshot([Self.entry(provider: "openai", model: "gpt-5.4")], catalog: catalog) + let router = Self.snapshot([Self.entry(provider: "openrouter", model: "openai/gpt-5.4")], catalog: catalog) + #expect(abs((direct.last30DaysCostUSD ?? 0) - 0.000244) < 1e-10) + #expect(abs((router.last30DaysCostUSD ?? 0) - 0.00142) < 1e-10) + #expect(router.daily.first?.costUSD == router.last30DaysCostUSD) + #expect(router.daily.first?.modelBreakdowns?.first?.costUSD == router.last30DaysCostUSD) + #expect(router.sessions.first?.costUSD == router.last30DaysCostUSD) + #expect(router.hourly.first?.costUSD == router.last30DaysCostUSD) + #expect(router.costProvenance == .listPriceEstimate) + } + + @Test + func `unqualified subscription model uses its own catalog and legacy route still works`() throws { + let catalog = try Self.catalog() + let bare = Self.snapshot([Self.entry(provider: "opencode-go", model: "gpt-5.4")], catalog: catalog) + let legacy = Self.snapshot( + [Self.entry(provider: "openai", model: "opencode-go/gpt-5.4")], catalog: catalog) + #expect(abs((bare.last30DaysCostUSD ?? 0) - 0.00284) < 1e-10) + #expect(bare.last30DaysCostUSD == legacy.last30DaysCostUSD) + } + + @Test + func `unknown route cannot borrow OpenAI prices or subscription attribution`() { + for provider in ["openrouter", "private-proxy", "xai", "google"] { + let entry = Self.entry(provider: provider, model: "openai/gpt-5.4") + let snapshot = Self.snapshot([entry], catalog: ModelsDevCatalog(providers: [:])) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.sessionCostUSD == nil) + #expect(snapshot.daily.first?.unpricedRequestCount == 1) + #expect(OpenCodexUsageFanOut.snapshotsBySubscription( + entries: [entry], now: Self.now, historyDays: 7, calendar: Self.calendar).isEmpty) + } + } + + @Test + func `router namespace does not fall through to a bare model in the same catalog`() throws { + let catalog = try Self.catalog(routerModel: "gpt-5.4") + let snapshot = Self.snapshot( + [Self.entry(provider: "openrouter", model: "openai/gpt-5.4")], catalog: catalog) + #expect(snapshot.last30DaysCostUSD == nil) + } + + @Test + func `partial custom price remains unknown instead of silently falling through`() throws { + let custom = CostUsageCustomPricing.parse(Data(""" + {"openrouter/openai/gpt-5.4":{"input":1}} + """.utf8)) + let snapshot = try OpenCodexUsageAggregator.snapshot( + entries: [Self.entry(provider: "openrouter", model: "openai/gpt-5.4")], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + customPricing: custom, + modelsDevCatalog: Self.catalog(), + customPricingOverlay: .empty) + #expect(snapshot.last30DaysCostUSD == nil) + } + + @Test + func `custom pricing counts cached tokens once and partial usage remains unknown`() throws { + let overlay = CostUsageCustomPricing.parse(Data(""" + {"openrouter/openai/gpt-5.4":{"input":10,"output":40,"cacheRead":1}} + """.utf8)) + let snapshot = try OpenCodexUsageAggregator.snapshot( + entries: [Self.entry(provider: "openrouter", model: "openai/gpt-5.4")], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + customPricing: overlay, + modelsDevCatalog: Self.catalog(), + customPricingOverlay: .empty) + #expect(abs((snapshot.last30DaysCostUSD ?? 0) - 0.00142) < 1e-10) + let partial = OpenCodexUsageEntry( + requestID: "partial", + timestamp: Self.now, + provider: "openrouter", + model: "openai/gpt-5.4", + usageStatus: .reported, + usage: OpenCodexTokenUsage(totalTokens: 500)) + let unpriced = try Self.snapshot([partial], catalog: Self.catalog()) + #expect(unpriced.last30DaysTokens == 500) + #expect(unpriced.last30DaysCostUSD == nil) + #expect(unpriced.sessionCostUSD == nil) + } + + @Test + func `custom pricing follows canonical provider alias after explicit observed override`() throws { + let entry = Self.entry(provider: "kimi-coding", model: "kimi-coding/k3") + let canonical = CostUsageCustomPricing.parse(Data(""" + {"kimi-for-coding/k3":{"input":3,"output":6,"cacheRead":0.3}} + """.utf8)) + let canonicalSnapshot = try OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + modelsDevCatalog: Self.catalog(), + customPricingOverlay: canonical) + #expect(abs((canonicalSnapshot.last30DaysCostUSD ?? 0) - 0.000306) < 1e-10) + + let explicit = CostUsageCustomPricing.parse(Data(""" + { + "kimi-coding/k3":{"input":1,"output":2,"cacheRead":0.1}, + "kimi-for-coding/k3":{"input":3,"output":6,"cacheRead":0.3} + } + """.utf8)) + let explicitSnapshot = try OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + modelsDevCatalog: Self.catalog(), + customPricingOverlay: explicit) + #expect(abs((explicitSnapshot.last30DaysCostUSD ?? 0) - 0.000102) < 1e-10) + } + + @Test + func `raw provider custom pricing wins before normalized targets without filling missing rates`() throws { + let entry = Self.entry(provider: "x-ai", model: "x-ai/grok-fixture") + let explicit = CostUsageCustomPricing.parse(Data(""" + { + "x-ai/grok-fixture":{"input":1,"output":2,"cacheRead":0.1}, + "xai/grok-fixture":{"input":3,"output":6,"cacheRead":0.3} + } + """.utf8)) + let explicitSnapshot = try OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + modelsDevCatalog: Self.catalog(), + customPricingOverlay: explicit) + #expect(abs((explicitSnapshot.last30DaysCostUSD ?? 0) - 0.000102) < 1e-10) + + let free = CostUsageCustomPricing.parse(Data(""" + {"x-ai/grok-fixture":{"input":0,"output":0,"cacheRead":0}} + """.utf8)) + let freeSnapshot = try OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + modelsDevCatalog: Self.catalog(), + customPricingOverlay: free) + #expect(freeSnapshot.last30DaysCostUSD == 0) + + let incomplete = CostUsageCustomPricing.parse(Data(""" + { + "x-ai/grok-fixture":{"input":1}, + "xai/grok-fixture":{"input":3,"output":6,"cacheRead":0.3} + } + """.utf8)) + let incompleteSnapshot = try OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + modelsDevCatalog: Self.catalog(), + customPricingOverlay: incomplete) + #expect(incompleteSnapshot.last30DaysCostUSD == nil) + } + + @Test + func `legacy OpenAI transport keeps recorded application price before routed price`() throws { + let entry = Self.entry(provider: "openai", model: "opencode-go/gpt-5.4") + let application = CostUsageCustomPricing.parse(Data(""" + { + "openai/opencode-go/gpt-5.4":{"input":1,"output":2,"cacheRead":0.1}, + "gpt-5.4":{"input":3,"output":6,"cacheRead":0.3} + } + """.utf8)) + let snapshot = try OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + modelsDevCatalog: Self.catalog(), + customPricingOverlay: application) + #expect(abs((snapshot.last30DaysCostUSD ?? 0) - 0.000102) < 1e-10) + + let caller = CostUsageCustomPricing.parse(Data(""" + {"openai/opencode-go/gpt-5.4":{"input":4,"output":8,"cacheRead":0.4}} + """.utf8)) + let callerSnapshot = try OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + customPricing: caller, + modelsDevCatalog: Self.catalog(), + customPricingOverlay: application) + #expect(abs((callerSnapshot.last30DaysCostUSD ?? 0) - 0.000488) < 1e-10) + } + + @Test + func `legacy recorded application zero and incomplete rates block routed fallback`() throws { + let entry = Self.entry(provider: "openai", model: "opencode-go/gpt-5.4") + let catalog = try Self.catalog() + let free = CostUsageCustomPricing.parse(Data(""" + { + "openai/opencode-go/gpt-5.4":{"input":0,"output":0,"cacheRead":0}, + "gpt-5.4":{"input":3,"output":6,"cacheRead":0.3} + } + """.utf8)) + let freeSnapshot = OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + modelsDevCatalog: catalog, + customPricingOverlay: free) + #expect(freeSnapshot.last30DaysCostUSD == 0) + + let incomplete = CostUsageCustomPricing.parse(Data(""" + { + "openai/opencode-go/gpt-5.4":{"input":1}, + "gpt-5.4":{"input":3,"output":6,"cacheRead":0.3} + } + """.utf8)) + let incompleteSnapshot = OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + modelsDevCatalog: catalog, + customPricingOverlay: incomplete) + #expect(incompleteSnapshot.last30DaysCostUSD == nil) + #expect(incompleteSnapshot.daily.first?.unpricedRequestCount == 1) + } + + @Test + func `bare application overrides retain precedence over provider qualified overrides`() throws { + for (provider, model) in [("openai", "gpt-5.4"), ("openai", "opencode-go/gpt-5.4")] { + let application = CostUsageCustomPricing.parse(Data(""" + { + "\(model)":{"input":0,"output":0,"cacheRead":0}, + "\(provider)/\(model)":{"input":3,"output":6,"cacheRead":0.3} + } + """.utf8)) + let snapshot = try OpenCodexUsageAggregator.snapshot( + entries: [Self.entry(provider: provider, model: model)], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + modelsDevCatalog: Self.catalog(), + customPricingOverlay: application) + #expect(snapshot.last30DaysCostUSD == 0) + } + } + + @Test + func `generic catalog row with consumed cache tokens and no cache rate stays unpriced`() throws { + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(""" + { + "anthropic":{"models":{"fixture":{"id":"fixture","cost":{"input":1,"output":2}}}}, + "openai":{"models":{"fixture":{"id":"fixture","cost":{"input":1,"output":2}}}}, + "xai":{"models":{"grok-fixture":{"id":"grok-fixture","cost":{"input":2,"output":8}}}} + } + """.utf8)) + let snapshot = Self.snapshot([Self.entry(provider: "xai", model: "grok-fixture")], catalog: catalog) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.daily.first?.unpricedRequestCount == 1) + } + + @Test + func `cache creation is priced once and requires an explicit rate`() throws { + let entry = OpenCodexUsageEntry( + requestID: "cache-creation", + timestamp: Self.now, + provider: "openrouter", + model: "openai/gpt-5.4", + usageStatus: .reported, + usage: OpenCodexTokenUsage( + inputTokens: 100, + outputTokens: 10, + cachedInputTokens: 20, + cacheCreationInputTokens: 30, + totalTokens: 160)) + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(""" + {"openrouter":{"models":{"openai/gpt-5.4":{"id":"openai/gpt-5.4",\ + "cost":{"input":10,"output":40,"cache_read":1,"cache_write":5}}}}} + """.utf8)) + let overlay = CostUsageCustomPricing.parse(Data(""" + {"openrouter/openai/gpt-5.4":{"input":10,"output":40,"cacheRead":1,"cacheWrite":5}} + """.utf8)) + let catalogSnapshot = Self.snapshot([entry], catalog: catalog) + let overlaySnapshot = OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + customPricing: overlay, + modelsDevCatalog: catalog, + customPricingOverlay: .empty) + for snapshot in [catalogSnapshot, overlaySnapshot] { + let cost = try #require(snapshot.last30DaysCostUSD) + #expect(abs(cost - 0.00157) < 1e-10) + #expect(snapshot.sessionCostUSD == cost) + #expect(snapshot.daily.first?.costUSD == cost) + #expect(snapshot.sessions.first?.costUSD == cost) + #expect(snapshot.hourly.first?.costUSD == cost) + } + + let unpriced = try Self.snapshot([entry], catalog: Self.catalog()) + #expect(unpriced.last30DaysTokens == 160) + #expect(unpriced.last30DaysCostUSD == nil) + #expect(unpriced.sessionCostUSD == nil) + #expect(unpriced.daily.first?.unpricedRequestCount == 1) + } + + @Test + func `cache-only rows preserve complete free and missing cache prices`() throws { + let entry = OpenCodexUsageEntry( + requestID: "cache-only", + timestamp: Self.now, + provider: "openrouter", + model: "openai/gpt-5.4", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 30)) + let cases: [(Double?, Double?)] = [(5, 0.00015), (0, 0), (nil, nil)] + for (rate, expected) in cases { + let catalog = try Self.catalog(cacheWrite: rate) + let overlay = CostUsageCustomPricing(entries: [ + "openrouter/openai/gpt-5.4": .init(input: 10, output: 40, cacheRead: 1, cacheWrite: rate), + ], fingerprint: "cache-only-fixture") + var snapshots = [Self.snapshot([entry], catalog: catalog)] + for callerOverride in [true, false] { + snapshots.append(OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + customPricing: callerOverride ? overlay : .empty, + modelsDevCatalog: catalog, + customPricingOverlay: callerOverride ? .empty : overlay)) + } + for snapshot in snapshots { + #expect(snapshot.last30DaysTokens == 30) + if let expected { + let cost = try #require(snapshot.last30DaysCostUSD) + #expect(abs(cost - expected) < 1e-10) + #expect(snapshot.sessionCostUSD == cost) + } else { + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.sessionCostUSD == nil) + } + #expect(snapshot.daily.first?.unpricedRequestCount == (expected == nil ? 1 : 0)) + } + } + } + + @Test + func `independent cache conversion overflow stays unpriced`() throws { + let entry = OpenCodexUsageEntry( + requestID: "cache-overflow", + timestamp: Self.now, + provider: "openrouter", + model: "openai/gpt-5.4", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: Int.max, outputTokens: 0, cacheCreationInputTokens: 1)) + let snapshot = try Self.snapshot([entry], catalog: Self.catalog(cacheWrite: 5)) + #expect(snapshot.last30DaysTokens == nil) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.sessionCostUSD == nil) + #expect(snapshot.daily.first?.unpricedRequestCount == 1) + } + + @Test + func `direct and legacy routed rows keep historical application and independent caller conventions`() throws { + for model in ["gpt-5.4", "opencode-go/gpt-5.4"] { + let overlay = CostUsageCustomPricing(entries: [ + model: .init(input: 10, output: 40, cacheRead: 1), + ], fingerprint: "historical-openai-fixture") + let entry = Self.entry(provider: "openai", model: model) + let application = try OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + modelsDevCatalog: Self.catalog(), + customPricingOverlay: overlay) + let caller = try OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + customPricing: overlay, + modelsDevCatalog: Self.catalog(), + customPricingOverlay: .empty) + let applicationCost = try #require(application.last30DaysCostUSD) + let callerCost = try #require(caller.last30DaysCostUSD) + #expect(abs(applicationCost - 0.00122) < 1e-10) + #expect(abs(callerCost - 0.00142) < 1e-10) + } + } + + @Test + func `fresh catalog miss refreshes router namespace and reprices without reparsing usage`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let before = try Self.catalog(routerModel: "gpt-5.4") + #expect(ModelsDevCache.save(catalog: before, fetchedAt: Self.now.addingTimeInterval(-901), cacheRoot: root)) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let log = root.appendingPathComponent("usage.jsonl") + try Data(""" + {"requestId":"persisted","timestamp":2000000000,"provider":"openrouter","model":"openai/gpt-5.4",\ + "usageStatus":"reported","usage":{"inputTokens":100,"outputTokens":10,"cachedInputTokens":20,"totalTokens":110}} + + """.utf8).write(to: log) + let store = OpenCodexUsageStore(cacheRoot: root) + let entries = try store.loadEntries(logURL: log) + #expect(Self.snapshot(entries, catalog: before).last30DaysCostUSD == nil) + let transport = try PricingTransport(catalog: Self.catalog()) + await OpenCodexUsageStore.refreshPricingIfNeeded( + entries: entries, now: Self.now, cacheRoot: root, client: ModelsDevClient(transport: transport)) + let refreshed = try #require(ModelsDevCache.load(now: Self.now, cacheRoot: root).artifact?.catalog) + #expect(abs((Self.snapshot(entries, catalog: refreshed).last30DaysCostUSD ?? 0) - 0.00142) < 1e-10) + #expect(await transport.calls == 1) + let recorder = OpenCodexUsageParser.LogReadRecorder() + let cachedEntries = try OpenCodexUsageStore.withLogReadRecorderForTesting(recorder) { + try store.loadEntries(logURL: log) + } + #expect(cachedEntries == entries) + #expect(recorder.snapshot().bytesRead == 0) + await OpenCodexUsageStore.refreshPricingIfNeeded( + entries: entries, now: Self.now, cacheRoot: root, client: ModelsDevClient(transport: transport)) + #expect(await transport.calls == 1) + } + + @Test + func `stale price refresh changes costs while failure preserves the last good rates`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let old = try Self.catalog(routerInput: 5) + #expect(ModelsDevCache.save( + catalog: old, fetchedAt: Self.now.addingTimeInterval(-90000), cacheRoot: root)) + let entries = [Self.entry(provider: "openrouter", model: "openai/gpt-5.4")] + let transport = try PricingTransport(catalog: Self.catalog()) + await OpenCodexUsageStore.refreshPricingIfNeeded( + entries: entries, now: Self.now, cacheRoot: root, client: ModelsDevClient(transport: transport)) + let refreshed = try #require(ModelsDevCache.load(now: Self.now, cacheRoot: root).artifact?.catalog) + #expect(Self.snapshot(entries, catalog: old).last30DaysCostUSD + != Self.snapshot(entries, catalog: refreshed).last30DaysCostUSD) + let failure = try PricingTransport(catalog: Self.catalog(), fail: true) + await OpenCodexUsageStore.refreshPricingIfNeeded( + entries: entries, + now: Self.now.addingTimeInterval(90000), + cacheRoot: root, + client: ModelsDevClient(transport: failure)) + #expect(ModelsDevCache.load(cacheRoot: root).artifact?.catalog == refreshed) + #expect(await failure.calls == 1) + } + + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static func entry(provider: String, model: String) -> OpenCodexUsageEntry { + OpenCodexUsageEntry( + requestID: "request", + timestamp: self.now, + provider: provider, + model: model, + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 100, outputTokens: 10, cachedInputTokens: 20, totalTokens: 110)) + } + + private static func snapshot( + _ entries: [OpenCodexUsageEntry], catalog: ModelsDevCatalog) -> CostUsageTokenSnapshot + { + OpenCodexUsageAggregator.snapshot( + entries: entries, + now: self.now, + historyDays: 7, + calendar: self.calendar, + modelsDevCatalog: catalog, + customPricingOverlay: .empty) + } + + private static func catalog( + routerModel: String = "openai/gpt-5.4", + routerInput: Double = 10, + cacheWrite: Double? = nil) throws -> ModelsDevCatalog + { + try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(""" + { + "openai":{"models":{"gpt-5.4":{"id":"gpt-5.4","cost":{"input":2,"output":8,"cache_read":0.2}}}}, + "anthropic":{"models":{"fixture":{"id":"fixture","cost":{"input":1,"output":2}}}}, + "opencode-go":{"models":{"gpt-5.4":{"id":"gpt-5.4","cost":{"input":20,"output":80,"cache_read":2}}}}, + "xai":{"models":{"grok-fixture":{ + "id":"grok-fixture","cost":{"input":30,"output":60,"cache_read":3} + }}}, + "openrouter":{"models":{"\(routerModel)":{ + "id":"\(routerModel)","cost":{"input":\(routerInput),"output":40,"cache_read":1,\ + "cache_write":\(cacheWrite.map { String($0) } ?? "null")} + }}} + } + """.utf8)) + } +} + +private actor PricingTransport: ModelsDevHTTPTransport { + private let data: Data + private let fail: Bool + private(set) var calls = 0 + + init(catalog: ModelsDevCatalog, fail: Bool = false) throws { + self.data = try JSONEncoder().encode(catalog) + self.fail = fail + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + self.calls += 1 + if self.fail { throw URLError(.notConnectedToInternet) } + return (self.data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} diff --git a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift index e6569e18cf..93f4bf5e8d 100644 --- a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift +++ b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift @@ -689,7 +689,7 @@ private enum OpenCodexUsageSnapshotReference { .summary(forLastDays: days, calendar: calendar) return CostUsageTokenSnapshot( sessionTokens: todayEntry?.totalTokens ?? (daily.isEmpty ? nil : 0), - sessionCostUSD: todayEntry?.costUSD ?? (daily.isEmpty ? nil : 0), + sessionCostUSD: todayEntry == nil && !daily.isEmpty ? 0 : todayEntry?.costUSD, sessionRequests: todayEntry?.requestCount ?? (daily.isEmpty ? nil : 0), last30DaysTokens: windowSummary.totalTokens, last30DaysCostUSD: windowSummary.totalCostUSD, diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 44f8305578..6855175d85 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1377,13 +1377,13 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 815, + line: 817, anchor: "let account = try context.resolvedAccounts(for: .cursor).first", expectedProviderIDs: ["cursor"], reason: "The Cursor-only cookie-settings resolver passes its fixed identity to token-account helpers."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 816, + line: 818, anchor: "return context.settingsSnapshot(for: .cursor, account: account)?.cursor", expectedProviderIDs: ["cursor"], reason: "The Cursor-only cookie-settings resolver passes its fixed identity to token-account helpers."), @@ -1407,19 +1407,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: 822, + line: 817, 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: 899, + line: 894, 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: 980, + line: 975, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -3475,7 +3475,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 825, + line: 827, anchor: "guard provider == .cursor else { return nil }", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3483,7 +3483,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 845, + line: 847, anchor: "guard provider == .cursor, settings?.cookieSource == .manual else { return nil }", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3579,7 +3579,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: 729, + line: 724, anchor: "guard provider == .codex || provider == .claude else { return nil }", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 3, @@ -3587,7 +3587,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: 1388, + line: 1383, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3595,7 +3595,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: 1693, + line: 1688, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3798,15 +3798,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: 501, - anchor: "providerIDs.append(\"opencode\")", - expectedProviderIDs: ["opencode"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["opencode@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: 539, + line: 531, anchor: "if self.codex[trimmed] != nil {", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -3814,7 +3806,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: 582, + line: 574, anchor: "if self.claude[base] != nil {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3822,7 +3814,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: 617, + line: 609, anchor: "let bundled = lookup.pricing.providerID == self.codexModelsDevProviderID ? self.codex[key] : nil", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3830,7 +3822,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: 651, + line: 643, anchor: "guard let pricing = self.codex[key] else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3838,7 +3830,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: 811, + line: 803, anchor: "guard let pricing = self.claude[key] else { return nil }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3846,12 +3838,28 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift", - line: 80, + line: 84, anchor: "[\"anthropic\", \"openai\"].allSatisfy { providerID in", 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/Vendored/CostUsage/ModelsDevPricingTargetResolver.swift", + line: 22, + anchor: "providerIDs.append(\"opencode\")", + expectedProviderIDs: ["opencode", "openrouter", "xai"], + expectedReferenceCount: 4, + expectedReferenceFingerprint: ["opencode@0", "xai@10", "openrouter@22", "openrouter@23"], + reason: "This exact pricing resolver maps recorded billing-provider aliases to models.dev provider IDs."), + AllowedProviderConstruct( + path: "Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsagePricing.swift", + line: 20, + anchor: "if provider == \"openai\",", + expectedProviderIDs: ["openai"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["openai@0"], + reason: "This exact pricing bridge preserves the legacy OpenCodex transport label contract."), AllowedProviderConstruct( path: "Sources/CodexBarWidget/CodexBarWidgetProvider.swift", line: 82, diff --git a/Tests/CodexBarTests/SpendDashboardOpenCodexPricingRefreshTests.swift b/Tests/CodexBarTests/SpendDashboardOpenCodexPricingRefreshTests.swift new file mode 100644 index 0000000000..a3b0994dd9 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardOpenCodexPricingRefreshTests.swift @@ -0,0 +1,109 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct SpendDashboardOpenCodexPricingRefreshTests { + @Test + func `fresh OpenCodex load refreshes pricing once with loaded entries before publication`() async throws { + let now = Date(timeIntervalSince1970: 1_787_079_600) + let entry = OpenCodexUsageEntry( + requestID: "request-1", + timestamp: now, + provider: "openai", + model: "gpt-5.4", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 10, outputTokens: 2, totalTokens: 12), + totalTokens: 12) + let recorder = PricingRefreshRecorder() + + let result = await SpendDashboardSource.mergingOpenCodexInputsAfterRefreshingPricing( + [], + request: Self.request(now: now), + environment: Self.environment, + entryLoader: { _ in [entry] }, + pricingRefresher: { entries, refreshDate in + await recorder.record(entries: entries, now: refreshDate) + }) + + let calls = await recorder.calls + #expect(calls.count == 1) + #expect(calls.first?.entries == [entry]) + #expect(calls.first?.now == now) + #expect(result.observation == .available) + let published = try #require(result.inputs.first) + #expect(published.provider == .codex) + #expect(published.snapshot.last30DaysTokens == 12) + } + + @Test + func `OpenCodex guards do not refresh pricing`() async { + let now = Date(timeIntervalSince1970: 1_787_079_600) + let recorder = PricingRefreshRecorder() + let refresher: @Sendable ([OpenCodexUsageEntry], Date) async -> Void = { entries, refreshDate in + await recorder.record(entries: entries, now: refreshDate) + } + + let disabled = await SpendDashboardSource.mergingOpenCodexInputsAfterRefreshingPricing( + [], + request: Self.request(now: now, enabled: false), + environment: Self.environment, + entryLoader: { _ in [] }, + pricingRefresher: refresher) + let hidden = await SpendDashboardSource.mergingOpenCodexInputsAfterRefreshingPricing( + [], + request: Self.request(now: now, hidden: true), + environment: Self.environment, + entryLoader: { _ in [] }, + pricingRefresher: refresher) + let empty = await SpendDashboardSource.mergingOpenCodexInputsAfterRefreshingPricing( + [], + request: Self.request(now: now), + environment: Self.environment, + entryLoader: { _ in [] }, + pricingRefresher: refresher) + + #expect(disabled.observation == .disabled) + #expect(hidden.observation == .disabled) + #expect(empty.observation == .confirmedEmpty) + let calls = await recorder.calls + #expect(calls.isEmpty) + } + + private static let environment = ["OPENCODEX_HOME": "/tmp/opencodex-pricing-refresh-tests"] + + private static func request( + now: Date, + enabled: Bool = true, + hidden: Bool = false) -> SpendDashboardLoadRequest + { + SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [], + codexAccountIdentities: [], + openCodexUsageLogsEnabled: enabled, + hiddenSourceIDs: hidden ? [SpendDashboardModel.openCodexSourceID] : []), + capturedInputs: [], + unavailableSourceIDs: [], + confirmedEmptySourceIDs: [], + codexRequests: [], + now: now, + force: false) + } +} + +private actor PricingRefreshRecorder { + struct Call: Sendable { + let entries: [OpenCodexUsageEntry] + let now: Date + } + + private(set) var calls: [Call] = [] + + func record(entries: [OpenCodexUsageEntry], now: Date) { + self.calls.append(Call(entries: entries, now: now)) + } +} diff --git a/docs/cli.md b/docs/cli.md index 21801e5156..1bb70b6120 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -70,6 +70,8 @@ See `docs/configuration.md` for the schema. - Cursor is fetched from the cookie-authenticated cursor.com dashboard API (macOS only; see `docs/cursor.md`) and honors the configured cookie source: a non-empty Manual header is required and forwarded, while Off fails explicitly instead of silently omitting Cursor. - `--format text|json` (default: text). `--json` includes the same cost concepts as Settings → Usage & Spend (token mix, `provenance`, coverage), but it is not the dashboard Export JSON schema. CLI places mix fields under each provider's `totals` and emits `provenance`/`coverage` on that provider object; Export JSON nests `tokenMix`, `provenance`, and `coverage` under `groups[]`. - OpenCodex appears as a separate `opencodex` payload only when **Include OpenCodex usage logs** is on in Settings. That payload does not invent `projects` (OpenCodex logs have no workspace path). + It refreshes cached models.dev prices before estimating recorded provider/model usage. OpenRouter model namespaces + remain scoped to OpenRouter, and missing usage or price fields remain unknown rather than zero. See [model pricing](model-pricing.md). - `--refresh` ignores cached scans. - `--breakdown` adds Claude-only daily and top-model details to text output. Both sections use the same last seven calendar days (or the shorter requested interval); when that interval has no rows, both explicitly label the latest recorded days. Incomplete attribution is marked partial. Ordinary text, other providers, and JSON output are unchanged. - `--provider-native-only` is experimental and excludes pi and OMP session mirrors from Claude and Codex history. diff --git a/docs/model-pricing.md b/docs/model-pricing.md index d2a81c860a..9036ad7361 100644 --- a/docs/model-pricing.md +++ b/docs/model-pricing.md @@ -22,6 +22,12 @@ The pipeline lets future scanner code read the last valid cache synchronously wi Catalog saves use a single atomic write on macOS and Linux, so refreshing an existing cache replaces its contents without removing the destination first. Successful saves invalidate the in-memory catalog memo. +Fresh OpenCodex dashboard loads and the opt-in CLI OpenCodex payload also refresh the catalog, even when +no native Codex or Claude scan runs. Missing exact provider/model targets may trigger an earlier refresh, +subject to the shared 15-minute retry cooldown. Cached dashboard publication and synchronous snapshot +reads remain network-free. OpenCodex stores raw usage and recomputes estimates from the current catalog; +updating a price does not require rereading unchanged usage logs. + ## Lookup rules Pricing is scoped by provider id and model id. This prevents two providers with the same model id or display name from sharing pricing accidentally. @@ -35,6 +41,31 @@ Local cost scanners preserve that scope when selecting a catalog: - Claude's [documented `k3[1m]` alias](https://www.kimi.com/code/docs/en/third-party-tools/claude-code.html) resolves to `kimi-for-coding/k3` after exact-row lookup, including the existing `kimi-coding/` and `kimi-for-coding/` routes. Recorded model names stay unchanged; other context variants and paid Moonshot routes are not inferred. Catalog zero rates remain known estimates, not a claim that subscriptions or extra usage are free. - Vertex AI Claude logs: models.dev provider id `google-vertex-anthropic` +### Explicit provider identity in OpenCodex + +OpenCodex estimates use the recorded provider and model together. An unqualified model on `opencode-go` +uses that provider's rates, not OpenAI's bundled prices. `provider=openrouter` with +`model=openai/gpt-5.4` looks up the exact `openai/gpt-5.4` model inside the `openrouter` catalog. +Only a redundant outer `openrouter/` prefix is removed. Router lookups do not fall back to bare model IDs, +another provider, or OpenAI's bundled/historical tables. + +The shared target resolver preserves the existing Kimi/OpenCode provider aliases. Legacy OpenCodex rows +with an `openai` transport label and an explicit supported subscription-route prefix retain that route. +Other providers cannot borrow subscription attribution from a model namespace: an OpenRouter-hosted +OpenAI model does not consume a Codex subscription. + +The CLI's separate OpenCodex payload can price any exact recorded provider/model present in the catalog. +This does not enable new ingestion sources or add API providers to the dashboard's subscription fan-out. +Existing Pi provider support and the opt-in OpenCodex setting are unchanged. Dollar amounts remain +list-price estimates; recorded token usage is not a billing receipt. Rows lacking input/output counts, +an exact price, or a consumed cache class's rate stay unpriced. An unpriced current day is not shown as $0. + +OpenCodex retains the recorded input, output, cache-read, and cache-creation counters. Non-OpenAI catalog +prices and caller-supplied custom prices charge these independent classes without clamping cache usage +to the input count. Historical OpenAI catalog pricing and all application-overlay calculations retain their +inclusive input convention, including legacy routed rows. No convention is inferred from aggregate total tokens; +missing cache prices remain unknown. + ## Units models.dev publishes costs as USD per 1M tokens. CodexBar converts those to USD per token in the metadata layer: @@ -58,7 +89,12 @@ The Linux CLI uses `FileManager`’s Application Support directory (XDG data hom Values are USD per million tokens. For native Codex session scans, resolution order is **overlay > models.dev > builtin**. Changing the file invalidates the Codex pricing fingerprint so the next native Codex scan reloads rates. -The overlay currently applies only to native Codex/OpenAI-compatible session pricing. Claude's local scanner, Cursor, and production OpenCodex snapshot loads do not read this file (OpenCodex keeps an empty overlay). A key such as `anthropic/claude-…` does not change Claude list prices. +The overlay applies to native Codex/OpenAI-compatible pricing and OpenCodex estimates. OpenCodex checks +the recorded provider/model identity before its provider catalog; its caller-supplied snapshot overlay +takes precedence over the app-level overlay. Bare model keys remain global overrides with the documented +bare-key precedence below. Use full keys such as `openrouter/openai/gpt-5.4` to scope routed prices. +Claude's local scanner and Cursor do not read this file. A key such as `anthropic/claude-…` does not change +Claude scanner list prices. Keys are case-insensitive and may be a bare model id (`gpt-5.4`) or `provider/model` (`openai/gpt-5.4`). Only an exact normalized key matches; there is no prefix or family glob. If both forms exist for the same model, the **bare key wins** and the provider-qualified row is ignored. Do not define both unless the bare override is the one you want.