SPB Git

spb/zyquo-agent Public MIT

The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.

Swift 94.7% Shell 4.1% Python 0.7% Makefile 0.5%
18.6 KB · 462 lines swift
Raw Blame History
1//2//  AnthropicClient.swift3//  Zyquo Agent4//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//13//  Ported from Zyquo Cloud; Zyquo Agent adds native tool calling14//  (docs/PROVIDER-REUSE.md §5.1): `tools` [{name, description, input_schema}]15//  + `tool_choice` on requests; streaming `content_block_start` (tool_use id +16//  name) → `input_json_delta` (partial_json fragments, accumulated per block17//  index) → `content_block_stop`; stop_reason "tool_use" vs "end_turn"; and18//  threading of assistant tool_use blocks + user tool_result blocks from19//  history.20//2122import Foundation2324struct AnthropicClient: ProviderClient {25    let providerID: ProviderID = .anthropic2627    private static let apiVersion = "2023-06-01"28    private static let defaultMaxTokens = 81922930    // MARK: - Wire types (requests)3132    private struct WireRequest: Encodable {33        var model: String34        var maxTokens: Int35        var messages: [WireMessage]36        var system: String?37        var stream: Bool?38        var temperature: Double?39        var topP: Double?40        var thinking: Thinking?41        var tools: [WireToolDef]?42        var toolChoice: WireToolChoice?4344        enum CodingKeys: String, CodingKey {45            case model, messages, system, stream, temperature, thinking, tools46            case maxTokens = "max_tokens"47            case topP = "top_p"48            case toolChoice = "tool_choice"49        }50    }5152    private struct Thinking: Encodable {53        var type: String54        var budgetTokens: Int?55        enum CodingKeys: String, CodingKey {56            case type57            case budgetTokens = "budget_tokens"58        }59    }6061    /// `tools: [{name, description, input_schema: <JSON Schema>}]`.62    private struct WireToolDef: Encodable {63        var name: String64        var description: String65        var inputSchema: JSONValue6667        enum CodingKeys: String, CodingKey {68            case name, description69            case inputSchema = "input_schema"70        }71    }7273    /// `tool_choice: {"type":"auto"|"any"|"none"|"tool","name":…}`.74    private struct WireToolChoice: Encodable {75        var type: String76        var name: String?77    }7879    private struct WireMessage: Encodable {80        var role: String81        var content: [WireBlock]82    }8384    private enum WireBlock: Encodable {85        case text(String)86        case image(mediaType: String, base64: String)87        /// Assistant tool call echoed back into history.88        case toolUse(id: String, name: String, input: JSONValue)89        /// Tool result returned inside a **user** turn.90        case toolResult(toolUseID: String, content: String, isError: Bool)9192        func encode(to encoder: Encoder) throws {93            var container = encoder.container(keyedBy: Key.self)94            switch self {95            case .text(let s):96                try container.encode("text", forKey: .type)97                try container.encode(s, forKey: .text)98            case .image(let mediaType, let base64):99                try container.encode("image", forKey: .type)100                var source = container.nestedContainer(keyedBy: Key.self, forKey: .source)101                try source.encode("base64", forKey: .type)102                try source.encode(mediaType, forKey: .mediaType)103                try source.encode(base64, forKey: .data)104            case .toolUse(let id, let name, let input):105                try container.encode("tool_use", forKey: .type)106                try container.encode(id, forKey: .id)107                try container.encode(name, forKey: .name)108                try container.encode(input, forKey: .input)109            case .toolResult(let toolUseID, let content, let isError):110                try container.encode("tool_result", forKey: .type)111                try container.encode(toolUseID, forKey: .toolUseID)112                try container.encode(content, forKey: .content)113                if isError {114                    try container.encode(true, forKey: .isError)115                }116            }117        }118119        enum Key: String, CodingKey {120            case type, text, source, data, id, name, input, content121            case mediaType = "media_type"122            case toolUseID = "tool_use_id"123            case isError = "is_error"124        }125    }126127    // MARK: - Wire types (responses)128129    private struct StreamEvent: Decodable {130        var type: String?131        /// Content block index (content_block_start/delta/stop events).132        var index: Int?133        var delta: Delta?134        var usage: WireUsage?135        var message: MessageStart?136        var contentBlock: ContentBlock?137        var error: WireError?138139        enum CodingKeys: String, CodingKey {140            case type, index, delta, usage, message, error141            case contentBlock = "content_block"142        }143144        struct Delta: Decodable {145            var type: String?146            var text: String?147            var thinking: String?148            /// input_json_delta fragments for a streaming tool_use block.149            var partialJSON: String?150            var stopReason: String?151            enum CodingKeys: String, CodingKey {152                case type, text, thinking153                case partialJSON = "partial_json"154                case stopReason = "stop_reason"155            }156        }157158        /// content_block_start payload: a tool_use block announces id + name.159        struct ContentBlock: Decodable {160            var type: String?161            var id: String?162            var name: String?163        }164165        struct MessageStart: Decodable {166            var usage: WireUsage?167        }168169        struct WireError: Decodable {170            var message: String?171        }172    }173174    private struct WireUsage: Decodable {175        var inputTokens: Int?176        var outputTokens: Int?177        enum CodingKeys: String, CodingKey {178            case inputTokens = "input_tokens"179            case outputTokens = "output_tokens"180        }181    }182183    private struct WireResponse: Decodable {184        var content: [Block]?185        var usage: WireUsage?186        var stopReason: String?187188        struct Block: Decodable {189            var type: String?190            var text: String?191            var thinking: String?192            /// tool_use block fields.193            var id: String?194            var name: String?195            var input: JSONValue?196        }197198        enum CodingKeys: String, CodingKey {199            case content, usage200            case stopReason = "stop_reason"201        }202    }203204    private struct WireModelList: Decodable {205        var data: [Entry]206        struct Entry: Decodable { var id: String }207    }208209    // MARK: - Request construction210211    private func urlRequest(path: String, apiKey: String, method: String = "POST") throws -> URLRequest {212        guard let base = providerID.defaultBaseURL else {213            throw ProviderError.invalidResponse(providerID, detail: "no base URL")214        }215        var request = URLRequest(url: base.appendingPathComponent(path))216        request.httpMethod = method217        request.setValue(apiKey, forHTTPHeaderField: "x-api-key")218        request.setValue(Self.apiVersion, forHTTPHeaderField: "anthropic-version")219        if method == "POST" {220            request.setValue("application/json", forHTTPHeaderField: "Content-Type")221        }222        return request223    }224225    private func buildBody(_ request: ChatRequest) throws -> Data {226        var messages: [WireMessage] = []227        for message in request.messages where message.role != .system {228            messages.append(wireMessage(from: message, vision: request.model.capabilities.vision))229        }230        let params = request.parameters231        var wire = WireRequest(232            model: request.model.id,233            maxTokens: params.maxTokens ?? Self.defaultMaxTokens,234            messages: messages235        )236        if let system = request.systemPrompt, !system.isEmpty {237            wire.system = system238        }239        if request.stream { wire.stream = true }240        // Claude 4.7+ removed temperature/top_p; ParameterSupport encodes that per model.241        let support = request.model.parameterSupport242        if support.temperature { wire.temperature = params.temperature }243        if support.topP { wire.topP = params.topP }244        if support.thinkingToggle, let enabled = params.thinkingEnabled {245            wire.thinking = enabled246                ? Thinking(type: "enabled", budgetTokens: 8000)247                : Thinking(type: "disabled")248        }249        // Native tool calling.250        if !request.tools.isEmpty, request.model.capabilities.tools {251            wire.tools = request.tools.map { spec in252                WireToolDef(253                    name: spec.name,254                    description: spec.description,255                    inputSchema: JSONValue.parse(spec.parametersJSONSchema) ?? .emptyObject256                )257            }258            switch request.toolChoice {259            case .auto:260                break // Anthropic default — omit261            case .none:262                wire.toolChoice = WireToolChoice(type: "none")263            case .required:264                wire.toolChoice = WireToolChoice(type: "any")265            case .named(let name):266                wire.toolChoice = WireToolChoice(type: "tool", name: name)267            }268        }269        return try JSONEncoder().encode(wire)270    }271272    private func wireMessage(from message: Message, vision: Bool) -> WireMessage {273        // Tool results travel as tool_result blocks inside a USER turn — not a274        // special role. Parallel calls put multiple results in one turn.275        if let results = message.toolResults, !results.isEmpty {276            let blocks = results.map { result in277                WireBlock.toolResult(278                    toolUseID: result.toolCallID,279                    content: result.content,280                    isError: result.isError281                )282            }283            return WireMessage(role: "user", content: blocks)284        }285        let role = message.role == .assistant ? "assistant" : "user"286        var text = message.text287        for attachment in message.attachments where attachment.kind == .textFile {288            let contents = String(data: attachment.data, encoding: .utf8) ?? ""289            text += "\n\n```\(attachment.fileName)\n\(contents)\n```"290        }291        var blocks: [WireBlock] = []292        if vision, message.role == .user {293            for image in message.attachments where image.kind == .image {294                blocks.append(.image(mediaType: image.mimeType, base64: image.data.base64EncodedString()))295            }296        }297        // Assistant turns that called tools: text block (when non-empty)298        // followed by the tool_use blocks, echoed back verbatim.299        if let calls = message.toolCalls, !calls.isEmpty, message.role == .assistant {300            if !text.isEmpty {301                blocks.append(.text(text))302            }303            for call in calls {304                blocks.append(.toolUse(305                    id: call.id,306                    name: call.name,307                    input: JSONValue.parse(call.argumentsJSON) ?? .emptyObject308                ))309            }310            return WireMessage(role: role, content: blocks)311        }312        blocks.append(.text(text.isEmpty ? " " : text))313        return WireMessage(role: role, content: blocks)314    }315316    // MARK: - ProviderClient317318    func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> {319        AsyncThrowingStream { continuation in320            let task = Task {321                do {322                    var urlReq = try urlRequest(path: "messages", apiKey: apiKey)323                    var streamRequest = request324                    streamRequest.stream = true325                    urlReq.httpBody = try buildBody(streamRequest)326327                    var usage = TokenUsage()328                    var stopReason: String?329                    // Streaming tool_use blocks, keyed by content block index.330                    struct PartialToolUse {331                        var id: String332                        var name: String333                        var inputJSON = ""334                    }335                    var partials: [Int: PartialToolUse] = [:]336                    let decoder = JSONDecoder()337338                    for try await sse in StreamingService.sseEvents(for: urlReq, provider: providerID) {339                        guard let data = sse.data.data(using: .utf8),340                              let event = try? decoder.decode(StreamEvent.self, from: data) else {341                            continue342                        }343                        let type = sse.event ?? event.type ?? ""344                        switch type {345                        case "message_start":346                            if let u = event.message?.usage {347                                usage.inputTokens = u.inputTokens ?? 0348                            }349                        case "content_block_start":350                            if let block = event.contentBlock, block.type == "tool_use" {351                                let index = event.index ?? (partials.keys.max().map { $0 + 1 } ?? 0)352                                let id = block.id ?? "toolu_\(index)_\(UUID().uuidString.prefix(8))"353                                let name = block.name ?? ""354                                partials[index] = PartialToolUse(id: id, name: name)355                                continuation.yield(.toolCallStarted(index: index, id: id, name: name))356                            }357                        case "content_block_delta":358                            if let text = event.delta?.text, !text.isEmpty {359                                continuation.yield(.textDelta(text))360                            }361                            if let thinking = event.delta?.thinking, !thinking.isEmpty {362                                continuation.yield(.reasoningDelta(thinking))363                            }364                            if let fragment = event.delta?.partialJSON, !fragment.isEmpty,365                               let index = event.index, partials[index] != nil {366                                partials[index]?.inputJSON += fragment367                                continuation.yield(.toolCallArgumentsDelta(index: index, delta: fragment))368                            }369                        case "content_block_stop":370                            break // block complete; finalized set is emitted at stream end371                        case "message_delta":372                            if let u = event.usage {373                                usage.outputTokens = u.outputTokens ?? usage.outputTokens374                            }375                            if let reason = event.delta?.stopReason {376                                stopReason = reason377                            }378                        case "error":379                            throw ProviderError.serverError(380                                providerID, status: 200, message: event.error?.message381                            )382                        case "message_stop":383                            break384                        default:385                            break // ping, unknown future events386                        }387                    }388                    let calls = partials.sorted { $0.key < $1.key }.map { _, partial in389                        ToolCall(390                            id: partial.id,391                            name: partial.name,392                            argumentsJSON: partial.inputJSON.isEmpty ? "{}" : partial.inputJSON393                        )394                    }395                    if !calls.isEmpty {396                        continuation.yield(.toolCalls(calls))397                    }398                    continuation.yield(.usage(usage))399                    continuation.yield(.finished(400                        reason: stopReason,401                        stop: StopReason.normalize(stopReason, hasToolCalls: !calls.isEmpty)402                    ))403                    continuation.finish()404                } catch {405                    continuation.finish(throwing: error)406                }407            }408            continuation.onTermination = { _ in task.cancel() }409        }410    }411412    func complete(_ request: ChatRequest, apiKey: String) async throws -> Message {413        var urlReq = try urlRequest(path: "messages", apiKey: apiKey)414        var plainRequest = request415        plainRequest.stream = false416        urlReq.httpBody = try buildBody(plainRequest)417        let data = try await StreamingService.postJSON(urlReq, provider: providerID)418        guard let response = try? JSONDecoder().decode(WireResponse.self, from: data) else {419            throw ProviderError.invalidResponse(providerID, detail: "undecodable messages response")420        }421        let blocks = response.content ?? []422        let text = blocks.compactMap { $0.type == "text" ? $0.text : nil }.joined()423        let thinking = blocks.compactMap { $0.type == "thinking" ? $0.thinking : nil }.joined()424        let calls: [ToolCall] = blocks.enumerated().compactMap { index, block in425            guard block.type == "tool_use" else { return nil }426            return ToolCall(427                id: block.id ?? "toolu_\(index)_\(UUID().uuidString.prefix(8))",428                name: block.name ?? "",429                argumentsJSON: (block.input ?? .emptyObject).jsonString430            )431        }432        var message = Message(433            role: .assistant,434            text: text,435            reasoning: thinking.isEmpty ? nil : thinking,436            toolCalls: calls.isEmpty ? nil : calls,437            modelID: request.model.id,438            provider: providerID439        )440        if let u = response.usage {441            let usage = TokenUsage(inputTokens: u.inputTokens ?? 0, outputTokens: u.outputTokens ?? 0)442            message.usage = usage443            message.estimatedCost = request.model.pricing?.cost(444                inputTokens: usage.inputTokens, outputTokens: usage.outputTokens445            )446        }447        return message448    }449450    func listModelIDs(apiKey: String) async throws -> [String] {451        var urlReq = try urlRequest(path: "models", apiKey: apiKey, method: "GET")452        urlReq.url = urlReq.url.flatMap {453            URL(string: $0.absoluteString + "?limit=100")454        }455        let data = try await StreamingService.getJSON(urlReq, provider: providerID)456        guard let list = try? JSONDecoder().decode(WireModelList.self, from: data) else {457            throw ProviderError.invalidResponse(providerID, detail: "unrecognized /models response shape")458        }459        return list.data.map(\.id)460    }461}462