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:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user