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:
- System prompt becomes the first message with
role: "system"; stored messages with rolesystemare skipped when building the array. - Parameter gating: a param is included only if the model's
ParameterSupportallows it (providers reject unknown params).usesMaxCompletionTokens→ sendmax_completion_tokensinstead ofmax_tokens(OpenAI reasoning models, Kimi K-series, Cerebras). 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).- Mistral
reasoning_effortmapping: only accepts"high"/"none"— mapmedium→high,low→none. - Qwen/DashScope
enable_thinking: only legal on streaming requests; omit on non-streaming. - Vision: user messages with images become
content: [{type:"text",text}, {type:"image_url", image_url:{url:"data:<mime>;base64,<data>"}}]; otherwisecontentis a plain string. Images only on user messages of vision-capable models. - Text-file attachments are injected inline into the message text, fenced:
```<fileName>\n<contents>\n```. requiresStreamingmodels (Qwen qwq/qvq, several Together-hosted): non-streamingcomplete()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 usereasoning_content; some usereasoning).delta.content ?? choices[0].text→ text delta (Together quirk: some models stream completions-style with token text inchoices[].text).- Mistral reasoning-model quirk:
delta.contentmay be an ARRAY of chunks{type:"thinking"|"text", …}—thinkingchunks (each{thinking:[{type:"text",text}]}) join into reasoning;textchunks ({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
/modelsendpoint (supportsModelListing = false) — key testing there does a minimal non-streaming completion (max_tokens: 16minimum — probe-verified) against the provider's cheapest catalog model.
4. Anthropic client — Messages API rules
- POST
…/v1/messages; headersx-api-key,anthropic-version: 2023-06-01, plus browser header (CORS-MATRIX).max_tokensis mandatory (default 8192 when the user hasn't set one). systemis 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 withthinkingToggle(Claude Fable 5 has thinking always-on — no toggle sent). Claude 4.7+ models don't accepttemperature/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;pingandcontent_block_start/stopignored;errorevents raise. Usage is emitted at stream end (input from start + output from delta). - Non-streaming:
content[]blocks — jointype=="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 afterdata:; handle\r\n; flush a trailing unterminated event at stream end. - Web implementation:
fetch(url, {signal})→ checkres.ok(non-2xx: read full body, map viaProviderError.from(status, body, provider)) → piperes.bodythroughTextDecoderStream→ split on newlines → feed the parser. - Error mapping: 401/403 → invalid key (names the provider); 429 → rate
limited (+
Retry-Afterif 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 fetchTypeError(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:
AbortControllerper in-flight generation; abort → typedcancellederror (maps to nativeonTermination → 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/outputPerMTokUSD;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(−); presetopenAIDefaultadds both penalties.TokenUsage:inputTokens, outputTokens, reasoningTokens?(+ addition).ChatEvent:reasoningDelta | textDelta | citations | usage | finished.Citation:index (1-based), url, title?(merge Perplexitycitations+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-flashexists under both deepseek and qwen;moonshotai/Kimi-K2.6under together and deepinfra). cheapestModel(provider): non-legacy, prefer non-reasoning, minoutputPerMTok(nil → ∞) — used for key tests and title generation.defaultModel: first recommended in catalog order →gpt-5.6-sol.- Dynamic
/modelsrefresh overlays aliveModelIDsset 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.