openzoo 0.49.4 → 0.49.6

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.
package/lib/gui.html CHANGED
@@ -194,7 +194,10 @@ async function send() {
194
194
  const x = d.x402 || {};
195
195
  state.calls += 1;
196
196
  if (typeof x.billedUsd === "number") state.spent += x.billedUsd;
197
- if (typeof x.savesVsDirect === "number" && x.savesVsDirect > 0) state.saved += x.savesVsDirect;
197
+ if (typeof x.savedUsd === "number" && x.savedUsd > 0) state.saved += x.savedUsd;
198
+ else if (typeof x.directUsd === "number" && typeof x.billedUsd === "number") {
199
+ state.saved += Math.max(0, x.directUsd - x.billedUsd);
200
+ }
198
201
  const bits = [];
199
202
  if (typeof x.billedUsd === "number") bits.push(`$${x.billedUsd.toFixed(4)}`);
200
203
  if (x.lecore?.engaged) bits.push(`🧠 ${x.lecore.recalled ?? "?"} slices`);
package/lib/livestatus.js CHANGED
@@ -28,6 +28,68 @@ export function formatPayStatus(attempt = 0) {
28
28
  return Number(attempt) > 0 ? 'waiting on x402…' : 'paying…';
29
29
  }
30
30
 
31
+ /** Cut from 1-model savings. X does not change it — they pay every launched racer. */
32
+ export function raceSavingsCutPct(y) {
33
+ const n = Math.max(1, Number(y) || 1);
34
+ return Math.round((1 - 1 / n) * 100);
35
+ }
36
+
37
+ function sitrepUsd(n) {
38
+ const x = Number(n) || 0;
39
+ return x >= 0.01 || x === 0 ? `$${x.toFixed(2)}` : `$${x.toFixed(5)}`;
40
+ }
41
+
42
+ function sitrepFlight({ status, liveStatus, liveRace } = {}) {
43
+ if (status !== 'thinking') return 'idle';
44
+ if (liveRace?.phase === 'judging') return 'classifier judging';
45
+ if (liveRace?.phase === 'winner') return 'winner';
46
+ if (liveStatus) return String(liveStatus);
47
+ return 'in flight';
48
+ }
49
+
50
+ /**
51
+ * Compact sitrep field list (drawer). No keys, no .npmrc.
52
+ * Prepaid is yes/no — never a secret.
53
+ */
54
+ export function formatSitrep(info = {}) {
55
+ const y = Number(info.race) >= 2 ? Number(info.race) : 1;
56
+ const need = Number(info.raceNeed) || 1;
57
+ const spent = Number(info.spentUsd) || 0;
58
+ const cogs = Number(info.cogsUsd) || 0;
59
+ const direct = Number(info.directUsd) || 0;
60
+ const mult = spent > 0 ? direct / spent : null;
61
+ const saved = mult == null
62
+ ? '—'
63
+ : `${mult >= 100 ? Math.round(mult) : mult.toFixed(mult >= 10 ? 1 : 2)}x`;
64
+ const credit = Number(info.creditUsd);
65
+ const prepaid = Number.isFinite(credit) && credit > 0 ? 'yes' : 'no';
66
+ return [
67
+ 'Sitrep',
68
+ ` race ${raceChoiceLabel(y, need)}`,
69
+ ` band ${info.tier || 'medium'}`,
70
+ ` mode ${info.runMode || 'ask'}`,
71
+ ` cwd ${info.dir || '—'}`,
72
+ ` in flight ${sitrepFlight(info)}`,
73
+ ` paid ${sitrepUsd(spent)}`,
74
+ ` cogs ${sitrepUsd(cogs)}`,
75
+ ` direct ${sitrepUsd(direct)}`,
76
+ ` saved ${saved}`,
77
+ ` paid calls ${Number(info.paidCalls) || 0}`,
78
+ ` prepaid ${prepaid}`,
79
+ ].join('\n');
80
+ }
81
+
82
+ /** Picker / sitrep label: `best 2 of 4 −75%`, `1 model 0%`. */
83
+ export function raceChoiceLabel(y, need = 1) {
84
+ const n = Math.max(1, Number(y) || 1);
85
+ const k = Math.max(1, Math.min(Number(need) || 1, n));
86
+ const cut = raceSavingsCutPct(n);
87
+ const cutTxt = cut === 0 ? '0%' : `−${cut}%`;
88
+ if (n < 2) return `1 model ${cutTxt}`;
89
+ if (k > 1) return `best ${k} of ${n} ${cutTxt}`;
90
+ return `race ${n} ${cutTxt}`;
91
+ }
92
+
31
93
  /** First-X-back race: how many of the K we asked for have actually landed. */
32
94
  export function formatRaceStatus(back, need) {
33
95
  const n = Math.max(1, Number(need) || 1);
@@ -35,6 +97,24 @@ export function formatRaceStatus(back, need) {
35
97
  return `racing ${b}/${n} back…`;
36
98
  }
37
99
 
100
+ /** OpenRouter id → short cell label. `z-ai/glm-4.7` → `glm-4.7`. */
101
+ export function shortModelName(id) {
102
+ const s = String(id || '').trim();
103
+ if (!s) return 'model';
104
+ const parts = s.split('/');
105
+ return parts[parts.length - 1] || s;
106
+ }
107
+
108
+ /** Compact spectator preview — opening lines, not the whole answer. */
109
+ export function clipRacePreview(text, maxLines = 8, maxChars = 420) {
110
+ const s = String(text || '').replace(/\r/g, '');
111
+ if (!s) return '';
112
+ const lines = s.split('\n');
113
+ let out = lines.length > maxLines ? lines.slice(0, maxLines).join('\n') + '\n…' : s;
114
+ if (out.length > maxChars) out = `${out.slice(0, maxChars - 1)}…`;
115
+ return out;
116
+ }
117
+
38
118
  /** Race-level failure when no countable answer exists. Never a single model name. */
39
119
  export const RACE_EVERY_FAILED = '(race: every model failed — no reply)';
40
120
 
@@ -154,20 +234,76 @@ export function pickRaceWinner(cands, minScore = RACE_MIN_SCORE) {
154
234
  /**
155
235
  * Live race bubble: stream the fastest still-alive entrant, swap once if the
156
236
  * winner is someone else. `onDelta(text, { replace, model })`.
237
+ *
238
+ * `onRace(snap)` is the spectator feed — one cell per launched model, with
239
+ * waiting/streaming/back/failed/abandoned plus a truncated preview. Not a
240
+ * second racer: `phase: 'judging'` is the classifier looking at the X that
241
+ * already made it back.
157
242
  */
158
- export function createRaceFeed(onDelta, onStatus, need) {
243
+ export function createRaceFeed(onDelta, onStatus, need, onRace) {
159
244
  let live = null;
160
245
  let settled = false;
161
246
  let back = 0;
247
+ let phase = 'racing';
248
+ let winnerModel = '';
249
+ let recutNote = '';
162
250
  const buf = new Map();
163
251
  const dead = new Set();
252
+ const order = [];
253
+ const cells = new Map();
164
254
  const paintStatus = () => { onStatus?.(formatRaceStatus(back, need)); };
255
+ const ensure = (model) => {
256
+ const id = String(model || '').trim() || 'model';
257
+ if (cells.has(id)) return cells.get(id);
258
+ const row = { model: id, status: 'waiting', preview: '', fail: '' };
259
+ cells.set(id, row);
260
+ order.push(id);
261
+ return row;
262
+ };
263
+ const freezeStragglers = () => {
264
+ if (back < need) return;
265
+ for (const row of cells.values()) {
266
+ if (row.status === 'waiting' || row.status === 'streaming') row.status = 'abandoned';
267
+ }
268
+ };
269
+ const snapshot = () => ({
270
+ need,
271
+ launched: order.length,
272
+ back,
273
+ phase,
274
+ winner: winnerModel || '',
275
+ recut: recutNote || '',
276
+ racers: order.map((id) => {
277
+ const row = cells.get(id);
278
+ return {
279
+ model: id,
280
+ short: shortModelName(id),
281
+ status: row.status,
282
+ preview: row.status === 'failed' ? '' : clipRacePreview(row.preview),
283
+ fail: row.fail || '',
284
+ };
285
+ }),
286
+ });
287
+ const emitRace = () => { onRace?.(snapshot()); };
165
288
  return {
166
- start() { paintStatus(); },
289
+ start(models, extra) {
290
+ if (Array.isArray(models)) {
291
+ for (const m of models) if (m) ensure(m);
292
+ }
293
+ if (extra && extra.recut) recutNote = String(extra.recut);
294
+ paintStatus();
295
+ emitRace();
296
+ },
297
+ snapshot,
167
298
  liveModel() { return live; },
168
299
  onToken(model, chunk) {
169
300
  if (settled || chunk == null || chunk === '') return;
301
+ const row = ensure(model);
302
+ if (row.status === 'abandoned' || row.status === 'failed' || row.status === 'back') return;
170
303
  buf.set(model, (buf.get(model) || '') + chunk);
304
+ row.preview = buf.get(model) || '';
305
+ if (row.status === 'waiting') row.status = 'streaming';
306
+ emitRace();
171
307
  if (!live) {
172
308
  live = model;
173
309
  onDelta?.(chunk, { model });
@@ -175,8 +311,15 @@ export function createRaceFeed(onDelta, onStatus, need) {
175
311
  }
176
312
  if (live === model) onDelta?.(chunk, { model });
177
313
  },
178
- onFail(model) {
314
+ onFail(model, arrival) {
179
315
  dead.add(model);
316
+ const row = ensure(model);
317
+ if (row.status !== 'abandoned' && row.status !== 'back') {
318
+ row.status = 'failed';
319
+ row.fail = raceFailKind(arrival || { model, text: '', error: 'error' });
320
+ row.preview = '';
321
+ emitRace();
322
+ }
180
323
  if (settled || live !== model) return;
181
324
  const next = [...buf.entries()].find(([m, t]) => m !== model && t && !dead.has(m));
182
325
  if (next) {
@@ -186,18 +329,44 @@ export function createRaceFeed(onDelta, onStatus, need) {
186
329
  live = null;
187
330
  }
188
331
  },
189
- onBack() {
332
+ onBack(model) {
190
333
  // Late countable stragglers after ship used to paint "racing 4/2 back…"
191
334
  // onto an already-idle thread (GET /threads/:id returns raw liveStatus).
192
335
  if (settled || back >= need) return;
193
336
  back += 1;
337
+ let row = model ? ensure(model) : null;
338
+ if (!row) {
339
+ row = [...cells.values()].find((r) => r.status === 'streaming' || r.status === 'waiting');
340
+ }
341
+ if (row && row.status !== 'abandoned' && row.status !== 'failed') {
342
+ row.status = 'back';
343
+ if (buf.has(row.model)) row.preview = buf.get(row.model);
344
+ }
345
+ freezeStragglers();
194
346
  paintStatus();
347
+ emitRace();
348
+ },
349
+ judge() {
350
+ if (settled) return;
351
+ phase = 'judging';
352
+ freezeStragglers();
353
+ emitRace();
195
354
  },
196
355
  settle(winner) {
197
356
  settled = true;
357
+ phase = 'winner';
198
358
  const text = String(winner?.text || '').trim()
199
359
  ? winner.text
200
360
  : RACE_EVERY_FAILED;
361
+ winnerModel = winner?.error ? '' : (winner?.model || '');
362
+ if (winnerModel) {
363
+ const row = ensure(winnerModel);
364
+ if (row.status !== 'failed') {
365
+ row.status = 'back';
366
+ if (winner?.text) row.preview = String(winner.text);
367
+ }
368
+ }
369
+ emitRace();
201
370
  // Live stream already showing this answer — keep going, do not re-dump.
202
371
  if (winner?.model && live === winner.model && !winner.error) return;
203
372
  live = winner?.model || live;
package/lib/mcp.js CHANGED
@@ -80,7 +80,7 @@ export function buildMcpServer() {
80
80
  + '(client-usable ceiling ~128M tokens; keep one call under ~9.8M) is bound once and the model reads only '
81
81
  + 'a few thousand tokens of it. Re-asking against an already-bound corpus is near-free and much faster, so '
82
82
  + 'send the corpus once and ask many questions. Returns the answer plus a payment receipt '
83
- + '(billedUsd, savesVsDirect, tokens actually read).',
83
+ + '(billedUsd, directUsd, savedUsd, tokens actually read).',
84
84
  inputSchema: {
85
85
  prompt: z.string().describe('The question or instruction.'),
86
86
  corpus: z.string().optional().describe('Optional big context body (document dump, logs, book...). Placed before the prompt.'),
@@ -128,6 +128,8 @@ export function buildMcpServer() {
128
128
  receipt: receipt ? {
129
129
  line: receipt.line,
130
130
  billedUsd: receipt.billedUsd,
131
+ directUsd: receipt.directUsd,
132
+ savedUsd: receipt.savedUsd,
131
133
  savesVsDirect: receipt.savesVsDirect,
132
134
  pricing: receipt.pricing,
133
135
  rail: receipt.rail,
package/lib/pay.js CHANGED
@@ -413,6 +413,9 @@ export class PayClient {
413
413
  line: receiptLine(accept, settle),
414
414
  rail: railOf(accept),
415
415
  billedUsd: accept.extra?.billedUsd,
416
+ directUsd: accept.extra?.directUsd,
417
+ savedUsd: accept.extra?.savedUsd,
418
+ cogsUsd: accept.extra?.cogsUsd,
416
419
  savesVsDirect: accept.extra?.savesVsDirect,
417
420
  pricing: accept.extra?.pricing,
418
421
  symbol: accept.extra?.symbol,
package/lib/podagent.mjs CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  } from './livestatus.js';
31
31
  import {
32
32
  probeGatewayRace, capRaceByCredit, inferRaceTier, RACE_NO_CREDIT,
33
+ recutRaceByHud, sessionDollarX,
33
34
  } from './racesettle.js';
34
35
  import { homedir } from 'node:os';
35
36
 
@@ -679,8 +680,16 @@ async function readProxySession(proxy = completionsProxy()) {
679
680
  }
680
681
 
681
682
  async function raceBudget(hooks) {
682
- if (hooks.creditUsd != null || hooks.quoteUsd != null) {
683
- return { creditUsd: hooks.creditUsd, quoteUsd: hooks.quoteUsd };
683
+ const injected = hooks.creditUsd != null || hooks.quoteUsd != null
684
+ || hooks.spentUsd != null || hooks.directUsd != null || hooks.dollarX != null;
685
+ if (injected) {
686
+ return {
687
+ creditUsd: hooks.creditUsd,
688
+ quoteUsd: hooks.quoteUsd,
689
+ spentUsd: hooks.spentUsd,
690
+ directUsd: hooks.directUsd,
691
+ dollarX: hooks.dollarX,
692
+ };
684
693
  }
685
694
  // Injected stream = unit-test N-parallel path. Do not poke :8402.
686
695
  if (hooks.stream) return {};
@@ -688,6 +697,8 @@ async function raceBudget(hooks) {
688
697
  return {
689
698
  creditUsd: s?.creditUsd,
690
699
  quoteUsd: s?.lastQuoteUsd ?? s?.quoteUsd,
700
+ spentUsd: s?.spentUsd,
701
+ directUsd: s?.directUsd,
691
702
  };
692
703
  }
693
704
 
@@ -719,8 +730,8 @@ async function brainGatewayRace(messages, onDelta, contextId, models, need, maxT
719
730
  const classify = hooks.classify || classifyRaceAnswer;
720
731
  const pairwise = hooks.pairwise || pairwiseTied;
721
732
  const minScore = hooks.minScore != null ? Number(hooks.minScore) : RACE_MIN_SCORE;
722
- const feed = createRaceFeed(onDelta, onStatus, want);
723
- feed.start();
733
+ const feed = createRaceFeed(onDelta, onStatus, want, hooks.onRace);
734
+ feed.start(models, hooks.raceRecut ? { recut: hooks.raceRecut } : undefined);
724
735
 
725
736
  const arrivals = [];
726
737
  const done = [];
@@ -772,7 +783,7 @@ async function brainGatewayRace(messages, onDelta, contextId, models, need, maxT
772
783
  if (isRaceCountable(lastFail)) {
773
784
  arrivals.push(lastFail);
774
785
  done.push(lastFail);
775
- feed.onBack();
786
+ feed.onBack(lastFail.model);
776
787
  if (text) onDelta(text);
777
788
  break;
778
789
  }
@@ -785,9 +796,9 @@ async function brainGatewayRace(messages, onDelta, contextId, models, need, maxT
785
796
  arrivals.push(a);
786
797
  if (isRaceCountable(a)) {
787
798
  done.push(a);
788
- feed.onBack();
799
+ feed.onBack(a.model);
789
800
  } else {
790
- feed.onFail(a.model);
801
+ feed.onFail(a.model, a);
791
802
  }
792
803
  }
793
804
  lastFail = parsed.arrivals[parsed.arrivals.length - 1] || lastFail;
@@ -804,6 +815,7 @@ async function brainGatewayRace(messages, onDelta, contextId, models, need, maxT
804
815
  if (cands.length === 1) return ship(cands[0]);
805
816
 
806
817
  onStatus?.('judging…');
818
+ feed.judge();
807
819
  const scored = await Promise.all(cands.map(async (c) => {
808
820
  let score = 0;
809
821
  try { score = Number(await classify(messages, c)) || 0; } catch { score = 0; }
@@ -960,6 +972,31 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
960
972
  const minScore = hooks.minScore != null ? Number(hooks.minScore) : RACE_MIN_SCORE;
961
973
  let list = (models || []).filter(Boolean).slice(0, RACE_MAX);
962
974
  const budget = await raceBudget(hooks);
975
+ let tier = hooks.tier || inferRaceTier(list, 'medium');
976
+ let wantNeed = Math.max(1, Math.min(Number(need) || 1, list.length || 1));
977
+ const dollarX = sessionDollarX(budget);
978
+ const hud = recutRaceByHud({
979
+ y: list.length, need: wantNeed, dollarX, tier,
980
+ });
981
+ let raceRecut = '';
982
+ if (hud.recut) {
983
+ raceRecut = 'savings';
984
+ if (hud.tier !== tier && !hooks.stream) {
985
+ try {
986
+ const next = await tierModels(hud.tier, hud.y, true);
987
+ if (next.length) { list = next; tier = hud.tier; }
988
+ else list = list.slice(0, hud.y);
989
+ } catch { list = list.slice(0, hud.y); }
990
+ } else {
991
+ list = list.slice(0, hud.y);
992
+ tier = hud.tier;
993
+ }
994
+ wantNeed = Math.min(hud.need, list.length);
995
+ onStatus?.(hud.y < 2
996
+ ? 'race recut to 1 — savings'
997
+ : `race recut to ${hud.y} — savings`);
998
+ }
999
+ hooks = { ...hooks, tier, raceRecut };
963
1000
  const capped = capRaceByCredit(list.length, budget);
964
1001
  if (capped.n < 1) {
965
1002
  onStatus?.('race refused — no credit');
@@ -970,7 +1007,7 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
970
1007
  onStatus?.(capped.n < 2 ? 'race shrunk to 1 — credit' : `race shrunk to ${capped.n} — credit`);
971
1008
  }
972
1009
  if (list.length < 2) return stream(messages, onDelta, contextId, list[0], maxTokens, 0, 0, onStatus);
973
- const want = Math.max(1, Math.min(Number(need) || 1, list.length));
1010
+ const want = Math.max(1, Math.min(wantNeed, list.length));
974
1011
 
975
1012
  // One Fly settle when the completions door honors `race:`. Custom stream
976
1013
  // hooks (unit tests of the N-parallel judge) keep the old path. Old
@@ -985,8 +1022,8 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
985
1022
  return brainGatewayRace(messages, onDelta, contextId, list, want, maxTokens, onStatus, hooks);
986
1023
  }
987
1024
 
988
- const feed = createRaceFeed(onDelta, onStatus, want);
989
- feed.start();
1025
+ const feed = createRaceFeed(onDelta, onStatus, want, hooks.onRace);
1026
+ feed.start(list, raceRecut ? { recut: raceRecut } : undefined);
990
1027
 
991
1028
  const done = [];
992
1029
  const arrivals = [];
@@ -1034,7 +1071,7 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
1034
1071
  if (isRaceCountable(last)) {
1035
1072
  arrivals.push(last);
1036
1073
  done.push(last);
1037
- feed.onBack();
1074
+ feed.onBack(m);
1038
1075
  return;
1039
1076
  }
1040
1077
  } catch (e) {
@@ -1043,7 +1080,7 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
1043
1080
  if (!shouldRetryRaceArrival(last) || attempt === 1 || raceAbort.signal.aborted) break;
1044
1081
  }
1045
1082
  arrivals.push(last);
1046
- feed.onFail(m);
1083
+ feed.onFail(m, last);
1047
1084
  };
1048
1085
 
1049
1086
  const attempts = list.map((m) => runOne(m).finally(() => {
@@ -1068,6 +1105,7 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
1068
1105
  if (cands.length === 1) return ship(cands[0]);
1069
1106
 
1070
1107
  onStatus?.('judging…');
1108
+ feed.judge();
1071
1109
  const scored = await Promise.all(cands.map(async (c) => {
1072
1110
  let score = 0;
1073
1111
  try { score = Number(await classify(messages, c)) || 0; } catch { score = 0; }