// // AnthropicClient.swift // Zyquo Cloud // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Native Anthropic Messages API client (/v1/messages) — NOT OpenAI-compatible. // Auth: x-api-key + anthropic-version headers. System prompt is a top-level // param, content is block-structured, max_tokens is mandatory, streaming uses // named SSE events. // import Foundation struct AnthropicClient: ProviderClient { let providerID: ProviderID = .anthropic private static let apiVersion = "2023-06-01" private static let defaultMaxTokens = 8192 // MARK: - Wire types (requests) private struct WireRequest: Encodable { var model: String var maxTokens: Int var messages: [WireMessage] var system: String? var stream: Bool? var temperature: Double? var topP: Double? var thinking: Thinking? enum CodingKeys: String, CodingKey { case model, messages, system, stream, temperature, thinking case maxTokens = "max_tokens" case topP = "top_p" } } private struct Thinking: Encodable { var type: String var budgetTokens: Int? enum CodingKeys: String, CodingKey { case type case budgetTokens = "budget_tokens" } } private struct WireMessage: Encodable { var role: String var content: [WireBlock] } private enum WireBlock: Encodable { case text(String) case image(mediaType: String, base64: String) func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: Key.self) switch self { case .text(let s): try container.encode("text", forKey: .type) try container.encode(s, forKey: .text) case .image(let mediaType, let base64): try container.encode("image", forKey: .type) var source = container.nestedContainer(keyedBy: Key.self, forKey: .source) try source.encode("base64", forKey: .type) try source.encode(mediaType, forKey: .mediaType) try source.encode(base64, forKey: .data) } } enum Key: String, CodingKey { case type, text, source, data case mediaType = "media_type" } } // MARK: - Wire types (responses) private struct StreamEvent: Decodable { var type: String? var delta: Delta? var usage: WireUsage? var message: MessageStart? var error: WireError? struct Delta: Decodable { var type: String? var text: String? var thinking: String? var stopReason: String? enum CodingKeys: String, CodingKey { case type, text, thinking case stopReason = "stop_reason" } } struct MessageStart: Decodable { var usage: WireUsage? } struct WireError: Decodable { var message: String? } } private struct WireUsage: Decodable { var inputTokens: Int? var outputTokens: Int? enum CodingKeys: String, CodingKey { case inputTokens = "input_tokens" case outputTokens = "output_tokens" } } private struct WireResponse: Decodable { var content: [Block]? var usage: WireUsage? var stopReason: String? struct Block: Decodable { var type: String? var text: String? var thinking: String? } enum CodingKeys: String, CodingKey { case content, usage case stopReason = "stop_reason" } } private struct WireModelList: Decodable { var data: [Entry] struct Entry: Decodable { var id: String } } // MARK: - Request construction private func urlRequest(path: String, apiKey: String, method: String = "POST") throws -> URLRequest { guard let base = providerID.defaultBaseURL else { throw ProviderError.invalidResponse(providerID, detail: "no base URL") } var request = URLRequest(url: base.appendingPathComponent(path)) request.httpMethod = method request.setValue(apiKey, forHTTPHeaderField: "x-api-key") request.setValue(Self.apiVersion, forHTTPHeaderField: "anthropic-version") if method == "POST" { request.setValue("application/json", forHTTPHeaderField: "Content-Type") } return request } private func buildBody(_ request: ChatRequest) throws -> Data { var messages: [WireMessage] = [] for message in request.messages where message.role != .system { messages.append(wireMessage(from: message, vision: request.model.capabilities.vision)) } let params = request.parameters var wire = WireRequest( model: request.model.id, maxTokens: params.maxTokens ?? Self.defaultMaxTokens, messages: messages ) if let system = request.systemPrompt, !system.isEmpty { wire.system = system } if request.stream { wire.stream = true } // Claude 4.7+ removed temperature/top_p; ParameterSupport encodes that per model. let support = request.model.parameterSupport if support.temperature { wire.temperature = params.temperature } if support.topP { wire.topP = params.topP } if support.thinkingToggle, let enabled = params.thinkingEnabled { wire.thinking = enabled ? Thinking(type: "enabled", budgetTokens: 8000) : Thinking(type: "disabled") } return try JSONEncoder().encode(wire) } private func wireMessage(from message: Message, vision: Bool) -> WireMessage { let role = message.role == .assistant ? "assistant" : "user" var text = message.text for attachment in message.attachments where attachment.kind == .textFile { let contents = String(data: attachment.data, encoding: .utf8) ?? "" text += "\n\n```\(attachment.fileName)\n\(contents)\n```" } var blocks: [WireBlock] = [] if vision, message.role == .user { for image in message.attachments where image.kind == .image { blocks.append(.image(mediaType: image.mimeType, base64: image.data.base64EncodedString())) } } blocks.append(.text(text.isEmpty ? " " : text)) return WireMessage(role: role, content: blocks) } // MARK: - ProviderClient func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream { AsyncThrowingStream { continuation in let task = Task { do { var urlReq = try urlRequest(path: "messages", apiKey: apiKey) var streamRequest = request streamRequest.stream = true urlReq.httpBody = try buildBody(streamRequest) var usage = TokenUsage() var stopReason: String? let decoder = JSONDecoder() for try await sse in StreamingService.sseEvents(for: urlReq, provider: providerID) { guard let data = sse.data.data(using: .utf8), let event = try? decoder.decode(StreamEvent.self, from: data) else { continue } let type = sse.event ?? event.type ?? "" switch type { case "message_start": if let u = event.message?.usage { usage.inputTokens = u.inputTokens ?? 0 } case "content_block_delta": if let text = event.delta?.text, !text.isEmpty { continuation.yield(.textDelta(text)) } if let thinking = event.delta?.thinking, !thinking.isEmpty { continuation.yield(.reasoningDelta(thinking)) } case "message_delta": if let u = event.usage { usage.outputTokens = u.outputTokens ?? usage.outputTokens } if let reason = event.delta?.stopReason { stopReason = reason } case "error": throw ProviderError.serverError( providerID, status: 200, message: event.error?.message ) case "message_stop": break default: break // ping, content_block_start/stop, unknown future events } } continuation.yield(.usage(usage)) continuation.yield(.finished(reason: stopReason)) continuation.finish() } catch { continuation.finish(throwing: error) } } continuation.onTermination = { _ in task.cancel() } } } func complete(_ request: ChatRequest, apiKey: String) async throws -> Message { var urlReq = try urlRequest(path: "messages", apiKey: apiKey) var plainRequest = request plainRequest.stream = false urlReq.httpBody = try buildBody(plainRequest) let data = try await StreamingService.postJSON(urlReq, provider: providerID) guard let response = try? JSONDecoder().decode(WireResponse.self, from: data) else { throw ProviderError.invalidResponse(providerID, detail: "undecodable messages response") } let text = (response.content ?? []).compactMap { $0.type == "text" ? $0.text : nil }.joined() let thinking = (response.content ?? []).compactMap { $0.type == "thinking" ? $0.thinking : nil }.joined() var message = Message( role: .assistant, text: text, reasoning: thinking.isEmpty ? nil : thinking, modelID: request.model.id, provider: providerID ) if let u = response.usage { let usage = TokenUsage(inputTokens: u.inputTokens ?? 0, outputTokens: u.outputTokens ?? 0) message.usage = usage message.estimatedCost = request.model.pricing?.cost( inputTokens: usage.inputTokens, outputTokens: usage.outputTokens ) } return message } func listModelIDs(apiKey: String) async throws -> [String] { var urlReq = try urlRequest(path: "models", apiKey: apiKey, method: "GET") urlReq.url = urlReq.url.flatMap { URL(string: $0.absoluteString + "?limit=100") } let data = try await StreamingService.getJSON(urlReq, provider: providerID) guard let list = try? JSONDecoder().decode(WireModelList.self, from: data) else { throw ProviderError.invalidResponse(providerID, detail: "unrecognized /models response shape") } return list.data.map(\.id) } }