The oAITests PBXNativeTarget (PBXContainerItemProxy, PBXTargetDependency,
XCBuildConfiguration with TEST_HOST/BUNDLE_LOADER, the "recommended
settings" changes accepted when the target was created -- DEAD_CODE_STRIPPING,
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED, STRING_CATALOG_GENERATE_SYMBOLS,
DEVELOPMENT_TEAM moved to project-level inheritance) was somehow never
actually staged in the very first "Add real oAITests target" commit
(8c7fb59) despite every xcodebuild test run since then depending on it
being present on disk. Every subsequent commit this session only staged
specific file paths (never oAI.xcodeproj again), so the gap went
unnoticed until a full `git status` review here.
Without this, anyone else pulling the branch (or a truly clean checkout
on this machine) would have all the .swift test files but no target to
compile them into -- xcodebuild test would fail to find oAITests at all.
Confirmed the diff is exactly the expected target-wiring content, nothing
unrelated or corrupted, before committing.
208 lines
9.1 KiB
Swift
208 lines
9.1 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|