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%
15.0 KB · 285 lines swift
Raw Blame History
1//2//  TranslatorTests.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Fixture tests for the translation layer: recorded Anthropic SSE events →9//  OpenAI chunks, recorded Gemini SSE → OpenAI chunks, CompatAdjuster10//  request/response tables, request builders. Fixtures follow the transcripts11//  in docs/ROUTER-RESEARCH.md §3.12//1314import XCTest15@testable import ZyquoRouter1617final class TranslatorTests: XCTestCase {18    private func chatRequest(_ dict: [String: Any]) throws -> ChatCompletionRequest {19        try ChatCompletionRequest(body: JSONSerialization.data(withJSONObject: dict))20    }2122    private func model(23        _ id: String,24        _ provider: ProviderID,25        reasoning: Bool = false,26        maxCompletionTokens: Bool = false27    ) -> 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    }3637    private func json(_ data: Data) -> [String: Any] {38        (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:]39    }4041    // MARK: - Anthropic request building4243    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))6364        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")6869        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")7778        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    }8485    // MARK: - Anthropic stream machine (fixture from research §3.1.4)8687    func testAnthropicStreamToOpenAIChunks() {88        var machine = AnthropicTranslator.StreamMachine(89            emitter: ChunkEmitter(model: "anthropic/claude-x"),90            includeUsage: true91        )92        func consume(_ event: String, _ data: String) -> [[String: Any]] {93            machine.consume(SSEEvent(event: event, data: data)).payloads.map(json)94        }9596        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": ""])99100        XCTAssertTrue(consume("ping", #"{"type":"ping"}"#).isEmpty)101        XCTAssertTrue(consume("content_block_start", #"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#).isEmpty)102103        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,")105106        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, "")111112        XCTAssertTrue(consume("content_block_delta", #"{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}}"#).isEmpty, "empty fragments skipped")113114        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"}"#)118119        _ = 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")122123        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    }131132    // MARK: - Gemini request building + stream machine133134    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))151152        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    }163164    func testGeminiStreamToOpenAIChunks() {165        var machine = GeminiTranslator.StreamMachine(166            emitter: ChunkEmitter(model: "gemini/gemini-x"),167            includeUsage: true168        )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")173174        // 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")182183        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    }190191    // MARK: - CompatAdjuster192193    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        ]201202        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)208209        let kimi = CompatAdjuster.adjustRequest(210            ["model": "kimi/k", "messages": [["role": "user", "content": "hi"]], "temperature": 1.8],211            model: model("k", .kimi), stream: false212        )213        XCTAssertEqual(kimi["temperature"] as? Double, 1.0, "Moonshot clamps to [0,1]")214215        let cerebras = CompatAdjuster.adjustRequest(216            ["model": "cerebras/c", "messages": [["role": "user", "content": "hi"]], "max_tokens": 50],217            model: model("c", .cerebras, maxCompletionTokens: true), stream: false218        )219        XCTAssertEqual(cerebras["max_completion_tokens"] as? Int, 50)220        XCTAssertNil(cerebras["max_tokens"], "never send both")221222        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: false226        )227        XCTAssertNil(xai["presence_penalty"], "Grok reasoning models 400 on penalties")228        XCTAssertNil(xai["stop"])229    }230231    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: .together237        )238        XCTAssertEqual(together["model"] as? String, "together/m")239        XCTAssertEqual((together["choices"] as! [[String: Any]])[0]["finish_reason"] as? String, "stop")240241        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: .mistral250        )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")254255        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: .perplexity260        )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    }265266    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: false270        )271        XCTAssertNil(swallowed)272    }273274    // MARK: - Request parsing275276    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}285