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>
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 | |