# PROVIDER-REUSE.md — Zyquo Router Study of the Zyquo Cloud repository (../zyquo-cloud) for provider-layer reuse. Source repo studied: `/Users/simon-pierreboucher/Desktop/zyquo-cloud` (Swift 5.9 SPM executable, macOS 13+, single dependency `swift-markdown` — see `Package.swift:12-39`). Catalog and research were compiled/verified by Cloud with **live keys on 2026-07-30** (`docs/PROVIDERS.md:8-13`, Phase 7 amendments at `docs/PROVIDERS.md:1638-1719`). Every claim below is traceable to a file/line in that repo. --- ## 1. How each provider's API is called in Zyquo Cloud ### 1.1 Architecture — two clients, one registry Cloud's entire provider layer is **four files** in `Sources/ZyquoCloud/Providers/` plus one shared networking service: | File | Role | |---|---| | `Sources/ZyquoCloud/Providers/ProviderProtocol.swift` | `ChatRequest`, `ChatEvent`, `protocol ProviderClient`, `ProviderError` | | `Sources/ZyquoCloud/Providers/OpenAICompatibleClient.swift` | One client for **11 of 12** providers (everything except Anthropic) + custom endpoints | | `Sources/ZyquoCloud/Providers/AnthropicClient.swift` | Native Anthropic Messages API (`/v1/messages`) | | `Sources/ZyquoCloud/Providers/ProviderRegistry.swift` | `wireFormat` → client resolution | | `Sources/ZyquoCloud/Services/StreamingService.swift` | `SSEParser`, `SSEEvent`, shared `URLSession`, retry/backoff, error mapping | **There is NO `GeminiClient.swift` in Zyquo Cloud.** Gemini is driven through its **OpenAI-compatible endpoint** `https://generativelanguage.googleapis.com/v1beta/openai` with Bearer auth (`Sources/ZyquoCloud/Models/ProviderID.swift:64`), through `OpenAICompatibleClient` like the other OpenAI-shaped providers. Cloud's research notes the compat endpoint's limitations and that a native client would be needed for rich thinking display (`docs/PROVIDERS.md:560`), but Cloud shipped compat-only. Zyquo Router's CLAUDE.md assumes a native `GeminiClient` exists — **it does not**; the Router must either keep the compat path (near-pass-through, easiest) or write the native translator itself per Phase 0.A research (see §5 caveats). The protocol (`ProviderProtocol.swift:31-42`): ```swift protocol ProviderClient { var providerID: ProviderID { get } func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream func complete(_ request: ChatRequest, apiKey: String) async throws -> Message func listModelIDs(apiKey: String) async throws -> [String] } ``` The provider-agnostic request/event types (`ProviderProtocol.swift:13-28`): ```swift struct ChatRequest { var model: AIModel var systemPrompt: String? var messages: [Message] var parameters: ChatParameters var stream: Bool = true } enum ChatEvent { case reasoningDelta(String) case textDelta(String) case citations([Citation]) case usage(TokenUsage) case finished(reason: String?) } ``` A default-protocol extension provides `testKey(_:fallbackModel:) async throws -> TimeInterval` (`ProviderProtocol.swift:49-68`): calls `listModelIDs` where available, else a 16-token `"Reply with exactly: OK"` completion (Perplexity has no `/models` — returns 404, `ProviderID.swift:77-79`, `docs/PROVIDERS.md:27`). `ProviderRegistry` (`ProviderRegistry.swift:13-34`) resolves clients purely from `ProviderID.wireFormat`: ```swift switch model.provider.wireFormat { case .anthropicMessages: return AnthropicClient() case .openAIChatCompletions: return OpenAICompatibleClient(provider: model.provider, baseURLOverride: model.customBaseURL) } ``` `WireFormat` has exactly two cases (`ProviderID.swift:88-93`): `.openAIChatCompletions` (11/12 providers) and `.anthropicMessages`. ### 1.2 Provider identity, base URLs, auth `ProviderID` is a 13-case enum (`Sources/ZyquoCloud/Models/ProviderID.swift:12-25`): `openai, anthropic, xai, mistral, gemini, qwen, deepseek, kimi, perplexity, together, deepinfra, cerebras, custom`. Exact `defaultBaseURL` constants (`ProviderID.swift:58-74`): | Provider | `defaultBaseURL` | Auth header | Wire format | `/models`? | |---|---|---|---|---| | OpenAI | `https://api.openai.com/v1` | `Authorization: Bearer ` | OpenAI (origin) | ✅ | | Anthropic | `https://api.anthropic.com/v1` | `x-api-key: ` + `anthropic-version: 2023-06-01` | Anthropic Messages | ✅ (`?limit=100`) | | xAI | `https://api.x.ai/v1` | Bearer | OpenAI-compat | ✅ | | Mistral | `https://api.mistral.ai/v1` | Bearer | OpenAI-compat | ✅ | | Google Gemini | `https://generativelanguage.googleapis.com/v1beta/openai` | Bearer (compat endpoint) | OpenAI-compat | ✅ (IDs prefixed `models/` — stripped) | | Qwen (DashScope intl) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | Bearer | OpenAI-compat | ✅ | | DeepSeek | `https://api.deepseek.com` | Bearer | OpenAI-compat | ✅ | | Kimi (Moonshot) | `https://api.moonshot.ai/v1` | Bearer | OpenAI-compat | ✅ | | Perplexity | `https://api.perplexity.ai` | Bearer | OpenAI-compat + search extras | ❌ (404) | | Together AI | `https://api.together.xyz/v1` | Bearer | OpenAI-compat | ✅ (bare array, not `{"data":[…]}`) | | DeepInfra | `https://api.deepinfra.com/v1/openai` | Bearer | OpenAI-compat | ✅ | | Cerebras | `https://api.cerebras.ai/v1` | Bearer | OpenAI-compat | ✅ | | custom | `nil` — from user endpoint config (`baseURLOverride`) | Bearer | OpenAI-compat | varies | Paths are appended with `base.appendingPathComponent(path)` so multi-segment bases (`…/compatible-mode/v1`, `…/v1beta/openai`) are preserved (`OpenAICompatibleClient.swift:229-241`). Chat endpoint path is `"chat/completions"`; Anthropic's is `"messages"`. Anthropic constants: `apiVersion = "2023-06-01"`, `defaultMaxTokens = 8192` (`AnthropicClient.swift:19-20`). ### 1.3 `OpenAICompatibleClient` — request/response wire types All wire types are **private nested Codable structs** (an adaptation point for the Router — see §5). **Request** (`OpenAICompatibleClient.swift:28-103`) — `WireRequest`: `model, messages: [WireMessage], stream, streamOptions (stream_options.include_usage), temperature, topP (top_p), maxTokens (max_tokens), maxCompletionTokens (max_completion_tokens), frequencyPenalty, presencePenalty, reasoningEffort (reasoning_effort), enableThinking (enable_thinking)`. `WireMessage { role: String, content: WireContent }`; `WireContent` encodes either a plain string or `[WirePart]` where `WirePart` is `.text(String)` or `.imageURL(String)` (encoded as `{"type":"image_url","image_url":{"url":"data:;base64,…"}}`). Images are always sent as base64 data URIs (`OpenAICompatibleClient.swift:302-320`). **Body construction** (`buildBody(_:)`, `OpenAICompatibleClient.swift:257-300`) is gated by the model's `ParameterSupport` (see §2): only supported params are encoded. Notable per-provider logic: - `wantsStreamOptions` (`OpenAICompatibleClient.swift:243-255`): `stream_options:{include_usage:true}` sent to **openai, xai, gemini, deepseek, kimi, together, cerebras, custom**; **omitted** for mistral (rejects unknown params), qwen, deepinfra, perplexity (usage arrives automatically). - Mistral `reasoning_effort` only accepts `"high"`/`"none"` → client maps `low → none`, else `high` (`OpenAICompatibleClient.swift:286-293`; `docs/PROVIDERS.md:1703`). - Qwen/DashScope `enable_thinking` is only legal on **streaming** requests (`OpenAICompatibleClient.swift:294-297`). - `usesMaxCompletionTokens` switches `max_tokens` → `max_completion_tokens` (OpenAI reasoning models, Cerebras, Kimi K-series) (`OpenAICompatibleClient.swift:277-283`). - System prompt injected as a leading `{"role":"system"}` message (`OpenAICompatibleClient.swift:259-261`). - Text-file attachments are inlined into the message text fenced with the file name (`OpenAICompatibleClient.swift:306-309`). **Response** (`OpenAICompatibleClient.swift:107-223`) — decoded with plain `JSONDecoder`, unknown fields ignored: - `WireChunk { choices: [WireChoice]?, usage: WireUsage?, citations: [String]?, searchResults: [WireSearchResult]? }` (`search_results` = Perplexity). - `WireChoice { delta: WireDelta?, message: WireDelta?, text: String?, finishReason: String? }` — the same struct decodes both streaming chunks (`delta`) and non-streaming responses (`message`); `text` is the Together completions-style fallback (`choices[].text` instead of `delta.content`, `OpenAICompatibleClient.swift:119-131`; `docs/PROVIDERS.md:1712-1713`). - `WireDelta { content, reasoningContent ("reasoning_content"), reasoning }` with a **custom `init(from:)`** (`OpenAICompatibleClient.swift:143-167`): `content` is normally a string, but Mistral reasoning models return an **array of `ContentChunk`s** (`{type:"thinking"|"text",…}` with nested `thinking:[{type:"text",text:…}]`) which are flattened into text + reasoning. - `WireUsage { promptTokens, completionTokens, completionTokensDetails.reasoningTokens }` → `TokenUsage` (`OpenAICompatibleClient.swift:187-210`). - `/models` decoding tries `{"data":[{id}]}` then a **bare array** (Together), and strips the `models/` prefix Gemini's compat endpoint adds (`listModelIDs`, `OpenAICompatibleClient.swift:407-421`). **Streaming loop** (`streamChat`, `OpenAICompatibleClient.swift:324-372`): wraps everything in an `AsyncThrowingStream` whose backing `Task` is cancelled `onTermination`; iterates `StreamingService.sseEvents(for:provider:)`; breaks on `data: [DONE]`; **silently skips undecodable chunks** (keep-alives, unknown shapes); yields `.reasoningDelta` (from `reasoning_content` or `reasoning`), `.textDelta` (from `delta.content` or `choices[].text`), `.citations` (once, Perplexity), `.usage`, then a final `.finished(reason:)` carrying the last `finish_reason` seen. **Non-streaming** (`complete`, `OpenAICompatibleClient.swift:374-405`): plain POST via `StreamingService.postJSON`, decodes the same `WireChunk` (reading `choice.message ?? choice.delta`), builds a `Message` with `reasoning`, `citations`, `usage`, and `estimatedCost` computed from `model.pricing.cost(inputTokens:outputTokens:)`. If `parameterSupport.requiresStreaming` is set the client transparently aggregates the stream instead (`completeViaStream`, `OpenAICompatibleClient.swift:425-454`). **Perplexity citations** (`OpenAICompatibleClient.swift:468-476`): top-level `citations` (array of URL strings) merged with `search_results` titles into numbered `Citation` values (`Sources/ZyquoCloud/Models/Message.swift:93-105`). ### 1.4 `AnthropicClient` — native Messages API `Sources/ZyquoCloud/Providers/AnthropicClient.swift`. Headers: `x-api-key` + `anthropic-version: 2023-06-01` (`AnthropicClient.swift:142-154`). **Never** routed through the OpenAI client (`wantsStreamOptions` explicitly notes "never routed here", `OpenAICompatibleClient.swift:252-253`). **Request** (`WireRequest`, `AnthropicClient.swift:24-48`): `model`, **mandatory `max_tokens`** (default 8192 when the user set none), `messages: [WireMessage]` (system messages filtered out), top-level `system: String?`, `stream`, `temperature`, `top_p`, and `thinking: {type: "enabled", budget_tokens: 8000} | {type: "disabled"}` when the model has `thinkingToggle` (`AnthropicClient.swift:156-181`). Content is block-structured: `WireBlock.text` and `WireBlock.image(mediaType:base64:)` encoding `{"type":"image","source":{"type":"base64","media_type":…,"data":…}}` (`AnthropicClient.swift:55-78`). Empty text is sent as `" "` (Anthropic rejects empty blocks, `AnthropicClient.swift:196`). Note per-model gating: Claude 4.7+/5 removed temperature/top_p — encoded in each model's `ParameterSupport` (`AnthropicClient.swift:171-174`, `ModelCatalogData.swift:249,257,266`). **Streaming** (`streamChat`, `AnthropicClient.swift:202-259`) decodes the **named SSE events** (the event name comes from `sse.event ?? event.type`): | Event | Handling | |---|---| | `message_start` | `usage.inputTokens` captured from `message.usage.input_tokens` | | `content_block_delta` | `delta.text` → `.textDelta`; `delta.thinking` → `.reasoningDelta` | | `message_delta` | `usage.output_tokens` (final count) + `delta.stop_reason` | | `error` | thrown as `ProviderError.serverError(status: 200, message:)` (mid-stream error events) | | `message_stop`, `ping`, `content_block_start/stop`, unknown | ignored | There is **no `[DONE]` sentinel** on Anthropic streams. `.usage` and `.finished(reason: stopReason)` are yielded at stream end. Non-streaming (`complete`, `AnthropicClient.swift:261-287`) joins `content[].type == "text"` and `"thinking"` blocks. `listModelIDs` GETs `messages`-sibling `models?limit=100` (`AnthropicClient.swift:289-299`). ### 1.5 SSE parsing — `StreamingService` / `SSEParser` `Sources/ZyquoCloud/Services/StreamingService.swift`. `SSEEvent { event: String?, data: String }` (`StreamingService.swift:12-17`). `SSEParser` (`StreamingService.swift:22-47`) is a line-fed incremental parser: accumulates `event:`/`data:` fields (multi-line `data:` joined with `\n`), emits a complete event at the blank-line separator, **ignores `:` comment lines** (DeepSeek sends `: keep-alive`) and `id:`/`retry:`/unknown fields. Unit-tested in `Tests/ZyquoCloudTests/SSEParserTests.swift`. `StreamingService.sseEvents(for:provider:)` (`StreamingService.swift:63-118`): - Shared `URLSession` with `timeoutIntervalForRequest = 120`, `timeoutIntervalForResource = 900` (long streams), `User-Agent: ZyquoCloud/1.0 (macOS)` (`StreamingService.swift:53-59`). - Uses `session.bytes(for:)`; non-2xx → drains the full error body and throws `ProviderError.from(status:body:provider:)`. - **Splits bytes on `\n` manually** (handling `\r\n`) because `AsyncBytes.lines` *skips empty lines*, which are the SSE event separators (`StreamingService.swift:80-95`) — a real pitfall the Router must keep. - Flushes a trailing partial line/event at EOF; checks `Task.isCancelled` per byte-boundary; cancellation surfaces as `ProviderError.cancelled`. **Retry policy**: `postJSON` (`StreamingService.swift:122-156`) retries **3 attempts** on 429/5xx with `Retry-After` honored when present, else exponential backoff `pow(2, attempt) * 2` seconds (4s, 8s). `getJSON` delegates to `postJSON` (same mapping). **Streaming requests are NOT retried** (single attempt). ### 1.6 Error types `ProviderError` (`ProviderProtocol.swift:73-146`), a `LocalizedError`: `invalidAPIKey(ProviderID)`, `rateLimited(ProviderID, retryAfter:)`, `serverError(ProviderID, status:, message:)`, `badRequest(ProviderID, message:)`, `networkError(underlying:)`, `invalidResponse(ProviderID, detail:)`, `missingAPIKey(ProviderID)`, `noModelAvailable(ProviderID)`, `cancelled`. Status mapping (`from(status:body:provider:)`): 401/403 → `invalidAPIKey`; 429 → `rateLimited` (retryAfter not parsed here); 400/404/422 → `badRequest`; else `serverError`. `extractMessage(from:)` (`ProviderProtocol.swift:127-145`) probes the provider error shapes `{"error":{"message":…}}`, `{"error":"…"}`, `{"message":…}`, `{"detail":…}`, and Gemini's top-level array form, falling back to the first 300 bytes of the body. The Router's error translation to OpenAI wire format (`{"error":{message,type,param,code}}` + proper status) maps directly onto these cases. --- ## 2. The complete model catalog **Swift types** (`Sources/ZyquoCloud/Models/AIModel.swift`): - `AIModel` (`AIModel.swift:15-43`): `id` (exact upstream model ID), `provider: ProviderID`, `displayName`, `contextWindow: Int`, `maxOutputTokens: Int?`, `capabilities: ModelCapabilities`, `pricing: ModelPricing?`, `parameterSupport: ParameterSupport`, `isLegacy`, `isRecommended`, `customBaseURL: URL?`. - `ModelCapabilities` (`AIModel.swift:47-60`): `vision, tools, reasoning, streaming (default true), jsonMode, citations`. - `ModelPricing` (`AIModel.swift:64-72`): **USD per 1M tokens**, `inputPerMTok` / `outputPerMTok`, with `func cost(inputTokens:outputTokens:) -> Double`. Cached/tiered pricing intentionally simplified to base rate; UI labels costs "estimates". **This is where the Router's cost meter gets its numbers.** - `ParameterSupport` (`AIModel.swift:77-93`): `temperature, topP, frequencyPenalty, presencePenalty, usesMaxCompletionTokens, reasoningEffort, thinkingToggle, requiresStreaming`; preset `.openAIDefault = ParameterSupport(frequencyPenalty: true, presencePenalty: true)`. - `TokenUsage` (`AIModel.swift:96-111`): `inputTokens, outputTokens, reasoningTokens?` + `+`. **Data lives in** `Sources/ZyquoCloud/Services/ModelCatalogData.swift` (1365 lines; `static let all = openai + anthropic + xai + mistral + gemini + qwen + deepseek + kimi + perplexity + together + deepinfra + cerebras`, `ModelCatalogData.swift:1361-1363`), generated from `docs/PROVIDERS.md` and `docs/research/*.md` on **2026-07-30**, live-verified (Phase 7 amendments, `docs/PROVIDERS.md:1638-1719`). Runtime access via `@MainActor final class ModelCatalog: ObservableObject` (`Sources/ZyquoCloud/Services/ModelCatalog.swift:17-72`) which layers dynamic `/models` refreshes (`applyLiveListing`, `unknownLiveIDs`) and user custom models on top, and provides `cheapestModel(for:)` (non-reasoning preferred, min `outputPerMTok`). Scope note (`ModelCatalogData.swift:13-16`): **chat-completions-capable chat models only** — embeddings, audio, realtime, image/video gen, moderation, and Responses-API-only models are excluded by design. **Total: 170 models across 12 providers.** The Router exposes ALL of them as `provider/model-id` (e.g. `anthropic/claude-opus-5`, `qwen/qwen3.7-max`, `together/moonshotai/Kimi-K3` — note Together/ DeepInfra IDs already contain `/`, so the Router's namespacing must split on the **first** `/` only). Legend: V=vision, T=tools, R=reasoning, J=jsonMode, C=citations; prices are USD per MTok in/out; ✩=isRecommended, †=isLegacy. ### OpenAI (27) — `ModelCatalogData.swift:33-239` Preset `openAIReasoning` (`ModelCatalogData.swift:26-29`): no temperature/top_p, `max_completion_tokens`, `reasoning_effort`. | Model ID | Ctx | Max out | Caps | $ in | $ out | Notes | |---|---|---|---|---|---|---| | `gpt-5.6-sol` ✩ | 1,050,000 | 128,000 | V T R J | 5.00 | 30.00 | openAIReasoning | | `gpt-5.6-terra` ✩ | 1,050,000 | 128,000 | V T R J | 2.50 | 15.00 | openAIReasoning | | `gpt-5.6-luna` | 1,050,000 | 128,000 | V T R J | 1.00 | 6.00 | openAIReasoning | | `chat-latest` | 128,000 | — | V T J | 5.00 | 30.00 | freq/pres pen; rejects `max_tokens` (uses `max_completion_tokens`) | | `gpt-5.5` | 400,000 | — | V T R J | 5.00 | 30.00 | openAIReasoning | | `gpt-5.4` | 400,000 | 128,000 | V T R J | 2.50 | 15.00 | openAIReasoning | | `gpt-5.4-mini` | 400,000 | — | V T R J | 0.75 | 4.50 | openAIReasoning | | `gpt-5.4-nano` | 400,000 | — | V T R J | 0.20 | 1.25 | openAIReasoning | | `gpt-5.3-chat-latest` | 128,000 | — | V T J | — | — | `max_completion_tokens` | | `gpt-5.2` | 400,000 | 128,000 | V T R J | 1.75 | 14.00 | openAIReasoning | | `gpt-5.2-chat-latest` | 128,000 | 16,000 | V T J | 1.75 | 14.00 | `max_completion_tokens` | | `gpt-5.1` | 400,000 | 128,000 | V T R J | 1.25 | 10.00 | openAIReasoning | | `gpt-5` | 400,000 | 128,000 | V T R J | 1.25 | 10.00 | openAIReasoning | | `gpt-5-mini` | 400,000 | 128,000 | V T R J | 0.25 | 2.00 | openAIReasoning | | `gpt-5-nano` | 400,000 | 128,000 | V T R J | 0.05 | 0.40 | openAIReasoning | | `o3` | 200,000 | 100,000 | V T R J | 2.00 | 8.00 | openAIReasoning | | `o4-mini` | 200,000 | 100,000 | V T R J | 1.10 | 4.40 | openAIReasoning | | `o3-mini` † | 200,000 | 100,000 | T R J | 1.10 | 4.40 | openAIReasoning | | `o1` † | 200,000 | 100,000 | V T R J | 15.00 | 60.00 | openAIReasoning | | `gpt-4.1` † | 1,047,576 | 32,768 | V T J | 2.00 | 8.00 | .openAIDefault | | `gpt-4.1-mini` † | 1,047,576 | 32,768 | V T J | 0.40 | 1.60 | .openAIDefault | | `gpt-4.1-nano` † | 1,047,576 | 32,768 | V T J | 0.10 | 0.40 | .openAIDefault | | `gpt-4o` † | 128,000 | 16,384 | V T J | 2.50 | 10.00 | .openAIDefault | | `gpt-4o-mini` † | 128,000 | 16,384 | V T J | 0.15 | 0.60 | .openAIDefault | | `gpt-4-turbo` † | 128,000 | 4,096 | V T J | 10.00 | 30.00 | .openAIDefault | | `gpt-4` † | 8,192 | 8,192 | T | 30.00 | 60.00 | .openAIDefault | | `gpt-3.5-turbo` † | 16,385 | 4,096 | T J | 0.50 | 1.50 | .openAIDefault | ### Anthropic (11) — `ModelCatalogData.swift:243-327` | Model ID | Ctx | Max out | Caps | $ in | $ out | Notes | |---|---|---|---|---|---|---| | `claude-opus-5` ✩ | 1,000,000 | 128,000 | V T R J | 5.00 | 25.00 | no temp/top_p; thinkingToggle | | `claude-sonnet-5` ✩ | 1,000,000 | 128,000 | V T R J | 3.00 | 15.00 | no temp/top_p; thinkingToggle | | `claude-fable-5` | 1,000,000 | 128,000 | V T R J | 10.00 | 50.00 | thinking always on — **no toggle**; no temp/top_p | | `claude-opus-4-8` | 1,000,000 | 128,000 | V T R J | 5.00 | 25.00 | no temp/top_p; thinkingToggle | | `claude-opus-4-7` | 1,000,000 | 128,000 | V T R J | 5.00 | 25.00 | no temp/top_p; thinkingToggle | | `claude-opus-4-6` | 1,000,000 | 128,000 | V T R J | 5.00 | 25.00 | temp/top_p OK; thinkingToggle | | `claude-sonnet-4-6` | 1,000,000 | 128,000 | V T R J | 3.00 | 15.00 | temp/top_p OK; thinkingToggle | | `claude-haiku-4-5-20251001` | 200,000 | 64,000 | V T R J | 1.00 | 5.00 | temp/top_p OK; thinkingToggle | | `claude-opus-4-5-20251101` † | 200,000 | 64,000 | V T R J | 5.00 | 25.00 | thinkingToggle | | `claude-sonnet-4-5-20250929` † | 1,000,000 | 64,000 | V T R J | 3.00 | 15.00 | thinkingToggle | | `claude-opus-4-1-20250805` † | 200,000 | 32,000 | V T R J | 15.00 | 75.00 | thinkingToggle | ### xAI (5) — `ModelCatalogData.swift:331-369` | Model ID | Ctx | Max out | Caps | $ in | $ out | Notes | |---|---|---|---|---|---|---| | `grok-4.5` ✩ | 500,000 | — | V T R J | 2.00 | 6.00 | reasoning_effort | | `grok-4.3` | 1,000,000 | — | V T R J | 1.25 | 2.50 | reasoning_effort | | `grok-4.20` | 1,000,000 | — | V T R J | 1.25 | 2.50 | rejects reasoning_effort | | `grok-4.20-non-reasoning` | 1,000,000 | — | V T J | 1.25 | 2.50 | | | `grok-code-fast-1` ✩ | 256,000 | — | V T R J | 1.00 | 2.00 | rejects reasoning_effort | Note: `grok-4.20`, `grok-4.20-non-reasoning`, `grok-code-fast-1` resolve on chat completions but do **not** appear in xAI `/models` (`docs/PROVIDERS.md:1706`). ### Mistral (10) — `ModelCatalogData.swift:373-451` | Model ID | Ctx | Caps | $ in | $ out | Notes | |---|---|---|---|---|---| | `mistral-medium-latest` ✩ (Medium 3.5) | 262,144 | V T R J | 1.50 | 7.50 | freq/pres + reasoning_effort | | `mistral-large-latest` ✩ (Large 3) | 262,144 | V T J | 0.50 | 1.50 | .openAIDefault | | `mistral-small-latest` ✩ (Small 4) | 262,144 | V T R J | 0.15 | 0.60 | freq/pres + reasoning_effort | | `codestral-latest` | 256,000 | T J | 0.30 | 0.90 | .openAIDefault | | `ministral-14b-latest` | 262,144 | V T J | 0.20 | 0.20 | .openAIDefault | | `ministral-8b-latest` | 262,144 | V T J | 0.15 | 0.15 | .openAIDefault | | `ministral-3b-latest` | 131,072 | V T J | 0.10 | 0.10 | .openAIDefault | | `magistral-medium-latest` † | 131,072 | T R J | 2.00 | 5.00 | ThinkChunk content arrays | | `devstral-latest` † (Devstral 2) | 262,144 | T J | 0.40 | 2.00 | | | `open-mistral-nemo` † | 131,072 | T J | 0.15 | 0.15 | | ### Google Gemini (14) — `ModelCatalogData.swift:455-559` (all max out 65,536 except Gemma 32,768) | Model ID | Ctx | Caps | $ in | $ out | Notes | |---|---|---|---|---|---| | `gemini-3.6-flash` ✩ | 1,048,576 | V T R J | 1.50 | 7.50 | reasoning_effort | | `gemini-3.5-flash` | 1,048,576 | V T R J | 1.50 | 9.00 | reasoning_effort | | `gemini-3.5-flash-lite` ✩ | 1,048,576 | V T R J | 0.30 | 2.50 | reasoning_effort | | `gemini-3.1-pro-preview` ✩ | 1,048,576 | V T R J | 2.00 | 12.00 | reasoning_effort | | `gemini-3.1-flash-lite` | 1,048,576 | V T R J | 0.25 | 1.50 | reasoning_effort | | `gemini-2.5-pro` | 1,048,576 | V T R J | 1.25 | 10.00 | reasoning_effort | | `gemini-2.5-flash` | 1,048,576 | V T R J | 0.30 | 2.50 | reasoning_effort | | `gemini-2.5-flash-lite` | 1,048,576 | V T R J | 0.10 | 0.40 | reasoning_effort | | `gemini-pro-latest` | 1,048,576 | V T R J | — | — | rolling alias, pricing varies | | `gemini-flash-latest` | 1,048,576 | V T R J | — | — | rolling alias | | `gemini-flash-lite-latest` | 1,048,576 | V T R J | — | — | rolling alias | | `gemini-3-flash-preview` | 1,048,576 | V T R J | 0.50 | 3.00 | reasoning_effort | | `gemma-4-26b-a4b-it` | 262,144 | J | — | — | max out 32,768 | | `gemma-4-31b-it` | 262,144 | J | — | — | max out 32,768 | ### Alibaba Qwen / DashScope (32) — `ModelCatalogData.swift:563-799` (pricing mostly unrecorded) | Model ID | Ctx | Max out | Caps | $ in | $ out | Notes | |---|---|---|---|---|---|---| | `qwen3.7-max` ✩ | 1,000,000 | — | T R J | 2.50 | 7.50 | enable_thinking | | `qwen3.7-plus` ✩ | 1,000,000 | 65,536 | V T R J | 0.32 | 1.28 | enable_thinking | | `qwen3.7-flash` ✩ | 1,000,000 | 65,536 | V T R J | 0.03 | 0.13 | enable_thinking | | `qwen3.6-plus` | 1,000,000 | — | V T R J | — | — | enable_thinking | | `qwen3.6-flash` | 1,000,000 | — | V T R J | — | — | enable_thinking | | `qwen3.5-plus` | 1,000,000 | — | V T R J | — | — | enable_thinking | | `qwen3.5-flash` | 1,000,000 | — | V T R J | — | — | enable_thinking | | `qwen-max` | 128,000 | — | T R J | — | — | enable_thinking | | `qwen-plus` | 1,000,000 | — | T R J | — | — | enable_thinking | | `qwen-turbo` † | 1,000,000 | — | T R J | — | — | enable_thinking | | `qwen-flash` | 1,000,000 | — | T R J | — | — | enable_thinking | | `qwen3-coder-plus` | 1,000,000 | — | T J | — | — | | | `qwen3-coder-flash` | 1,000,000 | — | T J | — | — | | | `qwen3-coder-next` | 262,144 | — | T J | — | — | | | `qwen3-coder-480b-a35b-instruct` | 262,144 | — | T J | — | — | | | `qwen3-vl-plus` | 1,000,000 | 65,536 | V T R J | — | — | enable_thinking | | `qwen3-vl-flash` | 1,000,000 | 65,536 | V T R J | — | — | enable_thinking | | `qwen3-vl-235b-a22b-instruct` | 131,072 | — | V T J | — | — | | | `qwen3-vl-235b-a22b-thinking` | 131,072 | — | V T R J | — | — | | | `qvq-max` | 131,072 | — | V R J | — | — | **requiresStreaming** | | `qwq-plus` | 131,072 | — | T R J | — | — | **requiresStreaming** | | `qwen3.5-397b-a17b` | 262,144 | — | T R J | — | — | enable_thinking | | `qwen3.5-122b-a10b` | 262,144 | — | T R J | — | — | enable_thinking | | `qwen3.5-35b-a3b` | 262,144 | — | T R J | — | — | enable_thinking | | `qwen3-235b-a22b-instruct-2507` | 262,144 | — | T J | — | — | | | `qwen3-235b-a22b-thinking-2507` | 262,144 | — | T R J | — | — | | | `qwen3-next-80b-a3b-instruct` | 262,144 | — | T J | — | — | | | `qwen3-next-80b-a3b-thinking` | 262,144 | — | T R J | — | — | | | `deepseek-v4-pro` | 1,000,000 | — | T R J | — | — | 3rd-party hosted; enable_thinking | | `deepseek-v4-flash` | 1,000,000 | — | T R J | — | — | 3rd-party hosted; enable_thinking | | `glm-5.2` | 198,000 | — | T R J | — | — | 3rd-party hosted; enable_thinking | | `kimi-k2.7-code` | 262,144 | — | T R J | — | — | 3rd-party hosted; enable_thinking | ### DeepSeek (2) — `ModelCatalogData.swift:803-820` | Model ID | Ctx | Max out | Caps | $ in | $ out | Notes | |---|---|---|---|---|---|---| | `deepseek-v4-flash` ✩ | 1,000,000 | 384,000 | T R J | 0.14 | 0.28 | reasoning_effort + thinkingToggle | | `deepseek-v4-pro` ✩ | 1,000,000 | 384,000 | T R J | 0.435 | 0.87 | reasoning_effort + thinkingToggle | (`deepseek-chat`/`deepseek-reasoner` were retired 2026-07-24, `docs/PROVIDERS.md:42-44`.) ### Kimi / Moonshot (12) — `ModelCatalogData.swift:824-920` | Model ID | Ctx | Max out | Caps | $ in | $ out | Notes | |---|---|---|---|---|---|---| | `kimi-k3` ✩ | 1,048,576 | 131,072 | V T R J | 3.00 | 15.00 | no temp/top_p; max_completion_tokens; reasoning_effort (thinking always on) | | `kimi-k2.7-code` ✩ | 262,144 | — | V T R J | 0.95 | 4.00 | no temp/top_p; max_completion_tokens | | `kimi-k2.7-code-highspeed` | 262,144 | — | V T R J | 1.90 | 8.00 | no temp/top_p; max_completion_tokens | | `kimi-k2.6` | 262,144 | — | V T R J | 0.95 | 4.00 | + thinkingToggle | | `kimi-k2.5` | 262,144 | — | V T R J | 0.60 | 3.00 | + thinkingToggle | | `moonshot-v1-8k` † | 8,192 | — | T J | 0.20 | 2.00 | temp capped 1.0 upstream | | `moonshot-v1-32k` † | 32,768 | — | T J | 1.00 | 3.00 | | | `moonshot-v1-128k` † | 131,072 | — | T J | 2.00 | 5.00 | | | `moonshot-v1-auto` † | 131,072 | — | T J | — | — | | | `moonshot-v1-8k-vision-preview` † | 8,192 | — | V T J | 0.20 | 2.00 | | | `moonshot-v1-32k-vision-preview` † | 32,768 | — | V T J | 1.00 | 3.00 | | | `moonshot-v1-128k-vision-preview` † | 131,072 | — | V T J | 2.00 | 5.00 | | ### Perplexity (4) — `ModelCatalogData.swift:924-955` (all `citations: true`; no `/models` endpoint) | Model ID | Ctx | Max out | Caps | $ in | $ out | Notes | |---|---|---|---|---|---|---| | `sonar` ✩ | 128,000 | 128,000 | J C | 1.00 | 1.00 | | | `sonar-pro` ✩ | 200,000 | 8,000 | J C | 3.00 | 15.00 | | | `sonar-reasoning-pro` | 128,000 | — | R J C | 2.00 | 8.00 | reasoning as inline `` blocks | | `sonar-deep-research` | 128,000 | — | R J C | 2.00 | 8.00 | multi-minute agentic runs — skipped in Cloud's automated sweep | ### Together AI (16) — `ModelCatalogData.swift:959-1077` | Model ID | Ctx | Caps | $ in | $ out | Notes | |---|---|---|---|---|---| | `moonshotai/Kimi-K3` ✩ | 1,000,000 | T R J | 3.00 | 15.00 | | | `moonshotai/Kimi-K2.7-Code` | 262,144 | T R J | 0.95 | 4.00 | | | `moonshotai/Kimi-K2.6` | 262,144 | T R J | 1.20 | 4.50 | | | `deepseek-ai/DeepSeek-V4-Pro` ✩ | 512,000 | T R J | 1.74 | 3.48 | | | `zai-org/GLM-5.2` | 512,000 | T R J | 1.40 | 4.40 | | | `Qwen/Qwen3.7-Max` | 1,000,000 | T R J | 1.25 | 3.75 | **requiresStreaming** | | `Qwen/Qwen3.7-Plus` | 1,000,000 | T J | 0.32 | 1.28 | **requiresStreaming** | | `Qwen/Qwen3.6-Plus` | 1,000,000 | T J | 0.50 | 3.00 | **requiresStreaming** | | `Qwen/Qwen3.5-9B` | 262,144 | T J | 0.17 | 0.25 | **requiresStreaming**; streams completions-style (`choices[].text`) | | `meta-llama/Llama-3.3-70B-Instruct-Turbo` | 131,072 | T J | 1.04 | 1.04 | | | `openai/gpt-oss-120b` ✩ | 131,072 | T R J | 0.15 | 0.60 | reasoning_effort | | `openai/gpt-oss-20b` | 131,072 | T R J | 0.05 | 0.20 | reasoning_effort | | `nvidia/nemotron-3-ultra-550b-a55b` | 512,288 | T R J | 0.60 | 3.60 | | | `MiniMaxAI/MiniMax-M3` | 524,288 | T R J | 0.30 | 1.20 | | | `google/gemma-4-31B-it` | 262,144 | T J | 0.39 | 0.97 | **requiresStreaming**; vision disabled 2026-07-30 (empty answers on image input) | | `thinkingmachines/Inkling` | 524,288 | T R J | 1.00 | 4.05 | | ### DeepInfra (34) — `ModelCatalogData.swift:1081-1328` | Model ID | Ctx | Caps | $ in | $ out | Notes | |---|---|---|---|---|---| | `anthropic/claude-fable-5` | 1,000,000 | V T R J | 10.00 | 50.00 | proxied frontier | | `anthropic/claude-opus-5` | 1,000,000 | V T R J | 5.00 | 25.00 | proxied | | `anthropic/claude-sonnet-5` | 1,000,000 | V T R J | 2.00 | 10.00 | proxied | | `anthropic/claude-opus-4-8` | 1,000,000 | V T R J | 5.00 | 25.00 | proxied | | `anthropic/claude-haiku-4-5` | 200,000 | V T R J | 1.00 | 5.00 | proxied | | `google/gemini-3.1-pro` | 1,000,000 | V T R J | 2.00 | 12.00 | proxied | | `google/gemini-3.5-flash` | 1,000,000 | V T R J | 1.50 | 9.00 | proxied | | `google/gemini-3.1-flash-lite` | 1,000,000 | V T J | 0.25 | 1.50 | proxied | | `google/gemini-2.5-pro` | 1,000,000 | V T R J | 1.25 | 10.00 | proxied | | `google/gemini-2.5-flash` | 1,000,000 | V T R J | 0.30 | 2.50 | proxied | | `deepseek-ai/DeepSeek-V4-Pro` ✩ | 1,048,576 | T R J | 1.30 | 2.60 | | | `deepseek-ai/DeepSeek-V4-Flash` ✩ | 1,048,576 | T J | 0.09 | 0.18 | | | `deepseek-ai/DeepSeek-V3.1` | 163,840 | T R J | 0.25 | 0.95 | | | `deepseek-ai/DeepSeek-R1-0528` | 163,840 | R | 0.50 | 2.15 | reasoning only | | `moonshotai/Kimi-K2.7-Code` | 262,144 | T R J | 0.74 | 3.50 | | | `moonshotai/Kimi-K2.6` | 262,144 | T R J | 0.75 | 3.50 | | | `moonshotai/Kimi-K2.5` | 262,144 | T J | 0.45 | 2.25 | **requiresStreaming** | | `zai-org/GLM-5.2` ✩ | 1,048,576 | T R J | 0.75 | 2.40 | | | `zai-org/GLM-4.7` | 202,752 | T R J | 0.40 | 1.75 | | | `Qwen/Qwen3.7-Max` | 256,000 | T R J | 2.50 | 7.50 | | | `Qwen/Qwen3.5-397B-A17B` | 262,144 | T R J | 0.45 | 3.00 | | | `Qwen/Qwen3-235B-A22B-Instruct-2507` | 262,144 | T J | 0.09 | 0.55 | | | `Qwen/Qwen3-235B-A22B-Thinking-2507` | 262,144 | T R J | 0.23 | 2.30 | | | `Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo` | 262,144 | T J | 0.30 | 1.00 | | | `Qwen/Qwen3-VL-235B-A22B-Instruct` | 262,144 | V T J | 0.20 | 0.88 | | | `meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8` | 1,048,576 | V T J | 0.20 | 0.80 | | | `meta-llama/Llama-4-Scout-17B-16E-Instruct` | 327,680 | V T J | 0.10 | 0.30 | | | `meta-llama/Llama-3.3-70B-Instruct-Turbo` | 131,072 | T J | 0.10 | 0.32 | | | `meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo` | 131,072 | T J | 0.02 | 0.04 | | | `openai/gpt-oss-120b` ✩ | 131,072 | T R J | 0.037 | 0.17 | reasoning_effort | | `openai/gpt-oss-20b` | 131,072 | T R J | 0.03 | 0.14 | reasoning_effort | | `MiniMaxAI/MiniMax-M3` | 524,288 | T R J | 0.30 | 1.20 | | | `nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B` | 262,144 | T R J | 0.50 | 2.20 | | | `mistralai/Mistral-Small-3.2-24B-Instruct-2506` | 128,000 | V T J | 0.075 | 0.20 | | (`google/gemma-4-31B-it` was **removed** from DeepInfra 2026-07-30 — endpoint hangs 60s+ with zero bytes, `ModelCatalogData.swift:1326-1327`, `docs/PROVIDERS.md:1714-1715`.) ### Cerebras (3) — `ModelCatalogData.swift:1332-1357` (all max out 40,000; `max_completion_tokens` required) | Model ID | Ctx | Caps | $ in | $ out | Notes | |---|---|---|---|---|---| | `gpt-oss-120b` ✩ | 131,072 | T R J | 0.35 | 0.75 | reasoning_effort | | `gemma-4-31b` | 131,072 | V T R J | 0.99 | 1.49 | reasoning_effort | | `zai-glm-4.7` † | 131,072 | T R J | 2.25 | 2.75 | discontinued 2026-08-17 | --- ## 3. The secure key vault — `SecureKeyStore` `Sources/ZyquoCloud/Services/SecureKeyStore.swift` (174 lines). **Confirmed: deliberately NOT the macOS Keychain** — the header says so verbatim (`SecureKeyStore.swift:8`). The only `Security.framework` usage in the file is `SecRandomCopyBytes(kSecRandomDefault, …)` for salt generation (`SecureKeyStore.swift:161-166`) — no `SecItem*` / keychain item APIs anywhere. A repo grep confirms no other Keychain usage in Zyquo Cloud. ### File location & format - Vault file: `~/Library/Application Support/ZyquoCloud/vault.zq` — built as `PersistenceService.shared.rootDirectory.appendingPathComponent("vault.zq")` (`SecureKeyStore.swift:56-57`; root dir from `Sources/ZyquoCloud/Services/PersistenceService.swift:24-27`). **Router equivalent:** `~/Library/Application Support/ZyquoRouter/vault.zq` (same format). - Binary layout (`SecureKeyStore.swift:11-13`): **`[salt 32B][AES-GCM nonce 12B][ciphertext+tag 16B]`** — everything after the salt is the CryptoKit `AES.GCM.SealedBox.combined` representation (nonce ‖ ciphertext ‖ tag), opened with `AES.GCM.SealedBox(combined:)` + `AES.GCM.open(_:using:)` (`SecureKeyStore.swift:71-72`) and produced by `AES.GCM.seal(_:using:).combined` (`SecureKeyStore.swift:84-85`). Nonce is generated by CryptoKit per seal (fresh every save). - Plaintext: a JSON dictionary `{"openai": "sk-…", "anthropic": "sk-ant-…", …}` — keys are **`ProviderID.rawValue` strings** (`SecureKeyStore.swift:13`, `key(for:)`/`setKey(_:for:)` at `SecureKeyStore.swift:94-108`). - Written atomically with `.completeFileProtection` (`SecureKeyStore.swift:91`); the directory is created on demand. ### Master key derivation (`SecureKeyStore.swift:118-159`) ```swift HKDF.deriveKey( inputKeyMaterial: SymmetricKey(data: machineEntropy() ‖ pepper), salt: <32-byte vault salt>, info: Data("ZyquoCloud.vault.v1".utf8), outputByteCount: 32 // AES-256 key ) ``` - `machineEntropy` = **IOPlatformUUID** (read from IOKit's `IOPlatformExpertDevice` registry entry via `IORegistryEntryCreateCFProperty(…, kIOPlatformUUIDKey, …)`, `SecureKeyStore.swift:138-148`) ‖ `NSHomeDirectory()` — binds the vault to this machine **and** account. Injectable via init for tests (`SecureKeyStore.swift:52-59`, `Tests/ZyquoCloudTests/SecureKeyStoreTests.swift`). - **Pepper**: 30 compiled-in bytes, stored XOR `0x5A` so the value never appears verbatim in the binary, reassembled at runtime (`pepper()`, `SecureKeyStore.swift:152-159`). - Salt: 32 random bytes (`SecRandomCopyBytes`) generated on first save; **re-saves reuse the existing salt** (`existingSalt()`, `SecureKeyStore.swift:169-173`) so the derived key stays stable. - Constants: `saltLength = 32`, `keyLength = 32` (`SecureKeyStore.swift:45-46`). ### Public API surface (`SecureKeyStore.swift:61-114`) ```swift init(vaultURL: URL? = nil, machineEntropy: (() throws -> Data)? = nil) func loadKeys() throws -> [String: String] // empty dict if no vault file func saveKeys(_ keys: [String: String]) throws // atomic full-dictionary rewrite func key(for provider: ProviderID) throws -> String? func setKey(_ apiKey: String, for provider: ProviderID) throws func deleteKey(for provider: ProviderID) throws static func redacted(_ apiKey: String) -> String // "••••abcd" (last 4 only) ``` Errors: `VaultError.corrupted` ("The key vault is damaged or belongs to another machine." — also thrown when GCM auth fails, i.e. wrong machine) and `VaultError.machineIdentityUnavailable` (`SecureKeyStore.swift:31-43`). The UI-facing wrapper is `@MainActor final class KeyVaultStore: ObservableObject` (`Sources/ZyquoCloud/ViewModels/KeyVaultStore.swift:16-93`): per-provider `KeyStatus` (`unset/saved/testing/verified(latency:)/failed(message:)`), redacted display strings, and `testKey(for:catalog:)` using `ProviderRegistry` + `ModelCatalog.cheapestModel(for:)`. Keys are decrypted on demand and never retained beyond the call (`KeyVaultStore.swift:55-61`). **Router decision for identical UX**: reuse `SecureKeyStore` byte-for-byte in design. Two constants differ per app: the vault path root (`ZyquoRouter` folder) and the HKDF `info` string (`"ZyquoCloud.vault.v1"`). Keeping `info` identical AND pointing at Cloud's vault file would let the apps literally share one vault; the Zyquo Router CLAUDE.md asks for the **same design and format** (users manage keys identically), not necessarily the same file — decide in Phase 2/3. If the Router keeps its own vault, use `info: "ZyquoRouter.vault.v1"` and its own `vault.zq` under `~/Library/Application Support/ZyquoRouter/`. Either way: no Keychain, ever. --- ## 4. Provider streaming quirks the Router's translation layer must normalize All recorded in code and in `docs/PROVIDERS.md` (cross-provider notes at lines 32-46; Phase 7 amendments at lines 1638-1719): 1. **Reasoning content field zoo** — normalize to one field (Router decision: `reasoning_content` on message/delta, per OpenAI-compat majority): - `delta.reasoning_content`: DeepSeek (on by default for v4-flash), Qwen, Kimi K-series, some DeepInfra models (`OpenAICompatibleClient.swift:133-146`). - `delta.reasoning` (no `_content`): some hosts (decoded as fallback, `OpenAICompatibleClient.swift:344`); Together exposes `message.reasoning` for hosted reasoning models (`docs/PROVIDERS.md:28`). - Anthropic: `thinking_delta` inside `content_block_delta` events; non-streaming `thinking` content blocks (`AnthropicClient.swift:226-232,270-271`). - Mistral: `content` arrives as an **array of ThinkChunk/TextChunk objects** (`{type:"thinking",thinking:[{type:"text",text:…}]}`) — must be flattened (`OpenAICompatibleClient.swift:143-184`; `docs/PROVIDERS.md:1704`). - Perplexity `sonar-reasoning-pro`: reasoning arrives as inline **`` text** in the content itself (`docs/PROVIDERS.md:27,37`) — Cloud does not split it; the Router must decide (pass through or extract). - OpenAI: **no reasoning text at all** on chat completions — only `usage.completion_tokens_details.reasoning_tokens` (`docs/PROVIDERS.md:178`). 2. **Usage-in-stream behavior** (`OpenAICompatibleClient.swift:243-255`; `docs/PROVIDERS.md:38-39`): `stream_options:{include_usage:true}` needed for OpenAI, xAI, Gemini-compat, DeepSeek, Kimi, Together, Cerebras (final chunk has empty `choices` + `usage`); Qwen, DeepInfra, Perplexity include usage automatically (and Mistral rejects the param). Anthropic splits usage: `input_tokens` in `message_start`, final `output_tokens` in `message_delta`. 3. **Keep-alive comments**: DeepSeek sends `: keep-alive` SSE comment lines — `SSEParser` drops any `:`-prefixed line (`StreamingService.swift:36`). Undecodable data chunks are skipped, not fatal (`OpenAICompatibleClient.swift:339-342`). 4. **Finish-reason quirks**: Together emits nonstandard `finish_reason: "eos"` (`docs/PROVIDERS.md:35`) — the Router must map it to `stop`. Anthropic uses `stop_reason` (`end_turn`, `max_tokens`, `stop_sequence`, `tool_use`) delivered in `message_delta`, which the Router maps to OpenAI `finish_reason` (`stop`/`length`/`stop`/`tool_calls`). 5. **Together completions-style streams**: some models (`Qwen/Qwen3.5-9B`, `google/gemma-4-31B-it`) stream tokens in `choices[].text` instead of `delta.content` (`OpenAICompatibleClient.swift:122-124`; `docs/PROVIDERS.md:1712-1713`). 6. **Models that reject non-streaming calls** (`ParameterSupport.requiresStreaming`, `AIModel.swift:88-90`): Qwen `qvq-max`/`qwq-plus`; Together `Qwen3.7-Max/-Plus`, `Qwen3.6-Plus`, `Qwen3.5-9B`, `google/gemma-4-31B-it`; DeepInfra `moonshotai/Kimi-K2.5`. For a non-streaming router request, aggregate the stream (Cloud's `completeViaStream` pattern). 7. **Perplexity citations**: top-level `citations: [String]` (URLs) + `search_results` (titles) on chunks and responses — surfaced once per stream (`OpenAICompatibleClient.swift:356-359,468-476`). No `/models` endpoint (404): the Router must serve Perplexity models from the built-in catalog only. 8. **Gemini compat endpoint**: `/models` IDs prefixed `models/` (stripped, `OpenAICompatibleClient.swift:411,420`); unknown fields like `extra_content`/`thought_signature` in deltas must be ignored; native-only features (thought summaries, `thoughtsTokenCount`) are not exposed on the compat endpoint (`docs/PROVIDERS.md:560`). 9. **Unknown-field tolerance generally**: OpenAI adds `obfuscation` fields; decoders must ignore unknown JSON keys everywhere (`docs/PROVIDERS.md:34-35`). 10. **Anthropic mid-stream errors**: an `error` SSE event can arrive with HTTP 200 (`AnthropicClient.swift:240-243`) — the Router must convert it into an OpenAI-format error (or a terminal chunk if the stream already started). 11. **Parameter strip tables** (per-model `ParameterSupport`): OpenAI reasoning models and Claude 4.7+/5 reject `temperature`/`top_p`; Cerebras + OpenAI reasoning + Kimi K-series require `max_completion_tokens`; Mistral `reasoning_effort` accepts only `high`/`none`; Qwen `enable_thinking` only when streaming; xAI `grok-4.20`/`grok-code-fast-1` reject `reasoning_effort`. This is exactly the Router's `CompatAdjuster` data. 12. **Vision minimums**: xAI and Qwen reject images smaller than 8px (`docs/PROVIDERS.md:1707`). 13. **Anthropic streams have no `data: [DONE]`** — termination is the `message_stop` event; the Router's SSEWriter must synthesize `[DONE]` itself for downstream OpenAI clients. --- ## 5. Reuse plan — files to port from Cloud → Router ### 5.1 Port nearly verbatim (change header `Zyquo Cloud` → `Zyquo Router`; keep type names) | Source (zyquo-cloud) | Destination (zyquo-router) | Changes | |---|---|---| | `Sources/ZyquoCloud/Models/ProviderID.swift` | `Sources/ZyquoRouter/Models/ProviderID.swift` | header only (keep all base URLs, `WireFormat`, `supportsModelListing`) | | `Sources/ZyquoCloud/Models/AIModel.swift` | `Sources/ZyquoRouter/Models/AIModel.swift` | header only (`AIModel`, `ModelCapabilities`, `ModelPricing`, `ParameterSupport`, `TokenUsage`) | | `Sources/ZyquoCloud/Services/ModelCatalogData.swift` | `Sources/ZyquoRouter/Services/ModelCatalogData.swift` | header only — **all 170 models, verbatim**; keep in sync with Cloud going forward | | `Sources/ZyquoCloud/Services/ModelCatalog.swift` | `Sources/ZyquoRouter/Services/ModelCatalog.swift` | header; keep `@MainActor ObservableObject` for the UI, but the server-side `RequestRouter` needs a `Sendable` snapshot of `all` (don't hop to the main actor per request) | | `Sources/ZyquoCloud/Services/StreamingService.swift` | `Sources/ZyquoRouter/Services/StreamingService.swift` | header; change User-Agent to `ZyquoRouter/1.0 (macOS)`; consider moving retry policy into `Router/RetryPolicy.swift` (see 5.3) | | `Sources/ZyquoCloud/Services/SecureKeyStore.swift` | `Sources/ZyquoRouter/Services/SecureKeyStore.swift` | header; vault root becomes `~/Library/Application Support/ZyquoRouter/`; decide HKDF `info` (`"ZyquoRouter.vault.v1"` for a separate vault, or keep Cloud's string + path to share one vault); keep pepper mechanism (may reuse the same obfuscated bytes) | | `Sources/ZyquoCloud/Services/PersistenceService.swift` | `Sources/ZyquoRouter/Services/PersistenceService.swift` | header; root folder `ZyquoRouter`; drop `Conversation` specifics, keep generic `load/save` | | `Sources/ZyquoCloud/Providers/ProviderProtocol.swift` | `Sources/ZyquoRouter/Providers/ProviderProtocol.swift` | header; **extend** (see 5.2) | | `Sources/ZyquoCloud/Providers/ProviderRegistry.swift` | `Sources/ZyquoRouter/Providers/ProviderRegistry.swift` | header only | | `Sources/ZyquoCloud/Providers/OpenAICompatibleClient.swift` | `Sources/ZyquoRouter/Providers/OpenAICompatibleClient.swift` | header + extensions (see 5.2) | | `Sources/ZyquoCloud/Providers/AnthropicClient.swift` | `Sources/ZyquoRouter/Providers/AnthropicClient.swift` | header + extensions (see 5.2) | | `Tests/ZyquoCloudTests/SSEParserTests.swift` | `Tests/ZyquoRouterTests/SSEParserTests.swift` | header/module rename | | `Tests/ZyquoCloudTests/SecureKeyStoreTests.swift` | `Tests/ZyquoRouterTests/SecureKeyStoreTests.swift` | header/module rename | | (subset of) `Sources/ZyquoCloud/Models/Message.swift` | `Sources/ZyquoRouter/Models/Message.swift` | keep `Message`, `Attachment`, `Citation`; drop chat-UI fields if unused (`isStreaming`, `errorText`) or keep for log display | `KeyVaultStore.swift` (ViewModel) ports with light edits for the Router's Keys screen (Provider Keys tab is spec'd "identical UX to Zyquo Cloud"). ### 5.2 What Cloud's clients DON'T give the Router (must be added, not just ported) Cloud is a chat app; its clients expose a **UI-oriented, lossy** event stream. The Router needs a **spec-complete OpenAI surface**. Gaps found in the code: 1. **No tool calling anywhere.** `WireRequest` has no `tools`/`tool_choice`; `WireDelta` decodes no `tool_calls`; `ChatEvent` has no tool case; Anthropic's client sends no `tools` and ignores `tool_use`/`input_json_delta` (grep of `Sources/ZyquoCloud/Providers/` for "tool" → zero hits). The Router must extend the wire types and `ChatEvent` (e.g. `.toolCallDelta(index:id:name:argumentsDelta:)`) and implement Anthropic `tool_use`/`tool_result` ↔ OpenAI `tool_calls`/`role:"tool"` translation per `docs/ROUTER-RESEARCH.md`. 2. **No `response_format`/JSON mode, `stop`, `n`, `seed`, `logprobs`, `user`** in `WireRequest` — the catalog tracks `jsonMode` capability but Cloud never sends it. Add these fields (gated by `CompatAdjuster`). 3. **Lossy events**: `ChatEvent` collapses per-choice structure (only `choices.first` is read, `OpenAICompatibleClient.swift:343`), drops chunk `id`/`created`/`model`/`system_fingerprint`, and merges role/content deltas. Fine for a chat UI; the Router must emit **byte-perfect `chat.completion.chunk`s**. Recommended approach: keep Cloud's request-construction + `StreamingService`/`SSEParser` + auth/quirk logic **as-is**, but widen the response path — either (a) make the wire response types non-private and add a raw-chunk streaming API (`streamRawChunks(_:apiKey:) -> AsyncThrowingStream` yielding upstream SSE data payloads for OpenAI-compatible providers, enabling near-pass-through), plus a translated path for Anthropic; or (b) enrich `ChatEvent` to carry everything (finish per choice, tool deltas, ids, raw usage). Option (a) is closest to how LiteLLM/OpenRouter behave for compat upstreams and preserves fidelity; the Anthropic translator then builds spec-exact chunks from the named events already parsed in `AnthropicClient.streamChat`. 4. **`ChatRequest` is UI-shaped** (`model: AIModel`, `messages: [Message]` with attachments). The Router receives OpenAI wire JSON; it should define a canonical internal request (`Translate/OpenAINormalizer.swift`) and map it into the ported clients' body builders — or refactor `buildBody` to accept the canonical type. Keep `ParameterSupport` gating exactly as Cloud does; it *is* the per-provider param strip/translate table (`CompatAdjuster` seed data). 5. **`ChatParameters`** lives in `Sources/ZyquoCloud/Models/Conversation.swift` (temperature, topP, maxTokens, frequency/presencePenalty, reasoningEffort, thinkingEnabled) — port the struct (or inline it) since both clients consume it. 6. **Retry semantics**: Cloud retries only non-streaming calls (3 attempts in `StreamingService.postJSON`). The Router's `RetryPolicy` (Phase 2 layout) should own this and also handle pre-first-byte retry for streaming + `Retry-After` propagation to clients. 7. **Gemini**: no native client exists (see §1.1). Near-term: reuse the compat path (verified live by Cloud, including vision via data URIs and `reasoning_effort`). If Phase 3's gate demands the native `generateContent` translation, `GeminiTranslator.swift` is **net-new work** guided by `docs/ROUTER-RESEARCH.md` — nothing to port from Cloud beyond `docs/PROVIDERS.md:524-696` research. 8. **Usage estimation**: when upstream omits usage, Cloud just shows nothing. The Router spec requires estimated-and-flagged usage — net-new (token estimation), though `TokenUsage` and `ModelPricing.cost` port directly for the metering. ### 5.3 Destination layout (matches Router CLAUDE.md Phase 2) ``` Sources/ZyquoRouter/ ├── Models/ ProviderID.swift, AIModel.swift, Message.swift (+ChatParameters) ← ported ├── Providers/ ProviderProtocol.swift, ProviderRegistry.swift, │ OpenAICompatibleClient.swift, AnthropicClient.swift ← ported + extended │ (GeminiClient.swift only if native path chosen — net-new) ├── Services/ StreamingService.swift, SecureKeyStore.swift, │ ModelCatalog.swift, ModelCatalogData.swift, PersistenceService.swift ← ported ├── Translate/ OpenAINormalizer.swift, AnthropicTranslator.swift, │ GeminiTranslator.swift, CompatAdjuster.swift ← new (seeded by ParameterSupport + §4) ├── Router/ RequestRouter.swift, RetryPolicy.swift, UsageMeter.swift ← new (RetryPolicy absorbs postJSON backoff) └── Server/ … ← new ``` ### 5.4 Porting checklist - [ ] Rewrite every file header comment `Zyquo Cloud` → `Zyquo Router` (mandatory header sweep). - [ ] `ZyquoCloud/1.0` User-Agent → `ZyquoRouter/1.0`; HKDF info + vault path decision recorded. - [ ] Keep type names Cloud uses: `ProviderClient`, `ProviderID`, `WireFormat`, `AIModel`, `ModelCatalog`, `SecureKeyStore`, `SSEParser`, `TokenUsage`, `ProviderError` (Router CLAUDE.md's uniform-naming rule already includes `ProviderClient`). - [ ] `ModelCatalogData.swift` stays byte-identical to Cloud's (modulo header) — single source of truth for the `GET /v1/models` catalog and pricing/cost metering. - [ ] Extend wire types for tools / response_format / stop / n / seed; add raw-chunk streaming path. - [ ] Port fixture-worthy behaviors into unit tests: SSE blank-line handling, `: keep-alive`, Mistral ThinkChunk flattening, Together `choices[].text`, Anthropic event mapping, Gemini `models/` prefix strip, `finish_reason:"eos"` → `stop`.