From 1e0e81b9bccd1e632ef7a098f6d1bbe51b47c20b Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 11 Aug 2026 10:55:52 +0200 Subject: [PATCH] Fix $0.00 cost on OpenRouter image generation models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image-gen models are billed per-image/per-request, not at the plain per-token prompt/completion rates ModelInfo.Pricing captures — so token-based cost calculation was always near-zero for them regardless of actual spend. Now requests OpenRouter's usage.include=true, decodes the actual billed usage.cost, and prefers it over calculated cost via a new resolveCost() helper (covers the dedicated Images API, regular chat completions, streaming, and the tool-calling loop alike). --- oAI/Providers/OpenRouterModels.swift | 12 +++++++++ oAI/Providers/OpenRouterProvider.swift | 12 ++++++--- oAI/ViewModels/ChatViewModel.swift | 22 ++++++++++------ oAITests/ChatViewModelPureLogicTests.swift | 30 ++++++++++++++++++++++ 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/oAI/Providers/OpenRouterModels.swift b/oAI/Providers/OpenRouterModels.swift index 68e1662..9e95f7b 100644 --- a/oAI/Providers/OpenRouterModels.swift +++ b/oAI/Providers/OpenRouterModels.swift @@ -47,11 +47,18 @@ struct OpenRouterChatRequest: Codable { let modalities: [String]? let reasoning: ReasoningAPIConfig? let cacheControl: CacheControl? + let usage: UsageOptions? struct CacheControl: Codable { let type: String } + /// Requests OpenRouter to include the actual billed USD cost in the response's `usage` + /// object — needed for models priced outside plain per-token rates (e.g. per-image). + struct UsageOptions: Codable { + let include: Bool + } + struct APIMessage: Codable { let role: String let content: MessageContent @@ -141,6 +148,7 @@ struct OpenRouterChatRequest: Codable { case toolChoice = "tool_choice" case modalities case reasoning + case usage case cacheControl = "cache_control" } } @@ -230,6 +238,9 @@ struct OpenRouterChatResponse: Codable { let completionTokens: Int let totalTokens: Int let promptTokensDetails: PromptTokensDetails? + /// Actual billed USD cost — only present when the request opted in via `usage.include`. + /// Needed for models priced outside plain per-token rates (e.g. per-image generation). + let cost: Double? struct PromptTokensDetails: Codable { let cachedTokens: Int? @@ -246,6 +257,7 @@ struct OpenRouterChatResponse: Codable { case completionTokens = "completion_tokens" case totalTokens = "total_tokens" case promptTokensDetails = "prompt_tokens_details" + case cost } } } diff --git a/oAI/Providers/OpenRouterProvider.swift b/oAI/Providers/OpenRouterProvider.swift index d38d9d3..ad17a0a 100644 --- a/oAI/Providers/OpenRouterProvider.swift +++ b/oAI/Providers/OpenRouterProvider.swift @@ -276,7 +276,8 @@ class OpenRouterProvider: AIProvider { var body: [String: Any] = [ "model": model, "messages": messages, - "stream": false + "stream": false, + "usage": ["include": true] ] if let tools = tools { let toolsData = try JSONEncoder().encode(tools) @@ -505,7 +506,8 @@ class OpenRouterProvider: AIProvider { toolChoice: request.tools != nil ? "auto" : nil, modalities: request.imageGeneration ? ["text", "image"] : nil, reasoning: reasoningConfig, - cacheControl: cacheControl + cacheControl: cacheControl, + usage: OpenRouterChatRequest.UsageOptions(include: true) ) } @@ -544,7 +546,8 @@ class OpenRouterProvider: AIProvider { completionTokens: usage.completionTokens, totalTokens: usage.totalTokens, cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens, - cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens + cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens, + rawCostUSD: usage.cost ) }, created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)), @@ -585,7 +588,8 @@ class OpenRouterProvider: AIProvider { completionTokens: usage.completionTokens, totalTokens: usage.totalTokens, cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens, - cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens + cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens, + rawCostUSD: usage.cost ) } ) diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index ea6a126..25c7e95 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -1150,8 +1150,7 @@ Don't narrate future actions ("Let me...") - just use the tools. if let usage = response.usage { messages[index].tokens = usage.completionTokens if let model = selectedModel { - let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil + let cost = Self.resolveCost(usage: usage, pricing: model.pricing) messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) } @@ -1214,8 +1213,7 @@ Don't narrate future actions ("Let me...") - just use the tools. if let usage = totalTokens { messages[index].tokens = usage.completionTokens if let model = selectedModel { - let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil + let cost = Self.resolveCost(usage: usage, pricing: model.pricing) messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) } @@ -1859,8 +1857,7 @@ Don't narrate future actions ("Let me...") - just use the tools. if willAutoContinue && finalContent.isEmpty { // Nothing worth showing yet — still record usage/cost for this turn. if let usage = totalUsage, let model = selectedModel { - let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil + let cost = Self.resolveCost(usage: usage, pricing: model.pricing) sessionStats.addMessage( inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, @@ -1884,8 +1881,7 @@ Don't narrate future actions ("Let me...") - just use the tools. // Calculate cost if let usage = totalUsage, let model = selectedModel { - let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil + let cost = Self.resolveCost(usage: usage, pricing: model.pricing) if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) { messages[index].cost = cost } @@ -2604,6 +2600,16 @@ Don't narrate future actions ("Let me...") - just use the tools. return inputCost + cacheReadCost + cacheWriteCost + outputCost } + /// Resolves a response's cost, preferring the provider's actual billed amount + /// (`usage.rawCostUSD` — e.g. OpenRouter's `usage.include` cost, needed for models priced + /// outside plain per-token rates like per-image generation) over token-based calculation. + /// Falls back to `nil` when neither the raw cost nor per-token pricing is available. + nonisolated static func resolveCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double? { + if let raw = usage.rawCostUSD { return raw } + guard pricing.prompt > 0 || pricing.completion > 0 else { return nil } + return calculateCost(usage: usage, pricing: pricing) + } + /// Summarize a chunk of messages into a concise summary private func summarizeMessageChunk(_ messages: [Message]) async -> String? { guard let provider = providerRegistry.getProvider(for: currentProvider), diff --git a/oAITests/ChatViewModelPureLogicTests.swift b/oAITests/ChatViewModelPureLogicTests.swift index 6df9387..aecf522 100644 --- a/oAITests/ChatViewModelPureLogicTests.swift +++ b/oAITests/ChatViewModelPureLogicTests.swift @@ -82,6 +82,36 @@ struct ChatViewModelPureLogicTests { #expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.0) } + // MARK: - resolveCost + + @Test("Raw provider-billed cost wins over token-based pricing when present") + func resolveCostPrefersRawCostUSD() { + let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000, rawCostUSD: 0.19) + let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0) + #expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == 0.19) + } + + @Test("Raw cost of zero is trusted, not treated as missing") + func resolveCostTrustsZeroRawCost() { + let usage = ChatResponse.Usage(promptTokens: 0, completionTokens: 0, totalTokens: 0, rawCostUSD: 0.0) + let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0) + #expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == 0.0) + } + + @Test("Falls back to token-based pricing when no raw cost is reported") + func resolveCostFallsBackToCalculation() { + let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000) + let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0) + #expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == 18.0) + } + + @Test("Returns nil when neither raw cost nor per-token pricing is available") + func resolveCostNilWhenNoDataAvailable() { + let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000) + let pricing = ModelInfo.Pricing(prompt: 0, completion: 0) + #expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == nil) + } + // MARK: - draftFingerprint @Test("Identical message content produces the same fingerprint")