Initial commit
This commit is contained in:
534
oAI/Providers/AnthropicProvider.swift
Normal file
534
oAI/Providers/AnthropicProvider.swift
Normal file
@@ -0,0 +1,534 @@
|
||||
//
|
||||
// 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 = 60
|
||||
config.timeoutIntervalForResource = 300
|
||||
self.session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
/// Create with OAuth (Pro/Max subscription)
|
||||
init(oauth: Bool) {
|
||||
self.authMode = .oauth
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 60
|
||||
config.timeoutIntervalForResource = 300
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user