phase3: standardized API — chat/completions with Anthropic/Gemini translation, compat adjuster, retries/fallbacks, local keys, docs/API.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 13 changed files with +2,541 and −8
modified
Sources/ZyquoRouter/App/Main.swift
+31 −1
@@ -19,11 +19,19 @@ import Foundation | ||
| 19 | 19 | enum Main { |
| 20 | 20 | static func main() { |
| 21 | 21 | let arguments = CommandLine.arguments |
| 22 | + if arguments.contains("--load-vault") { | |
| 23 | + loadVaultFromEnvironment() | |
| 24 | + exit(0) | |
| 25 | + } | |
| 22 | 26 | if let flagIndex = arguments.firstIndex(of: "--serve") { |
| 23 | 27 | // Headless mode for scripted verification (`ZyquoRouter --serve [port]`). |
| 24 | 28 | let port = arguments.indices.contains(flagIndex + 1) ? Int(arguments[flagIndex + 1]) ?? 8787 : 8787 |
| 25 | 29 | Task.detached { |
| 26 | − let routes = Routes(router: RequestRouter()) | |
| 30 | + let localKeys = PersistenceService.shared.load([APIKeyRecord].self, from: "local-keys.json") ?? [] | |
| 31 | + let routes = Routes( | |
| 32 | + router: RequestRouter(), | |
| 33 | + auth: AuthMiddleware(keys: localKeys) | |
| 34 | + ) | |
| 27 | 35 | let server = HTTPServer(host: "127.0.0.1", port: port) { request in |
| 28 | 36 | await routes.handle(request) |
| 29 | 37 | } |
@@ -43,4 +51,26 @@ enum Main { | ||
| 43 | 51 | } |
| 44 | 52 | ZyquoRouterApp.main() |
| 45 | 53 | } |
| 54 | + | |
| 55 | + /// Seeds the encrypted vault from environment variables (testing/setup). | |
| 56 | + /// Key values are never printed — only which providers were stored. | |
| 57 | + private static func loadVaultFromEnvironment() { | |
| 58 | + let mapping: [(ProviderID, String)] = [ | |
| 59 | + (.openai, "OPENAI_API_KEY"), (.anthropic, "ANTHROPIC_API_KEY"), | |
| 60 | + (.xai, "XAI_API_KEY"), (.mistral, "MISTRAL_API_KEY"), | |
| 61 | + (.gemini, "GEMINI_API_KEY"), (.qwen, "DASHSCOPE_API_KEY"), | |
| 62 | + (.deepseek, "DEEPSEEK_API_KEY"), (.kimi, "KIMI_API_KEY"), | |
| 63 | + (.perplexity, "PERPLEXITY_API_KEY"), (.together, "TOGETHER_API_KEY"), | |
| 64 | + (.deepinfra, "DEEPINFRA_API_KEY"), (.cerebras, "CEREBRAS_API_KEY"), | |
| 65 | + ] | |
| 66 | + let store = SecureKeyStore() | |
| 67 | + var stored: [String] = [] | |
| 68 | + for (provider, variable) in mapping { | |
| 69 | + if let value = ProcessInfo.processInfo.environment[variable], !value.isEmpty { | |
| 70 | + try? store.setKey(value, for: provider) | |
| 71 | + stored.append(provider.rawValue) | |
| 72 | + } | |
| 73 | + } | |
| 74 | + print("Vault updated (\(stored.count) providers): \(stored.joined(separator: ", "))") | |
| 75 | + } | |
| 46 | 76 | } |
added
Sources/ZyquoRouter/Router/UpstreamCall.swift
+137 −0
@@ -0,0 +1,137 @@ | ||
| 1 | +// | |
| 2 | +// UpstreamCall.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Executes one request against one upstream provider: endpoint + auth | |
| 9 | +// construction per wire format, JSON POST for non-streaming (retries live | |
| 10 | +// in StreamingService.postJSON), raw SSE event stream for streaming. | |
| 11 | +// Cancellation of the calling task cancels the upstream transfer. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +struct UpstreamCall { | |
| 17 | + let model: AIModel | |
| 18 | + let apiKey: String | |
| 19 | + | |
| 20 | + private var provider: ProviderID { model.provider } | |
| 21 | + | |
| 22 | + enum CallError: Error { | |
| 23 | + case noEndpoint(ProviderID) | |
| 24 | + } | |
| 25 | + | |
| 26 | + // MARK: - Endpoint + auth | |
| 27 | + | |
| 28 | + private func urlRequest(streaming: Bool, body: [String: Any]) throws -> URLRequest { | |
| 29 | + let url: URL | |
| 30 | + switch provider.wireFormat { | |
| 31 | + case .anthropicMessages: | |
| 32 | + guard let base = model.customBaseURL ?? provider.defaultBaseURL else { | |
| 33 | + throw CallError.noEndpoint(provider) | |
| 34 | + } | |
| 35 | + url = base.appendingPathComponent("messages") | |
| 36 | + case .openAIChatCompletions where provider == .gemini && Self.usesNativeGemini: | |
| 37 | + // Native generateContent (D10): Cloud's catalog stores the | |
| 38 | + // OpenAI-compat base (…/v1beta/openai); derive the native root. | |
| 39 | + let root = "https://generativelanguage.googleapis.com/v1beta" | |
| 40 | + let verb = streaming ? "streamGenerateContent?alt=sse" : "generateContent" | |
| 41 | + guard let native = URL(string: "\(root)/models/\(model.id):\(verb)") else { | |
| 42 | + throw CallError.noEndpoint(provider) | |
| 43 | + } | |
| 44 | + url = native | |
| 45 | + case .openAIChatCompletions: | |
| 46 | + guard let base = model.customBaseURL ?? provider.defaultBaseURL else { | |
| 47 | + throw CallError.noEndpoint(provider) | |
| 48 | + } | |
| 49 | + url = base.appendingPathComponent("chat/completions") | |
| 50 | + } | |
| 51 | + | |
| 52 | + var request = URLRequest(url: url) | |
| 53 | + request.httpMethod = "POST" | |
| 54 | + request.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
| 55 | + switch provider { | |
| 56 | + case .anthropic: | |
| 57 | + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") | |
| 58 | + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") | |
| 59 | + case .gemini: | |
| 60 | + request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key") | |
| 61 | + default: | |
| 62 | + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") | |
| 63 | + } | |
| 64 | + if streaming { | |
| 65 | + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") | |
| 66 | + } | |
| 67 | + request.httpBody = try JSONSerialization.data(withJSONObject: body) | |
| 68 | + return request | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// Gemini goes through the native generateContent translation. | |
| 72 | + static let usesNativeGemini = true | |
| 73 | + | |
| 74 | + /// Whether this call's upstream speaks the native Gemini API. | |
| 75 | + var isNativeGemini: Bool { provider == .gemini && Self.usesNativeGemini } | |
| 76 | + | |
| 77 | + // MARK: - Execution | |
| 78 | + | |
| 79 | + /// Non-streaming: returns the upstream JSON object. | |
| 80 | + /// StreamingService.postJSON already retries 429/5xx with backoff. | |
| 81 | + func complete(body: [String: Any]) async throws -> [String: Any] { | |
| 82 | + let request = try urlRequest(streaming: false, body: body) | |
| 83 | + let data = try await StreamingService.postJSON(request, provider: provider) | |
| 84 | + guard let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { | |
| 85 | + throw ProviderError.invalidResponse(provider, detail: "response is not a JSON object") | |
| 86 | + } | |
| 87 | + return json | |
| 88 | + } | |
| 89 | + | |
| 90 | + /// Streaming: raw upstream SSE events. Errors before the first event are | |
| 91 | + /// retryable by the caller (never after the first forwarded byte). | |
| 92 | + func stream(body: [String: Any]) throws -> AsyncThrowingStream<SSEEvent, Error> { | |
| 93 | + let request = try urlRequest(streaming: true, body: body) | |
| 94 | + return StreamingService.sseEvents(for: request, provider: provider) | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +// MARK: - ProviderError → OpenAI wire error | |
| 99 | + | |
| 100 | +extension ProviderError { | |
| 101 | + /// Maps upstream failures to (HTTP status, OpenAI error type/code) per | |
| 102 | + /// decision D7 — clear messages, no provider payload shapes, no keys. | |
| 103 | + var openAIWire: (status: Int, type: String, code: String?, message: String) { | |
| 104 | + switch self { | |
| 105 | + case .invalidAPIKey(let provider): | |
| 106 | + return (401, "authentication_error", "invalid_provider_key", | |
| 107 | + "The stored \(provider.displayName) API key was rejected upstream. Update it in Zyquo Router → Keys.") | |
| 108 | + case .missingAPIKey(let provider): | |
| 109 | + return (401, "authentication_error", "missing_provider_key", | |
| 110 | + "No \(provider.displayName) API key is configured. Add one in Zyquo Router → Keys.") | |
| 111 | + case .rateLimited(let provider, let retryAfter): | |
| 112 | + let hint = retryAfter.map { " Retry in \(Int($0.rounded()))s." } ?? "" | |
| 113 | + return (429, "rate_limit_error", "upstream_rate_limited", | |
| 114 | + "\(provider.displayName) rate-limited the request.\(hint)") | |
| 115 | + case .badRequest(let provider, let message): | |
| 116 | + return (400, "invalid_request_error", nil, | |
| 117 | + "\(provider.displayName) rejected the request\(message.map { ": \($0)" } ?? ".")") | |
| 118 | + case .serverError(let provider, let status, _): | |
| 119 | + return (502, "api_error", "upstream_error", | |
| 120 | + "\(provider.displayName) upstream error (HTTP \(status)).") | |
| 121 | + case .networkError(let underlying): | |
| 122 | + if (underlying as? URLError)?.code == .timedOut { | |
| 123 | + return (504, "api_error", "upstream_timeout", "The upstream request timed out.") | |
| 124 | + } | |
| 125 | + return (502, "api_error", "upstream_unreachable", | |
| 126 | + "Could not reach the upstream provider: \(underlying.localizedDescription)") | |
| 127 | + case .invalidResponse(let provider, let detail): | |
| 128 | + return (502, "api_error", "upstream_invalid_response", | |
| 129 | + "Unexpected response from \(provider.displayName): \(detail)") | |
| 130 | + case .noModelAvailable(let provider): | |
| 131 | + return (404, "invalid_request_error", "model_not_found", | |
| 132 | + "No model available for \(provider.displayName).") | |
| 133 | + case .cancelled: | |
| 134 | + return (499, "api_error", "client_disconnected", "The client disconnected.") | |
| 135 | + } | |
| 136 | + } | |
| 137 | +} | |
added
Sources/ZyquoRouter/Server/ChatCompletionsRoute.swift
+508 −0
@@ -0,0 +1,508 @@ | ||
| 1 | +// | |
| 2 | +// ChatCompletionsRoute.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// POST /v1/chat/completions — the core of the router. Parses the OpenAI | |
| 9 | +// request, resolves the model (aliases, fallback chains), checks | |
| 10 | +// capabilities, calls the upstream through the right translation path, | |
| 11 | +// and returns a spec-exact response or byte-exact chunk stream. Upstream | |
| 12 | +// failures become OpenAI-shaped errors (D7); transient ones retry before | |
| 13 | +// the first forwarded byte (D8); usage is metered, estimated-and-flagged | |
| 14 | +// when the upstream reports none (D9). | |
| 15 | +// | |
| 16 | + | |
| 17 | +import Foundation | |
| 18 | +import NIOHTTP1 | |
| 19 | + | |
| 20 | +struct ChatCompletionsRoute { | |
| 21 | + let router: RequestRouter | |
| 22 | + let providerKey: @Sendable (ProviderID) -> String? | |
| 23 | + let usageMeter: UsageMeter | |
| 24 | + let retryPolicy: RetryPolicy | |
| 25 | + | |
| 26 | + func handle(_ request: RouteRequest, localKey: APIKeyRecord?) async -> RouteResult { | |
| 27 | + // Parse. | |
| 28 | + let chat: ChatCompletionRequest | |
| 29 | + do { | |
| 30 | + chat = try ChatCompletionRequest(body: request.body) | |
| 31 | + } catch let error as ChatCompletionRequest.ParseError { | |
| 32 | + return OpenAIError.response( | |
| 33 | + status: .badRequest, | |
| 34 | + message: error.localizedDescription, | |
| 35 | + type: "invalid_request_error", | |
| 36 | + param: error.param | |
| 37 | + ) | |
| 38 | + } catch { | |
| 39 | + return OpenAIError.response( | |
| 40 | + status: .badRequest, | |
| 41 | + message: "Malformed JSON body.", | |
| 42 | + type: "invalid_request_error" | |
| 43 | + ) | |
| 44 | + } | |
| 45 | + | |
| 46 | + // Resolve the primary model + any configured fallback chain. | |
| 47 | + var candidates: [RequestRouter.Resolution] = [] | |
| 48 | + do { | |
| 49 | + let primary = try router.resolve(chat.model) | |
| 50 | + candidates.append(primary) | |
| 51 | + for fallbackID in router.fallbackChains[primary.namespacedID] ?? [] { | |
| 52 | + if let fallback = try? router.resolve(fallbackID) { | |
| 53 | + candidates.append(fallback) | |
| 54 | + } | |
| 55 | + } | |
| 56 | + } catch let error as RequestRouter.RoutingError { | |
| 57 | + return Routes.routingErrorResponse(error) | |
| 58 | + } catch { | |
| 59 | + return OpenAIError.response(status: .internalServerError, message: "Internal error.", type: "server_error") | |
| 60 | + } | |
| 61 | + | |
| 62 | + // Per-key model allow-list. | |
| 63 | + if let allowed = localKey?.allowedModels, | |
| 64 | + let primary = candidates.first, !allowed.contains(primary.namespacedID) { | |
| 65 | + return OpenAIError.response( | |
| 66 | + status: .forbidden, | |
| 67 | + message: "This API key is not allowed to use `\(candidates[0].namespacedID)`.", | |
| 68 | + type: "permission_error", | |
| 69 | + code: "model_not_allowed" | |
| 70 | + ) | |
| 71 | + } | |
| 72 | + | |
| 73 | + // Try candidates in order; report the actually-used model honestly. | |
| 74 | + var lastFailure: ProviderError = .noModelAvailable(candidates[0].model.provider) | |
| 75 | + for (index, resolution) in candidates.enumerated() { | |
| 76 | + let isLastCandidate = index == candidates.count - 1 | |
| 77 | + switch await attempt(chat: chat, resolution: resolution, request: request, localKey: localKey) { | |
| 78 | + case .success(let result): | |
| 79 | + return result | |
| 80 | + case .failure(let error): | |
| 81 | + lastFailure = error | |
| 82 | + if isLastCandidate || !shouldFallback(on: error) { | |
| 83 | + return errorResponse(for: error) | |
| 84 | + } | |
| 85 | + } | |
| 86 | + } | |
| 87 | + return errorResponse(for: lastFailure) | |
| 88 | + } | |
| 89 | + | |
| 90 | + // MARK: - One candidate attempt | |
| 91 | + | |
| 92 | + private enum AttemptOutcome { | |
| 93 | + case success(RouteResult) | |
| 94 | + case failure(ProviderError) | |
| 95 | + } | |
| 96 | + | |
| 97 | + private func attempt( | |
| 98 | + chat: ChatCompletionRequest, | |
| 99 | + resolution: RequestRouter.Resolution, | |
| 100 | + request: RouteRequest, | |
| 101 | + localKey: APIKeyRecord? | |
| 102 | + ) async -> AttemptOutcome { | |
| 103 | + let model = resolution.model | |
| 104 | + | |
| 105 | + // Capability gates — clear OpenAI errors instead of upstream 400s. | |
| 106 | + if chat.hasTools, !model.capabilities.tools { | |
| 107 | + return .failure(.badRequest(model.provider, message: "`\(resolution.namespacedID)` does not support tools/function calling")) | |
| 108 | + } | |
| 109 | + if chat.hasImageContent, !model.capabilities.vision { | |
| 110 | + return .failure(.badRequest(model.provider, message: "`\(resolution.namespacedID)` does not accept image input")) | |
| 111 | + } | |
| 112 | + guard let apiKey = providerKey(model.provider) else { | |
| 113 | + return .failure(.missingAPIKey(model.provider)) | |
| 114 | + } | |
| 115 | + let call = UpstreamCall(model: model, apiKey: apiKey) | |
| 116 | + if call.isNativeGemini, GeminiTranslator.hasRemoteImageURL(chat) { | |
| 117 | + return .failure(.badRequest(model.provider, message: "Gemini requires images as base64 data URIs — remote image URLs are not fetched by the router")) | |
| 118 | + } | |
| 119 | + | |
| 120 | + // A model that rejects non-streaming calls is transparently streamed | |
| 121 | + // and aggregated when the client asked for a buffered response. | |
| 122 | + let clientWantsStream = chat.stream | |
| 123 | + let mustStreamUpstream = model.parameterSupport.requiresStreaming | |
| 124 | + | |
| 125 | + if clientWantsStream { | |
| 126 | + return await streamingAttempt(chat: chat, resolution: resolution, call: call, localKey: localKey) | |
| 127 | + } | |
| 128 | + if mustStreamUpstream { | |
| 129 | + return await aggregatedStreamingAttempt(chat: chat, resolution: resolution, call: call, localKey: localKey) | |
| 130 | + } | |
| 131 | + return await bufferedAttempt(chat: chat, resolution: resolution, call: call, localKey: localKey) | |
| 132 | + } | |
| 133 | + | |
| 134 | + private func shouldFallback(on error: ProviderError) -> Bool { | |
| 135 | + switch error { | |
| 136 | + case .rateLimited, .serverError, .networkError, .invalidResponse, .missingAPIKey, .invalidAPIKey: | |
| 137 | + return true | |
| 138 | + case .badRequest, .noModelAvailable, .cancelled: | |
| 139 | + return false | |
| 140 | + } | |
| 141 | + } | |
| 142 | + | |
| 143 | + private func errorResponse(for error: ProviderError) -> RouteResult { | |
| 144 | + let wire = error.openAIWire | |
| 145 | + var extraHeaders: [(String, String)] = [] | |
| 146 | + if case .rateLimited(_, let retryAfter) = error, let retryAfter { | |
| 147 | + extraHeaders.append(("Retry-After", String(Int(retryAfter.rounded())))) | |
| 148 | + } | |
| 149 | + return OpenAIError.response( | |
| 150 | + status: HTTPResponseStatus(statusCode: wire.status), | |
| 151 | + message: wire.message, | |
| 152 | + type: wire.type, | |
| 153 | + code: wire.code, | |
| 154 | + extraHeaders: extraHeaders | |
| 155 | + ) | |
| 156 | + } | |
| 157 | + | |
| 158 | + // MARK: - Buffered (non-streaming) | |
| 159 | + | |
| 160 | + private func bufferedAttempt( | |
| 161 | + chat: ChatCompletionRequest, | |
| 162 | + resolution: RequestRouter.Resolution, | |
| 163 | + call: UpstreamCall, | |
| 164 | + localKey: APIKeyRecord? | |
| 165 | + ) async -> AttemptOutcome { | |
| 166 | + let started = Date() | |
| 167 | + let emitter = ChunkEmitter(model: resolution.namespacedID) | |
| 168 | + do { | |
| 169 | + let response: [String: Any] | |
| 170 | + switch upstreamKind(call) { | |
| 171 | + case .anthropic: | |
| 172 | + let body = AnthropicTranslator.buildRequest(chat, model: resolution.model) | |
| 173 | + response = AnthropicTranslator.translateResponse(try await call.complete(body: body), emitter: emitter) | |
| 174 | + case .gemini: | |
| 175 | + let body = GeminiTranslator.buildRequest(chat, model: resolution.model) | |
| 176 | + let upstream = try await call.complete(body: body) | |
| 177 | + if let blocked = GeminiTranslator.blockReason(upstream) { | |
| 178 | + return .failure(.badRequest(.gemini, message: "Gemini blocked the prompt (reason: \(blocked))")) | |
| 179 | + } | |
| 180 | + response = GeminiTranslator.translateResponse(upstream, emitter: emitter) | |
| 181 | + case .compat: | |
| 182 | + let body = CompatAdjuster.adjustRequest(chat.raw, model: resolution.model, stream: false) | |
| 183 | + var normalized = CompatAdjuster.normalizeResponse( | |
| 184 | + try await call.complete(body: body), | |
| 185 | + namespacedModel: resolution.namespacedID, | |
| 186 | + provider: resolution.model.provider | |
| 187 | + ) | |
| 188 | + if normalized["usage"] == nil { | |
| 189 | + normalized["usage"] = estimatedUsage(chat: chat, outputText: Self.responseText(normalized)) | |
| 190 | + } | |
| 191 | + response = normalized | |
| 192 | + } | |
| 193 | + | |
| 194 | + await meter(response: response, resolution: resolution, localKey: localKey, started: started, streamed: false, status: 200) | |
| 195 | + return .success(.complete( | |
| 196 | + status: .ok, | |
| 197 | + headers: [("Content-Type", "application/json")], | |
| 198 | + body: ChunkEmitter.serialize(response) | |
| 199 | + )) | |
| 200 | + } catch let error as ProviderError { | |
| 201 | + await meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: false, error: error) | |
| 202 | + return .failure(error) | |
| 203 | + } catch { | |
| 204 | + let wrapped = ProviderError.networkError(underlying: error) | |
| 205 | + await meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: false, error: wrapped) | |
| 206 | + return .failure(wrapped) | |
| 207 | + } | |
| 208 | + } | |
| 209 | + | |
| 210 | + // MARK: - Streaming | |
| 211 | + | |
| 212 | + private func streamingAttempt( | |
| 213 | + chat: ChatCompletionRequest, | |
| 214 | + resolution: RequestRouter.Resolution, | |
| 215 | + call: UpstreamCall, | |
| 216 | + localKey: APIKeyRecord? | |
| 217 | + ) async -> AttemptOutcome { | |
| 218 | + // Pre-flight retries: transient failures before ANY byte reaches the | |
| 219 | + // client are retried/fallback-able. Open the upstream stream and pull | |
| 220 | + // its first event before committing to the client response. | |
| 221 | + var attempt = 1 | |
| 222 | + while true { | |
| 223 | + do { | |
| 224 | + let (events, firstEvent) = try await openUpstreamStream(chat: chat, call: call, resolution: resolution) | |
| 225 | + return .success(streamResult( | |
| 226 | + chat: chat, resolution: resolution, call: call, localKey: localKey, | |
| 227 | + events: events, firstEvent: firstEvent | |
| 228 | + )) | |
| 229 | + } catch let error as ProviderError { | |
| 230 | + let retryable: Bool | |
| 231 | + switch error { | |
| 232 | + case .rateLimited: retryable = true | |
| 233 | + case .serverError(_, let status, _): retryable = retryPolicy.shouldRetry(status: status, attempt: attempt) | |
| 234 | + case .networkError: retryable = attempt < retryPolicy.maxAttempts | |
| 235 | + default: retryable = false | |
| 236 | + } | |
| 237 | + var retryAfter: TimeInterval? | |
| 238 | + if case .rateLimited(_, let after) = error { retryAfter = after } | |
| 239 | + guard retryable, attempt < retryPolicy.maxAttempts else { return .failure(error) } | |
| 240 | + try? await Task.sleep(nanoseconds: UInt64(retryPolicy.delay(attempt: attempt, retryAfter: retryAfter) * 1_000_000_000)) | |
| 241 | + attempt += 1 | |
| 242 | + } catch { | |
| 243 | + return .failure(.networkError(underlying: error)) | |
| 244 | + } | |
| 245 | + } | |
| 246 | + } | |
| 247 | + | |
| 248 | + /// Opens the upstream stream and awaits its first event so that upstream | |
| 249 | + /// HTTP errors surface here (retryable) instead of mid-client-stream. | |
| 250 | + private func openUpstreamStream( | |
| 251 | + chat: ChatCompletionRequest, | |
| 252 | + call: UpstreamCall, | |
| 253 | + resolution: RequestRouter.Resolution | |
| 254 | + ) async throws -> (AsyncThrowingStream<SSEEvent, Error>.AsyncIterator, SSEEvent?) { | |
| 255 | + let body: [String: Any] | |
| 256 | + switch upstreamKind(call) { | |
| 257 | + case .anthropic: | |
| 258 | + body = AnthropicTranslator.buildRequest(chat, model: resolution.model) | |
| 259 | + case .gemini: | |
| 260 | + body = GeminiTranslator.buildRequest(chat, model: resolution.model) | |
| 261 | + case .compat: | |
| 262 | + body = CompatAdjuster.adjustRequest(chat.raw, model: resolution.model, stream: true) | |
| 263 | + } | |
| 264 | + var iterator = try call.stream(body: body).makeAsyncIterator() | |
| 265 | + let first = try await iterator.next() | |
| 266 | + return (iterator, first) | |
| 267 | + } | |
| 268 | + | |
| 269 | + private func streamResult( | |
| 270 | + chat: ChatCompletionRequest, | |
| 271 | + resolution: RequestRouter.Resolution, | |
| 272 | + call: UpstreamCall, | |
| 273 | + localKey: APIKeyRecord?, | |
| 274 | + events: AsyncThrowingStream<SSEEvent, Error>.AsyncIterator, | |
| 275 | + firstEvent: SSEEvent? | |
| 276 | + ) -> RouteResult { | |
| 277 | + let started = Date() | |
| 278 | + return .stream(status: .ok, headers: []) { writer in | |
| 279 | + var iterator = events | |
| 280 | + var next = firstEvent | |
| 281 | + var usage: [String: Any]? | |
| 282 | + var finishReason: String? | |
| 283 | + var outputChars = 0 | |
| 284 | + | |
| 285 | + func iterate(_ handle: (SSEEvent) async throws -> Void) async throws { | |
| 286 | + while let event = next { | |
| 287 | + try await handle(event) | |
| 288 | + next = try await iterator.next() | |
| 289 | + } | |
| 290 | + } | |
| 291 | + | |
| 292 | + do { | |
| 293 | + switch self.upstreamKind(call) { | |
| 294 | + case .anthropic: | |
| 295 | + var machine = AnthropicTranslator.StreamMachine( | |
| 296 | + emitter: ChunkEmitter(model: resolution.namespacedID), | |
| 297 | + includeUsage: chat.includeUsage | |
| 298 | + ) | |
| 299 | + try await iterate { event in | |
| 300 | + let (payloads, _) = machine.consume(event) | |
| 301 | + for payload in payloads { try await writer.send(raw: payload) } | |
| 302 | + } | |
| 303 | + usage = UsageBuilder.build( | |
| 304 | + promptTokens: machine.promptTokens, | |
| 305 | + completionTokens: machine.completionTokens, | |
| 306 | + cachedTokens: machine.cachedTokens > 0 ? machine.cachedTokens : nil | |
| 307 | + ) | |
| 308 | + finishReason = machine.finishReasonSent | |
| 309 | + | |
| 310 | + case .gemini: | |
| 311 | + var machine = GeminiTranslator.StreamMachine( | |
| 312 | + emitter: ChunkEmitter(model: resolution.namespacedID), | |
| 313 | + includeUsage: chat.includeUsage | |
| 314 | + ) | |
| 315 | + try await iterate { event in | |
| 316 | + for payload in machine.consume(event) { try await writer.send(raw: payload) } | |
| 317 | + } | |
| 318 | + for payload in machine.finalPayloads() { try await writer.send(raw: payload) } | |
| 319 | + usage = machine.lastUsage.map(GeminiTranslator.normalizedUsage) | |
| 320 | + finishReason = "stop" | |
| 321 | + | |
| 322 | + case .compat: | |
| 323 | + try await iterate { event in | |
| 324 | + if event.data == "[DONE]" { return } | |
| 325 | + guard let json = (try? JSONSerialization.jsonObject(with: Data(event.data.utf8))) as? [String: Any] else { return } | |
| 326 | + guard let chunk = CompatAdjuster.normalizeChunk( | |
| 327 | + json, | |
| 328 | + namespacedModel: resolution.namespacedID, | |
| 329 | + provider: resolution.model.provider, | |
| 330 | + clientWantsUsage: chat.includeUsage | |
| 331 | + ) else { | |
| 332 | + // Swallowed usage-only chunk: still meter it. | |
| 333 | + if let chunkUsage = json["usage"] as? [String: Any] { usage = chunkUsage } | |
| 334 | + return | |
| 335 | + } | |
| 336 | + if let chunkUsage = chunk["usage"] as? [String: Any] { usage = chunkUsage } | |
| 337 | + if let choices = chunk["choices"] as? [[String: Any]] { | |
| 338 | + for choice in choices { | |
| 339 | + if let finish = choice["finish_reason"] as? String { finishReason = finish } | |
| 340 | + if let delta = choice["delta"] as? [String: Any], | |
| 341 | + let content = delta["content"] as? String { | |
| 342 | + outputChars += content.count | |
| 343 | + } | |
| 344 | + } | |
| 345 | + } | |
| 346 | + try await writer.send(raw: ChunkEmitter.serialize(chunk)) | |
| 347 | + } | |
| 348 | + // Client asked for usage but the upstream never sent it. | |
| 349 | + if chat.includeUsage, usage == nil { | |
| 350 | + let estimated = self.estimatedUsage(chat: chat, outputText: String(repeating: "x", count: outputChars)) | |
| 351 | + usage = estimated | |
| 352 | + let emitter = ChunkEmitter(model: resolution.namespacedID) | |
| 353 | + try await writer.send(raw: emitter.usageChunk(estimated)) | |
| 354 | + } | |
| 355 | + } | |
| 356 | + try await writer.sendDone() | |
| 357 | + await self.meter( | |
| 358 | + usageDict: usage, finishReason: finishReason, resolution: resolution, | |
| 359 | + localKey: localKey, started: started, streamed: true, status: 200 | |
| 360 | + ) | |
| 361 | + } catch let error as ProviderError { | |
| 362 | + // Mid-stream failure: never retry (bytes were forwarded). | |
| 363 | + // Emit a LiteLLM-style error frame, then terminate. | |
| 364 | + await self.meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: true, error: error) | |
| 365 | + let wire = error.openAIWire | |
| 366 | + let frame = OpenAIError(error: .init(message: wire.message, type: wire.type, param: nil, code: wire.code)) | |
| 367 | + try? await writer.send(raw: (try? JSONEncoder().encode(frame)) ?? Data()) | |
| 368 | + try? await writer.sendDone() | |
| 369 | + } | |
| 370 | + } | |
| 371 | + } | |
| 372 | + | |
| 373 | + /// Client asked non-streaming but the model only streams: aggregate. | |
| 374 | + private func aggregatedStreamingAttempt( | |
| 375 | + chat: ChatCompletionRequest, | |
| 376 | + resolution: RequestRouter.Resolution, | |
| 377 | + call: UpstreamCall, | |
| 378 | + localKey: APIKeyRecord? | |
| 379 | + ) async -> AttemptOutcome { | |
| 380 | + let started = Date() | |
| 381 | + do { | |
| 382 | + let body = CompatAdjuster.adjustRequest(chat.raw, model: resolution.model, stream: true) | |
| 383 | + var content = "" | |
| 384 | + var reasoning = "" | |
| 385 | + var toolCalls: [Int: [String: Any]] = [:] | |
| 386 | + var finishReason = "stop" | |
| 387 | + var usage: [String: Any]? | |
| 388 | + | |
| 389 | + for try await event in try call.stream(body: body) { | |
| 390 | + if event.data == "[DONE]" { break } | |
| 391 | + guard let json = (try? JSONSerialization.jsonObject(with: Data(event.data.utf8))) as? [String: Any], | |
| 392 | + let chunk = CompatAdjuster.normalizeChunk( | |
| 393 | + json, namespacedModel: resolution.namespacedID, | |
| 394 | + provider: resolution.model.provider, clientWantsUsage: true | |
| 395 | + ) else { continue } | |
| 396 | + if let chunkUsage = chunk["usage"] as? [String: Any] { usage = chunkUsage } | |
| 397 | + for choice in chunk["choices"] as? [[String: Any]] ?? [] { | |
| 398 | + if let finish = choice["finish_reason"] as? String { finishReason = finish } | |
| 399 | + guard let delta = choice["delta"] as? [String: Any] else { continue } | |
| 400 | + content += delta["content"] as? String ?? "" | |
| 401 | + reasoning += delta["reasoning_content"] as? String ?? "" | |
| 402 | + for call in delta["tool_calls"] as? [[String: Any]] ?? [] { | |
| 403 | + let index = call["index"] as? Int ?? 0 | |
| 404 | + var existing = toolCalls[index] ?? ["type": "function", "function": ["name": "", "arguments": ""]] | |
| 405 | + if let id = call["id"] as? String { existing["id"] = id } | |
| 406 | + if let function = call["function"] as? [String: Any] { | |
| 407 | + var merged = existing["function"] as? [String: Any] ?? [:] | |
| 408 | + if let name = function["name"] as? String, !name.isEmpty { merged["name"] = name } | |
| 409 | + merged["arguments"] = (merged["arguments"] as? String ?? "") + (function["arguments"] as? String ?? "") | |
| 410 | + existing["function"] = merged | |
| 411 | + } | |
| 412 | + toolCalls[index] = existing | |
| 413 | + } | |
| 414 | + } | |
| 415 | + } | |
| 416 | + | |
| 417 | + var message: [String: Any] = ["role": "assistant", "content": content] | |
| 418 | + if !reasoning.isEmpty { message["reasoning_content"] = reasoning } | |
| 419 | + if !toolCalls.isEmpty { | |
| 420 | + message["tool_calls"] = toolCalls.sorted { $0.key < $1.key }.map(\.value) | |
| 421 | + if finishReason == "stop" { finishReason = "tool_calls" } | |
| 422 | + } | |
| 423 | + let emitter = ChunkEmitter(model: resolution.namespacedID) | |
| 424 | + let response = emitter.completion( | |
| 425 | + message: message, | |
| 426 | + finishReason: finishReason, | |
| 427 | + usage: usage ?? estimatedUsage(chat: chat, outputText: content + reasoning) | |
| 428 | + ) | |
| 429 | + await meter(response: response, resolution: resolution, localKey: localKey, started: started, streamed: false, status: 200) | |
| 430 | + return .success(.complete(status: .ok, headers: [("Content-Type", "application/json")], body: ChunkEmitter.serialize(response))) | |
| 431 | + } catch let error as ProviderError { | |
| 432 | + await meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: false, error: error) | |
| 433 | + return .failure(error) | |
| 434 | + } catch { | |
| 435 | + return .failure(.networkError(underlying: error)) | |
| 436 | + } | |
| 437 | + } | |
| 438 | + | |
| 439 | + // MARK: - Shared helpers | |
| 440 | + | |
| 441 | + private enum UpstreamKind { | |
| 442 | + case anthropic, gemini, compat | |
| 443 | + } | |
| 444 | + | |
| 445 | + private func upstreamKind(_ call: UpstreamCall) -> UpstreamKind { | |
| 446 | + if call.model.provider == .anthropic { return .anthropic } | |
| 447 | + if call.isNativeGemini { return .gemini } | |
| 448 | + return .compat | |
| 449 | + } | |
| 450 | + | |
| 451 | + private func estimatedUsage(chat: ChatCompletionRequest, outputText: String) -> [String: Any] { | |
| 452 | + let promptText = chat.messages.map(\.flattenedText).joined(separator: "\n") | |
| 453 | + return UsageBuilder.build( | |
| 454 | + promptTokens: UsageBuilder.estimateTokens(promptText), | |
| 455 | + completionTokens: UsageBuilder.estimateTokens(outputText), | |
| 456 | + estimated: true | |
| 457 | + ) | |
| 458 | + } | |
| 459 | + | |
| 460 | + private static func responseText(_ response: [String: Any]) -> String { | |
| 461 | + ((response["choices"] as? [[String: Any]])?.first?["message"] as? [String: Any])?["content"] as? String ?? "" | |
| 462 | + } | |
| 463 | + | |
| 464 | + private func meter( | |
| 465 | + response: [String: Any]? = nil, | |
| 466 | + usageDict: [String: Any]? = nil, | |
| 467 | + finishReason: String? = nil, | |
| 468 | + resolution: RequestRouter.Resolution, | |
| 469 | + localKey: APIKeyRecord?, | |
| 470 | + started: Date, | |
| 471 | + streamed: Bool, | |
| 472 | + status: Int | |
| 473 | + ) async { | |
| 474 | + let usage = usageDict ?? response?["usage"] as? [String: Any] ?? [:] | |
| 475 | + let prompt = usage["prompt_tokens"] as? Int ?? 0 | |
| 476 | + let completion = usage["completion_tokens"] as? Int ?? 0 | |
| 477 | + let estimated = (usage["x_zyquo"] as? [String: Any])?["usage_estimated"] as? Bool ?? false | |
| 478 | + let reasoningTokens = (usage["completion_tokens_details"] as? [String: Any])?["reasoning_tokens"] as? Int | |
| 479 | + await usageMeter.record(UsageRecord( | |
| 480 | + namespacedModelID: resolution.namespacedID, | |
| 481 | + provider: resolution.model.provider, | |
| 482 | + localKeyName: localKey?.name, | |
| 483 | + usage: TokenUsage(inputTokens: prompt, outputTokens: completion, reasoningTokens: reasoningTokens), | |
| 484 | + usageEstimated: estimated, | |
| 485 | + estimatedCost: resolution.model.pricing?.cost(inputTokens: prompt, outputTokens: completion), | |
| 486 | + latency: Date().timeIntervalSince(started), | |
| 487 | + streamed: streamed, | |
| 488 | + status: status | |
| 489 | + )) | |
| 490 | + } | |
| 491 | + | |
| 492 | + private func meterFailure( | |
| 493 | + resolution: RequestRouter.Resolution, | |
| 494 | + localKey: APIKeyRecord?, | |
| 495 | + started: Date, | |
| 496 | + streamed: Bool, | |
| 497 | + error: ProviderError | |
| 498 | + ) async { | |
| 499 | + await usageMeter.record(UsageRecord( | |
| 500 | + namespacedModelID: resolution.namespacedID, | |
| 501 | + provider: resolution.model.provider, | |
| 502 | + localKeyName: localKey?.name, | |
| 503 | + latency: Date().timeIntervalSince(started), | |
| 504 | + streamed: streamed, | |
| 505 | + status: error.openAIWire.status | |
| 506 | + )) | |
| 507 | + } | |
| 508 | +} | |
modified
Sources/ZyquoRouter/Server/Routes.swift
+19 −5
@@ -18,6 +18,7 @@ struct Routes: Sendable { | ||
| 18 | 18 | let cors: CORS |
| 19 | 19 | let version: String |
| 20 | 20 | let startedAt: Date |
| 21 | + let chat: ChatCompletionsRoute | |
| 21 | 22 | |
| 22 | 23 | private static let encoder: JSONEncoder = { |
| 23 | 24 | let encoder = JSONEncoder() |
@@ -30,13 +31,23 @@ struct Routes: Sendable { | ||
| 30 | 31 | auth: AuthMiddleware = AuthMiddleware(), |
| 31 | 32 | cors: CORS = CORS(), |
| 32 | 33 | version: String = "1.0.0", |
| 33 | − startedAt: Date = Date() | |
| 34 | + startedAt: Date = Date(), | |
| 35 | + providerKey: @escaping @Sendable (ProviderID) -> String? = { provider in | |
| 36 | + try? SecureKeyStore().key(for: provider) | |
| 37 | + }, | |
| 38 | + usageMeter: UsageMeter = UsageMeter() | |
| 34 | 39 | ) { |
| 35 | 40 | self.router = router |
| 36 | 41 | self.auth = auth |
| 37 | 42 | self.cors = cors |
| 38 | 43 | self.version = version |
| 39 | 44 | self.startedAt = startedAt |
| 45 | + self.chat = ChatCompletionsRoute( | |
| 46 | + router: router, | |
| 47 | + providerKey: providerKey, | |
| 48 | + usageMeter: usageMeter, | |
| 49 | + retryPolicy: RetryPolicy() | |
| 50 | + ) | |
| 40 | 51 | } |
| 41 | 52 | |
| 42 | 53 | func handle(_ request: RouteRequest) async -> RouteResult { |
@@ -51,6 +62,7 @@ struct Routes: Sendable { | ||
| 51 | 62 | return withCORS(health()) |
| 52 | 63 | } |
| 53 | 64 | |
| 65 | + let localKey: APIKeyRecord? | |
| 54 | 66 | switch auth.authorize(request) { |
| 55 | 67 | case .unauthorized(let message): |
| 56 | 68 | return withCORS(OpenAIError.response( |
@@ -59,11 +71,13 @@ struct Routes: Sendable { | ||
| 59 | 71 | type: "authentication_error", |
| 60 | 72 | code: "invalid_api_key" |
| 61 | 73 | )) |
| 62 | − case .allowed: | |
| 63 | − break | |
| 74 | + case .allowed(let record): | |
| 75 | + localKey = record | |
| 64 | 76 | } |
| 65 | 77 | |
| 66 | 78 | switch (request.method, path) { |
| 79 | + case (.POST, "/v1/chat/completions"): | |
| 80 | + return withCORS(await chat.handle(request, localKey: localKey)) | |
| 67 | 81 | case (.GET, "/v1/models"): |
| 68 | 82 | return withCORS(modelList()) |
| 69 | 83 | case (.GET, _) where path.hasPrefix("/v1/models/"): |
@@ -146,14 +160,14 @@ struct Routes: Sendable { | ||
| 146 | 160 | ) |
| 147 | 161 | return json(entry) |
| 148 | 162 | } catch let error as RequestRouter.RoutingError { |
| 149 | − return routingErrorResponse(error) | |
| 163 | + return Self.routingErrorResponse(error) | |
| 150 | 164 | } catch { |
| 151 | 165 | return OpenAIError.response(status: .internalServerError, message: "Internal error.", type: "server_error") |
| 152 | 166 | } |
| 153 | 167 | } |
| 154 | 168 | |
| 155 | 169 | /// Shared mapping used by every route that resolves a model. |
| 156 | − func routingErrorResponse(_ error: RequestRouter.RoutingError) -> RouteResult { | |
| 170 | + static func routingErrorResponse(_ error: RequestRouter.RoutingError) -> RouteResult { | |
| 157 | 171 | switch error { |
| 158 | 172 | case .unknownModel(let name), .disabledModel(let name): |
| 159 | 173 | return OpenAIError.modelNotFound(name) |
added
Sources/ZyquoRouter/Translate/AnthropicTranslator.swift
+375 −0
@@ -0,0 +1,375 @@ | ||
| 1 | +// | |
| 2 | +// AnthropicTranslator.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Bidirectional translation between OpenAI chat/completions and the | |
| 9 | +// Anthropic Messages API, per docs/ROUTER-RESEARCH.md §3.1: system | |
| 10 | +// extraction, turn merging, tools/tool_choice, required max_tokens, | |
| 11 | +// stop_reason/usage mapping, and the SSE event state machine that emits | |
| 12 | +// byte-exact OpenAI chunks. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import Foundation | |
| 16 | + | |
| 17 | +enum AnthropicTranslator { | |
| 18 | + // MARK: - Request (OpenAI → Anthropic) | |
| 19 | + | |
| 20 | + static func buildRequest(_ request: ChatCompletionRequest, model: AIModel) -> [String: Any] { | |
| 21 | + var body: [String: Any] = [ | |
| 22 | + "model": model.id, | |
| 23 | + // max_tokens is REQUIRED: synthesize from the catalog when omitted. | |
| 24 | + "max_tokens": request.maxTokens ?? model.maxOutputTokens ?? 4096, | |
| 25 | + ] | |
| 26 | + | |
| 27 | + // System/developer messages → top-level system. | |
| 28 | + let systemText = request.messages | |
| 29 | + .filter { $0.role == "system" || $0.role == "developer" } | |
| 30 | + .map(\.flattenedText) | |
| 31 | + .filter { !$0.isEmpty } | |
| 32 | + .joined(separator: "\n\n") | |
| 33 | + var system = systemText | |
| 34 | + | |
| 35 | + // Conversation messages with Anthropic's constraints: only | |
| 36 | + // user/assistant roles, alternating, first must be user; tool results | |
| 37 | + // become user tool_result blocks; consecutive same-role turns merge. | |
| 38 | + var messages: [[String: Any]] = [] | |
| 39 | + func append(role: String, blocks: [[String: Any]]) { | |
| 40 | + guard !blocks.isEmpty else { return } | |
| 41 | + if var last = messages.last, last["role"] as? String == role { | |
| 42 | + var content = last["content"] as? [[String: Any]] ?? [] | |
| 43 | + content.append(contentsOf: blocks) | |
| 44 | + last["content"] = content | |
| 45 | + messages[messages.count - 1] = last | |
| 46 | + } else { | |
| 47 | + messages.append(["role": role, "content": blocks]) | |
| 48 | + } | |
| 49 | + } | |
| 50 | + | |
| 51 | + for message in request.messages { | |
| 52 | + switch message.role { | |
| 53 | + case "system", "developer": | |
| 54 | + continue | |
| 55 | + case "user": | |
| 56 | + append(role: "user", blocks: contentBlocks(message)) | |
| 57 | + case "assistant": | |
| 58 | + var blocks = contentBlocks(message) | |
| 59 | + for call in message.toolCalls ?? [] { | |
| 60 | + guard let function = call["function"] as? [String: Any] else { continue } | |
| 61 | + let arguments = function["arguments"] as? String ?? "{}" | |
| 62 | + let input = (try? JSONSerialization.jsonObject(with: Data(arguments.utf8))) as? [String: Any] ?? [:] | |
| 63 | + blocks.append([ | |
| 64 | + "type": "tool_use", | |
| 65 | + "id": call["id"] as? String ?? "call_\(UUID().uuidString.prefix(12))", | |
| 66 | + "name": function["name"] as? String ?? "unknown", | |
| 67 | + "input": input, | |
| 68 | + ]) | |
| 69 | + } | |
| 70 | + append(role: "assistant", blocks: blocks) | |
| 71 | + case "tool": | |
| 72 | + append(role: "user", blocks: [[ | |
| 73 | + "type": "tool_result", | |
| 74 | + "tool_use_id": message.toolCallID ?? "", | |
| 75 | + "content": message.flattenedText, | |
| 76 | + ]]) | |
| 77 | + default: | |
| 78 | + continue | |
| 79 | + } | |
| 80 | + } | |
| 81 | + if messages.first?["role"] as? String != "user" { | |
| 82 | + messages.insert(["role": "user", "content": [["type": "text", "text": "(continue)"]]], at: 0) | |
| 83 | + } | |
| 84 | + body["messages"] = messages | |
| 85 | + | |
| 86 | + // Sampling params (temperature clamped to Anthropic's 0–1). | |
| 87 | + if let temperature = request.raw["temperature"] as? Double { | |
| 88 | + body["temperature"] = min(temperature, 1.0) | |
| 89 | + } | |
| 90 | + if let topP = request.raw["top_p"] as? Double { body["top_p"] = topP } | |
| 91 | + if let topK = request.raw["top_k"] as? Int { body["top_k"] = topK } | |
| 92 | + if let stop = request.raw["stop"] as? String { | |
| 93 | + body["stop_sequences"] = [stop] | |
| 94 | + } else if let stop = request.raw["stop"] as? [String] { | |
| 95 | + body["stop_sequences"] = stop | |
| 96 | + } | |
| 97 | + if let user = request.raw["user"] as? String { | |
| 98 | + body["metadata"] = ["user_id": user] | |
| 99 | + } | |
| 100 | + | |
| 101 | + // Tools + tool_choice. | |
| 102 | + if let tools = request.tools { | |
| 103 | + body["tools"] = tools.compactMap { tool -> [String: Any]? in | |
| 104 | + guard let function = tool["function"] as? [String: Any], | |
| 105 | + let name = function["name"] as? String else { return nil } | |
| 106 | + var entry: [String: Any] = [ | |
| 107 | + "name": name, | |
| 108 | + "input_schema": function["parameters"] as? [String: Any] | |
| 109 | + ?? ["type": "object", "properties": [String: Any]()], | |
| 110 | + ] | |
| 111 | + if let description = function["description"] as? String { | |
| 112 | + entry["description"] = description | |
| 113 | + } | |
| 114 | + return entry | |
| 115 | + } | |
| 116 | + } | |
| 117 | + var toolChoice: [String: Any]? | |
| 118 | + switch request.raw["tool_choice"] { | |
| 119 | + case let choice as String: | |
| 120 | + switch choice { | |
| 121 | + case "auto": toolChoice = ["type": "auto"] | |
| 122 | + case "required": toolChoice = ["type": "any"] | |
| 123 | + case "none": toolChoice = ["type": "none"] | |
| 124 | + default: break | |
| 125 | + } | |
| 126 | + case let choice as [String: Any]: | |
| 127 | + if let function = choice["function"] as? [String: Any], let name = function["name"] as? String { | |
| 128 | + toolChoice = ["type": "tool", "name": name] | |
| 129 | + } | |
| 130 | + default: | |
| 131 | + break | |
| 132 | + } | |
| 133 | + if request.raw["parallel_tool_calls"] as? Bool == false, request.hasTools { | |
| 134 | + var choice = toolChoice ?? ["type": "auto"] | |
| 135 | + choice["disable_parallel_tool_use"] = true | |
| 136 | + toolChoice = choice | |
| 137 | + } | |
| 138 | + if let toolChoice { body["tool_choice"] = toolChoice } | |
| 139 | + | |
| 140 | + // JSON mode (best-effort system steering; json_schema via the current | |
| 141 | + // structured-output surface is provider-verified in Phase 7). | |
| 142 | + if let format = request.raw["response_format"] as? [String: Any], | |
| 143 | + let type = format["type"] as? String, type == "json_object" || type == "json_schema" { | |
| 144 | + let instruction = "You must respond with valid JSON only — no prose, no markdown fences." | |
| 145 | + system = system.isEmpty ? instruction : system + "\n\n" + instruction | |
| 146 | + } | |
| 147 | + if !system.isEmpty { body["system"] = system } | |
| 148 | + | |
| 149 | + // Reasoning: standard reasoning_effort → thinking budget; raw | |
| 150 | + // `thinking` extra-body always wins. | |
| 151 | + if let thinking = request.raw["thinking"] as? [String: Any] { | |
| 152 | + body["thinking"] = thinking | |
| 153 | + } else if model.capabilities.reasoning, | |
| 154 | + let effort = request.raw["reasoning_effort"] as? String { | |
| 155 | + let budget: Int | |
| 156 | + switch effort { | |
| 157 | + case "minimal", "low": budget = 1024 | |
| 158 | + case "high", "xhigh", "max": budget = 24576 | |
| 159 | + default: budget = 8192 | |
| 160 | + } | |
| 161 | + if let maxTokens = body["max_tokens"] as? Int, maxTokens <= budget { | |
| 162 | + body["max_tokens"] = budget + 4096 | |
| 163 | + } | |
| 164 | + body["thinking"] = ["type": "enabled", "budget_tokens": budget] | |
| 165 | + } | |
| 166 | + | |
| 167 | + body["stream"] = request.stream | |
| 168 | + return body | |
| 169 | + } | |
| 170 | + | |
| 171 | + private static func contentBlocks(_ message: OAIMessage) -> [[String: Any]] { | |
| 172 | + if let text = message.contentString { | |
| 173 | + return text.isEmpty ? [] : [["type": "text", "text": text]] | |
| 174 | + } | |
| 175 | + guard let parts = message.contentParts else { return [] } | |
| 176 | + return parts.compactMap { part in | |
| 177 | + switch part["type"] as? String { | |
| 178 | + case "text": | |
| 179 | + let text = part["text"] as? String ?? "" | |
| 180 | + return text.isEmpty ? nil : ["type": "text", "text": text] | |
| 181 | + case "image_url": | |
| 182 | + guard let image = part["image_url"] as? [String: Any], | |
| 183 | + let url = image["url"] as? String else { return nil } | |
| 184 | + if url.hasPrefix("data:"), | |
| 185 | + let comma = url.firstIndex(of: ",") { | |
| 186 | + let header = url[url.index(url.startIndex, offsetBy: 5)..<comma] | |
| 187 | + let mediaType = header.split(separator: ";").first.map(String.init) ?? "image/png" | |
| 188 | + return ["type": "image", "source": [ | |
| 189 | + "type": "base64", | |
| 190 | + "media_type": mediaType, | |
| 191 | + "data": String(url[url.index(after: comma)...]), | |
| 192 | + ]] | |
| 193 | + } | |
| 194 | + return ["type": "image", "source": ["type": "url", "url": url]] | |
| 195 | + default: | |
| 196 | + return nil | |
| 197 | + } | |
| 198 | + } | |
| 199 | + } | |
| 200 | + | |
| 201 | + // MARK: - Shared mapping | |
| 202 | + | |
| 203 | + static func finishReason(from stopReason: String?) -> String { | |
| 204 | + switch stopReason { | |
| 205 | + case "end_turn", "stop_sequence", "pause_turn", .none: return "stop" | |
| 206 | + case "max_tokens", "model_context_window_exceeded": return "length" | |
| 207 | + case "tool_use": return "tool_calls" | |
| 208 | + case "refusal": return "content_filter" | |
| 209 | + default: return "stop" | |
| 210 | + } | |
| 211 | + } | |
| 212 | + | |
| 213 | + /// prompt_tokens include cache reads/writes (Anthropic excludes them). | |
| 214 | + static func normalizedUsage(_ usage: [String: Any]) -> [String: Any] { | |
| 215 | + let input = usage["input_tokens"] as? Int ?? 0 | |
| 216 | + let cacheRead = usage["cache_read_input_tokens"] as? Int ?? 0 | |
| 217 | + let cacheCreation = usage["cache_creation_input_tokens"] as? Int ?? 0 | |
| 218 | + let output = usage["output_tokens"] as? Int ?? 0 | |
| 219 | + return UsageBuilder.build( | |
| 220 | + promptTokens: input + cacheRead + cacheCreation, | |
| 221 | + completionTokens: output, | |
| 222 | + cachedTokens: cacheRead > 0 ? cacheRead : nil | |
| 223 | + ) | |
| 224 | + } | |
| 225 | + | |
| 226 | + // MARK: - Response (Anthropic → OpenAI), non-streaming | |
| 227 | + | |
| 228 | + static func translateResponse(_ upstream: [String: Any], emitter: ChunkEmitter) -> [String: Any] { | |
| 229 | + var text = "" | |
| 230 | + var reasoning = "" | |
| 231 | + var toolCalls: [[String: Any]] = [] | |
| 232 | + for block in upstream["content"] as? [[String: Any]] ?? [] { | |
| 233 | + switch block["type"] as? String { | |
| 234 | + case "text": | |
| 235 | + text += block["text"] as? String ?? "" | |
| 236 | + case "thinking": | |
| 237 | + reasoning += block["thinking"] as? String ?? "" | |
| 238 | + case "tool_use": | |
| 239 | + let input = block["input"] as? [String: Any] ?? [:] | |
| 240 | + let arguments = String( | |
| 241 | + data: (try? JSONSerialization.data(withJSONObject: input)) ?? Data("{}".utf8), | |
| 242 | + encoding: .utf8 | |
| 243 | + ) ?? "{}" | |
| 244 | + toolCalls.append([ | |
| 245 | + "id": block["id"] as? String ?? "call_\(UUID().uuidString.prefix(12))", | |
| 246 | + "type": "function", | |
| 247 | + "function": ["name": block["name"] as? String ?? "", "arguments": arguments], | |
| 248 | + ]) | |
| 249 | + default: | |
| 250 | + break | |
| 251 | + } | |
| 252 | + } | |
| 253 | + | |
| 254 | + var message: [String: Any] = ["role": "assistant"] | |
| 255 | + message["content"] = toolCalls.isEmpty || !text.isEmpty ? text : NSNull() | |
| 256 | + if !reasoning.isEmpty { message["reasoning_content"] = reasoning } | |
| 257 | + if !toolCalls.isEmpty { message["tool_calls"] = toolCalls } | |
| 258 | + | |
| 259 | + return emitter.completion( | |
| 260 | + message: message, | |
| 261 | + finishReason: finishReason(from: upstream["stop_reason"] as? String), | |
| 262 | + usage: normalizedUsage(upstream["usage"] as? [String: Any] ?? [:]) | |
| 263 | + ) | |
| 264 | + } | |
| 265 | + | |
| 266 | + // MARK: - Streaming state machine (Anthropic events → OpenAI chunks) | |
| 267 | + | |
| 268 | + /// Feed each upstream SSE event in; write the produced OpenAI chunks out. | |
| 269 | + struct StreamMachine { | |
| 270 | + let emitter: ChunkEmitter | |
| 271 | + let includeUsage: Bool | |
| 272 | + | |
| 273 | + private var toolIndex = -1 | |
| 274 | + private var currentBlockIsTool = false | |
| 275 | + private(set) var promptTokens = 0 | |
| 276 | + private(set) var cachedTokens = 0 | |
| 277 | + private(set) var completionTokens = 0 | |
| 278 | + private(set) var finishReasonSent: String? | |
| 279 | + private(set) var upstreamError: (status: Int, message: String)? | |
| 280 | + | |
| 281 | + init(emitter: ChunkEmitter, includeUsage: Bool) { | |
| 282 | + self.emitter = emitter | |
| 283 | + self.includeUsage = includeUsage | |
| 284 | + } | |
| 285 | + | |
| 286 | + /// Returns the OpenAI SSE `data:` payloads to emit for one event, | |
| 287 | + /// and whether the stream is finished. | |
| 288 | + mutating func consume(_ event: SSEEvent) -> (payloads: [Data], done: Bool) { | |
| 289 | + guard let json = (try? JSONSerialization.jsonObject(with: Data(event.data.utf8))) as? [String: Any] else { | |
| 290 | + return ([], false) | |
| 291 | + } | |
| 292 | + switch event.event ?? json["type"] as? String ?? "" { | |
| 293 | + case "message_start": | |
| 294 | + if let usage = (json["message"] as? [String: Any])?["usage"] as? [String: Any] { | |
| 295 | + promptTokens = (usage["input_tokens"] as? Int ?? 0) | |
| 296 | + + (usage["cache_read_input_tokens"] as? Int ?? 0) | |
| 297 | + + (usage["cache_creation_input_tokens"] as? Int ?? 0) | |
| 298 | + cachedTokens = usage["cache_read_input_tokens"] as? Int ?? 0 | |
| 299 | + } | |
| 300 | + return ([emitter.roleChunk()], false) | |
| 301 | + | |
| 302 | + case "content_block_start": | |
| 303 | + guard let block = json["content_block"] as? [String: Any] else { return ([], false) } | |
| 304 | + if block["type"] as? String == "tool_use" { | |
| 305 | + toolIndex += 1 | |
| 306 | + currentBlockIsTool = true | |
| 307 | + return ([emitter.toolCallStartChunk( | |
| 308 | + toolIndex: toolIndex, | |
| 309 | + callID: block["id"] as? String ?? "call_\(UUID().uuidString.prefix(12))", | |
| 310 | + name: block["name"] as? String ?? "" | |
| 311 | + )], false) | |
| 312 | + } | |
| 313 | + currentBlockIsTool = false | |
| 314 | + return ([], false) | |
| 315 | + | |
| 316 | + case "content_block_delta": | |
| 317 | + guard let delta = json["delta"] as? [String: Any] else { return ([], false) } | |
| 318 | + switch delta["type"] as? String { | |
| 319 | + case "text_delta": | |
| 320 | + let text = delta["text"] as? String ?? "" | |
| 321 | + return (text.isEmpty ? [] : [emitter.contentChunk(text)], false) | |
| 322 | + case "input_json_delta": | |
| 323 | + let fragment = delta["partial_json"] as? String ?? "" | |
| 324 | + guard !fragment.isEmpty, currentBlockIsTool else { return ([], false) } | |
| 325 | + return ([emitter.toolCallArgumentsChunk(toolIndex: toolIndex, fragment: fragment)], false) | |
| 326 | + case "thinking_delta": | |
| 327 | + let thinking = delta["thinking"] as? String ?? "" | |
| 328 | + return (thinking.isEmpty ? [] : [emitter.reasoningChunk(thinking)], false) | |
| 329 | + default: // signature_delta and friends: log-only material | |
| 330 | + return ([], false) | |
| 331 | + } | |
| 332 | + | |
| 333 | + case "content_block_stop": | |
| 334 | + currentBlockIsTool = false | |
| 335 | + return ([], false) | |
| 336 | + | |
| 337 | + case "message_delta": | |
| 338 | + if let usage = json["usage"] as? [String: Any], | |
| 339 | + let output = usage["output_tokens"] as? Int { | |
| 340 | + completionTokens = output // cumulative | |
| 341 | + } | |
| 342 | + if let delta = json["delta"] as? [String: Any], | |
| 343 | + let stopReason = delta["stop_reason"] as? String { | |
| 344 | + let reason = AnthropicTranslator.finishReason(from: stopReason) | |
| 345 | + finishReasonSent = reason | |
| 346 | + return ([emitter.finishChunk(reason: reason)], false) | |
| 347 | + } | |
| 348 | + return ([], false) | |
| 349 | + | |
| 350 | + case "message_stop": | |
| 351 | + var payloads: [Data] = [] | |
| 352 | + if includeUsage { | |
| 353 | + payloads.append(emitter.usageChunk(UsageBuilder.build( | |
| 354 | + promptTokens: promptTokens, | |
| 355 | + completionTokens: completionTokens, | |
| 356 | + cachedTokens: cachedTokens > 0 ? cachedTokens : nil | |
| 357 | + ))) | |
| 358 | + } | |
| 359 | + return (payloads, true) | |
| 360 | + | |
| 361 | + case "error": | |
| 362 | + let detail = (json["error"] as? [String: Any])?["message"] as? String ?? "upstream stream error" | |
| 363 | + upstreamError = (502, detail) | |
| 364 | + let frame = OpenAIError(error: .init( | |
| 365 | + message: detail, type: "api_error", param: nil, | |
| 366 | + code: (json["error"] as? [String: Any])?["type"] as? String | |
| 367 | + )) | |
| 368 | + return ([(try? JSONEncoder().encode(frame)) ?? Data()], true) | |
| 369 | + | |
| 370 | + default: // ping etc. | |
| 371 | + return ([], false) | |
| 372 | + } | |
| 373 | + } | |
| 374 | + } | |
| 375 | +} | |
added
Sources/ZyquoRouter/Translate/ChatCompletionRequest.swift
+118 −0
@@ -0,0 +1,118 @@ | ||
| 1 | +// | |
| 2 | +// ChatCompletionRequest.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The parsed inbound OpenAI /v1/chat/completions request. The raw JSON object | |
| 9 | +// is preserved (unknown keys pass through to OpenAI-compatible upstreams per | |
| 10 | +// decision D5); typed accessors cover everything the router itself needs for | |
| 11 | +// routing, translation, and capability checks. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +struct ChatCompletionRequest { | |
| 17 | + /// The request exactly as received (top-level JSON object). | |
| 18 | + let raw: [String: Any] | |
| 19 | + | |
| 20 | + let model: String | |
| 21 | + let messages: [OAIMessage] | |
| 22 | + let stream: Bool | |
| 23 | + let includeUsage: Bool | |
| 24 | + | |
| 25 | + enum ParseError: LocalizedError { | |
| 26 | + case notAnObject | |
| 27 | + case missing(String) | |
| 28 | + case invalid(String, detail: String) | |
| 29 | + | |
| 30 | + var errorDescription: String? { | |
| 31 | + switch self { | |
| 32 | + case .notAnObject: return "The request body must be a JSON object." | |
| 33 | + case .missing(let param): return "Missing required parameter: '\(param)'." | |
| 34 | + case .invalid(let param, let detail): return "Invalid value for '\(param)': \(detail)." | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + var param: String? { | |
| 39 | + switch self { | |
| 40 | + case .notAnObject: return nil | |
| 41 | + case .missing(let param), .invalid(let param, _): return param | |
| 42 | + } | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + init(body: Data) throws { | |
| 47 | + guard let object = try? JSONSerialization.jsonObject(with: body), | |
| 48 | + let dict = object as? [String: Any] else { | |
| 49 | + throw ParseError.notAnObject | |
| 50 | + } | |
| 51 | + raw = dict | |
| 52 | + | |
| 53 | + guard let model = dict["model"] as? String, !model.isEmpty else { | |
| 54 | + throw ParseError.missing("model") | |
| 55 | + } | |
| 56 | + self.model = model | |
| 57 | + | |
| 58 | + guard let rawMessages = dict["messages"] as? [[String: Any]], !rawMessages.isEmpty else { | |
| 59 | + throw ParseError.missing("messages") | |
| 60 | + } | |
| 61 | + messages = try rawMessages.enumerated().map { index, message in | |
| 62 | + try OAIMessage(json: message, index: index) | |
| 63 | + } | |
| 64 | + | |
| 65 | + stream = dict["stream"] as? Bool ?? false | |
| 66 | + includeUsage = (dict["stream_options"] as? [String: Any])?["include_usage"] as? Bool ?? false | |
| 67 | + | |
| 68 | + if let n = dict["n"] as? Int, n > 1 { | |
| 69 | + // Router-wide policy (research §3.2.2): n>1 is rejected for | |
| 70 | + // uniform behavior across upstreams. | |
| 71 | + throw ParseError.invalid("n", detail: "the router supports n=1 only") | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + /// max_completion_tokens wins over the deprecated max_tokens. | |
| 76 | + var maxTokens: Int? { | |
| 77 | + (raw["max_completion_tokens"] as? Int) ?? (raw["max_tokens"] as? Int) | |
| 78 | + } | |
| 79 | + | |
| 80 | + var tools: [[String: Any]]? { raw["tools"] as? [[String: Any]] } | |
| 81 | + var hasTools: Bool { !(tools ?? []).isEmpty } | |
| 82 | + | |
| 83 | + /// Any message carrying an image content part (vision capability check). | |
| 84 | + var hasImageContent: Bool { | |
| 85 | + messages.contains { message in | |
| 86 | + message.contentParts?.contains { ($0["type"] as? String) == "image_url" } ?? false | |
| 87 | + } | |
| 88 | + } | |
| 89 | +} | |
| 90 | + | |
| 91 | +/// One inbound message, loosely typed: content may be a string, an array of | |
| 92 | +/// content parts, or null (assistant tool-call turns). | |
| 93 | +struct OAIMessage { | |
| 94 | + let role: String | |
| 95 | + let json: [String: Any] | |
| 96 | + | |
| 97 | + init(json: [String: Any], index: Int) throws { | |
| 98 | + guard let role = json["role"] as? String else { | |
| 99 | + throw ChatCompletionRequest.ParseError.invalid("messages[\(index)].role", detail: "missing role") | |
| 100 | + } | |
| 101 | + self.role = role | |
| 102 | + self.json = json | |
| 103 | + } | |
| 104 | + | |
| 105 | + var contentString: String? { json["content"] as? String } | |
| 106 | + var contentParts: [[String: Any]]? { json["content"] as? [[String: Any]] } | |
| 107 | + var toolCalls: [[String: Any]]? { json["tool_calls"] as? [[String: Any]] } | |
| 108 | + var toolCallID: String? { json["tool_call_id"] as? String } | |
| 109 | + | |
| 110 | + /// All text in this message (string content or text parts joined). | |
| 111 | + var flattenedText: String { | |
| 112 | + if let text = contentString { return text } | |
| 113 | + guard let parts = contentParts else { return "" } | |
| 114 | + return parts.compactMap { part in | |
| 115 | + (part["type"] as? String) == "text" ? part["text"] as? String : nil | |
| 116 | + }.joined(separator: "\n") | |
| 117 | + } | |
| 118 | +} | |
added
Sources/ZyquoRouter/Translate/CompatAdjuster.swift
+259 −0
@@ -0,0 +1,259 @@ | ||
| 1 | +// | |
| 2 | +// CompatAdjuster.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Per-provider request/response adjustment for the OpenAI-compatible | |
| 9 | +// upstreams (everything except Anthropic and Gemini, which translate fully). | |
| 10 | +// Tables from docs/ROUTER-RESEARCH.md §3.3: strip what 400s, rename what | |
| 11 | +// differs, clamp what has narrower ranges, pass unknown keys through (D5), | |
| 12 | +// and normalize response quirks into the OpenAI contract. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import Foundation | |
| 16 | + | |
| 17 | +enum CompatAdjuster { | |
| 18 | + // MARK: - Request adjustment | |
| 19 | + | |
| 20 | + /// Rewrites an inbound OpenAI request body for a specific compat upstream. | |
| 21 | + static func adjustRequest( | |
| 22 | + _ original: [String: Any], | |
| 23 | + model: AIModel, | |
| 24 | + stream: Bool | |
| 25 | + ) -> [String: Any] { | |
| 26 | + var body = original | |
| 27 | + let provider = model.provider | |
| 28 | + | |
| 29 | + // Bare upstream model id (namespace/alias resolution already happened). | |
| 30 | + body["model"] = model.id | |
| 31 | + | |
| 32 | + // max_tokens naming: send whichever the model documents, never both. | |
| 33 | + let maxTokens = (body["max_completion_tokens"] as? Int) ?? (body["max_tokens"] as? Int) | |
| 34 | + body["max_tokens"] = nil | |
| 35 | + body["max_completion_tokens"] = nil | |
| 36 | + if let maxTokens { | |
| 37 | + body[model.parameterSupport.usesMaxCompletionTokens ? "max_completion_tokens" : "max_tokens"] = maxTokens | |
| 38 | + } | |
| 39 | + | |
| 40 | + // stream/stream_options are router-controlled. | |
| 41 | + body["stream"] = stream | |
| 42 | + body["stream_options"] = nil | |
| 43 | + if stream, supportsStreamUsage(provider) { | |
| 44 | + body["stream_options"] = ["include_usage": true] | |
| 45 | + } | |
| 46 | + | |
| 47 | + // Parameters the catalog says this model rejects. | |
| 48 | + if !model.parameterSupport.temperature { body["temperature"] = nil } | |
| 49 | + if !model.parameterSupport.topP { body["top_p"] = nil } | |
| 50 | + if !model.parameterSupport.frequencyPenalty { body["frequency_penalty"] = nil } | |
| 51 | + if !model.parameterSupport.presencePenalty { body["presence_penalty"] = nil } | |
| 52 | + if !model.capabilities.reasoning { body["reasoning_effort"] = nil } | |
| 53 | + | |
| 54 | + // Echo-back hygiene: strip reasoning_content/reasoning_details from | |
| 55 | + // incoming assistant messages (wastes tokens; DeepSeek 400s) — except | |
| 56 | + // DeepSeek tool-call loops, which REQUIRE reasoning_content back. | |
| 57 | + let keepReasoning = provider == .deepseek && body["tools"] != nil | |
| 58 | + if !keepReasoning, var messages = body["messages"] as? [[String: Any]] { | |
| 59 | + for index in messages.indices where messages[index]["role"] as? String == "assistant" { | |
| 60 | + messages[index]["reasoning_content"] = nil | |
| 61 | + messages[index]["reasoning_details"] = nil | |
| 62 | + } | |
| 63 | + body["messages"] = messages | |
| 64 | + } | |
| 65 | + | |
| 66 | + switch provider { | |
| 67 | + case .xai: | |
| 68 | + if model.capabilities.reasoning { | |
| 69 | + // Grok reasoning models 400 on these instead of ignoring them. | |
| 70 | + body["presence_penalty"] = nil | |
| 71 | + body["frequency_penalty"] = nil | |
| 72 | + body["stop"] = nil | |
| 73 | + } | |
| 74 | + case .mistral: | |
| 75 | + if let seed = body["seed"] { body["random_seed"] = seed; body["seed"] = nil } | |
| 76 | + body["logit_bias"] = nil | |
| 77 | + body["user"] = nil | |
| 78 | + body["logprobs"] = nil | |
| 79 | + case .qwen: | |
| 80 | + body["logit_bias"] = nil | |
| 81 | + // Hybrid-thinking models think by default; honor the client's ask. | |
| 82 | + if model.parameterSupport.thinkingToggle, body["enable_thinking"] == nil { | |
| 83 | + body["enable_thinking"] = body["reasoning_effort"] != nil | |
| 84 | + body["reasoning_effort"] = nil | |
| 85 | + } | |
| 86 | + case .deepseek: | |
| 87 | + body["logprobs"] = nil | |
| 88 | + body["top_logprobs"] = nil | |
| 89 | + case .kimi: | |
| 90 | + if let temperature = body["temperature"] as? Double { | |
| 91 | + body["temperature"] = min(max(temperature, 0), 1) | |
| 92 | + } | |
| 93 | + case .deepinfra: | |
| 94 | + body["logit_bias"] = nil | |
| 95 | + case .openai, .perplexity, .together, .cerebras, .anthropic, .gemini, .custom: | |
| 96 | + break | |
| 97 | + } | |
| 98 | + return body | |
| 99 | + } | |
| 100 | + | |
| 101 | + /// Providers that honor `stream_options.include_usage` (research §3.3). | |
| 102 | + static func supportsStreamUsage(_ provider: ProviderID) -> Bool { | |
| 103 | + switch provider { | |
| 104 | + case .openai, .xai, .mistral, .qwen, .deepseek, .kimi, .together, .deepinfra, .cerebras: | |
| 105 | + return true | |
| 106 | + case .perplexity, .anthropic, .gemini, .custom: | |
| 107 | + return false | |
| 108 | + } | |
| 109 | + } | |
| 110 | + | |
| 111 | + // MARK: - Response normalization (non-streaming) | |
| 112 | + | |
| 113 | + /// Normalizes a compat upstream's `chat.completion` into the router | |
| 114 | + /// contract: namespaced model echo, canonical finish_reason, flattened | |
| 115 | + /// reasoning, usage detail mapping. Unknown fields pass through. | |
| 116 | + static func normalizeResponse( | |
| 117 | + _ original: [String: Any], | |
| 118 | + namespacedModel: String, | |
| 119 | + provider: ProviderID | |
| 120 | + ) -> [String: Any] { | |
| 121 | + var body = original | |
| 122 | + body["model"] = namespacedModel | |
| 123 | + | |
| 124 | + if var choices = body["choices"] as? [[String: Any]] { | |
| 125 | + for index in choices.indices { | |
| 126 | + if let finish = choices[index]["finish_reason"] as? String { | |
| 127 | + choices[index]["finish_reason"] = normalizeFinishReason(finish) | |
| 128 | + } | |
| 129 | + if var message = choices[index]["message"] as? [String: Any] { | |
| 130 | + normalizeMessage(&message, provider: provider) | |
| 131 | + choices[index]["message"] = message | |
| 132 | + } | |
| 133 | + // Perplexity leaks a `delta` field into non-streaming choices. | |
| 134 | + if provider == .perplexity { choices[index]["delta"] = nil } | |
| 135 | + } | |
| 136 | + body["choices"] = choices | |
| 137 | + } | |
| 138 | + | |
| 139 | + if var usage = body["usage"] as? [String: Any] { | |
| 140 | + normalizeUsage(&usage, provider: provider) | |
| 141 | + body["usage"] = usage | |
| 142 | + } | |
| 143 | + return body | |
| 144 | + } | |
| 145 | + | |
| 146 | + /// Normalizes one streamed chunk in place. Returns nil for chunks the | |
| 147 | + /// router should swallow (e.g. usage chunk when the client didn't ask). | |
| 148 | + static func normalizeChunk( | |
| 149 | + _ original: [String: Any], | |
| 150 | + namespacedModel: String, | |
| 151 | + provider: ProviderID, | |
| 152 | + clientWantsUsage: Bool | |
| 153 | + ) -> [String: Any]? { | |
| 154 | + var chunk = original | |
| 155 | + chunk["model"] = namespacedModel | |
| 156 | + | |
| 157 | + if var choices = chunk["choices"] as? [[String: Any]] { | |
| 158 | + for index in choices.indices { | |
| 159 | + if let finish = choices[index]["finish_reason"] as? String { | |
| 160 | + choices[index]["finish_reason"] = normalizeFinishReason(finish) | |
| 161 | + } | |
| 162 | + if var delta = choices[index]["delta"] as? [String: Any] { | |
| 163 | + normalizeMessage(&delta, provider: provider) | |
| 164 | + choices[index]["delta"] = delta | |
| 165 | + } | |
| 166 | + } | |
| 167 | + chunk["choices"] = choices | |
| 168 | + | |
| 169 | + // Usage-only chunk (empty choices): swallow unless requested. | |
| 170 | + if choices.isEmpty, chunk["usage"] != nil, !clientWantsUsage { | |
| 171 | + return nil | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 175 | + if var usage = chunk["usage"] as? [String: Any] { | |
| 176 | + normalizeUsage(&usage, provider: provider) | |
| 177 | + chunk["usage"] = usage | |
| 178 | + } | |
| 179 | + return chunk | |
| 180 | + } | |
| 181 | + | |
| 182 | + // MARK: - Shared pieces | |
| 183 | + | |
| 184 | + /// Everything lands in the OpenAI closed set (research §3.3 rule 4). | |
| 185 | + static func normalizeFinishReason(_ raw: String) -> String { | |
| 186 | + switch raw { | |
| 187 | + case "stop", "length", "tool_calls", "content_filter", "function_call": | |
| 188 | + return raw | |
| 189 | + case "eos": | |
| 190 | + return "stop" | |
| 191 | + case "max_tokens", "model_length": | |
| 192 | + return "length" | |
| 193 | + case "safety", "recitation": | |
| 194 | + return "content_filter" | |
| 195 | + default: | |
| 196 | + return "stop" | |
| 197 | + } | |
| 198 | + } | |
| 199 | + | |
| 200 | + /// Message/delta-level quirks: Mistral thinking arrays, Perplexity | |
| 201 | + /// <think> tags, Together text field. | |
| 202 | + private static func normalizeMessage(_ message: inout [String: Any], provider: ProviderID) { | |
| 203 | + switch provider { | |
| 204 | + case .mistral: | |
| 205 | + // Magistral: content is an ARRAY of {type:"thinking"|"text"} chunks. | |
| 206 | + if let parts = message["content"] as? [[String: Any]] { | |
| 207 | + var text = "" | |
| 208 | + var reasoning = message["reasoning_content"] as? String ?? "" | |
| 209 | + for part in parts { | |
| 210 | + switch part["type"] as? String { | |
| 211 | + case "text": | |
| 212 | + text += part["text"] as? String ?? "" | |
| 213 | + case "thinking": | |
| 214 | + for inner in part["thinking"] as? [[String: Any]] ?? [] { | |
| 215 | + reasoning += inner["text"] as? String ?? "" | |
| 216 | + } | |
| 217 | + default: | |
| 218 | + break | |
| 219 | + } | |
| 220 | + } | |
| 221 | + message["content"] = text | |
| 222 | + if !reasoning.isEmpty { message["reasoning_content"] = reasoning } | |
| 223 | + } | |
| 224 | + case .perplexity: | |
| 225 | + // sonar-reasoning embeds <think>…</think> in content. | |
| 226 | + if let content = message["content"] as? String, | |
| 227 | + content.hasPrefix("<think>"), | |
| 228 | + let closeRange = content.range(of: "</think>") { | |
| 229 | + let reasoning = String(content[content.index(content.startIndex, offsetBy: 7)..<closeRange.lowerBound]) | |
| 230 | + message["reasoning_content"] = reasoning | |
| 231 | + message["content"] = String(content[closeRange.upperBound...]) | |
| 232 | + .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 233 | + } | |
| 234 | + case .together: | |
| 235 | + // Some Together models put streamed text in choices[].text. | |
| 236 | + if message["content"] == nil, let text = message["text"] as? String { | |
| 237 | + message["content"] = text | |
| 238 | + message["text"] = nil | |
| 239 | + } | |
| 240 | + default: | |
| 241 | + break | |
| 242 | + } | |
| 243 | + } | |
| 244 | + | |
| 245 | + /// Provider usage extras → OpenAI detail objects. | |
| 246 | + private static func normalizeUsage(_ usage: inout [String: Any], provider: ProviderID) { | |
| 247 | + if provider == .deepseek, let cacheHit = usage["prompt_cache_hit_tokens"] as? Int, cacheHit > 0 { | |
| 248 | + var details = usage["prompt_tokens_details"] as? [String: Any] ?? [:] | |
| 249 | + if details["cached_tokens"] == nil { details["cached_tokens"] = cacheHit } | |
| 250 | + usage["prompt_tokens_details"] = details | |
| 251 | + } | |
| 252 | + if let reasoningTokens = usage["reasoning_tokens"] as? Int, reasoningTokens > 0 { | |
| 253 | + var details = usage["completion_tokens_details"] as? [String: Any] ?? [:] | |
| 254 | + if details["reasoning_tokens"] == nil { details["reasoning_tokens"] = reasoningTokens } | |
| 255 | + usage["completion_tokens_details"] = details | |
| 256 | + usage["reasoning_tokens"] = nil | |
| 257 | + } | |
| 258 | + } | |
| 259 | +} | |
added
Sources/ZyquoRouter/Translate/GeminiTranslator.swift
+379 −0
@@ -0,0 +1,379 @@ | ||
| 1 | +// | |
| 2 | +// GeminiTranslator.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Native Gemini generateContent / streamGenerateContent translation per | |
| 9 | +// docs/ROUTER-RESEARCH.md §3.2 (decision D10): contents/parts with role | |
| 10 | +// rename, systemInstruction, generationConfig, functionDeclarations, the | |
| 11 | +// finishReason table (incl. the STOP+functionCall → tool_calls override), | |
| 12 | +// and SSE chunks → OpenAI chunks (role synthesized, whole-argument tool | |
| 13 | +// deltas, router-added [DONE]). | |
| 14 | +// | |
| 15 | + | |
| 16 | +import Foundation | |
| 17 | + | |
| 18 | +enum GeminiTranslator { | |
| 19 | + // MARK: - Request (OpenAI → Gemini) | |
| 20 | + | |
| 21 | + static func buildRequest(_ request: ChatCompletionRequest, model: AIModel) -> [String: Any] { | |
| 22 | + var body: [String: Any] = [:] | |
| 23 | + | |
| 24 | + let systemText = request.messages | |
| 25 | + .filter { $0.role == "system" || $0.role == "developer" } | |
| 26 | + .map(\.flattenedText) | |
| 27 | + .filter { !$0.isEmpty } | |
| 28 | + .joined(separator: "\n\n") | |
| 29 | + if !systemText.isEmpty { | |
| 30 | + body["systemInstruction"] = ["parts": [["text": systemText]]] | |
| 31 | + } | |
| 32 | + | |
| 33 | + // tool_call_id → function name, resolved from prior assistant turns | |
| 34 | + // (functionResponse requires the name). | |
| 35 | + var callNames: [String: String] = [:] | |
| 36 | + for message in request.messages { | |
| 37 | + for call in message.toolCalls ?? [] { | |
| 38 | + if let id = call["id"] as? String, | |
| 39 | + let name = (call["function"] as? [String: Any])?["name"] as? String { | |
| 40 | + callNames[id] = name | |
| 41 | + } | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + var contents: [[String: Any]] = [] | |
| 46 | + func append(role: String, parts: [[String: Any]]) { | |
| 47 | + guard !parts.isEmpty else { return } | |
| 48 | + if var last = contents.last, last["role"] as? String == role { | |
| 49 | + var merged = last["parts"] as? [[String: Any]] ?? [] | |
| 50 | + merged.append(contentsOf: parts) | |
| 51 | + last["parts"] = merged | |
| 52 | + contents[contents.count - 1] = last | |
| 53 | + } else { | |
| 54 | + contents.append(["role": role, "parts": parts]) | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + for message in request.messages { | |
| 59 | + switch message.role { | |
| 60 | + case "system", "developer": | |
| 61 | + continue | |
| 62 | + case "user": | |
| 63 | + append(role: "user", parts: parts(for: message)) | |
| 64 | + case "assistant": | |
| 65 | + var assistantParts = parts(for: message) | |
| 66 | + for call in message.toolCalls ?? [] { | |
| 67 | + guard let function = call["function"] as? [String: Any], | |
| 68 | + let name = function["name"] as? String else { continue } | |
| 69 | + let arguments = function["arguments"] as? String ?? "{}" | |
| 70 | + let args = (try? JSONSerialization.jsonObject(with: Data(arguments.utf8))) as? [String: Any] ?? [:] | |
| 71 | + var functionCall: [String: Any] = ["name": name, "args": args] | |
| 72 | + if let id = call["id"] as? String { functionCall["id"] = id } | |
| 73 | + assistantParts.append(["functionCall": functionCall]) | |
| 74 | + } | |
| 75 | + append(role: "model", parts: assistantParts) | |
| 76 | + case "tool": | |
| 77 | + let callID = message.toolCallID ?? "" | |
| 78 | + let text = message.flattenedText | |
| 79 | + // functionResponse.response must be a JSON object. | |
| 80 | + let response = (try? JSONSerialization.jsonObject(with: Data(text.utf8))) as? [String: Any] | |
| 81 | + ?? ["result": text] | |
| 82 | + var functionResponse: [String: Any] = [ | |
| 83 | + "name": callNames[callID] ?? callID, | |
| 84 | + "response": response, | |
| 85 | + ] | |
| 86 | + if !callID.isEmpty { functionResponse["id"] = callID } | |
| 87 | + append(role: "user", parts: [["functionResponse": functionResponse]]) | |
| 88 | + default: | |
| 89 | + continue | |
| 90 | + } | |
| 91 | + } | |
| 92 | + body["contents"] = contents | |
| 93 | + | |
| 94 | + var generation: [String: Any] = [:] | |
| 95 | + if let temperature = request.raw["temperature"] as? Double { generation["temperature"] = temperature } | |
| 96 | + if let topP = request.raw["top_p"] as? Double { generation["topP"] = topP } | |
| 97 | + if let topK = request.raw["top_k"] as? Int { generation["topK"] = topK } | |
| 98 | + if let maxTokens = request.maxTokens { generation["maxOutputTokens"] = maxTokens } | |
| 99 | + if let seed = request.raw["seed"] as? Int { generation["seed"] = seed } | |
| 100 | + if let presence = request.raw["presence_penalty"] as? Double { generation["presencePenalty"] = presence } | |
| 101 | + if let frequency = request.raw["frequency_penalty"] as? Double { generation["frequencyPenalty"] = frequency } | |
| 102 | + if let stop = request.raw["stop"] as? String { | |
| 103 | + generation["stopSequences"] = [stop] | |
| 104 | + } else if let stop = request.raw["stop"] as? [String] { | |
| 105 | + generation["stopSequences"] = stop | |
| 106 | + } | |
| 107 | + if let format = request.raw["response_format"] as? [String: Any] { | |
| 108 | + switch format["type"] as? String { | |
| 109 | + case "json_object": | |
| 110 | + generation["responseMimeType"] = "application/json" | |
| 111 | + case "json_schema": | |
| 112 | + generation["responseMimeType"] = "application/json" | |
| 113 | + if let schema = (format["json_schema"] as? [String: Any])?["schema"] { | |
| 114 | + generation["responseJsonSchema"] = schema | |
| 115 | + } | |
| 116 | + default: | |
| 117 | + break | |
| 118 | + } | |
| 119 | + } | |
| 120 | + if model.capabilities.reasoning, let effort = request.raw["reasoning_effort"] as? String { | |
| 121 | + let budget: Int | |
| 122 | + switch effort { | |
| 123 | + case "minimal", "low": budget = 1024 | |
| 124 | + case "high", "xhigh", "max": budget = 24576 | |
| 125 | + default: budget = 8192 | |
| 126 | + } | |
| 127 | + generation["thinkingConfig"] = ["thinkingBudget": budget, "includeThoughts": true] | |
| 128 | + } | |
| 129 | + if !generation.isEmpty { body["generationConfig"] = generation } | |
| 130 | + | |
| 131 | + if let tools = request.tools { | |
| 132 | + let declarations = tools.compactMap { tool -> [String: Any]? in | |
| 133 | + guard let function = tool["function"] as? [String: Any], | |
| 134 | + let name = function["name"] as? String else { return nil } | |
| 135 | + var declaration: [String: Any] = ["name": name] | |
| 136 | + if let description = function["description"] as? String { | |
| 137 | + declaration["description"] = description | |
| 138 | + } | |
| 139 | + if var parameters = function["parameters"] as? [String: Any] { | |
| 140 | + parameters["$schema"] = nil | |
| 141 | + declaration["parameters"] = parameters | |
| 142 | + } | |
| 143 | + return declaration | |
| 144 | + } | |
| 145 | + if !declarations.isEmpty { | |
| 146 | + body["tools"] = [["functionDeclarations": declarations]] | |
| 147 | + } | |
| 148 | + } | |
| 149 | + var callingConfig: [String: Any]? | |
| 150 | + switch request.raw["tool_choice"] { | |
| 151 | + case let choice as String: | |
| 152 | + switch choice { | |
| 153 | + case "auto": callingConfig = ["mode": "AUTO"] | |
| 154 | + case "required": callingConfig = ["mode": "ANY"] | |
| 155 | + case "none": callingConfig = ["mode": "NONE"] | |
| 156 | + default: break | |
| 157 | + } | |
| 158 | + case let choice as [String: Any]: | |
| 159 | + if let function = choice["function"] as? [String: Any], let name = function["name"] as? String { | |
| 160 | + callingConfig = ["mode": "ANY", "allowedFunctionNames": [name]] | |
| 161 | + } | |
| 162 | + default: | |
| 163 | + break | |
| 164 | + } | |
| 165 | + if let callingConfig { | |
| 166 | + body["toolConfig"] = ["functionCallingConfig": callingConfig] | |
| 167 | + } | |
| 168 | + return body | |
| 169 | + } | |
| 170 | + | |
| 171 | + private static func parts(for message: OAIMessage) -> [[String: Any]] { | |
| 172 | + if let text = message.contentString { | |
| 173 | + return text.isEmpty ? [] : [["text": text]] | |
| 174 | + } | |
| 175 | + guard let contentParts = message.contentParts else { return [] } | |
| 176 | + return contentParts.compactMap { part in | |
| 177 | + switch part["type"] as? String { | |
| 178 | + case "text": | |
| 179 | + let text = part["text"] as? String ?? "" | |
| 180 | + return text.isEmpty ? nil : ["text": text] | |
| 181 | + case "image_url": | |
| 182 | + guard let image = part["image_url"] as? [String: Any], | |
| 183 | + let url = image["url"] as? String, | |
| 184 | + url.hasPrefix("data:"), | |
| 185 | + let comma = url.firstIndex(of: ",") else { return nil } | |
| 186 | + let header = url[url.index(url.startIndex, offsetBy: 5)..<comma] | |
| 187 | + let mimeType = header.split(separator: ";").first.map(String.init) ?? "image/png" | |
| 188 | + return ["inlineData": [ | |
| 189 | + "mimeType": mimeType, | |
| 190 | + "data": String(url[url.index(after: comma)...]), | |
| 191 | + ]] | |
| 192 | + default: | |
| 193 | + return nil | |
| 194 | + } | |
| 195 | + } | |
| 196 | + } | |
| 197 | + | |
| 198 | + /// Remote (non data-URI) image URLs need inlining for Gemini; the route | |
| 199 | + /// rejects them with a clear error instead of fetching arbitrary URLs. | |
| 200 | + static func hasRemoteImageURL(_ request: ChatCompletionRequest) -> Bool { | |
| 201 | + request.messages.contains { message in | |
| 202 | + message.contentParts?.contains { part in | |
| 203 | + guard (part["type"] as? String) == "image_url", | |
| 204 | + let url = (part["image_url"] as? [String: Any])?["url"] as? String else { return false } | |
| 205 | + return !url.hasPrefix("data:") | |
| 206 | + } ?? false | |
| 207 | + } | |
| 208 | + } | |
| 209 | + | |
| 210 | + // MARK: - Shared mapping | |
| 211 | + | |
| 212 | + static func finishReason(from raw: String?, hasFunctionCall: Bool) -> String { | |
| 213 | + if hasFunctionCall { return "tool_calls" } | |
| 214 | + switch raw { | |
| 215 | + case "STOP", .none: return "stop" | |
| 216 | + case "MAX_TOKENS": return "length" | |
| 217 | + case "SAFETY", "PROHIBITED_CONTENT", "BLOCKLIST", "SPII", "IMAGE_SAFETY", "RECITATION": | |
| 218 | + return "content_filter" | |
| 219 | + default: return "stop" | |
| 220 | + } | |
| 221 | + } | |
| 222 | + | |
| 223 | + static func normalizedUsage(_ metadata: [String: Any]) -> [String: Any] { | |
| 224 | + let prompt = metadata["promptTokenCount"] as? Int ?? 0 | |
| 225 | + let candidates = metadata["candidatesTokenCount"] as? Int ?? 0 | |
| 226 | + let thoughts = metadata["thoughtsTokenCount"] as? Int ?? 0 | |
| 227 | + return UsageBuilder.build( | |
| 228 | + promptTokens: prompt, | |
| 229 | + completionTokens: candidates + thoughts, // OpenAI counts reasoning inside completion | |
| 230 | + cachedTokens: metadata["cachedContentTokenCount"] as? Int, | |
| 231 | + reasoningTokens: thoughts > 0 ? thoughts : nil | |
| 232 | + ) | |
| 233 | + } | |
| 234 | + | |
| 235 | + /// Empty-candidates + promptFeedback.blockReason → clear 400. | |
| 236 | + static func blockReason(_ upstream: [String: Any]) -> String? { | |
| 237 | + guard (upstream["candidates"] as? [[String: Any]] ?? []).isEmpty else { return nil } | |
| 238 | + return (upstream["promptFeedback"] as? [String: Any])?["blockReason"] as? String | |
| 239 | + } | |
| 240 | + | |
| 241 | + // MARK: - Response (Gemini → OpenAI), non-streaming | |
| 242 | + | |
| 243 | + static func translateResponse(_ upstream: [String: Any], emitter: ChunkEmitter) -> [String: Any] { | |
| 244 | + let candidate = (upstream["candidates"] as? [[String: Any]])?.first ?? [:] | |
| 245 | + let parts = (candidate["content"] as? [String: Any])?["parts"] as? [[String: Any]] ?? [] | |
| 246 | + | |
| 247 | + var text = "" | |
| 248 | + var reasoning = "" | |
| 249 | + var toolCalls: [[String: Any]] = [] | |
| 250 | + for part in parts { | |
| 251 | + if let partText = part["text"] as? String { | |
| 252 | + if part["thought"] as? Bool == true { | |
| 253 | + reasoning += partText | |
| 254 | + } else { | |
| 255 | + text += partText | |
| 256 | + } | |
| 257 | + } | |
| 258 | + if let functionCall = part["functionCall"] as? [String: Any] { | |
| 259 | + let args = functionCall["args"] as? [String: Any] ?? [:] | |
| 260 | + let arguments = String( | |
| 261 | + data: (try? JSONSerialization.data(withJSONObject: args)) ?? Data("{}".utf8), | |
| 262 | + encoding: .utf8 | |
| 263 | + ) ?? "{}" | |
| 264 | + toolCalls.append([ | |
| 265 | + "id": functionCall["id"] as? String ?? "call_\(UUID().uuidString.prefix(12))", | |
| 266 | + "type": "function", | |
| 267 | + "function": ["name": functionCall["name"] as? String ?? "", "arguments": arguments], | |
| 268 | + ]) | |
| 269 | + } | |
| 270 | + } | |
| 271 | + | |
| 272 | + var message: [String: Any] = ["role": "assistant"] | |
| 273 | + message["content"] = toolCalls.isEmpty || !text.isEmpty ? text : NSNull() | |
| 274 | + if !reasoning.isEmpty { message["reasoning_content"] = reasoning } | |
| 275 | + if !toolCalls.isEmpty { message["tool_calls"] = toolCalls } | |
| 276 | + | |
| 277 | + return emitter.completion( | |
| 278 | + message: message, | |
| 279 | + finishReason: finishReason( | |
| 280 | + from: candidate["finishReason"] as? String, | |
| 281 | + hasFunctionCall: !toolCalls.isEmpty | |
| 282 | + ), | |
| 283 | + usage: normalizedUsage(upstream["usageMetadata"] as? [String: Any] ?? [:]) | |
| 284 | + ) | |
| 285 | + } | |
| 286 | + | |
| 287 | + // MARK: - Streaming (Gemini SSE → OpenAI chunks) | |
| 288 | + | |
| 289 | + /// Each Gemini SSE data payload is a complete GenerateContentResponse | |
| 290 | + /// carrying the increment; there is no upstream [DONE]. | |
| 291 | + struct StreamMachine { | |
| 292 | + let emitter: ChunkEmitter | |
| 293 | + let includeUsage: Bool | |
| 294 | + | |
| 295 | + private var roleSent = false | |
| 296 | + private var toolIndex = -1 | |
| 297 | + private var finishSent = false | |
| 298 | + private(set) var lastUsage: [String: Any]? | |
| 299 | + private(set) var promptTokens = 0 | |
| 300 | + private(set) var completionTokens = 0 | |
| 301 | + | |
| 302 | + init(emitter: ChunkEmitter, includeUsage: Bool) { | |
| 303 | + self.emitter = emitter | |
| 304 | + self.includeUsage = includeUsage | |
| 305 | + } | |
| 306 | + | |
| 307 | + mutating func consume(_ event: SSEEvent) -> [Data] { | |
| 308 | + guard let json = (try? JSONSerialization.jsonObject(with: Data(event.data.utf8))) as? [String: Any] else { | |
| 309 | + return [] | |
| 310 | + } | |
| 311 | + var payloads: [Data] = [] | |
| 312 | + if !roleSent { | |
| 313 | + roleSent = true | |
| 314 | + payloads.append(emitter.roleChunk()) | |
| 315 | + } | |
| 316 | + | |
| 317 | + if let metadata = json["usageMetadata"] as? [String: Any] { | |
| 318 | + lastUsage = metadata // cumulative; last one wins | |
| 319 | + promptTokens = metadata["promptTokenCount"] as? Int ?? promptTokens | |
| 320 | + completionTokens = (metadata["candidatesTokenCount"] as? Int ?? 0) | |
| 321 | + + (metadata["thoughtsTokenCount"] as? Int ?? 0) | |
| 322 | + } | |
| 323 | + | |
| 324 | + let candidate = (json["candidates"] as? [[String: Any]])?.first ?? [:] | |
| 325 | + var sawFunctionCall = false | |
| 326 | + for part in (candidate["content"] as? [String: Any])?["parts"] as? [[String: Any]] ?? [] { | |
| 327 | + if let text = part["text"] as? String, !text.isEmpty { | |
| 328 | + if part["thought"] as? Bool == true { | |
| 329 | + payloads.append(emitter.reasoningChunk(text)) | |
| 330 | + } else { | |
| 331 | + payloads.append(emitter.contentChunk(text)) | |
| 332 | + } | |
| 333 | + } | |
| 334 | + if let functionCall = part["functionCall"] as? [String: Any] { | |
| 335 | + // Tool calls arrive complete: announce, then one full- | |
| 336 | + // arguments delta (strict SDKs accept single-shot args). | |
| 337 | + sawFunctionCall = true | |
| 338 | + toolIndex += 1 | |
| 339 | + let args = functionCall["args"] as? [String: Any] ?? [:] | |
| 340 | + let arguments = String( | |
| 341 | + data: (try? JSONSerialization.data(withJSONObject: args)) ?? Data("{}".utf8), | |
| 342 | + encoding: .utf8 | |
| 343 | + ) ?? "{}" | |
| 344 | + payloads.append(emitter.toolCallStartChunk( | |
| 345 | + toolIndex: toolIndex, | |
| 346 | + callID: functionCall["id"] as? String ?? "call_\(UUID().uuidString.prefix(12))", | |
| 347 | + name: functionCall["name"] as? String ?? "" | |
| 348 | + )) | |
| 349 | + payloads.append(emitter.toolCallArgumentsChunk(toolIndex: toolIndex, fragment: arguments)) | |
| 350 | + } | |
| 351 | + } | |
| 352 | + | |
| 353 | + if let rawFinish = candidate["finishReason"] as? String, !finishSent { | |
| 354 | + finishSent = true | |
| 355 | + payloads.append(emitter.finishChunk(reason: GeminiTranslator.finishReason( | |
| 356 | + from: rawFinish, | |
| 357 | + hasFunctionCall: sawFunctionCall || toolIndex >= 0 | |
| 358 | + ))) | |
| 359 | + } | |
| 360 | + return payloads | |
| 361 | + } | |
| 362 | + | |
| 363 | + /// Called when the upstream stream ends (no [DONE] from Gemini). | |
| 364 | + mutating func finalPayloads() -> [Data] { | |
| 365 | + var payloads: [Data] = [] | |
| 366 | + if !finishSent { | |
| 367 | + finishSent = true | |
| 368 | + payloads.append(emitter.finishChunk(reason: "stop")) | |
| 369 | + } | |
| 370 | + if includeUsage { | |
| 371 | + payloads.append(emitter.usageChunk( | |
| 372 | + lastUsage.map(GeminiTranslator.normalizedUsage) | |
| 373 | + ?? UsageBuilder.build(promptTokens: promptTokens, completionTokens: completionTokens, estimated: true) | |
| 374 | + )) | |
| 375 | + } | |
| 376 | + return payloads | |
| 377 | + } | |
| 378 | + } | |
| 379 | +} | |
modified
Sources/ZyquoRouter/Translate/OpenAINormalizer.swift
+147 −0
@@ -103,3 +103,150 @@ struct OpenAIModelList: Codable { | ||
| 103 | 103 | var object = "list" |
| 104 | 104 | var data: [OpenAIModelEntry] |
| 105 | 105 | } |
| 106 | + | |
| 107 | +// MARK: - Response / chunk construction | |
| 108 | + | |
| 109 | +/// Builds spec-exact `chat.completion` and `chat.completion.chunk` JSON. | |
| 110 | +/// One emitter per request: `id` and `created` stay constant for a stream. | |
| 111 | +struct ChunkEmitter { | |
| 112 | + let id: String | |
| 113 | + let created: Int | |
| 114 | + /// Namespaced router model id, echoed on every chunk/response. | |
| 115 | + let model: String | |
| 116 | + | |
| 117 | + init(model: String) { | |
| 118 | + var hex = "" | |
| 119 | + for _ in 0..<12 { hex += String(format: "%x", Int.random(in: 0...15)) } | |
| 120 | + id = "chatcmpl-\(hex)" | |
| 121 | + created = Int(Date().timeIntervalSince1970) | |
| 122 | + self.model = model | |
| 123 | + } | |
| 124 | + | |
| 125 | + static func serialize(_ object: [String: Any]) -> Data { | |
| 126 | + (try? JSONSerialization.data(withJSONObject: object)) ?? Data("{}".utf8) | |
| 127 | + } | |
| 128 | + | |
| 129 | + private func envelope(delta: [String: Any]?, finishReason: String?) -> [String: Any] { | |
| 130 | + [ | |
| 131 | + "id": id, | |
| 132 | + "object": "chat.completion.chunk", | |
| 133 | + "created": created, | |
| 134 | + "model": model, | |
| 135 | + "choices": [[ | |
| 136 | + "index": 0, | |
| 137 | + "delta": delta ?? [:], | |
| 138 | + "finish_reason": finishReason as Any, | |
| 139 | + ] as [String: Any]], | |
| 140 | + ] | |
| 141 | + } | |
| 142 | + | |
| 143 | + /// First chunk of every stream: the role delta. | |
| 144 | + func roleChunk() -> Data { | |
| 145 | + Self.serialize(envelope(delta: ["role": "assistant", "content": ""], finishReason: nil)) | |
| 146 | + } | |
| 147 | + | |
| 148 | + func contentChunk(_ text: String) -> Data { | |
| 149 | + Self.serialize(envelope(delta: ["content": text], finishReason: nil)) | |
| 150 | + } | |
| 151 | + | |
| 152 | + func reasoningChunk(_ text: String) -> Data { | |
| 153 | + Self.serialize(envelope(delta: ["reasoning_content": text], finishReason: nil)) | |
| 154 | + } | |
| 155 | + | |
| 156 | + /// Announces a tool call: id + name once, empty arguments accumulator. | |
| 157 | + func toolCallStartChunk(toolIndex: Int, callID: String, name: String) -> Data { | |
| 158 | + Self.serialize(envelope( | |
| 159 | + delta: ["tool_calls": [[ | |
| 160 | + "index": toolIndex, | |
| 161 | + "id": callID, | |
| 162 | + "type": "function", | |
| 163 | + "function": ["name": name, "arguments": ""], | |
| 164 | + ] as [String: Any]]], | |
| 165 | + finishReason: nil | |
| 166 | + )) | |
| 167 | + } | |
| 168 | + | |
| 169 | + func toolCallArgumentsChunk(toolIndex: Int, fragment: String) -> Data { | |
| 170 | + Self.serialize(envelope( | |
| 171 | + delta: ["tool_calls": [[ | |
| 172 | + "index": toolIndex, | |
| 173 | + "function": ["arguments": fragment], | |
| 174 | + ] as [String: Any]]], | |
| 175 | + finishReason: nil | |
| 176 | + )) | |
| 177 | + } | |
| 178 | + | |
| 179 | + func finishChunk(reason: String) -> Data { | |
| 180 | + Self.serialize(envelope(delta: [:], finishReason: reason)) | |
| 181 | + } | |
| 182 | + | |
| 183 | + /// Usage chunk: empty `choices` array, only when the client asked for it. | |
| 184 | + func usageChunk(_ usage: [String: Any]) -> Data { | |
| 185 | + Self.serialize([ | |
| 186 | + "id": id, | |
| 187 | + "object": "chat.completion.chunk", | |
| 188 | + "created": created, | |
| 189 | + "model": model, | |
| 190 | + "choices": [] as [Any], | |
| 191 | + "usage": usage, | |
| 192 | + ]) | |
| 193 | + } | |
| 194 | + | |
| 195 | + /// Complete non-streaming `chat.completion` object. | |
| 196 | + func completion( | |
| 197 | + message: [String: Any], | |
| 198 | + finishReason: String, | |
| 199 | + usage: [String: Any], | |
| 200 | + extras: [String: Any] = [:] | |
| 201 | + ) -> [String: Any] { | |
| 202 | + var object: [String: Any] = [ | |
| 203 | + "id": id, | |
| 204 | + "object": "chat.completion", | |
| 205 | + "created": created, | |
| 206 | + "model": model, | |
| 207 | + "choices": [[ | |
| 208 | + "index": 0, | |
| 209 | + "message": message, | |
| 210 | + "finish_reason": finishReason, | |
| 211 | + ] as [String: Any]], | |
| 212 | + "usage": usage, | |
| 213 | + ] | |
| 214 | + for (key, value) in extras { | |
| 215 | + object[key] = value | |
| 216 | + } | |
| 217 | + return object | |
| 218 | + } | |
| 219 | +} | |
| 220 | + | |
| 221 | +/// OpenAI-shape usage dictionaries from normalized numbers. | |
| 222 | +enum UsageBuilder { | |
| 223 | + static func build( | |
| 224 | + promptTokens: Int, | |
| 225 | + completionTokens: Int, | |
| 226 | + cachedTokens: Int? = nil, | |
| 227 | + reasoningTokens: Int? = nil, | |
| 228 | + estimated: Bool = false | |
| 229 | + ) -> [String: Any] { | |
| 230 | + var usage: [String: Any] = [ | |
| 231 | + "prompt_tokens": promptTokens, | |
| 232 | + "completion_tokens": completionTokens, | |
| 233 | + "total_tokens": promptTokens + completionTokens, | |
| 234 | + ] | |
| 235 | + if let cachedTokens, cachedTokens > 0 { | |
| 236 | + usage["prompt_tokens_details"] = ["cached_tokens": cachedTokens] | |
| 237 | + } | |
| 238 | + if let reasoningTokens, reasoningTokens > 0 { | |
| 239 | + usage["completion_tokens_details"] = ["reasoning_tokens": reasoningTokens] | |
| 240 | + } | |
| 241 | + if estimated { | |
| 242 | + usage["x_zyquo"] = ["usage_estimated": true] | |
| 243 | + } | |
| 244 | + return usage | |
| 245 | + } | |
| 246 | + | |
| 247 | + /// Rough local estimation (~4 chars/token) used only when the upstream | |
| 248 | + /// reports nothing; always flagged via x_zyquo.usage_estimated. | |
| 249 | + static func estimateTokens(_ text: String) -> Int { | |
| 250 | + max(1, text.count / 4) | |
| 251 | + } | |
| 252 | +} | |
modified
Sources/ZyquoRouter/ViewModels/ServerController.swift
+12 −1
@@ -44,7 +44,18 @@ final class ServerController: ObservableObject { | ||
| 44 | 44 | |
| 45 | 45 | let host = bindLAN ? "0.0.0.0" : "127.0.0.1" |
| 46 | 46 | let port = port |
| 47 | − let routes = Routes(router: RequestRouter()) | |
| 47 | + let localKeys = PersistenceService.shared.load([APIKeyRecord].self, from: "local-keys.json") ?? [] | |
| 48 | + | |
| 49 | + // LAN exposure is gated behind at least one local API key (D12). | |
| 50 | + if bindLAN, !localKeys.contains(where: \.enabled) { | |
| 51 | + state = .failed("LAN mode requires at least one enabled local API key — create one in Keys, or switch back to Localhost only.") | |
| 52 | + return | |
| 53 | + } | |
| 54 | + | |
| 55 | + let routes = Routes( | |
| 56 | + router: RequestRouter(), | |
| 57 | + auth: AuthMiddleware(keys: localKeys) | |
| 58 | + ) | |
| 48 | 59 | let server = HTTPServer(host: host, port: port) { request in |
| 49 | 60 | await routes.handle(request) |
| 50 | 61 | } |
added
Tests/ZyquoRouterTests/TranslatorTests.swift
+284 −0
@@ -0,0 +1,284 @@ | ||
| 1 | +// | |
| 2 | +// TranslatorTests.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Fixture tests for the translation layer: recorded Anthropic SSE events → | |
| 9 | +// OpenAI chunks, recorded Gemini SSE → OpenAI chunks, CompatAdjuster | |
| 10 | +// request/response tables, request builders. Fixtures follow the transcripts | |
| 11 | +// in docs/ROUTER-RESEARCH.md §3. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import XCTest | |
| 15 | +@testable import ZyquoRouter | |
| 16 | + | |
| 17 | +final class TranslatorTests: XCTestCase { | |
| 18 | + private func chatRequest(_ dict: [String: Any]) throws -> ChatCompletionRequest { | |
| 19 | + try ChatCompletionRequest(body: JSONSerialization.data(withJSONObject: dict)) | |
| 20 | + } | |
| 21 | + | |
| 22 | + private func model( | |
| 23 | + _ id: String, | |
| 24 | + _ provider: ProviderID, | |
| 25 | + reasoning: Bool = false, | |
| 26 | + maxCompletionTokens: Bool = false | |
| 27 | + ) -> AIModel { | |
| 28 | + AIModel( | |
| 29 | + id: id, provider: provider, displayName: id, | |
| 30 | + contextWindow: 128_000, maxOutputTokens: 8192, | |
| 31 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: reasoning), | |
| 32 | + pricing: nil, | |
| 33 | + parameterSupport: ParameterSupport(usesMaxCompletionTokens: maxCompletionTokens) | |
| 34 | + ) | |
| 35 | + } | |
| 36 | + | |
| 37 | + private func json(_ data: Data) -> [String: Any] { | |
| 38 | + (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:] | |
| 39 | + } | |
| 40 | + | |
| 41 | + // MARK: - Anthropic request building | |
| 42 | + | |
| 43 | + func testAnthropicRequestTranslation() throws { | |
| 44 | + let request = try chatRequest([ | |
| 45 | + "model": "anthropic/claude-x", | |
| 46 | + "temperature": 1.4, | |
| 47 | + "messages": [ | |
| 48 | + ["role": "system", "content": "You are terse."], | |
| 49 | + ["role": "user", "content": "Weather?"], | |
| 50 | + ["role": "assistant", "content": NSNull(), "tool_calls": [[ | |
| 51 | + "id": "call_abc", "type": "function", | |
| 52 | + "function": ["name": "get_weather", "arguments": "{\"location\": \"Paris\"}"], | |
| 53 | + ]]], | |
| 54 | + ["role": "tool", "tool_call_id": "call_abc", "content": "18°C"], | |
| 55 | + ], | |
| 56 | + "tools": [["type": "function", "function": [ | |
| 57 | + "name": "get_weather", | |
| 58 | + "parameters": ["type": "object", "properties": ["location": ["type": "string"]]], | |
| 59 | + ]]], | |
| 60 | + "parallel_tool_calls": false, | |
| 61 | + ]) | |
| 62 | + let body = AnthropicTranslator.buildRequest(request, model: model("claude-x", .anthropic)) | |
| 63 | + | |
| 64 | + XCTAssertEqual(body["model"] as? String, "claude-x") | |
| 65 | + XCTAssertEqual(body["system"] as? String, "You are terse.") | |
| 66 | + XCTAssertEqual(body["max_tokens"] as? Int, 8192, "synthesized from catalog when omitted") | |
| 67 | + XCTAssertEqual(body["temperature"] as? Double, 1.0, "clamped to Anthropic's 0–1") | |
| 68 | + | |
| 69 | + let messages = body["messages"] as! [[String: Any]] | |
| 70 | + XCTAssertEqual(messages.count, 3) | |
| 71 | + let assistantBlocks = messages[1]["content"] as! [[String: Any]] | |
| 72 | + XCTAssertEqual(assistantBlocks[0]["type"] as? String, "tool_use") | |
| 73 | + XCTAssertEqual((assistantBlocks[0]["input"] as? [String: Any])?["location"] as? String, "Paris") | |
| 74 | + let toolResult = (messages[2]["content"] as! [[String: Any]])[0] | |
| 75 | + XCTAssertEqual(toolResult["type"] as? String, "tool_result") | |
| 76 | + XCTAssertEqual(toolResult["tool_use_id"] as? String, "call_abc") | |
| 77 | + | |
| 78 | + let tools = body["tools"] as! [[String: Any]] | |
| 79 | + XCTAssertNotNil(tools[0]["input_schema"]) | |
| 80 | + let toolChoice = body["tool_choice"] as! [String: Any] | |
| 81 | + XCTAssertEqual(toolChoice["type"] as? String, "auto") | |
| 82 | + XCTAssertEqual(toolChoice["disable_parallel_tool_use"] as? Bool, true) | |
| 83 | + } | |
| 84 | + | |
| 85 | + // MARK: - Anthropic stream machine (fixture from research §3.1.4) | |
| 86 | + | |
| 87 | + func testAnthropicStreamToOpenAIChunks() { | |
| 88 | + var machine = AnthropicTranslator.StreamMachine( | |
| 89 | + emitter: ChunkEmitter(model: "anthropic/claude-x"), | |
| 90 | + includeUsage: true | |
| 91 | + ) | |
| 92 | + func consume(_ event: String, _ data: String) -> [[String: Any]] { | |
| 93 | + machine.consume(SSEEvent(event: event, data: data)).payloads.map(json) | |
| 94 | + } | |
| 95 | + | |
| 96 | + let start = consume("message_start", #"{"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":472,"cache_read_input_tokens":10,"output_tokens":2}}}"#) | |
| 97 | + XCTAssertEqual((start[0]["choices"] as! [[String: Any]])[0]["delta"] as! [String: String], | |
| 98 | + ["role": "assistant", "content": ""]) | |
| 99 | + | |
| 100 | + XCTAssertTrue(consume("ping", #"{"type":"ping"}"#).isEmpty) | |
| 101 | + XCTAssertTrue(consume("content_block_start", #"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#).isEmpty) | |
| 102 | + | |
| 103 | + let text = consume("content_block_delta", #"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Okay,"}}"#) | |
| 104 | + XCTAssertEqual(((text[0]["choices"] as! [[String: Any]])[0]["delta"] as! [String: Any])["content"] as? String, "Okay,") | |
| 105 | + | |
| 106 | + let toolStart = consume("content_block_start", #"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{}}}"#) | |
| 107 | + let toolDelta = ((toolStart[0]["choices"] as! [[String: Any]])[0]["delta"] as! [String: Any])["tool_calls"] as! [[String: Any]] | |
| 108 | + XCTAssertEqual(toolDelta[0]["index"] as? Int, 0) | |
| 109 | + XCTAssertEqual(toolDelta[0]["id"] as? String, "toolu_1") | |
| 110 | + XCTAssertEqual((toolDelta[0]["function"] as! [String: Any])["arguments"] as? String, "") | |
| 111 | + | |
| 112 | + XCTAssertTrue(consume("content_block_delta", #"{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}}"#).isEmpty, "empty fragments skipped") | |
| 113 | + | |
| 114 | + let args = consume("content_block_delta", #"{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"location\":\"Paris\"}"}}"#) | |
| 115 | + let argsDelta = ((args[0]["choices"] as! [[String: Any]])[0]["delta"] as! [String: Any])["tool_calls"] as! [[String: Any]] | |
| 116 | + XCTAssertNil(argsDelta[0]["id"], "no id repetition on argument deltas") | |
| 117 | + XCTAssertEqual((argsDelta[0]["function"] as! [String: Any])["arguments"] as? String, #"{"location":"Paris"}"#) | |
| 118 | + | |
| 119 | + _ = consume("content_block_stop", #"{"type":"content_block_stop","index":1}"#) | |
| 120 | + let finish = consume("message_delta", #"{"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":89}}"#) | |
| 121 | + XCTAssertEqual((finish[0]["choices"] as! [[String: Any]])[0]["finish_reason"] as? String, "tool_calls") | |
| 122 | + | |
| 123 | + let (stopPayloads, done) = machine.consume(SSEEvent(event: "message_stop", data: #"{"type":"message_stop"}"#)) | |
| 124 | + XCTAssertTrue(done) | |
| 125 | + let usageChunk = json(stopPayloads[0]) | |
| 126 | + XCTAssertEqual((usageChunk["choices"] as? [Any])?.count, 0, "usage chunk has empty choices") | |
| 127 | + let usage = usageChunk["usage"] as! [String: Any] | |
| 128 | + XCTAssertEqual(usage["prompt_tokens"] as? Int, 482, "input + cache reads") | |
| 129 | + XCTAssertEqual(usage["completion_tokens"] as? Int, 89) | |
| 130 | + } | |
| 131 | + | |
| 132 | + // MARK: - Gemini request building + stream machine | |
| 133 | + | |
| 134 | + func testGeminiRequestTranslation() throws { | |
| 135 | + let request = try chatRequest([ | |
| 136 | + "model": "gemini/gemini-x", | |
| 137 | + "max_tokens": 100, | |
| 138 | + "temperature": 0.7, | |
| 139 | + "stop": "\n\n", | |
| 140 | + "messages": [ | |
| 141 | + ["role": "system", "content": "Be brief."], | |
| 142 | + ["role": "user", "content": "Hi"], | |
| 143 | + ["role": "assistant", "content": NSNull(), "tool_calls": [[ | |
| 144 | + "id": "call_1", "type": "function", | |
| 145 | + "function": ["name": "lookup", "arguments": "{\"q\":\"x\"}"], | |
| 146 | + ]]], | |
| 147 | + ["role": "tool", "tool_call_id": "call_1", "content": "plain text result"], | |
| 148 | + ], | |
| 149 | + ]) | |
| 150 | + let body = GeminiTranslator.buildRequest(request, model: model("gemini-x", .gemini)) | |
| 151 | + | |
| 152 | + XCTAssertNotNil(body["systemInstruction"]) | |
| 153 | + let contents = body["contents"] as! [[String: Any]] | |
| 154 | + XCTAssertEqual(contents[1]["role"] as? String, "model", "assistant → model") | |
| 155 | + let functionResponse = ((contents[2]["parts"] as! [[String: Any]])[0]["functionResponse"] as! [String: Any]) | |
| 156 | + XCTAssertEqual(functionResponse["name"] as? String, "lookup", "name resolved from tool_call_id") | |
| 157 | + XCTAssertEqual((functionResponse["response"] as! [String: Any])["result"] as? String, "plain text result", | |
| 158 | + "non-JSON tool output wrapped in an object") | |
| 159 | + let generation = body["generationConfig"] as! [String: Any] | |
| 160 | + XCTAssertEqual(generation["maxOutputTokens"] as? Int, 100) | |
| 161 | + XCTAssertEqual(generation["stopSequences"] as? [String], ["\n\n"]) | |
| 162 | + } | |
| 163 | + | |
| 164 | + func testGeminiStreamToOpenAIChunks() { | |
| 165 | + var machine = GeminiTranslator.StreamMachine( | |
| 166 | + emitter: ChunkEmitter(model: "gemini/gemini-x"), | |
| 167 | + includeUsage: true | |
| 168 | + ) | |
| 169 | + let first = machine.consume(SSEEvent(event: nil, data: #"{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"},"index":0}]}"#)).map(json) | |
| 170 | + XCTAssertEqual((first[0]["choices"] as! [[String: Any]])[0]["delta"] as! [String: String], | |
| 171 | + ["role": "assistant", "content": ""], "role chunk synthesized") | |
| 172 | + XCTAssertEqual(((first[1]["choices"] as! [[String: Any]])[0]["delta"] as! [String: Any])["content"] as? String, "The") | |
| 173 | + | |
| 174 | + // functionCall arrives complete → STOP must map to tool_calls. | |
| 175 | + let tool = machine.consume(SSEEvent(event: nil, data: #"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"location":"Paris"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":57,"candidatesTokenCount":12,"thoughtsTokenCount":88,"totalTokenCount":157}}"#)).map(json) | |
| 176 | + let toolStart = ((tool[0]["choices"] as! [[String: Any]])[0]["delta"] as! [String: Any])["tool_calls"] as! [[String: Any]] | |
| 177 | + XCTAssertEqual((toolStart[0]["function"] as! [String: Any])["name"] as? String, "get_weather") | |
| 178 | + XCTAssertTrue((toolStart[0]["id"] as! String).hasPrefix("call_"), "missing id synthesized") | |
| 179 | + let finish = tool[2] | |
| 180 | + XCTAssertEqual((finish["choices"] as! [[String: Any]])[0]["finish_reason"] as? String, "tool_calls", | |
| 181 | + "STOP + functionCall override") | |
| 182 | + | |
| 183 | + let final = machine.finalPayloads().map(json) | |
| 184 | + XCTAssertEqual(final.count, 1, "finish already sent; only usage remains") | |
| 185 | + let usage = final[0]["usage"] as! [String: Any] | |
| 186 | + XCTAssertEqual(usage["prompt_tokens"] as? Int, 57) | |
| 187 | + XCTAssertEqual(usage["completion_tokens"] as? Int, 100, "candidates + thoughts") | |
| 188 | + XCTAssertEqual((usage["completion_tokens_details"] as! [String: Any])["reasoning_tokens"] as? Int, 88) | |
| 189 | + } | |
| 190 | + | |
| 191 | + // MARK: - CompatAdjuster | |
| 192 | + | |
| 193 | + func testCompatAdjusterRequestTables() { | |
| 194 | + let base: [String: Any] = [ | |
| 195 | + "model": "mistral/mistral-x", | |
| 196 | + "messages": [["role": "user", "content": "hi"]], | |
| 197 | + "seed": 42, | |
| 198 | + "max_tokens": 100, | |
| 199 | + "custom_extra": "kept", | |
| 200 | + ] | |
| 201 | + | |
| 202 | + let mistral = CompatAdjuster.adjustRequest(base, model: model("mistral-x", .mistral), stream: true) | |
| 203 | + XCTAssertEqual(mistral["model"] as? String, "mistral-x", "namespace stripped") | |
| 204 | + XCTAssertEqual(mistral["random_seed"] as? Int, 42, "seed renamed") | |
| 205 | + XCTAssertNil(mistral["seed"]) | |
| 206 | + XCTAssertEqual(mistral["custom_extra"] as? String, "kept", "unknown keys pass through (D5)") | |
| 207 | + XCTAssertEqual((mistral["stream_options"] as? [String: Any])?["include_usage"] as? Bool, true) | |
| 208 | + | |
| 209 | + let kimi = CompatAdjuster.adjustRequest( | |
| 210 | + ["model": "kimi/k", "messages": [["role": "user", "content": "hi"]], "temperature": 1.8], | |
| 211 | + model: model("k", .kimi), stream: false | |
| 212 | + ) | |
| 213 | + XCTAssertEqual(kimi["temperature"] as? Double, 1.0, "Moonshot clamps to [0,1]") | |
| 214 | + | |
| 215 | + let cerebras = CompatAdjuster.adjustRequest( | |
| 216 | + ["model": "cerebras/c", "messages": [["role": "user", "content": "hi"]], "max_tokens": 50], | |
| 217 | + model: model("c", .cerebras, maxCompletionTokens: true), stream: false | |
| 218 | + ) | |
| 219 | + XCTAssertEqual(cerebras["max_completion_tokens"] as? Int, 50) | |
| 220 | + XCTAssertNil(cerebras["max_tokens"], "never send both") | |
| 221 | + | |
| 222 | + let xai = CompatAdjuster.adjustRequest( | |
| 223 | + ["model": "xai/g", "messages": [["role": "user", "content": "hi"]], | |
| 224 | + "presence_penalty": 0.5, "stop": ["x"]], | |
| 225 | + model: model("g", .xai, reasoning: true), stream: false | |
| 226 | + ) | |
| 227 | + XCTAssertNil(xai["presence_penalty"], "Grok reasoning models 400 on penalties") | |
| 228 | + XCTAssertNil(xai["stop"]) | |
| 229 | + } | |
| 230 | + | |
| 231 | + func testCompatAdjusterResponseNormalization() { | |
| 232 | + // Together "eos" + Mistral thinking-array + Perplexity <think>. | |
| 233 | + let together = CompatAdjuster.normalizeResponse( | |
| 234 | + ["model": "m", "choices": [["index": 0, "finish_reason": "eos", | |
| 235 | + "message": ["role": "assistant", "content": "hi"]]]], | |
| 236 | + namespacedModel: "together/m", provider: .together | |
| 237 | + ) | |
| 238 | + XCTAssertEqual(together["model"] as? String, "together/m") | |
| 239 | + XCTAssertEqual((together["choices"] as! [[String: Any]])[0]["finish_reason"] as? String, "stop") | |
| 240 | + | |
| 241 | + let mistral = CompatAdjuster.normalizeResponse( | |
| 242 | + ["choices": [["index": 0, "finish_reason": "stop", "message": [ | |
| 243 | + "role": "assistant", | |
| 244 | + "content": [ | |
| 245 | + ["type": "thinking", "thinking": [["type": "text", "text": "hmm"]]], | |
| 246 | + ["type": "text", "text": "answer"], | |
| 247 | + ], | |
| 248 | + ]]]], | |
| 249 | + namespacedModel: "mistral/m", provider: .mistral | |
| 250 | + ) | |
| 251 | + let mistralMessage = (mistral["choices"] as! [[String: Any]])[0]["message"] as! [String: Any] | |
| 252 | + XCTAssertEqual(mistralMessage["content"] as? String, "answer") | |
| 253 | + XCTAssertEqual(mistralMessage["reasoning_content"] as? String, "hmm") | |
| 254 | + | |
| 255 | + let perplexity = CompatAdjuster.normalizeResponse( | |
| 256 | + ["choices": [["index": 0, "finish_reason": "stop", "message": [ | |
| 257 | + "role": "assistant", "content": "<think>reasoning here</think>\nfinal answer", | |
| 258 | + ]]]], | |
| 259 | + namespacedModel: "perplexity/sonar-reasoning", provider: .perplexity | |
| 260 | + ) | |
| 261 | + let perplexityMessage = (perplexity["choices"] as! [[String: Any]])[0]["message"] as! [String: Any] | |
| 262 | + XCTAssertEqual(perplexityMessage["content"] as? String, "final answer") | |
| 263 | + XCTAssertEqual(perplexityMessage["reasoning_content"] as? String, "reasoning here") | |
| 264 | + } | |
| 265 | + | |
| 266 | + func testUsageChunkSwallowedWhenClientDidNotAsk() { | |
| 267 | + let swallowed = CompatAdjuster.normalizeChunk( | |
| 268 | + ["choices": [] as [Any], "usage": ["prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3]], | |
| 269 | + namespacedModel: "openai/x", provider: .openai, clientWantsUsage: false | |
| 270 | + ) | |
| 271 | + XCTAssertNil(swallowed) | |
| 272 | + } | |
| 273 | + | |
| 274 | + // MARK: - Request parsing | |
| 275 | + | |
| 276 | + func testRequestParsingErrors() { | |
| 277 | + XCTAssertThrowsError(try chatRequest(["messages": [["role": "user", "content": "hi"]]])) | |
| 278 | + XCTAssertThrowsError(try chatRequest(["model": "x"])) | |
| 279 | + XCTAssertThrowsError(try chatRequest([ | |
| 280 | + "model": "x", "n": 3, | |
| 281 | + "messages": [["role": "user", "content": "hi"]], | |
| 282 | + ]), "n>1 rejected router-wide") | |
| 283 | + } | |
| 284 | +} | |
added
docs/API.md
+237 −0
@@ -0,0 +1,237 @@ | ||
| 1 | +# Zyquo Router — API Reference | |
| 2 | + | |
| 3 | +The exact public contract served on `http://localhost:<port>` (default port **8787**). | |
| 4 | +This document and the implementation are maintained together; the in-app Docs screen | |
| 5 | +renders this file. OpenAI-compatible: point any OpenAI SDK at | |
| 6 | +`base_url = http://localhost:8787/v1`. | |
| 7 | + | |
| 8 | +```python | |
| 9 | +from openai import OpenAI | |
| 10 | +client = OpenAI(base_url="http://localhost:8787/v1", api_key="zyquo-sk-…") | |
| 11 | +r = client.chat.completions.create( | |
| 12 | + model="anthropic/claude-sonnet-4-5", # any provider/model from GET /v1/models | |
| 13 | + messages=[{"role": "user", "content": "Hello"}], | |
| 14 | +) | |
| 15 | +``` | |
| 16 | + | |
| 17 | +--- | |
| 18 | + | |
| 19 | +## Authentication | |
| 20 | + | |
| 21 | +- **Localhost (default bind):** authentication is optional. With no local API keys | |
| 22 | + configured, requests need no `Authorization` header. | |
| 23 | +- **When local keys exist** (created in *Keys → Local API Keys*), every endpoint except | |
| 24 | + `GET /health` requires `Authorization: Bearer zyquo-sk-…`. Unknown, revoked, or | |
| 25 | + malformed tokens → `401` (`authentication_error`, code `invalid_api_key`). | |
| 26 | +- **LAN bind (`0.0.0.0`)** refuses to start without at least one enabled local key. | |
| 27 | +- Keys may carry a **model allow-list**: requests for other models → `403` | |
| 28 | + (`permission_error`, code `model_not_allowed`). | |
| 29 | +- Provider API keys (OpenAI, Anthropic, …) live only in the encrypted vault on the Mac | |
| 30 | + running the router. **No endpoint ever returns them**, and they never appear in logs | |
| 31 | + or error messages. | |
| 32 | + | |
| 33 | +## Model naming | |
| 34 | + | |
| 35 | +- Canonical IDs are **namespaced**: `provider/model-id` — e.g. `openai/gpt-5.2`, | |
| 36 | + `anthropic/claude-sonnet-4-5`, `deepseek/deepseek-chat`, | |
| 37 | + `deepinfra/meta-llama/Llama-4-Maverick`. The segment before the first `/` must be a | |
| 38 | + provider (`openai, anthropic, xai, mistral, gemini, qwen, deepseek, kimi, perplexity, | |
| 39 | + together, deepinfra, cerebras`); model IDs may themselves contain `/`. | |
| 40 | +- **Bare upstream IDs** are accepted when unambiguous across providers | |
| 41 | + (`deepseek-chat` works; an ID hosted by two providers → `404` with the namespaced | |
| 42 | + candidates listed). | |
| 43 | +- **Aliases** (user-defined, e.g. `fast`) resolve before anything else. | |
| 44 | +- Disabled models 404 exactly like unknown ones. | |
| 45 | +- Responses always echo the **namespaced ID** in `model` — including when a fallback | |
| 46 | + chain routed the request to a different model than requested (honest reporting). | |
| 47 | + | |
| 48 | +--- | |
| 49 | + | |
| 50 | +## POST /v1/chat/completions | |
| 51 | + | |
| 52 | +The full current OpenAI request schema is accepted. Highlights and router-specific | |
| 53 | +behavior: | |
| 54 | + | |
| 55 | +| Field | Behavior | | |
| 56 | +|---|---| | |
| 57 | +| `model` | Namespaced ID, unambiguous bare ID, or alias. Required. | | |
| 58 | +| `messages` | All roles: `system`, `developer`, `user`, `assistant` (incl. `tool_calls`), `tool`. Content may be a string or content-part array. | | |
| 59 | +| Image parts | `{"type":"image_url","image_url":{"url":…}}` with data-URI base64 or remote URL. Vision-capable models only (else `400`). **Gemini: data-URI only** — the router does not fetch remote URLs for Gemini (`400` with explanation). | | |
| 60 | +| `max_tokens` / `max_completion_tokens` | Both accepted; `max_completion_tokens` wins. Sent upstream under the name each provider documents. Anthropic requires one — when omitted the router fills the model's catalog max output (fallback 4096). | | |
| 61 | +| `temperature`, `top_p`, `stop`, `seed`, `frequency_penalty`, `presence_penalty`, `user`, `logprobs`… | Translated, clamped, renamed, or stripped per provider (see *Provider notes*). Unsupported params are stripped silently — never a 400 for asking. | | |
| 62 | +| `n` | **Only `n=1`.** `n>1` → `400` (`invalid_request_error`, param `n`). | | |
| 63 | +| `tools`, `tool_choice`, `parallel_tool_calls` | Full function calling on tool-capable models (else `400`). Translated natively for Anthropic (`input_schema`, `tool_choice` auto/any/none/tool, `disable_parallel_tool_use`) and Gemini (`functionDeclarations`, `functionCallingConfig`). | | |
| 64 | +| `response_format` | `json_object` / `json_schema` forwarded where supported (Gemini: `responseMimeType`/`responseJsonSchema`; OpenAI-compatible: pass-through). Anthropic: best-effort via system steering (documented limitation). | | |
| 65 | +| `reasoning_effort` | OpenAI-standard values, translated per provider (Anthropic `thinking` budget 1024/8192/24576; Gemini `thinkingConfig`; pass-through where native). Stripped on non-reasoning models. | | |
| 66 | +| `stream` | SSE streaming (below). | | |
| 67 | +| `stream_options.include_usage` | Adds the final usage chunk (empty `choices`). | | |
| 68 | +| **Unknown keys** | **Passed through** to OpenAI-compatible upstreams — use provider extras like Perplexity `search_domain_filter`, Qwen `enable_thinking`, Together `top_k`, Anthropic `thinking` (extra body). | | |
| 69 | + | |
| 70 | +### Non-streaming response | |
| 71 | + | |
| 72 | +Spec-exact `chat.completion`: | |
| 73 | + | |
| 74 | +```json | |
| 75 | +{ | |
| 76 | + "id": "chatcmpl-5f9d174703e1", | |
| 77 | + "object": "chat.completion", | |
| 78 | + "created": 1785462056, | |
| 79 | + "model": "anthropic/claude-haiku-4-5-20251001", | |
| 80 | + "choices": [{ | |
| 81 | + "index": 0, | |
| 82 | + "message": { "role": "assistant", "content": "OK" }, | |
| 83 | + "finish_reason": "stop" | |
| 84 | + }], | |
| 85 | + "usage": { "prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16 } | |
| 86 | +} | |
| 87 | +``` | |
| 88 | + | |
| 89 | +- `finish_reason` ∈ `stop | length | tool_calls | content_filter` (every upstream value | |
| 90 | + is normalized into this set; e.g. Together `eos`→`stop`, Anthropic `tool_use`→ | |
| 91 | + `tool_calls`, Gemini `SAFETY`→`content_filter`). | |
| 92 | +- `usage` comes from the upstream when reported. When an upstream reports none, the | |
| 93 | + router **estimates** (~4 chars/token) and flags it: | |
| 94 | + `"usage": { …, "x_zyquo": {"usage_estimated": true} }`. | |
| 95 | +- Cached prompt tokens land in `usage.prompt_tokens_details.cached_tokens`; reasoning | |
| 96 | + tokens in `usage.completion_tokens_details.reasoning_tokens`. | |
| 97 | + | |
| 98 | +### Reasoning output | |
| 99 | + | |
| 100 | +Reasoning/thinking text is normalized to **`reasoning_content`** — a sibling of | |
| 101 | +`content` on the message (non-streaming) and the delta (streaming) — the DeepSeek | |
| 102 | +convention that most tooling already understands. Sources: DeepSeek/Qwen/Kimi/xAI | |
| 103 | +native field, Anthropic `thinking` blocks, Gemini `thought` parts, Mistral Magistral | |
| 104 | +thinking chunks, Perplexity `<think>` tags (extracted). | |
| 105 | + | |
| 106 | +### Streaming (SSE) | |
| 107 | + | |
| 108 | +`Content-Type: text/event-stream`; each event is `data: <chat.completion.chunk JSON>`, | |
| 109 | +terminated by `data: [DONE]`. Byte-exact chunk discipline: | |
| 110 | + | |
| 111 | +1. First chunk: role delta `{"delta":{"role":"assistant","content":""}}`. | |
| 112 | +2. Content deltas `{"delta":{"content":"…"}}`; reasoning deltas | |
| 113 | + `{"delta":{"reasoning_content":"…"}}`. | |
| 114 | +3. Tool calls stream as OpenAI deltas: first frame carries | |
| 115 | + `{"index":N,"id":"…","type":"function","function":{"name":"…","arguments":""}}`, | |
| 116 | + subsequent frames only `{"index":N,"function":{"arguments":"<fragment>"}}`. | |
| 117 | + (Gemini delivers arguments whole; the router emits announce + one full fragment.) | |
| 118 | +4. Finish chunk: empty delta + `"finish_reason"`. | |
| 119 | +5. If `stream_options.include_usage`: one usage chunk with **empty `choices` array**. | |
| 120 | +6. `data: [DONE]`. | |
| 121 | + | |
| 122 | +The `id`/`created`/`model` envelope is constant across a stream. Comment lines | |
| 123 | +(`: keep-alive`) may appear and must be ignored (all OpenAI SDKs do). | |
| 124 | + | |
| 125 | +**Mid-stream upstream failure:** the router cannot change the HTTP status after bytes | |
| 126 | +are sent; it emits one error frame `data: {"error":{"message":…,"type":…,"code":…}}` | |
| 127 | +followed by `data: [DONE]`, and never retries after the first forwarded byte. | |
| 128 | + | |
| 129 | +**Client disconnect** cancels the upstream call immediately. | |
| 130 | + | |
| 131 | +### Retries & fallbacks | |
| 132 | + | |
| 133 | +- Transient upstream failures (429, 5xx, network) retry with exponential backoff + | |
| 134 | + jitter (max 3 attempts), honoring `Retry-After` — only before any byte has been | |
| 135 | + forwarded. | |
| 136 | +- User-configured **fallback chains** try the next model in the chain on upstream | |
| 137 | + failure (rate limit, 5xx, network, missing/invalid provider key — never on request | |
| 138 | + errors). The response `model` field reports the model that actually answered. | |
| 139 | + | |
| 140 | +### Errors | |
| 141 | + | |
| 142 | +Always OpenAI-shaped: `{"error": {"message", "type", "param", "code"}}`. | |
| 143 | + | |
| 144 | +| Status | When | type / code | | |
| 145 | +|---|---|---| | |
| 146 | +| 400 | Malformed body, missing `model`/`messages`, `n>1`, capability mismatch (tools/vision on unsupporting model), upstream rejected request, Gemini prompt block | `invalid_request_error` | | |
| 147 | +| 401 | Missing/invalid/revoked local key → `invalid_api_key` · provider key missing → `missing_provider_key` · provider key rejected upstream → `invalid_provider_key` | `authentication_error` | | |
| 148 | +| 403 | Local key not allowed for this model | `permission_error` / `model_not_allowed` | | |
| 149 | +| 404 | Unknown/disabled/ambiguous model (`model_not_found`), unknown route | `invalid_request_error` | | |
| 150 | +| 413 | Body over the request size limit (default 32 MB) | `invalid_request_error` | | |
| 151 | +| 429 | Upstream rate limit (with `Retry-After` when known) | `rate_limit_error` / `upstream_rate_limited` | | |
| 152 | +| 502 | Upstream 5xx / unreachable / malformed upstream response | `api_error` / `upstream_error` | | |
| 153 | +| 504 | Upstream timeout | `api_error` / `upstream_timeout` | | |
| 154 | + | |
| 155 | +Provider payload shapes and key material never leak into errors. | |
| 156 | + | |
| 157 | +--- | |
| 158 | + | |
| 159 | +## GET /v1/models | |
| 160 | + | |
| 161 | +OpenAI list shape over the full enabled catalog (all providers, namespaced IDs), with | |
| 162 | +router metadata under the `x_zyquo` extension key: | |
| 163 | + | |
| 164 | +```json | |
| 165 | +{ | |
| 166 | + "object": "list", | |
| 167 | + "data": [{ | |
| 168 | + "id": "anthropic/claude-sonnet-4-5", | |
| 169 | + "object": "model", | |
| 170 | + "created": 1785461333, | |
| 171 | + "owned_by": "anthropic", | |
| 172 | + "x_zyquo": { | |
| 173 | + "display_name": "Claude Sonnet 4.5", | |
| 174 | + "context_window": 200000, "max_output_tokens": 64000, | |
| 175 | + "vision": true, "tools": true, "reasoning": true, | |
| 176 | + "input_per_mtok": 3.0, "output_per_mtok": 15.0 | |
| 177 | + } | |
| 178 | + }] | |
| 179 | +} | |
| 180 | +``` | |
| 181 | + | |
| 182 | +`GET /v1/models/{id}` returns a single entry (namespaced, bare, or alias `id`; | |
| 183 | +URL-encode if needed — IDs containing `/` also work raw). | |
| 184 | + | |
| 185 | +## GET /health | |
| 186 | + | |
| 187 | +Unauthenticated readiness probe: | |
| 188 | + | |
| 189 | +```json | |
| 190 | +{ "status": "ok", "version": "1.0.0", "uptime": 42, "models": 170 } | |
| 191 | +``` | |
| 192 | + | |
| 193 | +--- | |
| 194 | + | |
| 195 | +## Provider notes (translation table summary) | |
| 196 | + | |
| 197 | +| Provider | Upstream API | Notes | | |
| 198 | +|---|---|---| | |
| 199 | +| `openai` | native chat/completions | Reference; pass-through. | | |
| 200 | +| `anthropic` | Messages API (translated) | `max_tokens` synthesized when omitted; `temperature` clamped to ≤1; system/developer → top-level `system`; consecutive turns merged; tool results become `tool_result` blocks; `stop_reason` mapped; usage includes cache reads in `prompt_tokens`. `response_format` best-effort. Extra body `thinking` / `top_k` forwarded. | | |
| 201 | +| `gemini` | native generateContent (translated) | Roles renamed (`assistant`→`model`); `tool` messages → `functionResponse` (object-wrapped, name resolved from `tool_call_id`); `STOP`+functionCall → `finish_reason:"tool_calls"`; images must be data URIs; `n>1` unsupported; blocked prompts → 400 naming the reason. | | |
| 202 | +| `xai` | compat | Reasoning models reject `presence_penalty`/`frequency_penalty`/`stop` — stripped. `search_parameters` pass-through. | | |
| 203 | +| `mistral` | compat | `seed`→`random_seed`; `logit_bias`/`user`/`logprobs` stripped. Magistral thinking arrays flattened into `reasoning_content`. | | |
| 204 | +| `qwen` (DashScope intl) | compat | `enable_thinking`/`thinking_budget` pass-through; streaming-only models transparently aggregated for non-streaming clients. | | |
| 205 | +| `deepseek` | compat | `reasoning_content` passed through natively; cache-hit tokens → `cached_tokens`; assistant `reasoning_content` echoed back **only** in tool loops (stripped otherwise). | | |
| 206 | +| `kimi` (Moonshot) | compat | `temperature` clamped to [0,1]. | | |
| 207 | +| `perplexity` | compat | `citations`/`search_results` pass through verbatim; `<think>` extracted to `reasoning_content`; search params (`search_domain_filter`, `web_search_options`, …) pass-through. No function calling. | | |
| 208 | +| `together` | compat | `finish_reason:"eos"`→`stop`; `top_k`/`min_p`/`repetition_penalty` pass-through. | | |
| 209 | +| `deepinfra` | compat | `logit_bias` stripped; usage `estimated_cost` used for cost metering. | | |
| 210 | +| `cerebras` | compat | `max_completion_tokens` naming; base64-only images. | | |
| 211 | + | |
| 212 | +## Copy-paste snippets | |
| 213 | + | |
| 214 | +```bash | |
| 215 | +curl http://localhost:8787/v1/chat/completions \ | |
| 216 | + -H "Content-Type: application/json" \ | |
| 217 | + -H "Authorization: Bearer zyquo-sk-…" \ | |
| 218 | + -d '{"model":"deepseek/deepseek-chat","messages":[{"role":"user","content":"Hi"}],"stream":true}' | |
| 219 | +``` | |
| 220 | + | |
| 221 | +```javascript | |
| 222 | +import OpenAI from "openai"; | |
| 223 | +const client = new OpenAI({ baseURL: "http://localhost:8787/v1", apiKey: "zyquo-sk-…" }); | |
| 224 | +const stream = await client.chat.completions.create({ | |
| 225 | + model: "gemini/gemini-2.5-flash", | |
| 226 | + messages: [{ role: "user", content: "Hi" }], | |
| 227 | + stream: true, | |
| 228 | +}); | |
| 229 | +for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta?.content ?? ""); | |
| 230 | +``` | |
| 231 | + | |
| 232 | +```python | |
| 233 | +# LangChain | |
| 234 | +from langchain_openai import ChatOpenAI | |
| 235 | +llm = ChatOpenAI(base_url="http://localhost:8787/v1", api_key="zyquo-sk-…", | |
| 236 | + model="anthropic/claude-sonnet-4-5") | |
| 237 | +``` | |
modified
docs/PLAN.md
+35 −1
@@ -77,7 +77,41 @@ tests ported and green. New: `HTTPServer` on NIOAsyncChannel structured concurre | ||
| 77 | 77 | immediately; port-in-use typed error). 23 tests green; zero warnings; headers swept. |
| 78 | 78 | |
| 79 | 79 | |
| 80 | −## Phase 3 — Standardized API (the core) — pending | |
| 80 | +## Phase 3 — Standardized API (the core) | |
| 81 | + | |
| 82 | +- [x] 3.1 OpenAI wire layer in `Translate/OpenAINormalizer.swift`: parsed canonical request (raw JSON + typed fields, unknown params preserved per D5), response/chunk emission helpers | |
| 83 | +- [x] 3.2 `Translate/CompatAdjuster.swift`: per-provider param strip/rename/clamp table (seeded from `ParameterSupport` + research §3.3) for the 10 OpenAI-compatible upstreams; response/chunk quirk normalization (Together `eos`/`text`, Mistral ThinkChunk, Perplexity citations, model echo → namespaced ID) | |
| 84 | +- [x] 3.3 `Router/UpstreamCall.swift`: executes the upstream request (auth per provider), non-streaming + SSE; pre-first-byte retries via `RetryPolicy`; fallback chains; client-disconnect cancellation | |
| 85 | +- [x] 3.4 `Translate/AnthropicTranslator.swift`: request (system extraction, tools, tool_choice, max_tokens required, temp clamp), response (stop_reason/usage mapping, tool_use → tool_calls), SSE state machine → byte-exact OpenAI chunks (text/tool/thinking deltas) | |
| 86 | +- [x] 3.5 `Translate/GeminiTranslator.swift` (net-new, native API per D10): contents/parts, systemInstruction, generationConfig, tools/functionDeclarations, finishReason table, streamGenerateContent?alt=sse → OpenAI chunks | |
| 87 | +- [x] 3.6 `POST /v1/chat/completions` route: parse → resolve (404/ambiguous) → capability checks → upstream → spec-exact response/stream; `stream_options.include_usage`; reasoning_content normalization (D6); usage estimation flagged when upstream omits (D9); OpenAI-format error mapping (D7) | |
| 88 | +- [x] 3.7 Access control: local `zyquo-sk-…` keys enforced on chat route (per-key model allow-list), LAN bind requires ≥1 key | |
| 89 | +- [x] 3.8 Fixture unit tests: Anthropic events → chunks, Gemini → chunks, CompatAdjuster tables, SSE edge cases | |
| 90 | +- [x] 3.9 `docs/API.md` written as the exact public contract | |
| 91 | +- [x] 3.10 PHASE GATE: streaming + non-streaming verified with curl AND official OpenAI Python SDK against one OpenAI-compatible provider, Anthropic, and Gemini (real keys, through the router only) | |
| 92 | + | |
| 93 | +**Phase gate: PASSED (2026-07-30).** | |
| 94 | + | |
| 95 | +**Phase 3 summary:** `POST /v1/chat/completions` is live and spec-exact. New layers: | |
| 96 | +`ChatCompletionRequest` (typed parse + raw pass-through per D5), `CompatAdjuster` | |
| 97 | +(per-provider strip/rename/clamp + quirk normalization: Together `eos`, Mistral | |
| 98 | +thinking arrays, Perplexity `<think>` + citations, DeepSeek cache tokens, usage-chunk | |
| 99 | +swallowing), `AnthropicTranslator` (full request map incl. turn merging/tools/thinking | |
| 100 | +budget; SSE state machine → byte-exact chunks), `GeminiTranslator` (native | |
| 101 | +generateContent per D10 incl. functionResponse object-wrapping, STOP+functionCall | |
| 102 | +override, synthesized role chunk + router-added [DONE]), `UpstreamCall` (per-provider | |
| 103 | +endpoints/auth + ProviderError→OpenAI wire mapping D7), `ChatCompletionsRoute` | |
| 104 | +(capability gates, pre-first-byte retries D8, fallback chains, stream-aggregation for | |
| 105 | +streaming-only models, estimated-and-flagged usage D9, UsageMeter recording). | |
| 106 | +Local `zyquo-sk` keys enforced (401/403 paths verified live; /health stays open; LAN | |
| 107 | +requires ≥1 key). `--load-vault` seeds the encrypted vault from env. `docs/API.md` | |
| 108 | +written as the served contract. GATE: curl + OpenAI Python SDK 2.51 unmodified — | |
| 109 | +non-streaming, streaming (role/finish/usage/[DONE] discipline), and streamed tool | |
| 110 | +calls all PASS against xAI (compat), Anthropic (translated), Gemini (native). 31 unit/ | |
| 111 | +fixture tests green; zero warnings; headers swept. | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 81 | 115 | ## Phase 4 — Design system & UI spec — pending |
| 82 | 116 | ## Phase 5 — App icon — pending |
| 83 | 117 | ## Phase 6 — Features — pending |
| 84 | 118 | |