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%

phase2: browser provider layer — SSE parser, OpenAI-compat + Anthropic clients, 170-model catalog; streaming gate passed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 11 days ago (Jul 31, 2026) parent 26dd5bf

Showing 15 changed files with +2,258 and −3

modified docs/PLAN.md +19 −1
@@ -54,7 +54,25 @@ headers everywhere, ZyquoTheme tokens as CSS variables (light default + dark +
54 54 origins plus localhost for Zyquo Router, and an app-shell-only PWA. Build,
55 55 lint, and typecheck all green on first checkpoint.
56 56
57 ## Phase 2 — Architecture + browser provider layer — pending
57 +## Phase 2 — Architecture + browser provider layer
58 +
59 +- [x] `src/types/` — Provider, WireFormat, AIModel, capabilities/pricing/ParameterSupport, TokenUsage, Citation, Attachment, Message (+variants), Conversation, Settings
60 +- [x] `providers/types.ts` — ProviderClient interface, ChatRequest/ChatEvent, ProviderError (typed, human messages, CORS-shaped network hint)
61 +- [x] `providers/registry.ts` — 12-provider metadata (base URLs, auth, wire format, stream_options wants, /models support) + clientFor()
62 +- [x] `providers/sse.ts` — SSEParser state machine + fetch/ReadableStream event stream + retrying requestJSON (429/5xx backoff, Retry-After) + AbortController cancellation
63 +- [x] `providers/openaiCompatible.ts` — all native quirks ported (param gating, Mistral effort mapping + array-content reasoning, Qwen enable_thinking, Together choices[].text, Perplexity citations, Gemini models/ prefix, requiresStreaming aggregation, vision parts, text-file fencing)
64 +- [x] `providers/anthropic.ts` — Messages API (mandatory max_tokens, block content, named SSE events, thinking toggle, browser CORS header)
65 +- [x] `providers/catalog.ts` — ALL 170 models transcribed 1:1 (counts verified: 27/11/5/10/14/32/2/12/4/16/34/3) + rank/cheapest/default/find helpers
66 +- [x] `storage/keys.ts` — namespaced localStorage key map (gate prerequisite)
67 +- [x] PHASE GATE: real token-by-token streaming in a real Chromium browser via `#stream-test` — 26 incremental snapshots, usage `15→256 tok`, `finished (length)`, Stop → `cancelled: Generation stopped.` (scripts/gate-phase2.mjs)
68 +
69 +**Phase 2 summary.** The whole native provider layer now runs on browser
70 +fetch + ReadableStream: one OpenAI-compatible client (11 providers + custom),
71 +one Anthropic Messages client, one SSE parser for both stream shapes, and the
72 +complete 170-model catalog with per-model parameter gating. Gate passed against
73 +live OpenAI from headless Chromium: token-by-token rendering, usage events, and
74 +AbortController cancellation all verified. Playwright added (dev-only) as the
75 +browser harness that Phase 7 will reuse for the full matrix.
58 76
59 77 ## Phase 3 — Local persistence — pending
60 78
modified package-lock.json +48 −0
@@ -21,6 +21,7 @@
21 21 "@vitejs/plugin-react": "^4.6.0",
22 22 "eslint": "^9.30.0",
23 23 "eslint-plugin-react-hooks": "^5.2.0",
24 + "playwright": "^1.62.1",
24 25 "prettier": "^3.6.0",
25 26 "typescript": "^5.8.3",
26 27 "vite": "^7.0.0",
@@ -5957,6 +5958,53 @@
5957 5958 "url": "https://github.com/sponsors/jonschlinkert"
5958 5959 }
5959 5960 },
5961 + "node_modules/playwright": {
5962 + "version": "1.62.1",
5963 + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
5964 + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
5965 + "dev": true,
5966 + "license": "Apache-2.0",
5967 + "dependencies": {
5968 + "playwright-core": "1.62.1"
5969 + },
5970 + "bin": {
5971 + "playwright": "cli.js"
5972 + },
5973 + "engines": {
5974 + "node": ">=20"
5975 + },
5976 + "optionalDependencies": {
5977 + "fsevents": "2.3.2"
5978 + }
5979 + },
5980 + "node_modules/playwright-core": {
5981 + "version": "1.62.1",
5982 + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
5983 + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
5984 + "dev": true,
5985 + "license": "Apache-2.0",
5986 + "bin": {
5987 + "playwright-core": "cli.js"
5988 + },
5989 + "engines": {
5990 + "node": ">=20"
5991 + }
5992 + },
5993 + "node_modules/playwright/node_modules/fsevents": {
5994 + "version": "2.3.2",
5995 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
5996 + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
5997 + "dev": true,
5998 + "hasInstallScript": true,
5999 + "license": "MIT",
6000 + "optional": true,
6001 + "os": [
6002 + "darwin"
6003 + ],
6004 + "engines": {
6005 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
6006 + }
6007 + },
5960 6008 "node_modules/possible-typed-array-names": {
5961 6009 "version": "1.1.0",
5962 6010 "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
modified package.json +1 −0
@@ -27,6 +27,7 @@
27 27 "@vitejs/plugin-react": "^4.6.0",
28 28 "eslint": "^9.30.0",
29 29 "eslint-plugin-react-hooks": "^5.2.0",
30 + "playwright": "^1.62.1",
30 31 "prettier": "^3.6.0",
31 32 "typescript": "^5.8.3",
32 33 "vite": "^7.0.0",
added scripts/gate-phase2.mjs +85 −0
@@ -0,0 +1,85 @@
1 +/*
2 + * gate-phase2.mjs
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Phase 2 gate check: drives a real Chromium browser against the dev server,
9 + * seeds a provider key from .keys.local.json into localStorage, runs the bare
10 + * stream test view, and asserts the completion arrived TOKEN-BY-TOKEN
11 + * (multiple incremental output snapshots) with working Stop cancellation.
12 + * Usage: node scripts/gate-phase2.mjs [provider] [baseURL]
13 + */
14 +
15 +import { chromium } from 'playwright'
16 +import { readFileSync } from 'node:fs'
17 +import { fileURLToPath } from 'node:url'
18 +import { dirname, join } from 'node:path'
19 +
20 +const root = dirname(dirname(fileURLToPath(import.meta.url)))
21 +const keys = JSON.parse(readFileSync(join(root, '.keys.local.json'), 'utf8'))
22 +const provider = process.argv[2] ?? 'openai'
23 +const baseURL = process.argv[3] ?? 'http://localhost:5173'
24 +
25 +const browser = await chromium.launch()
26 +const page = await browser.newPage()
27 +
28 +await page.goto(baseURL)
29 +await page.evaluate(
30 + ([p, k]) => localStorage.setItem('zyquo.cloud.web.keys', JSON.stringify({ [p]: k })),
31 + [provider, keys[provider]]
32 +)
33 +await page.goto(`${baseURL}/#stream-test`)
34 +await page.reload()
35 +
36 +await page.selectOption('[data-testid="provider"]', provider)
37 +await page.fill('[data-testid="prompt"]', 'Write a 150-word paragraph about clouds.')
38 +await page.click('[data-testid="run"]')
39 +
40 +// Sample the output as it streams to prove token-by-token rendering.
41 +const snapshots = []
42 +for (let i = 0; i < 600; i++) {
43 + const status = (await page.textContent('[data-testid="status"]'))?.replace('status: ', '') ?? ''
44 + const output = await page.textContent('[data-testid="output"]')
45 + if (output && (snapshots.length === 0 || output !== snapshots[snapshots.length - 1])) {
46 + snapshots.push(output)
47 + }
48 + if (
49 + status.startsWith('finished') ||
50 + /invalidAPIKey|badRequest|serverError|networkError|rateLimited|invalidResponse/.test(status)
51 + ) {
52 + break
53 + }
54 + await page.waitForTimeout(50)
55 +}
56 +
57 +const status = await page.textContent('[data-testid="status"]')
58 +const tokens = await page.textContent('[data-testid="tokens"]')
59 +const finalOutput = snapshots[snapshots.length - 1] ?? ''
60 +
61 +console.log(`provider: ${provider}`)
62 +console.log(`status: ${status}`)
63 +console.log(`usage: ${tokens}`)
64 +console.log(`snapshots: ${snapshots.length} incremental output states`)
65 +console.log(`final out: ${finalOutput.slice(0, 120).replace(/\n/g, ' / ')}`)
66 +
67 +// Cancellation check: start a second stream and stop it immediately.
68 +await page.click('[data-testid="run"]')
69 +await page.waitForTimeout(700)
70 +await page.click('[data-testid="stop"]')
71 +await page.waitForTimeout(700)
72 +const stopStatus = await page.textContent('[data-testid="status"]')
73 +console.log(`stop test: ${stopStatus}`)
74 +
75 +await browser.close()
76 +
77 +const streamedIncrementally = snapshots.length >= 3
78 +const finished = status?.includes('finished')
79 +const stopped = stopStatus?.includes('cancelled') || stopStatus?.includes('stopped')
80 +if (streamedIncrementally && finished && stopped) {
81 + console.log('GATE PASS: real token-by-token streaming + cancellation in a real browser')
82 +} else {
83 + console.error('GATE FAIL')
84 + process.exit(1)
85 +}
modified src/App.tsx +4 −1
@@ -6,9 +6,12 @@
6 6 * Mail: contact@spboucher.ai
7 7 */
8 8
9 +import StreamTestView from './features/StreamTestView'
10 +
9 11 // Phase 1 placeholder shell — replaced by the real chat layout in Phase 4.
10 // Phase 2 mounts a bare streaming test view here to satisfy its phase gate.
12 +// #stream-test mounts the bare streaming view (Phase 2 gate; kept for Phase 7).
11 13 export default function App() {
14 + if (window.location.hash === '#stream-test') return <StreamTestView />
12 15 return (
13 16 <div
14 17 style={{
added src/features/StreamTestView.tsx +145 −0
@@ -0,0 +1,145 @@
1 +/*
2 + * StreamTestView.tsx
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Phase 2 gate: a bare test view that renders a REAL streaming completion
9 + * token-by-token against any configured provider, using a key from
10 + * localStorage. Reached via #stream-test. Superseded by the real chat UI in
11 + * Phase 4 but kept for verification (Phase 7 drives it programmatically).
12 + */
13 +
14 +import { useRef, useState } from 'react'
15 +import { cheapestModel, findModel, modelsFor } from '../providers/catalog'
16 +import { clientFor } from '../providers/registry'
17 +import { ProviderError } from '../providers/types'
18 +import { getKey, configuredProviders } from '../storage/keys'
19 +import { BUILT_IN_PROVIDERS, type Provider } from '../types'
20 +
21 +export default function StreamTestView() {
22 + const [provider, setProvider] = useState<Provider>('openai')
23 + const [modelID, setModelID] = useState('')
24 + const [prompt, setPrompt] = useState('Count from 1 to 10, one number per line.')
25 + const [output, setOutput] = useState('')
26 + const [reasoning, setReasoning] = useState('')
27 + const [status, setStatus] = useState('idle')
28 + const [tokens, setTokens] = useState('')
29 + const abortRef = useRef<AbortController | null>(null)
30 +
31 + const run = async () => {
32 + const key = getKey(provider)
33 + if (!key) {
34 + setStatus(`no key for ${provider} in localStorage`)
35 + return
36 + }
37 + const model =
38 + (modelID !== '' ? findModel(provider, modelID) : undefined) ??
39 + cheapestModel(provider) ??
40 + modelsFor(provider)[0]
41 + if (!model) {
42 + setStatus('no model')
43 + return
44 + }
45 + setOutput('')
46 + setReasoning('')
47 + setTokens('')
48 + setStatus(`streaming ${model.id}…`)
49 + const controller = new AbortController()
50 + abortRef.current = controller
51 + try {
52 + const client = clientFor(model)
53 + const events = client.streamChat(
54 + {
55 + model,
56 + messages: [
57 + {
58 + id: 't1',
59 + role: 'user',
60 + text: prompt,
61 + createdAt: Date.now(),
62 + },
63 + ],
64 + parameters: { maxTokens: 256 },
65 + stream: true,
66 + },
67 + key,
68 + controller.signal
69 + )
70 + for await (const event of events) {
71 + if (event.type === 'textDelta') setOutput((prev) => prev + event.text)
72 + else if (event.type === 'reasoningDelta') setReasoning((prev) => prev + event.text)
73 + else if (event.type === 'usage')
74 + setTokens(`${event.usage.inputTokens}→${event.usage.outputTokens} tok`)
75 + else if (event.type === 'finished') setStatus(`finished (${event.reason ?? 'n/a'})`)
76 + }
77 + } catch (err) {
78 + setStatus(err instanceof ProviderError ? `${err.kind}: ${err.message}` : String(err))
79 + } finally {
80 + abortRef.current = null
81 + }
82 + }
83 +
84 + return (
85 + <div
86 + style={{
87 + padding: 'var(--z-space-xl)',
88 + maxWidth: 720,
89 + margin: '0 auto',
90 + height: '100%',
91 + overflow: 'auto',
92 + fontFamily: 'var(--z-font-mono)',
93 + fontSize: 12.5,
94 + }}
95 + >
96 + <h2 style={{ fontFamily: 'var(--z-font-sans)' }}>Zyquo Cloud Web — stream test</h2>
97 + <p data-testid="configured">configured: {configuredProviders().join(', ') || '(none)'}</p>
98 + <div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
99 + <select
100 + data-testid="provider"
101 + value={provider}
102 + onChange={(e) => setProvider(e.target.value as Provider)}
103 + >
104 + {BUILT_IN_PROVIDERS.map((p) => (
105 + <option key={p} value={p}>
106 + {p}
107 + </option>
108 + ))}
109 + </select>
110 + <input
111 + data-testid="model"
112 + placeholder="model id (blank = cheapest)"
113 + value={modelID}
114 + onChange={(e) => setModelID(e.target.value)}
115 + style={{ width: 260 }}
116 + />
117 + <input
118 + data-testid="prompt"
119 + value={prompt}
120 + onChange={(e) => setPrompt(e.target.value)}
121 + style={{ width: 320 }}
122 + />
123 + <button data-testid="run" onClick={() => void run()}>
124 + Stream
125 + </button>
126 + <button data-testid="stop" onClick={() => abortRef.current?.abort()}>
127 + Stop
128 + </button>
129 + </div>
130 + <p data-testid="status">status: {status}</p>
131 + <p data-testid="tokens">{tokens}</p>
132 + {reasoning !== '' && (
133 + <pre
134 + data-testid="reasoning"
135 + style={{ whiteSpace: 'pre-wrap', color: 'var(--z-text-tertiary)' }}
136 + >
137 + {reasoning}
138 + </pre>
139 + )}
140 + <pre data-testid="output" style={{ whiteSpace: 'pre-wrap' }}>
141 + {output}
142 + </pre>
143 + </div>
144 + )
145 +}
added src/providers/anthropic.ts +251 −0
@@ -0,0 +1,251 @@
1 +/*
2 + * anthropic.ts
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Native Anthropic Messages API client (/v1/messages) — NOT OpenAI-compatible.
9 + * Ported 1:1 from AnthropicClient.swift. Auth: x-api-key + anthropic-version,
10 + * plus the browser CORS opt-in header (see docs/CORS-MATRIX.md). System prompt
11 + * is a top-level param, content is block-structured, max_tokens is mandatory,
12 + * streaming uses named SSE events.
13 + */
14 +
15 +import type { Citation, Message, Provider, TokenUsage } from '../types'
16 +import { PROVIDER_META } from './registry'
17 +import { joinURL, requestJSON, sseEvents } from './sse'
18 +import {
19 + ProviderError,
20 + type ChatEvent,
21 + type ChatRequest,
22 + type CompletionResult,
23 + type ProviderClient,
24 +} from './types'
25 +
26 +const API_VERSION = '2023-06-01'
27 +const DEFAULT_MAX_TOKENS = 8192
28 +
29 +// --- Wire types ---
30 +
31 +type WireBlock =
32 + | { type: 'text'; text: string }
33 + | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
34 +
35 +interface WireRequestBody {
36 + model: string
37 + max_tokens: number
38 + messages: { role: string; content: WireBlock[] }[]
39 + system?: string
40 + stream?: boolean
41 + temperature?: number
42 + top_p?: number
43 + thinking?: { type: 'enabled'; budget_tokens: number } | { type: 'disabled' }
44 +}
45 +
46 +interface WireStreamEvent {
47 + type?: string
48 + delta?: { type?: string; text?: string; thinking?: string; stop_reason?: string }
49 + usage?: { input_tokens?: number; output_tokens?: number }
50 + message?: { usage?: { input_tokens?: number; output_tokens?: number } }
51 + error?: { message?: string }
52 +}
53 +
54 +interface WireResponse {
55 + content?: { type?: string; text?: string; thinking?: string }[]
56 + usage?: { input_tokens?: number; output_tokens?: number }
57 + stop_reason?: string
58 +}
59 +
60 +export class AnthropicClient implements ProviderClient {
61 + readonly provider: Provider = 'anthropic'
62 +
63 + private baseURL(request?: { baseURLOverride?: string }): string {
64 + const base = request?.baseURLOverride ?? PROVIDER_META.anthropic.defaultBaseURL
65 + if (!base) throw ProviderError.invalidResponse(this.provider, 'no base URL configured')
66 + return base
67 + }
68 +
69 + private headers(apiKey: string): Record<string, string> {
70 + return {
71 + 'x-api-key': apiKey,
72 + 'anthropic-version': API_VERSION,
73 + // Anthropic's documented browser CORS opt-in. The name is Anthropic's
74 + // deliberate reminder that browser-resident keys are user-visible —
75 + // which is this app's transparent bring-your-own-key model.
76 + 'anthropic-dangerous-direct-browser-access': 'true',
77 + 'Content-Type': 'application/json',
78 + }
79 + }
80 +
81 + private buildBody(request: ChatRequest): WireRequestBody {
82 + const messages: WireRequestBody['messages'] = []
83 + for (const message of request.messages) {
84 + if (message.role === 'system') continue
85 + messages.push(wireMessage(message, request.model.capabilities.vision))
86 + }
87 + const params = request.parameters
88 + const body: WireRequestBody = {
89 + model: request.model.id,
90 + max_tokens: params.maxTokens ?? request.model.maxOutputTokens ?? DEFAULT_MAX_TOKENS,
91 + messages,
92 + }
93 + if (request.systemPrompt && request.systemPrompt !== '') body.system = request.systemPrompt
94 + if (request.stream) body.stream = true
95 + // Claude 4.7+ removed temperature/top_p; ParameterSupport encodes that per model.
96 + const support = request.model.parameterSupport
97 + if (support.temperature && params.temperature !== undefined) {
98 + body.temperature = params.temperature
99 + }
100 + if (support.topP && params.topP !== undefined) body.top_p = params.topP
101 + if (support.thinkingToggle && params.thinkingEnabled !== undefined) {
102 + body.thinking = params.thinkingEnabled
103 + ? { type: 'enabled', budget_tokens: 8000 }
104 + : { type: 'disabled' }
105 + }
106 + return body
107 + }
108 +
109 + async *streamChat(
110 + request: ChatRequest,
111 + apiKey: string,
112 + signal?: AbortSignal
113 + ): AsyncGenerator<ChatEvent> {
114 + const url = joinURL(this.baseURL(request), 'messages')
115 + const body = this.buildBody({ ...request, stream: true })
116 +
117 + const usage: TokenUsage = { inputTokens: 0, outputTokens: 0 }
118 + let stopReason: string | null = null
119 +
120 + for await (const sse of sseEvents(
121 + url,
122 + { headers: this.headers(apiKey), body: JSON.stringify(body) },
123 + this.provider,
124 + signal
125 + )) {
126 + let event: WireStreamEvent
127 + try {
128 + event = JSON.parse(sse.data) as WireStreamEvent
129 + } catch {
130 + continue
131 + }
132 + const type = sse.event ?? event.type ?? ''
133 + switch (type) {
134 + case 'message_start':
135 + usage.inputTokens = event.message?.usage?.input_tokens ?? 0
136 + break
137 + case 'content_block_delta':
138 + if (event.delta?.text) yield { type: 'textDelta', text: event.delta.text }
139 + if (event.delta?.thinking) yield { type: 'reasoningDelta', text: event.delta.thinking }
140 + break
141 + case 'message_delta':
142 + usage.outputTokens = event.usage?.output_tokens ?? usage.outputTokens
143 + if (event.delta?.stop_reason) stopReason = event.delta.stop_reason
144 + break
145 + case 'error':
146 + throw ProviderError.serverError(this.provider, 200, event.error?.message ?? null)
147 + case 'message_stop':
148 + break
149 + default:
150 + break // ping, content_block_start/stop, unknown future events
151 + }
152 + }
153 + yield { type: 'usage', usage }
154 + yield { type: 'finished', reason: stopReason }
155 + }
156 +
157 + async complete(
158 + request: ChatRequest,
159 + apiKey: string,
160 + signal?: AbortSignal
161 + ): Promise<CompletionResult> {
162 + const url = joinURL(this.baseURL(request), 'messages')
163 + const body = this.buildBody({ ...request, stream: false })
164 + const responseText = await requestJSON(
165 + url,
166 + { method: 'POST', headers: this.headers(apiKey), body: JSON.stringify(body) },
167 + this.provider,
168 + signal
169 + )
170 + let response: WireResponse
171 + try {
172 + response = JSON.parse(responseText) as WireResponse
173 + } catch {
174 + throw ProviderError.invalidResponse(this.provider, 'undecodable messages response')
175 + }
176 + const blocks = response.content ?? []
177 + const text = blocks
178 + .filter((b) => b.type === 'text')
179 + .map((b) => b.text ?? '')
180 + .join('')
181 + const thinking = blocks
182 + .filter((b) => b.type === 'thinking')
183 + .map((b) => b.thinking ?? '')
184 + .join('')
185 + const result: CompletionResult = { text }
186 + if (thinking !== '') result.reasoning = thinking
187 + if (response.usage) {
188 + result.usage = {
189 + inputTokens: response.usage.input_tokens ?? 0,
190 + outputTokens: response.usage.output_tokens ?? 0,
191 + }
192 + }
193 + return result
194 + }
195 +
196 + async listModelIDs(apiKey: string, baseURLOverride?: string): Promise<string[]> {
197 + const url =
198 + joinURL(
199 + this.baseURL(baseURLOverride !== undefined ? { baseURLOverride } : undefined),
200 + 'models'
201 + ) + '?limit=100'
202 + const responseText = await requestJSON(
203 + url,
204 + {
205 + method: 'GET',
206 + headers: {
207 + 'x-api-key': apiKey,
208 + 'anthropic-version': API_VERSION,
209 + 'anthropic-dangerous-direct-browser-access': 'true',
210 + },
211 + },
212 + this.provider
213 + )
214 + let parsed: { data?: { id?: unknown }[] }
215 + try {
216 + parsed = JSON.parse(responseText) as { data?: { id?: unknown }[] }
217 + } catch {
218 + throw ProviderError.invalidResponse(this.provider, 'unrecognized /models response shape')
219 + }
220 + if (!Array.isArray(parsed.data)) {
221 + throw ProviderError.invalidResponse(this.provider, 'unrecognized /models response shape')
222 + }
223 + return parsed.data.map((e) => e.id).filter((id): id is string => typeof id === 'string')
224 + }
225 +}
226 +
227 +// Citation type is unused by Anthropic but kept for interface parity.
228 +export type { Citation }
229 +
230 +function wireMessage(message: Message, vision: boolean): { role: string; content: WireBlock[] } {
231 + const role = message.role === 'assistant' ? 'assistant' : 'user'
232 + let text = message.text
233 + for (const attachment of message.attachments ?? []) {
234 + if (attachment.kind === 'textFile') {
235 + text += `\n\n\`\`\`${attachment.fileName}\n${attachment.data}\n\`\`\``
236 + }
237 + }
238 + const blocks: WireBlock[] = []
239 + if (vision && message.role === 'user') {
240 + for (const image of message.attachments ?? []) {
241 + if (image.kind === 'image') {
242 + blocks.push({
243 + type: 'image',
244 + source: { type: 'base64', media_type: image.mimeType, data: image.data },
245 + })
246 + }
247 + }
248 + }
249 + blocks.push({ type: 'text', text: text === '' ? ' ' : text })
250 + return { role, content: blocks }
251 +}
added src/providers/catalog.ts +409 −0
@@ -0,0 +1,409 @@
1 +/*
2 + * catalog.ts
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * The complete model catalog — ALL 170 models ported 1:1 from native
9 + * Zyquo Cloud's ModelCatalogData.swift (counts: openai 27, anthropic 11,
10 + * xai 5, mistral 10, gemini 14, qwen 32, deepseek 2, kimi 12, perplexity 4,
11 + * together 16, deepinfra 34, cerebras 3). Never hardcode models in views or
12 + * clients — instances come exclusively from here (+ user custom models).
13 + */
14 +
15 +import type {
16 + AIModel,
17 + ModelCapabilities,
18 + ModelPricing,
19 + ParameterSupport,
20 + Provider,
21 +} from '../types'
22 +
23 +// --- Compact constructors (defaults mirror the native structs) ---
24 +
25 +function C(o: Partial<ModelCapabilities> = {}): ModelCapabilities {
26 + return {
27 + vision: false,
28 + tools: false,
29 + reasoning: false,
30 + streaming: true,
31 + jsonMode: false,
32 + citations: false,
33 + ...o,
34 + }
35 +}
36 +
37 +function P(o: Partial<ParameterSupport> = {}): ParameterSupport {
38 + return {
39 + temperature: true,
40 + topP: true,
41 + frequencyPenalty: false,
42 + presencePenalty: false,
43 + usesMaxCompletionTokens: false,
44 + reasoningEffort: false,
45 + thinkingToggle: false,
46 + requiresStreaming: false,
47 + ...o,
48 + }
49 +}
50 +
51 +function $(inputPerMTok: number, outputPerMTok: number): ModelPricing {
52 + return { inputPerMTok, outputPerMTok }
53 +}
54 +
55 +type Flag = 'rec' | 'legacy'
56 +
57 +function m(
58 + provider: Provider,
59 + id: string,
60 + displayName: string,
61 + contextWindow: number,
62 + maxOutputTokens: number | null,
63 + capabilities: ModelCapabilities,
64 + pricing: ModelPricing | null,
65 + parameterSupport: ParameterSupport,
66 + flag?: Flag
67 +): AIModel {
68 + return {
69 + id,
70 + provider,
71 + displayName,
72 + contextWindow,
73 + maxOutputTokens,
74 + capabilities,
75 + pricing,
76 + parameterSupport,
77 + isLegacy: flag === 'legacy',
78 + isRecommended: flag === 'rec',
79 + }
80 +}
81 +
82 +// --- Shared presets (native: ParameterSupport(), .openAIDefault, openAIReasoning) ---
83 +
84 +const psDefault = P()
85 +const psOpenAI = P({ frequencyPenalty: true, presencePenalty: true })
86 +const psOAIReasoning = P({
87 + temperature: false,
88 + topP: false,
89 + usesMaxCompletionTokens: true,
90 + reasoningEffort: true,
91 +})
92 +
93 +// --- OpenAI (27) ---
94 +
95 +const openai: AIModel[] = [
96 + m('openai', 'gpt-5.6-sol', 'GPT-5.6 Sol', 1050000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(5, 30), psOAIReasoning, 'rec'),
97 + m('openai', 'gpt-5.6-terra', 'GPT-5.6 Terra', 1050000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(2.5, 15), psOAIReasoning, 'rec'),
98 + m('openai', 'gpt-5.6-luna', 'GPT-5.6 Luna', 1050000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1, 6), psOAIReasoning),
99 + m('openai', 'chat-latest', 'ChatGPT Latest', 128000, null, C({ vision: true, tools: true, jsonMode: true }), $(5, 30), P({ frequencyPenalty: true, presencePenalty: true, usesMaxCompletionTokens: true })),
100 + m('openai', 'gpt-5.5', 'GPT-5.5', 400000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(5, 30), psOAIReasoning),
101 + m('openai', 'gpt-5.4', 'GPT-5.4', 400000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(2.5, 15), psOAIReasoning),
102 + m('openai', 'gpt-5.4-mini', 'GPT-5.4 mini', 400000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.75, 4.5), psOAIReasoning),
103 + m('openai', 'gpt-5.4-nano', 'GPT-5.4 nano', 400000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.2, 1.25), psOAIReasoning),
104 + m('openai', 'gpt-5.3-chat-latest', 'GPT-5.3 Chat Latest', 128000, null, C({ vision: true, tools: true, jsonMode: true }), null, P({ frequencyPenalty: true, presencePenalty: true, usesMaxCompletionTokens: true })),
105 + m('openai', 'gpt-5.2', 'GPT-5.2', 400000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1.75, 14), psOAIReasoning),
106 + m('openai', 'gpt-5.2-chat-latest', 'GPT-5.2 Chat Latest', 128000, 16000, C({ vision: true, tools: true, jsonMode: true }), $(1.75, 14), P({ frequencyPenalty: true, presencePenalty: true, usesMaxCompletionTokens: true })),
107 + m('openai', 'gpt-5.1', 'GPT-5.1', 400000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1.25, 10), psOAIReasoning),
108 + m('openai', 'gpt-5', 'GPT-5', 400000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1.25, 10), psOAIReasoning),
109 + m('openai', 'gpt-5-mini', 'GPT-5 mini', 400000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.25, 2), psOAIReasoning),
110 + m('openai', 'gpt-5-nano', 'GPT-5 nano', 400000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.05, 0.4), psOAIReasoning),
111 + m('openai', 'o3', 'OpenAI o3', 200000, 100000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(2, 8), psOAIReasoning),
112 + m('openai', 'o4-mini', 'OpenAI o4-mini', 200000, 100000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1.1, 4.4), psOAIReasoning),
113 + m('openai', 'o3-mini', 'OpenAI o3-mini', 200000, 100000, C({ tools: true, reasoning: true, jsonMode: true }), $(1.1, 4.4), psOAIReasoning, 'legacy'),
114 + m('openai', 'o1', 'OpenAI o1', 200000, 100000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(15, 60), psOAIReasoning, 'legacy'),
115 + m('openai', 'gpt-4.1', 'GPT-4.1', 1047576, 32768, C({ vision: true, tools: true, jsonMode: true }), $(2, 8), psOpenAI, 'legacy'),
116 + m('openai', 'gpt-4.1-mini', 'GPT-4.1 mini', 1047576, 32768, C({ vision: true, tools: true, jsonMode: true }), $(0.4, 1.6), psOpenAI, 'legacy'),
117 + m('openai', 'gpt-4.1-nano', 'GPT-4.1 nano', 1047576, 32768, C({ vision: true, tools: true, jsonMode: true }), $(0.1, 0.4), psOpenAI, 'legacy'),
118 + m('openai', 'gpt-4o', 'GPT-4o', 128000, 16384, C({ vision: true, tools: true, jsonMode: true }), $(2.5, 10), psOpenAI, 'legacy'),
119 + m('openai', 'gpt-4o-mini', 'GPT-4o mini', 128000, 16384, C({ vision: true, tools: true, jsonMode: true }), $(0.15, 0.6), psOpenAI, 'legacy'),
120 + m('openai', 'gpt-4-turbo', 'GPT-4 Turbo', 128000, 4096, C({ vision: true, tools: true, jsonMode: true }), $(10, 30), psOpenAI, 'legacy'),
121 + m('openai', 'gpt-4', 'GPT-4', 8192, 8192, C({ tools: true }), $(30, 60), psOpenAI, 'legacy'),
122 + m('openai', 'gpt-3.5-turbo', 'GPT-3.5 Turbo', 16385, 4096, C({ tools: true, jsonMode: true }), $(0.5, 1.5), psOpenAI, 'legacy'),
123 +]
124 +
125 +// --- Anthropic (11) ---
126 +
127 +const psClaudeThinking = P({ temperature: false, topP: false, thinkingToggle: true })
128 +const psClaudeClassic = P({ thinkingToggle: true })
129 +
130 +const anthropic: AIModel[] = [
131 + m('anthropic', 'claude-opus-5', 'Claude Opus 5', 1000000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(5, 25), psClaudeThinking, 'rec'),
132 + m('anthropic', 'claude-sonnet-5', 'Claude Sonnet 5', 1000000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(3, 15), psClaudeThinking, 'rec'),
133 + // Thinking always on — no toggle sent.
134 + m('anthropic', 'claude-fable-5', 'Claude Fable 5', 1000000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(10, 50), P({ temperature: false, topP: false })),
135 + m('anthropic', 'claude-opus-4-8', 'Claude Opus 4.8', 1000000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(5, 25), psClaudeThinking),
136 + m('anthropic', 'claude-opus-4-7', 'Claude Opus 4.7', 1000000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(5, 25), psClaudeThinking),
137 + m('anthropic', 'claude-opus-4-6', 'Claude Opus 4.6', 1000000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(5, 25), psClaudeClassic),
138 + m('anthropic', 'claude-sonnet-4-6', 'Claude Sonnet 4.6', 1000000, 128000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(3, 15), psClaudeClassic),
139 + m('anthropic', 'claude-haiku-4-5-20251001', 'Claude Haiku 4.5', 200000, 64000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1, 5), psClaudeClassic),
140 + m('anthropic', 'claude-opus-4-5-20251101', 'Claude Opus 4.5', 200000, 64000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(5, 25), psClaudeClassic, 'legacy'),
141 + m('anthropic', 'claude-sonnet-4-5-20250929', 'Claude Sonnet 4.5', 1000000, 64000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(3, 15), psClaudeClassic, 'legacy'),
142 + m('anthropic', 'claude-opus-4-1-20250805', 'Claude Opus 4.1', 200000, 32000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(15, 75), psClaudeClassic, 'legacy'),
143 +]
144 +
145 +// --- xAI (5) ---
146 +
147 +const xai: AIModel[] = [
148 + m('xai', 'grok-4.5', 'Grok 4.5', 500000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(2, 6), P({ reasoningEffort: true }), 'rec'),
149 + m('xai', 'grok-4.3', 'Grok 4.3', 1000000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1.25, 2.5), P({ reasoningEffort: true })),
150 + m('xai', 'grok-4.20', 'Grok 4.20 Reasoning', 1000000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1.25, 2.5), psDefault),
151 + m('xai', 'grok-4.20-non-reasoning', 'Grok 4.20 Non-Reasoning', 1000000, null, C({ vision: true, tools: true, jsonMode: true }), $(1.25, 2.5), psDefault),
152 + m('xai', 'grok-code-fast-1', 'Grok Code Fast 1', 256000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1, 2), psDefault, 'rec'),
153 +]
154 +
155 +// --- Mistral (10) ---
156 +
157 +const mistral: AIModel[] = [
158 + m('mistral', 'mistral-medium-latest', 'Mistral Medium 3.5', 262144, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1.5, 7.5), P({ frequencyPenalty: true, presencePenalty: true, reasoningEffort: true }), 'rec'),
159 + m('mistral', 'mistral-large-latest', 'Mistral Large 3', 262144, null, C({ vision: true, tools: true, jsonMode: true }), $(0.5, 1.5), psOpenAI, 'rec'),
160 + m('mistral', 'mistral-small-latest', 'Mistral Small 4', 262144, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.15, 0.6), P({ frequencyPenalty: true, presencePenalty: true, reasoningEffort: true }), 'rec'),
161 + m('mistral', 'codestral-latest', 'Codestral', 256000, null, C({ tools: true, jsonMode: true }), $(0.3, 0.9), psOpenAI),
162 + m('mistral', 'ministral-14b-latest', 'Ministral 3 14B', 262144, null, C({ vision: true, tools: true, jsonMode: true }), $(0.2, 0.2), psOpenAI),
163 + m('mistral', 'ministral-8b-latest', 'Ministral 3 8B', 262144, null, C({ vision: true, tools: true, jsonMode: true }), $(0.15, 0.15), psOpenAI),
164 + m('mistral', 'ministral-3b-latest', 'Ministral 3 3B', 131072, null, C({ vision: true, tools: true, jsonMode: true }), $(0.1, 0.1), psOpenAI),
165 + m('mistral', 'magistral-medium-latest', 'Magistral Medium', 131072, null, C({ tools: true, reasoning: true, jsonMode: true }), $(2, 5), psOpenAI, 'legacy'),
166 + m('mistral', 'devstral-latest', 'Devstral 2', 262144, null, C({ tools: true, jsonMode: true }), $(0.4, 2), psOpenAI, 'legacy'),
167 + m('mistral', 'open-mistral-nemo', 'Mistral Nemo', 131072, null, C({ tools: true, jsonMode: true }), $(0.15, 0.15), psOpenAI, 'legacy'),
168 +]
169 +
170 +// --- Google Gemini (14) ---
171 +
172 +const psGemini = P({ reasoningEffort: true })
173 +
174 +const gemini: AIModel[] = [
175 + m('gemini', 'gemini-3.6-flash', 'Gemini 3.6 Flash', 1048576, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1.5, 7.5), psGemini, 'rec'),
176 + m('gemini', 'gemini-3.5-flash', 'Gemini 3.5 Flash', 1048576, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1.5, 9), psGemini),
177 + m('gemini', 'gemini-3.5-flash-lite', 'Gemini 3.5 Flash-Lite', 1048576, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.3, 2.5), psGemini, 'rec'),
178 + m('gemini', 'gemini-3.1-pro-preview', 'Gemini 3.1 Pro (Preview)', 1048576, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(2, 12), psGemini, 'rec'),
179 + m('gemini', 'gemini-3.1-flash-lite', 'Gemini 3.1 Flash-Lite', 1048576, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.25, 1.5), psGemini),
180 + m('gemini', 'gemini-2.5-pro', 'Gemini 2.5 Pro', 1048576, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1.25, 10), psGemini),
181 + m('gemini', 'gemini-2.5-flash', 'Gemini 2.5 Flash', 1048576, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.3, 2.5), psGemini),
182 + m('gemini', 'gemini-2.5-flash-lite', 'Gemini 2.5 Flash-Lite', 1048576, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.1, 0.4), psGemini),
183 + m('gemini', 'gemini-pro-latest', 'Gemini Pro (Latest)', 1048576, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), null, psGemini),
184 + m('gemini', 'gemini-flash-latest', 'Gemini Flash (Latest)', 1048576, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), null, psGemini),
185 + m('gemini', 'gemini-flash-lite-latest', 'Gemini Flash-Lite (Latest)', 1048576, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), null, psGemini),
186 + m('gemini', 'gemini-3-flash-preview', 'Gemini 3 Flash (Preview)', 1048576, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.5, 3), psGemini),
187 + m('gemini', 'gemma-4-26b-a4b-it', 'Gemma 4 26B', 262144, 32768, C({ jsonMode: true }), null, psDefault),
188 + m('gemini', 'gemma-4-31b-it', 'Gemma 4 31B', 262144, 32768, C({ jsonMode: true }), null, psDefault),
189 +]
190 +
191 +// --- Alibaba Qwen / DashScope intl (32) ---
192 +
193 +const psQwenThinking = P({ thinkingToggle: true })
194 +const psQwenStream = P({ requiresStreaming: true })
195 +
196 +const qwen: AIModel[] = [
197 + m('qwen', 'qwen3.7-max', 'Qwen3.7 Max', 1000000, null, C({ tools: true, reasoning: true, jsonMode: true }), $(2.5, 7.5), psQwenThinking, 'rec'),
198 + m('qwen', 'qwen3.7-plus', 'Qwen3.7 Plus', 1000000, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.32, 1.28), psQwenThinking, 'rec'),
199 + m('qwen', 'qwen3.7-flash', 'Qwen3.7 Flash', 1000000, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.03, 0.13), psQwenThinking, 'rec'),
200 + m('qwen', 'qwen3.6-plus', 'Qwen3.6 Plus', 1000000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
201 + m('qwen', 'qwen3.6-flash', 'Qwen3.6 Flash', 1000000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
202 + m('qwen', 'qwen3.5-plus', 'Qwen3.5 Plus', 1000000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
203 + m('qwen', 'qwen3.5-flash', 'Qwen3.5 Flash', 1000000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
204 + m('qwen', 'qwen-max', 'Qwen Max', 128000, null, C({ tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
205 + m('qwen', 'qwen-plus', 'Qwen Plus', 1000000, null, C({ tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
206 + m('qwen', 'qwen-turbo', 'Qwen Turbo', 1000000, null, C({ tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking, 'legacy'),
207 + m('qwen', 'qwen-flash', 'Qwen Flash', 1000000, null, C({ tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
208 + m('qwen', 'qwen3-coder-plus', 'Qwen3 Coder Plus', 1000000, null, C({ tools: true, jsonMode: true }), null, psDefault),
209 + m('qwen', 'qwen3-coder-flash', 'Qwen3 Coder Flash', 1000000, null, C({ tools: true, jsonMode: true }), null, psDefault),
210 + m('qwen', 'qwen3-coder-next', 'Qwen3 Coder Next', 262144, null, C({ tools: true, jsonMode: true }), null, psDefault),
211 + m('qwen', 'qwen3-coder-480b-a35b-instruct', 'Qwen3 Coder 480B A35B', 262144, null, C({ tools: true, jsonMode: true }), null, psDefault),
212 + m('qwen', 'qwen3-vl-plus', 'Qwen3 VL Plus', 1000000, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
213 + m('qwen', 'qwen3-vl-flash', 'Qwen3 VL Flash', 1000000, 65536, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
214 + m('qwen', 'qwen3-vl-235b-a22b-instruct', 'Qwen3 VL 235B Instruct', 131072, null, C({ vision: true, tools: true, jsonMode: true }), null, psDefault),
215 + m('qwen', 'qwen3-vl-235b-a22b-thinking', 'Qwen3 VL 235B Thinking', 131072, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), null, psDefault),
216 + m('qwen', 'qvq-max', 'QVQ Max', 131072, null, C({ vision: true, reasoning: true, jsonMode: true }), null, psQwenStream),
217 + m('qwen', 'qwq-plus', 'QwQ Plus', 131072, null, C({ tools: true, reasoning: true, jsonMode: true }), null, psQwenStream),
218 + m('qwen', 'qwen3.5-397b-a17b', 'Qwen3.5 397B A17B', 262144, null, C({ tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
219 + m('qwen', 'qwen3.5-122b-a10b', 'Qwen3.5 122B A10B', 262144, null, C({ tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
220 + m('qwen', 'qwen3.5-35b-a3b', 'Qwen3.5 35B A3B', 262144, null, C({ tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
221 + m('qwen', 'qwen3-235b-a22b-instruct-2507', 'Qwen3 235B Instruct 2507', 262144, null, C({ tools: true, jsonMode: true }), null, psDefault),
222 + m('qwen', 'qwen3-235b-a22b-thinking-2507', 'Qwen3 235B Thinking 2507', 262144, null, C({ tools: true, reasoning: true, jsonMode: true }), null, psDefault),
223 + m('qwen', 'qwen3-next-80b-a3b-instruct', 'Qwen3 Next 80B Instruct', 262144, null, C({ tools: true, jsonMode: true }), null, psDefault),
224 + m('qwen', 'qwen3-next-80b-a3b-thinking', 'Qwen3 Next 80B Thinking', 262144, null, C({ tools: true, reasoning: true, jsonMode: true }), null, psDefault),
225 + m('qwen', 'deepseek-v4-pro', 'DeepSeek V4 Pro (DashScope)', 1000000, null, C({ tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
226 + m('qwen', 'deepseek-v4-flash', 'DeepSeek V4 Flash (DashScope)', 1000000, null, C({ tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
227 + m('qwen', 'glm-5.2', 'GLM 5.2 (DashScope)', 198000, null, C({ tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
228 + m('qwen', 'kimi-k2.7-code', 'Kimi K2.7 Code (DashScope)', 262144, null, C({ tools: true, reasoning: true, jsonMode: true }), null, psQwenThinking),
229 +]
230 +
231 +// --- DeepSeek (2) ---
232 +
233 +const psDeepSeek = P({ reasoningEffort: true, thinkingToggle: true })
234 +
235 +const deepseek: AIModel[] = [
236 + m('deepseek', 'deepseek-v4-flash', 'DeepSeek V4 Flash', 1000000, 384000, C({ tools: true, reasoning: true, jsonMode: true }), $(0.14, 0.28), psDeepSeek, 'rec'),
237 + m('deepseek', 'deepseek-v4-pro', 'DeepSeek V4 Pro', 1000000, 384000, C({ tools: true, reasoning: true, jsonMode: true }), $(0.435, 0.87), psDeepSeek, 'rec'),
238 +]
239 +
240 +// --- Kimi / Moonshot (12) ---
241 +
242 +const psKimiMax = P({ temperature: false, topP: false, usesMaxCompletionTokens: true })
243 +
244 +const kimi: AIModel[] = [
245 + m('kimi', 'kimi-k3', 'Kimi K3', 1048576, 131072, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(3, 15), psOAIReasoning, 'rec'),
246 + m('kimi', 'kimi-k2.7-code', 'Kimi K2.7 Code', 262144, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.95, 4), psKimiMax, 'rec'),
247 + m('kimi', 'kimi-k2.7-code-highspeed', 'Kimi K2.7 Code Highspeed', 262144, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1.9, 8), psKimiMax),
248 + m('kimi', 'kimi-k2.6', 'Kimi K2.6', 262144, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.95, 4), P({ temperature: false, topP: false, usesMaxCompletionTokens: true, thinkingToggle: true })),
249 + m('kimi', 'kimi-k2.5', 'Kimi K2.5', 262144, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.6, 3), P({ temperature: false, topP: false, usesMaxCompletionTokens: true, thinkingToggle: true })),
250 + m('kimi', 'moonshot-v1-8k', 'Moonshot v1 8K', 8192, null, C({ tools: true, jsonMode: true }), $(0.2, 2), psOpenAI, 'legacy'),
251 + m('kimi', 'moonshot-v1-32k', 'Moonshot v1 32K', 32768, null, C({ tools: true, jsonMode: true }), $(1, 3), psOpenAI, 'legacy'),
252 + m('kimi', 'moonshot-v1-128k', 'Moonshot v1 128K', 131072, null, C({ tools: true, jsonMode: true }), $(2, 5), psOpenAI, 'legacy'),
253 + m('kimi', 'moonshot-v1-auto', 'Moonshot v1 Auto', 131072, null, C({ tools: true, jsonMode: true }), null, psOpenAI, 'legacy'),
254 + m('kimi', 'moonshot-v1-8k-vision-preview', 'Moonshot v1 8K Vision', 8192, null, C({ vision: true, tools: true, jsonMode: true }), $(0.2, 2), psOpenAI, 'legacy'),
255 + m('kimi', 'moonshot-v1-32k-vision-preview', 'Moonshot v1 32K Vision', 32768, null, C({ vision: true, tools: true, jsonMode: true }), $(1, 3), psOpenAI, 'legacy'),
256 + m('kimi', 'moonshot-v1-128k-vision-preview', 'Moonshot v1 128K Vision', 131072, null, C({ vision: true, tools: true, jsonMode: true }), $(2, 5), psOpenAI, 'legacy'),
257 +]
258 +
259 +// --- Perplexity (4) — all citations, no /models endpoint ---
260 +
261 +const perplexity: AIModel[] = [
262 + m('perplexity', 'sonar', 'Sonar', 128000, 128000, C({ jsonMode: true, citations: true }), $(1, 1), psDefault, 'rec'),
263 + m('perplexity', 'sonar-pro', 'Sonar Pro', 200000, 8000, C({ jsonMode: true, citations: true }), $(3, 15), psDefault, 'rec'),
264 + m('perplexity', 'sonar-reasoning-pro', 'Sonar Reasoning Pro', 128000, null, C({ reasoning: true, jsonMode: true, citations: true }), $(2, 8), P({ reasoningEffort: true })),
265 + m('perplexity', 'sonar-deep-research', 'Sonar Deep Research', 128000, null, C({ reasoning: true, jsonMode: true, citations: true }), $(2, 8), P({ reasoningEffort: true })),
266 +]
267 +
268 +// --- Together AI (16) ---
269 +
270 +const psTogetherStream = P({ frequencyPenalty: true, presencePenalty: true, requiresStreaming: true })
271 +
272 +const together: AIModel[] = [
273 + m('together', 'moonshotai/Kimi-K3', 'Kimi K3', 1000000, null, C({ tools: true, reasoning: true, jsonMode: true }), $(3, 15), psOpenAI, 'rec'),
274 + m('together', 'moonshotai/Kimi-K2.7-Code', 'Kimi K2.7 Code', 262144, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.95, 4), psOpenAI),
275 + m('together', 'moonshotai/Kimi-K2.6', 'Kimi K2.6', 262144, null, C({ tools: true, reasoning: true, jsonMode: true }), $(1.2, 4.5), psOpenAI),
276 + m('together', 'deepseek-ai/DeepSeek-V4-Pro', 'DeepSeek V4 Pro', 512000, null, C({ tools: true, reasoning: true, jsonMode: true }), $(1.74, 3.48), psOpenAI, 'rec'),
277 + m('together', 'zai-org/GLM-5.2', 'GLM 5.2', 512000, null, C({ tools: true, reasoning: true, jsonMode: true }), $(1.4, 4.4), psOpenAI),
278 + m('together', 'Qwen/Qwen3.7-Max', 'Qwen3.7 Max', 1000000, null, C({ tools: true, reasoning: true, jsonMode: true }), $(1.25, 3.75), psTogetherStream),
279 + m('together', 'Qwen/Qwen3.7-Plus', 'Qwen3.7 Plus', 1000000, null, C({ tools: true, jsonMode: true }), $(0.32, 1.28), psTogetherStream),
280 + m('together', 'Qwen/Qwen3.6-Plus', 'Qwen3.6 Plus', 1000000, null, C({ tools: true, jsonMode: true }), $(0.5, 3), psTogetherStream),
281 + m('together', 'Qwen/Qwen3.5-9B', 'Qwen3.5 9B', 262144, null, C({ tools: true, jsonMode: true }), $(0.17, 0.25), psTogetherStream),
282 + m('together', 'meta-llama/Llama-3.3-70B-Instruct-Turbo', 'Llama 3.3 70B Turbo', 131072, null, C({ tools: true, jsonMode: true }), $(1.04, 1.04), psOpenAI),
283 + m('together', 'openai/gpt-oss-120b', 'GPT-OSS 120B', 131072, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.15, 0.6), P({ frequencyPenalty: true, presencePenalty: true, reasoningEffort: true }), 'rec'),
284 + m('together', 'openai/gpt-oss-20b', 'GPT-OSS 20B', 131072, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.05, 0.2), P({ frequencyPenalty: true, presencePenalty: true, reasoningEffort: true })),
285 + m('together', 'nvidia/nemotron-3-ultra-550b-a55b', 'Nemotron 3 Ultra 550B', 512288, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.6, 3.6), psOpenAI),
286 + m('together', 'MiniMaxAI/MiniMax-M3', 'MiniMax M3', 524288, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.3, 1.2), psOpenAI),
287 + // Vision explicitly false — endpoint streams empty on images (verified 2026-07-30 natively).
288 + m('together', 'google/gemma-4-31B-it', 'Gemma 4 31B', 262144, null, C({ tools: true, jsonMode: true }), $(0.39, 0.97), psTogetherStream),
289 + m('together', 'thinkingmachines/Inkling', 'Inkling', 524288, null, C({ tools: true, reasoning: true, jsonMode: true }), $(1, 4.05), psOpenAI),
290 +]
291 +
292 +// --- DeepInfra (34) ---
293 +
294 +const deepinfra: AIModel[] = [
295 + // Proxied Anthropic/Google models use bare defaults (no penalties).
296 + m('deepinfra', 'anthropic/claude-fable-5', 'Claude Fable 5', 1000000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(10, 50), psDefault),
297 + m('deepinfra', 'anthropic/claude-opus-5', 'Claude Opus 5', 1000000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(5, 25), psDefault),
298 + m('deepinfra', 'anthropic/claude-sonnet-5', 'Claude Sonnet 5', 1000000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(2, 10), psDefault),
299 + m('deepinfra', 'anthropic/claude-opus-4-8', 'Claude Opus 4.8', 1000000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(5, 25), psDefault),
300 + m('deepinfra', 'anthropic/claude-haiku-4-5', 'Claude Haiku 4.5', 200000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1, 5), psDefault),
301 + m('deepinfra', 'google/gemini-3.1-pro', 'Gemini 3.1 Pro', 1000000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(2, 12), psDefault),
302 + m('deepinfra', 'google/gemini-3.5-flash', 'Gemini 3.5 Flash', 1000000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1.5, 9), psDefault),
303 + m('deepinfra', 'google/gemini-3.1-flash-lite', 'Gemini 3.1 Flash-Lite', 1000000, null, C({ vision: true, tools: true, jsonMode: true }), $(0.25, 1.5), psDefault),
304 + m('deepinfra', 'google/gemini-2.5-pro', 'Gemini 2.5 Pro', 1000000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(1.25, 10), psDefault),
305 + m('deepinfra', 'google/gemini-2.5-flash', 'Gemini 2.5 Flash', 1000000, null, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.3, 2.5), psDefault),
306 + m('deepinfra', 'deepseek-ai/DeepSeek-V4-Pro', 'DeepSeek V4 Pro', 1048576, null, C({ tools: true, reasoning: true, jsonMode: true }), $(1.3, 2.6), psOpenAI, 'rec'),
307 + m('deepinfra', 'deepseek-ai/DeepSeek-V4-Flash', 'DeepSeek V4 Flash', 1048576, null, C({ tools: true, jsonMode: true }), $(0.09, 0.18), psOpenAI, 'rec'),
308 + m('deepinfra', 'deepseek-ai/DeepSeek-V3.1', 'DeepSeek V3.1', 163840, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.25, 0.95), psOpenAI),
309 + m('deepinfra', 'deepseek-ai/DeepSeek-R1-0528', 'DeepSeek R1 0528', 163840, null, C({ reasoning: true }), $(0.5, 2.15), psOpenAI),
310 + m('deepinfra', 'moonshotai/Kimi-K2.7-Code', 'Kimi K2.7 Code', 262144, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.74, 3.5), psOpenAI),
311 + m('deepinfra', 'moonshotai/Kimi-K2.6', 'Kimi K2.6', 262144, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.75, 3.5), psOpenAI),
312 + m('deepinfra', 'moonshotai/Kimi-K2.5', 'Kimi K2.5', 262144, null, C({ tools: true, jsonMode: true }), $(0.45, 2.25), psTogetherStream),
313 + m('deepinfra', 'zai-org/GLM-5.2', 'GLM 5.2', 1048576, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.75, 2.4), psOpenAI, 'rec'),
314 + m('deepinfra', 'zai-org/GLM-4.7', 'GLM 4.7', 202752, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.4, 1.75), psOpenAI),
315 + m('deepinfra', 'Qwen/Qwen3.7-Max', 'Qwen3.7 Max', 256000, null, C({ tools: true, reasoning: true, jsonMode: true }), $(2.5, 7.5), psOpenAI),
316 + m('deepinfra', 'Qwen/Qwen3.5-397B-A17B', 'Qwen3.5 397B A17B', 262144, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.45, 3), psOpenAI),
317 + m('deepinfra', 'Qwen/Qwen3-235B-A22B-Instruct-2507', 'Qwen3 235B Instruct 2507', 262144, null, C({ tools: true, jsonMode: true }), $(0.09, 0.55), psOpenAI),
318 + m('deepinfra', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen3 235B Thinking 2507', 262144, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.23, 2.3), psOpenAI),
319 + m('deepinfra', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo', 'Qwen3 Coder 480B Turbo', 262144, null, C({ tools: true, jsonMode: true }), $(0.3, 1), psOpenAI),
320 + m('deepinfra', 'Qwen/Qwen3-VL-235B-A22B-Instruct', 'Qwen3 VL 235B', 262144, null, C({ vision: true, tools: true, jsonMode: true }), $(0.2, 0.88), psOpenAI),
321 + m('deepinfra', 'meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8', 'Llama 4 Maverick', 1048576, null, C({ vision: true, tools: true, jsonMode: true }), $(0.2, 0.8), psOpenAI),
322 + m('deepinfra', 'meta-llama/Llama-4-Scout-17B-16E-Instruct', 'Llama 4 Scout', 327680, null, C({ vision: true, tools: true, jsonMode: true }), $(0.1, 0.3), psOpenAI),
323 + m('deepinfra', 'meta-llama/Llama-3.3-70B-Instruct-Turbo', 'Llama 3.3 70B Turbo', 131072, null, C({ tools: true, jsonMode: true }), $(0.1, 0.32), psOpenAI),
324 + m('deepinfra', 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo', 'Llama 3.1 8B Turbo', 131072, null, C({ tools: true, jsonMode: true }), $(0.02, 0.04), psOpenAI),
325 + m('deepinfra', 'openai/gpt-oss-120b', 'GPT-OSS 120B', 131072, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.037, 0.17), P({ frequencyPenalty: true, presencePenalty: true, reasoningEffort: true }), 'rec'),
326 + m('deepinfra', 'openai/gpt-oss-20b', 'GPT-OSS 20B', 131072, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.03, 0.14), P({ frequencyPenalty: true, presencePenalty: true, reasoningEffort: true })),
327 + m('deepinfra', 'MiniMaxAI/MiniMax-M3', 'MiniMax M3', 524288, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.3, 1.2), psOpenAI),
328 + m('deepinfra', 'nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B', 'Nemotron 3 Ultra 550B', 262144, null, C({ tools: true, reasoning: true, jsonMode: true }), $(0.5, 2.2), psOpenAI),
329 + m('deepinfra', 'mistralai/Mistral-Small-3.2-24B-Instruct-2506', 'Mistral Small 3.2 24B', 128000, null, C({ vision: true, tools: true, jsonMode: true }), $(0.075, 0.2), psOpenAI),
330 +]
331 +
332 +// --- Cerebras (3) ---
333 +
334 +const psCerebras = P({
335 + frequencyPenalty: true,
336 + presencePenalty: true,
337 + usesMaxCompletionTokens: true,
338 + reasoningEffort: true,
339 +})
340 +
341 +const cerebras: AIModel[] = [
342 + m('cerebras', 'gpt-oss-120b', 'GPT-OSS 120B', 131072, 40000, C({ tools: true, reasoning: true, jsonMode: true }), $(0.35, 0.75), psCerebras, 'rec'),
343 + m('cerebras', 'gemma-4-31b', 'Gemma 4 31B', 131072, 40000, C({ vision: true, tools: true, reasoning: true, jsonMode: true }), $(0.99, 1.49), psCerebras),
344 + // Provider-announced discontinuation 2026-08-17.
345 + m('cerebras', 'zai-glm-4.7', 'GLM 4.7', 131072, 40000, C({ tools: true, reasoning: true, jsonMode: true }), $(2.25, 2.75), psCerebras, 'legacy'),
346 +]
347 +
348 +// --- Aggregate (concatenation order is load-bearing: defaultModel = first recommended) ---
349 +
350 +export const CATALOG: readonly AIModel[] = [
351 + ...openai,
352 + ...anthropic,
353 + ...xai,
354 + ...mistral,
355 + ...gemini,
356 + ...qwen,
357 + ...deepseek,
358 + ...kimi,
359 + ...perplexity,
360 + ...together,
361 + ...deepinfra,
362 + ...cerebras,
363 +]
364 +
365 +// --- Catalog queries (ported from ModelCatalog.swift) ---
366 +
367 +/** All catalog models for one provider (unranked — rank in the UI layer with favorites). */
368 +export function modelsFor(provider: Provider): AIModel[] {
369 + return CATALOG.filter((model) => model.provider === provider)
370 +}
371 +
372 +/** Lookup keyed on (provider, id) — ids duplicate across providers. */
373 +export function findModel(provider: Provider, id: string): AIModel | undefined {
374 + return CATALOG.find((model) => model.provider === provider && model.id === id)
375 +}
376 +
377 +/**
378 + * Cheapest non-legacy model for a provider, preferring non-reasoning models
379 + * (falls back to all candidates). Used for key tests and title generation.
380 + */
381 +export function cheapestModel(provider: Provider): AIModel | undefined {
382 + const candidates = modelsFor(provider).filter((model) => !model.isLegacy)
383 + const preferred = candidates.filter((model) => !model.capabilities.reasoning)
384 + const pool = preferred.length > 0 ? preferred : candidates
385 + let best: AIModel | undefined
386 + let bestPrice = Infinity
387 + for (const model of pool) {
388 + const price = model.pricing?.outputPerMTok ?? Infinity
389 + if (price < bestPrice || best === undefined) {
390 + best = model
391 + bestPrice = price
392 + }
393 + }
394 + return best
395 +}
396 +
397 +/** First recommended model in catalog order (gpt-5.6-sol). */
398 +export function defaultModel(): AIModel {
399 + const recommended = CATALOG.find((model) => model.isRecommended)
400 + return recommended ?? (CATALOG[0] as AIModel)
401 +}
402 +
403 +/** Picker rank: favorites (0) → recommended (1) → normal (2) → legacy (3). */
404 +export function rankModel(model: AIModel, favoriteIDs: ReadonlySet<string>): number {
405 + if (favoriteIDs.has(model.id)) return 0
406 + if (model.isRecommended) return 1
407 + if (model.isLegacy) return 3
408 + return 2
409 +}
added src/providers/openaiCompatible.ts +381 −0
@@ -0,0 +1,381 @@
1 +/*
2 + * openaiCompatible.ts
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * One client for every provider speaking the OpenAI /chat/completions schema:
9 + * OpenAI, xAI, Mistral, Gemini (compat endpoint), Qwen/DashScope, DeepSeek,
10 + * Kimi, Perplexity, Together, DeepInfra, Cerebras, and custom endpoints.
11 + * Ported 1:1 from the native OpenAICompatibleClient.swift — all provider
12 + * quirks live HERE, nothing leaks above the providers/ layer.
13 + */
14 +
15 +import type { Citation, Message, Provider, TokenUsage } from '../types'
16 +import { PROVIDER_META } from './registry'
17 +import { joinURL, requestJSON, sseEvents } from './sse'
18 +import {
19 + ProviderError,
20 + type ChatEvent,
21 + type ChatRequest,
22 + type CompletionResult,
23 + type ProviderClient,
24 +} from './types'
25 +
26 +// --- Wire types (requests) ---
27 +
28 +type WirePart =
29 + | { type: 'text'; text: string }
30 + | { type: 'image_url'; image_url: { url: string } }
31 +
32 +interface WireMessage {
33 + role: string
34 + content: string | WirePart[]
35 +}
36 +
37 +interface WireRequestBody {
38 + model: string
39 + messages: WireMessage[]
40 + stream?: boolean
41 + stream_options?: { include_usage: boolean }
42 + temperature?: number
43 + top_p?: number
44 + max_tokens?: number
45 + max_completion_tokens?: number
46 + frequency_penalty?: number
47 + presence_penalty?: number
48 + reasoning_effort?: string
49 + enable_thinking?: boolean
50 +}
51 +
52 +// --- Wire types (responses) ---
53 +
54 +/**
55 + * Mistral reasoning models return `delta.content` as an ARRAY of chunks:
56 + * {"type":"text","text":…} or {"type":"thinking","thinking":[{"type":"text","text":…}]}.
57 + */
58 +interface WireContentChunk {
59 + type?: string
60 + text?: string
61 + thinking?: { text?: string }[]
62 +}
63 +
64 +interface WireDelta {
65 + content?: string | WireContentChunk[]
66 + reasoning_content?: string
67 + reasoning?: string
68 +}
69 +
70 +interface WireChoice {
71 + delta?: WireDelta
72 + message?: WireDelta
73 + /** Together streams some models completions-style: token text in choices[].text. */
74 + text?: string
75 + finish_reason?: string | null
76 +}
77 +
78 +interface WireUsage {
79 + prompt_tokens?: number
80 + completion_tokens?: number
81 + completion_tokens_details?: { reasoning_tokens?: number }
82 +}
83 +
84 +interface WireChunk {
85 + choices?: WireChoice[]
86 + usage?: WireUsage | null
87 + citations?: string[]
88 + search_results?: { title?: string; url?: string }[]
89 +}
90 +
91 +/** Splits a WireDelta's content into text + reasoning (Mistral array quirk). */
92 +function splitDelta(delta: WireDelta): { text: string; reasoning: string } {
93 + let reasoning = delta.reasoning_content ?? delta.reasoning ?? ''
94 + let text = ''
95 + if (typeof delta.content === 'string') {
96 + text = delta.content
97 + } else if (Array.isArray(delta.content)) {
98 + const textParts: string[] = []
99 + const thinkingParts: string[] = []
100 + for (const chunk of delta.content) {
101 + const flattened = chunk.text ?? (chunk.thinking ?? []).map((p) => p.text ?? '').join('')
102 + if (chunk.type === 'thinking') thinkingParts.push(flattened)
103 + else textParts.push(flattened)
104 + }
105 + text = textParts.join('')
106 + if (reasoning === '') reasoning = thinkingParts.join('')
107 + }
108 + return { text, reasoning }
109 +}
110 +
111 +function toUsage(wire: WireUsage): TokenUsage {
112 + const reasoningTokens = wire.completion_tokens_details?.reasoning_tokens
113 + return {
114 + inputTokens: wire.prompt_tokens ?? 0,
115 + outputTokens: wire.completion_tokens ?? 0,
116 + ...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
117 + }
118 +}
119 +
120 +/**
121 + * Perplexity: `citations` is an array of URL strings; `search_results` adds
122 + * titles. Merge both into numbered citations.
123 + */
124 +function citationsFrom(chunk: WireChunk): Citation[] | null {
125 + const urls = chunk.citations
126 + if (!urls || urls.length === 0) return null
127 + const titles = chunk.search_results ?? []
128 + return urls.map((url, index) => {
129 + const title = titles[index]?.title
130 + return { index: index + 1, url, ...(title ? { title } : {}) }
131 + })
132 +}
133 +
134 +export class OpenAICompatibleClient implements ProviderClient {
135 + readonly provider: Provider
136 +
137 + constructor(provider: Provider) {
138 + this.provider = provider
139 + }
140 +
141 + private baseURL(request?: { baseURLOverride?: string; model?: { customBaseURL?: string } }): string {
142 + const override = request?.baseURLOverride ?? request?.model?.customBaseURL
143 + const base = override ?? PROVIDER_META[this.provider].defaultBaseURL
144 + if (!base) {
145 + throw ProviderError.invalidResponse(this.provider, 'no base URL configured')
146 + }
147 + return base
148 + }
149 +
150 + private headers(apiKey: string): Record<string, string> {
151 + return {
152 + Authorization: `Bearer ${apiKey}`,
153 + 'Content-Type': 'application/json',
154 + }
155 + }
156 +
157 + private buildBody(request: ChatRequest): WireRequestBody {
158 + const messages: WireMessage[] = []
159 + if (request.systemPrompt && request.systemPrompt !== '') {
160 + messages.push({ role: 'system', content: request.systemPrompt })
161 + }
162 + for (const message of request.messages) {
163 + if (message.role === 'system') continue
164 + messages.push(wireMessage(message, request.model.capabilities.vision))
165 + }
166 +
167 + const support = request.model.parameterSupport
168 + const params = request.parameters
169 + const body: WireRequestBody = { model: request.model.id, messages }
170 +
171 + if (request.stream) {
172 + body.stream = true
173 + if (PROVIDER_META[this.provider].wantsStreamOptions) {
174 + body.stream_options = { include_usage: true }
175 + }
176 + }
177 + if (support.temperature && params.temperature !== undefined) {
178 + body.temperature = params.temperature
179 + }
180 + if (support.topP && params.topP !== undefined) body.top_p = params.topP
181 + if (params.maxTokens !== undefined) {
182 + if (support.usesMaxCompletionTokens) body.max_completion_tokens = params.maxTokens
183 + else body.max_tokens = params.maxTokens
184 + }
185 + if (support.frequencyPenalty && params.frequencyPenalty !== undefined) {
186 + body.frequency_penalty = params.frequencyPenalty
187 + }
188 + if (support.presencePenalty && params.presencePenalty !== undefined) {
189 + body.presence_penalty = params.presencePenalty
190 + }
191 + if (support.reasoningEffort && params.reasoningEffort !== undefined) {
192 + // Mistral only accepts "high"/"none": map medium→high, low→none.
193 + if (this.provider === 'mistral') {
194 + body.reasoning_effort = params.reasoningEffort === 'low' ? 'none' : 'high'
195 + } else {
196 + body.reasoning_effort = params.reasoningEffort
197 + }
198 + }
199 + if (support.thinkingToggle && this.provider === 'qwen') {
200 + // DashScope: enable_thinking is only legal on streaming requests.
201 + if (request.stream && params.thinkingEnabled !== undefined) {
202 + body.enable_thinking = params.thinkingEnabled
203 + }
204 + }
205 + return body
206 + }
207 +
208 + async *streamChat(
209 + request: ChatRequest,
210 + apiKey: string,
211 + signal?: AbortSignal
212 + ): AsyncGenerator<ChatEvent> {
213 + const url = joinURL(this.baseURL(request), 'chat/completions')
214 + const body = this.buildBody({ ...request, stream: true })
215 +
216 + let citationsSent = false
217 + let finishReason: string | null = null
218 +
219 + for await (const event of sseEvents(
220 + url,
221 + { headers: this.headers(apiKey), body: JSON.stringify(body) },
222 + this.provider,
223 + signal
224 + )) {
225 + if (event.data === '[DONE]') break
226 + let chunk: WireChunk
227 + try {
228 + chunk = JSON.parse(event.data) as WireChunk
229 + } catch {
230 + continue // tolerate unknown/malformed keep-alive chunks
231 + }
232 + const choice = chunk.choices?.[0]
233 + if (choice) {
234 + const { text, reasoning } = choice.delta
235 + ? splitDelta(choice.delta)
236 + : { text: '', reasoning: '' }
237 + if (reasoning !== '') yield { type: 'reasoningDelta', text: reasoning }
238 + const deltaText = text !== '' ? text : (choice.text ?? '')
239 + if (deltaText !== '') yield { type: 'textDelta', text: deltaText }
240 + if (choice.finish_reason != null) finishReason = choice.finish_reason
241 + }
242 + if (!citationsSent) {
243 + const citations = citationsFrom(chunk)
244 + if (citations && citations.length > 0) {
245 + citationsSent = true
246 + yield { type: 'citations', citations }
247 + }
248 + }
249 + if (chunk.usage) yield { type: 'usage', usage: toUsage(chunk.usage) }
250 + }
251 + yield { type: 'finished', reason: finishReason }
252 + }
253 +
254 + async complete(
255 + request: ChatRequest,
256 + apiKey: string,
257 + signal?: AbortSignal
258 + ): Promise<CompletionResult> {
259 + // Some models reject non-streaming calls — aggregate a stream instead.
260 + if (request.model.parameterSupport.requiresStreaming) {
261 + return this.completeViaStream(request, apiKey, signal)
262 + }
263 + const url = joinURL(this.baseURL(request), 'chat/completions')
264 + const body = this.buildBody({ ...request, stream: false })
265 + const responseText = await requestJSON(
266 + url,
267 + { method: 'POST', headers: this.headers(apiKey), body: JSON.stringify(body) },
268 + this.provider,
269 + signal
270 + )
271 + let chunk: WireChunk
272 + try {
273 + chunk = JSON.parse(responseText) as WireChunk
274 + } catch {
275 + throw ProviderError.invalidResponse(this.provider, 'undecodable completion response')
276 + }
277 + const choice = chunk.choices?.[0]
278 + const content = choice?.message ?? choice?.delta
279 + if (!choice || !content) {
280 + throw ProviderError.invalidResponse(this.provider, 'response contained no message')
281 + }
282 + const { text, reasoning } = splitDelta(content)
283 + const result: CompletionResult = { text: text !== '' ? text : (choice.text ?? '') }
284 + if (reasoning !== '') result.reasoning = reasoning
285 + const citations = citationsFrom(chunk)
286 + if (citations) result.citations = citations
287 + if (chunk.usage) result.usage = toUsage(chunk.usage)
288 + return result
289 + }
290 +
291 + /** Non-streaming result assembled from the streaming endpoint. */
292 + private async completeViaStream(
293 + request: ChatRequest,
294 + apiKey: string,
295 + signal?: AbortSignal
296 + ): Promise<CompletionResult> {
297 + let text = ''
298 + let reasoning = ''
299 + let citations: Citation[] = []
300 + let usage: TokenUsage | undefined
301 + for await (const event of this.streamChat(request, apiKey, signal)) {
302 + switch (event.type) {
303 + case 'textDelta':
304 + text += event.text
305 + break
306 + case 'reasoningDelta':
307 + reasoning += event.text
308 + break
309 + case 'citations':
310 + citations = event.citations
311 + break
312 + case 'usage':
313 + usage = event.usage
314 + break
315 + case 'finished':
316 + break
317 + }
318 + }
319 + const result: CompletionResult = { text }
320 + if (reasoning !== '') result.reasoning = reasoning
321 + if (citations.length > 0) result.citations = citations
322 + if (usage) result.usage = usage
323 + return result
324 + }
325 +
326 + async listModelIDs(apiKey: string, baseURLOverride?: string): Promise<string[]> {
327 + const url = joinURL(
328 + this.baseURL(baseURLOverride !== undefined ? { baseURLOverride } : undefined),
329 + 'models'
330 + )
331 + const responseText = await requestJSON(
332 + url,
333 + { method: 'GET', headers: { Authorization: `Bearer ${apiKey}` } },
334 + this.provider
335 + )
336 + // Together returns a bare array; everyone else wraps in {"data": […]}.
337 + // Gemini's compat endpoint prefixes IDs with "models/" — normalize.
338 + let parsed: unknown
339 + try {
340 + parsed = JSON.parse(responseText)
341 + } catch {
342 + throw ProviderError.invalidResponse(this.provider, 'unrecognized /models response shape')
343 + }
344 + let ids: string[]
345 + if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { data?: unknown }).data)) {
346 + ids = ((parsed as { data: { id?: unknown }[] }).data ?? [])
347 + .map((entry) => entry.id)
348 + .filter((id): id is string => typeof id === 'string')
349 + } else if (Array.isArray(parsed)) {
350 + ids = (parsed as { id?: unknown }[])
351 + .map((entry) => entry.id)
352 + .filter((id): id is string => typeof id === 'string')
353 + } else {
354 + throw ProviderError.invalidResponse(this.provider, 'unrecognized /models response shape')
355 + }
356 + return ids.map((id) => (id.startsWith('models/') ? id.slice(7) : id))
357 + }
358 +}
359 +
360 +function wireMessage(message: Message, vision: boolean): WireMessage {
361 + const role = message.role === 'assistant' ? 'assistant' : 'user'
362 + let text = message.text
363 + // Text-file attachments are injected inline, fenced with the file name.
364 + for (const attachment of message.attachments ?? []) {
365 + if (attachment.kind === 'textFile') {
366 + text += `\n\n\`\`\`${attachment.fileName}\n${attachment.data}\n\`\`\``
367 + }
368 + }
369 + const images = (message.attachments ?? []).filter((a) => a.kind === 'image')
370 + if (!vision || images.length === 0 || message.role !== 'user') {
371 + return { role, content: text }
372 + }
373 + const parts: WirePart[] = [{ type: 'text', text }]
374 + for (const image of images) {
375 + parts.push({
376 + type: 'image_url',
377 + image_url: { url: `data:${image.mimeType};base64,${image.data}` },
378 + })
379 + }
380 + return { role, content: parts }
381 +}
added src/providers/registry.ts +151 −0
@@ -0,0 +1,151 @@
1 +/*
2 + * registry.ts
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Provider metadata + client resolution, ported from ProviderID.swift and
9 + * ProviderRegistry.swift. The only place that knows which wire format each
10 + * provider speaks and where its API lives.
11 + */
12 +
13 +import type { AIModel, Provider, WireFormat } from '../types'
14 +
15 +export interface ProviderMeta {
16 + displayName: string
17 + wireFormat: WireFormat
18 + /** Base URL of the provider's API (chat + models live under this root). */
19 + defaultBaseURL: string | null
20 + /** Whether the provider exposes a usable `/models` listing endpoint. */
21 + supportsModelListing: boolean
22 + /**
23 + * Providers whose final streamed chunk carries usage only when asked via
24 + * stream_options. Mistral rejects unknown params; Qwen/DeepInfra/Perplexity
25 + * include usage automatically.
26 + */
27 + wantsStreamOptions: boolean
28 + /** CORS verdict from docs/CORS-MATRIX.md (all 12 work; Anthropic needs a header). */
29 + corsDirect: boolean
30 +}
31 +
32 +export const PROVIDER_META: Record<Provider, ProviderMeta> = {
33 + openai: {
34 + displayName: 'OpenAI',
35 + wireFormat: 'openAIChatCompletions',
36 + defaultBaseURL: 'https://api.openai.com/v1',
37 + supportsModelListing: true,
38 + wantsStreamOptions: true,
39 + corsDirect: true,
40 + },
41 + anthropic: {
42 + displayName: 'Anthropic',
43 + wireFormat: 'anthropicMessages',
44 + defaultBaseURL: 'https://api.anthropic.com/v1',
45 + supportsModelListing: true,
46 + wantsStreamOptions: false,
47 + corsDirect: true,
48 + },
49 + xai: {
50 + displayName: 'xAI',
51 + wireFormat: 'openAIChatCompletions',
52 + defaultBaseURL: 'https://api.x.ai/v1',
53 + supportsModelListing: true,
54 + wantsStreamOptions: true,
55 + corsDirect: true,
56 + },
57 + mistral: {
58 + displayName: 'Mistral',
59 + wireFormat: 'openAIChatCompletions',
60 + defaultBaseURL: 'https://api.mistral.ai/v1',
61 + supportsModelListing: true,
62 + wantsStreamOptions: false,
63 + corsDirect: true,
64 + },
65 + gemini: {
66 + displayName: 'Google Gemini',
67 + wireFormat: 'openAIChatCompletions',
68 + defaultBaseURL: 'https://generativelanguage.googleapis.com/v1beta/openai',
69 + supportsModelListing: true,
70 + wantsStreamOptions: true,
71 + corsDirect: true,
72 + },
73 + qwen: {
74 + displayName: 'Alibaba Qwen',
75 + wireFormat: 'openAIChatCompletions',
76 + defaultBaseURL: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
77 + supportsModelListing: true,
78 + wantsStreamOptions: false,
79 + corsDirect: true,
80 + },
81 + deepseek: {
82 + displayName: 'DeepSeek',
83 + wireFormat: 'openAIChatCompletions',
84 + defaultBaseURL: 'https://api.deepseek.com',
85 + supportsModelListing: true,
86 + wantsStreamOptions: true,
87 + corsDirect: true,
88 + },
89 + kimi: {
90 + displayName: 'Kimi',
91 + wireFormat: 'openAIChatCompletions',
92 + defaultBaseURL: 'https://api.moonshot.ai/v1',
93 + supportsModelListing: true,
94 + wantsStreamOptions: true,
95 + corsDirect: true,
96 + },
97 + perplexity: {
98 + displayName: 'Perplexity',
99 + wireFormat: 'openAIChatCompletions',
100 + defaultBaseURL: 'https://api.perplexity.ai',
101 + supportsModelListing: false,
102 + wantsStreamOptions: false,
103 + corsDirect: true,
104 + },
105 + together: {
106 + displayName: 'Together AI',
107 + wireFormat: 'openAIChatCompletions',
108 + defaultBaseURL: 'https://api.together.xyz/v1',
109 + supportsModelListing: true,
110 + wantsStreamOptions: true,
111 + corsDirect: true,
112 + },
113 + deepinfra: {
114 + displayName: 'DeepInfra',
115 + wireFormat: 'openAIChatCompletions',
116 + defaultBaseURL: 'https://api.deepinfra.com/v1/openai',
117 + supportsModelListing: true,
118 + wantsStreamOptions: false,
119 + corsDirect: true,
120 + },
121 + cerebras: {
122 + displayName: 'Cerebras',
123 + wireFormat: 'openAIChatCompletions',
124 + defaultBaseURL: 'https://api.cerebras.ai/v1',
125 + supportsModelListing: true,
126 + wantsStreamOptions: true,
127 + corsDirect: true,
128 + },
129 + custom: {
130 + displayName: 'Custom',
131 + wireFormat: 'openAIChatCompletions',
132 + defaultBaseURL: null,
133 + supportsModelListing: true,
134 + wantsStreamOptions: true,
135 + corsDirect: true,
136 + },
137 +}
138 +
139 +import type { ProviderClient } from './types'
140 +import { OpenAICompatibleClient } from './openaiCompatible'
141 +import { AnthropicClient } from './anthropic'
142 +
143 +/** Resolves the right client for a provider or custom model. */
144 +export function clientFor(providerOrModel: Provider | AIModel): ProviderClient {
145 + const provider =
146 + typeof providerOrModel === 'string' ? providerOrModel : providerOrModel.provider
147 + if (PROVIDER_META[provider].wireFormat === 'anthropicMessages') {
148 + return new AnthropicClient()
149 + }
150 + return new OpenAICompatibleClient(provider)
151 +}
added src/providers/sse.ts +190 −0
@@ -0,0 +1,190 @@
1 +/*
2 + * sse.ts
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * fetch + ReadableStream SSE parsing, ported from the native SSEParser /
9 + * StreamingService. One parser handles both stream shapes: OpenAI-style
10 + * anonymous `data:` events and Anthropic's named `event:` blocks.
11 + */
12 +
13 +import type { Provider } from '../types'
14 +import { ProviderError } from './types'
15 +
16 +/** One Server-Sent Event as parsed off the wire. */
17 +export interface SSEEvent {
18 + /** The `event:` field, if the stream names its events (Anthropic does). */
19 + event: string | null
20 + /** Joined `data:` lines. */
21 + data: string
22 +}
23 +
24 +/**
25 + * Incremental SSE parser. Feed it raw lines (without trailing newlines) and it
26 + * yields complete events at blank-line boundaries, ignoring `:` comment lines
27 + * (DeepSeek sends `: keep-alive`) and unknown fields.
28 + */
29 +export class SSEParser {
30 + private currentEvent: string | null = null
31 + private currentData: string[] = []
32 +
33 + /** Consumes one line. Returns a completed event at a blank separator, else null. */
34 + consume(line: string): SSEEvent | null {
35 + if (line === '') {
36 + if (this.currentData.length === 0 && this.currentEvent === null) return null
37 + const event: SSEEvent = { event: this.currentEvent, data: this.currentData.join('\n') }
38 + this.currentEvent = null
39 + this.currentData = []
40 + return event.data === '' && event.event === null ? null : event
41 + }
42 + if (line.startsWith(':')) return null // comment / keep-alive
43 + if (line.startsWith('event:')) {
44 + this.currentEvent = line.slice(6).trim()
45 + } else if (line.startsWith('data:')) {
46 + let value = line.slice(5)
47 + if (value.startsWith(' ')) value = value.slice(1)
48 + this.currentData.push(value)
49 + }
50 + // id:/retry:/unknown fields are ignored.
51 + return null
52 + }
53 +}
54 +
55 +/**
56 + * POSTs `body` as JSON and yields the SSE events of the response.
57 + * Throws ProviderError on non-2xx status (reading the full error body).
58 + * Cancellation: abort the signal — surfaces as a `cancelled` ProviderError.
59 + */
60 +export async function* sseEvents(
61 + url: string,
62 + init: { headers: Record<string, string>; body: string },
63 + provider: Provider,
64 + signal?: AbortSignal
65 +): AsyncGenerator<SSEEvent> {
66 + let res: Response
67 + try {
68 + res = await fetch(url, {
69 + method: 'POST',
70 + headers: init.headers,
71 + body: init.body,
72 + ...(signal ? { signal } : {}),
73 + })
74 + } catch (err) {
75 + if (err instanceof DOMException && err.name === 'AbortError') throw ProviderError.cancelled()
76 + throw ProviderError.network(provider, err)
77 + }
78 +
79 + if (!res.ok) {
80 + const body = await res.text().catch(() => '')
81 + const retryAfter = parseRetryAfter(res)
82 + throw ProviderError.from(res.status, body, provider, retryAfter)
83 + }
84 + if (!res.body) {
85 + throw ProviderError.invalidResponse(provider, 'response had no body')
86 + }
87 +
88 + const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
89 + const parser = new SSEParser()
90 + let buffer = ''
91 +
92 + try {
93 + for (;;) {
94 + let chunk: ReadableStreamReadResult<string>
95 + try {
96 + chunk = await reader.read()
97 + } catch (err) {
98 + if (err instanceof DOMException && err.name === 'AbortError') throw ProviderError.cancelled()
99 + throw ProviderError.network(provider, err)
100 + }
101 + if (chunk.done) break
102 + buffer += chunk.value
103 + // Split on newlines, preserving blank lines (they are event separators).
104 + let newlineIndex: number
105 + while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
106 + let line = buffer.slice(0, newlineIndex)
107 + buffer = buffer.slice(newlineIndex + 1)
108 + if (line.endsWith('\r')) line = line.slice(0, -1)
109 + const event = parser.consume(line)
110 + if (event) yield event
111 + }
112 + }
113 + // Flush a trailing line + event if the stream ended without a final
114 + // newline / blank separator.
115 + if (buffer !== '') {
116 + const event = parser.consume(buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer)
117 + if (event) yield event
118 + }
119 + const finalEvent = parser.consume('')
120 + if (finalEvent) yield finalEvent
121 + } finally {
122 + reader.cancel().catch(() => {})
123 + }
124 +}
125 +
126 +/**
127 + * Non-streaming JSON request with exponential backoff on 429/5xx (3 attempts,
128 + * honoring Retry-After). Returns the response body text.
129 + */
130 +export async function requestJSON(
131 + url: string,
132 + init: { method: 'GET' | 'POST'; headers: Record<string, string>; body?: string },
133 + provider: Provider,
134 + signal?: AbortSignal
135 +): Promise<string> {
136 + const maxAttempts = 3
137 + let lastError: ProviderError = ProviderError.invalidResponse(provider, 'no attempts made')
138 + for (let attempt = 1; attempt <= maxAttempts; attempt++) {
139 + let res: Response
140 + try {
141 + res = await fetch(url, {
142 + method: init.method,
143 + headers: init.headers,
144 + ...(init.body !== undefined ? { body: init.body } : {}),
145 + ...(signal ? { signal } : {}),
146 + })
147 + } catch (err) {
148 + if (err instanceof DOMException && err.name === 'AbortError') throw ProviderError.cancelled()
149 + throw ProviderError.network(provider, err)
150 + }
151 + const body = await res.text().catch(() => '')
152 + if (res.ok) return body
153 + const retryAfter = parseRetryAfter(res)
154 + const error = ProviderError.from(res.status, body, provider, retryAfter)
155 + if (attempt < maxAttempts && (res.status === 429 || res.status >= 500)) {
156 + lastError = error
157 + const delay = (retryAfter ?? 2 ** attempt * 2) * 1000 // 4s, 8s
158 + await sleep(delay, signal)
159 + continue
160 + }
161 + throw error
162 + }
163 + throw lastError
164 +}
165 +
166 +function parseRetryAfter(res: Response): number | undefined {
167 + const header = res.headers.get('Retry-After')
168 + if (!header) return undefined
169 + const seconds = Number(header)
170 + return Number.isFinite(seconds) ? seconds : undefined
171 +}
172 +
173 +function sleep(ms: number, signal?: AbortSignal): Promise<void> {
174 + return new Promise((resolve, reject) => {
175 + const timer = setTimeout(resolve, ms)
176 + signal?.addEventListener(
177 + 'abort',
178 + () => {
179 + clearTimeout(timer)
180 + reject(ProviderError.cancelled())
181 + },
182 + { once: true }
183 + )
184 + })
185 +}
186 +
187 +/** Joins a base URL and a path, preserving base path components. */
188 +export function joinURL(base: string, path: string): string {
189 + return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
190 +}
added src/providers/types.ts +228 −0
@@ -0,0 +1,228 @@
1 +/*
2 + * types.ts (providers)
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Provider-agnostic client contract, ported from ProviderProtocol.swift.
9 + * Provider behavior differences never leak above this layer.
10 + */
11 +
12 +import type {
13 + AIModel,
14 + ChatParameters,
15 + Citation,
16 + Message,
17 + Provider,
18 + TokenUsage,
19 +} from '../types'
20 +import { PROVIDER_META } from './registry'
21 +
22 +/** A provider-agnostic chat request. Clients translate into their wire format. */
23 +export interface ChatRequest {
24 + model: AIModel
25 + systemPrompt?: string
26 + messages: Message[]
27 + parameters: ChatParameters
28 + stream: boolean
29 + /** Per-provider base-URL override (user proxy / Zyquo Router / custom model). */
30 + baseURLOverride?: string
31 +}
32 +
33 +/** Incremental events surfaced while a response streams. */
34 +export type ChatEvent =
35 + | { type: 'reasoningDelta'; text: string }
36 + | { type: 'textDelta'; text: string }
37 + | { type: 'citations'; citations: Citation[] }
38 + | { type: 'usage'; usage: TokenUsage }
39 + | { type: 'finished'; reason: string | null }
40 +
41 +/** The assembled result of a completion. */
42 +export interface CompletionResult {
43 + text: string
44 + reasoning?: string
45 + citations?: Citation[]
46 + usage?: TokenUsage
47 +}
48 +
49 +/** One cloud AI provider client. */
50 +export interface ProviderClient {
51 + readonly provider: Provider
52 +
53 + /** Streams a chat completion; yields ChatEvents, throws ProviderError. */
54 + streamChat(request: ChatRequest, apiKey: string, signal?: AbortSignal): AsyncGenerator<ChatEvent>
55 +
56 + /** Non-streaming completion (title generation, key tests, verify harness). */
57 + complete(request: ChatRequest, apiKey: string, signal?: AbortSignal): Promise<CompletionResult>
58 +
59 + /** Model IDs currently served by the provider, for dynamic catalog refresh. */
60 + listModelIDs(apiKey: string, baseURLOverride?: string): Promise<string[]>
61 +}
62 +
63 +export type ProviderErrorKind =
64 + | 'invalidAPIKey'
65 + | 'rateLimited'
66 + | 'serverError'
67 + | 'badRequest'
68 + | 'networkError'
69 + | 'invalidResponse'
70 + | 'missingAPIKey'
71 + | 'noModelAvailable'
72 + | 'cancelled'
73 +
74 +/** Errors mapped to clear, human-readable messages. */
75 +export class ProviderError extends Error {
76 + readonly kind: ProviderErrorKind
77 + readonly provider: Provider | null
78 + readonly status?: number
79 + readonly retryAfter?: number
80 +
81 + constructor(
82 + kind: ProviderErrorKind,
83 + provider: Provider | null,
84 + message: string,
85 + opts: { status?: number; retryAfter?: number } = {}
86 + ) {
87 + super(message)
88 + this.name = 'ProviderError'
89 + this.kind = kind
90 + this.provider = provider
91 + if (opts.status !== undefined) this.status = opts.status
92 + if (opts.retryAfter !== undefined) this.retryAfter = opts.retryAfter
93 + }
94 +
95 + static displayName(provider: Provider | null): string {
96 + return provider ? PROVIDER_META[provider].displayName : 'the provider'
97 + }
98 +
99 + static invalidAPIKey(provider: Provider): ProviderError {
100 + return new ProviderError(
101 + 'invalidAPIKey',
102 + provider,
103 + `Invalid API key for ${ProviderError.displayName(provider)}.`
104 + )
105 + }
106 +
107 + static rateLimited(provider: Provider, retryAfter?: number): ProviderError {
108 + const suffix =
109 + retryAfter !== undefined
110 + ? `retry in ${Math.round(retryAfter)}s.`
111 + : 'please retry shortly.'
112 + return new ProviderError(
113 + 'rateLimited',
114 + provider,
115 + `${ProviderError.displayName(provider)} rate limited — ${suffix}`,
116 + retryAfter !== undefined ? { retryAfter } : {}
117 + )
118 + }
119 +
120 + static badRequest(provider: Provider, message: string | null): ProviderError {
121 + return new ProviderError(
122 + 'badRequest',
123 + provider,
124 + `${ProviderError.displayName(provider)} rejected the request${message ? `: ${message}` : ''}.`
125 + )
126 + }
127 +
128 + static serverError(provider: Provider, status: number, message: string | null): ProviderError {
129 + return new ProviderError(
130 + 'serverError',
131 + provider,
132 + `${ProviderError.displayName(provider)} server error (${status})${message ? `: ${message}` : ''}.`,
133 + { status }
134 + )
135 + }
136 +
137 + static invalidResponse(provider: Provider, detail: string): ProviderError {
138 + return new ProviderError(
139 + 'invalidResponse',
140 + provider,
141 + `Unexpected response from ${ProviderError.displayName(provider)}: ${detail}`
142 + )
143 + }
144 +
145 + static missingAPIKey(provider: Provider): ProviderError {
146 + return new ProviderError(
147 + 'missingAPIKey',
148 + provider,
149 + `No API key configured for ${ProviderError.displayName(provider)}. Add one in Settings → Providers & Keys.`
150 + )
151 + }
152 +
153 + static noModelAvailable(provider: Provider): ProviderError {
154 + return new ProviderError(
155 + 'noModelAvailable',
156 + provider,
157 + `No model available for ${ProviderError.displayName(provider)}.`
158 + )
159 + }
160 +
161 + static cancelled(): ProviderError {
162 + return new ProviderError('cancelled', null, 'Generation stopped.')
163 + }
164 +
165 + /**
166 + * A fetch TypeError with no HTTP status usually means the request never left
167 + * the browser (network down) or CORS blocked it — explain the fallback.
168 + */
169 + static network(provider: Provider, underlying: unknown): ProviderError {
170 + const detail = underlying instanceof Error ? underlying.message : String(underlying)
171 + return new ProviderError(
172 + 'networkError',
173 + provider,
174 + `Couldn't reach ${ProviderError.displayName(provider)} (${detail}). ` +
175 + `If this persists, the provider may be blocking browser calls — ` +
176 + `set a proxy URL in Settings or run Zyquo Router locally.`
177 + )
178 + }
179 +
180 + /** Maps an HTTP status + provider error body to a typed error. */
181 + static from(status: number, body: string, provider: Provider, retryAfter?: number): ProviderError {
182 + const message = extractErrorMessage(body)
183 + switch (status) {
184 + case 401:
185 + case 403:
186 + return ProviderError.invalidAPIKey(provider)
187 + case 429:
188 + return ProviderError.rateLimited(provider, retryAfter)
189 + case 400:
190 + case 404:
191 + case 422:
192 + return ProviderError.badRequest(provider, message)
193 + default:
194 + return ProviderError.serverError(provider, status, message)
195 + }
196 + }
197 +}
198 +
199 +/**
200 + * Providers wrap errors differently ({"error":{"message":…}}, {"message":…},
201 + * {"error":"…"}, Gemini arrays…). Try the common shapes.
202 + */
203 +export function extractErrorMessage(body: string): string | null {
204 + let obj: unknown
205 + try {
206 + obj = JSON.parse(body)
207 + } catch {
208 + return body.slice(0, 300) || null
209 + }
210 + if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
211 + const dict = obj as Record<string, unknown>
212 + const err = dict['error']
213 + if (err && typeof err === 'object' && typeof (err as Record<string, unknown>)['message'] === 'string') {
214 + return (err as Record<string, unknown>)['message'] as string
215 + }
216 + if (typeof err === 'string') return err
217 + if (typeof dict['message'] === 'string') return dict['message']
218 + if (typeof dict['detail'] === 'string') return dict['detail']
219 + }
220 + if (Array.isArray(obj) && obj.length > 0) {
221 + const first = obj[0] as Record<string, unknown> | undefined
222 + const err = first?.['error']
223 + if (err && typeof err === 'object' && typeof (err as Record<string, unknown>)['message'] === 'string') {
224 + return (err as Record<string, unknown>)['message'] as string
225 + }
226 + }
227 + return body.slice(0, 300) || null
228 +}
added src/storage/keys.ts +107 −0
@@ -0,0 +1,107 @@
1 +/*
2 + * keys.ts
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Provider API key storage — plain localStorage under zyquo.cloud.web.keys,
9 + * namespaced JSON map. This module (and the rest of storage/) is the ONLY
10 + * place that touches localStorage. Keys never leave the device except inside
11 + * a provider request the user initiated. Optional passphrase lock (WebCrypto
12 + * AES-GCM) lives in vault.ts and wraps this map at rest.
13 + */
14 +
15 +import type { Provider } from '../types'
16 +
17 +const KEYS_KEY = 'zyquo.cloud.web.keys'
18 +
19 +export type KeyMap = Partial<Record<Provider, string>>
20 +
21 +/** In-memory overlay used while the passphrase lock is active (Phase 3). */
22 +let unlockedOverlay: KeyMap | null = null
23 +
24 +export function setUnlockedOverlay(map: KeyMap | null): void {
25 + unlockedOverlay = map
26 +}
27 +
28 +export function isOverlayActive(): boolean {
29 + return unlockedOverlay !== null
30 +}
31 +
32 +export function readKeyMap(): KeyMap {
33 + if (unlockedOverlay) return { ...unlockedOverlay }
34 + try {
35 + const raw = localStorage.getItem(KEYS_KEY)
36 + if (!raw) return {}
37 + const parsed: unknown = JSON.parse(raw)
38 + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
39 + return parsed as KeyMap
40 + }
41 + return {}
42 + } catch {
43 + return {}
44 + }
45 +}
46 +
47 +function writeKeyMap(map: KeyMap): void {
48 + if (unlockedOverlay) {
49 + unlockedOverlay = { ...map }
50 + return
51 + }
52 + localStorage.setItem(KEYS_KEY, JSON.stringify(map))
53 +}
54 +
55 +export function getKey(provider: Provider): string | undefined {
56 + const value = readKeyMap()[provider]
57 + return value && value !== '' ? value : undefined
58 +}
59 +
60 +export function setKey(provider: Provider, key: string): void {
61 + const map = readKeyMap()
62 + map[provider] = key
63 + writeKeyMap(map)
64 +}
65 +
66 +export function removeKey(provider: Provider): void {
67 + const map = readKeyMap()
68 + delete map[provider]
69 + writeKeyMap(map)
70 +}
71 +
72 +export function hasKey(provider: Provider): boolean {
73 + return getKey(provider) !== undefined
74 +}
75 +
76 +export function configuredProviders(): Provider[] {
77 + return Object.entries(readKeyMap())
78 + .filter(([, v]) => typeof v === 'string' && v !== '')
79 + .map(([k]) => k as Provider)
80 +}
81 +
82 +/** Masked display: last 4 characters only (e.g. "••••••••Q3ew"). */
83 +export function maskedKey(key: string): string {
84 + if (key.length <= 4) return '••••'
85 + return `${'•'.repeat(8)}${key.slice(-4)}`
86 +}
87 +
88 +/** Replaces the whole map (import / passphrase unlock). */
89 +export function replaceKeyMap(map: KeyMap): void {
90 + writeKeyMap(map)
91 +}
92 +
93 +export function clearAllKeys(): void {
94 + if (unlockedOverlay) {
95 + unlockedOverlay = {}
96 + }
97 + localStorage.removeItem(KEYS_KEY)
98 +}
99 +
100 +/** Raw ciphertext/plaintext blob accessors for the passphrase vault (Phase 3). */
101 +export function rawKeysRecord(): string | null {
102 + return localStorage.getItem(KEYS_KEY)
103 +}
104 +
105 +export function setRawKeysRecord(value: string): void {
106 + localStorage.setItem(KEYS_KEY, value)
107 +}
added src/types/index.ts +238 −0
@@ -0,0 +1,238 @@
1 +/*
2 + * index.ts (types)
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Core data model, ported 1:1 from native Zyquo Cloud
9 + * (ProviderID.swift, AIModel.swift, Message.swift, Conversation.swift).
10 + */
11 +
12 +/** The 12 built-in cloud AI providers, plus user-defined custom endpoints. */
13 +export type Provider =
14 + | 'openai'
15 + | 'anthropic'
16 + | 'xai'
17 + | 'mistral'
18 + | 'gemini'
19 + | 'qwen'
20 + | 'deepseek'
21 + | 'kimi'
22 + | 'perplexity'
23 + | 'together'
24 + | 'deepinfra'
25 + | 'cerebras'
26 + | 'custom'
27 +
28 +export const BUILT_IN_PROVIDERS: readonly Provider[] = [
29 + 'openai',
30 + 'anthropic',
31 + 'xai',
32 + 'mistral',
33 + 'gemini',
34 + 'qwen',
35 + 'deepseek',
36 + 'kimi',
37 + 'perplexity',
38 + 'together',
39 + 'deepinfra',
40 + 'cerebras',
41 +]
42 +
43 +/** The request/response schema a provider speaks. */
44 +export type WireFormat = 'openAIChatCompletions' | 'anthropicMessages'
45 +
46 +/** What a model can do. Drives UI affordances and request construction. */
47 +export interface ModelCapabilities {
48 + vision: boolean
49 + tools: boolean
50 + reasoning: boolean
51 + streaming: boolean
52 + jsonMode: boolean
53 + citations: boolean
54 +}
55 +
56 +/** USD per 1M tokens (base rate — UI labels costs as estimates). */
57 +export interface ModelPricing {
58 + inputPerMTok: number
59 + outputPerMTok: number
60 +}
61 +
62 +/** Estimated cost in USD for a usage record. */
63 +export function pricingCost(pricing: ModelPricing, inputTokens: number, outputTokens: number): number {
64 + return (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1_000_000
65 +}
66 +
67 +/**
68 + * Which sampling/control parameters a model accepts. Providers reject requests
69 + * carrying unsupported parameters, so requests only include what's supported —
70 + * and the params panel only shows controls that apply.
71 + */
72 +export interface ParameterSupport {
73 + temperature: boolean
74 + topP: boolean
75 + frequencyPenalty: boolean
76 + presencePenalty: boolean
77 + /** Send "max_completion_tokens" instead of "max_tokens". */
78 + usesMaxCompletionTokens: boolean
79 + /** Accepts `reasoning_effort`. */
80 + reasoningEffort: boolean
81 + /** Anthropic `thinking` / Qwen `enable_thinking` explicit toggle. */
82 + thinkingToggle: boolean
83 + /** Model rejects non-streaming calls — `complete` aggregates a stream. */
84 + requiresStreaming: boolean
85 +}
86 +
87 +/** A chat-capable model offered by a provider. Instances come from the catalog. */
88 +export interface AIModel {
89 + /** Exact model ID as sent in API requests. */
90 + id: string
91 + provider: Provider
92 + displayName: string
93 + contextWindow: number
94 + maxOutputTokens: number | null
95 + capabilities: ModelCapabilities
96 + pricing: ModelPricing | null
97 + parameterSupport: ParameterSupport
98 + isLegacy: boolean
99 + isRecommended: boolean
100 + /** Base URL override for user-defined custom models; undefined for built-ins. */
101 + customBaseURL?: string
102 +}
103 +
104 +/** Short badge text for the model chip (e.g. "1M ctx"). */
105 +export function contextBadge(model: AIModel): string {
106 + const ctx = model.contextWindow
107 + if (ctx >= 1_000_000) return `${Math.floor(ctx / 1_000_000)}M ctx`
108 + if (ctx >= 1_000) return `${Math.floor(ctx / 1_000)}K ctx`
109 + return `${ctx} ctx`
110 +}
111 +
112 +/** Token usage reported by a provider for one exchange. */
113 +export interface TokenUsage {
114 + inputTokens: number
115 + outputTokens: number
116 + reasoningTokens?: number
117 +}
118 +
119 +export function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
120 + const reasoning = (a.reasoningTokens ?? 0) + (b.reasoningTokens ?? 0)
121 + return {
122 + inputTokens: a.inputTokens + b.inputTokens,
123 + outputTokens: a.outputTokens + b.outputTokens,
124 + ...(reasoning > 0 ? { reasoningTokens: reasoning } : {}),
125 + }
126 +}
127 +
128 +/** One numbered web citation (Perplexity sonar family). */
129 +export interface Citation {
130 + index: number
131 + url: string
132 + title?: string
133 +}
134 +
135 +export type AttachmentKind = 'image' | 'textFile'
136 +
137 +/** An image or text-file attachment on a user message. */
138 +export interface Attachment {
139 + id: string
140 + kind: AttachmentKind
141 + fileName: string
142 + mimeType: string
143 + /** Base64 payload (images) or UTF-8 text content (text files). */
144 + data: string
145 +}
146 +
147 +export type MessageRole = 'user' | 'assistant' | 'system'
148 +
149 +/** One chat turn. */
150 +export interface Message {
151 + id: string
152 + role: MessageRole
153 + text: string
154 + reasoning?: string
155 + citations?: Citation[]
156 + attachments?: Attachment[]
157 + modelID?: string
158 + provider?: Provider
159 + usage?: TokenUsage
160 + estimatedCost?: number
161 + errorText?: string
162 + createdAt: number
163 + /** Alternate assistant responses (regenerate variants); active one is `text`. */
164 + variants?: MessageVariant[]
165 + /** Index of the active variant in `variants`, if any. */
166 + activeVariant?: number
167 + pinned?: boolean
168 + note?: string
169 +}
170 +
171 +/** A stored alternate response for one assistant turn. */
172 +export interface MessageVariant {
173 + text: string
174 + reasoning?: string
175 + citations?: Citation[]
176 + modelID?: string
177 + provider?: Provider
178 + usage?: TokenUsage
179 + estimatedCost?: number
180 + createdAt: number
181 +}
182 +
183 +/** Per-conversation sampling parameters. undefined = provider default (omitted). */
184 +export interface ChatParameters {
185 + temperature?: number
186 + topP?: number
187 + maxTokens?: number
188 + frequencyPenalty?: number
189 + presencePenalty?: number
190 + reasoningEffort?: 'low' | 'medium' | 'high'
191 + thinkingEnabled?: boolean
192 +}
193 +
194 +/** One conversation thread. */
195 +export interface Conversation {
196 + id: string
197 + title: string
198 + createdAt: number
199 + updatedAt: number
200 + modelID: string
201 + provider: Provider
202 + systemPrompt?: string
203 + parameters: ChatParameters
204 + messages: Message[]
205 + pinned: boolean
206 + archived?: boolean
207 + tags?: string[]
208 + personaID?: string
209 + /** False once the user manually renames (disables auto-titling). */
210 + hasAutoTitle: boolean
211 + /** Conversation this one was branched from, if any. */
212 + branchedFrom?: { conversationID: string; messageID: string }
213 +}
214 +
215 +export type ThemeMode = 'light' | 'dark' | 'system'
216 +export type AccentChoice = 'indigo' | 'graphite' | 'teal' | 'amber' | 'rose'
217 +export type MessageDensity = 'comfortable' | 'compact'
218 +
219 +/** Global app settings (storage/settings.ts). */
220 +export interface Settings {
221 + theme: ThemeMode
222 + accent: AccentChoice
223 + chatFontSize: number
224 + density: MessageDensity
225 + defaultModelID: string
226 + defaultProvider: Provider
227 + defaultSystemPrompt: string
228 + defaultParameters: ChatParameters
229 + /** Per-provider base-URL overrides (user proxy / Zyquo Router). */
230 + proxyBaseURLs: Partial<Record<Provider, string>>
231 + streamingEnabled: boolean
232 + /** User-defined model aliases (e.g. "fast" → model). */
233 + aliases: Record<string, { modelID: string; provider: Provider }>
234 + favoriteModelIDs: string[]
235 + recentModelIDs: string[]
236 + firstRunAcknowledged: boolean
237 + focusMode: boolean
238 +}
modified tsconfig.app.tsbuildinfo +1 −1
@@ -1 +1 @@
1 {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"}
\ No newline at end of file
1 +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/features/streamtestview.tsx","./src/providers/anthropic.ts","./src/providers/catalog.ts","./src/providers/openaicompatible.ts","./src/providers/registry.ts","./src/providers/sse.ts","./src/providers/types.ts","./src/storage/keys.ts","./src/types/index.ts"],"version":"5.9.3"}
\ No newline at end of file
2 2