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%
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