Fix $0.00 cost on OpenRouter image generation models

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).
This commit is contained in:
2026-08-11 10:55:52 +02:00
parent ef6265c1c5
commit 1e0e81b9bc
4 changed files with 64 additions and 12 deletions
+12
View File
@@ -47,11 +47,18 @@ struct OpenRouterChatRequest: Codable {
let modalities: [String]? let modalities: [String]?
let reasoning: ReasoningAPIConfig? let reasoning: ReasoningAPIConfig?
let cacheControl: CacheControl? let cacheControl: CacheControl?
let usage: UsageOptions?
struct CacheControl: Codable { struct CacheControl: Codable {
let type: String 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 { struct APIMessage: Codable {
let role: String let role: String
let content: MessageContent let content: MessageContent
@@ -141,6 +148,7 @@ struct OpenRouterChatRequest: Codable {
case toolChoice = "tool_choice" case toolChoice = "tool_choice"
case modalities case modalities
case reasoning case reasoning
case usage
case cacheControl = "cache_control" case cacheControl = "cache_control"
} }
} }
@@ -230,6 +238,9 @@ struct OpenRouterChatResponse: Codable {
let completionTokens: Int let completionTokens: Int
let totalTokens: Int let totalTokens: Int
let promptTokensDetails: PromptTokensDetails? 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 { struct PromptTokensDetails: Codable {
let cachedTokens: Int? let cachedTokens: Int?
@@ -246,6 +257,7 @@ struct OpenRouterChatResponse: Codable {
case completionTokens = "completion_tokens" case completionTokens = "completion_tokens"
case totalTokens = "total_tokens" case totalTokens = "total_tokens"
case promptTokensDetails = "prompt_tokens_details" case promptTokensDetails = "prompt_tokens_details"
case cost
} }
} }
} }
+8 -4
View File
@@ -276,7 +276,8 @@ class OpenRouterProvider: AIProvider {
var body: [String: Any] = [ var body: [String: Any] = [
"model": model, "model": model,
"messages": messages, "messages": messages,
"stream": false "stream": false,
"usage": ["include": true]
] ]
if let tools = tools { if let tools = tools {
let toolsData = try JSONEncoder().encode(tools) let toolsData = try JSONEncoder().encode(tools)
@@ -505,7 +506,8 @@ class OpenRouterProvider: AIProvider {
toolChoice: request.tools != nil ? "auto" : nil, toolChoice: request.tools != nil ? "auto" : nil,
modalities: request.imageGeneration ? ["text", "image"] : nil, modalities: request.imageGeneration ? ["text", "image"] : nil,
reasoning: reasoningConfig, reasoning: reasoningConfig,
cacheControl: cacheControl cacheControl: cacheControl,
usage: OpenRouterChatRequest.UsageOptions(include: true)
) )
} }
@@ -544,7 +546,8 @@ class OpenRouterProvider: AIProvider {
completionTokens: usage.completionTokens, completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens, totalTokens: usage.totalTokens,
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens, cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens,
rawCostUSD: usage.cost
) )
}, },
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)), created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
@@ -585,7 +588,8 @@ class OpenRouterProvider: AIProvider {
completionTokens: usage.completionTokens, completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens, totalTokens: usage.totalTokens,
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens, cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens,
rawCostUSD: usage.cost
) )
} }
) )
+14 -8
View File
@@ -1150,8 +1150,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
if let usage = response.usage { if let usage = response.usage {
messages[index].tokens = usage.completionTokens messages[index].tokens = usage.completionTokens
if let model = selectedModel { if let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
messages[index].cost = cost messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, 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 { if let usage = totalTokens {
messages[index].tokens = usage.completionTokens messages[index].tokens = usage.completionTokens
if let model = selectedModel { if let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
messages[index].cost = cost messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, 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 { if willAutoContinue && finalContent.isEmpty {
// Nothing worth showing yet still record usage/cost for this turn. // Nothing worth showing yet still record usage/cost for this turn.
if let usage = totalUsage, let model = selectedModel { if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
sessionStats.addMessage( sessionStats.addMessage(
inputTokens: usage.promptTokens, inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens, outputTokens: usage.completionTokens,
@@ -1884,8 +1881,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Calculate cost // Calculate cost
if let usage = totalUsage, let model = selectedModel { if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) { if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
messages[index].cost = cost messages[index].cost = cost
} }
@@ -2604,6 +2600,16 @@ Don't narrate future actions ("Let me...") - just use the tools.
return inputCost + cacheReadCost + cacheWriteCost + outputCost 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 /// Summarize a chunk of messages into a concise summary
private func summarizeMessageChunk(_ messages: [Message]) async -> String? { private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
guard let provider = providerRegistry.getProvider(for: currentProvider), guard let provider = providerRegistry.getProvider(for: currentProvider),
@@ -82,6 +82,36 @@ struct ChatViewModelPureLogicTests {
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.0) #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 // MARK: - draftFingerprint
@Test("Identical message content produces the same fingerprint") @Test("Identical message content produces the same fingerprint")