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

# 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<ChatEvent, Error>
    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 <key> OpenAI (origin)
Anthropic https://api.anthropic.com/v1 x-api-key: <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:<mime>;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_tokensmax_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 ContentChunks ({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 <think> 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<SHA256>.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 <think>…</think> 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 CloudZyquo 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.chunks. 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<Data, Error> 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)

text
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 CloudZyquo 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.