spb/zyquo-atlas Public License
The AI-native macOS web browser — every surface, intelligent.
Swift 75.2%
JavaScript 22%
Shell 2%
Makefile 0.9%
1//2// AnthropicClient.swift3// Zyquo Atlas4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// Native Anthropic Messages API client (/v1/messages) — NOT OpenAI-compatible.9// Auth: x-api-key + anthropic-version headers. System prompt is a top-level10// param, content is block-structured, max_tokens is mandatory, streaming uses11// named SSE events.12//1314import Foundation1516struct AnthropicClient: ProviderClient {17 let providerID: ProviderID = .anthropic1819 private static let apiVersion = "2023-06-01"20 private static let defaultMaxTokens = 81922122 // MARK: - Wire types (requests)2324 private struct WireRequest: Encodable {25 var model: String26 var maxTokens: Int27 var messages: [WireMessage]28 var system: String?29 var stream: Bool?30 var temperature: Double?31 var topP: Double?32 var thinking: Thinking?3334 enum CodingKeys: String, CodingKey {35 case model, messages, system, stream, temperature, thinking36 case maxTokens = "max_tokens"37 case topP = "top_p"38 }39 }4041 private struct Thinking: Encodable {42 var type: String43 var budgetTokens: Int?44 enum CodingKeys: String, CodingKey {45 case type46 case budgetTokens = "budget_tokens"47 }48 }4950 private struct WireMessage: Encodable {51 var role: String52 var content: [WireBlock]53 }5455 private enum WireBlock: Encodable {56 case text(String)57 case image(mediaType: String, base64: String)5859 func encode(to encoder: Encoder) throws {60 var container = encoder.container(keyedBy: Key.self)61 switch self {62 case .text(let s):63 try container.encode("text", forKey: .type)64 try container.encode(s, forKey: .text)65 case .image(let mediaType, let base64):66 try container.encode("image", forKey: .type)67 var source = container.nestedContainer(keyedBy: Key.self, forKey: .source)68 try source.encode("base64", forKey: .type)69 try source.encode(mediaType, forKey: .mediaType)70 try source.encode(base64, forKey: .data)71 }72 }7374 enum Key: String, CodingKey {75 case type, text, source, data76 case mediaType = "media_type"77 }78 }7980 // MARK: - Wire types (responses)8182 private struct StreamEvent: Decodable {83 var type: String?84 var delta: Delta?85 var usage: WireUsage?86 var message: MessageStart?87 var error: WireError?8889 struct Delta: Decodable {90 var type: String?91 var text: String?92 var thinking: String?93 var stopReason: String?94 enum CodingKeys: String, CodingKey {95 case type, text, thinking96 case stopReason = "stop_reason"97 }98 }99100 struct MessageStart: Decodable {101 var usage: WireUsage?102 }103104 struct WireError: Decodable {105 var message: String?106 }107 }108109 private struct WireUsage: Decodable {110 var inputTokens: Int?111 var outputTokens: Int?112 enum CodingKeys: String, CodingKey {113 case inputTokens = "input_tokens"114 case outputTokens = "output_tokens"115 }116 }117118 private struct WireResponse: Decodable {119 var content: [Block]?120 var usage: WireUsage?121 var stopReason: String?122123 struct Block: Decodable {124 var type: String?125 var text: String?126 var thinking: String?127 }128129 enum CodingKeys: String, CodingKey {130 case content, usage131 case stopReason = "stop_reason"132 }133 }134135 private struct WireModelList: Decodable {136 var data: [Entry]137 struct Entry: Decodable { var id: String }138 }139140 // MARK: - Request construction141142 private func urlRequest(path: String, apiKey: String, method: String = "POST") throws -> URLRequest {143 guard let base = providerID.defaultBaseURL else {144 throw ProviderError.invalidResponse(providerID, detail: "no base URL")145 }146 var request = URLRequest(url: base.appendingPathComponent(path))147 request.httpMethod = method148 request.setValue(apiKey, forHTTPHeaderField: "x-api-key")149 request.setValue(Self.apiVersion, forHTTPHeaderField: "anthropic-version")150 if method == "POST" {151 request.setValue("application/json", forHTTPHeaderField: "Content-Type")152 }153 return request154 }155156 private func buildBody(_ request: ChatRequest) throws -> Data {157 var messages: [WireMessage] = []158 for message in request.messages where message.role != .system {159 messages.append(wireMessage(from: message, vision: request.model.capabilities.vision))160 }161 let params = request.parameters162 var wire = WireRequest(163 model: request.model.id,164 maxTokens: params.maxTokens ?? Self.defaultMaxTokens,165 messages: messages166 )167 if let system = request.systemPrompt, !system.isEmpty {168 wire.system = system169 }170 if request.stream { wire.stream = true }171 // Claude 4.7+ removed temperature/top_p; ParameterSupport encodes that per model.172 let support = request.model.parameterSupport173 if support.temperature { wire.temperature = params.temperature }174 if support.topP { wire.topP = params.topP }175 if support.thinkingToggle, let enabled = params.thinkingEnabled {176 wire.thinking = enabled177 ? Thinking(type: "enabled", budgetTokens: 8000)178 : Thinking(type: "disabled")179 }180 return try JSONEncoder().encode(wire)181 }182183 private func wireMessage(from message: Message, vision: Bool) -> WireMessage {184 let role = message.role == .assistant ? "assistant" : "user"185 var text = message.text186 for attachment in message.attachments where attachment.kind == .textFile {187 let contents = String(data: attachment.data, encoding: .utf8) ?? ""188 text += "\n\n```\(attachment.fileName)\n\(contents)\n```"189 }190 var blocks: [WireBlock] = []191 if vision, message.role == .user {192 for image in message.attachments where image.kind == .image {193 blocks.append(.image(mediaType: image.mimeType, base64: image.data.base64EncodedString()))194 }195 }196 blocks.append(.text(text.isEmpty ? " " : text))197 return WireMessage(role: role, content: blocks)198 }199200 // MARK: - ProviderClient201202 func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> {203 AsyncThrowingStream { continuation in204 let task = Task {205 do {206 var urlReq = try urlRequest(path: "messages", apiKey: apiKey)207 var streamRequest = request208 streamRequest.stream = true209 urlReq.httpBody = try buildBody(streamRequest)210211 var usage = TokenUsage()212 var stopReason: String?213 let decoder = JSONDecoder()214215 for try await sse in StreamingService.sseEvents(for: urlReq, provider: providerID) {216 guard let data = sse.data.data(using: .utf8),217 let event = try? decoder.decode(StreamEvent.self, from: data) else {218 continue219 }220 let type = sse.event ?? event.type ?? ""221 switch type {222 case "message_start":223 if let u = event.message?.usage {224 usage.inputTokens = u.inputTokens ?? 0225 }226 case "content_block_delta":227 if let text = event.delta?.text, !text.isEmpty {228 continuation.yield(.textDelta(text))229 }230 if let thinking = event.delta?.thinking, !thinking.isEmpty {231 continuation.yield(.reasoningDelta(thinking))232 }233 case "message_delta":234 if let u = event.usage {235 usage.outputTokens = u.outputTokens ?? usage.outputTokens236 }237 if let reason = event.delta?.stopReason {238 stopReason = reason239 }240 case "error":241 throw ProviderError.serverError(242 providerID, status: 200, message: event.error?.message243 )244 case "message_stop":245 break246 default:247 break // ping, content_block_start/stop, unknown future events248 }249 }250 continuation.yield(.usage(usage))251 continuation.yield(.finished(reason: stopReason))252 continuation.finish()253 } catch {254 continuation.finish(throwing: error)255 }256 }257 continuation.onTermination = { _ in task.cancel() }258 }259 }260261 func complete(_ request: ChatRequest, apiKey: String) async throws -> Message {262 var urlReq = try urlRequest(path: "messages", apiKey: apiKey)263 var plainRequest = request264 plainRequest.stream = false265 urlReq.httpBody = try buildBody(plainRequest)266 let data = try await StreamingService.postJSON(urlReq, provider: providerID)267 guard let response = try? JSONDecoder().decode(WireResponse.self, from: data) else {268 throw ProviderError.invalidResponse(providerID, detail: "undecodable messages response")269 }270 let text = (response.content ?? []).compactMap { $0.type == "text" ? $0.text : nil }.joined()271 let thinking = (response.content ?? []).compactMap { $0.type == "thinking" ? $0.thinking : nil }.joined()272 var message = Message(273 role: .assistant,274 text: text,275 reasoning: thinking.isEmpty ? nil : thinking,276 modelID: request.model.id,277 provider: providerID278 )279 if let u = response.usage {280 let usage = TokenUsage(inputTokens: u.inputTokens ?? 0, outputTokens: u.outputTokens ?? 0)281 message.usage = usage282 message.estimatedCost = request.model.pricing?.cost(283 inputTokens: usage.inputTokens, outputTokens: usage.outputTokens284 )285 }286 return message287 }288289 func listModelIDs(apiKey: String) async throws -> [String] {290 var urlReq = try urlRequest(path: "models", apiKey: apiKey, method: "GET")291 urlReq.url = urlReq.url.flatMap {292 URL(string: $0.absoluteString + "?limit=100")293 }294 let data = try await StreamingService.getJSON(urlReq, provider: providerID)295 guard let list = try? JSONDecoder().decode(WireModelList.self, from: data) else {296 throw ProviderError.invalidResponse(providerID, detail: "unrecognized /models response shape")297 }298 return list.data.map(\.id)299 }300}301