Zyquo Atlas — Provider Reuse Study (Phase 0.B)
Compiled 2026-07-30 from a full read of the Zyquo Cloud repository
(~/Desktop/zyquo-cloud), the declared single source of truth for Atlas's AI layer.
Atlas must call every provider identically to Cloud — same wire formats, same models,
same key vault. All 12 providers and the full 170-model catalog were live-verified with
real keys on 2026-07-30 through Cloud's Phase 7 harness: 202/202 tests green
(zyquo-cloud/docs/VERIFICATION.md). Keys live in zyquo-cloud/.env.keys (gitignored).
1. Architecture
The provider layer is small and fully decoupled from UI: it imports only Foundation + CryptoKit + IOKit + Security — zero SwiftUI, zero theme coupling, no external HTTP libraries. There are exactly two client implementations.
1.1 ProviderClient protocol (Providers/ProviderProtocol.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]
}A protocol extension provides testKey(_:fallbackModel:) async throws -> TimeInterval:
uses /models when providerID.supportsModelListing, else (Perplexity only) a tiny
non-streaming completion (maxTokens: 16, "Reply with exactly: OK").
Provider-agnostic request/event types:
struct ChatRequest {
var model: AIModel
var systemPrompt: String?
var messages: [Message]
var parameters: ChatParameters
var stream: Bool = true
}
enum ChatEvent {
case reasoningDelta(String) // unified reasoning stream (see §2)
case textDelta(String)
case citations([Citation]) // Perplexity search citations
case usage(TokenUsage)
case finished(reason: String?)
}
struct ChatParameters: Codable, Hashable { // in Cloud: embedded in Conversation.swift
var temperature: Double?
var topP: Double?
var maxTokens: Int?
var frequencyPenalty: Double?
var presencePenalty: Double?
var reasoningEffort: String? // "low" / "medium" / "high"
var thinkingEnabled: Bool? // Anthropic thinking / Qwen enable_thinking
}nil parameter = provider default = omitted from the request body entirely.
Errors (ProviderError): invalidAPIKey, rateLimited(retryAfter:), serverError,
badRequest, networkError, invalidResponse, missingAPIKey, noModelAvailable,
cancelled. The static mapper ProviderError.from(status:body:provider:) maps
401/403 → invalidAPIKey, 429 → rateLimited, 400/404/422 → badRequest, else serverError;
extractMessage(from:) tolerates every provider's error shape ({"error":{"message":…}},
{"error":"…"}, {"message":…}, {"detail":…}, Gemini-style arrays, raw-bytes fallback).
1.2 Registry dispatch (Providers/ProviderRegistry.swift)
Dispatch key is ProviderID.wireFormat:
enum WireFormat: String, Codable { case openAIChatCompletions; case anthropicMessages }Only .anthropic → AnthropicClient. All 11 other providers + .custom →
OpenAICompatibleClient(provider:baseURLOverride:). Clients are stateless structs
instantiated per call; the only shared state is StreamingService.session.
There is NO
GeminiClientin Cloud. Gemini is served through theOpenAICompatibleClientvia Google's OpenAI-compat endpoint. Atlas's plannedProviders/GeminiClient.swiftis therefore dropped from the Phase 2 layout — creating one would re-invent a request format, which CLAUDE.md forbids.
ProviderID enum: openai, anthropic, xai, mistral, gemini, qwen, deepseek, kimi, perplexity, together, deepinfra, cerebras, custom. supportsModelListing == (self != .perplexity).
2. Per-provider wiring (the exact call contract)
Common OpenAI-compat path: POST {base}/chat/completions, GET {base}/models,
Authorization: Bearer <key>. URLs built with appendingPathComponent so multi-segment
bases (/compatible-mode/v1, /v1beta/openai, /v1/openai) survive.
stream_options: {"include_usage": true} is sent ONLY for: openai, xai, gemini,
deepseek, kimi, together, cerebras, custom. NOT for mistral (rejects unknown params),
qwen, deepinfra, perplexity (usage automatic).
| Provider | Base URL | Auth | Request quirks | Response/SSE quirks |
|---|---|---|---|---|
| OpenAI | https://api.openai.com/v1 |
Bearer | Reasoning models: no temperature/topP/penalties, max_completion_tokens (usesMaxCompletionTokens), reasoning_effort |
usage in final empty-choices chunk; unknown field obfuscation tolerated |
| Anthropic | https://api.anthropic.com/v1 |
x-api-key + anthropic-version: 2023-06-01 |
Messages API POST /messages; max_tokens mandatory (default 8192); top-level system; content block arrays; thinking:{type:"enabled",budget_tokens:8000} when toggled; empty text → " " |
Named SSE events: message_start/content_block_delta (delta.text, delta.thinking)/message_delta (usage, stop_reason)/error/message_stop; usage yielded once at end; /models?limit=100 |
| xAI | https://api.x.ai/v1 |
Bearer | reasoning_effort low/med/high |
standard |
| Mistral | https://api.mistral.ai/v1 |
Bearer | NO stream_options; reasoning_effort accepts only "high"/"none" → client remaps low→"none", medium/high→"high" |
reasoning models return delta.content as chunk ARRAYS (`{type:"thinking" |
| Gemini | https://generativelanguage.googleapis.com/v1beta/openai |
Bearer (compat endpoint) | stream_options yes | /models IDs prefixed models/ → normalized; extra_content/thought_signature tolerated |
| Qwen (DashScope intl) | https://dashscope-intl.aliyuncs.com/compatible-mode/v1 |
Bearer | enable_thinking sent ONLY when stream == true (rejected otherwise); requiresStreaming models (qwq/qvq) → complete() aggregates the stream |
delta.reasoning_content |
| DeepSeek | https://api.deepseek.com (no /v1) |
Bearer | stream_options yes | delta.reasoning_content; : keep-alive SSE comments |
| Kimi (Moonshot) | https://api.moonshot.ai/v1 |
Bearer | K-series reasoning_effort |
delta.reasoning_content; engine can 429 engine_overloaded_error server-side |
| Perplexity | https://api.perplexity.ai |
Bearer | NO /models (404); no stream_options |
top-level citations + search_results merged once into [Citation]; sonar-reasoning-pro emits inline <think> text (UI concern) |
| Together | https://api.together.xyz/v1 |
Bearer | stream_options yes | /models returns bare array; some models stream completions-style (choices[].text fallback); finish_reason:"eos"; message.reasoning field |
| DeepInfra | https://api.deepinfra.com/v1/openai |
Bearer | no stream_options (usage automatic) | model-dependent reasoning_content; bursts >3 concurrent can 429 heavy models |
| Cerebras | https://api.cerebras.ai/v1 |
Bearer | usesMaxCompletionTokens: true; reasoning_effort; free tier 5 req/min (verify serially) |
standard |
| custom | user base URL | Bearer | OpenAI-compat, stream_options yes | — |
Reasoning unification: reasoning_content (DeepSeek/Qwen/Kimi/DeepInfra),
reasoning (Together), Mistral chunk arrays, and Anthropic thinking_delta all surface
as ChatEvent.reasoningDelta — Atlas UI handles one event, not four formats.
Vision: OpenAI-compat sends [{type:"text"},{type:"image_url","image_url":{"url":"data:<mime>;base64,…"}}]
only when model.capabilities.vision && role == .user && has images; Anthropic uses
{"type":"image","source":{"type":"base64",…}} blocks. Text attachments are inlined
into message text fenced with the filename (identical in both clients).
3. Model catalog — ALL 170 models ship in Atlas
Services/ModelCatalogData.swift (1364 lines): 170 built-in chat models, generated
from zyquo-cloud/docs/PROVIDERS.md + docs/research/ (dated 2026-07-30, live-probed).
Scope: chat-completions-capable models only (embeddings/audio/image/Responses-only excluded).
Per-provider counts: OpenAI 27 · Anthropic 11 · xAI 5 · Mistral 10 · Gemini 14 · Qwen 32 · DeepSeek 2 · Kimi 12 · Perplexity 4 · Together 16 · DeepInfra 34 · Cerebras 3. 29 recommended, 34 with thinking toggle, 8 requiring streaming, 34 legacy-flagged.
AIModel carries id (exact wire ID), provider, displayName, contextWindow,
maxOutputTokens, capabilities (vision/tools/reasoning/streaming/jsonMode/citations),
pricing (in/out per MTok + cost()), parameterSupport (temperature/topP/penalties/
usesMaxCompletionTokens/reasoningEffort/thinkingToggle/requiresStreaming),
isLegacy, isRecommended, customBaseURL.
ModelCatalog (@MainActor ObservableObject): builtIn, user customModels
(custom ID + base URL → .custom provider), liveModelIDs from /models refresh,
favorites, ranked models(for:) (favorite < recommended < normal < legacy),
cheapestModel(for:) (used for key tests; Atlas reuses it for cheap actions like
hover-summaries), defaultModel, unknownLiveIDs(for:).
Atlas usage: the full catalog is exposed everywhere a model picker appears
(sidebar chat, per-action defaults). Atlas adds per-action default-model preferences
around ModelCatalog (fast/cheap for hovers, strong for deep chat) — never inside it.
4. SecureKeyStore — the encrypted vault (deliberately NOT Keychain)
Services/SecureKeyStore.swift (CryptoKit + IOKit + Security):
- File: Cloud stores
vault.zqunder~/Library/Application Support/ZyquoCloud/. Blob layout:[salt 32B][AES.GCM combined: nonce 12B + ciphertext + tag 16B]. Plaintext = JSON[providerID.rawValue: key]. - Key derivation:
HKDF<SHA256>overIOPlatformUUID ‖ NSHomeDirectory()(machine entropy) ‖ compiled-in pepper (30 bytes, XOR-obfuscated, assembled at runtime), with the vault's salt and info"ZyquoCloud.vault.v1"→ AES-256-GCM 256-bit key. The vault is thus bound to machine + user account; decrypt failure →.corrupted. - Write: salt reused from existing vault or
SecRandomCopyBytes; atomic + complete-file-protection writes. - API:
loadKeys(),saveKeys(_:),key(for:),setKey(_:for:),deleteKey(for:),redacted(_:)("••••" + last 4). Test-injectablevaultURL/machineEntropy. - Why no Keychain: explicit design — no entitlement/prompt friction, self-contained, machine-bound file.
KeyVaultStore (@MainActor ObservableObject): holds only redacted keys in memory;
apiKey(for:) decrypts on demand at call sites which use and drop the key; per-provider
KeyStatus (unset/saved/testing/verified(latency)/failed).
Atlas decision (implement in Phase 3): same format + configurable vaultURL,
defaulting to ~/Library/Application Support/ZyquoAtlas/vault.zq with info
"ZyquoAtlas.vault.v1", PLUS a one-time "Import keys from Zyquo Cloud" that reads
Cloud's vault using Cloud's exact derivation constants (same pepper, info
"ZyquoCloud.vault.v1", Cloud's path) and re-encrypts into Atlas's vault. This delivers
"same keys as Cloud" without cross-app file coupling. Never print/log the decoded pepper
or any key.
5. Streaming (Services/StreamingService.swift)
SSEParser: incremental line-based parser — blank line = event boundary;:-prefixed comment lines dropped (DeepSeek keep-alives);event:name (Anthropic only);data:strips one leading space;id:/retry:ignored. Unit-tested (SSEParserTests).StreamingService.session:URLSessionwith request timeout 120s, resource 900s,User-Agentheader (Atlas:"ZyquoAtlas/1.0 (macOS)").sseEvents(for:provider:):session.bytes(for:); non-2xx drains body and throws the mappedProviderError. Manual byte-level line splitting on0x0A(AsyncBytes.lines skips the empty lines that ARE the SSE separators); trailing partial line + synthetic blank line flushed at EOF.Task.isCancelledchecked per byte;CancellationError→ProviderError.cancelled.postJSON: non-streaming, 3 attempts, retry only on 429/5xx, delay =Retry-Afteror exponential (4s, 8s).- Both clients wrap SSE loops in
AsyncThrowingStreamwithcontinuation.onTermination = { _ in task.cancel() }— dropping the consuming for-await loop cancels the inner Task and the network stream. Atlas's cancel-on-navigation hooks directly into this:AIServicekeeps the per-tabTask, navigation callstask.cancel(), everything unwinds.[DONE]handled; undecodable / keep-alive chunks skipped (try? decode … else continue) giving cross-provider unknown-field tolerance; Perplexity citations emitted exactly once.
Consumption pattern to copy into Atlas's AIService (from Cloud's ConversationStore):
let apiKey = try vault.apiKey(for: model.provider) // decrypt on demand, drop after
let client = ProviderRegistry.client(for: model)
streamTask = Task {
for try await event in client.streamChat(request, apiKey: apiKey) { … }
}
// Stop / navigation: streamTask?.cancel()6. Port plan (file-by-file)
Every ported file's header changes // Zyquo Cloud → // Zyquo Atlas; author/mail
lines unchanged. The set below compiles standalone with Foundation + CryptoKit + IOKit +
Security — no SwiftUI, no swift-markdown.
| # | Cloud file | Atlas destination | Verdict |
|---|---|---|---|
| 1 | Providers/ProviderProtocol.swift |
Providers/ProviderProtocol.swift |
Verbatim (header; Settings wording cosmetic) |
| 2 | Providers/OpenAICompatibleClient.swift |
same | Verbatim — all 11 compat providers' quirks live here |
| 3 | Providers/AnthropicClient.swift |
same | Verbatim |
| 4 | Providers/ProviderRegistry.swift |
same | Verbatim — no GeminiClient added |
| 5 | Models/ProviderID.swift |
Models/ProviderID.swift |
Verbatim |
| 6 | Models/AIModel.swift |
Models/AIModel.swift |
Verbatim |
| 7 | Models/Message.swift |
Models/Message.swift |
Verbatim — clients write modelID/usage/estimatedCost/citations/isStreaming/errorText; do not re-invent |
| 8 | Models/Conversation.swift → ChatParameters only |
new Models/ChatParameters.swift |
Extract the struct; leave Conversation/Persona/PromptTemplate behind |
| 9 | Services/StreamingService.swift |
same | One-string rename: User-Agent → ZyquoAtlas/1.0 (macOS) |
| 10 | Services/ModelCatalogData.swift |
same | Verbatim — ALL 170 models |
| 11 | Services/ModelCatalog.swift |
same | Verbatim |
| 12 | Services/SecureKeyStore.swift |
same | Adapt: root dir from Atlas's PersistenceService; vault info ZyquoAtlas.vault.v1; add Cloud-vault import path (§4) |
| 13 | ViewModels/KeyVaultStore.swift |
same | Verbatim once 1–12 in place |
| 14 | Services/PersistenceService.swift |
— | Do NOT port — Atlas writes its own rooted at ZyquoAtlas/ |
| 15 | Tests/…/SSEParserTests.swift, SecureKeyStoreTests.swift |
Atlas tests | Port with renames (injection points already exist) |
| 16 | docs/PROVIDERS.md (Cloud) |
reference | Cited as catalog source of truth; not duplicated to avoid drift |
Behavioral invariants (the "identical" contract): wire bodies byte-equivalent per
provider (param gating via ParameterSupport, max_tokens vs max_completion_tokens,
Mistral effort remap, Qwen enable_thinking stream-only, the stream_options allowlist);
Anthropic headers + mandatory max_tokens + thinking budget 8000; SSE parser semantics
(blank-line boundaries, comment tolerance, [DONE], trailing flush); the error-mapping
table; retry policy (3 attempts, 429/5xx, Retry-After or 4s/8s); cancellation via
onTermination → task.cancel(); and key handling (decrypt on demand, never retain,
redact to last 4, never log).
Verification harness: Cloud's Verify/VerifyHarness.swift (--verify) is the
pattern for Atlas's Phase 7 AI-verification: /models diff, "OK" completion on every
catalog model, streaming test, vision test; serial sweeps for Cerebras (5 req/min) and
Mistral; env keys from .env.keys; results table written to docs/VERIFICATION.md.
Atlas extends it with browser-specific actions (omnibox ask, summarize page,
chat-with-page, selection actions, writing assist, multi-tab compare).