ava-engine 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.env.example +63 -0
  2. package/CLA.md +43 -0
  3. package/COMMERCIAL-LICENSE.md +43 -0
  4. package/LICENSE +73 -0
  5. package/README.md +238 -2
  6. package/THIRD_PARTY_NOTICES.md +38 -0
  7. package/TRADEMARKS.md +31 -0
  8. package/bin/ava-engine.mjs +12 -0
  9. package/lib/asr.mjs +143 -0
  10. package/lib/cli/config.mjs +95 -0
  11. package/lib/cli/main.mjs +152 -0
  12. package/lib/cli/setup.mjs +219 -0
  13. package/lib/fetch-models.mjs +104 -0
  14. package/lib/guard.mjs +92 -0
  15. package/lib/models.mjs +47 -0
  16. package/lib/openrouter.mjs +153 -0
  17. package/lib/ours.mjs +766 -0
  18. package/lib/platforms.mjs +201 -0
  19. package/lib/state.mjs +150 -0
  20. package/lib/stt.mjs +22 -0
  21. package/lib/tts.mjs +178 -0
  22. package/lib/workflow.mjs +18 -0
  23. package/package.json +72 -4
  24. package/public/app.js +1708 -0
  25. package/public/demo.js +420 -0
  26. package/public/favicon.svg +1 -0
  27. package/public/fonts/OFL.txt +96 -0
  28. package/public/fonts/inter-tight-var.woff2 +0 -0
  29. package/public/fonts/inter-var.woff2 +0 -0
  30. package/public/fonts/plex-mono-400.woff2 +0 -0
  31. package/public/fonts/plex-mono-500.woff2 +0 -0
  32. package/public/img/cta-1600.avif +0 -0
  33. package/public/img/cta-1600.webp +0 -0
  34. package/public/img/cta-2400.avif +0 -0
  35. package/public/img/cta-2400.webp +0 -0
  36. package/public/img/cta-960.avif +0 -0
  37. package/public/img/cta-960.webp +0 -0
  38. package/public/img/flows-1600.avif +0 -0
  39. package/public/img/flows-1600.webp +0 -0
  40. package/public/img/flows-2400.avif +0 -0
  41. package/public/img/flows-2400.webp +0 -0
  42. package/public/img/flows-960.avif +0 -0
  43. package/public/img/flows-960.webp +0 -0
  44. package/public/img/hero-1600.avif +0 -0
  45. package/public/img/hero-1600.webp +0 -0
  46. package/public/img/hero-2400.avif +0 -0
  47. package/public/img/hero-2400.webp +0 -0
  48. package/public/img/hero-960.avif +0 -0
  49. package/public/img/hero-960.webp +0 -0
  50. package/public/img/p-csm-480.avif +0 -0
  51. package/public/img/p-csm-480.webp +0 -0
  52. package/public/img/p-csm-800.avif +0 -0
  53. package/public/img/p-csm-800.webp +0 -0
  54. package/public/img/p-docs-480.avif +0 -0
  55. package/public/img/p-docs-480.webp +0 -0
  56. package/public/img/p-docs-800.avif +0 -0
  57. package/public/img/p-docs-800.webp +0 -0
  58. package/public/img/p-quiet-480.avif +0 -0
  59. package/public/img/p-quiet-480.webp +0 -0
  60. package/public/img/p-quiet-800.avif +0 -0
  61. package/public/img/p-quiet-800.webp +0 -0
  62. package/public/img/p-tickets-480.avif +0 -0
  63. package/public/img/p-tickets-480.webp +0 -0
  64. package/public/img/p-tickets-800.avif +0 -0
  65. package/public/img/p-tickets-800.webp +0 -0
  66. package/public/img/p-trial-480.avif +0 -0
  67. package/public/img/p-trial-480.webp +0 -0
  68. package/public/img/p-trial-800.avif +0 -0
  69. package/public/img/p-trial-800.webp +0 -0
  70. package/public/img/sees-1600.avif +0 -0
  71. package/public/img/sees-1600.webp +0 -0
  72. package/public/img/sees-2400.avif +0 -0
  73. package/public/img/sees-2400.webp +0 -0
  74. package/public/img/sees-960.avif +0 -0
  75. package/public/img/sees-960.webp +0 -0
  76. package/public/img/speaks-1600.avif +0 -0
  77. package/public/img/speaks-1600.webp +0 -0
  78. package/public/img/speaks-2400.avif +0 -0
  79. package/public/img/speaks-2400.webp +0 -0
  80. package/public/img/speaks-960.avif +0 -0
  81. package/public/img/speaks-960.webp +0 -0
  82. package/public/img/voice-1600.avif +0 -0
  83. package/public/img/voice-1600.webp +0 -0
  84. package/public/img/voice-2400.avif +0 -0
  85. package/public/img/voice-2400.webp +0 -0
  86. package/public/img/voice-960.avif +0 -0
  87. package/public/img/voice-960.webp +0 -0
  88. package/public/index.html +440 -0
  89. package/public/landing.html +975 -0
  90. package/public/pcm-worklet.js +11 -0
  91. package/public/perceptionWorker.js +103 -0
  92. package/public/rtc.js +60 -0
  93. package/public/specialist.html +92 -0
  94. package/public/specialist.js +132 -0
  95. package/public/style.css +836 -0
  96. package/public/vendor/lenis.min.js +3 -0
  97. package/scripts/fetch-models.sh +15 -0
  98. package/server.mjs +502 -0
@@ -0,0 +1,104 @@
1
+ // Offline speech-to-text model downloads (sherpa-onnx). Dependency-free so the
2
+ // Dockerfile and the CLI can both use it:
3
+ //
4
+ // node lib/fetch-models.mjs [english-small|english|multilingual|all] [--dir <path>]
5
+ //
6
+ // Archives are extracted with the system `tar` (macOS, Linux and Windows 10+).
7
+
8
+ import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
9
+ import { join, resolve, dirname } from "node:path";
10
+ import { spawn } from "node:child_process";
11
+ import { Readable } from "node:stream";
12
+ import { pipeline } from "node:stream/promises";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const BASE = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models";
16
+
17
+ /** What the CLI offers. `files` are fetched in order; `dir` marks a pack as installed. */
18
+ export const MODEL_PACKS = {
19
+ "english-small": {
20
+ label: "English, fast", size: "122 MB",
21
+ hint: "Streaming English with live captions. A good default.",
22
+ files: [{ name: "sherpa-onnx-streaming-zipformer-en-20M-2023-02-17.tar.bz2", dir: "sherpa-onnx-streaming-zipformer-en-20M-2023-02-17" }],
23
+ },
24
+ english: {
25
+ label: "English, most accurate", size: "296 MB",
26
+ hint: "The larger streaming English model. Used instead of the small one when both are installed.",
27
+ files: [{ name: "sherpa-onnx-streaming-zipformer-en-2023-06-26.tar.bz2", dir: "sherpa-onnx-streaming-zipformer-en-2023-06-26" }],
28
+ },
29
+ multilingual: {
30
+ label: "Multilingual (zh/en/ja/ko/yue)", size: "1 GB",
31
+ hint: "SenseVoice with voice activity detection. Captions appear when the speaker pauses.",
32
+ files: [
33
+ { name: "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17.tar.bz2", dir: "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17" },
34
+ { name: "silero_vad.onnx", file: "silero_vad.onnx" },
35
+ ],
36
+ },
37
+ };
38
+
39
+ export function packInstalled(pack, dir) {
40
+ return MODEL_PACKS[pack].files.every((f) => existsSync(join(dir, f.dir || f.file)));
41
+ }
42
+
43
+ export function installedPacks(dir) {
44
+ return Object.keys(MODEL_PACKS).filter((p) => packInstalled(p, dir));
45
+ }
46
+
47
+ function untar(archive, cwd) {
48
+ return new Promise((res, rej) => {
49
+ const p = spawn("tar", ["xjf", archive], { cwd, stdio: ["ignore", "ignore", "pipe"] });
50
+ let err = "";
51
+ p.stderr.on("data", (d) => { err += d; });
52
+ p.on("error", (e) => rej(new Error(`couldn't run tar: ${e.message}`)));
53
+ p.on("close", (code) => (code === 0 ? res() : rej(new Error(`tar failed (${code}): ${err.trim()}`))));
54
+ });
55
+ }
56
+
57
+ async function download(url, dest, onProgress) {
58
+ const res = await fetch(url, { redirect: "follow" });
59
+ if (!res.ok || !res.body) throw new Error(`download failed: HTTP ${res.status} for ${url}`);
60
+ const total = Number(res.headers.get("content-length")) || 0;
61
+ let done = 0;
62
+ const body = Readable.fromWeb(res.body);
63
+ body.on("data", (c) => { done += c.length; onProgress?.(done, total); });
64
+ const tmp = dest + ".part";
65
+ await pipeline(body, createWriteStream(tmp));
66
+ renameSync(tmp, dest); // only a complete download gets the real name
67
+ }
68
+
69
+ /**
70
+ * Download and unpack one model pack into `dir`. Skips files already present.
71
+ * onProgress({ file, done, total, phase: "download" | "extract" | "skip" })
72
+ */
73
+ export async function fetchPack(pack, dir, onProgress) {
74
+ const spec = MODEL_PACKS[pack];
75
+ if (!spec) throw new Error(`unknown model pack "${pack}" (choose: ${Object.keys(MODEL_PACKS).join(", ")})`);
76
+ mkdirSync(dir, { recursive: true });
77
+ for (const f of spec.files) {
78
+ const target = join(dir, f.dir || f.file);
79
+ if (existsSync(target)) { onProgress?.({ file: f.name, phase: "skip" }); continue; }
80
+ const dest = join(dir, f.name);
81
+ await download(`${BASE}/${f.name}`, dest, (done, total) => onProgress?.({ file: f.name, phase: "download", done, total }));
82
+ if (f.dir) {
83
+ onProgress?.({ file: f.name, phase: "extract" });
84
+ try { await untar(f.name, dir); } finally { rmSync(dest, { force: true }); }
85
+ }
86
+ }
87
+ }
88
+
89
+ // ── command line ──
90
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
91
+ const args = process.argv.slice(2);
92
+ const di = args.indexOf("--dir");
93
+ const dir = resolve(di >= 0 ? args.splice(di, 2)[1] : join(dirname(fileURLToPath(import.meta.url)), "..", "models"));
94
+ const wanted = args[0] === "all" ? ["english", "multilingual"] : args.length ? args : ["english-small"];
95
+ for (const pack of wanted) {
96
+ let last = -1;
97
+ await fetchPack(pack, dir, (p) => {
98
+ if (p.phase === "skip") console.log(`✓ ${p.file} (already present)`);
99
+ else if (p.phase === "extract") console.log(` unpacking ${p.file}…`);
100
+ else if (p.total) { const pct = Math.floor((p.done / p.total) * 100); if (pct !== last && pct % 10 === 0) { last = pct; console.log(`↓ ${p.file} ${pct}%`); } }
101
+ });
102
+ }
103
+ console.log(`Models are in ${dir}`);
104
+ }
package/lib/guard.mjs ADDED
@@ -0,0 +1,92 @@
1
+ // Abuse protection for a publicly reachable server. Every /api call can spend
2
+ // the operator's model/voice credit, so a deploy should never be wide open.
3
+ //
4
+ // ACCESS_PASSWORD optional. When set, the app, specialist console, /api/* and
5
+ // the WebSockets require it (HTTP Basic auth, then a cookie so
6
+ // WebSocket upgrades are covered too). The landing page and
7
+ // static assets stay public.
8
+ // TRUST_PROXY=1 take the client IP from Fly-Client-IP / X-Forwarded-For
9
+ // (only behind a proxy you control — the header is spoofable).
10
+
11
+ import { createHmac, timingSafeEqual } from "node:crypto";
12
+
13
+ const PASSWORD = process.env.ACCESS_PASSWORD || "";
14
+ const TRUST_PROXY = process.env.TRUST_PROXY === "1";
15
+ export const AUTH_ENABLED = !!PASSWORD;
16
+
17
+ const COOKIE = "ava_auth";
18
+ const cookieValue = PASSWORD ? createHmac("sha256", PASSWORD).update("ava-access-v1").digest("hex") : "";
19
+
20
+ function safeEqual(a, b) {
21
+ const x = Buffer.from(String(a)), y = Buffer.from(String(b));
22
+ return x.length === y.length && timingSafeEqual(x, y);
23
+ }
24
+
25
+ function readCookie(req, name) {
26
+ for (const part of String(req.headers.cookie || "").split(";")) {
27
+ const i = part.indexOf("=");
28
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /** True if the request carries a valid password (Basic auth) or auth cookie. */
34
+ export function isAuthorized(req) {
35
+ if (!AUTH_ENABLED) return true;
36
+ const c = readCookie(req, COOKIE);
37
+ if (c && safeEqual(c, cookieValue)) return true;
38
+ const h = String(req.headers.authorization || "");
39
+ if (h.startsWith("Basic ")) {
40
+ const pass = Buffer.from(h.slice(6), "base64").toString("utf8").split(":").slice(1).join(":");
41
+ return safeEqual(pass, PASSWORD);
42
+ }
43
+ return false;
44
+ }
45
+
46
+ /** Headers that ask the browser for the password. */
47
+ export function challengeHeaders() {
48
+ return { "WWW-Authenticate": 'Basic realm="Ava", charset="UTF-8"' };
49
+ }
50
+
51
+ /** Set-Cookie header that remembers a successful login (so WebSockets pass). */
52
+ export function authCookieHeader(req) {
53
+ if (!AUTH_ENABLED || readCookie(req, COOKIE) === cookieValue) return {};
54
+ const secure = TRUST_PROXY || req.socket.encrypted ? "; Secure" : "";
55
+ return { "Set-Cookie": `${COOKIE}=${cookieValue}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200${secure}` };
56
+ }
57
+
58
+ export function clientIp(req) {
59
+ if (TRUST_PROXY) {
60
+ const fly = req.headers["fly-client-ip"];
61
+ if (fly) return String(fly);
62
+ const xff = req.headers["x-forwarded-for"];
63
+ if (xff) return String(xff).split(",")[0].trim();
64
+ }
65
+ return req.socket.remoteAddress || "?";
66
+ }
67
+
68
+ /** Reject cross-site WebSocket upgrades (browsers always send Origin). */
69
+ export function sameOrigin(req) {
70
+ const origin = req.headers.origin;
71
+ if (!origin) return true; // non-browser client; still subject to auth
72
+ try { return new URL(origin).host === req.headers.host; } catch { return false; }
73
+ }
74
+
75
+ /**
76
+ * Fixed-window rate limiter: limiter("turn", 120, 60_000)(ip) → true if allowed.
77
+ * Buckets are swept as they expire, so memory stays bounded by active clients.
78
+ */
79
+ export function limiter(name, max, windowMs) {
80
+ const hits = new Map();
81
+ setInterval(() => { const now = Date.now(); for (const [k, v] of hits) if (now >= v.reset) hits.delete(k); }, windowMs).unref();
82
+ const allow = (key) => {
83
+ const now = Date.now();
84
+ let b = hits.get(key);
85
+ if (!b || now >= b.reset) { b = { n: 0, reset: now + windowMs }; hits.set(key, b); }
86
+ b.n += 1;
87
+ return b.n <= max;
88
+ };
89
+ /** True while `key` is over the limit, without counting a new hit. */
90
+ allow.blocked = (key) => { const b = hits.get(key); return !!b && Date.now() < b.reset && b.n >= max; };
91
+ return allow;
92
+ }
package/lib/models.mjs ADDED
@@ -0,0 +1,47 @@
1
+ // Central model config. Override any of these via env (see .env.example).
2
+ // Slugs are OpenRouter model ids. Change freely — the UI shows whatever is set.
3
+
4
+ export const MODELS = {
5
+ talker: process.env.MODEL_TALKER || "meta-llama/llama-3.3-70b-instruct", // the SPOKEN reply — latency-critical; on Groq ~0.2s TTFT (vs ~0.7s on gemini-flash-lite)
6
+ perception: process.env.MODEL_PERCEPTION || "google/gemini-2.5-flash",
7
+ planner: process.env.MODEL_PLANNER || "anthropic/claude-sonnet-4.5",
8
+ intent: process.env.MODEL_INTENT || "google/gemini-2.5-flash-lite",
9
+ };
10
+
11
+ // Per-role provider routing (OpenRouter). Only the talker is latency-critical —
12
+ // it's the reply the user actually hears — so we prefer Groq's very low
13
+ // time-to-first-token. allow_fallbacks keeps replies working (just slower) if
14
+ // Groq is briefly unavailable. Set TALKER_PROVIDER=auto to let OpenRouter pick.
15
+ export const PROVIDER = {
16
+ talker: process.env.TALKER_PROVIDER === "auto" ? undefined
17
+ : { order: [process.env.TALKER_PROVIDER || "groq"], allow_fallbacks: true },
18
+ // Perception (vision) is on the turn's hot path when the screen changed, so it
19
+ // wants a fast provider too. Only pinned when PERCEPTION_PROVIDER is set (the
20
+ // default vision model may not be served by that provider).
21
+ perception: process.env.PERCEPTION_PROVIDER && process.env.PERCEPTION_PROVIDER !== "auto"
22
+ ? { order: [process.env.PERCEPTION_PROVIDER], allow_fallbacks: true } : undefined,
23
+ };
24
+
25
+ // Fallback price table ($ per 1M tokens) used only if OpenRouter doesn't
26
+ // return a usage.cost for a call. Approximate; edit to taste. [in, out]
27
+ export const PRICES = {
28
+ "google/gemini-2.5-flash": [0.30, 2.50],
29
+ "google/gemini-2.5-flash-lite": [0.10, 0.40],
30
+ "anthropic/claude-sonnet-4.5": [3.00, 15.00],
31
+ "google/gemini-2.5-pro": [1.25, 10.00],
32
+ "openai/gpt-4o-mini": [0.15, 0.60],
33
+ };
34
+
35
+ export function estimateCost(model, promptTokens, completionTokens) {
36
+ const p = PRICES[model];
37
+ if (!p) return 0;
38
+ return (promptTokens / 1e6) * p[0] + (completionTokens / 1e6) * p[1];
39
+ }
40
+
41
+ // Friendly display label per role.
42
+ export const ROLE_LABEL = {
43
+ talker: "Talker",
44
+ perception: "Perception",
45
+ planner: "Planner",
46
+ intent: "Intent",
47
+ };
@@ -0,0 +1,153 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { estimateCost } from "./models.mjs";
3
+
4
+ // The HTTP request a model call belongs to. When that client disconnects mid-turn,
5
+ // its in-flight calls are aborted instead of spending tokens nobody will read.
6
+ export const requestScope = new AsyncLocalStorage();
7
+ function callSignal(ms) {
8
+ const client = requestScope.getStore()?.signal;
9
+ return client ? AbortSignal.any([AbortSignal.timeout(ms), client]) : AbortSignal.timeout(ms);
10
+ }
11
+
12
+ const OR_URL = "https://openrouter.ai/api/v1/chat/completions";
13
+ export const MOCK = !process.env.OPENROUTER_API_KEY;
14
+ // Optional app attribution on openrouter.ai (https://openrouter.ai/docs/app-attribution).
15
+ const ATTRIBUTION = {
16
+ ...(process.env.OPENROUTER_REFERER ? { "HTTP-Referer": process.env.OPENROUTER_REFERER } : {}),
17
+ "X-Title": process.env.OPENROUTER_TITLE || "Ava",
18
+ };
19
+ // Hard caps so one hung upstream call can never freeze the observe loop or a turn.
20
+ const CHAT_TIMEOUT_MS = 30_000;
21
+ const STREAM_TIMEOUT_MS = 60_000;
22
+
23
+ // Rough token estimate for mock mode + for context-size display.
24
+ export function estTokens(x) {
25
+ if (x == null) return 0;
26
+ if (typeof x === "string") return Math.ceil(x.length / 4);
27
+ // OpenAI-style content array: text parts by length, image parts flat ~258/tile.
28
+ if (Array.isArray(x)) {
29
+ let t = 0;
30
+ for (const p of x) {
31
+ if (p?.type === "text") t += Math.ceil((p.text || "").length / 4);
32
+ else if (p?.type === "image_url") t += 900; // ~one downscaled screen frame
33
+ }
34
+ return t;
35
+ }
36
+ return Math.ceil(JSON.stringify(x).length / 4);
37
+ }
38
+
39
+ function messagesTokens(messages) {
40
+ return messages.reduce((s, m) => s + estTokens(m.content) + 4, 0);
41
+ }
42
+
43
+ /**
44
+ * One chat completion. Returns:
45
+ * { text, model, role, promptTokens, completionTokens, cost, latencyMs, cached }
46
+ * In MOCK mode returns a deterministic canned reply from `mock(messages)`.
47
+ */
48
+ export async function chat({ model, role, messages, maxTokens = 350, temperature = 0.5, mock, provider }) {
49
+ const t0 = Date.now();
50
+
51
+ if (MOCK) {
52
+ const text = (mock ? mock(messages) : "[mock reply]");
53
+ const promptTokens = messagesTokens(messages);
54
+ const completionTokens = estTokens(text);
55
+ // Simulate realistic per-role latency so the UI feels real.
56
+ const base = role === "planner" ? 3200 : role === "perception" ? 520 : 620;
57
+ const latencyMs = base + Math.round((Math.random() - 0.5) * 220);
58
+ await new Promise((r) => setTimeout(r, Math.min(latencyMs, 900))); // don't actually wait the full planner time
59
+ return {
60
+ text, model, role, promptTokens, completionTokens,
61
+ cost: estimateCost(model, promptTokens, completionTokens),
62
+ latencyMs, mocked: true,
63
+ };
64
+ }
65
+
66
+ const body = {
67
+ model,
68
+ messages,
69
+ max_tokens: maxTokens,
70
+ temperature,
71
+ usage: { include: true },
72
+ ...(provider ? { provider } : {}),
73
+ };
74
+ const res = await fetch(OR_URL, {
75
+ method: "POST",
76
+ headers: {
77
+ "Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
78
+ "Content-Type": "application/json",
79
+ ...ATTRIBUTION,
80
+ },
81
+ body: JSON.stringify(body),
82
+ signal: callSignal(CHAT_TIMEOUT_MS),
83
+ });
84
+
85
+ const latencyMs = Date.now() - t0;
86
+ if (!res.ok) {
87
+ const detail = await res.text().catch(() => "");
88
+ const err = new Error(`OpenRouter ${res.status}: ${detail.slice(0, 300)}`);
89
+ err.status = res.status;
90
+ throw err;
91
+ }
92
+ const data = await res.json();
93
+ const choice = data.choices?.[0];
94
+ const text = choice?.message?.content ?? "";
95
+ const u = data.usage || {};
96
+ const promptTokens = u.prompt_tokens ?? 0;
97
+ const completionTokens = u.completion_tokens ?? 0;
98
+ const cost = typeof u.cost === "number" ? u.cost : estimateCost(model, promptTokens, completionTokens);
99
+ return { text, model, role, promptTokens, completionTokens, cost, latencyMs, mocked: false };
100
+ }
101
+
102
+ /**
103
+ * Streaming chat completion. Calls onDelta(textChunk) as tokens arrive.
104
+ * Returns the same usage shape as chat() once complete.
105
+ */
106
+ export async function chatStream({ model, role, messages, maxTokens = 220, temperature = 0.5, mock, onDelta, provider }) {
107
+ const t0 = Date.now();
108
+
109
+ if (MOCK) {
110
+ const text = mock ? mock(messages) : "[mock reply]";
111
+ for (const w of text.match(/\S+\s*/g) || [text]) { onDelta(w); await new Promise((r) => setTimeout(r, 28)); }
112
+ const promptTokens = messagesTokens(messages), completionTokens = estTokens(text);
113
+ return { text, model, role, promptTokens, completionTokens, cost: estimateCost(model, promptTokens, completionTokens), latencyMs: Date.now() - t0, mocked: true };
114
+ }
115
+
116
+ const res = await fetch(OR_URL, {
117
+ method: "POST",
118
+ headers: {
119
+ "Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
120
+ "Content-Type": "application/json",
121
+ ...ATTRIBUTION,
122
+ },
123
+ body: JSON.stringify({ model, messages, max_tokens: maxTokens, temperature, stream: true, usage: { include: true }, ...(provider ? { provider } : {}) }),
124
+ signal: callSignal(STREAM_TIMEOUT_MS),
125
+ });
126
+ if (!res.ok) {
127
+ const detail = await res.text().catch(() => "");
128
+ throw new Error(`OpenRouter ${res.status}: ${detail.slice(0, 300)}`);
129
+ }
130
+ const reader = res.body.getReader();
131
+ const dec = new TextDecoder();
132
+ let buf = "", text = "", usage = null;
133
+ for (;;) {
134
+ const { value, done } = await reader.read();
135
+ if (done) break;
136
+ buf += dec.decode(value, { stream: true });
137
+ let idx;
138
+ while ((idx = buf.indexOf("\n")) >= 0) {
139
+ const line = buf.slice(0, idx).trim(); buf = buf.slice(idx + 1);
140
+ if (!line.startsWith("data:")) continue;
141
+ const d = line.slice(5).trim();
142
+ if (d === "[DONE]") continue;
143
+ let j; try { j = JSON.parse(d); } catch { continue; }
144
+ const delta = j.choices?.[0]?.delta?.content;
145
+ if (delta) { text += delta; onDelta(delta); }
146
+ if (j.usage) usage = j.usage;
147
+ }
148
+ }
149
+ const promptTokens = usage?.prompt_tokens ?? 0;
150
+ const completionTokens = usage?.completion_tokens ?? 0;
151
+ const cost = typeof usage?.cost === "number" ? usage.cost : estimateCost(model, promptTokens, completionTokens);
152
+ return { text, model, role, promptTokens, completionTokens, cost, latencyMs: Date.now() - t0, mocked: false };
153
+ }