openzoo 0.49.7 → 0.49.9

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.
@@ -0,0 +1,737 @@
1
+ /**
2
+ * Task router: cheapest model that will probably finish the job.
3
+ *
4
+ * argmin cost(m, task) subject to P_success(m | task) >= bar(task)
5
+ * m in feasible(task)
6
+ *
7
+ * If nothing clears the bar, return the strongest option and flag
8
+ * cleared_bar=false. Price is used in cost() only — never as a capability
9
+ * proxy. Pure JS; sha256 encoder; no numpy/python/torch; no network.
10
+ *
11
+ * Artifacts (prefer packed lib/modelroute/, then vendor/modelroute/):
12
+ * catalog.json, router.json, outcomes.json (partial hard-suite fold).
13
+ * holographic_modelroute.py is a reference, not a runtime dep.
14
+ *
15
+ * Shipped outcomes.json is the measured prior. Live records go to
16
+ * ~/.openzoo/modelroute-outcomes.json and are summed on top so a later
17
+ * reship of the suite table is picked up without double-counting.
18
+ */
19
+ import { createHash } from 'node:crypto';
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ export const AUTO_MODEL_ID = 'openzoo/auto';
26
+ export const AUTO_MODEL_ALIASES = new Set(['openzoo/auto', 'openzoo-auto', 'auto']);
27
+
28
+ export function isAutoModel(id) {
29
+ return AUTO_MODEL_ALIASES.has(String(id || '').trim().toLowerCase());
30
+ }
31
+
32
+ /**
33
+ * Ids the gateway cannot quote for a chat turn. Live GET /v1/models includes
34
+ * :batch twins (positive prices, OpenRouter batch endpoint — 500s), ~latest
35
+ * pointers that duplicate a real id, and openzoo-* aliases of those same ids.
36
+ * Auto must never emit these; the published catalog must never list them.
37
+ */
38
+ export function isUnservableRouteId(id) {
39
+ const s = String(id || '').trim();
40
+ if (!s) return true;
41
+ if (isAutoModel(s)) return false;
42
+ return s.includes(':batch') || s.startsWith('~') || s.startsWith('openzoo-');
43
+ }
44
+
45
+ /** Both sides must be a finite positive token price. $0 / NaN / missing 500. */
46
+ export function isPricedTokenPair(priceIn, priceOut) {
47
+ const pin = Number(priceIn);
48
+ const pout = Number(priceOut);
49
+ return Number.isFinite(pin) && Number.isFinite(pout) && pin > 0 && pout > 0;
50
+ }
51
+
52
+ const MOD_IMAGE = 2;
53
+ export const BIND_ABOVE_TOKENS = 60_000;
54
+ export const BIND_SLICE_TOKENS = 8_000;
55
+ const BLEND_TEMP = 0.05;
56
+ const COST_FLOOR = 1e-6;
57
+ const IDF_BUCKETS = 8192;
58
+ const CHANNEL_W = [1.0, 0.6, 0.45];
59
+
60
+ const _WORD = /[a-z0-9]+/g;
61
+ const _IMG_CUE = /\b(image|images|screenshot|screenshots|photo|photos|pic|picture|pictures|scan|scanned|attached|attachment|diagram|chart|graph|x[- ]?ray|mockup|handwrit\w*|snap|frame|logo|infographic)\b/;
62
+ const _TOOL_CUE = /\b(tool call\w*|tool_choice|function call\w*|call the (tool|api|endpoint|function)|use the \w+ tool|invoke the|agent(ic)? loop|orchestrat\w*|tools? (and|then|as needed)|agent that)\b/;
63
+ const _JSON_CUE = /\b(json (only|schema|output|mode)|(valid|strict|only) json|structured output\w*|json ?schema|response_format|pydantic|machine[ -]readable|no prose|conform\w* to this schema)\b/;
64
+ const _HARD_CUE = /\b(prove|proof|derive|rigorous|production|carefully|step by step|complex|subtle|edge case\w*|optimis\w*|optimiz\w*|architect\w*|security|correctness|exactly|strictly|must)\b/;
65
+
66
+ const _OUT_TOKENS = {
67
+ code: 900, reasoning: 1200, longctx: 1000, vision: 350, bulk: 60,
68
+ creative: 900, translate: 500, agentic: 250, chat: 250, advice: 700,
69
+ };
70
+
71
+ const _CLASS_CATEGORIES = {
72
+ code: { programming: 1.0, technology: 0.5 },
73
+ reasoning: { science: 1.0, academia: 0.7, trivia: 0.2 },
74
+ longctx: { academia: 0.8, legal: 0.5, technology: 0.4 },
75
+ vision: { technology: 0.6, trivia: 0.2 },
76
+ bulk: {},
77
+ creative: { roleplay: 1.0, marketing: 0.7, 'marketing/seo': 0.4 },
78
+ translate: { translation: 1.0 },
79
+ agentic: { programming: 0.8, technology: 0.8 },
80
+ chat: { trivia: 1.0, technology: 0.3 },
81
+ advice: { legal: 0.8, health: 0.8, finance: 0.8 },
82
+ };
83
+
84
+ const _BAR = {
85
+ code: [0.58, 0.72], reasoning: [0.65, 0.82], longctx: [0.60, 0.74],
86
+ vision: [0.55, 0.70], bulk: [0.40, 0.52], creative: [0.50, 0.66],
87
+ translate: [0.52, 0.68], agentic: [0.60, 0.76], chat: [0.40, 0.52],
88
+ advice: [0.68, 0.80],
89
+ };
90
+
91
+ function sha256(s) {
92
+ return createHash('sha256').update(s, 'utf8').digest();
93
+ }
94
+
95
+ export function artifactDir() {
96
+ const env = process.env.OPENZOO_MODELROUTE_DIR;
97
+ if (env) return env;
98
+ const here = path.dirname(fileURLToPath(import.meta.url));
99
+ // Packed npm tree: lib/modelroute/{catalog,router,outcomes}.json
100
+ const bundled = path.join(here, 'modelroute');
101
+ if (existsSync(path.join(bundled, 'catalog.json'))) return bundled;
102
+ // Repo / overlay: vendor/modelroute (same files, plus the Python reference)
103
+ const vendor = path.resolve(here, '..', 'vendor', 'modelroute');
104
+ if (existsSync(path.join(vendor, 'catalog.json'))) return vendor;
105
+ return bundled;
106
+ }
107
+
108
+ export function shippedOutcomesPath() {
109
+ return path.join(artifactDir(), 'outcomes.json');
110
+ }
111
+
112
+ export function defaultOutcomesPath() {
113
+ return process.env.OPENZOO_MODELROUTE_OUTCOMES
114
+ || path.join(os.homedir(), '.openzoo', 'modelroute-outcomes.json');
115
+ }
116
+
117
+ function readOutcomeTab(filePath) {
118
+ if (!filePath || !existsSync(filePath)) return {};
119
+ try {
120
+ const tab = JSON.parse(readFileSync(filePath, 'utf8'));
121
+ return tab && typeof tab === 'object' && !Array.isArray(tab) ? tab : {};
122
+ } catch {
123
+ return {};
124
+ }
125
+ }
126
+
127
+ function mergeOutcomeTabs(base, extra) {
128
+ const tab = { ...base };
129
+ for (const [k, pair] of Object.entries(extra || {})) {
130
+ const s = Number(pair?.[0]) || 0;
131
+ const n = Number(pair?.[1]) || 0;
132
+ const [s0, n0] = tab[k] || [0, 0];
133
+ tab[k] = [s0 + s, n0 + n];
134
+ }
135
+ return tab;
136
+ }
137
+
138
+ function writeOutcomeTab(filePath, tab) {
139
+ if (!filePath) return;
140
+ mkdirSync(path.dirname(filePath) || '.', { recursive: true });
141
+ const keys = Object.keys(tab).sort();
142
+ const ordered = {};
143
+ for (const key of keys) ordered[key] = tab[key];
144
+ writeFileSync(filePath, JSON.stringify(ordered, null, 0));
145
+ }
146
+
147
+ export function parseLooseJson(text) {
148
+ return JSON.parse(String(text).replace(/\bNaN\b/g, 'null').replace(/-?Infinity\b/g, 'null'));
149
+ }
150
+
151
+ export function atomPositions(token, dim, k, seed) {
152
+ let buf = sha256(`${seed}|${token}`);
153
+ const idx = [];
154
+ const sgn = [];
155
+ let salt = 0;
156
+ while (idx.length < k) {
157
+ for (let off = 0; off < 32 && idx.length < k; off += 8) {
158
+ const chunk = buf.readBigUInt64BE(off);
159
+ idx.push(Number(chunk % BigInt(dim)));
160
+ sgn.push((chunk >> 63n) & 1n ? 1 : -1);
161
+ }
162
+ salt += 1;
163
+ buf = sha256(`${seed}|${token}|${salt}`);
164
+ }
165
+ return { idx, sgn };
166
+ }
167
+
168
+ function argmaxTiebreak(s) {
169
+ let i = 0;
170
+ for (let j = 1; j < s.length; j++) if (s[j] > s[i]) i = j;
171
+ return i;
172
+ }
173
+
174
+ function unitRow(row) {
175
+ let n = 0;
176
+ for (const x of row) n += x * x;
177
+ n = Math.sqrt(n) + 1e-12;
178
+ return row.map((x) => x / n);
179
+ }
180
+
181
+ function l2normalize(v) {
182
+ let n = 0;
183
+ for (const x of v) n += x * x;
184
+ n = Math.sqrt(n);
185
+ if (n < 1e-12) return v;
186
+ return v.map((x) => x / n);
187
+ }
188
+
189
+ export class TextEncoder {
190
+ constructor({
191
+ dim = 4096, k = 8, seed = 17, char_n = 4, use_bigrams = true, cache = true, idf = null,
192
+ } = {}) {
193
+ this.dim = dim | 0;
194
+ this.k = k | 0;
195
+ this.seed = seed | 0;
196
+ this.char_n = char_n | 0;
197
+ this.use_bigrams = Boolean(use_bigrams);
198
+ this._cache = cache ? new Map() : null;
199
+ this.idf = idf ? Float64Array.from(idf) : null;
200
+ }
201
+
202
+ idfBucket(tok) {
203
+ return Number(sha256(`idf|${this.seed}|${tok}`).readBigUInt64BE(0) % BigInt(IDF_BUCKETS));
204
+ }
205
+
206
+ weight(tok) {
207
+ return this.idf == null ? 1 : this.idf[this.idfBucket(tok)];
208
+ }
209
+
210
+ channels(text) {
211
+ const words = String(text || '').toLowerCase().match(_WORD) || [];
212
+ const bigrams = this.use_bigrams
213
+ ? words.slice(1).map((b, i) => `${words[i]}_${b}`)
214
+ : [];
215
+ const flat = words.join(' ');
216
+ const n = this.char_n;
217
+ const chars = [];
218
+ const limit = Math.max(0, flat.length - n + 1);
219
+ for (let i = 0; i < limit; i++) chars.push(`#${flat.slice(i, i + n)}`);
220
+ return [words, bigrams, chars];
221
+ }
222
+
223
+ atom(tok) {
224
+ if (!this._cache) return atomPositions(tok, this.dim, this.k, this.seed);
225
+ let got = this._cache.get(tok);
226
+ if (!got) {
227
+ got = atomPositions(tok, this.dim, this.k, this.seed);
228
+ this._cache.set(tok, got);
229
+ }
230
+ return got;
231
+ }
232
+
233
+ bundle(toks) {
234
+ const v = new Float64Array(this.dim);
235
+ for (const tok of toks) {
236
+ const { idx, sgn } = this.atom(tok);
237
+ const w = this.weight(tok);
238
+ for (let i = 0; i < idx.length; i++) v[idx[i]] += sgn[i] * w;
239
+ }
240
+ return l2normalize(Array.from(v));
241
+ }
242
+
243
+ encode(text) {
244
+ let v = new Array(this.dim).fill(0);
245
+ const chans = this.channels(text);
246
+ for (let c = 0; c < CHANNEL_W.length; c++) {
247
+ if (!chans[c].length) continue;
248
+ const b = this.bundle(chans[c]);
249
+ const w = CHANNEL_W[c];
250
+ for (let i = 0; i < v.length; i++) v[i] += w * b[i];
251
+ }
252
+ return l2normalize(v);
253
+ }
254
+ }
255
+
256
+ export class TaskClassifier {
257
+ constructor(classes, encoder, P) {
258
+ this.classes = [...classes];
259
+ this.enc = encoder;
260
+ this.P = P;
261
+ }
262
+
263
+ static load(routerPath) {
264
+ const raw = JSON.parse(readFileSync(routerPath, 'utf8'));
265
+ const cfg = typeof raw.config === 'string' ? JSON.parse(raw.config) : raw.config;
266
+ const enc = new TextEncoder({ ...cfg, idf: raw.idf });
267
+ const [rows, cols] = raw.q_shape;
268
+ const buf = Buffer.from(raw.q_b64, 'base64');
269
+ const q = new Int8Array(buf.buffer, buf.byteOffset, buf.byteLength);
270
+ return TaskClassifier._fromQuant(raw.classes, enc, q, raw.scale, rows, cols);
271
+ }
272
+
273
+ static _fromQuant(classes, enc, q, scale, rows, cols) {
274
+ const P = [];
275
+ for (let r = 0; r < rows; r++) {
276
+ const row = new Array(cols);
277
+ const s = Number(scale[r]);
278
+ for (let c = 0; c < cols; c++) row[c] = (q[r * cols + c] * s) / 127;
279
+ P.push(unitRow(row));
280
+ }
281
+ return new TaskClassifier(classes, enc, P);
282
+ }
283
+
284
+ scores(text) {
285
+ const v = this.enc.encode(text);
286
+ return this.P.map((row) => {
287
+ let s = 0;
288
+ for (let i = 0; i < row.length; i++) s += row[i] * v[i];
289
+ return s;
290
+ });
291
+ }
292
+
293
+ predict(text) {
294
+ const s = this.scores(text);
295
+ const i = argmaxTiebreak(s);
296
+ const sorted = [...s].sort((a, b) => b - a);
297
+ const margin = sorted.length > 1 ? sorted[0] - sorted[1] : sorted[0];
298
+ return [this.classes[i], s[i], margin];
299
+ }
300
+ }
301
+
302
+ export function extractConstraints(text, {
303
+ has_image = false, needs_tools = null, needs_json = null,
304
+ input_tokens = null, output_tokens = null, task_class = null,
305
+ } = {}) {
306
+ const low = String(text || '').toLowerCase();
307
+ const est_in = input_tokens != null ? (input_tokens | 0) : Math.max(16, (String(text || '').length / 4) | 0);
308
+ const est_out = output_tokens != null ? (output_tokens | 0) : (_OUT_TOKENS[task_class] ?? 400);
309
+ return {
310
+ needs_image: Boolean(has_image || _IMG_CUE.test(low)),
311
+ needs_tools: needs_tools == null ? Boolean(_TOOL_CUE.test(low)) : Boolean(needs_tools),
312
+ needs_json: needs_json == null ? Boolean(_JSON_CUE.test(low)) : Boolean(needs_json),
313
+ est_in,
314
+ est_out,
315
+ min_context: ((((est_in + est_out) * 1.25) | 0) + 512),
316
+ estimated: [input_tokens == null, output_tokens == null],
317
+ };
318
+ }
319
+
320
+ export function difficulty(text, margin = 1.0, est_in = 0) {
321
+ const hits = Number(_HARD_CUE.test(String(text || '').toLowerCase()))
322
+ + Number(est_in > 2000)
323
+ + Number(margin < 0.05);
324
+ return hits >= 1 ? 'hard' : 'easy';
325
+ }
326
+
327
+ export class Outcomes {
328
+ static PRIOR_STRENGTH = 6.0;
329
+
330
+ /**
331
+ * @param {string|null} filePath live write path; null = memory only
332
+ * @param {{ shipped?: string|false }} [opts]
333
+ * shipped path to preload (suite table). false skips. omitted + filePath
334
+ * set means "that file only" (tests). runtime() always preloads shipped.
335
+ */
336
+ constructor(filePath = defaultOutcomesPath(), opts = {}) {
337
+ this.path = filePath;
338
+ this.shipped = {};
339
+ this.live = {};
340
+ this.tab = {};
341
+ if (opts.shipped) this.shipped = readOutcomeTab(opts.shipped);
342
+ if (filePath) this.live = readOutcomeTab(filePath);
343
+ this.tab = mergeOutcomeTabs(this.shipped, this.live);
344
+ }
345
+
346
+ /** Shipped hard-suite table + this machine's live file (summed). */
347
+ static runtime() {
348
+ const ship = shippedOutcomesPath();
349
+ const live = defaultOutcomesPath();
350
+ const same = live && ship && path.resolve(live) === path.resolve(ship);
351
+ // Never write the live delta onto the packed suite file.
352
+ return new Outcomes(same ? null : live, { shipped: ship });
353
+ }
354
+
355
+ static key(taskClass, modelId) {
356
+ return `${taskClass}|${modelId}`;
357
+ }
358
+
359
+ record(taskClass, modelId, ok) {
360
+ const k = Outcomes.key(taskClass, modelId);
361
+ const [ls, ln] = this.live[k] || [0, 0];
362
+ this.live[k] = [ls + (ok ? 1 : 0), ln + 1];
363
+ this.tab = mergeOutcomeTabs(this.shipped, this.live);
364
+ if (this.path) writeOutcomeTab(this.path, this.live);
365
+ return this.tab[k];
366
+ }
367
+
368
+ posterior(taskClass, modelId, prior) {
369
+ const [s, n] = this.tab[Outcomes.key(taskClass, modelId)] || [0, 0];
370
+ const a0 = Outcomes.PRIOR_STRENGTH;
371
+ return [(a0 * prior + s) / (a0 + n), n];
372
+ }
373
+ }
374
+
375
+ export class Catalog {
376
+ static P_TOP = 0.88;
377
+ static P_BOT = 0.62;
378
+ static P_UNKNOWN = 0.45;
379
+ static P_UNKNOWN_TOOLS = 0.05;
380
+ static P_UNKNOWN_REASON = 0.06;
381
+
382
+ constructor(catalogPath, raw = null) {
383
+ const data = raw || parseLooseJson(readFileSync(catalogPath, 'utf8'));
384
+ this.ids = data.ids.map(String);
385
+ this.categories = data.categories.map(String);
386
+ this.ctx = data.ctx.map((n) => Number(n) || 0);
387
+ this.price_in = data.price_in.map((n) => (n == null ? NaN : Number(n)));
388
+ this.price_out = data.price_out.map((n) => (n == null ? NaN : Number(n)));
389
+ this.modality = data.modality.map((n) => Number(n) || 0);
390
+ this.tools = data.tools.map(Boolean);
391
+ this.jsonmode = data.jsonmode.map(Boolean);
392
+ this.reasoning = data.reasoning.map(Boolean);
393
+ this.ranks = data.ranks;
394
+ this.stamp = String(data.stamp || '?');
395
+ this._cat_ix = Object.fromEntries(this.categories.map((c, i) => [c, i]));
396
+ }
397
+
398
+ get length() { return this.ids.length; }
399
+
400
+ feasible(cons, allowFree = false, allowIds = null) {
401
+ const allow = allowIds == null ? null : new Set(allowIds);
402
+ const ok = new Array(this.ids.length).fill(true);
403
+ for (let i = 0; i < this.ids.length; i++) {
404
+ if (isUnservableRouteId(this.ids[i])) ok[i] = false;
405
+ if (allow && !allow.has(this.ids[i])) ok[i] = false;
406
+ if (cons.needs_image && !((this.modality[i] & MOD_IMAGE) > 0)) ok[i] = false;
407
+ if (cons.needs_tools && !this.tools[i]) ok[i] = false;
408
+ if (cons.needs_json && !this.jsonmode[i]) ok[i] = false;
409
+ if (this.ctx[i] < cons.min_context) ok[i] = false;
410
+ const pin = this.price_in[i];
411
+ const pout = this.price_out[i];
412
+ if (!Number.isFinite(pin) || !Number.isFinite(pout) || pin < 0 || pout < 0) ok[i] = false;
413
+ // Paid routing requires both sides > 0. OR used to let prompt=0 through.
414
+ if (!allowFree && !isPricedTokenPair(pin, pout)) ok[i] = false;
415
+ }
416
+ return ok;
417
+ }
418
+
419
+ cost(cons) {
420
+ return this.ids.map((_, i) => this.price_in[i] * cons.est_in / 1e6 + this.price_out[i] * cons.est_out / 1e6);
421
+ }
422
+
423
+ prior(taskClass, cons) {
424
+ const weights = _CLASS_CATEGORIES[taskClass] || {};
425
+ const p = new Array(this.ids.length).fill(Catalog.P_UNKNOWN);
426
+ const keys = Object.keys(weights);
427
+ if (keys.length) {
428
+ const num = new Array(this.ids.length).fill(0);
429
+ const den = new Array(this.ids.length).fill(0);
430
+ const span = Catalog.P_TOP - Catalog.P_BOT;
431
+ for (const cat of keys) {
432
+ const j = this._cat_ix[cat];
433
+ if (j == null) continue;
434
+ const w = weights[cat];
435
+ for (let i = 0; i < this.ids.length; i++) {
436
+ const r = Number(this.ranks[i][j]) || 0;
437
+ if (r > 0) {
438
+ num[i] += w * (Catalog.P_TOP - (r - 1) / 19 * span);
439
+ den[i] += w;
440
+ }
441
+ }
442
+ }
443
+ for (let i = 0; i < this.ids.length; i++) if (den[i] > 0) p[i] = num[i] / den[i];
444
+ }
445
+ for (let i = 0; i < this.ids.length; i++) {
446
+ if (p[i] !== Catalog.P_UNKNOWN) continue;
447
+ if (cons.needs_tools || cons.needs_json) {
448
+ if (this.tools[i]) p[i] += Catalog.P_UNKNOWN_TOOLS;
449
+ }
450
+ if (taskClass === 'reasoning' || taskClass === 'code') {
451
+ if (this.reasoning[i]) p[i] += Catalog.P_UNKNOWN_REASON;
452
+ }
453
+ p[i] = Math.min(1, Math.max(0, p[i]));
454
+ }
455
+ return p;
456
+ }
457
+
458
+ bar(taskClass, diff) {
459
+ const [lo, hi] = _BAR[taskClass] || [0.55, 0.70];
460
+ return diff === 'hard' ? hi : lo;
461
+ }
462
+ }
463
+
464
+ function emptyRoute(cls, c2, cons, diff, bar, stamp, reason) {
465
+ return {
466
+ model: null, task_class: cls, runner_up: c2, bind_first: Boolean(cons?.bind_first),
467
+ difficulty: diff, bar, constraints: cons, shortlist: [],
468
+ reason, cleared_bar: false, feasible_models: 0, catalog_stamp: stamp,
469
+ };
470
+ }
471
+
472
+ export function route(text, {
473
+ catalog, classifier, outcomes, k = 5, bar_shift = 0,
474
+ allow_free = false, blend = true, context = null, bindable = true,
475
+ has_image, needs_tools, needs_json, input_tokens, output_tokens,
476
+ allow_ids = null,
477
+ } = {}) {
478
+ const cat = catalog ?? getCatalog();
479
+ const clf = classifier ?? getClassifier();
480
+ const out = outcomes ?? getOutcomes();
481
+
482
+ const classifyOn = context ? `${context}\n${text}` : text;
483
+ const s = clf.scores(classifyOn);
484
+ let i0 = argmaxTiebreak(s);
485
+ let i1 = 0;
486
+ let best2 = -Infinity;
487
+ for (let i = 0; i < s.length; i++) {
488
+ if (i === i0) continue;
489
+ if (s[i] > best2) { best2 = s[i]; i1 = i; }
490
+ }
491
+ if (s.length < 2) i1 = i0;
492
+ const cls = clf.classes[i0];
493
+ const c2 = clf.classes[i1];
494
+ const conf = s[i0];
495
+ const margin = s[i0] - s[i1];
496
+ let cons = extractConstraints(text, {
497
+ has_image, needs_tools, needs_json, input_tokens, output_tokens, task_class: cls,
498
+ });
499
+
500
+ const raw_in = cons.est_in;
501
+ const bind_first = Boolean(bindable && raw_in > BIND_ABOVE_TOKENS);
502
+ if (bind_first) {
503
+ cons = {
504
+ ...cons,
505
+ est_in: BIND_SLICE_TOKENS,
506
+ min_context: ((((BIND_SLICE_TOKENS + cons.est_out) * 1.25) | 0) + 512),
507
+ };
508
+ }
509
+ cons.bind_first = bind_first;
510
+ cons.raw_in = raw_in;
511
+
512
+ const diff = difficulty(classifyOn, margin, raw_in);
513
+ const gap = s[i0] - s[i1];
514
+ const w2 = Math.exp(-Math.max(0, gap) / BLEND_TEMP);
515
+ let w = [1 / (1 + w2), w2 / (1 + w2)];
516
+ if (!blend) w = [1, 0];
517
+
518
+ const feas = cat.feasible(cons, allow_free, allow_ids);
519
+ const cost = cat.cost(cons);
520
+ const prior1 = cat.prior(cls, cons);
521
+ const prior2 = cat.prior(c2, cons);
522
+ const prior = prior1.map((p, i) => w[0] * p + w[1] * prior2[i]);
523
+ const bar = Math.min(1, Math.max(0, w[0] * cat.bar(cls, diff) + w[1] * cat.bar(c2, diff) + bar_shift));
524
+ const post = [];
525
+ const nobs = [];
526
+ for (let i = 0; i < cat.ids.length; i++) {
527
+ const [p, n] = out.posterior(cls, cat.ids[i], prior[i]);
528
+ post.push(p);
529
+ nobs.push(n);
530
+ }
531
+
532
+ const idx = [];
533
+ for (let i = 0; i < feas.length; i++) if (feas[i]) idx.push(i);
534
+ if (!idx.length) {
535
+ return emptyRoute(cls, c2, cons, diff, bar, cat.stamp,
536
+ 'no model in the catalogue satisfies the hard constraints');
537
+ }
538
+
539
+ const clears = idx.filter((i) => post[i] >= bar);
540
+ const cleared = clears.length > 0;
541
+ const pool = cleared ? clears : idx;
542
+ const order = cleared
543
+ ? [...pool].sort((a, b) => {
544
+ const ca = Math.round(cost[a] * 1e12) / 1e12;
545
+ const cb = Math.round(cost[b] * 1e12) / 1e12;
546
+ if (ca !== cb) return ca - cb;
547
+ if (post[b] !== post[a]) return post[b] - post[a];
548
+ return cat.ids[a] < cat.ids[b] ? -1 : cat.ids[a] > cat.ids[b] ? 1 : 0;
549
+ })
550
+ : [...pool].sort((a, b) => {
551
+ if (post[b] !== post[a]) return post[b] - post[a];
552
+ const ca = Math.round(cost[a] * 1e12) / 1e12;
553
+ const cb = Math.round(cost[b] * 1e12) / 1e12;
554
+ if (ca !== cb) return ca - cb;
555
+ return cat.ids[a] < cat.ids[b] ? -1 : cat.ids[a] > cat.ids[b] ? 1 : 0;
556
+ });
557
+
558
+ const cheapestFeasible = Math.max(Math.min(...idx.map((i) => cost[i])), COST_FLOOR);
559
+ const short = [];
560
+ for (const i of order.slice(0, k)) {
561
+ short.push({
562
+ model: cat.ids[i],
563
+ p_success: Math.round(post[i] * 1000) / 1000,
564
+ evidence: nobs[i] ? `measured(n=${nobs[i]})` : 'prior',
565
+ usd_per_task: Math.round(cost[i] * 1e6) / 1e6,
566
+ relative_cost: cheapestFeasible ? Math.round((cost[i] / cheapestFeasible) * 100) / 100 : null,
567
+ context: cat.ctx[i] | 0,
568
+ });
569
+ }
570
+ const top = order[0];
571
+ const nClear = idx.filter((i) => post[i] >= bar).length;
572
+ const reason = cleared
573
+ ? `${bind_first ? `bind ${cons.raw_in} tokens to leCore first, then ` : ''}`
574
+ + `cheapest of ${nClear} feasible models clearing P>=${bar.toFixed(2)} for a ${diff} ${cls} task`
575
+ : `NO feasible model clears P>=${bar.toFixed(2)} for a ${diff} ${cls} task; returning highest-P instead`;
576
+
577
+ return {
578
+ model: cat.ids[top],
579
+ task_class: cls,
580
+ runner_up: c2,
581
+ bind_first: cons.bind_first,
582
+ blend: [Math.round(w[0] * 1000) / 1000, Math.round(w[1] * 1000) / 1000],
583
+ class_confidence: Math.round(conf * 1000) / 1000,
584
+ class_margin: Math.round(margin * 1000) / 1000,
585
+ difficulty: diff,
586
+ bar: Math.round(bar * 1000) / 1000,
587
+ p_success: Math.round(post[top] * 1000) / 1000,
588
+ usd_per_task: Math.round(cost[top] * 1e6) / 1e6,
589
+ cleared_bar: cleared,
590
+ feasible_models: idx.length,
591
+ shortlist: short,
592
+ constraints: cons,
593
+ catalog_stamp: cat.stamp,
594
+ reason,
595
+ };
596
+ }
597
+
598
+ let _catalog;
599
+ let _classifier;
600
+ let _outcomes;
601
+
602
+ export function getCatalog() {
603
+ if (!_catalog) _catalog = new Catalog(path.join(artifactDir(), 'catalog.json'));
604
+ return _catalog;
605
+ }
606
+
607
+ export function getClassifier() {
608
+ if (!_classifier) _classifier = TaskClassifier.load(path.join(artifactDir(), 'router.json'));
609
+ return _classifier;
610
+ }
611
+
612
+ export function getOutcomes() {
613
+ if (!_outcomes) _outcomes = Outcomes.runtime();
614
+ return _outcomes;
615
+ }
616
+
617
+ export function resetModelrouteSingletons() {
618
+ _catalog = undefined;
619
+ _classifier = undefined;
620
+ _outcomes = undefined;
621
+ }
622
+
623
+ function rawMessageText(m) {
624
+ const c = m?.content;
625
+ if (typeof c === 'string') return c;
626
+ if (Array.isArray(c)) {
627
+ return c.map((b) => {
628
+ if (typeof b === 'string') return b;
629
+ if (b?.type === 'text' && typeof b.text === 'string') return b.text;
630
+ if (typeof b?.text === 'string') return b.text;
631
+ return '';
632
+ }).filter(Boolean).join('\n');
633
+ }
634
+ return '';
635
+ }
636
+
637
+ function messageHasImage(m) {
638
+ const c = m?.content;
639
+ if (!Array.isArray(c)) return false;
640
+ return c.some((b) => b && (b.type === 'image_url' || b.type === 'image' || b.image_url || b.type === 'input_image'));
641
+ }
642
+
643
+ /** Pull route() kwargs out of a chat/completions body. */
644
+ export function routeInputFromChat(body) {
645
+ const messages = Array.isArray(body?.messages) ? body.messages : [];
646
+ const userIdx = [];
647
+ for (let i = 0; i < messages.length; i++) if (messages[i]?.role === 'user') userIdx.push(i);
648
+ const lastUser = userIdx.length ? messages[userIdx[userIdx.length - 1]] : null;
649
+ const text = rawMessageText(lastUser) || (typeof body?.prompt === 'string' ? body.prompt : '');
650
+ const contextParts = [];
651
+ for (let i = 0; i < messages.length; i++) {
652
+ if (lastUser && messages[i] === lastUser) continue;
653
+ const t = rawMessageText(messages[i]);
654
+ if (t) contextParts.push(t);
655
+ }
656
+ const hasImage = messages.some(messageHasImage) || Boolean(body?.has_image);
657
+ const needsTools = Array.isArray(body?.tools) && body.tools.length
658
+ ? true
659
+ : (body?.tool_choice && body.tool_choice !== 'none' ? true : undefined);
660
+ const rf = body?.response_format;
661
+ const needsJson = rf && (rf.type === 'json_object' || rf.type === 'json_schema') ? true : undefined;
662
+ return {
663
+ text,
664
+ context: contextParts.length ? contextParts.join('\n') : undefined,
665
+ has_image: hasImage,
666
+ needs_tools: needsTools,
667
+ needs_json: needsJson,
668
+ };
669
+ }
670
+
671
+ export function routeChatBody(body, extra = {}) {
672
+ const input = routeInputFromChat(body);
673
+ return route(input.text, {
674
+ allow_free: extra.allow_free ?? false,
675
+ bindable: extra.bindable ?? true,
676
+ ...input,
677
+ ...extra,
678
+ });
679
+ }
680
+
681
+ /** Cheapest-first among shortlist entries that cleared the bar. Do not loop forever. */
682
+ export function fallbackChain(result, { max = 5 } = {}) {
683
+ if (!result?.cleared_bar || !Array.isArray(result.shortlist)) return [];
684
+ const chosen = result.model;
685
+ return result.shortlist
686
+ .filter((s) => s.model && s.model !== chosen && (s.p_success == null || s.p_success >= result.bar))
687
+ .sort((a, b) => (a.usd_per_task - b.usd_per_task) || String(a.model).localeCompare(String(b.model)))
688
+ .slice(0, max)
689
+ .map((s) => s.model);
690
+ }
691
+
692
+ export function isRetryableStatus(status) {
693
+ const n = Number(status);
694
+ return n === 429 || (n >= 500 && n <= 599);
695
+ }
696
+
697
+ function completionContent(data) {
698
+ const c = data?.choices?.[0]?.message?.content;
699
+ if (typeof c === 'string') return c;
700
+ if (Array.isArray(c)) return c.map((b) => (typeof b === 'string' ? b : b?.text || '')).join('');
701
+ return '';
702
+ }
703
+
704
+ /**
705
+ * ok=true on 2xx with content; false on empty / error / 4xx except 402.
706
+ * Returns null when the call should not be recorded (402).
707
+ */
708
+ export function outcomeFromResponse(status, data) {
709
+ const n = Number(status);
710
+ if (n === 402) return null;
711
+ if (n >= 200 && n < 300) return Boolean(String(completionContent(data) || '').trim());
712
+ if (n >= 400) return false;
713
+ return false;
714
+ }
715
+
716
+ export function recordRouteOutcome(result, ok) {
717
+ if (!result?.task_class || !result?.model || ok == null) return null;
718
+ return getOutcomes().record(result.task_class, result.model, Boolean(ok));
719
+ }
720
+
721
+ export function autoModelListEntry() {
722
+ return {
723
+ id: AUTO_MODEL_ID,
724
+ object: 'model',
725
+ owned_by: 'openzoo',
726
+ display_name: 'Auto',
727
+ served_by: AUTO_MODEL_ID,
728
+ description: 'Virtual model: cheapest zoo id that clears the task bar',
729
+ };
730
+ }
731
+
732
+ /** True when Auto's feasible set is non-empty after the priced-id filter. */
733
+ export function autoHasPricedModels(catalog, allowIds = null) {
734
+ const cat = catalog ?? getCatalog();
735
+ const cons = { needs_image: false, needs_tools: false, needs_json: false, min_context: 16 };
736
+ return cat.feasible(cons, false, allowIds).some(Boolean);
737
+ }