# Provider Reuse — Porting the Native Zyquo Cloud Provider Layer (Phase 0.B) Source of truth: the native macOS repo at `~/Desktop/zyquo-cloud` (studied 2026-07-31: `ProviderID.swift`, `ProviderRegistry.swift`, `OpenAICompatibleClient.swift`, `AnthropicClient.swift`, `StreamingService.swift`, `ProviderProtocol.swift`, `AIModel.swift`, `ModelCatalog.swift`, `ModelCatalogData.swift`, `ZyquoTheme.swift`, Views/). The web app re-implements this layer 1:1 on browser `fetch` + `ReadableStream` + `AbortController`; it never re-invents request formats. ## 1. Architecture to reproduce Native shape → web shape: | Native (Swift) | Web (TS) | |---|---| | `ProviderID` enum (12 + custom) | `Provider` union type + metadata table (`providers/registry.ts`) | | `WireFormat` (`openAIChatCompletions` \| `anthropicMessages`) | same two-value union | | `ProviderRegistry.client(for:)` | `getClient(model)` — Anthropic → `anthropic.ts`, everything else → `openaiCompatible.ts` | | `OpenAICompatibleClient` (one client, 11 providers + custom) | `providers/openaiCompatible.ts` | | `AnthropicClient` (native Messages API) | `providers/anthropic.ts` | | `StreamingService` + `SSEParser` | `providers/sse.ts` (fetch + ReadableStream line splitter + SSE state machine) | | `ChatRequest` / `ChatEvent` / `ProviderClient` protocol | `providers/types.ts` (`ChatRequest`, `ChatEvent`, `ProviderClient` interface: `streamChat`, `complete`, `listModelIDs`, `testKey`) | | `ModelCatalogData.all` (170 models) | `providers/catalog.ts` (typed, complete port) | | `ProviderError` (typed, human messages) | `ProviderError` class with the same cases + messages | **Key finding:** only TWO wire formats exist. 11 of 12 providers (OpenAI, xAI, Mistral, **Gemini via its OpenAI-compat endpoint**, Qwen/DashScope, DeepSeek, Kimi, Perplexity, Together, DeepInfra, Cerebras) speak OpenAI `/chat/completions`; Anthropic alone speaks `/v1/messages`. The native app has no separate Gemini `generateContent` client — Gemini rides the compat endpoint with `Authorization: Bearer `, and the web app does the same. ## 2. Base URLs & auth (exact, from `ProviderID.defaultBaseURL`) | Provider | Base URL | Auth | |---|---|---| | openai | `https://api.openai.com/v1` | `Authorization: Bearer` | | anthropic | `https://api.anthropic.com/v1` | `x-api-key` + `anthropic-version: 2023-06-01` + **(web only)** `anthropic-dangerous-direct-browser-access: true` | | xai | `https://api.x.ai/v1` | Bearer | | mistral | `https://api.mistral.ai/v1` | Bearer | | gemini | `https://generativelanguage.googleapis.com/v1beta/openai` | Bearer (the API key) | | qwen | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | Bearer | | deepseek | `https://api.deepseek.com` | Bearer | | kimi | `https://api.moonshot.ai/v1` | Bearer | | perplexity | `https://api.perplexity.ai` | Bearer | | together | `https://api.together.xyz/v1` | Bearer | | deepinfra | `https://api.deepinfra.com/v1/openai` | Bearer | | cerebras | `https://api.cerebras.ai/v1` | Bearer | Paths appended to the base: `chat/completions` and `models` (OpenAI-compat); `messages` and `models?limit=100` (Anthropic). Base path components must be preserved when joining (`…/compatible-mode/v1`, `…/v1beta/openai`). Per-provider base-URL override (proxy / Zyquo Router) plugs in exactly where `baseURLOverride` does natively. ## 3. OpenAI-compatible client — request construction rules Body fields (all optional unless noted): `model`, `messages`, `stream`, `stream_options.include_usage`, `temperature`, `top_p`, `max_tokens` **or** `max_completion_tokens`, `frequency_penalty`, `presence_penalty`, `reasoning_effort`, `enable_thinking`. Rules ported verbatim: 1. **System prompt** becomes the first message with `role: "system"`; stored messages with role `system` are skipped when building the array. 2. **Parameter gating:** a param is included only if the model's `ParameterSupport` allows it (providers reject unknown params). `usesMaxCompletionTokens` → send `max_completion_tokens` instead of `max_tokens` (OpenAI reasoning models, Kimi K-series, Cerebras). 3. **`stream_options: {include_usage: true}`** only for: openai, xai, gemini, deepseek, kimi, together, cerebras, custom. NOT for mistral (rejects unknown params), qwen/deepinfra/perplexity (usage included automatically). 4. **Mistral `reasoning_effort` mapping:** only accepts `"high"`/`"none"` — map `medium→high`, `low→none`. 5. **Qwen/DashScope `enable_thinking`:** only legal on **streaming** requests; omit on non-streaming. 6. **Vision:** user messages with images become `content: [{type:"text",text}, {type:"image_url", image_url:{url:"data:;base64,"}}]`; otherwise `content` is a plain string. Images only on user messages of vision-capable models. 7. **Text-file attachments** are injected inline into the message text, fenced: ` ```\n\n``` `. 8. **`requiresStreaming` models** (Qwen qwq/qvq, several Together-hosted): non-streaming `complete()` aggregates the stream instead. ### Streamed response parsing (SSE `data:` lines, `[DONE]` terminator) Per chunk (`choices[0]`): - `delta.reasoning_content ?? delta.reasoning` → reasoning delta (DeepSeek, Qwen, xAI, DeepInfra use `reasoning_content`; some use `reasoning`). - `delta.content ?? choices[0].text` → text delta (**Together quirk:** some models stream completions-style with token text in `choices[].text`). - **Mistral reasoning-model quirk:** `delta.content` may be an ARRAY of chunks `{type:"thinking"|"text", …}` — `thinking` chunks (each `{thinking:[{type:"text",text}]}`) join into reasoning; `text` chunks (`{text}`) join into content. - `finish_reason` → finished reason. - Top-level `citations` (array of URL strings) + `search_results` (`[{title,url}]`) → Perplexity numbered citations (emit once). - Top-level `usage` (`prompt_tokens`, `completion_tokens`, `completion_tokens_details.reasoning_tokens`) → usage event. - Malformed / unknown JSON chunks are tolerated and skipped (keep-alives). ### `/models` listing quirks - Together returns a **bare array**; everyone else wraps in `{"data": […]}`. - Gemini compat prefixes ids with `models/` — strip it. - Perplexity has **no** `/models` endpoint (`supportsModelListing = false`) — key testing there does a minimal non-streaming completion (`max_tokens: 16` minimum — probe-verified) against the provider's cheapest catalog model. ## 4. Anthropic client — Messages API rules - POST `…/v1/messages`; headers `x-api-key`, `anthropic-version: 2023-06-01`, plus browser header (CORS-MATRIX). **`max_tokens` is mandatory** (default 8192 when the user hasn't set one). - `system` is a top-level string param, never a message. - Message content is block-structured: image blocks `{type:"image", source:{type:"base64", media_type, data}}` come **before** the text block; empty text becomes `" "` (API rejects empty). - `thinking`: `{type:"enabled", budget_tokens: 8000}` / `{type:"disabled"}` for models with `thinkingToggle` (Claude Fable 5 has thinking always-on — no toggle sent). Claude 4.7+ models don't accept `temperature`/`top_p` (ParameterSupport encodes this per model). - **Named SSE events** (`event:` field): `message_start` (input usage) → `content_block_delta` (`delta.text` → text; `delta.thinking` → reasoning) → `message_delta` (output usage + `stop_reason`) → `message_stop`; `ping` and `content_block_start/stop` ignored; `error` events raise. Usage is emitted at stream end (input from start + output from delta). - Non-streaming: `content[]` blocks — join `type=="text"` for text, `type=="thinking"` for reasoning. ## 5. SSE parser + networking (from `SSEParser`/`StreamingService`) - Line-level state machine: accumulate `event:`/`data:` fields; a **blank line** dispatches the event (multi-`data:` lines join with `\n`); `:` comment lines are keep-alives (DeepSeek sends `: keep-alive`) — skip; `id:`/`retry:` ignored; strip one leading space after `data:`; handle `\r\n`; flush a trailing unterminated event at stream end. - Web implementation: `fetch(url, {signal})` → check `res.ok` (non-2xx: read full body, map via `ProviderError.from(status, body, provider)`) → pipe `res.body` through `TextDecoderStream` → split on newlines → feed the parser. - **Error mapping:** 401/403 → invalid key (names the provider); 429 → rate limited (+ `Retry-After` if present); 400/404/422 → bad request with the provider's message; else server error. Error bodies are shape-sniffed: `{error:{message}}`, `{error:"…"}`, `{message}`, `{detail}`, Gemini arrays. A fetch `TypeError` (no HTTP status) is surfaced as a network/CORS-shaped error with the proxy/Router hint. - **Retry:** non-streaming POSTs retry ×3 with exponential backoff (4s, 8s) on 429/5xx, honoring `Retry-After`. Streaming does not auto-retry. - Cancellation: `AbortController` per in-flight generation; abort → typed `cancelled` error (maps to native `onTermination → task.cancel()`). ## 6. Data model (ported to `types/`) - `AIModel`: `id` (exact wire ID), `provider`, `displayName`, `contextWindow`, `maxOutputTokens?`, `capabilities`, `pricing?`, `parameterSupport`, `isLegacy`, `isRecommended`, `customBaseURL?`. Context badge: `≥1M → "NM ctx"`, `≥1K → "NK ctx"`. - `ModelCapabilities`: `vision, tools, reasoning, streaming(=true), jsonMode, citations` (defaults false). - `ModelPricing`: `inputPerMTok/outputPerMTok` USD; `cost(in,out)` = base-rate estimate (cached/tiered pricing intentionally simplified; UI labels costs as estimates). - `ParameterSupport`: `temperature(+), topP(+), frequencyPenalty(−), presencePenalty(−), usesMaxCompletionTokens(−), reasoningEffort(−), thinkingToggle(−), requiresStreaming(−)`; preset `openAIDefault` adds both penalties. - `TokenUsage`: `inputTokens, outputTokens, reasoningTokens?` (+ addition). - `ChatEvent`: `reasoningDelta | textDelta | citations | usage | finished`. - `Citation`: `index (1-based), url, title?` (merge Perplexity `citations` + `search_results`). ## 7. Model catalog (ported to `providers/catalog.ts`) **170 models** ported completely and faithfully from `ModelCatalogData.swift` (NOT from the drifted `docs/research/catalog-summary.md` which claims 195): openai 27, anthropic 11, xai 5, mistral 10, gemini 14, qwen 32, deepseek 2, kimi 12, perplexity 4, together 16, deepinfra 34, cerebras 3. 23 recommended models; legacies badged and ranked last. The full per-model data (IDs, names, context, max output, capabilities, pricing, parameter support, flags) is transcribed 1:1 in `catalog.ts` — that file IS the appendix. Catalog behavior ported from `ModelCatalog.swift`: - Ranking in pickers: favorites (0) → recommended (1) → normal (2) → legacy (3). - **Lookups keyed on `(provider, id)`** — ids duplicate across providers (e.g. `deepseek-v4-flash` exists under both deepseek and qwen; `moonshotai/Kimi-K2.6` under together and deepinfra). - `cheapestModel(provider)`: non-legacy, prefer non-reasoning, min `outputPerMTok` (nil → ∞) — used for key tests and title generation. - `defaultModel`: first recommended in catalog order → `gpt-5.6-sol`. - Dynamic `/models` refresh overlays a `liveModelIDs` set per provider (never mutates built-ins); unknown live IDs are offered as custom-model candidates. ## 8. Design tokens (ported to `design/` as CSS variables — from `ZyquoTheme.swift`) | Token | Light | Dark | |---|---|---| | background | `#FAFBFD` | `#14161E` | | surface | `#FFFFFF` | `#1C1F2A` | | surfaceSecondary | `#F2F4F8` | `#232734` | | accent | `#4E6AF0` | `#6D84F5` | | accentSubtle | `#EBEFFD` | `#28304C` | | textPrimary | `#1A1C22` | `#E8EAF2` | | textSecondary | `#6B7080` | `#9BA1B5` | | textTertiary | `#9EA3B0` | `#6A7188` | | border (0.5px hairlines) | `#E4E7EE` | `#2C3040` | | success / warning / danger | `#2FA36B` / `#D9822B` / `#D64545` | `#43BD83` / `#E59A4D` / `#E36363` | Typography: system font stack (SF Pro on Apple); title 20/semibold; body 13.5px default (user-adjustable 12–18), line-height **1.45**; caption 11; code mono 12.5. Spacing 4/8/12/16/20/24/32. Radii 6/10/14. Shadow: soft only (`rgba(0,0,0,0.06)`, blur 12, y 2) on floating elements. Metrics: sidebar 260, chat header 52, max message column 760, hairline 0.5. Motion: hover 80ms, send/message-in 150ms ease-out, pressed scale 0.97. Full native UX study (layout, picker, bubbles, settings, palette, compare, personas, prompt library, store behaviors, brand glyph) → `docs/NATIVE-UX.md`. ## 9. Vault → localStorage (explicit difference) The native app stores keys in a machine-bound encrypted vault (`SecureKeyStore`). A browser has no equivalent primitive, so the web edition stores keys in **plain `localStorage`** (`zyquo.cloud.web.keys`), with a clear first-run notice, masked display, and an **opt-in passphrase lock** (WebCrypto AES-GCM, key derived from the passphrase) — honestly framed: once unlocked, a compromised page can still read keys. This is a deliberate bring-your-own-key local-only model, not a secure vault.