SPB Git

spb/zyquo-atlas Public License

The AI-native macOS web browser — every surface, intelligent.

Swift 75.2% JavaScript 22% Shell 2% Makefile 0.9%
16.2 KB

# 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<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:

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 .anthropicAnthropicClient. All 11 other providers + .customOpenAICompatibleClient(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 <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.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<SHA256> 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; CancellationErrorProviderError.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.swiftChatParameters 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).