// // 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) } // MARK: - resolveFallbackPricing @Test("Exact-length single prefix match returns that tier's pricing") func fallbackPricingSingleMatch() { let pricing = AnthropicProvider.resolveFallbackPricing(for: "claude-haiku-4-5-20251001") #expect(pricing.prompt == 0.80) #expect(pricing.completion == 4.0) } @Test("Longest matching prefix wins when multiple prefixes could match") func fallbackPricingLongestPrefixWins() { let fallback: [(prefix: String, prompt: Double, completion: Double)] = [ ("claude", 1.0, 2.0), ("claude-sonnet", 3.0, 15.0), ] let pricing = AnthropicProvider.resolveFallbackPricing(for: "claude-sonnet-5", fallback: fallback) #expect(pricing.prompt == 3.0) #expect(pricing.completion == 15.0) } @Test("An unmatched model ID prices at zero rather than guessing") func fallbackPricingNoMatch() { let pricing = AnthropicProvider.resolveFallbackPricing(for: "some-future-unknown-model") #expect(pricing.prompt == 0) #expect(pricing.completion == 0) } }