# 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`) ```swift protocol ProviderClient { var providerID: ProviderID { get } func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream func complete(_ request: ChatRequest, apiKey: String) async throws -> Message func listModelIDs(apiKey: String) async throws -> [String] } ``` 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: ```swift 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`: ```swift 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 `GeminiClient` in Cloud.** Gemini is served through the > `OpenAICompatibleClient` via Google's OpenAI-compat endpoint. Atlas's planned > `Providers/GeminiClient.swift` is 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 `. 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"|"text"}`) — decoder handles string or array | | 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 `` 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:;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.zq` under `~/Library/Application Support/ZyquoCloud/`. Blob layout: `[salt 32B][AES.GCM combined: nonce 12B + ciphertext + tag 16B]`. Plaintext = JSON `[providerID.rawValue: key]`. - **Key derivation:** `HKDF` over `IOPlatformUUID ‖ 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-injectable `vaultURL`/`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`: `URLSession` with request timeout 120s, resource 900s, `User-Agent` header (Atlas: `"ZyquoAtlas/1.0 (macOS)"`). - `sseEvents(for:provider:)`: `session.bytes(for:)`; non-2xx drains body and throws the mapped `ProviderError`. **Manual byte-level line splitting on `0x0A`** (AsyncBytes.lines skips the empty lines that ARE the SSE separators); trailing partial line + synthetic blank line flushed at EOF. `Task.isCancelled` checked per byte; `CancellationError` → `ProviderError.cancelled`. - `postJSON`: non-streaming, **3 attempts**, retry only on 429/5xx, delay = `Retry-After` or exponential (4s, 8s). - Both clients wrap SSE loops in `AsyncThrowingStream` with `continuation.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: `AIService` keeps the per-tab `Task`, navigation calls `task.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`): ```swift 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).