From 67f1d5d005ec65cc5ee92fc87e4a687aca6aa883 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 16 Sep 2026 02:54:36 -0700 Subject: [PATCH] fix(opencodex): price usage by its recorded provider Keep router model namespaces from changing subscription attribution, resolve exact provider prices, and preserve unknown costs when counts or required rates are absent. Refresh the existing public pricing cache for fresh opt-in loads. Preserve independently recorded cache classes and existing caller/application override conventions, with cache-only, free, incomplete-rate, overflow, route, and historical compatibility regressions. Co-authored-by: Alec Gutman <44984861+Chipagosfinest@users.noreply.github.com> --- .../CodexBar/SpendDashboardController.swift | 2 +- .../SpendDashboardSource+OpenCodex.swift | 40 ++ Sources/CodexBarCLI/CLICostCommand.swift | 6 +- Sources/CodexBarCore/CostUsageFetcher.swift | 5 - .../Generated/CodexParserHash.generated.swift | 2 +- .../CostUsage/CostUsagePricing+Provider.swift | 82 +++ .../Vendored/CostUsage/CostUsagePricing.swift | 12 +- .../Vendored/CostUsage/CostUsageStore.swift | 1 + .../Vendored/CostUsage/ModelsDevPricing.swift | 19 +- .../ModelsDevPricingTargetResolver.swift | 54 ++ .../OpenCodexRouteDispatcher.swift | 5 +- .../OpenCodexUsageAggregator.swift | 45 +- .../OpenCodexUsagePricing.swift | 60 ++ Tests/CodexBarTests/CostUsageStoreTests.swift | 2 + .../ModelsDevPricingTargetResolverTests.swift | 67 +++ .../OpenCodexProviderPricingTests.swift | 532 ++++++++++++++++++ .../OpenCodexUsageFanOutTests.swift | 2 +- .../ProviderArchitectureGatekeeperTests.swift | 56 +- ...ashboardOpenCodexPricingRefreshTests.swift | 109 ++++ docs/cli.md | 2 + docs/model-pricing.md | 38 +- 21 files changed, 1071 insertions(+), 70 deletions(-) create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Provider.swift create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricingTargetResolver.swift create mode 100644 Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsagePricing.swift create mode 100644 Tests/CodexBarTests/ModelsDevPricingTargetResolverTests.swift create mode 100644 Tests/CodexBarTests/OpenCodexProviderPricingTests.swift create mode 100644 Tests/CodexBarTests/SpendDashboardOpenCodexPricingRefreshTests.swift 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.