spb/zyquo-atlas Public License
The AI-native macOS web browser — every surface, intelligent.
Swift 75.2%
JavaScript 22%
Shell 2%
Makefile 0.9%
1<!--2 PROVIDER-REUSE.md3 Zyquo Atlas4 Author: Simon-Pierre Boucher5 Mail: contact@spboucher.ai6-->78# Zyquo Atlas — Provider Reuse Study (Phase 0.B)910Compiled 2026-07-30 from a full read of the **Zyquo Cloud** repository11(`~/Desktop/zyquo-cloud`), the declared single source of truth for Atlas's AI layer.12Atlas must call every provider **identically** to Cloud — same wire formats, same models,13same key vault. All 12 providers and the full 170-model catalog were live-verified with14real keys on 2026-07-30 through Cloud's Phase 7 harness: **202/202 tests green**15(`zyquo-cloud/docs/VERIFICATION.md`). Keys live in `zyquo-cloud/.env.keys` (gitignored).1617---1819## 1. Architecture2021The provider layer is small and fully decoupled from UI: it imports only22**Foundation + CryptoKit + IOKit + Security** — zero SwiftUI, zero theme coupling,23no external HTTP libraries. There are exactly **two** client implementations.2425### 1.1 `ProviderClient` protocol (`Providers/ProviderProtocol.swift`)2627```swift28protocol ProviderClient {29 var providerID: ProviderID { get }30 func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error>31 func complete(_ request: ChatRequest, apiKey: String) async throws -> Message32 func listModelIDs(apiKey: String) async throws -> [String]33}34```3536A protocol extension provides `testKey(_:fallbackModel:) async throws -> TimeInterval`:37uses `/models` when `providerID.supportsModelListing`, else (Perplexity only) a tiny38non-streaming completion (`maxTokens: 16`, "Reply with exactly: OK").3940Provider-agnostic request/event types:4142```swift43struct ChatRequest {44 var model: AIModel45 var systemPrompt: String?46 var messages: [Message]47 var parameters: ChatParameters48 var stream: Bool = true49}5051enum ChatEvent {52 case reasoningDelta(String) // unified reasoning stream (see §2)53 case textDelta(String)54 case citations([Citation]) // Perplexity search citations55 case usage(TokenUsage)56 case finished(reason: String?)57}5859struct ChatParameters: Codable, Hashable { // in Cloud: embedded in Conversation.swift60 var temperature: Double?61 var topP: Double?62 var maxTokens: Int?63 var frequencyPenalty: Double?64 var presencePenalty: Double?65 var reasoningEffort: String? // "low" / "medium" / "high"66 var thinkingEnabled: Bool? // Anthropic thinking / Qwen enable_thinking67}68```6970`nil` parameter = provider default = **omitted from the request body entirely**.7172Errors (`ProviderError`): `invalidAPIKey`, `rateLimited(retryAfter:)`, `serverError`,73`badRequest`, `networkError`, `invalidResponse`, `missingAPIKey`, `noModelAvailable`,74`cancelled`. The static mapper `ProviderError.from(status:body:provider:)` maps75401/403 → invalidAPIKey, 429 → rateLimited, 400/404/422 → badRequest, else serverError;76`extractMessage(from:)` tolerates every provider's error shape (`{"error":{"message":…}}`,77`{"error":"…"}`, `{"message":…}`, `{"detail":…}`, Gemini-style arrays, raw-bytes fallback).7879### 1.2 Registry dispatch (`Providers/ProviderRegistry.swift`)8081Dispatch key is `ProviderID.wireFormat`:8283```swift84enum WireFormat: String, Codable { case openAIChatCompletions; case anthropicMessages }85```8687Only `.anthropic` → `AnthropicClient`. **All 11 other providers + `.custom` →88`OpenAICompatibleClient(provider:baseURLOverride:)`.** Clients are stateless structs89instantiated per call; the only shared state is `StreamingService.session`.9091> **There is NO `GeminiClient` in Cloud.** Gemini is served through the92> `OpenAICompatibleClient` via Google's OpenAI-compat endpoint. Atlas's planned93> `Providers/GeminiClient.swift` is therefore **dropped** from the Phase 2 layout —94> creating one would re-invent a request format, which CLAUDE.md forbids.9596`ProviderID` enum: `openai, anthropic, xai, mistral, gemini, qwen, deepseek, kimi,97perplexity, together, deepinfra, cerebras, custom`. `supportsModelListing == (self != .perplexity)`.9899---100101## 2. Per-provider wiring (the exact call contract)102103Common OpenAI-compat path: `POST {base}/chat/completions`, `GET {base}/models`,104`Authorization: Bearer <key>`. URLs built with `appendingPathComponent` so multi-segment105bases (`/compatible-mode/v1`, `/v1beta/openai`, `/v1/openai`) survive.106107`stream_options: {"include_usage": true}` is sent ONLY for: openai, xai, gemini,108deepseek, kimi, together, cerebras, custom. NOT for mistral (rejects unknown params),109qwen, deepinfra, perplexity (usage automatic).110111| Provider | Base URL | Auth | Request quirks | Response/SSE quirks |112|---|---|---|---|---|113| 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 |114| 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` |115| xAI | `https://api.x.ai/v1` | Bearer | `reasoning_effort` low/med/high | standard |116| 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 |117| Gemini | `https://generativelanguage.googleapis.com/v1beta/openai` | Bearer (compat endpoint) | stream_options yes | `/models` IDs prefixed `models/` → normalized; `extra_content`/`thought_signature` tolerated |118| 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` |119| DeepSeek | `https://api.deepseek.com` (no `/v1`) | Bearer | stream_options yes | `delta.reasoning_content`; `: keep-alive` SSE comments |120| Kimi (Moonshot) | `https://api.moonshot.ai/v1` | Bearer | K-series `reasoning_effort` | `delta.reasoning_content`; engine can 429 `engine_overloaded_error` server-side |121| 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) |122| 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 |123| DeepInfra | `https://api.deepinfra.com/v1/openai` | Bearer | no stream_options (usage automatic) | model-dependent `reasoning_content`; bursts >3 concurrent can 429 heavy models |124| Cerebras | `https://api.cerebras.ai/v1` | Bearer | `usesMaxCompletionTokens: true`; `reasoning_effort`; free tier 5 req/min (verify serially) | standard |125| custom | user base URL | Bearer | OpenAI-compat, stream_options yes | — |126127**Reasoning unification:** `reasoning_content` (DeepSeek/Qwen/Kimi/DeepInfra),128`reasoning` (Together), Mistral chunk arrays, and Anthropic `thinking_delta` all surface129as `ChatEvent.reasoningDelta` — Atlas UI handles one event, not four formats.130131**Vision:** OpenAI-compat sends `[{type:"text"},{type:"image_url","image_url":{"url":"data:<mime>;base64,…"}}]`132only when `model.capabilities.vision && role == .user && has images`; Anthropic uses133`{"type":"image","source":{"type":"base64",…}}` blocks. Text attachments are inlined134into message text fenced with the filename (identical in both clients).135136---137138## 3. Model catalog — ALL 170 models ship in Atlas139140`Services/ModelCatalogData.swift` (1364 lines): **170 built-in chat models**, generated141from `zyquo-cloud/docs/PROVIDERS.md` + `docs/research/` (dated 2026-07-30, live-probed).142Scope: chat-completions-capable models only (embeddings/audio/image/Responses-only excluded).143144Per-provider counts: OpenAI 27 · Anthropic 11 · xAI 5 · Mistral 10 · Gemini 14 ·145Qwen 32 · DeepSeek 2 · Kimi 12 · Perplexity 4 · Together 16 · DeepInfra 34 · Cerebras 3.14629 recommended, 34 with thinking toggle, 8 requiring streaming, 34 legacy-flagged.147148`AIModel` carries `id` (exact wire ID), `provider`, `displayName`, `contextWindow`,149`maxOutputTokens`, `capabilities` (vision/tools/reasoning/streaming/jsonMode/citations),150`pricing` (in/out per MTok + `cost()`), `parameterSupport` (temperature/topP/penalties/151`usesMaxCompletionTokens`/`reasoningEffort`/`thinkingToggle`/`requiresStreaming`),152`isLegacy`, `isRecommended`, `customBaseURL`.153154`ModelCatalog` (`@MainActor ObservableObject`): `builtIn`, user `customModels`155(custom ID + base URL → `.custom` provider), `liveModelIDs` from `/models` refresh,156favorites, ranked `models(for:)` (favorite < recommended < normal < legacy),157`cheapestModel(for:)` (used for key tests; Atlas reuses it for cheap actions like158hover-summaries), `defaultModel`, `unknownLiveIDs(for:)`.159160**Atlas usage:** the full catalog is exposed everywhere a model picker appears161(sidebar chat, per-action defaults). Atlas adds per-action default-model preferences162*around* `ModelCatalog` (fast/cheap for hovers, strong for deep chat) — never inside it.163164---165166## 4. SecureKeyStore — the encrypted vault (deliberately NOT Keychain)167168`Services/SecureKeyStore.swift` (CryptoKit + IOKit + Security):169170- **File:** Cloud stores `vault.zq` under `~/Library/Application Support/ZyquoCloud/`.171 Blob layout: `[salt 32B][AES.GCM combined: nonce 12B + ciphertext + tag 16B]`.172 Plaintext = JSON `[providerID.rawValue: key]`.173- **Key derivation:** `HKDF<SHA256>` over `IOPlatformUUID ‖ NSHomeDirectory()` (machine174 entropy) ‖ compiled-in pepper (30 bytes, XOR-obfuscated, assembled at runtime), with175 the vault's salt and info `"ZyquoCloud.vault.v1"` → AES-256-GCM 256-bit key. The vault176 is thus bound to machine + user account; decrypt failure → `.corrupted`.177- **Write:** salt reused from existing vault or `SecRandomCopyBytes`; atomic +178 complete-file-protection writes.179- **API:** `loadKeys()`, `saveKeys(_:)`, `key(for:)`, `setKey(_:for:)`, `deleteKey(for:)`,180 `redacted(_:)` ("••••" + last 4). Test-injectable `vaultURL`/`machineEntropy`.181- **Why no Keychain:** explicit design — no entitlement/prompt friction, self-contained,182 machine-bound file.183184`KeyVaultStore` (`@MainActor ObservableObject`): holds only **redacted** keys in memory;185`apiKey(for:)` decrypts on demand at call sites which use and drop the key; per-provider186`KeyStatus` (unset/saved/testing/verified(latency)/failed).187188**Atlas decision (implement in Phase 3):** same format + configurable `vaultURL`,189defaulting to `~/Library/Application Support/ZyquoAtlas/vault.zq` with info190`"ZyquoAtlas.vault.v1"`, PLUS a one-time **"Import keys from Zyquo Cloud"** that reads191Cloud's vault using Cloud's exact derivation constants (same pepper, info192`"ZyquoCloud.vault.v1"`, Cloud's path) and re-encrypts into Atlas's vault. This delivers193"same keys as Cloud" without cross-app file coupling. Never print/log the decoded pepper194or any key.195196---197198## 5. Streaming (`Services/StreamingService.swift`)199200- `SSEParser`: incremental line-based parser — blank line = event boundary; `:`-prefixed201 comment lines dropped (DeepSeek keep-alives); `event:` name (Anthropic only); `data:`202 strips one leading space; `id:`/`retry:` ignored. Unit-tested (`SSEParserTests`).203- `StreamingService.session`: `URLSession` with request timeout 120s, resource 900s,204 `User-Agent` header (Atlas: `"ZyquoAtlas/1.0 (macOS)"`).205- `sseEvents(for:provider:)`: `session.bytes(for:)`; non-2xx drains body and throws the206 mapped `ProviderError`. **Manual byte-level line splitting on `0x0A`** (AsyncBytes.lines207 skips the empty lines that ARE the SSE separators); trailing partial line + synthetic208 blank line flushed at EOF. `Task.isCancelled` checked per byte; `CancellationError` →209 `ProviderError.cancelled`.210- `postJSON`: non-streaming, **3 attempts**, retry only on 429/5xx, delay = `Retry-After`211 or exponential (4s, 8s).212- Both clients wrap SSE loops in `AsyncThrowingStream` with213 `continuation.onTermination = { _ in task.cancel() }` — **dropping the consuming214 for-await loop cancels the inner Task and the network stream.** Atlas's215 cancel-on-navigation hooks directly into this: `AIService` keeps the per-tab `Task`,216 navigation calls `task.cancel()`, everything unwinds. `[DONE]` handled; undecodable /217 keep-alive chunks skipped (`try? decode … else continue`) giving cross-provider218 unknown-field tolerance; Perplexity citations emitted exactly once.219220Consumption pattern to copy into Atlas's `AIService` (from Cloud's `ConversationStore`):221222```swift223let apiKey = try vault.apiKey(for: model.provider) // decrypt on demand, drop after224let client = ProviderRegistry.client(for: model)225streamTask = Task {226 for try await event in client.streamChat(request, apiKey: apiKey) { … }227}228// Stop / navigation: streamTask?.cancel()229```230231---232233## 6. Port plan (file-by-file)234235Every ported file's header changes `// Zyquo Cloud` → `// Zyquo Atlas`; author/mail236lines unchanged. The set below compiles standalone with Foundation + CryptoKit + IOKit +237Security — no SwiftUI, no swift-markdown.238239| # | Cloud file | Atlas destination | Verdict |240|---|---|---|---|241| 1 | `Providers/ProviderProtocol.swift` | `Providers/ProviderProtocol.swift` | Verbatim (header; Settings wording cosmetic) |242| 2 | `Providers/OpenAICompatibleClient.swift` | same | **Verbatim** — all 11 compat providers' quirks live here |243| 3 | `Providers/AnthropicClient.swift` | same | **Verbatim** |244| 4 | `Providers/ProviderRegistry.swift` | same | **Verbatim** — no GeminiClient added |245| 5 | `Models/ProviderID.swift` | `Models/ProviderID.swift` | Verbatim |246| 6 | `Models/AIModel.swift` | `Models/AIModel.swift` | Verbatim |247| 7 | `Models/Message.swift` | `Models/Message.swift` | Verbatim — clients write `modelID/usage/estimatedCost/citations/isStreaming/errorText`; do not re-invent |248| 8 | `Models/Conversation.swift` → `ChatParameters` only | new `Models/ChatParameters.swift` | **Extract** the struct; leave Conversation/Persona/PromptTemplate behind |249| 9 | `Services/StreamingService.swift` | same | One-string rename: User-Agent → `ZyquoAtlas/1.0 (macOS)` |250| 10 | `Services/ModelCatalogData.swift` | same | Verbatim — ALL 170 models |251| 11 | `Services/ModelCatalog.swift` | same | Verbatim |252| 12 | `Services/SecureKeyStore.swift` | same | **Adapt:** root dir from Atlas's PersistenceService; vault info `ZyquoAtlas.vault.v1`; add Cloud-vault import path (§4) |253| 13 | `ViewModels/KeyVaultStore.swift` | same | Verbatim once 1–12 in place |254| 14 | `Services/PersistenceService.swift` | — | Do NOT port — Atlas writes its own rooted at `ZyquoAtlas/` |255| 15 | `Tests/…/SSEParserTests.swift`, `SecureKeyStoreTests.swift` | Atlas tests | Port with renames (injection points already exist) |256| 16 | `docs/PROVIDERS.md` (Cloud) | reference | Cited as catalog source of truth; not duplicated to avoid drift |257258**Behavioral invariants (the "identical" contract):** wire bodies byte-equivalent per259provider (param gating via `ParameterSupport`, `max_tokens` vs `max_completion_tokens`,260Mistral effort remap, Qwen `enable_thinking` stream-only, the `stream_options` allowlist);261Anthropic headers + mandatory `max_tokens` + thinking budget 8000; SSE parser semantics262(blank-line boundaries, comment tolerance, `[DONE]`, trailing flush); the error-mapping263table; retry policy (3 attempts, 429/5xx, Retry-After or 4s/8s); cancellation via264`onTermination → task.cancel()`; and key handling (decrypt on demand, never retain,265redact to last 4, never log).266267**Verification harness:** Cloud's `Verify/VerifyHarness.swift` (`--verify`) is the268pattern for Atlas's Phase 7 AI-verification: `/models` diff, "OK" completion on every269catalog model, streaming test, vision test; serial sweeps for Cerebras (5 req/min) and270Mistral; env keys from `.env.keys`; results table written to `docs/VERIFICATION.md`.271Atlas extends it with browser-specific actions (omnibox ask, summarize page,272chat-with-page, selection actions, writing assist, multi-tab compare).273