Phase 2: tests for the provider layer and other exposed pure helpers

66 new tests across 8 files (93 total in the suite now):

- GitSyncServiceTests: convertToSSH/injectCredentials/sanitizeFilename/
  detectSecretsInText, including a below-threshold false-positive check
  on the secret regex.
- ChatViewModelPureLogicTests: detectGoodbyePhrase, inferProvider,
  calculateCost -- including the cache-read (0.1x) / cache-write (1.25x)
  pricing multipliers, which is real billing-affecting logic.
- EmbeddingServiceTests / ContextSelectionServiceTests: embedding
  (de)serialization round-trip, importance-score weighting, and the
  token-estimate fallback (content.count / 4) when no real count exists.
- OpenRouterProviderTests / OllamaProviderTests / OpenAIProviderTests /
  AnthropicProviderTests: request-building (attachments, online mode,
  cache_control breakpoints, o1/o3 temperature omission, tool schema
  conversion) and response-parsing (text, tool_use blocks, empty-choices
  fallback behavior) for all four providers, with no network involved.

Also marks calculateCost/inferProvider/detectGoodbyePhrase (ChatViewModel)
and serializeEmbedding/deserializeEmbedding (EmbeddingService) as
`nonisolated` -- discovered via the actual test failures, not
speculation: the project's `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`
setting isolates the classes that carry an explicit `@MainActor`
(ChatViewModel), so calling their static members from a plain
synchronous @Test needs the pure ones marked `nonisolated`. Matches
the existing convention already used elsewhere in EmbeddingService
(cosineSimilarity was already nonisolated before this change).

Phase 2 of the test-suite rollout plan (peaceful-baking-kurzweil).
This commit is contained in:
2026-07-23 14:11:50 +02:00
parent f39527b1d8
commit a053d4a983
10 changed files with 758 additions and 5 deletions
+2 -2
View File
@@ -348,7 +348,7 @@ final class EmbeddingService {
// MARK: - Serialization // MARK: - Serialization
/// Serialize embedding to binary data (4 bytes per float, little-endian) /// Serialize embedding to binary data (4 bytes per float, little-endian)
func serializeEmbedding(_ embedding: [Float]) -> Data { nonisolated func serializeEmbedding(_ embedding: [Float]) -> Data {
var data = Data(capacity: embedding.count * 4) var data = Data(capacity: embedding.count * 4)
for value in embedding { for value in embedding {
var littleEndian = value.bitPattern.littleEndian var littleEndian = value.bitPattern.littleEndian
@@ -360,7 +360,7 @@ final class EmbeddingService {
} }
/// Deserialize embedding from binary data /// Deserialize embedding from binary data
func deserializeEmbedding(_ data: Data) -> [Float] { nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
var embedding: [Float] = [] var embedding: [Float] = []
embedding.reserveCapacity(data.count / 4) embedding.reserveCapacity(data.count / 4)
+3 -3
View File
@@ -421,7 +421,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
func inferProviderPublic(from modelId: String) -> Settings.Provider? { Self.inferProvider(from: modelId) } func inferProviderPublic(from modelId: String) -> Settings.Provider? { Self.inferProvider(from: modelId) }
static func inferProvider(from modelId: String) -> Settings.Provider? { nonisolated static func inferProvider(from modelId: String) -> Settings.Provider? {
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet") // OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
if modelId.contains("/") { return .openrouter } if modelId.contains("/") { return .openrouter }
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929") // Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
@@ -2038,7 +2038,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
} }
/// Detect goodbye phrases in user message /// Detect goodbye phrases in user message
static func detectGoodbyePhrase(in text: String) -> Bool { nonisolated static func detectGoodbyePhrase(in text: String) -> Bool {
let lowercased = text.lowercased() let lowercased = text.lowercased()
let goodbyePhrases = [ let goodbyePhrases = [
"bye", "goodbye", "bye bye", "good bye", "bye", "goodbye", "bye bye", "good bye",
@@ -2259,7 +2259,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
/// pricing when present: cache writes cost 1.25x the base input rate, cache /// pricing when present: cache writes cost 1.25x the base input rate, cache
/// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder /// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder
/// it does not need cache tokens subtracted from it. /// it does not need cache tokens subtracted from it.
static func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double { nonisolated static func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double {
let inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000 let inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000
let cacheReadCost = Double(usage.cacheReadInputTokens ?? 0) * pricing.prompt * 0.1 / 1_000_000 let cacheReadCost = Double(usage.cacheReadInputTokens ?? 0) * pricing.prompt * 0.1 / 1_000_000
let cacheWriteCost = Double(usage.cacheCreationInputTokens ?? 0) * pricing.prompt * 1.25 / 1_000_000 let cacheWriteCost = Double(usage.cacheCreationInputTokens ?? 0) * pricing.prompt * 1.25 / 1_000_000
+180
View File
@@ -0,0 +1,180 @@
//
// AnthropicProviderTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import oAI
@Suite("AnthropicProvider request/response conversion")
struct AnthropicProviderTests {
private var provider: AnthropicProvider { AnthropicProvider(apiKey: "test-key") }
private func decodeBody(_ urlRequest: URLRequest) throws -> [String: Any] {
try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
}
// MARK: - buildURLRequest
@Test("System messages are pulled out of the message list into a top-level system field")
func systemMessageExtracted() throws {
let messages = [
Message(role: .system, content: "be concise"),
Message(role: .user, content: "hi")
]
let request = ChatRequest(messages: messages, model: "claude-sonnet-4-5")
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
let body = try decodeBody(urlRequest)
let apiMessages = body["messages"] as? [[String: Any]]
#expect(apiMessages?.count == 1) // system message removed from the array
#expect(apiMessages?.first?["role"] as? String == "user")
let system = body["system"] as? [[String: Any]]
#expect(system?.first?["text"] as? String == "be concise")
let cacheControl = system?.first?["cache_control"] as? [String: String]
#expect(cacheControl?["type"] == "ephemeral")
}
@Test("The last message gets a cache_control breakpoint on its content block")
func lastMessageGetsCacheBreakpoint() throws {
let messages = [
Message(role: .user, content: "first"),
Message(role: .assistant, content: "second"),
Message(role: .user, content: "third")
]
let request = ChatRequest(messages: messages, model: "claude-sonnet-4-5")
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
let body = try decodeBody(urlRequest)
let apiMessages = body["messages"] as? [[String: Any]]
// Earlier messages keep plain string content (no cache breakpoint).
#expect(apiMessages?[0]["content"] as? String == "first")
#expect(apiMessages?[1]["content"] as? String == "second")
// The last message's content becomes an array with a cache_control block.
let lastContent = apiMessages?[2]["content"] as? [[String: Any]]
#expect(lastContent?.first?["text"] as? String == "third")
let cacheControl = lastContent?.first?["cache_control"] as? [String: String]
#expect(cacheControl?["type"] == "ephemeral")
}
@Test("max_tokens defaults to 16000 when not specified on the request")
func defaultMaxTokens() throws {
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "claude-sonnet-4-5")
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
let body = try decodeBody(urlRequest)
#expect(body["max_tokens"] as? Int == 16000)
}
@Test("Online mode adds a web_search tool even with no explicit tools requested")
func onlineModeAddsWebSearchTool() throws {
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "claude-sonnet-4-5", onlineMode: true)
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
let body = try decodeBody(urlRequest)
let tools = body["tools"] as? [[String: Any]]
#expect(tools?.first?["name"] as? String == "web_search")
// tool_choice is only added when the caller supplied explicit tools.
#expect(body["tool_choice"] == nil)
}
@Test("Empty message content is replaced with a placeholder rather than sent blank")
func emptyContentGetsPlaceholder() throws {
let request = ChatRequest(messages: [Message(role: .user, content: " ")], model: "claude-sonnet-4-5")
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
let body = try decodeBody(urlRequest)
let apiMessages = body["messages"] as? [[String: Any]]
// This is also the last message, so it gets wrapped in a cache-control array.
let content = apiMessages?.first?["content"] as? [[String: Any]]
#expect(content?.first?["text"] as? String == "[Image]")
}
// MARK: - parseResponse
@Test("Parses text content, usage, and stop_reason from a well-formed response")
func parsesWellFormedResponse() throws {
let json: [String: Any] = [
"id": "msg_123",
"model": "claude-sonnet-4-5",
"content": [["type": "text", "text": "hello there"]],
"stop_reason": "end_turn",
"usage": ["input_tokens": 10, "output_tokens": 5]
]
let data = try JSONSerialization.data(withJSONObject: json)
let response = try provider.parseResponse(data: data)
#expect(response.id == "msg_123")
#expect(response.content == "hello there")
#expect(response.finishReason == "end_turn")
#expect(response.usage?.promptTokens == 10)
#expect(response.usage?.completionTokens == 5)
#expect(response.toolCalls == nil)
}
@Test("Parses a tool_use block into a ToolCallInfo with JSON-encoded arguments")
func parsesToolUseBlock() throws {
let json: [String: Any] = [
"id": "msg_1",
"model": "claude-sonnet-4-5",
"content": [
["type": "tool_use", "id": "tool_1", "name": "get_weather", "input": ["city": "Oslo"]]
],
"usage": ["input_tokens": 1, "output_tokens": 1]
]
let data = try JSONSerialization.data(withJSONObject: json)
let response = try provider.parseResponse(data: data)
#expect(response.toolCalls?.count == 1)
#expect(response.toolCalls?.first?.functionName == "get_weather")
#expect(response.toolCalls?.first?.arguments.contains("Oslo") == true)
}
@Test("Throws invalidResponse for non-JSON data")
func throwsOnInvalidJSON() {
let data = "not json".data(using: .utf8)!
#expect(throws: (any Error).self) {
try provider.parseResponse(data: data)
}
}
// MARK: - convertParametersToDict
@Test("Converts tool parameters to Anthropic's input_schema dict shape")
func convertsParametersToInputSchema() {
let params = Tool.Function.Parameters(
type: "object",
properties: [
"city": Tool.Function.Parameters.Property(type: "string", description: "City name"),
"unit": Tool.Function.Parameters.Property(type: "string", description: "Unit", enum: ["celsius", "fahrenheit"])
],
required: ["city"]
)
let dict = provider.convertParametersToDict(params)
#expect(dict["type"] as? String == "object")
#expect(dict["required"] as? [String] == ["city"])
let properties = dict["properties"] as? [String: Any]
let cityProp = properties?["city"] as? [String: Any]
#expect(cityProp?["type"] as? String == "string")
let unitProp = properties?["unit"] as? [String: Any]
#expect(unitProp?["enum"] as? [String] == ["celsius", "fahrenheit"])
}
@Test("Array-typed properties include a nested items schema")
func convertsArrayItemsSchema() {
let params = Tool.Function.Parameters(
type: "object",
properties: [
"tags": Tool.Function.Parameters.Property(type: "array", description: "Tags", items: .init(type: "string"))
],
required: nil
)
let dict = provider.convertParametersToDict(params)
let properties = dict["properties"] as? [String: Any]
let tagsProp = properties?["tags"] as? [String: Any]
let items = tagsProp?["items"] as? [String: Any]
#expect(items?["type"] as? String == "string")
#expect(dict["required"] == nil)
}
}
+106
View File
@@ -0,0 +1,106 @@
//
// ChatViewModelPureLogicTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
@testable import oAI
@Suite("ChatViewModel pure static helpers")
struct ChatViewModelPureLogicTests {
// MARK: - detectGoodbyePhrase
@Test("Recognizes a clear farewell phrase")
func detectsGoodbye() {
#expect(ChatViewModel.detectGoodbyePhrase(in: "OK, bye!"))
#expect(ChatViewModel.detectGoodbyePhrase(in: "That's all, thanks."))
#expect(ChatViewModel.detectGoodbyePhrase(in: "Have a good day"))
}
@Test("Does not false-positive on a word that merely contains a phrase as a substring")
func doesNotMatchSubstring() {
// "bye" is a whole-word match target -- "goodbyeee" should not match "bye"
// as a substring because of the \b word-boundary regex.
#expect(!ChatViewModel.detectGoodbyePhrase(in: "goodbyeee is not a real word"))
}
@Test("Does not match ordinary polite phrases that aren't farewells")
func doesNotMatchPoliteRequests() {
#expect(!ChatViewModel.detectGoodbyePhrase(in: "Thanks, that's helpful!"))
#expect(!ChatViewModel.detectGoodbyePhrase(in: "Done with step one, what's next?"))
}
@Test("Matching is case-insensitive")
func matchIsCaseInsensitive() {
#expect(ChatViewModel.detectGoodbyePhrase(in: "BYE BYE"))
}
// 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)
}
// 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)
}
}
@@ -0,0 +1,67 @@
//
// ContextSelectionServiceTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
@testable import oAI
@Suite("ContextSelectionService pure scoring helpers")
struct ContextSelectionServiceTests {
private var service: ContextSelectionService { ContextSelectionService.shared }
// MARK: - getImportanceScore
@Test("A short message with no cost or token data scores from length alone")
func lowScoreForPlainShortMessage() {
let message = Message(role: .user, content: "hi")
#expect(service.getImportanceScore(message) < 0.01)
}
@Test("Cost, length, and token factors combine to the exact weighted sum")
func combinesWeightedFactors() {
// cost 0.005 / 0.01 = 0.5 -> * 0.5 weight = 0.25
// 1000 chars / 2000 = 0.5 -> * 0.3 weight = 0.15
// 500 tokens / 1000 = 0.5 -> * 0.2 weight = 0.1
// total = 0.5
let message = Message(role: .assistant, content: String(repeating: "a", count: 1000), tokens: 500, cost: 0.005)
#expect(abs(service.getImportanceScore(message) - 0.5) < 0.0001)
}
@Test("Score is capped at 1.0 even when every factor maxes out and would otherwise overflow it")
func scoreIsCappedAtOne() {
let message = Message(
role: .assistant,
content: String(repeating: "a", count: 5000), // well past the 2000-char max
tokens: 5000, // well past the 1000-token max
cost: 1.0 // well past the $0.01 max
)
#expect(service.getImportanceScore(message) == 1.0)
}
// MARK: - estimateTokens
@Test("Uses the message's actual token count when present")
func usesActualTokenCountWhenAvailable() {
let messages = [Message(role: .user, content: "irrelevant for this test", tokens: 42)]
#expect(service.estimateTokens(messages) == 42)
}
@Test("Falls back to content.count / 4 when tokens is nil")
func fallsBackToCharacterEstimate() {
let messages = [Message(role: .user, content: String(repeating: "x", count: 40))]
#expect(service.estimateTokens(messages) == 10)
}
@Test("Sums estimates across a mix of messages with and without token counts")
func sumsAcrossMixedMessages() {
let messages = [
Message(role: .user, content: "ignored", tokens: 100),
Message(role: .assistant, content: String(repeating: "y", count: 20)), // 20/4 = 5
]
#expect(service.estimateTokens(messages) == 105)
}
}
+35
View File
@@ -0,0 +1,35 @@
//
// EmbeddingServiceTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
@testable import oAI
@Suite("EmbeddingService serialization")
struct EmbeddingServiceTests {
@Test("Serialize then deserialize round-trips an embedding exactly")
func roundTrip() {
let original: [Float] = [0.0, 1.0, -1.0, 3.14159, -0.0001, 1_000_000.5]
let data = EmbeddingService.shared.serializeEmbedding(original)
let restored = EmbeddingService.shared.deserializeEmbedding(data)
#expect(restored == original)
}
@Test("Serialized data is exactly 4 bytes per float")
func serializedSizeIsFourBytesPerFloat() {
let embedding: [Float] = Array(repeating: 0.5, count: 10)
let data = EmbeddingService.shared.serializeEmbedding(embedding)
#expect(data.count == 40)
}
@Test("Empty embedding serializes to empty data and back")
func emptyEmbedding() {
let data = EmbeddingService.shared.serializeEmbedding([])
#expect(data.isEmpty)
#expect(EmbeddingService.shared.deserializeEmbedding(data).isEmpty)
}
}
+94
View File
@@ -0,0 +1,94 @@
//
// GitSyncServiceTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
@testable import oAI
@Suite("GitSyncService pure helpers")
struct GitSyncServiceTests {
private var service: GitSyncService { GitSyncService.shared }
// MARK: - convertToSSH
@Test("Already-SSH URLs pass through unchanged")
func convertToSSHAlreadySSH() {
#expect(service.convertToSSH("git@github.com:user/repo.git") == "git@github.com:user/repo.git")
}
@Test("HTTPS URL converts to SSH form")
func convertToSSHFromHTTPS() {
#expect(service.convertToSSH("https://gitlab.pm/rune/oAI-Sync.git") == "git@gitlab.pm:rune/oAI-Sync.git")
}
@Test("HTTP URL converts to SSH form")
func convertToSSHFromHTTP() {
#expect(service.convertToSSH("http://example.com/user/repo.git") == "git@example.com:user/repo.git")
}
@Test("Malformed URL with no path segment is returned unchanged")
func convertToSSHMalformedNoSlash() {
#expect(service.convertToSSH("https://onlyhost") == "https://onlyhost")
}
@Test("Unknown scheme is returned unchanged")
func convertToSSHUnknownScheme() {
#expect(service.convertToSSH("ftp://example.com/repo") == "ftp://example.com/repo")
}
// MARK: - injectCredentials
@Test("Injects username and password into an HTTPS URL")
func injectCredentialsHTTPS() {
let result = service.injectCredentials("https://gitlab.pm/rune/repo.git", username: "rune", password: "secret")
#expect(result == "https://rune:secret@gitlab.pm/rune/repo.git")
}
@Test("Non-HTTPS URLs are returned unchanged (credentials not injected)")
func injectCredentialsNonHTTPS() {
let result = service.injectCredentials("git@gitlab.pm:rune/repo.git", username: "rune", password: "secret")
#expect(result == "git@gitlab.pm:rune/repo.git")
}
// MARK: - sanitizeFilename
@Test("Strips filesystem-invalid characters, replacing each with a dash")
func sanitizeFilenameStripsInvalidChars() {
#expect(service.sanitizeFilename("a/b\\c:d*e?f\"g<h>i|j") == "a-b-c-d-e-f-g-h-i-j")
}
@Test("Leaves an already-valid filename untouched")
func sanitizeFilenameValidInput() {
#expect(service.sanitizeFilename("My Chat 2026-01-15") == "My Chat 2026-01-15")
}
// MARK: - detectSecretsInText
@Test("Detects an OpenAI-style API key")
func detectSecretsOpenAIKey() {
let text = "here's my key: sk-abcdefghijklmnopqrstuvwxyz123456"
#expect(service.detectSecretsInText(text).contains("OpenAI Key"))
}
@Test("Detects a GitHub personal access token")
func detectSecretsGitHubToken() {
let text = "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890"
#expect(service.detectSecretsInText(text).contains("Access Token"))
}
@Test("Plain conversational text has no detected secrets")
func detectSecretsCleanText() {
#expect(service.detectSecretsInText("Just a normal conversation about the weather.").isEmpty)
}
@Test("Does not flag an OpenAI-shaped string one character short of the length threshold")
func detectSecretsJustBelowThreshold() {
// "sk-" + 31 chars = 34 total, but the pattern requires 32+ chars after "sk-"
let shortKey = "sk-" + String(repeating: "a", count: 31)
#expect(!service.detectSecretsInText(shortKey).contains("OpenAI Key"))
}
}
+79
View File
@@ -0,0 +1,79 @@
//
// OllamaProviderTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import oAI
@Suite("OllamaProvider request/response conversion")
struct OllamaProviderTests {
private var provider: OllamaProvider { OllamaProvider() }
// MARK: - buildRequestBody
@Test("Builds a basic request body with model, messages, and stream flag")
func basicRequestBody() {
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2")
let body = provider.buildRequestBody(from: request, stream: true)
#expect(body["model"] as? String == "llama3.2")
#expect(body["stream"] as? Bool == true)
let messages = body["messages"] as? [[String: Any]]
#expect(messages?.count == 1)
#expect(messages?.first?["role"] as? String == "user")
#expect(messages?.first?["content"] as? String == "hi")
}
@Test("System prompt is prepended as its own system-role message")
func systemPromptPrepended() {
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2", systemPrompt: "be concise")
let body = provider.buildRequestBody(from: request, stream: false)
let messages = body["messages"] as? [[String: Any]]
#expect(messages?.count == 2)
#expect(messages?.first?["role"] as? String == "system")
#expect(messages?.first?["content"] as? String == "be concise")
}
@Test("maxTokens and temperature are nested under options only when present")
func optionsOnlyWhenPresent() {
let withOptions = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2", maxTokens: 500, temperature: 0.7)
let bodyWithOptions = provider.buildRequestBody(from: withOptions, stream: false)
let options = bodyWithOptions["options"] as? [String: Any]
#expect(options?["num_predict"] as? Int == 500)
#expect(options?["temperature"] as? Double == 0.7)
let withoutOptions = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2")
let bodyWithoutOptions = provider.buildRequestBody(from: withoutOptions, stream: false)
#expect(bodyWithoutOptions["options"] == nil)
}
// MARK: - parseOllamaResponse
@Test("Parses content and token counts from a well-formed response")
func parsesWellFormedResponse() {
let json: [String: Any] = [
"message": ["content": "the answer"],
"prompt_eval_count": 10,
"eval_count": 20
]
let response = provider.parseOllamaResponse(json, model: "llama3.2")
#expect(response.content == "the answer")
#expect(response.role == "assistant")
#expect(response.finishReason == "stop")
#expect(response.usage?.promptTokens == 10)
#expect(response.usage?.completionTokens == 20)
#expect(response.usage?.totalTokens == 30)
}
@Test("Missing fields default to empty content and zero token counts")
func missingFieldsDefaultGracefully() {
let response = provider.parseOllamaResponse([:], model: "llama3.2")
#expect(response.content == "")
#expect(response.usage?.promptTokens == 0)
#expect(response.usage?.completionTokens == 0)
}
}
+89
View File
@@ -0,0 +1,89 @@
//
// OpenAIProviderTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import oAI
@Suite("OpenAIProvider request/response conversion")
struct OpenAIProviderTests {
private var provider: OpenAIProvider { OpenAIProvider(apiKey: "test-key") }
// MARK: - buildURLRequest
@Test("Sets the Authorization header and JSON content type")
func setsAuthAndContentTypeHeaders() throws {
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "gpt-4o")
let urlRequest = try provider.buildURLRequest(from: request, stream: false)
#expect(urlRequest.value(forHTTPHeaderField: "Authorization") == "Bearer test-key")
#expect(urlRequest.value(forHTTPHeaderField: "Content-Type") == "application/json")
#expect(urlRequest.value(forHTTPHeaderField: "Accept") == nil)
}
@Test("Streaming requests add an SSE Accept header and stream_options")
func streamingAddsAcceptHeaderAndUsageOption() throws {
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "gpt-4o")
let urlRequest = try provider.buildURLRequest(from: request, stream: true)
#expect(urlRequest.value(forHTTPHeaderField: "Accept") == "text/event-stream")
let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
let streamOptions = body["stream_options"] as? [String: Any]
#expect(streamOptions?["include_usage"] as? Bool == true)
}
@Test("o1/o3 reasoning models omit temperature even when one is requested")
func reasoningModelsOmitTemperature() throws {
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "o1-preview", temperature: 0.7)
let urlRequest = try provider.buildURLRequest(from: request, stream: false)
let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
#expect(body["temperature"] == nil)
}
@Test("Non-reasoning models include the requested temperature")
func nonReasoningModelsIncludeTemperature() throws {
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "gpt-4o", temperature: 0.7)
let urlRequest = try provider.buildURLRequest(from: request, stream: false)
let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
#expect(body["temperature"] as? Double == 0.7)
}
@Test("A message with an image attachment produces multi-part vision-format content")
func imageAttachmentProducesMultipartContent() throws {
let attachment = FileAttachment(path: "photo.png", type: .image, data: Data([0x01]))
let message = Message(role: .user, content: "look at this", attachments: [attachment])
let request = ChatRequest(messages: [message], model: "gpt-4o")
let urlRequest = try provider.buildURLRequest(from: request, stream: false)
let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
let messages = body["messages"] as? [[String: Any]]
let content = messages?.first?["content"] as? [[String: Any]]
#expect(content?.count == 2)
#expect(content?.first?["type"] as? String == "text")
#expect(content?.last?["type"] as? String == "image_url")
}
// MARK: - convertToChatResponse
@Test("Returns an empty-content response when there are no choices, rather than throwing")
func convertToChatResponseEmptyChoicesFallback() {
let apiResponse = OpenRouterChatResponse(id: "x", model: "m", choices: [], usage: nil, created: 0)
let response = provider.convertToChatResponse(apiResponse)
#expect(response.content == "")
#expect(response.role == "assistant")
}
// MARK: - fallbackModels
@Test("Fallback models are non-empty, all support tools, and are sorted by name")
func fallbackModelsAreWellFormed() {
let models = provider.fallbackModels()
#expect(!models.isEmpty)
#expect(models.allSatisfy { $0.capabilities.tools == true })
#expect(models.allSatisfy { $0.capabilities.online == false })
#expect(models.map(\.name) == models.map(\.name).sorted())
}
}
+103
View File
@@ -0,0 +1,103 @@
//
// OpenRouterProviderTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import oAI
@Suite("OpenRouterProvider request/response conversion")
struct OpenRouterProviderTests {
private var provider: OpenRouterProvider { OpenRouterProvider(apiKey: "test-key") }
// MARK: - buildAPIRequest
@Test("Plain text message with no attachments uses simple string content")
func buildRequestPlainText() throws {
let request = ChatRequest(messages: [Message(role: .user, content: "hello")], model: "openai/gpt-4o")
let apiRequest = try provider.buildAPIRequest(from: request)
guard case .string(let text) = apiRequest.messages.first?.content else {
Issue.record("Expected .string content for a message with no attachments")
return
}
#expect(text == "hello")
}
@Test("Message with an image attachment uses array content with a base64 data URL")
func buildRequestWithImageAttachment() throws {
let attachment = FileAttachment(path: "photo.png", type: .image, data: Data([0x01, 0x02]))
let message = Message(role: .user, content: "check this out", attachments: [attachment])
let request = ChatRequest(messages: [message], model: "openai/gpt-4o")
let apiRequest = try provider.buildAPIRequest(from: request)
guard case .array(let items) = apiRequest.messages.first?.content else {
Issue.record("Expected .array content for a message with attachments")
return
}
#expect(items.count == 2) // text + image
}
@Test("Online mode appends :online suffix, but not for image generation requests")
func onlineModeSuffix() throws {
let base = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o", onlineMode: true)
let request = try provider.buildAPIRequest(from: base)
#expect(request.model == "openai/gpt-4o:online")
let imageGenRequest = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o", onlineMode: true, imageGeneration: true)
let noSuffix = try provider.buildAPIRequest(from: imageGenRequest)
#expect(noSuffix.model == "openai/gpt-4o")
}
@Test("Online mode does not double-append :online if the model already has it")
func onlineModeNoDoubleSuffix() throws {
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o:online", onlineMode: true)
let apiRequest = try provider.buildAPIRequest(from: request)
#expect(apiRequest.model == "openai/gpt-4o:online")
}
@Test("Anthropic models get an explicit cache_control opt-in; others don't")
func cacheControlOnlyForAnthropic() throws {
let anthropicRequest = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "anthropic/claude-sonnet-4-5")
let anthropicAPI = try provider.buildAPIRequest(from: anthropicRequest)
#expect(anthropicAPI.cacheControl?.type == "ephemeral")
let openAIRequest = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o")
let openAIAPI = try provider.buildAPIRequest(from: openAIRequest)
#expect(openAIAPI.cacheControl == nil)
}
// MARK: - convertToChatResponse
@Test("Throws invalidResponse when there are no choices")
func convertToChatResponseThrowsOnEmptyChoices() {
let apiResponse = OpenRouterChatResponse(id: "x", model: "m", choices: [], usage: nil, created: 0)
#expect(throws: (any Error).self) {
try provider.convertToChatResponse(apiResponse)
}
}
// MARK: - decodeImageOutputs
@Test("Decodes a base64 data URL into raw Data")
func decodeImageOutputsValidDataURL() {
let payload = Data([0xDE, 0xAD, 0xBE, 0xEF])
let dataURL = "data:image/png;base64,\(payload.base64EncodedString())"
let output = OpenRouterChatResponse.ImageOutput(imageUrl: .init(url: dataURL))
let decoded = provider.decodeImageOutputs([output])
#expect(decoded?.first == payload)
}
@Test("Returns nil for an empty outputs array")
func decodeImageOutputsEmpty() {
#expect(provider.decodeImageOutputs([]) == nil)
}
@Test("Skips a malformed URL with no comma separator")
func decodeImageOutputsMalformedURL() {
let output = OpenRouterChatResponse.ImageOutput(imageUrl: .init(url: "not-a-data-url"))
#expect(provider.decodeImageOutputs([output]) == nil)
}
}