SPB Git

spb/zyquo-router Public MIT

One local endpoint, every AI provider — a private OpenAI-compatible LLM gateway for your Mac (170 models, 12 providers).

Swift 95.7% Python 2.3% Shell 1.2% Makefile 0.9%

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>
Simon-Pierre Boucher committed 11 days ago (Jul 31, 2026) parent ec0152e

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