SPB Git

spb/zyquo-atlas Public License

The AI-native macOS web browser — every surface, intelligent.

Swift 75.2% JavaScript 22% Shell 2% Makefile 0.9%
11.3 KB · 301 lines swift
Raw Blame History
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