diff --git a/oAI/Services/EmbeddingService.swift b/oAI/Services/EmbeddingService.swift index 5d523e8..f096bc5 100644 --- a/oAI/Services/EmbeddingService.swift +++ b/oAI/Services/EmbeddingService.swift @@ -348,7 +348,7 @@ final class EmbeddingService { // MARK: - Serialization /// 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) for value in embedding { var littleEndian = value.bitPattern.littleEndian @@ -360,7 +360,7 @@ final class EmbeddingService { } /// Deserialize embedding from binary data - func deserializeEmbedding(_ data: Data) -> [Float] { + nonisolated func deserializeEmbedding(_ data: Data) -> [Float] { var embedding: [Float] = [] embedding.reserveCapacity(data.count / 4) diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 0561643..1eebbe1 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -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) } - 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") if modelId.contains("/") { return .openrouter } // 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 - static func detectGoodbyePhrase(in text: String) -> Bool { + nonisolated static func detectGoodbyePhrase(in text: String) -> Bool { let lowercased = text.lowercased() let goodbyePhrases = [ "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 /// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder — /// 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 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 diff --git a/oAITests/AnthropicProviderTests.swift b/oAITests/AnthropicProviderTests.swift new file mode 100644 index 0000000..5aa54ee --- /dev/null +++ b/oAITests/AnthropicProviderTests.swift @@ -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) + } +} diff --git a/oAITests/ChatViewModelPureLogicTests.swift b/oAITests/ChatViewModelPureLogicTests.swift new file mode 100644 index 0000000..f577f6a --- /dev/null +++ b/oAITests/ChatViewModelPureLogicTests.swift @@ -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) + } +} diff --git a/oAITests/ContextSelectionServiceTests.swift b/oAITests/ContextSelectionServiceTests.swift new file mode 100644 index 0000000..de96c0e --- /dev/null +++ b/oAITests/ContextSelectionServiceTests.swift @@ -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) + } +} diff --git a/oAITests/EmbeddingServiceTests.swift b/oAITests/EmbeddingServiceTests.swift new file mode 100644 index 0000000..f135ffd --- /dev/null +++ b/oAITests/EmbeddingServiceTests.swift @@ -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) + } +} diff --git a/oAITests/GitSyncServiceTests.swift b/oAITests/GitSyncServiceTests.swift new file mode 100644 index 0000000..e5e7b2d --- /dev/null +++ b/oAITests/GitSyncServiceTests.swift @@ -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\"gi|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")) + } +} diff --git a/oAITests/OllamaProviderTests.swift b/oAITests/OllamaProviderTests.swift new file mode 100644 index 0000000..b3a758f --- /dev/null +++ b/oAITests/OllamaProviderTests.swift @@ -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) + } +} diff --git a/oAITests/OpenAIProviderTests.swift b/oAITests/OpenAIProviderTests.swift new file mode 100644 index 0000000..46c1f85 --- /dev/null +++ b/oAITests/OpenAIProviderTests.swift @@ -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()) + } +} diff --git a/oAITests/OpenRouterProviderTests.swift b/oAITests/OpenRouterProviderTests.swift new file mode 100644 index 0000000..ff24c4b --- /dev/null +++ b/oAITests/OpenRouterProviderTests.swift @@ -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) + } +}