SPB Git

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%
13.0 KB

# 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 <API key>, 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:<mime>;base64,<data>"}}]; 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: ```<fileName>\n<contents>\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.