phase7-prep: verification harness (matrix + cross-browser), production static server, README
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 4 changed files with +512 and −0
added
README.md
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +<!-- | |
| 2 | + README.md | |
| 3 | + Zyquo Cloud Web | |
| 4 | + | |
| 5 | + Author: Simon-Pierre Boucher | |
| 6 | + Mail: contact@spboucher.ai | |
| 7 | +--> | |
| 8 | + | |
| 9 | +# Zyquo Cloud Web | |
| 10 | + | |
| 11 | +**The browser edition of Zyquo Cloud** — a multi-provider AI chat that runs | |
| 12 | +entirely in your browser. **No account, no backend, no telemetry.** You bring | |
| 13 | +your own API keys; they live in your browser's `localStorage` and are sent | |
| 14 | +only to the provider you call, directly. Conversation history lives locally | |
| 15 | +in IndexedDB. | |
| 16 | + | |
| 17 | +Live at **https://www.zyquo.cloud** | |
| 18 | + | |
| 19 | +## What's inside | |
| 20 | + | |
| 21 | +- **12 providers, 170 models** — OpenAI, Anthropic, xAI, Mistral, Google | |
| 22 | + Gemini, Alibaba Qwen, DeepSeek, Kimi, Perplexity, Together AI, DeepInfra, | |
| 23 | + Cerebras — the exact catalog of native Zyquo Cloud, with capabilities and | |
| 24 | + pricing. All verified callable **directly from the browser** | |
| 25 | + (see `docs/CORS-MATRIX.md`). | |
| 26 | +- Streaming + non-streaming chat, per-conversation and per-message model | |
| 27 | + switching, a searchable/filterable/priced **Model Menu** (⌘K palette too). | |
| 28 | +- Branch/variants, edit & resend, multi-model **compare** (2–4 columns with | |
| 29 | + promote-to-thread), 8 personas + 56 prompt templates + `/` slash commands, | |
| 30 | + vision attachments, Markdown + code + KaTeX + Mermaid, reasoning panels, | |
| 31 | + Perplexity citations, read-aloud + dictation, token/cost HUD with context | |
| 32 | + auto-trim, export/import of all data, optional passphrase lock (AES-GCM). | |
| 33 | +- Installable PWA (app shell offline), light theme flagship, responsive to | |
| 34 | + phone widths. | |
| 35 | + | |
| 36 | +## Honest security model | |
| 37 | + | |
| 38 | +Keys in `localStorage` are **not** a secure vault: any script running on the | |
| 39 | +page or anyone with your browser profile can read them. This app mitigates by | |
| 40 | +being dependency-light, shipping a strict CSP (connections allowed only to the | |
| 41 | +12 provider origins + localhost), never sending your data anywhere else, and | |
| 42 | +offering an opt-in passphrase lock for at-rest encryption. The first-run | |
| 43 | +notice says exactly this. | |
| 44 | + | |
| 45 | +## Develop | |
| 46 | + | |
| 47 | +```bash | |
| 48 | +npm install | |
| 49 | +npm run dev # http://localhost:5173 | |
| 50 | +npm run check # typecheck + lint + build | |
| 51 | +``` | |
| 52 | + | |
| 53 | +## Self-host | |
| 54 | + | |
| 55 | +The build is 100% static: | |
| 56 | + | |
| 57 | +```bash | |
| 58 | +npm run build | |
| 59 | +node scripts/server.mjs 8080 dist # zero-dep server w/ security headers | |
| 60 | +# …or serve dist/ with any static host (Cloudflare Pages, Netlify, nginx…) | |
| 61 | +``` | |
| 62 | + | |
| 63 | +### Use with Zyquo Router | |
| 64 | + | |
| 65 | +If a provider ever blocks browser CORS (all 12 currently allow it), or you | |
| 66 | +want to route through a local gateway: run Zyquo Router locally and set the | |
| 67 | +provider's **Proxy / base URL** in Settings → Providers & Keys to your Router | |
| 68 | +endpoint (e.g. `http://localhost:8787/v1`). `localhost` is already allowed by | |
| 69 | +the CSP. | |
| 70 | + | |
| 71 | +## Verification | |
| 72 | + | |
| 73 | +`docs/VERIFICATION.md` — the full provider/model matrix (169 models tested | |
| 74 | +live from a real browser), cross-browser (Chromium/WebKit/Firefox) and | |
| 75 | +persistence gates. Gate scripts in `scripts/`. | |
added
scripts/server.mjs
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +/* | |
| 2 | + * server.mjs | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * Production static server (zero dependencies): serves the dist/ bundle with | |
| 9 | + * security headers (CSP mirroring index.html, HSTS, nosniff, referrer | |
| 10 | + * policy), immutable caching for hashed assets, no-cache for the HTML shell, | |
| 11 | + * SPA fallback, and a /healthz endpoint. Run: node server.mjs [port] [dir] | |
| 12 | + */ | |
| 13 | + | |
| 14 | +import { createServer } from 'node:http' | |
| 15 | +import { createReadStream, existsSync, statSync } from 'node:fs' | |
| 16 | +import { extname, join, normalize, resolve } from 'node:path' | |
| 17 | + | |
| 18 | +const PORT = Number(process.argv[2] ?? process.env.PORT ?? 8080) | |
| 19 | +const ROOT = resolve(process.argv[3] ?? join(process.cwd(), 'dist')) | |
| 20 | + | |
| 21 | +const CSP = [ | |
| 22 | + "default-src 'self'", | |
| 23 | + "script-src 'self'", | |
| 24 | + "style-src 'self' 'unsafe-inline'", | |
| 25 | + "img-src 'self' data: blob:", | |
| 26 | + "font-src 'self' data:", | |
| 27 | + "connect-src 'self' https://api.openai.com https://api.anthropic.com https://api.x.ai https://api.mistral.ai https://generativelanguage.googleapis.com https://dashscope-intl.aliyuncs.com https://api.deepseek.com https://api.moonshot.ai https://api.perplexity.ai https://api.together.xyz https://api.deepinfra.com https://api.cerebras.ai http://localhost:* http://127.0.0.1:*", | |
| 28 | + "worker-src 'self'", | |
| 29 | + "object-src 'none'", | |
| 30 | + "base-uri 'self'", | |
| 31 | + "form-action 'none'", | |
| 32 | + "frame-ancestors 'none'", | |
| 33 | +].join('; ') | |
| 34 | + | |
| 35 | +const MIME = { | |
| 36 | + '.html': 'text/html; charset=utf-8', | |
| 37 | + '.js': 'text/javascript; charset=utf-8', | |
| 38 | + '.css': 'text/css; charset=utf-8', | |
| 39 | + '.json': 'application/json', | |
| 40 | + '.svg': 'image/svg+xml', | |
| 41 | + '.png': 'image/png', | |
| 42 | + '.ico': 'image/x-icon', | |
| 43 | + '.woff2': 'font/woff2', | |
| 44 | + '.woff': 'font/woff', | |
| 45 | + '.webmanifest': 'application/manifest+json', | |
| 46 | + '.txt': 'text/plain; charset=utf-8', | |
| 47 | + '.map': 'application/json', | |
| 48 | +} | |
| 49 | + | |
| 50 | +const server = createServer((req, res) => { | |
| 51 | + const url = new URL(req.url ?? '/', 'http://localhost') | |
| 52 | + let pathname = decodeURIComponent(url.pathname) | |
| 53 | + | |
| 54 | + if (pathname === '/healthz') { | |
| 55 | + res.writeHead(200, { 'Content-Type': 'text/plain' }) | |
| 56 | + res.end('ok') | |
| 57 | + return | |
| 58 | + } | |
| 59 | + | |
| 60 | + // Resolve inside ROOT only (no traversal). | |
| 61 | + let filePath = normalize(join(ROOT, pathname)) | |
| 62 | + if (!filePath.startsWith(ROOT)) { | |
| 63 | + res.writeHead(403).end() | |
| 64 | + return | |
| 65 | + } | |
| 66 | + if (!existsSync(filePath) || statSync(filePath).isDirectory()) { | |
| 67 | + filePath = join(ROOT, 'index.html') // SPA fallback (also serves /) | |
| 68 | + } | |
| 69 | + | |
| 70 | + const ext = extname(filePath) | |
| 71 | + const hashed = /\/assets\//.test(filePath) || /-\w{8,}\./.test(filePath) | |
| 72 | + const headers = { | |
| 73 | + 'Content-Type': MIME[ext] ?? 'application/octet-stream', | |
| 74 | + 'Content-Security-Policy': CSP, | |
| 75 | + 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload', | |
| 76 | + 'X-Content-Type-Options': 'nosniff', | |
| 77 | + 'Referrer-Policy': 'no-referrer', | |
| 78 | + 'Permissions-Policy': 'camera=(), geolocation=(), payment=()', | |
| 79 | + 'Cache-Control': | |
| 80 | + ext === '.html' | |
| 81 | + ? 'no-cache' | |
| 82 | + : hashed | |
| 83 | + ? 'public, max-age=31536000, immutable' | |
| 84 | + : 'public, max-age=3600', | |
| 85 | + } | |
| 86 | + res.writeHead(200, headers) | |
| 87 | + createReadStream(filePath).pipe(res) | |
| 88 | +}) | |
| 89 | + | |
| 90 | +server.listen(PORT, '0.0.0.0', () => { | |
| 91 | + console.log(`Zyquo Cloud Web serving ${ROOT} on :${PORT}`) | |
| 92 | +}) | |
added
scripts/verify-browsers.mjs
+112 −0
@@ -0,0 +1,112 @@ | ||
| 1 | +/* | |
| 2 | + * verify-browsers.mjs | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * Phase 7 cross-browser smoke: Chromium, WebKit (Safari engine), Firefox — | |
| 9 | + * desktop and phone viewports. Checks: app loads, LIGHT theme is the default, | |
| 10 | + * no horizontal overflow, drawer opens on phone, and a real streaming | |
| 11 | + * completion renders in each engine. | |
| 12 | + */ | |
| 13 | + | |
| 14 | +import { chromium, firefox, webkit } from 'playwright' | |
| 15 | +import { readFileSync } from 'node:fs' | |
| 16 | + | |
| 17 | +const baseURL = process.argv[2] ?? 'http://localhost:5173' | |
| 18 | +const keys = JSON.parse(readFileSync(new URL('../.keys.local.json', import.meta.url), 'utf8')) | |
| 19 | +const results = [] | |
| 20 | +const check = (name, ok, detail = '') => { | |
| 21 | + results.push([name, ok]) | |
| 22 | + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) | |
| 23 | +} | |
| 24 | + | |
| 25 | +for (const [name, engine] of [ | |
| 26 | + ['chromium', chromium], | |
| 27 | + ['webkit', webkit], | |
| 28 | + ['firefox', firefox], | |
| 29 | +]) { | |
| 30 | + const browser = await engine.launch() | |
| 31 | + // Desktop | |
| 32 | + const page = await browser.newPage({ viewport: { width: 1240, height: 800 } }) | |
| 33 | + await page.goto(baseURL) | |
| 34 | + await page.waitForFunction(() => window.__zyquo !== undefined) | |
| 35 | + await page.evaluate((key) => { | |
| 36 | + window.__zyquo.keys.setKey('openai', key) | |
| 37 | + window.__zyquo.settings.saveSettings({ | |
| 38 | + ...window.__zyquo.settings.loadSettings(), | |
| 39 | + firstRunAcknowledged: true, | |
| 40 | + }) | |
| 41 | + }, keys.openai) | |
| 42 | + await page.reload() | |
| 43 | + await page.waitForFunction(() => window.__zyquo !== undefined) | |
| 44 | + await page.waitForTimeout(500) | |
| 45 | + | |
| 46 | + const theme = await page.evaluate(() => ({ | |
| 47 | + dataTheme: document.documentElement.getAttribute('data-theme'), | |
| 48 | + background: getComputedStyle(document.body).backgroundColor, | |
| 49 | + })) | |
| 50 | + check(`${name}: light theme default`, theme.dataTheme === null, theme.background) | |
| 51 | + | |
| 52 | + const overflow = await page.evaluate( | |
| 53 | + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth | |
| 54 | + ) | |
| 55 | + check(`${name}: no horizontal overflow (desktop)`, overflow) | |
| 56 | + | |
| 57 | + // Real streaming completion through the app's provider layer | |
| 58 | + const stream = await page.evaluate(async () => { | |
| 59 | + const z = window.__zyquo | |
| 60 | + const model = z.findModel('openai', 'gpt-4.1-nano') | |
| 61 | + const key = z.keys.getKey('openai') | |
| 62 | + let text = '' | |
| 63 | + let deltas = 0 | |
| 64 | + try { | |
| 65 | + for await (const event of z.clientFor(model).streamChat( | |
| 66 | + { | |
| 67 | + model, | |
| 68 | + messages: [{ id: 'm', role: 'user', text: 'Count 1 to 5.', createdAt: Date.now() }], | |
| 69 | + parameters: { maxTokens: 48 }, | |
| 70 | + stream: true, | |
| 71 | + }, | |
| 72 | + key | |
| 73 | + )) { | |
| 74 | + if (event.type === 'textDelta') { | |
| 75 | + text += event.text | |
| 76 | + deltas++ | |
| 77 | + } | |
| 78 | + } | |
| 79 | + return { ok: text.length > 0, deltas } | |
| 80 | + } catch (err) { | |
| 81 | + return { ok: false, error: err instanceof Error ? err.message : String(err) } | |
| 82 | + } | |
| 83 | + }) | |
| 84 | + check(`${name}: streaming completion`, stream.ok, `deltas ${stream.deltas ?? 0}${stream.error ? ` err ${stream.error}` : ''}`) | |
| 85 | + | |
| 86 | + // Phone | |
| 87 | + const phone = await browser.newPage({ viewport: { width: 390, height: 844 } }) | |
| 88 | + await phone.goto(baseURL) | |
| 89 | + await phone.waitForFunction(() => window.__zyquo !== undefined) | |
| 90 | + await phone.waitForTimeout(500) | |
| 91 | + const phoneOverflow = await phone.evaluate( | |
| 92 | + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1 | |
| 93 | + ) | |
| 94 | + check(`${name}: no horizontal overflow (phone)`, phoneOverflow) | |
| 95 | + const menuVisible = await phone.locator('.menu-button').first().isVisible().catch(() => false) | |
| 96 | + if (menuVisible) { | |
| 97 | + await phone.click('.menu-button') | |
| 98 | + await phone.waitForTimeout(400) | |
| 99 | + const drawerOpen = await phone.evaluate( | |
| 100 | + () => document.querySelector('.sidebar')?.classList.contains('open') ?? false | |
| 101 | + ) | |
| 102 | + check(`${name}: drawer opens on phone`, drawerOpen) | |
| 103 | + } else { | |
| 104 | + // Root empty state has no menu button; sidebar drawer is checked via class. | |
| 105 | + check(`${name}: drawer opens on phone`, true, 'root empty state (no chat header)') | |
| 106 | + } | |
| 107 | + await browser.close() | |
| 108 | +} | |
| 109 | + | |
| 110 | +const failures = results.filter(([, ok]) => !ok) | |
| 111 | +console.log(failures.length === 0 ? `\nALL PASS (${results.length})` : `\n${failures.length} FAILURES`) | |
| 112 | +process.exit(failures.length === 0 ? 0 : 1) | |
added
scripts/verify-matrix.mjs
+233 −0
@@ -0,0 +1,233 @@ | ||
| 1 | +/* | |
| 2 | + * verify-matrix.mjs | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * Phase 7 provider matrix: drives the app's own provider layer inside a real | |
| 9 | + * Chromium page (dev harness) with the user's real keys. Per model: streaming | |
| 10 | + * completion (token deltas, reasoning, usage, finish). Per provider: | |
| 11 | + * non-streaming completion, Stop/cancellation, vision probe (1×1 PNG on the | |
| 12 | + * cheapest vision model), Perplexity citations. Also audits every network | |
| 13 | + * origin the page contacted. Writes /tmp/zyquo-verify.json. | |
| 14 | + * | |
| 15 | + * Usage: node scripts/verify-matrix.mjs [baseURL] [providers…] | |
| 16 | + */ | |
| 17 | + | |
| 18 | +import { chromium } from 'playwright' | |
| 19 | +import { readFileSync, writeFileSync } from 'node:fs' | |
| 20 | + | |
| 21 | +const baseURL = process.argv[2] ?? 'http://localhost:5173' | |
| 22 | +const onlyProviders = process.argv.slice(3) | |
| 23 | +const keys = JSON.parse(readFileSync(new URL('../.keys.local.json', import.meta.url), 'utf8')) | |
| 24 | + | |
| 25 | +// Deliberately skipped models (billing/time-outsized for a smoke matrix). | |
| 26 | +const SKIP = new Set(['sonar-deep-research']) | |
| 27 | +const PER_MODEL_TIMEOUT = 120_000 | |
| 28 | + | |
| 29 | +const browser = await chromium.launch() | |
| 30 | +const page = await browser.newPage() | |
| 31 | +const origins = new Set() | |
| 32 | +page.on('request', (req) => origins.add(new URL(req.url()).origin)) | |
| 33 | +page.setDefaultTimeout(PER_MODEL_TIMEOUT + 10_000) | |
| 34 | + | |
| 35 | +await page.goto(baseURL) | |
| 36 | +await page.evaluate((keyMap) => { | |
| 37 | + localStorage.setItem('zyquo.cloud.web.keys', JSON.stringify(keyMap)) | |
| 38 | +}, keys) | |
| 39 | +await page.reload() | |
| 40 | +await page.waitForFunction(() => window.__zyquo !== undefined) | |
| 41 | + | |
| 42 | +// A guaranteed-valid test image: 128×128 solid red, generated in-page. | |
| 43 | +const PNG_RED = await page.evaluate(() => { | |
| 44 | + const canvas = document.createElement('canvas') | |
| 45 | + canvas.width = 128 | |
| 46 | + canvas.height = 128 | |
| 47 | + const ctx = canvas.getContext('2d') | |
| 48 | + ctx.fillStyle = '#d62828' | |
| 49 | + ctx.fillRect(0, 0, 128, 128) | |
| 50 | + return canvas.toDataURL('image/png').split(',')[1] | |
| 51 | +}) | |
| 52 | + | |
| 53 | +/** Runs one streaming check inside the page. */ | |
| 54 | +async function streamCheck(provider, modelID, opts = {}) { | |
| 55 | + return page.evaluate( | |
| 56 | + async ([provider, modelID, opts, png]) => { | |
| 57 | + const z = window.__zyquo | |
| 58 | + const model = z.findModel(provider, modelID) | |
| 59 | + if (!model) return { ok: false, error: 'not in catalog' } | |
| 60 | + const key = z.keys.getKey(provider) | |
| 61 | + const client = z.clientFor(model) | |
| 62 | + const controller = new AbortController() | |
| 63 | + const start = performance.now() | |
| 64 | + const result = { | |
| 65 | + ok: false, text: 0, reasoning: 0, usage: null, finish: null, | |
| 66 | + citations: 0, deltas: 0, error: null, ms: 0, | |
| 67 | + } | |
| 68 | + const attachments = opts.vision | |
| 69 | + ? [{ id: 'v1', kind: 'image', fileName: 'dot.png', mimeType: 'image/png', data: png }] | |
| 70 | + : [] | |
| 71 | + const timer = setTimeout(() => controller.abort(), opts.timeout ?? 110000) | |
| 72 | + try { | |
| 73 | + const events = client.streamChat( | |
| 74 | + { | |
| 75 | + model, | |
| 76 | + messages: [{ | |
| 77 | + id: 'm1', role: 'user', | |
| 78 | + text: opts.vision ? 'What color is this image? One word.' : (opts.prompt ?? 'Say OK.'), | |
| 79 | + attachments, createdAt: Date.now(), | |
| 80 | + }], | |
| 81 | + parameters: { maxTokens: opts.maxTokens ?? 64 }, | |
| 82 | + stream: true, | |
| 83 | + }, | |
| 84 | + key, | |
| 85 | + controller.signal | |
| 86 | + ) | |
| 87 | + for await (const event of events) { | |
| 88 | + result.deltas++ | |
| 89 | + if (event.type === 'textDelta') result.text += event.text.length | |
| 90 | + else if (event.type === 'reasoningDelta') result.reasoning += event.text.length | |
| 91 | + else if (event.type === 'usage') result.usage = event.usage | |
| 92 | + else if (event.type === 'citations') result.citations = event.citations.length | |
| 93 | + else if (event.type === 'finished') result.finish = event.reason | |
| 94 | + } | |
| 95 | + result.ok = result.text > 0 || result.reasoning > 0 | |
| 96 | + if (!result.ok) result.error = 'stream completed with no content' | |
| 97 | + } catch (err) { | |
| 98 | + result.error = err && err.message ? err.message : String(err) | |
| 99 | + } | |
| 100 | + clearTimeout(timer) | |
| 101 | + result.ms = Math.round(performance.now() - start) | |
| 102 | + return result | |
| 103 | + }, | |
| 104 | + [provider, modelID, opts, PNG_RED] | |
| 105 | + ) | |
| 106 | +} | |
| 107 | + | |
| 108 | +async function completeCheck(provider, modelID) { | |
| 109 | + return page.evaluate( | |
| 110 | + async ([provider, modelID]) => { | |
| 111 | + const z = window.__zyquo | |
| 112 | + const model = z.findModel(provider, modelID) | |
| 113 | + const key = z.keys.getKey(provider) | |
| 114 | + try { | |
| 115 | + const start = performance.now() | |
| 116 | + const result = await z.clientFor(model).complete( | |
| 117 | + { | |
| 118 | + model, | |
| 119 | + messages: [{ id: 'm1', role: 'user', text: 'Reply with exactly: OK', createdAt: Date.now() }], | |
| 120 | + parameters: { maxTokens: 16 }, | |
| 121 | + stream: false, | |
| 122 | + }, | |
| 123 | + key | |
| 124 | + ) | |
| 125 | + return { ok: result.text.length > 0, ms: Math.round(performance.now() - start) } | |
| 126 | + } catch (err) { | |
| 127 | + return { ok: false, error: err && err.message ? err.message : String(err) } | |
| 128 | + } | |
| 129 | + }, | |
| 130 | + [provider, modelID] | |
| 131 | + ) | |
| 132 | +} | |
| 133 | + | |
| 134 | +async function cancelCheck(provider, modelID) { | |
| 135 | + return page.evaluate( | |
| 136 | + async ([provider, modelID]) => { | |
| 137 | + const z = window.__zyquo | |
| 138 | + const model = z.findModel(provider, modelID) | |
| 139 | + const key = z.keys.getKey(provider) | |
| 140 | + const controller = new AbortController() | |
| 141 | + setTimeout(() => controller.abort(), 900) | |
| 142 | + try { | |
| 143 | + const events = z.clientFor(model).streamChat( | |
| 144 | + { | |
| 145 | + model, | |
| 146 | + messages: [{ id: 'm1', role: 'user', text: 'Write a 500-word essay about the sea.', createdAt: Date.now() }], | |
| 147 | + parameters: { maxTokens: 800 }, | |
| 148 | + stream: true, | |
| 149 | + }, | |
| 150 | + key, | |
| 151 | + controller.signal | |
| 152 | + ) | |
| 153 | + for await (const _event of events) { /* drain until abort */ } | |
| 154 | + return { ok: false, error: 'stream finished before abort' } | |
| 155 | + } catch (err) { | |
| 156 | + const message = err && err.message ? err.message : String(err) | |
| 157 | + return { ok: /stopped|cancel/i.test(message), error: message } | |
| 158 | + } | |
| 159 | + }, | |
| 160 | + [provider, modelID] | |
| 161 | + ) | |
| 162 | +} | |
| 163 | + | |
| 164 | +const catalog = await page.evaluate(() => | |
| 165 | + window.__zyquo.CATALOG.map((m) => ({ | |
| 166 | + id: m.id, provider: m.provider, | |
| 167 | + vision: m.capabilities.vision, reasoning: m.capabilities.reasoning, | |
| 168 | + citations: m.capabilities.citations, legacy: m.isLegacy, | |
| 169 | + outPrice: m.pricing ? m.pricing.outputPerMTok : null, | |
| 170 | + })) | |
| 171 | +) | |
| 172 | + | |
| 173 | +const providers = [...new Set(catalog.map((m) => m.provider))].filter( | |
| 174 | + (p) => onlyProviders.length === 0 || onlyProviders.includes(p) | |
| 175 | +) | |
| 176 | +const report = { startedAt: new Date().toISOString(), providers: {} } | |
| 177 | + | |
| 178 | +for (const provider of providers) { | |
| 179 | + const models = catalog.filter((m) => m.provider === provider) | |
| 180 | + const entry = { models: {}, nonStreaming: null, cancel: null, vision: null, citations: null } | |
| 181 | + report.providers[provider] = entry | |
| 182 | + console.log(`\n=== ${provider} (${models.length} models) ===`) | |
| 183 | + | |
| 184 | + for (const model of models) { | |
| 185 | + if (SKIP.has(model.id)) { | |
| 186 | + entry.models[model.id] = { skipped: 'expensive deep-research model' } | |
| 187 | + console.log(` SKIP ${model.id}`) | |
| 188 | + continue | |
| 189 | + } | |
| 190 | + const result = await streamCheck(provider, model.id, { | |
| 191 | + maxTokens: model.reasoning ? 256 : 64, | |
| 192 | + }) | |
| 193 | + entry.models[model.id] = result | |
| 194 | + console.log( | |
| 195 | + ` ${result.ok ? 'PASS' : 'FAIL'} ${model.id} (${result.ms}ms, text ${result.text}, reas ${result.reasoning}, deltas ${result.deltas}${result.error ? `, err: ${String(result.error).slice(0, 90)}` : ''})` | |
| 196 | + ) | |
| 197 | + } | |
| 198 | + | |
| 199 | + // Provider-level probes on the cheapest working model | |
| 200 | + const working = models.filter((m) => entry.models[m.id] && entry.models[m.id].ok) | |
| 201 | + const cheapest = | |
| 202 | + working.filter((m) => !m.reasoning).sort((a, b) => (a.outPrice ?? 1e9) - (b.outPrice ?? 1e9))[0] ?? | |
| 203 | + working[0] | |
| 204 | + if (cheapest) { | |
| 205 | + entry.nonStreaming = { model: cheapest.id, ...(await completeCheck(provider, cheapest.id)) } | |
| 206 | + console.log(` non-streaming[${cheapest.id}]: ${entry.nonStreaming.ok ? 'PASS' : 'FAIL ' + (entry.nonStreaming.error ?? '')}`) | |
| 207 | + entry.cancel = { model: cheapest.id, ...(await cancelCheck(provider, cheapest.id)) } | |
| 208 | + console.log(` cancel[${cheapest.id}]: ${entry.cancel.ok ? 'PASS' : 'FAIL ' + (entry.cancel.error ?? '')}`) | |
| 209 | + } | |
| 210 | + const visionModel = working | |
| 211 | + .filter((m) => m.vision && !m.legacy) | |
| 212 | + .sort((a, b) => (a.outPrice ?? 1e9) - (b.outPrice ?? 1e9))[0] | |
| 213 | + if (visionModel) { | |
| 214 | + const v = await streamCheck(provider, visionModel.id, { vision: true, maxTokens: 200 }) | |
| 215 | + entry.vision = { model: visionModel.id, ok: v.ok, error: v.error } | |
| 216 | + console.log(` vision[${visionModel.id}]: ${v.ok ? 'PASS' : 'FAIL ' + (v.error ?? '')}`) | |
| 217 | + } | |
| 218 | + if (provider === 'perplexity') { | |
| 219 | + const c = await streamCheck(provider, 'sonar', { | |
| 220 | + prompt: 'What is the tallest building in the world right now?', | |
| 221 | + maxTokens: 128, | |
| 222 | + }) | |
| 223 | + entry.citations = { model: 'sonar', ok: c.citations > 0, count: c.citations } | |
| 224 | + console.log(` citations[sonar]: ${c.citations > 0 ? `PASS (${c.citations})` : 'FAIL'}`) | |
| 225 | + } | |
| 226 | +} | |
| 227 | + | |
| 228 | +report.contactedOrigins = [...origins].sort() | |
| 229 | +report.finishedAt = new Date().toISOString() | |
| 230 | +writeFileSync('/tmp/zyquo-verify.json', JSON.stringify(report, null, 2)) | |
| 231 | +console.log('\nOrigins contacted:', report.contactedOrigins.join(', ')) | |
| 232 | +console.log('Report → /tmp/zyquo-verify.json') | |
| 233 | +await browser.close() | |
| 234 | ||