|
| 1 | +// ============================================================================= |
| 2 | +// apis.mjs — the crew of external models, each behind one small function. Every |
| 3 | +// call is (a) cached on disk by a hash of its request, so a re-run of a playtest |
| 4 | +// replays for free and is reproducible, and (b) appended to a call log with its |
| 5 | +// latency and token usage, so every number in the docs can point to the raw |
| 6 | +// response that produced it. |
| 7 | +// |
| 8 | +// vlm(messages, opts) DeepInfra OpenAI-compatible chat (vision models) |
| 9 | +// llm(provider, messages) deepseek | kimi | zai | deepinfra text models |
| 10 | +// systemOne(state, questions) TypeSafe Jev typed answers (noul/choice/score) |
| 11 | +// quantumBytes(n) MothQuantum comet-qrng-v1 (emu mode by default) |
| 12 | +// |
| 13 | +// Keys come only from the environment; nothing here writes a key to disk. |
| 14 | +// Set SYZ_ML_OFFLINE=1 to forbid network calls (cache hits still work). |
| 15 | +// ============================================================================= |
| 16 | +import { createHash } from 'node:crypto'; |
| 17 | +import fs from 'node:fs'; |
| 18 | +import path from 'node:path'; |
| 19 | +import { fileURLToPath } from 'node:url'; |
| 20 | + |
| 21 | +const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 22 | +export const CACHE_DIR = process.env.SYZ_ML_CACHE || path.join(HERE, 'cache'); |
| 23 | +export const LOG_FILE = process.env.SYZ_ML_LOG || path.join(HERE, 'logs', 'calls.jsonl'); |
| 24 | +const OFFLINE = () => process.env.SYZ_ML_OFFLINE === '1'; |
| 25 | + |
| 26 | +export const PROVIDERS = { |
| 27 | + deepinfra: { url: 'https://api.deepinfra.com/v1/openai/chat/completions', key: 'DEEPINFRA_KEY', model: 'Qwen/Qwen3-VL-30B-A3B-Instruct' }, |
| 28 | + deepseek: { url: 'https://api.deepseek.com/chat/completions', key: 'DEEPSEEK_KEY', model: 'deepseek-chat' }, |
| 29 | + kimi: { url: 'https://api.moonshot.ai/v1/chat/completions', key: 'KIMIAI_KEY', model: 'kimi-k2.6' }, |
| 30 | + zai: { url: 'https://api.z.ai/api/paas/v4/chat/completions', key: 'ZAI_KEY', model: 'glm-4.5-flash', |
| 31 | + extra: { thinking: { type: 'disabled' } } }, |
| 32 | +}; |
| 33 | + |
| 34 | +export const stats = { calls: 0, cacheHits: 0, byProvider: {} }; |
| 35 | + |
| 36 | +function cacheKey(obj) { return createHash('sha256').update(JSON.stringify(obj)).digest('hex').slice(0, 24); } |
| 37 | +function logCall(rec) { |
| 38 | + fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true }); |
| 39 | + fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + '\n'); |
| 40 | +} |
| 41 | +// Strip base64 image payloads from what we log (they are reproducible from the scene). |
| 42 | +function redactImages(messages) { |
| 43 | + return JSON.parse(JSON.stringify(messages, (k, v) => |
| 44 | + typeof v === 'string' && v.startsWith('data:image') ? `<image ${v.length}B sha=${cacheKey(v)}>` : v)); |
| 45 | +} |
| 46 | + |
| 47 | +async function cached(kind, request, doFetch, { tag = '', nocache = false } = {}) { |
| 48 | + const key = cacheKey({ kind, request }); |
| 49 | + const file = path.join(CACHE_DIR, kind, key + '.json'); |
| 50 | + if (!nocache && fs.existsSync(file)) { stats.cacheHits++; return { ...JSON.parse(fs.readFileSync(file, 'utf8')), cached: true }; } |
| 51 | + if (OFFLINE()) throw new Error(`offline: no cache for ${kind} ${key}`); |
| 52 | + const t0 = Date.now(); |
| 53 | + const out = await doFetch(); |
| 54 | + out.ms = Date.now() - t0; |
| 55 | + stats.calls++; stats.byProvider[kind] = (stats.byProvider[kind] || 0) + 1; |
| 56 | + fs.mkdirSync(path.dirname(file), { recursive: true }); |
| 57 | + fs.writeFileSync(file, JSON.stringify(out)); |
| 58 | + logCall({ at: new Date().toISOString(), kind, tag, key, ms: out.ms, usage: out.usage || null, |
| 59 | + model: out.model || null, text: out.text?.slice(0, 4000) ?? null, error: out.error || null }); |
| 60 | + return { ...out, cached: false }; |
| 61 | +} |
| 62 | + |
| 63 | +async function postJSON(url, headers, body, timeoutMs = 120000, tries = 3) { |
| 64 | + let last; |
| 65 | + for (let i = 0; i < tries; i++) { |
| 66 | + try { |
| 67 | + const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, |
| 68 | + body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) }); |
| 69 | + const txt = await r.text(); |
| 70 | + if (r.status === 429 || r.status >= 500) { last = new Error(`${r.status} ${txt.slice(0, 200)}`); await new Promise((s) => setTimeout(s, 2000 * (i + 1))); continue; } |
| 71 | + if (!r.ok) throw new Error(`${r.status} ${txt.slice(0, 400)}`); |
| 72 | + return JSON.parse(txt); |
| 73 | + } catch (e) { last = e; if (i < tries - 1) await new Promise((s) => setTimeout(s, 2000 * (i + 1))); } |
| 74 | + } |
| 75 | + throw last; |
| 76 | +} |
| 77 | + |
| 78 | +// OpenAI-compatible chat. Returns { text, usage, model }. |
| 79 | +export async function chat(provider, messages, { model, max_tokens = 800, temperature = 0, tag, nocache, json = false } = {}) { |
| 80 | + const p = PROVIDERS[provider]; |
| 81 | + if (!p) throw new Error('unknown provider ' + provider); |
| 82 | + const key = process.env[p.key]; |
| 83 | + const body = { model: model || p.model, messages, max_tokens, temperature, ...(p.extra || {}), |
| 84 | + ...(json ? { response_format: { type: 'json_object' } } : {}) }; |
| 85 | + return cached(provider, { ...body, messages: body.messages }, async () => { |
| 86 | + if (!key) throw new Error(`missing ${p.key}`); |
| 87 | + const d = await postJSON(p.url, { Authorization: `Bearer ${key}` }, body); |
| 88 | + const text = d.choices?.[0]?.message?.content ?? ''; |
| 89 | + return { text, usage: d.usage || null, model: d.model || body.model, request: redactImages(messages) }; |
| 90 | + }, { tag, nocache }); |
| 91 | +} |
| 92 | + |
| 93 | +export const vlm = (messages, opts = {}) => chat('deepinfra', messages, opts); |
| 94 | +export const llm = (provider, messages, opts = {}) => chat(provider, messages, opts); |
| 95 | + |
| 96 | +// TypeSafe Jev: typed answers. questions = { name: {type:'noul'|'choice'|'score', instructions, criteria?} } |
| 97 | +export async function systemOne(state, questions, { model = 'jev-latest', tag, nocache } = {}) { |
| 98 | + const body = { model, state, questions }; |
| 99 | + return cached('typesafe', body, async () => { |
| 100 | + const key = process.env.TYPESAFEAI_KEY; if (!key) throw new Error('missing TYPESAFEAI_KEY'); |
| 101 | + const d = await postJSON('https://api.typesafe.ai/v1/systemone', { Authorization: `Bearer ${key}` }, body, 60000); |
| 102 | + return { answers: d.answers, usage: d.usage, model: d.model, text: JSON.stringify(d.answers) }; |
| 103 | + }, { tag, nocache }); |
| 104 | +} |
| 105 | + |
| 106 | +// MothQuantum comet-qrng-v1. mode 'emu' = Aer simulator (uncertified); 'qpu' = IBM hardware. |
| 107 | +// Draws are never cached (a replayed random stream would defeat the point), but are logged. |
| 108 | +export async function quantumBytes(n = 32, { mode = 'emu', tag } = {}) { |
| 109 | + if (OFFLINE()) throw new Error('offline: quantum draws are never cached'); |
| 110 | + const base = process.env.MOTHQUANTUM_BASE || 'https://api.mothquantum.com/api/v1'; |
| 111 | + const H = { Authorization: `Bearer ${process.env.MOTHQUANTUM_KEY}` }; |
| 112 | + const t0 = Date.now(); |
| 113 | + const sub = await postJSON(`${base}/engines/comet-qrng-v1/process`, H, |
| 114 | + { mode, params: { num_qubits: 12, shots: Math.max(256, n * 8), output_bytes: n, bell_witness: false } }, 60000); |
| 115 | + for (let i = 0; i < 40; i++) { |
| 116 | + await new Promise((s) => setTimeout(s, 1500)); |
| 117 | + const st = await (await fetch(`${base}/jobs/${sub.job_id}/status`, { headers: H })).json(); |
| 118 | + if (st.status === 'failed') throw new Error('qrng job failed: ' + JSON.stringify(st.error)); |
| 119 | + if (st.status === 'completed') { |
| 120 | + const res = await (await fetch(`${base}/jobs/${sub.job_id}/result`, { headers: H })).json(); |
| 121 | + const out = res.result.output; |
| 122 | + const rec = { hex: out.random.hex, bytes: out.random.bytes, job_id: sub.job_id, mode, |
| 123 | + commit: out.commitment?.commit, ms: Date.now() - t0 }; |
| 124 | + stats.calls++; stats.byProvider.quantum = (stats.byProvider.quantum || 0) + 1; |
| 125 | + logCall({ at: new Date().toISOString(), kind: 'quantum', tag, key: sub.job_id, ms: rec.ms, text: rec.hex, mode }); |
| 126 | + return rec; |
| 127 | + } |
| 128 | + } |
| 129 | + throw new Error('qrng job timed out'); |
| 130 | +} |
| 131 | + |
| 132 | +// Pull the first JSON object out of a model reply (models wrap JSON in prose/fences). |
| 133 | +export function extractJSON(text) { |
| 134 | + if (!text) return null; |
| 135 | + const t = text.replace(/```(?:json)?/g, ''); |
| 136 | + const s = t.indexOf('{'); if (s < 0) return null; |
| 137 | + let depth = 0, inStr = false, esc = false; |
| 138 | + for (let i = s; i < t.length; i++) { |
| 139 | + const c = t[i]; |
| 140 | + if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; } |
| 141 | + if (c === '"') inStr = true; else if (c === '{') depth++; |
| 142 | + else if (c === '}' && --depth === 0) { try { return JSON.parse(t.slice(s, i + 1)); } catch { return null; } } |
| 143 | + } |
| 144 | + return null; |
| 145 | +} |
0 commit comments