//
// AnthropicProvider.swift
// oAI
//
// Anthropic Messages API provider with SSE streaming and tool support
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see .
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.
private static let knownModels: [ModelInfo] = [
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-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-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-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)
),
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)
),
]
/// 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
if let known = enrichment[id] { return known }
// Unknown new model — use display name and sensible defaults
return ModelInfo(
id: id,
name: displayName,
description: item["description"] as? String ?? "",
contextLength: 200_000,
pricing: .init(prompt: 0, completion: 0),
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 {
conversationMessages.append(["role": role, "content": msg["content"] as? String ?? ""])
}
}
var body: [String: Any] = [
"model": model,
"messages": conversationMessages,
"max_tokens": maxTokens ?? 16000,
"stream": false
]
if let systemText = systemText {
body["system"] = systemText
}
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 {
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
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
}
}
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)
}
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]] = []
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 {
apiMessages.append(["role": msg.role.rawValue, "content": msg.content])
}
}
var body: [String: Any] = [
"model": request.model,
"messages": apiMessages,
"max_tokens": request.maxTokens ?? 16000,
"stream": stream
]
if let systemText = systemText {
body["system"] = systemText
}
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
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
),
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
}
props[key] = propDict
}
var dict: [String: Any] = [
"type": params.type,
"properties": props
]
if let required = params.required {
dict["required"] = required
}
return dict
}
}