SPB Git

spb/zyquo-cloud-web

Public MIT

Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.

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 (Jul 31, 2026) parent 26dd5bf Browse files

15 changed files +2,258 −3

modified docs/PLAN.md +19 −1
@@ -54,7 +54,25 @@ headers everywhere, ZyquoTheme tokens as CSS variables (light default + dark +
54 origins plus localhost for Zyquo Router, and an app-shell-only PWA. Build, 54 origins plus localhost for Zyquo Router, and an app-shell-only PWA. Build,
55 lint, and typecheck all green on first checkpoint. 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 ## Phase 3 — Local persistence — pending 77 ## Phase 3 — Local persistence — pending
60 78
modified package-lock.json +48 −0
@@ -21,6 +21,7 @@
21 "@vitejs/plugin-react": "^4.6.0", 21 "@vitejs/plugin-react": "^4.6.0",
22 "eslint": "^9.30.0", 22 "eslint": "^9.30.0",
23 "eslint-plugin-react-hooks": "^5.2.0", 23 "eslint-plugin-react-hooks": "^5.2.0",
24+ "playwright": "^1.62.1",
24 "prettier": "^3.6.0", 25 "prettier": "^3.6.0",
25 "typescript": "^5.8.3", 26 "typescript": "^5.8.3",
26 "vite": "^7.0.0", 27 "vite": "^7.0.0",
@@ -5957,6 +5958,53 @@
5957 "url": "https://github.com/sponsors/jonschlinkert" 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 "node_modules/possible-typed-array-names": { 6008 "node_modules/possible-typed-array-names": {
5961 "version": "1.1.0", 6009 "version": "1.1.0",
5962 "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", 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 "@vitejs/plugin-react": "^4.6.0", 27 "@vitejs/plugin-react": "^4.6.0",
28 "eslint": "^9.30.0", 28 "eslint": "^9.30.0",
29 "eslint-plugin-react-hooks": "^5.2.0", 29 "eslint-plugin-react-hooks": "^5.2.0",
30+ "playwright": "^1.62.1",
30 "prettier": "^3.6.0", 31 "prettier": "^3.6.0",
31 "typescript": "^5.8.3", 32 "typescript": "^5.8.3",
32 "vite": "^7.0.0", 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 * Mail: contact@spboucher.ai 6 * Mail: contact@spboucher.ai
7 */ 7 */
8 8
9+import StreamTestView from './features/StreamTestView'
10+
9 // Phase 1 placeholder shell — replaced by the real chat layout in Phase 4. 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 export default function App() { 13 export default function App() {
14+ if (window.location.hash === '#stream-test') return <StreamTestView />
12 return ( 15 return (
13 <div 16 <div
14 style={{ 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
Large diff (190 lines) is not loaded by default to keep the page fast.Load diff
added src/providers/types.ts +228 −0
Large diff (228 lines) is not loaded by default to keep the page fast.Load diff
added src/storage/keys.ts +107 −0
Large diff (107 lines) is not loaded by default to keep the page fast.Load diff
added src/types/index.ts +238 −0
Large diff (238 lines) is not loaded by default to keep the page fast.Load diff
modified tsconfig.app.tsbuildinfo +1 −1
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme