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):
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):
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:
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_effortonly accepts"high"/"none"→ client mapslow → none, elsehigh(OpenAICompatibleClient.swift:286-293;docs/PROVIDERS.md:1703). - Qwen/DashScope
enable_thinkingis only legal on streaming requests (OpenAICompatibleClient.swift:294-297). usesMaxCompletionTokensswitchesmax_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);textis the Together completions-style fallback (choices[].textinstead ofdelta.content,OpenAICompatibleClient.swift:119-131;docs/PROVIDERS.md:1712-1713).WireDelta { content, reasoningContent ("reasoning_content"), reasoning }with a custominit(from:)(OpenAICompatibleClient.swift:143-167):contentis normally a string, but Mistral reasoning models return an array ofContentChunks ({type:"thinking"|"text",…}with nestedthinking:[{type:"text",text:…}]) which are flattened into text + reasoning.WireUsage { promptTokens, completionTokens, completionTokensDetails.reasoningTokens }→TokenUsage(OpenAICompatibleClient.swift:187-210)./modelsdecoding tries{"data":[{id}]}then a bare array (Together), and strips themodels/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
URLSessionwithtimeoutIntervalForRequest = 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 throwsProviderError.from(status:body:provider:). - Splits bytes on
\nmanually (handling\r\n) becauseAsyncBytes.linesskips 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.isCancelledper byte-boundary; cancellation surfaces asProviderError.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, withfunc 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 asPersistenceService.shared.rootDirectory.appendingPathComponent("vault.zq")(SecureKeyStore.swift:56-57; root dir fromSources/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 CryptoKitAES.GCM.SealedBox.combinedrepresentation (nonce ‖ ciphertext ‖ tag), opened withAES.GCM.SealedBox(combined:)+AES.GCM.open(_:using:)(SecureKeyStore.swift:71-72) and produced byAES.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 areProviderID.rawValuestrings (SecureKeyStore.swift:13,key(for:)/setKey(_:for:)atSecureKeyStore.swift:94-108). - Written atomically with
.completeFileProtection(SecureKeyStore.swift:91); the directory is created on demand.
Master key derivation (SecureKeyStore.swift:118-159)
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'sIOPlatformExpertDeviceregistry entry viaIORegistryEntryCreateCFProperty(…, 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
0x5Aso 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)
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):
- Reasoning content field zoo — normalize to one field (Router decision:
reasoning_contenton 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 exposesmessage.reasoningfor hosted reasoning models (docs/PROVIDERS.md:28).- Anthropic:
thinking_deltainsidecontent_block_deltaevents; non-streamingthinkingcontent blocks (AnthropicClient.swift:226-232,270-271). - Mistral:
contentarrives 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).
- 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 emptychoices+usage); Qwen, DeepInfra, Perplexity include usage automatically (and Mistral rejects the param). Anthropic splits usage:input_tokensinmessage_start, finaloutput_tokensinmessage_delta. - Keep-alive comments: DeepSeek sends
: keep-aliveSSE comment lines —SSEParserdrops any:-prefixed line (StreamingService.swift:36). Undecodable data chunks are skipped, not fatal (OpenAICompatibleClient.swift:339-342). - Finish-reason quirks: Together emits nonstandard
finish_reason: "eos"(docs/PROVIDERS.md:35) — the Router must map it tostop. Anthropic usesstop_reason(end_turn,max_tokens,stop_sequence,tool_use) delivered inmessage_delta, which the Router maps to OpenAIfinish_reason(stop/length/stop/tool_calls). - Together completions-style streams: some models (
Qwen/Qwen3.5-9B,google/gemma-4-31B-it) stream tokens inchoices[].textinstead ofdelta.content(OpenAICompatibleClient.swift:122-124;docs/PROVIDERS.md:1712-1713). - Models that reject non-streaming calls (
ParameterSupport.requiresStreaming,AIModel.swift:88-90): Qwenqvq-max/qwq-plus; TogetherQwen3.7-Max/-Plus,Qwen3.6-Plus,Qwen3.5-9B,google/gemma-4-31B-it; DeepInframoonshotai/Kimi-K2.5. For a non-streaming router request, aggregate the stream (Cloud'scompleteViaStreampattern). - 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/modelsendpoint (404): the Router must serve Perplexity models from the built-in catalog only. - Gemini compat endpoint:
/modelsIDs prefixedmodels/(stripped,OpenAICompatibleClient.swift:411,420); unknown fields likeextra_content/thought_signaturein deltas must be ignored; native-only features (thought summaries,thoughtsTokenCount) are not exposed on the compat endpoint (docs/PROVIDERS.md:560). - Unknown-field tolerance generally: OpenAI adds
obfuscationfields; decoders must ignore unknown JSON keys everywhere (docs/PROVIDERS.md:34-35). - Anthropic mid-stream errors: an
errorSSE 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). - Parameter strip tables (per-model
ParameterSupport): OpenAI reasoning models and Claude 4.7+/5 rejecttemperature/top_p; Cerebras + OpenAI reasoning + Kimi K-series requiremax_completion_tokens; Mistralreasoning_effortaccepts onlyhigh/none; Qwenenable_thinkingonly when streaming; xAIgrok-4.20/grok-code-fast-1rejectreasoning_effort. This is exactly the Router'sCompatAdjusterdata. - Vision minimums: xAI and Qwen reject images smaller than 8px (
docs/PROVIDERS.md:1707). - Anthropic streams have no
data: [DONE]— termination is themessage_stopevent; 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:
- No tool calling anywhere.
WireRequesthas notools/tool_choice;WireDeltadecodes notool_calls;ChatEventhas no tool case; Anthropic's client sends notoolsand ignorestool_use/input_json_delta(grep ofSources/ZyquoCloud/Providers/for "tool" → zero hits). The Router must extend the wire types andChatEvent(e.g..toolCallDelta(index:id:name:argumentsDelta:)) and implement Anthropictool_use/tool_result↔ OpenAItool_calls/role:"tool"translation perdocs/ROUTER-RESEARCH.md. - No
response_format/JSON mode,stop,n,seed,logprobs,userinWireRequest— the catalog tracksjsonModecapability but Cloud never sends it. Add these fields (gated byCompatAdjuster). - Lossy events:
ChatEventcollapses per-choice structure (onlychoices.firstis read,OpenAICompatibleClient.swift:343), drops chunkid/created/model/system_fingerprint, and merges role/content deltas. Fine for a chat UI; the Router must emit byte-perfectchat.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) enrichChatEventto 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 inAnthropicClient.streamChat. ChatRequestis 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 refactorbuildBodyto accept the canonical type. KeepParameterSupportgating exactly as Cloud does; it is the per-provider param strip/translate table (CompatAdjusterseed data).ChatParameterslives inSources/ZyquoCloud/Models/Conversation.swift(temperature, topP, maxTokens, frequency/presencePenalty, reasoningEffort, thinkingEnabled) — port the struct (or inline it) since both clients consume it.- Retry semantics: Cloud retries only non-streaming calls (3 attempts in
StreamingService.postJSON). The Router'sRetryPolicy(Phase 2 layout) should own this and also handle pre-first-byte retry for streaming +Retry-Afterpropagation to clients. - 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 nativegenerateContenttranslation,GeminiTranslator.swiftis net-new work guided bydocs/ROUTER-RESEARCH.md— nothing to port from Cloud beyonddocs/PROVIDERS.md:524-696research. - Usage estimation: when upstream omits usage, Cloud just shows nothing. The Router spec
requires estimated-and-flagged usage — net-new (token estimation), though
TokenUsageandModelPricing.costport 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/ … ← new5.4 Porting checklist
- Rewrite every file header comment
Zyquo Cloud→Zyquo Router(mandatory header sweep). -
ZyquoCloud/1.0User-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 includesProviderClient). -
ModelCatalogData.swiftstays byte-identical to Cloud's (modulo header) — single source of truth for theGET /v1/modelscatalog 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, Togetherchoices[].text, Anthropic event mapping, Geminimodels/prefix strip,finish_reason:"eos"→stop.