Files
oai-swift/oAI/Providers/AnthropicProvider.swift
T
runeandClaude Sonnet 5 0af644456e Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:34:04 +02:00

769 lines
34 KiB
Swift

//
// AnthropicProvider.swift
// oAI
//
// Anthropic Messages API provider with SSE streaming and tool support
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import Foundation
import os
class AnthropicProvider: AIProvider {
let name = "Anthropic"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: true,
supportsTools: true,
supportsOnlineSearch: false,
maxContextLength: nil
)
enum AuthMode {
case apiKey(String)
case oauth
}
private let authMode: AuthMode
private let baseURL = "https://api.anthropic.com/v1"
private let apiVersion = "2023-06-01"
private let session: URLSession
/// Create with a standard API key
init(apiKey: String) {
self.authMode = .apiKey(apiKey)
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 180 // 3 minutes for initial response (tool use needs thinking time)
config.timeoutIntervalForResource = 600 // 10 minutes total
self.session = URLSession(configuration: config)
}
/// Create with OAuth (Pro/Max subscription)
init(oauth: Bool) {
self.authMode = .oauth
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 180 // 3 minutes for initial response (tool use needs thinking time)
config.timeoutIntervalForResource = 600 // 10 minutes total
self.session = URLSession(configuration: config)
}
/// Whether this provider is using OAuth authentication
var isOAuth: Bool {
if case .oauth = authMode { return true }
return false
}
// MARK: - Models
/// Local metadata used to enrich API results (pricing, context length) and as offline fallback.
/// Entries are matched by exact ID first; if no exact match is found, the enrichment step
/// falls back to prefix matching so newly-released model variants (e.g. "claude-sonnet-4-6-20260301")
/// still inherit the correct pricing tier.
private static let knownModels: [ModelInfo] = [
// Claude Fable 5
ModelInfo(
id: "claude-fable-5",
name: "Claude Fable 5",
description: "Anthropic's creative and storytelling model",
contextLength: 200_000,
pricing: .init(prompt: 10.0, completion: 50.0),
capabilities: .init(vision: true, tools: true, online: true)
),
// Claude 4.x series
ModelInfo(
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
description: "Most capable and intelligent model",
contextLength: 200_000,
pricing: .init(prompt: 15.0, completion: 75.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
description: "Best balance of speed and capability",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-haiku-4-6",
name: "Claude Haiku 4.6",
description: "Fastest and most affordable",
contextLength: 200_000,
pricing: .init(prompt: 0.80, completion: 4.0),
capabilities: .init(vision: true, tools: true, online: true)
),
// Claude 4.5 series
ModelInfo(
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
description: "Previous generation Opus",
contextLength: 200_000,
pricing: .init(prompt: 15.0, completion: 75.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5",
description: "Previous generation Opus",
contextLength: 200_000,
pricing: .init(prompt: 15.0, completion: 75.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
description: "Best balance of speed and capability",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
description: "Best balance of speed and capability",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-haiku-4-5",
name: "Claude Haiku 4.5",
description: "Fastest and most affordable",
contextLength: 200_000,
pricing: .init(prompt: 0.80, completion: 4.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-haiku-4-5-20251001",
name: "Claude Haiku 4.5",
description: "Fastest and most affordable",
contextLength: 200_000,
pricing: .init(prompt: 0.80, completion: 4.0),
capabilities: .init(vision: true, tools: true, online: true)
),
// Claude 3.x series
ModelInfo(
id: "claude-3-7-sonnet-20250219",
name: "Claude 3.7 Sonnet",
description: "Previous generation Sonnet",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-3-haiku-20240307",
name: "Claude 3 Haiku",
description: "Previous generation Haiku",
contextLength: 200_000,
pricing: .init(prompt: 0.25, completion: 1.25),
capabilities: .init(vision: true, tools: true, online: true)
),
]
/// Pricing tiers used for fuzzy fallback matching on unknown model IDs.
/// Keyed by model name prefix (longest match wins).
private static let pricingFallback: [(prefix: String, prompt: Double, completion: Double)] = [
("claude-fable", 10.0, 50.0),
("claude-opus", 15.0, 75.0),
("claude-sonnet", 3.0, 15.0),
("claude-haiku", 0.80, 4.0),
]
/// Fetch live model list from GET /v1/models, enriched with local pricing/context metadata.
/// Falls back to knownModels if the request fails (no key, offline, etc.).
func listModels() async throws -> [ModelInfo] {
guard let url = URL(string: "\(baseURL)/models") else { return Self.knownModels }
var request = URLRequest(url: url)
request.httpMethod = "GET"
do {
try await applyAuth(to: &request)
} catch {
Log.api.warning("Anthropic listModels: auth failed, using fallback — \(error.localizedDescription)")
return Self.knownModels
}
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
Log.api.warning("Anthropic listModels: HTTP \(code), using fallback")
return Self.knownModels
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let items = json["data"] as? [[String: Any]] else {
Log.api.warning("Anthropic listModels: unexpected JSON shape, using fallback")
return Self.knownModels
}
let enrichment = Dictionary(uniqueKeysWithValues: Self.knownModels.map { ($0.id, $0) })
let models: [ModelInfo] = items.compactMap { item in
guard let id = item["id"] as? String,
id.hasPrefix("claude-") else { return nil }
let displayName = item["display_name"] as? String ?? id
// Exact match first
if let known = enrichment[id] { return known }
// Fuzzy fallback: find the longest prefix that matches
let fallback = Self.pricingFallback
.filter { id.hasPrefix($0.prefix) }
.max(by: { $0.prefix.count < $1.prefix.count })
let pricing = fallback.map { ModelInfo.Pricing(prompt: $0.prompt, completion: $0.completion) }
?? ModelInfo.Pricing(prompt: 0, completion: 0)
return ModelInfo(
id: id,
name: displayName,
description: item["description"] as? String ?? "",
contextLength: 200_000,
pricing: pricing,
capabilities: .init(vision: true, tools: true, online: false)
)
}
Log.api.info("Anthropic listModels: fetched \(models.count) model(s) from API")
return models.isEmpty ? Self.knownModels : models
} catch {
Log.api.warning("Anthropic listModels: network error (\(error.localizedDescription)), using fallback")
return Self.knownModels
}
}
func getModel(_ id: String) async throws -> ModelInfo? {
let models = try await listModels()
return models.first { $0.id == id }
}
// MARK: - Chat Completion
func chat(request: ChatRequest) async throws -> ChatResponse {
Log.api.info("Anthropic chat request: model=\(request.model), messages=\(request.messages.count)")
var (urlRequest, _) = try buildURLRequest(from: request, stream: false)
try await applyAuth(to: &urlRequest)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Anthropic chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorObj["error"] as? [String: Any],
let message = error["message"] as? String {
Log.api.error("Anthropic chat HTTP \(httpResponse.statusCode): \(message)")
throw ProviderError.unknown(message)
}
Log.api.error("Anthropic chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
return try parseResponse(data: data)
}
// MARK: - Chat with raw tool messages
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
Log.api.info("Anthropic tool chat: model=\(model), messages=\(messages.count)")
let url = messagesURL
// Separate system message from conversation messages
var systemText: String? = nil
var conversationMessages: [[String: Any]] = []
for msg in messages {
let role = msg["role"] as? String ?? ""
if role == "system" {
systemText = msg["content"] as? String
} else if role == "tool" {
// Convert OpenAI tool result format to Anthropic tool_result format
let toolCallId = msg["tool_call_id"] as? String ?? ""
let content = msg["content"] as? String ?? ""
conversationMessages.append([
"role": "user",
"content": [
["type": "tool_result", "tool_use_id": toolCallId, "content": content]
]
])
} else if role == "assistant" {
// Check for tool_calls — convert to Anthropic content blocks
if let toolCalls = msg["tool_calls"] as? [[String: Any]], !toolCalls.isEmpty {
var contentBlocks: [[String: Any]] = []
if let text = msg["content"] as? String, !text.isEmpty {
contentBlocks.append(["type": "text", "text": text])
}
for tc in toolCalls {
let fn = tc["function"] as? [String: Any] ?? [:]
let name = fn["name"] as? String ?? ""
let argsStr = fn["arguments"] as? String ?? "{}"
let argsObj = (try? JSONSerialization.jsonObject(with: Data(argsStr.utf8))) ?? [:]
contentBlocks.append([
"type": "tool_use",
"id": tc["id"] as? String ?? UUID().uuidString,
"name": name,
"input": argsObj
])
}
conversationMessages.append(["role": "assistant", "content": contentBlocks])
} else {
conversationMessages.append(["role": "assistant", "content": msg["content"] as? String ?? ""])
}
} else {
// Content may be a String (plain text) or an Array (image/multipart blocks)
if let contentArray = msg["content"] as? [[String: Any]] {
// Convert image_url blocks (OpenAI format) to Anthropic image blocks
let anthropicBlocks: [[String: Any]] = contentArray.compactMap { block in
let type = block["type"] as? String ?? ""
if type == "text" {
return block
} else if type == "image_url",
let imageUrl = block["image_url"] as? [String: Any],
let url = imageUrl["url"] as? String,
url.hasPrefix("data:") {
// data:<mediaType>;base64,<data>
let withoutData = url.dropFirst(5) // strip "data:"
guard let semicolon = withoutData.firstIndex(of: ";"),
let comma = withoutData.firstIndex(of: ",") else { return nil }
let mediaType = String(withoutData[withoutData.startIndex..<semicolon])
let base64Data = String(withoutData[withoutData.index(after: comma)...])
return [
"type": "image",
"source": [
"type": "base64",
"media_type": mediaType,
"data": base64Data
]
]
}
return nil
}
if !anthropicBlocks.isEmpty {
conversationMessages.append(["role": role, "content": anthropicBlocks])
}
} else {
let content = (msg["content"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
conversationMessages.append(["role": role, "content": content.isEmpty ? "[Image]" : content])
}
}
}
// Mark the last message with a cache breakpoint so the next loop
// iteration (or next turn) can reuse everything up through this one.
if var lastMessage = conversationMessages.popLast() {
if let content = lastMessage["content"] as? String {
lastMessage["content"] = [["type": "text", "text": content, "cache_control": ["type": "ephemeral"]]]
} else if var blocks = lastMessage["content"] as? [[String: Any]], var lastBlock = blocks.popLast() {
lastBlock["cache_control"] = ["type": "ephemeral"]
blocks.append(lastBlock)
lastMessage["content"] = blocks
}
conversationMessages.append(lastMessage)
}
var body: [String: Any] = [
"model": model,
"messages": conversationMessages,
"max_tokens": maxTokens ?? 16000,
"stream": false
]
if let systemText = systemText {
// Array form carries a cache breakpoint; also covers tools, which
// render before system in Anthropic's prefix order.
body["system"] = [["type": "text", "text": systemText, "cache_control": ["type": "ephemeral"]]]
}
if let temperature = temperature {
body["temperature"] = temperature
}
if let tools = tools {
body["tools"] = tools.map { tool -> [String: Any] in
[
"name": tool.function.name,
"description": tool.function.description,
"input_schema": convertParametersToDict(tool.function.parameters)
]
}
body["tool_choice"] = ["type": "auto"]
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
try await applyAuth(to: &urlRequest)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Anthropic tool chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorObj["error"] as? [String: Any],
let message = error["message"] as? String {
Log.api.error("Anthropic tool chat HTTP \(httpResponse.statusCode): \(message)")
throw ProviderError.unknown(message)
}
Log.api.error("Anthropic tool chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
return try parseResponse(data: data)
}
// MARK: - Streaming Chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
Log.api.info("Anthropic stream request: model=\(request.model), messages=\(request.messages.count)")
return AsyncThrowingStream { continuation in
Task {
do {
var (urlRequest, _) = try buildURLRequest(from: request, stream: true)
try await self.applyAuth(to: &urlRequest)
let (bytes, response) = try await session.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Anthropic stream: invalid response (not HTTP)")
continuation.finish(throwing: ProviderError.invalidResponse)
return
}
guard httpResponse.statusCode == 200 else {
Log.api.error("Anthropic stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
return
}
var currentId = ""
var currentModel = request.model
var inputTokens = 0
var cacheCreationTokens: Int? = nil
var cacheReadTokens: Int? = nil
for try await line in bytes.lines {
// Anthropic SSE: "event: ..." and "data: {...}"
guard line.hasPrefix("data: ") else { continue }
let jsonString = String(line.dropFirst(6))
guard let jsonData = jsonString.data(using: .utf8),
let event = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let eventType = event["type"] as? String else {
continue
}
switch eventType {
case "message_start":
if let message = event["message"] as? [String: Any] {
currentId = message["id"] as? String ?? ""
currentModel = message["model"] as? String ?? request.model
if let usageDict = message["usage"] as? [String: Any] {
inputTokens = usageDict["input_tokens"] as? Int ?? 0
cacheCreationTokens = usageDict["cache_creation_input_tokens"] as? Int
cacheReadTokens = usageDict["cache_read_input_tokens"] as? Int
if cacheCreationTokens != nil || cacheReadTokens != nil {
Log.api.info("Anthropic stream cache usage: input=\(inputTokens), created=\(cacheCreationTokens ?? 0), read=\(cacheReadTokens ?? 0)")
}
}
}
case "content_block_delta":
if let delta = event["delta"] as? [String: Any],
let deltaType = delta["type"] as? String,
deltaType == "text_delta",
let text = delta["text"] as? String {
continuation.yield(StreamChunk(
id: currentId,
model: currentModel,
delta: .init(content: text, role: nil, images: nil),
finishReason: nil,
usage: nil
))
}
case "message_delta":
let delta = event["delta"] as? [String: Any]
let stopReason = delta?["stop_reason"] as? String
var usage: ChatResponse.Usage? = nil
if let usageDict = event["usage"] as? [String: Any] {
let outputTokens = usageDict["output_tokens"] as? Int ?? 0
usage = ChatResponse.Usage(
promptTokens: inputTokens,
completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens,
cacheCreationInputTokens: cacheCreationTokens,
cacheReadInputTokens: cacheReadTokens
)
}
continuation.yield(StreamChunk(
id: currentId,
model: currentModel,
delta: .init(content: nil, role: nil, images: nil),
finishReason: stopReason,
usage: usage
))
case "message_stop":
continuation.finish()
return
default:
break
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Credits
func getCredits() async throws -> Credits? {
// Anthropic doesn't have a public credits API
return nil
}
// MARK: - Auth Helpers
/// Apply auth headers based on mode (API key or OAuth Bearer)
private func applyAuth(to request: inout URLRequest) async throws {
switch authMode {
case .apiKey(let key):
request.addValue(key, forHTTPHeaderField: "x-api-key")
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
case .oauth:
let token = try await AnthropicOAuthService.shared.getValidAccessToken()
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
request.addValue("oauth-2025-04-20,interleaved-thinking-2025-05-14", forHTTPHeaderField: "anthropic-beta")
}
}
/// Build the messages endpoint URL, appending ?beta=true for OAuth
private var messagesURL: URL {
switch authMode {
case .apiKey:
return URL(string: "\(baseURL)/messages")!
case .oauth:
return URL(string: "\(baseURL)/messages?beta=true")!
}
}
// MARK: - Request Building
private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> (URLRequest, Data) {
let url = messagesURL
// Separate system message
var systemText: String? = request.systemPrompt
var apiMessages: [[String: Any]] = []
for msg in request.messages {
if msg.role == .system {
systemText = msg.content
continue
}
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
if hasAttachments, let attachments = msg.attachments {
var contentBlocks: [[String: Any]] = []
if !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
contentBlocks.append(["type": "text", "text": msg.content])
}
for attachment in attachments {
guard let data = attachment.data else { continue }
switch attachment.type {
case .image, .pdf:
let base64 = data.base64EncodedString()
contentBlocks.append([
"type": "image",
"source": [
"type": "base64",
"media_type": attachment.mimeType,
"data": base64
]
])
case .text:
let filename = (attachment.path as NSString).lastPathComponent
let textContent = String(data: data, encoding: .utf8) ?? ""
contentBlocks.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"])
}
}
apiMessages.append(["role": msg.role.rawValue, "content": contentBlocks])
} else {
let content = msg.content.trimmingCharacters(in: .whitespacesAndNewlines)
apiMessages.append(["role": msg.role.rawValue, "content": content.isEmpty ? "[Image]" : content])
}
}
// Mark the last message with a cache breakpoint so the next turn can
// reuse everything up through this one as a cached prefix.
if var lastMessage = apiMessages.popLast() {
if let content = lastMessage["content"] as? String {
lastMessage["content"] = [["type": "text", "text": content, "cache_control": ["type": "ephemeral"]]]
} else if var blocks = lastMessage["content"] as? [[String: Any]], var lastBlock = blocks.popLast() {
lastBlock["cache_control"] = ["type": "ephemeral"]
blocks.append(lastBlock)
lastMessage["content"] = blocks
}
apiMessages.append(lastMessage)
}
var body: [String: Any] = [
"model": request.model,
"messages": apiMessages,
"max_tokens": request.maxTokens ?? 16000,
"stream": stream
]
if let systemText = systemText {
// Array form (rather than a plain string) carries a cache breakpoint.
// Per Anthropic's render order (tools -> system -> messages), this
// single breakpoint caches the tool definitions too.
body["system"] = [["type": "text", "text": systemText, "cache_control": ["type": "ephemeral"]]]
}
if let temperature = request.temperature {
body["temperature"] = temperature
}
var toolsArray: [[String: Any]] = []
if let tools = request.tools {
toolsArray += tools.map { tool -> [String: Any] in
[
"name": tool.function.name,
"description": tool.function.description,
"input_schema": convertParametersToDict(tool.function.parameters)
]
}
}
if request.onlineMode {
toolsArray.append([
"type": "web_search_20250305",
"name": "web_search",
"max_uses": 5
])
}
if !toolsArray.isEmpty {
body["tools"] = toolsArray
if request.tools != nil {
body["tool_choice"] = ["type": "auto"]
}
}
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
if stream {
urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept")
}
urlRequest.httpBody = bodyData
// Auth is applied async in the caller (chat/streamChat)
return (urlRequest, bodyData)
}
private func parseResponse(data: Data) throws -> ChatResponse {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw ProviderError.invalidResponse
}
let id = json["id"] as? String ?? ""
let model = json["model"] as? String ?? ""
let contentBlocks = json["content"] as? [[String: Any]] ?? []
var textContent = ""
var toolCalls: [ToolCallInfo] = []
for block in contentBlocks {
let blockType = block["type"] as? String ?? ""
switch blockType {
case "text":
textContent += block["text"] as? String ?? ""
case "tool_use":
let tcId = block["id"] as? String ?? UUID().uuidString
let tcName = block["name"] as? String ?? ""
let tcInput = block["input"] ?? [:]
let argsData = try JSONSerialization.data(withJSONObject: tcInput)
let argsStr = String(data: argsData, encoding: .utf8) ?? "{}"
toolCalls.append(ToolCallInfo(id: tcId, type: "function", functionName: tcName, arguments: argsStr))
default:
break
}
}
let usageDict = json["usage"] as? [String: Any]
let inputTokens = usageDict?["input_tokens"] as? Int ?? 0
let outputTokens = usageDict?["output_tokens"] as? Int ?? 0
let cacheCreationTokens = usageDict?["cache_creation_input_tokens"] as? Int
let cacheReadTokens = usageDict?["cache_read_input_tokens"] as? Int
if cacheCreationTokens != nil || cacheReadTokens != nil {
Log.api.info("Anthropic cache usage: input=\(inputTokens), created=\(cacheCreationTokens ?? 0), read=\(cacheReadTokens ?? 0)")
}
return ChatResponse(
id: id,
model: model,
content: textContent,
role: "assistant",
finishReason: json["stop_reason"] as? String,
usage: ChatResponse.Usage(
promptTokens: inputTokens,
completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens,
cacheCreationInputTokens: cacheCreationTokens,
cacheReadInputTokens: cacheReadTokens
),
created: Date(),
toolCalls: toolCalls.isEmpty ? nil : toolCalls
)
}
private func convertParametersToDict(_ params: Tool.Function.Parameters) -> [String: Any] {
var props: [String: Any] = [:]
for (key, prop) in params.properties {
var propDict: [String: Any] = [
"type": prop.type,
"description": prop.description
]
if let enumVals = prop.enum {
propDict["enum"] = enumVals
}
if let items = prop.items {
propDict["items"] = ["type": items.type]
}
props[key] = propDict
}
var dict: [String: Any] = [
"type": params.type,
"properties": props
]
if let required = params.required {
dict["required"] = required
}
return dict
}
}