535 lines
21 KiB
Swift
535 lines
21 KiB
Swift
//
|
|
// AnthropicProvider.swift
|
|
// oAI
|
|
//
|
|
// Anthropic Messages API provider with SSE streaming and tool support
|
|
//
|
|
|
|
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 (hardcoded — Anthropic has no public models list endpoint)
|
|
|
|
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)
|
|
),
|
|
]
|
|
|
|
func listModels() async throws -> [ModelInfo] {
|
|
return Self.knownModels
|
|
}
|
|
|
|
func getModel(_ id: String) async throws -> ModelInfo? {
|
|
return Self.knownModels.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 ?? 4096,
|
|
"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<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
|
|
|
|
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
|
|
}
|
|
|
|
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: 0, completionTokens: outputTokens, totalTokens: 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 ?? 4096,
|
|
"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
|
|
}
|
|
}
|