|
1 |
+<!-- |
|
2 |
+ PROVIDER-REUSE.md |
|
3 |
+ Zyquo Atlas |
|
4 |
+ Author: Simon-Pierre Boucher |
|
5 |
+ Mail: contact@spboucher.ai |
|
6 |
+--> |
|
7 |
+ |
|
8 |
+# Zyquo Atlas — Provider Reuse Study (Phase 0.B) |
|
9 |
+ |
|
10 |
+Compiled 2026-07-30 from a full read of the **Zyquo Cloud** repository |
|
11 |
+(`~/Desktop/zyquo-cloud`), the declared single source of truth for Atlas's AI layer. |
|
12 |
+Atlas must call every provider **identically** to Cloud — same wire formats, same models, |
|
13 |
+same key vault. All 12 providers and the full 170-model catalog were live-verified with |
|
14 |
+real 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). |
|
16 |
+ |
|
17 |
+--- |
|
18 |
+ |
|
19 |
+## 1. Architecture |
|
20 |
+ |
|
21 |
+The provider layer is small and fully decoupled from UI: it imports only |
|
22 |
+**Foundation + CryptoKit + IOKit + Security** — zero SwiftUI, zero theme coupling, |
|
23 |
+no external HTTP libraries. There are exactly **two** client implementations. |
|
24 |
+ |
|
25 |
+### 1.1 `ProviderClient` protocol (`Providers/ProviderProtocol.swift`) |
|
26 |
+ |
|
27 |
+```swift |
|
28 |
+protocol 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 -> Message |
|
32 |
+ func listModelIDs(apiKey: String) async throws -> [String] |
|
33 |
+} |
|
34 |
+``` |
|
35 |
+ |
|
36 |
+A protocol extension provides `testKey(_:fallbackModel:) async throws -> TimeInterval`: |
|
37 |
+uses `/models` when `providerID.supportsModelListing`, else (Perplexity only) a tiny |
|
38 |
+non-streaming completion (`maxTokens: 16`, "Reply with exactly: OK"). |
|
39 |
+ |
|
40 |
+Provider-agnostic request/event types: |
|
41 |
+ |
|
42 |
+```swift |
|
43 |
+struct ChatRequest { |
|
44 |
+ var model: AIModel |
|
45 |
+ var systemPrompt: String? |
|
46 |
+ var messages: [Message] |
|
47 |
+ var parameters: ChatParameters |
|
48 |
+ var stream: Bool = true |
|
49 |
+} |
|
50 |
+ |
|
51 |
+enum ChatEvent { |
|
52 |
+ case reasoningDelta(String) // unified reasoning stream (see §2) |
|
53 |
+ case textDelta(String) |
|
54 |
+ case citations([Citation]) // Perplexity search citations |
|
55 |
+ case usage(TokenUsage) |
|
56 |
+ case finished(reason: String?) |
|
57 |
+} |
|
58 |
+ |
|
59 |
+struct ChatParameters: Codable, Hashable { // in Cloud: embedded in Conversation.swift |
|
60 |
+ 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_thinking |
|
67 |
+} |
|
68 |
+``` |
|
69 |
+ |
|
70 |
+`nil` parameter = provider default = **omitted from the request body entirely**. |
|
71 |
+ |
|
72 |
+Errors (`ProviderError`): `invalidAPIKey`, `rateLimited(retryAfter:)`, `serverError`, |
|
73 |
+`badRequest`, `networkError`, `invalidResponse`, `missingAPIKey`, `noModelAvailable`, |
|
74 |
+`cancelled`. The static mapper `ProviderError.from(status:body:provider:)` maps |
|
75 |
+401/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). |
|
78 |
+ |
|
79 |
+### 1.2 Registry dispatch (`Providers/ProviderRegistry.swift`) |
|
80 |
+ |
|
81 |
+Dispatch key is `ProviderID.wireFormat`: |
|
82 |
+ |
|
83 |
+```swift |
|
84 |
+enum WireFormat: String, Codable { case openAIChatCompletions; case anthropicMessages } |
|
85 |
+``` |
|
86 |
+ |
|
87 |
+Only `.anthropic` → `AnthropicClient`. **All 11 other providers + `.custom` → |
|
88 |
+`OpenAICompatibleClient(provider:baseURLOverride:)`.** Clients are stateless structs |
|
89 |
+instantiated per call; the only shared state is `StreamingService.session`. |
|
90 |
+ |
|
91 |
+> **There is NO `GeminiClient` in Cloud.** Gemini is served through the |
|
92 |
+> `OpenAICompatibleClient` via Google's OpenAI-compat endpoint. Atlas's planned |
|
93 |
+> `Providers/GeminiClient.swift` is therefore **dropped** from the Phase 2 layout — |
|
94 |
+> creating one would re-invent a request format, which CLAUDE.md forbids. |
|
95 |
+ |
|
96 |
+`ProviderID` enum: `openai, anthropic, xai, mistral, gemini, qwen, deepseek, kimi, |
|
97 |
+perplexity, together, deepinfra, cerebras, custom`. `supportsModelListing == (self != .perplexity)`. |
|
98 |
+ |
|
99 |
+--- |
|
100 |
+ |
|
101 |
+## 2. Per-provider wiring (the exact call contract) |
|
102 |
+ |
|
103 |
+Common OpenAI-compat path: `POST {base}/chat/completions`, `GET {base}/models`, |
|
104 |
+`Authorization: Bearer <key>`. URLs built with `appendingPathComponent` so multi-segment |
|
105 |
+bases (`/compatible-mode/v1`, `/v1beta/openai`, `/v1/openai`) survive. |
|
106 |
+ |
|
107 |
+`stream_options: {"include_usage": true}` is sent ONLY for: openai, xai, gemini, |
|
108 |
+deepseek, kimi, together, cerebras, custom. NOT for mistral (rejects unknown params), |
|
109 |
+qwen, deepinfra, perplexity (usage automatic). |
|
110 |
+ |
|
111 |
+| 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 | — | |
|
126 |
+ |
|
127 |
+**Reasoning unification:** `reasoning_content` (DeepSeek/Qwen/Kimi/DeepInfra), |
|
128 |
+`reasoning` (Together), Mistral chunk arrays, and Anthropic `thinking_delta` all surface |
|
129 |
+as `ChatEvent.reasoningDelta` — Atlas UI handles one event, not four formats. |
|
130 |
+ |
|
131 |
+**Vision:** OpenAI-compat sends `[{type:"text"},{type:"image_url","image_url":{"url":"data:<mime>;base64,…"}}]` |
|
132 |
+only when `model.capabilities.vision && role == .user && has images`; Anthropic uses |
|
133 |
+`{"type":"image","source":{"type":"base64",…}}` blocks. Text attachments are inlined |
|
134 |
+into message text fenced with the filename (identical in both clients). |
|
135 |
+ |
|
136 |
+--- |
|
137 |
+ |
|
138 |
+## 3. Model catalog — ALL 170 models ship in Atlas |
|
139 |
+ |
|
140 |
+`Services/ModelCatalogData.swift` (1364 lines): **170 built-in chat models**, generated |
|
141 |
+from `zyquo-cloud/docs/PROVIDERS.md` + `docs/research/` (dated 2026-07-30, live-probed). |
|
142 |
+Scope: chat-completions-capable models only (embeddings/audio/image/Responses-only excluded). |
|
143 |
+ |
|
144 |
+Per-provider counts: OpenAI 27 · Anthropic 11 · xAI 5 · Mistral 10 · Gemini 14 · |
|
145 |
+Qwen 32 · DeepSeek 2 · Kimi 12 · Perplexity 4 · Together 16 · DeepInfra 34 · Cerebras 3. |
|
146 |
+29 recommended, 34 with thinking toggle, 8 requiring streaming, 34 legacy-flagged. |
|
147 |
+ |
|
148 |
+`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`. |
|
153 |
+ |
|
154 |
+`ModelCatalog` (`@MainActor ObservableObject`): `builtIn`, user `customModels` |
|
155 |
+(custom ID + base URL → `.custom` provider), `liveModelIDs` from `/models` refresh, |
|
156 |
+favorites, ranked `models(for:)` (favorite < recommended < normal < legacy), |
|
157 |
+`cheapestModel(for:)` (used for key tests; Atlas reuses it for cheap actions like |
|
158 |
+hover-summaries), `defaultModel`, `unknownLiveIDs(for:)`. |
|
159 |
+ |
|
160 |
+**Atlas usage:** the full catalog is exposed everywhere a model picker appears |
|
161 |
+(sidebar chat, per-action defaults). Atlas adds per-action default-model preferences |
|
162 |
+*around* `ModelCatalog` (fast/cheap for hovers, strong for deep chat) — never inside it. |
|
163 |
+ |
|
164 |
+--- |
|
165 |
+ |
|
166 |
+## 4. SecureKeyStore — the encrypted vault (deliberately NOT Keychain) |
|
167 |
+ |
|
168 |
+`Services/SecureKeyStore.swift` (CryptoKit + IOKit + Security): |
|
169 |
+ |
|
170 |
+- **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()` (machine |
|
174 |
+ entropy) ‖ compiled-in pepper (30 bytes, XOR-obfuscated, assembled at runtime), with |
|
175 |
+ the vault's salt and info `"ZyquoCloud.vault.v1"` → AES-256-GCM 256-bit key. The vault |
|
176 |
+ 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. |
|
183 |
+ |
|
184 |
+`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-provider |
|
186 |
+`KeyStatus` (unset/saved/testing/verified(latency)/failed). |
|
187 |
+ |
|
188 |
+**Atlas decision (implement in Phase 3):** same format + configurable `vaultURL`, |
|
189 |
+defaulting to `~/Library/Application Support/ZyquoAtlas/vault.zq` with info |
|
190 |
+`"ZyquoAtlas.vault.v1"`, PLUS a one-time **"Import keys from Zyquo Cloud"** that reads |
|
191 |
+Cloud's vault using Cloud's exact derivation constants (same pepper, info |
|
192 |
+`"ZyquoCloud.vault.v1"`, Cloud's path) and re-encrypts into Atlas's vault. This delivers |
|
193 |
+"same keys as Cloud" without cross-app file coupling. Never print/log the decoded pepper |
|
194 |
+or any key. |
|
195 |
+ |
|
196 |
+--- |
|
197 |
+ |
|
198 |
+## 5. Streaming (`Services/StreamingService.swift`) |
|
199 |
+ |
|
200 |
+- `SSEParser`: incremental line-based parser — blank line = event boundary; `:`-prefixed |
|
201 |
+ 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 the |
|
206 |
+ mapped `ProviderError`. **Manual byte-level line splitting on `0x0A`** (AsyncBytes.lines |
|
207 |
+ skips the empty lines that ARE the SSE separators); trailing partial line + synthetic |
|
208 |
+ 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` with |
|
213 |
+ `continuation.onTermination = { _ in task.cancel() }` — **dropping the consuming |
|
214 |
+ for-await loop cancels the inner Task and the network stream.** Atlas's |
|
215 |
+ 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-provider |
|
218 |
+ unknown-field tolerance; Perplexity citations emitted exactly once. |
|
219 |
+ |
|
220 |
+Consumption pattern to copy into Atlas's `AIService` (from Cloud's `ConversationStore`): |
|
221 |
+ |
|
222 |
+```swift |
|
223 |
+let apiKey = try vault.apiKey(for: model.provider) // decrypt on demand, drop after |
|
224 |
+let client = ProviderRegistry.client(for: model) |
|
225 |
+streamTask = Task { |
|
226 |
+ for try await event in client.streamChat(request, apiKey: apiKey) { … } |
|
227 |
+} |
|
228 |
+// Stop / navigation: streamTask?.cancel() |
|
229 |
+``` |
|
230 |
+ |
|
231 |
+--- |
|
232 |
+ |
|
233 |
+## 6. Port plan (file-by-file) |
|
234 |
+ |
|
235 |
+Every ported file's header changes `// Zyquo Cloud` → `// Zyquo Atlas`; author/mail |
|
236 |
+lines unchanged. The set below compiles standalone with Foundation + CryptoKit + IOKit + |
|
237 |
+Security — no SwiftUI, no swift-markdown. |
|
238 |
+ |
|
239 |
+| # | 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 | |
|
257 |
+ |
|
258 |
+**Behavioral invariants (the "identical" contract):** wire bodies byte-equivalent per |
|
259 |
+provider (param gating via `ParameterSupport`, `max_tokens` vs `max_completion_tokens`, |
|
260 |
+Mistral effort remap, Qwen `enable_thinking` stream-only, the `stream_options` allowlist); |
|
261 |
+Anthropic headers + mandatory `max_tokens` + thinking budget 8000; SSE parser semantics |
|
262 |
+(blank-line boundaries, comment tolerance, `[DONE]`, trailing flush); the error-mapping |
|
263 |
+table; retry policy (3 attempts, 429/5xx, Retry-After or 4s/8s); cancellation via |
|
264 |
+`onTermination → task.cancel()`; and key handling (decrypt on demand, never retain, |
|
265 |
+redact to last 4, never log). |
|
266 |
+ |
|
267 |
+**Verification harness:** Cloud's `Verify/VerifyHarness.swift` (`--verify`) is the |
|
268 |
+pattern for Atlas's Phase 7 AI-verification: `/models` diff, "OK" completion on every |
|
269 |
+catalog model, streaming test, vision test; serial sweeps for Cerebras (5 req/min) and |
|
270 |
+Mistral; env keys from `.env.keys`; results table written to `docs/VERIFICATION.md`. |
|
271 |
+Atlas extends it with browser-specific actions (omnibox ask, summarize page, |
|
272 |
+chat-with-page, selection actions, writing assist, multi-tab compare). |
|
273 |
|