spb/zyquo-cloud-web Public MIT
Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.
TypeScript 81.9%
CSS 8.9%
JavaScript 7.5%
Shell 1.1%
HTML 0.6%
1<!--2 PROVIDER-REUSE.md3 Zyquo Cloud Web45 Author: Simon-Pierre Boucher6 Mail: contact@spboucher.ai7-->89# Provider Reuse — Porting the Native Zyquo Cloud Provider Layer (Phase 0.B)1011Source of truth: the native macOS repo at `~/Desktop/zyquo-cloud` (studied122026-07-31: `ProviderID.swift`, `ProviderRegistry.swift`,13`OpenAICompatibleClient.swift`, `AnthropicClient.swift`, `StreamingService.swift`,14`ProviderProtocol.swift`, `AIModel.swift`, `ModelCatalog.swift`,15`ModelCatalogData.swift`, `ZyquoTheme.swift`, Views/). The web app re-implements16this layer 1:1 on browser `fetch` + `ReadableStream` + `AbortController`; it17never re-invents request formats.1819## 1. Architecture to reproduce2021Native shape → web shape:2223| Native (Swift) | Web (TS) |24|---|---|25| `ProviderID` enum (12 + custom) | `Provider` union type + metadata table (`providers/registry.ts`) |26| `WireFormat` (`openAIChatCompletions` \| `anthropicMessages`) | same two-value union |27| `ProviderRegistry.client(for:)` | `getClient(model)` — Anthropic → `anthropic.ts`, everything else → `openaiCompatible.ts` |28| `OpenAICompatibleClient` (one client, 11 providers + custom) | `providers/openaiCompatible.ts` |29| `AnthropicClient` (native Messages API) | `providers/anthropic.ts` |30| `StreamingService` + `SSEParser` | `providers/sse.ts` (fetch + ReadableStream line splitter + SSE state machine) |31| `ChatRequest` / `ChatEvent` / `ProviderClient` protocol | `providers/types.ts` (`ChatRequest`, `ChatEvent`, `ProviderClient` interface: `streamChat`, `complete`, `listModelIDs`, `testKey`) |32| `ModelCatalogData.all` (170 models) | `providers/catalog.ts` (typed, complete port) |33| `ProviderError` (typed, human messages) | `ProviderError` class with the same cases + messages |3435**Key finding:** only TWO wire formats exist. 11 of 12 providers (OpenAI, xAI,36Mistral, **Gemini via its OpenAI-compat endpoint**, Qwen/DashScope, DeepSeek,37Kimi, Perplexity, Together, DeepInfra, Cerebras) speak OpenAI38`/chat/completions`; Anthropic alone speaks `/v1/messages`. The native app has39no separate Gemini `generateContent` client — Gemini rides the compat endpoint40with `Authorization: Bearer <API key>`, and the web app does the same.4142## 2. Base URLs & auth (exact, from `ProviderID.defaultBaseURL`)4344| Provider | Base URL | Auth |45|---|---|---|46| openai | `https://api.openai.com/v1` | `Authorization: Bearer` |47| anthropic | `https://api.anthropic.com/v1` | `x-api-key` + `anthropic-version: 2023-06-01` + **(web only)** `anthropic-dangerous-direct-browser-access: true` |48| xai | `https://api.x.ai/v1` | Bearer |49| mistral | `https://api.mistral.ai/v1` | Bearer |50| gemini | `https://generativelanguage.googleapis.com/v1beta/openai` | Bearer (the API key) |51| qwen | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | Bearer |52| deepseek | `https://api.deepseek.com` | Bearer |53| kimi | `https://api.moonshot.ai/v1` | Bearer |54| perplexity | `https://api.perplexity.ai` | Bearer |55| together | `https://api.together.xyz/v1` | Bearer |56| deepinfra | `https://api.deepinfra.com/v1/openai` | Bearer |57| cerebras | `https://api.cerebras.ai/v1` | Bearer |5859Paths appended to the base: `chat/completions` and `models` (OpenAI-compat);60`messages` and `models?limit=100` (Anthropic). Base path components must be61preserved when joining (`…/compatible-mode/v1`, `…/v1beta/openai`).62Per-provider base-URL override (proxy / Zyquo Router) plugs in exactly where63`baseURLOverride` does natively.6465## 3. OpenAI-compatible client — request construction rules6667Body fields (all optional unless noted): `model`, `messages`, `stream`,68`stream_options.include_usage`, `temperature`, `top_p`, `max_tokens` **or**69`max_completion_tokens`, `frequency_penalty`, `presence_penalty`,70`reasoning_effort`, `enable_thinking`. Rules ported verbatim:71721. **System prompt** becomes the first message with `role: "system"`; stored73 messages with role `system` are skipped when building the array.742. **Parameter gating:** a param is included only if the model's75 `ParameterSupport` allows it (providers reject unknown params).76 `usesMaxCompletionTokens` → send `max_completion_tokens` instead of77 `max_tokens` (OpenAI reasoning models, Kimi K-series, Cerebras).783. **`stream_options: {include_usage: true}`** only for: openai, xai, gemini,79 deepseek, kimi, together, cerebras, custom. NOT for mistral (rejects unknown80 params), qwen/deepinfra/perplexity (usage included automatically).814. **Mistral `reasoning_effort` mapping:** only accepts `"high"`/`"none"` —82 map `medium→high`, `low→none`.835. **Qwen/DashScope `enable_thinking`:** only legal on **streaming** requests;84 omit on non-streaming.856. **Vision:** user messages with images become86 `content: [{type:"text",text}, {type:"image_url", image_url:{url:"data:<mime>;base64,<data>"}}]`;87 otherwise `content` is a plain string. Images only on user messages of88 vision-capable models.897. **Text-file attachments** are injected inline into the message text, fenced:90 ` ```<fileName>\n<contents>\n``` `.918. **`requiresStreaming` models** (Qwen qwq/qvq, several Together-hosted):92 non-streaming `complete()` aggregates the stream instead.9394### Streamed response parsing (SSE `data:` lines, `[DONE]` terminator)9596Per chunk (`choices[0]`):97- `delta.reasoning_content ?? delta.reasoning` → reasoning delta (DeepSeek,98 Qwen, xAI, DeepInfra use `reasoning_content`; some use `reasoning`).99- `delta.content ?? choices[0].text` → text delta (**Together quirk:** some100 models stream completions-style with token text in `choices[].text`).101- **Mistral reasoning-model quirk:** `delta.content` may be an ARRAY of chunks102 `{type:"thinking"|"text", …}` — `thinking` chunks (each103 `{thinking:[{type:"text",text}]}`) join into reasoning; `text` chunks104 (`{text}`) join into content.105- `finish_reason` → finished reason.106- Top-level `citations` (array of URL strings) + `search_results`107 (`[{title,url}]`) → Perplexity numbered citations (emit once).108- Top-level `usage` (`prompt_tokens`, `completion_tokens`,109 `completion_tokens_details.reasoning_tokens`) → usage event.110- Malformed / unknown JSON chunks are tolerated and skipped (keep-alives).111112### `/models` listing quirks113- Together returns a **bare array**; everyone else wraps in `{"data": […]}`.114- Gemini compat prefixes ids with `models/` — strip it.115- Perplexity has **no** `/models` endpoint (`supportsModelListing = false`) —116 key testing there does a minimal non-streaming completion (`max_tokens: 16`117 minimum — probe-verified) against the provider's cheapest catalog model.118119## 4. Anthropic client — Messages API rules120121- POST `…/v1/messages`; headers `x-api-key`, `anthropic-version: 2023-06-01`,122 plus browser header (CORS-MATRIX). **`max_tokens` is mandatory** (default123 8192 when the user hasn't set one).124- `system` is a top-level string param, never a message.125- Message content is block-structured: image blocks126 `{type:"image", source:{type:"base64", media_type, data}}` come **before** the127 text block; empty text becomes `" "` (API rejects empty).128- `thinking`: `{type:"enabled", budget_tokens: 8000}` / `{type:"disabled"}` for129 models with `thinkingToggle` (Claude Fable 5 has thinking always-on — no130 toggle sent). Claude 4.7+ models don't accept `temperature`/`top_p`131 (ParameterSupport encodes this per model).132- **Named SSE events** (`event:` field): `message_start` (input usage) →133 `content_block_delta` (`delta.text` → text; `delta.thinking` → reasoning) →134 `message_delta` (output usage + `stop_reason`) → `message_stop`; `ping` and135 `content_block_start/stop` ignored; `error` events raise. Usage is emitted at136 stream end (input from start + output from delta).137- Non-streaming: `content[]` blocks — join `type=="text"` for text,138 `type=="thinking"` for reasoning.139140## 5. SSE parser + networking (from `SSEParser`/`StreamingService`)141142- Line-level state machine: accumulate `event:`/`data:` fields; a **blank line**143 dispatches the event (multi-`data:` lines join with `\n`); `:` comment lines144 are keep-alives (DeepSeek sends `: keep-alive`) — skip; `id:`/`retry:`145 ignored; strip one leading space after `data:`; handle `\r\n`; flush a146 trailing unterminated event at stream end.147- Web implementation: `fetch(url, {signal})` → check `res.ok` (non-2xx: read148 full body, map via `ProviderError.from(status, body, provider)`) → pipe149 `res.body` through `TextDecoderStream` → split on newlines → feed the parser.150- **Error mapping:** 401/403 → invalid key (names the provider); 429 → rate151 limited (+ `Retry-After` if present); 400/404/422 → bad request with the152 provider's message; else server error. Error bodies are shape-sniffed:153 `{error:{message}}`, `{error:"…"}`, `{message}`, `{detail}`, Gemini arrays.154 A fetch `TypeError` (no HTTP status) is surfaced as a network/CORS-shaped155 error with the proxy/Router hint.156- **Retry:** non-streaming POSTs retry ×3 with exponential backoff (4s, 8s) on157 429/5xx, honoring `Retry-After`. Streaming does not auto-retry.158- Cancellation: `AbortController` per in-flight generation; abort → typed159 `cancelled` error (maps to native `onTermination → task.cancel()`).160161## 6. Data model (ported to `types/`)162163- `AIModel`: `id` (exact wire ID), `provider`, `displayName`, `contextWindow`,164 `maxOutputTokens?`, `capabilities`, `pricing?`, `parameterSupport`,165 `isLegacy`, `isRecommended`, `customBaseURL?`. Context badge: `≥1M → "NM ctx"`,166 `≥1K → "NK ctx"`.167- `ModelCapabilities`: `vision, tools, reasoning, streaming(=true), jsonMode,168 citations` (defaults false).169- `ModelPricing`: `inputPerMTok/outputPerMTok` USD; `cost(in,out)` = base-rate170 estimate (cached/tiered pricing intentionally simplified; UI labels costs as171 estimates).172- `ParameterSupport`: `temperature(+), topP(+), frequencyPenalty(−),173 presencePenalty(−), usesMaxCompletionTokens(−), reasoningEffort(−),174 thinkingToggle(−), requiresStreaming(−)`; preset `openAIDefault` adds both175 penalties.176- `TokenUsage`: `inputTokens, outputTokens, reasoningTokens?` (+ addition).177- `ChatEvent`: `reasoningDelta | textDelta | citations | usage | finished`.178- `Citation`: `index (1-based), url, title?` (merge Perplexity `citations` +179 `search_results`).180181## 7. Model catalog (ported to `providers/catalog.ts`)182183**170 models** ported completely and faithfully from `ModelCatalogData.swift`184(NOT from the drifted `docs/research/catalog-summary.md` which claims 195):185openai 27, anthropic 11, xai 5, mistral 10, gemini 14, qwen 32, deepseek 2,186kimi 12, perplexity 4, together 16, deepinfra 34, cerebras 3. 23 recommended187models; legacies badged and ranked last. The full per-model data (IDs, names,188context, max output, capabilities, pricing, parameter support, flags) is189transcribed 1:1 in `catalog.ts` — that file IS the appendix.190191Catalog behavior ported from `ModelCatalog.swift`:192- Ranking in pickers: favorites (0) → recommended (1) → normal (2) → legacy (3).193- **Lookups keyed on `(provider, id)`** — ids duplicate across providers194 (e.g. `deepseek-v4-flash` exists under both deepseek and qwen;195 `moonshotai/Kimi-K2.6` under together and deepinfra).196- `cheapestModel(provider)`: non-legacy, prefer non-reasoning, min197 `outputPerMTok` (nil → ∞) — used for key tests and title generation.198- `defaultModel`: first recommended in catalog order → `gpt-5.6-sol`.199- Dynamic `/models` refresh overlays a `liveModelIDs` set per provider (never200 mutates built-ins); unknown live IDs are offered as custom-model candidates.201202## 8. Design tokens (ported to `design/` as CSS variables — from `ZyquoTheme.swift`)203204| Token | Light | Dark |205|---|---|---|206| background | `#FAFBFD` | `#14161E` |207| surface | `#FFFFFF` | `#1C1F2A` |208| surfaceSecondary | `#F2F4F8` | `#232734` |209| accent | `#4E6AF0` | `#6D84F5` |210| accentSubtle | `#EBEFFD` | `#28304C` |211| textPrimary | `#1A1C22` | `#E8EAF2` |212| textSecondary | `#6B7080` | `#9BA1B5` |213| textTertiary | `#9EA3B0` | `#6A7188` |214| border (0.5px hairlines) | `#E4E7EE` | `#2C3040` |215| success / warning / danger | `#2FA36B` / `#D9822B` / `#D64545` | `#43BD83` / `#E59A4D` / `#E36363` |216217Typography: system font stack (SF Pro on Apple); title 20/semibold; body21813.5px default (user-adjustable 12–18), line-height **1.45**; caption 11;219code mono 12.5. Spacing 4/8/12/16/20/24/32. Radii 6/10/14. Shadow: soft only220(`rgba(0,0,0,0.06)`, blur 12, y 2) on floating elements. Metrics: sidebar 260,221chat header 52, max message column 760, hairline 0.5. Motion: hover 80ms,222send/message-in 150ms ease-out, pressed scale 0.97.223224Full native UX study (layout, picker, bubbles, settings, palette, compare,225personas, prompt library, store behaviors, brand glyph) → `docs/NATIVE-UX.md`.226227## 9. Vault → localStorage (explicit difference)228229The native app stores keys in a machine-bound encrypted vault230(`SecureKeyStore`). A browser has no equivalent primitive, so the web edition231stores keys in **plain `localStorage`** (`zyquo.cloud.web.keys`), with a clear232first-run notice, masked display, and an **opt-in passphrase lock**233(WebCrypto AES-GCM, key derived from the passphrase) — honestly framed: once234unlocked, a compromised page can still read keys. This is a deliberate235bring-your-own-key local-only model, not a secure vault.236