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