Files
oai-swift/oAITests/ChatViewModelPureLogicTests.swift
rune 1e0e81b9bc 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).
2026-08-11 10:55:52 +02:00

190 lines
8.0 KiB
Swift

//
// ChatViewModelPureLogicTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
@testable import Confab
@Suite("ChatViewModel pure static helpers")
struct ChatViewModelPureLogicTests {
// MARK: - inferProvider
@Test("Model ID with a slash is inferred as OpenRouter")
func infersOpenRouter() {
#expect(ChatViewModel.inferProvider(from: "anthropic/claude-3-5-sonnet") == .openrouter)
}
@Test("claude- prefixed model is inferred as direct Anthropic")
func infersAnthropic() {
#expect(ChatViewModel.inferProvider(from: "claude-sonnet-4-5-20250929") == .anthropic)
}
@Test("gpt-/o1/o3/dall-e/chatgpt prefixed models are inferred as OpenAI")
func infersOpenAI() {
#expect(ChatViewModel.inferProvider(from: "gpt-4o") == .openai)
#expect(ChatViewModel.inferProvider(from: "o1-preview") == .openai)
#expect(ChatViewModel.inferProvider(from: "o3-mini") == .openai)
#expect(ChatViewModel.inferProvider(from: "dall-e-3") == .openai)
#expect(ChatViewModel.inferProvider(from: "chatgpt-4o-latest") == .openai)
}
@Test("A bare local model name with no recognized prefix falls back to Ollama")
func fallsBackToOllama() {
#expect(ChatViewModel.inferProvider(from: "llama3.2") == .ollama)
}
@Test("apple- prefixed model is inferred as Apple on-device")
func infersAppleOnDevice() {
#expect(ChatViewModel.inferProvider(from: "apple-on-device") == .appleOnDevice)
}
// MARK: - calculateCost
@Test("Base prompt and completion cost with no cache usage")
func calculatesBaseCost() {
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.calculateCost(usage: usage, pricing: pricing) == 18.0)
}
@Test("Cache read tokens are charged at 0.1x the prompt rate")
func calculatesCacheReadCost() {
let usage = ChatResponse.Usage(
promptTokens: 0,
completionTokens: 0,
totalTokens: 1_000_000,
cacheReadInputTokens: 1_000_000
)
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.3)
}
@Test("Cache write tokens are charged at 1.25x the prompt rate")
func calculatesCacheWriteCost() {
let usage = ChatResponse.Usage(
promptTokens: 0,
completionTokens: 0,
totalTokens: 1_000_000,
cacheCreationInputTokens: 1_000_000
)
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 3.75)
}
@Test("Zero usage produces zero cost")
func zeroUsageIsZeroCost() {
let usage = ChatResponse.Usage(promptTokens: 0, completionTokens: 0, totalTokens: 0)
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.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
@Test("Identical message content produces the same fingerprint")
func draftFingerprintStableForSameContent() {
let messages = [
Message(role: .user, content: "Hello there"),
Message(role: .assistant, content: "Hi! How can I help?")
]
#expect(ChatViewModel.draftFingerprint(for: messages) == ChatViewModel.draftFingerprint(for: messages))
}
@Test("Changed message content produces a different fingerprint")
func draftFingerprintChangesWithContent() {
let before = [Message(role: .user, content: "Hello there")]
let after = [Message(role: .user, content: "Hello there"), Message(role: .assistant, content: "Hi!")]
#expect(ChatViewModel.draftFingerprint(for: before) != ChatViewModel.draftFingerprint(for: after))
}
@Test("Empty message list has a stable fingerprint")
func draftFingerprintEmptyIsStable() {
#expect(ChatViewModel.draftFingerprint(for: []) == ChatViewModel.draftFingerprint(for: []))
}
// MARK: - buildNotesSection
@Test("Nil body produces no notes section at all")
func buildNotesSectionNilBodyIsEmpty() {
#expect(ChatViewModel.buildNotesSection(body: nil) == "")
}
@Test("Empty body still produces a section, with a placeholder for the notes content")
func buildNotesSectionEmptyBodyShowsPlaceholder() {
let section = ChatViewModel.buildNotesSection(body: "")
#expect(section.contains("## Conversation Notes"))
#expect(section.contains("nothing saved yet"))
}
@Test("Non-empty body is included verbatim")
func buildNotesSectionIncludesBody() {
let section = ChatViewModel.buildNotesSection(body: "User prefers dark roast coffee.")
#expect(section.contains("User prefers dark roast coffee."))
}
// MARK: - extractNotesUpdate
@Test("No fenced block leaves content untouched and returns no notes body")
func extractNotesUpdateNoBlock() {
let (display, body) = ChatViewModel.extractNotesUpdate(from: "Just a normal reply.")
#expect(display == "Just a normal reply.")
#expect(body == nil)
}
@Test("A fenced update-notes block is stripped from the display text and its body extracted")
func extractNotesUpdateStripsBlock() {
let content = "Sure, noted!\n\n```update-notes\nUser prefers dark roast coffee.\n```"
let (display, body) = ChatViewModel.extractNotesUpdate(from: content)
#expect(display == "Sure, noted!")
#expect(body == "User prefers dark roast coffee.")
}
@Test("Text surrounding the fenced block on both sides is preserved")
func extractNotesUpdatePreservesSurroundingText() {
let content = "Before text.\n```update-notes\nRemember this.\n```\nAfter text."
let (display, body) = ChatViewModel.extractNotesUpdate(from: content)
#expect(display == "Before text.\n\nAfter text.")
#expect(body == "Remember this.")
}
@Test("A block with the language tag but an empty body extracts an empty string, not nil")
func extractNotesUpdateEmptyBody() {
let content = "```update-notes\n```"
let (_, body) = ChatViewModel.extractNotesUpdate(from: content)
#expect(body == "")
}
}