openzoo 0.48.97 → 0.48.99

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/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
+ import { fetchHeaders } from './fetch.js';
3
4
 
4
5
  export const config = {
5
6
  port: Number(process.env.OPENZOO_PORT || 8402),
@@ -160,7 +161,7 @@ export function unfundableRails(liveRailNames) {
160
161
  * Underlying assets only: the wrapped settlement mints are internal plumbing.
161
162
  */
162
163
  export async function liveRails() {
163
- const r = await fetch(`${config.apiBase}/v1/chat/completions`, {
164
+ const r = await fetchHeaders(`${config.apiBase}/v1/chat/completions`, {
164
165
  method: 'POST',
165
166
  headers: { 'content-type': 'application/json' },
166
167
  body: JSON.stringify({ model: 'nvidia/nemotron-3.5-lightning', messages: [{ role: 'user', content: 'ping' }], max_tokens: 1 }),
package/lib/fetch.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Time-to-first-byte for hops to x402-tokens.fly.dev / OpenRouter.
3
+ *
4
+ * A hung keep-alive (no headers) must not pin an undici socket forever —
5
+ * that is what wedged GET /v1/session (LISTEN up, HTTP 000) while a
6
+ * brainRace of 4 sat on the same pool. Once headers arrive, the body may
7
+ * stream for as long as generation takes. Do not set fetch `family` here;
8
+ * Happy Eyeballs + ipv4first live in bin/openzoo.js.
9
+ */
10
+ export const HEADERS_MS = Number(process.env.OPENZOO_UPSTREAM_HEADERS_MS || 120_000);
11
+ export const UPSTREAM_HEADERS_MS = HEADERS_MS;
12
+ export const CREDIT_TIMEOUT_MS = Number(process.env.OPENZOO_CREDIT_TIMEOUT_MS || 2_500);
13
+
14
+ export function headersMs() {
15
+ const n = Number(process.env.OPENZOO_UPSTREAM_HEADERS_MS || 120_000);
16
+ return Number.isFinite(n) && n > 0 ? n : 120_000;
17
+ }
18
+
19
+ function mergeSignal(existing, timeout) {
20
+ if (!existing) return timeout;
21
+ if (typeof AbortSignal.any === 'function') return AbortSignal.any([existing, timeout]);
22
+ const c = new AbortController();
23
+ const abort = () => { try { c.abort(); } catch { /* already */ } };
24
+ if (existing.aborted || timeout.aborted) { abort(); return c.signal; }
25
+ existing.addEventListener('abort', abort, { once: true });
26
+ timeout.addEventListener('abort', abort, { once: true });
27
+ return c.signal;
28
+ }
29
+
30
+ /** fetch that aborts if headers have not arrived in `ms`, then lets the stream run. */
31
+ export async function fetchHeaders(url, init = {}, ms = headersMs()) {
32
+ const ac = new AbortController();
33
+ const timer = setTimeout(() => ac.abort(), ms);
34
+ timer.unref?.();
35
+ try {
36
+ const res = await fetch(url, {
37
+ ...init,
38
+ signal: mergeSignal(init.signal, ac.signal),
39
+ });
40
+ clearTimeout(timer);
41
+ return res;
42
+ } catch (err) {
43
+ clearTimeout(timer);
44
+ throw err;
45
+ }
46
+ }
package/lib/grokui.mjs CHANGED
@@ -11,8 +11,8 @@ import { randomUUID } from 'node:crypto';
11
11
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
12
12
  import { cpus, homedir } from 'node:os';
13
13
  import path from 'node:path';
14
- import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES } from './podagent.mjs';
15
- import { peekDirectiveStatus, formatRaceStatus, STALE_THINKING_MS } from './livestatus.js';
14
+ import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES, normalizeTier } from './podagent.mjs';
15
+ import { peekDirectiveStatus, formatRaceStatus, STALE_THINKING_MS, summarizeRaceFailures } from './livestatus.js';
16
16
  import { creditBalance } from './info.js';
17
17
  import {
18
18
  SUBSCRIPTIONS_PAGE,
@@ -404,6 +404,7 @@ actually calls for delegation or file work.`;
404
404
 
405
405
  // id -> { id, name, color, parent, messages: [{role,content}], history: [{who,text}], status }
406
406
  const threads = new Map();
407
+ const turnAborts = new WeakMap();
407
408
 
408
409
  // Threads are the whole point of the app — losing them on every restart (the
409
410
  // server got restarted a lot while iterating this session) is a real bug, not
@@ -632,10 +633,10 @@ function newGroupThread(names) {
632
633
  }
633
634
 
634
635
  // Real leCore binding — POST /v1/hrr/bind on the local proxy, same free
635
- // passthrough the wiki documents. Fire-and-forget after each turn: the next
636
- // turn's brain()/brainStream() call picks up t.contextId once it lands, via
637
- // the X-HRR-Context header, so retrieval is real and automatic, not a prompt
638
- // claim about a mechanism that doesn't exist.
636
+ // passthrough the wiki documents. Fire-and-forget after each turn so the
637
+ // project corpus stays bound. Completions must NOT send X-HRR-Context:
638
+ // sidecar maybeCacheCorpus skips spill when that header is set, which is
639
+ // the path `npx openzoo claude` uses (bind-prefix / send-3/131-turns).
639
640
  // Chunked, not one shot: a single request over ~8MB (post JSON-escaping)
640
641
  // gets rejected, so a large/growing thread's bind would silently fail past
641
642
  // whatever point it crossed that line — confirmed live by a bot's own RUN
@@ -950,7 +951,7 @@ const SLASH_COMMANDS = [
950
951
  { name: '/tokens', args: '', help: 'tokens and calls this session' },
951
952
  { name: '/model', args: '[id]', help: 'show or switch this thread’s model' },
952
953
  { name: '/models', args: '[filter]', help: 'search the ~435 served models' },
953
- { name: '/tier', args: 'cheap|medium|expensive', help: 'how much to spend per turn when no model is pinned' },
954
+ { name: '/tier', args: 'cheap|medium|expensive|grok 4.6', help: 'how much to spend per turn when no model is pinned' },
954
955
  { name: '/race', args: '<n> | <k> <n>', help: 'launch n models; judge the first k back (k=1 = fastest wins)' },
955
956
  { name: '/compact', args: '', help: 'summarise history to shrink context' },
956
957
  { name: '/clear', args: '', help: 'wipe this thread’s history' },
@@ -1005,9 +1006,10 @@ function condense(label, text) {
1005
1006
  if (s.length <= FEEDBACK_MAX) return `${label}\n${s}`;
1006
1007
  const head = s.slice(0, Math.floor(FEEDBACK_MAX * 0.7));
1007
1008
  const tail = s.slice(-Math.floor(FEEDBACK_MAX * 0.3));
1008
- // The wording matters. Retrieval is AUTOMATIC — the thread's context id
1009
- // rides on every call as x-hrr-context and leCore injects whatever slice is
1010
- // relevant to what you say next. Telling the model to "ask for it" invites
1009
+ // The wording matters. Retrieval is AUTOMATIC — sidecar spill binds the
1010
+ // transcript the same way Claude CLI does (maybeCacheCorpus), and leCore
1011
+ // injects whatever slice is relevant to what you say next. Telling the
1012
+ // model to "ask for it" invites
1011
1013
  // it to invent a RECALL directive that does not exist, which is the exact
1012
1014
  // failure mode this whole harness keeps hitting: a model fabricating a
1013
1015
  // mechanism instead of using the real one.
@@ -1027,7 +1029,7 @@ function emitToThread(threadId, ev) {
1027
1029
  }
1028
1030
 
1029
1031
  async function sessionStats() {
1030
- try { return await (await fetch(`${PROXY}/session`)).json(); }
1032
+ try { return await (await fetch(`${PROXY}/session`, { signal: AbortSignal.timeout(2000) })).json(); }
1031
1033
  catch { return null; }
1032
1034
  }
1033
1035
 
@@ -1108,8 +1110,8 @@ async function handleSlash(task, t) {
1108
1110
  + (t.model ? `NOTE: /model ${t.model} is pinned on this thread, so the tier is ignored until you /model default.\n` : '')
1109
1111
  + 'Switch with /tier <name> · /race <n> to ask several at once.';
1110
1112
  }
1111
- const want = arg.trim().toLowerCase();
1112
- if (!TIER_NAMES.includes(want)) return `Unknown tier "${arg}". One of: ${TIER_NAMES.join(', ')}.`;
1113
+ const want = normalizeTier(arg);
1114
+ if (!want) return `Unknown tier "${arg}". One of: ${TIER_NAMES.join(', ')} (also: grok 4.6).`;
1113
1115
  t.tier = want; saveThreads();
1114
1116
  const picks = await tierModels(want, 3);
1115
1117
  return `This thread now runs on the ${want} tier — ${picks.join(', ')}…`
@@ -1328,6 +1330,7 @@ setInterval(() => {
1328
1330
  const last = t.lastDeltaAt || t.thinkingAt || 0;
1329
1331
  if (!last || now - last < STALE_THINKING_MS) continue;
1330
1332
  t.turnSeq = (t.turnSeq || 0) + 1;
1333
+ try { turnAborts.get(t)?.abort(); } catch { /* none */ }
1331
1334
  t.status = 'idle';
1332
1335
  t.liveStatus = '';
1333
1336
  dirty = true;
@@ -2318,7 +2321,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2318
2321
  const stillMine = () => threads.get(threadId)?.turnSeq === seq;
2319
2322
  const paint = (ev) => {
2320
2323
  if (!stillMine()) return;
2321
- if (ev.type === 'status' && ev.detail) t.liveStatus = ev.detail;
2324
+ if (ev.type === 'status' && ev.detail && t.status === 'thinking') t.liveStatus = ev.detail;
2322
2325
  if (ev.type === 'delta' || ev.type === 'status' || ev.type === 'start') t.lastDeltaAt = Date.now();
2323
2326
  onEvent?.(ev);
2324
2327
  };
@@ -2327,6 +2330,9 @@ async function runTurn(threadId, userText, onEvent, images) {
2327
2330
  t.status = 'thinking';
2328
2331
  t.thinkingAt = Date.now();
2329
2332
  t.lastDeltaAt = Date.now();
2333
+ try { turnAborts.get(t)?.abort(); } catch { /* none */ }
2334
+ const turnAbort = new AbortController();
2335
+ turnAborts.set(t, turnAbort);
2330
2336
  const raceN = Math.min(Number(t.race) || 0, 4);
2331
2337
  const raceNeed = Math.min(Math.max(Number(t.raceNeed) || 1, 1), raceN || 1);
2332
2338
  t.liveStatus = (!t.model && raceN >= 2) ? formatRaceStatus(0, raceNeed) : 'waiting on model…';
@@ -2433,7 +2439,10 @@ async function runTurn(threadId, userText, onEvent, images) {
2433
2439
  // setting the latency. Collection is first-X-back (non-empty);
2434
2440
  // classify runs only on those X.
2435
2441
  const need = Math.min(Math.max(Number(t.raceNeed) || 1, 1), race);
2436
- return (await brainRace(callMsgs, emit, t.contextId, models, need, undefined, emitStatus)).trim();
2442
+ return (await brainRace(callMsgs, emit, t.contextId, models, need, undefined, emitStatus, {
2443
+ signal: turnAbort.signal,
2444
+ onArrivals: (arr) => { t.lastRaceFail = summarizeRaceFailures(arr); },
2445
+ })).trim();
2437
2446
  }
2438
2447
  // A retry draws a DIFFERENT model from the tier rather than the same one.
2439
2448
  const model = t.model || (await tierModels(t.tier || 'medium', attempt + 1, attempt > 0))[attempt] || undefined;
@@ -3212,6 +3221,7 @@ const APP_HTML = `<!doctype html>
3212
3221
  <option value="cheap">cheap</option>
3213
3222
  <option value="medium" selected>medium</option>
3214
3223
  <option value="expensive">expensive</option>
3224
+ <option value="grok4.6">grok 4.6</option>
3215
3225
  </select>
3216
3226
  <select class="dial" id="raceSel" data-component="model-race" aria-label="Race models"
3217
3227
  title="Ask N models from the tier at once, drawn at random — fastest real answer wins. You pay for every entrant.">
@@ -3622,7 +3632,7 @@ const APP_HTML = `<!doctype html>
3622
3632
  : 'Ask N models from the tier at once, drawn at random — fastest real answer wins. You pay for every entrant.';
3623
3633
  }
3624
3634
  if (!pinned && (t.tier === 'expensive' || (t.race || 0) >= 2)) {
3625
- if (t.tier === 'expensive') tierSel.className = 'dial hot';
3635
+ if (t.tier === 'expensive' || t.tier === 'grok4.6') tierSel.className = 'dial hot';
3626
3636
  if ((t.race || 0) >= 2) raceSel.className = 'dial hot';
3627
3637
  }
3628
3638
  }
@@ -5066,7 +5076,7 @@ const server = http.createServer((req, res) => {
5066
5076
  if (req.method === 'GET' && req.url === '/hud-summary') {
5067
5077
  (async () => {
5068
5078
  let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null, chainUsd: null };
5069
- try { you = { ...you, ...(await (await fetch('http://127.0.0.1:8402/v1/session')).json()) }; }
5079
+ try { you = { ...you, ...(await (await fetch('http://127.0.0.1:8402/v1/session', { signal: AbortSignal.timeout(2000) })).json()) }; }
5070
5080
  catch { /* local proxy not running — HUD shows zeros rather than guessing */ }
5071
5081
  try {
5072
5082
  you.creditUsd = await creditBalance();
@@ -5154,7 +5164,8 @@ const server = http.createServer((req, res) => {
5154
5164
  res.writeHead(200, { 'content-type': 'application/json' });
5155
5165
  res.end(t ? JSON.stringify({
5156
5166
  id: t.id, history: t.history, status: t.status,
5157
- liveStatus: t.liveStatus || '',
5167
+ liveStatus: t.status === 'thinking' ? (t.liveStatus || '') : '',
5168
+ lastRaceFail: t.lastRaceFail || null,
5158
5169
  workspacePort: workspacePort || 0, dir: t.dir || WORKSPACE_DIR,
5159
5170
  }) : '{}');
5160
5171
  return;
package/lib/info.js CHANGED
@@ -4,6 +4,7 @@ import { config, FUNDING_ASSETS, EVM_FUNDING_ASSETS, evmRpcFor, fundingLine } fr
4
4
  import { loadOrCreateWallet } from './wallet.js';
5
5
  import { tokenBalance } from './x402.js';
6
6
  import { evmTokenBalance, evmNativeBalance } from './evm.js';
7
+ import { CREDIT_TIMEOUT_MS, fetchHeaders } from './fetch.js';
7
8
 
8
9
  export function printAddress() {
9
10
  const { keypair, evmPrivateKey, created, path } = loadOrCreateWallet();
@@ -51,7 +52,7 @@ export async function quotedPrices() {
51
52
  //
52
53
  // The chat challenge prices each asset at its real spot (DexScreener), and
53
54
  // needs no namespace signature, so it is both correct and simpler.
54
- const r = await fetch(`${config.apiBase}/v1/chat/completions`, {
55
+ const r = await fetchHeaders(`${config.apiBase}/v1/chat/completions`, {
55
56
  method: 'POST',
56
57
  headers: { 'content-type': 'application/json' },
57
58
  body: JSON.stringify({ model: config.defaultModel || 'anthropic/claude-sonnet-5', max_tokens: 1, messages: [{ role: 'user', content: 'x' }] }),
@@ -247,10 +248,15 @@ export async function topUp(usdArg) {
247
248
  export { priceHoldings, formatHoldingMoney } from './livestatus.js';
248
249
 
249
250
  /** Current prepaid credit for this wallet's namespace. */
251
+ export { CREDIT_TIMEOUT_MS };
252
+
250
253
  export async function creditBalance() {
251
254
  const { withNamespace } = await import('./namespace.js');
252
255
  try {
253
- const r = await fetch(`${config.apiBase}/v1/credits`, { headers: withNamespace({}) });
256
+ const r = await fetch(`${config.apiBase}/v1/credits`, {
257
+ headers: withNamespace({}),
258
+ signal: AbortSignal.timeout(CREDIT_TIMEOUT_MS),
259
+ });
254
260
  const j = await r.json();
255
261
  return Number(j.balanceUsd) || 0;
256
262
  } catch {
package/lib/livestatus.js CHANGED
@@ -6,8 +6,8 @@
6
6
  * tool) and abort a reader that has gone silent.
7
7
  */
8
8
 
9
- export const STREAM_IDLE_MS = Number(process.env.OZ_STREAM_IDLE_MS || 55_000);
10
- export const STALE_THINKING_MS = Number(process.env.OZ_STALE_THINKING_MS || 90_000);
9
+ export const STREAM_IDLE_MS = Number(process.env.OZ_STREAM_IDLE_MS || 180_000);
10
+ export const STALE_THINKING_MS = Number(process.env.OZ_STALE_THINKING_MS || 240_000);
11
11
  export const MODEL_WAIT_TICK_MS = 1000;
12
12
  export const MODEL_WAIT_SECONDS_AFTER_MS = 2000;
13
13
 
@@ -30,8 +30,8 @@ export function formatPayStatus(attempt = 0) {
30
30
 
31
31
  /** First-X-back race: how many of the K we asked for have actually landed. */
32
32
  export function formatRaceStatus(back, need) {
33
- const b = Math.max(0, Number(back) || 0);
34
33
  const n = Math.max(1, Number(need) || 1);
34
+ const b = Math.min(n, Math.max(0, Number(back) || 0));
35
35
  return `racing ${b}/${n} back…`;
36
36
  }
37
37
 
@@ -72,6 +72,44 @@ export function raceLastShip(arrivals) {
72
72
  return { model: '', text: RACE_EVERY_FAILED, error: true };
73
73
  }
74
74
 
75
+ /** Classify one failed arrival without naming a model. */
76
+ export function raceFailKind(arrival) {
77
+ const err = String(arrival?.error || '');
78
+ const text = String(arrival?.text || '').trim();
79
+ const s = `${err} ${text}`.trim();
80
+ if (!s) return 'empty body';
81
+ if (/timeout|STREAM_IDLE|aborted|AbortError/i.test(s)) return 'timeout';
82
+ if (/402|payment failed/i.test(s)) return 'pay';
83
+ if (/fetch failed/i.test(s)) return 'fetch failed';
84
+ const http = /HTTP\s+(\d{3})/i.exec(s);
85
+ if (http) return `HTTP ${http[1]}`;
86
+ if (err) return 'error';
87
+ if (!isRaceCountable(arrival)) return 'empty body';
88
+ return 'ok';
89
+ }
90
+
91
+ /** Counts of failure kinds across a race. Used for lastRaceFail on GET. */
92
+ export function summarizeRaceFailures(arrivals) {
93
+ const counts = {};
94
+ for (const a of Array.isArray(arrivals) ? arrivals : []) {
95
+ const k = raceFailKind(a);
96
+ if (k === 'ok') continue;
97
+ counts[k] = (counts[k] || 0) + 1;
98
+ }
99
+ return counts;
100
+ }
101
+
102
+ /**
103
+ * Transient racer deaths — retry the same slot once.
104
+ * Pay/402 will not get better. A real answer is done.
105
+ */
106
+ export function shouldRetryRaceArrival(arrival) {
107
+ if (isRaceCountable(arrival)) return false;
108
+ const k = raceFailKind(arrival);
109
+ return k === 'fetch failed' || k === 'timeout' || k === 'empty body'
110
+ || k === 'error' || /^HTTP 5/.test(k) || k === 'HTTP 000';
111
+ }
112
+
75
113
  /** Default bar a classified race answer must clear (0–10). Overridable. */
76
114
  export const RACE_MIN_SCORE = Number(process.env.OZ_RACE_MIN_SCORE || 6);
77
115
 
@@ -149,6 +187,9 @@ export function createRaceFeed(onDelta, onStatus, need) {
149
187
  }
150
188
  },
151
189
  onBack() {
190
+ // Late countable stragglers after ship used to paint "racing 4/2 back…"
191
+ // onto an already-idle thread (GET /threads/:id returns raw liveStatus).
192
+ if (settled || back >= need) return;
152
193
  back += 1;
153
194
  paintStatus();
154
195
  },
package/lib/models.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { config } from './config.js';
2
+ import { fetchHeaders } from './fetch.js';
2
3
 
3
4
  /** Same threshold as BIND_MIN_CHARS in hrr.js — kept local so this
4
5
  * module stays importable without the wallet/rpc stack. */
@@ -53,7 +54,7 @@ let cache = { at: 0, ids: null };
53
54
 
54
55
  export async function zooModelIds() {
55
56
  if (cache.ids && Date.now() - cache.at < CATALOG_TTL_MS) return cache.ids;
56
- const r = await fetch(`${config.apiBase}/v1/models`);
57
+ const r = await fetchHeaders(`${config.apiBase}/v1/models`);
57
58
  if (!r.ok) throw new Error(`model catalog fetch failed: HTTP ${r.status}`);
58
59
  const d = await r.json();
59
60
  const ids = (d.data || []).map((m) => m.id).filter(Boolean);
package/lib/pay.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  resolvePool, poolState, depositForShares, buildWrapInstructions, sendWrap,
15
15
  } from './wrap.js';
16
16
  import { applySubscriptionHeaders, loadSubscription, stripAuthorization } from './subscription.js';
17
+ import { fetchHeaders } from './fetch.js';
17
18
 
18
19
  export class QuoteTooHighError extends Error {
19
20
  constructor(billedUsd, quote) {
@@ -339,7 +340,7 @@ export class PayClient {
339
340
  // there is no key, or if the gateway still answers 402.
340
341
  const sub = loadSubscription();
341
342
  if (sub?.key) init = { ...init, headers: applySubscriptionHeaders(init.headers, sub) };
342
- const first = await fetch(url, init);
343
+ const first = await fetchHeaders(url, init);
343
344
  if (first.status !== 402) {
344
345
  return { response: first, paid: false, subscription: Boolean(sub?.key && first.ok) };
345
346
  }
@@ -400,7 +401,7 @@ export class PayClient {
400
401
  });
401
402
  }
402
403
  onStage?.('paying');
403
- const response = await fetch(url, {
404
+ const response = await fetchHeaders(url, {
404
405
  ...init,
405
406
  headers: { ...stripAuthorization(init.headers || {}), 'X-PAYMENT': payment.header },
406
407
  });
package/lib/podagent.mjs CHANGED
@@ -25,8 +25,10 @@ import { randomUUID } from 'node:crypto';
25
25
  import {
26
26
  formatPayStatus, startModelWait, readWithIdleTimeout, STREAM_IDLE_MS,
27
27
  createRaceFeed, pickRaceWinner, parseClassifyScore, RACE_MIN_SCORE,
28
- isRaceCountable, raceLastShip,
28
+ isRaceCountable, raceLastShip, shouldRetryRaceArrival, raceFailKind,
29
+ summarizeRaceFailures,
29
30
  } from './livestatus.js';
31
+ import { homedir } from 'node:os';
30
32
 
31
33
  const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
32
34
  .split(',').map((s) => Number(s.trim())).filter(Boolean);
@@ -278,9 +280,15 @@ export function adaptiveTopK(boundItems) {
278
280
  return Math.max(16, Math.min(256, Math.ceil(Math.sqrt(n) * 2)));
279
281
  }
280
282
 
281
- async function postChat(body, contextId, topK, onStatus) {
283
+ async function postChat(body, contextId, topK, onStatus, signal) {
284
+ void contextId; // spill path: never send as x-hrr-context
282
285
  let r;
283
286
  for (let attempt = 0; attempt <= PAYMENT_RETRIES; attempt++) {
287
+ if (signal?.aborted) {
288
+ const err = new Error(signal.reason?.message || 'aborted');
289
+ err.name = 'AbortError';
290
+ throw err;
291
+ }
284
292
  r = await fetch(`${PROXY}/chat/completions`, {
285
293
  method: 'POST',
286
294
  headers: {
@@ -288,12 +296,16 @@ async function postChat(body, contextId, topK, onStatus) {
288
296
  // Only sent when we actually know the corpus size; without it the
289
297
  // gateway keeps its own default rather than getting a made-up number.
290
298
  ...(topK ? { 'x-hrr-top-k': String(topK) } : {}),
291
- // real leCore memory for this thread, bound via POST /v1/hrr/bind — NOT
292
- // a fabricated mechanism. Retrieval runs automatically once this header
293
- // is set; nothing more for the model to invent or explain.
294
- ...(contextId ? { 'x-hrr-context': contextId } : {}),
299
+ // Do NOT attach x-hrr-context. proxy.js maybeCacheCorpus bails
300
+ // (`if (req.headers['x-hrr-context']) return null`) so Claude CLI's
301
+ // bind-prefix / send-3/131-turns never ran for grokui. Tetris then
302
+ // shipped ~850k chars × race of 4 and every model failed in ~22s.
303
+ // Completions must hit the same sidecar spill path as `npx openzoo claude`.
304
+ // bindThread stays for other things; contextId is kept on the signature
305
+ // so brain / brainStream callers do not change.
295
306
  },
296
307
  body: JSON.stringify(body),
308
+ ...(signal ? { signal } : {}),
297
309
  });
298
310
  if (r.status !== 402 || attempt === PAYMENT_RETRIES) return r;
299
311
  // A 402 retry used to be silent — grokui sat on mute "…" for the whole
@@ -319,7 +331,7 @@ function withModelId(messages, model) {
319
331
  : m));
320
332
  }
321
333
 
322
- export async function brain(messages, contextId, modelOverride, topK) {
334
+ export async function brain(messages, contextId, modelOverride, topK, signal) {
323
335
  // explicit plugins, not relying on the gateway's "inject when caller said
324
336
  // nothing" default — an explicit array is always respected as-is, so every
325
337
  // bot on every model actually has web search. max_tokens 900 was cutting
@@ -334,7 +346,7 @@ export async function brain(messages, contextId, modelOverride, topK) {
334
346
  messages = vision ? messages : stripImages(messages);
335
347
  const r = await postChat(
336
348
  { model, max_tokens: 4096, messages: withModelId(messages, model), plugins: [{ id: 'web' }] },
337
- contextId, topK, undefined,
349
+ contextId, topK, undefined, signal,
338
350
  );
339
351
  const j = await r.json().catch(() => ({}));
340
352
  const content = j?.choices?.[0]?.message?.content;
@@ -375,19 +387,21 @@ async function brainContinue(messages, sofar, contextId, modelOverride, round) {
375
387
  * callers that need to parse a directive out of the complete reply still can.
376
388
  * onStatus(detail) is an optional second channel: paying / waiting on model /
377
389
  * thinking, so a 20–40s settle is visibly alive instead of mute dots. */
378
- export async function brainStream(messages, onDelta, contextId, modelOverride, maxTokens, round = 0, topK = 0, onStatus) {
390
+ export async function brainStream(messages, onDelta, contextId, modelOverride, maxTokens, round = 0, topK = 0, onStatus, signal) {
379
391
  const vision = hasImages(messages);
380
392
  const model = vision ? VISION_MODEL : (modelOverride || MODEL);
381
393
  messages = vision ? messages : stripImages(messages);
382
394
  const budget = maxTokens || MAX_TOKENS;
383
395
  const r = await postChat(
384
396
  { model, max_tokens: budget, messages: withModelId(messages, model), plugins: [{ id: 'web' }], stream: true },
385
- contextId, topK, onStatus,
397
+ contextId, topK, onStatus, signal,
386
398
  );
387
399
  if (!r.ok || !r.body) {
388
400
  // fall back to the non-streaming path rather than fail outright
389
- const content = await r.json().then((j) => j?.choices?.[0]?.message?.content).catch(() => undefined);
390
- const text = content || (r.ok ? '' : await httpErrorNote(r.status));
401
+ const j = await r.json().catch(() => ({}));
402
+ const content = j?.choices?.[0]?.message?.content;
403
+ const proxied = j?.error?.message;
404
+ const text = content || (r.ok ? '' : (proxied ? `(request failed — HTTP ${r.status}: ${proxied})` : await httpErrorNote(r.status)));
391
405
  if (text) onDelta(text);
392
406
  return text;
393
407
  }
@@ -417,7 +431,7 @@ export async function brainStream(messages, onDelta, contextId, modelOverride, m
417
431
  return full + note;
418
432
  }
419
433
  onStatus?.('waiting on model…');
420
- const fallback = await brain(messages, contextId, modelOverride, topK);
434
+ const fallback = await brain(messages, contextId, modelOverride, topK, signal);
421
435
  if (fallback) onDelta(fallback);
422
436
  return fallback || '(stream timed out — no tokens arrived)';
423
437
  }
@@ -465,7 +479,7 @@ export async function brainStream(messages, onDelta, contextId, modelOverride, m
465
479
  // reasoning model thinks the most. Retry ONCE with a bigger budget.
466
480
  if (!full && reasonedChars > 0 && !maxTokens) {
467
481
  onStatus?.('retrying…');
468
- return brainStream(messages, onDelta, contextId, modelOverride, budget * 4, round, topK, onStatus);
482
+ return brainStream(messages, onDelta, contextId, modelOverride, budget * 4, round, topK, onStatus, signal);
469
483
  }
470
484
 
471
485
  // CUT OFF MID-ANSWER. finish_reason "length" means the model had more to say
@@ -564,8 +578,32 @@ const TIERS = {
564
578
  'qwen/qwen3.8-max', // 18
565
579
  'x-ai/grok-4.5', // 18
566
580
  ],
581
+ // Dedicated grok 4.6 band (tweeted as its own dial next to cheap/medium/expensive).
582
+ // One slug cannot fill a 4-wide race without replacement, so the pool is grok
583
+ // chat models with 4.6 first. No imagine / stt / tts / video.
584
+ 'grok4.6': [
585
+ 'x-ai/grok-4.6',
586
+ 'x-ai/grok-4.5',
587
+ 'x-ai/grok-4.3',
588
+ 'x-ai/grok-4.20',
589
+ ],
567
590
  };
568
591
  export const TIER_NAMES = Object.keys(TIERS);
592
+ export const TIER_ALIASES = {
593
+ grok: 'grok4.6',
594
+ 'grok 4.6': 'grok4.6',
595
+ 'grok-4.6': 'grok4.6',
596
+ 'grok4.6': 'grok4.6',
597
+ };
598
+ export function normalizeTier(s) {
599
+ const raw = String(s || '').trim().toLowerCase();
600
+ if (TIER_NAMES.includes(raw)) return raw;
601
+ if (TIER_ALIASES[raw]) return TIER_ALIASES[raw];
602
+ const compact = raw.replace(/[\s_]/g, '');
603
+ if (TIER_NAMES.includes(compact)) return compact;
604
+ if (TIER_ALIASES[compact]) return TIER_ALIASES[compact];
605
+ return null;
606
+ }
569
607
 
570
608
  let catalogCache = { at: 0, ids: null };
571
609
  async function servedIds() {
@@ -667,40 +705,68 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
667
705
  let finished = 0;
668
706
  let release;
669
707
  const enough = new Promise((r) => { release = r; });
708
+ const raceAbort = new AbortController();
709
+ if (hooks.signal) {
710
+ if (hooks.signal.aborted) raceAbort.abort(hooks.signal.reason);
711
+ else hooks.signal.addEventListener('abort', () => raceAbort.abort(), { once: true });
712
+ }
713
+
714
+ const noteRace = (arr) => {
715
+ try {
716
+ hooks.onArrivals?.(arr);
717
+ const line = JSON.stringify({
718
+ at: new Date().toISOString(),
719
+ fail: summarizeRaceFailures(arr),
720
+ n: arr.length,
721
+ kinds: arr.map((a) => raceFailKind(a)),
722
+ });
723
+ appendFileSync(`${homedir()}/.openzoo/grokui-race.log`, line + '\n');
724
+ } catch { /* diagnostic only */ }
725
+ };
670
726
 
671
727
  const ship = (cand) => {
672
728
  const out = cand && String(cand.text || '').trim() ? cand : raceLastShip(arrivals);
673
729
  feed.settle(out);
730
+ noteRace(arrivals);
731
+ try { raceAbort.abort(); } catch { /* already */ }
674
732
  return out.text;
675
733
  };
676
734
 
677
735
  // Do not pass onStatus into each entrant — their "waiting on model…" would
678
736
  // clobber the race line. Race owns the status until a winner ships.
679
- const attempts = list.map((m) => stream(messages, (chunk) => feed.onToken(m, chunk), contextId, m, maxTokens)
680
- .then((text) => {
681
- const raw = text == null ? '' : String(text);
682
- const arrival = { model: m, text: raw };
683
- arrivals.push(arrival);
684
- if (isRaceCountable(arrival)) {
685
- done.push(arrival);
686
- feed.onBack();
687
- } else {
688
- feed.onFail(m);
737
+ // fetch-failed / empty / 5xx are retried ONCE on the same slot — tetris was
738
+ // shipping every-model-failed after a single ~22s fetch failed per racer.
739
+ const runOne = async (m) => {
740
+ let last = { model: m, text: '', error: 'empty body' };
741
+ for (let attempt = 0; attempt < 2; attempt++) {
742
+ if (raceAbort.signal.aborted && attempt > 0) break;
743
+ try {
744
+ const text = await stream(messages, (chunk) => feed.onToken(m, chunk), contextId, m, maxTokens, 0, 0, undefined, raceAbort.signal);
745
+ last = { model: m, text: text == null ? '' : String(text) };
746
+ if (isRaceCountable(last)) {
747
+ arrivals.push(last);
748
+ done.push(last);
749
+ feed.onBack();
750
+ return;
751
+ }
752
+ } catch (e) {
753
+ last = { model: m, text: '', error: e?.message || 'error' };
689
754
  }
690
- })
691
- .catch((e) => {
692
- arrivals.push({ model: m, text: '', error: e?.message || 'error' });
693
- feed.onFail(m);
694
- })
695
- .finally(() => {
696
- finished += 1;
697
- // Either we have what we asked for, or everyone is done and no more is
698
- // coming without the second condition a race where two of three fail
699
- // would hang forever waiting for a K that can never arrive.
700
- if (done.length >= want || finished === list.length) release();
701
- }));
702
- // Losers keep running; swallow their rejections so one cannot take the
703
- // process down after the winner has already been returned.
755
+ if (!shouldRetryRaceArrival(last) || attempt === 1 || raceAbort.signal.aborted) break;
756
+ }
757
+ arrivals.push(last);
758
+ feed.onFail(m);
759
+ };
760
+
761
+ const attempts = list.map((m) => runOne(m).finally(() => {
762
+ finished += 1;
763
+ // Either we have what we asked for, or everyone is done and no more is
764
+ // coming without the second condition a race where two of three fail
765
+ // would hang forever waiting for a K that can never arrive.
766
+ if (done.length >= want || finished === list.length) release();
767
+ }));
768
+ // Losers keep running until ship() aborts them; swallow rejections so one
769
+ // cannot take the process down after the winner has already been returned.
704
770
  for (const p of attempts) p.catch(() => {});
705
771
 
706
772
  await enough;
package/lib/proxy.js CHANGED
@@ -360,7 +360,7 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
360
360
  if (!msgs?.length) return null;
361
361
 
362
362
  // PATHS FIRST, BYTES LATER. The cut/length gates below used to run before
363
- // filesForCorpus, so a short agent turn that Read a file never bound it �
363
+ // filesForCorpus, so a short agent turn that Read a file never bound it �
364
364
  // and when the extract itself returned empty, nothing logged. Collect
365
365
  // unconditionally, but only paths + cheap stat/mtime: a 2MB Read must not
366
366
  // stall this turn. readdir + readFile + bindCorpus run after we return,
@@ -486,14 +486,14 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
486
486
  //
487
487
  // FILES RIDE THE BACKGROUND, NEVER THE CRITICAL PATH.
488
488
  //
489
- // The FIRST bind of a session is necessarily synchronous � the request cannot
489
+ // The FIRST bind of a session is necessarily synchronous � the request cannot
490
490
  // go until the context_id exists, because it travels as x-hrr-context. Folding
491
491
  // file bytes into that bind put a 400KB upload in front of the caller's turn,
492
492
  // which is the cold-bind stall this whole exercise was meant to remove: the
493
493
  // bind endpoint measures 0.34-0.48s on a 613KB corpus, and that is 0.34-0.48s
494
494
  // the user waits before a single token appears.
495
495
  //
496
- // Nothing recalls a file during the turn that read it � the model already has
496
+ // Nothing recalls a file during the turn that read it � the model already has
497
497
  // the tool result in its window. Files are only worth having bound for the
498
498
  // NEXT ask. So the conversation binds inline and the files are appended after
499
499
  // the fact, off the clock.
@@ -503,15 +503,15 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
503
503
 
504
504
  // FILES BIND EVEN WHEN THE CONVERSATION IS TOO SMALL TO SPILL.
505
505
  //
506
- // The threshold exists to stop us binding a two-line chat � it was never
506
+ // The threshold exists to stop us binding a two-line chat � it was never
507
507
  // meant to gate FILES. But bailing here skipped them entirely, so a fresh
508
508
  // session that reads a 200KB file bound nothing and scored 1.00x forever:
509
509
  // OBSERVED on a live session that read files all turn and never produced a
510
510
  // corpus, because its conversation stayed under the threshold the whole time.
511
511
  //
512
512
  // A file is worth binding on its own merit. So when the turns are too small
513
- // to spill but files exist, bind the files anyway � in the background,
514
- // against this session's context � and let this turn go unspilled. The corpus
513
+ // to spill but files exist, bind the files anyway � in the background,
514
+ // against this session's context � and let this turn go unspilled. The corpus
515
515
  // is then waiting for the next ask.
516
516
  if (corpus.length <= BIND_MIN_CHARS) {
517
517
  bindFilesInBackground('conversation under spill threshold, background');
@@ -560,7 +560,7 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
560
560
  // Sidecar came back up: we still know the context_id and the accumulated
561
561
  // char count, but not the prior prefix string, so we cannot slice a delta.
562
562
  // Re-append the current prefix (some overlap is harmless) and keep the
563
- // restored ledger � do not add corpus.length again.
563
+ // restored ledger � do not add corpus.length again.
564
564
  void bindCorpus(corpus, {
565
565
  appendTo: prior.contextId,
566
566
  onStage: (stage, info) => {
@@ -572,7 +572,7 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
572
572
  } else if (prior && typeof prior.corpus === 'string' && corpus.startsWith(prior.corpus) && corpus.length > prior.corpus.length) {
573
573
  const delta = corpus.slice(prior.corpus.length);
574
574
  deltaChars = delta.length;
575
- // FIRE AND FORGET. This delta is history for FUTURE turns � the answer
575
+ // FIRE AND FORGET. This delta is history for FUTURE turns � the answer
576
576
  // being generated right now is served from the tail plus what is already
577
577
  // bound, so waiting on the upload buys nothing and costs the user the
578
578
  // round trip on every single turn. The context id is already known, so
@@ -580,7 +580,7 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
580
580
  //
581
581
  // The FIRST bind is deliberately NOT async: the request must carry
582
582
  // x-hrr-context, and that id does not exist until the bind returns. Firing
583
- // that one off would send the opening turn with no context at all � a
583
+ // that one off would send the opening turn with no context at all � a
584
584
  // silently worse answer traded for a shorter pause, which is the wrong way
585
585
  // round.
586
586
  void bindCorpus(delta, {
@@ -598,7 +598,7 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
598
598
  },
599
599
  });
600
600
  }
601
- // CONVERSATION LEDGER � every successful bind AND append, not only when
601
+ // CONVERSATION LEDGER � every successful bind AND append, not only when
602
602
  // files exist. First bind initializes to the bound corpus size; each append
603
603
  // adds the delta; file bytes ride on top. This is what makes x-hrr-corpus-chars
604
604
  // the accumulated bound corpus instead of this-turn's prefix.
@@ -614,11 +614,11 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
614
614
  // we just secured: this turn is already answerable without them, and the next
615
615
  // ask gets them for free. `boundFiles` already deduped by path:mtime, so this
616
616
  // uploads each version exactly once no matter how often the agent re-reads it.
617
- // Read + readdir are inside setImmediate � they must not run before send().
617
+ // Read + readdir are inside setImmediate � they must not run before send().
618
618
  bindFilesInBackground('background', { appendTo: bind.contextId, asAppend: true });
619
619
  spillMemo.set(anchor, { corpus, contextId: bind.contextId, hash: bind.hash });
620
620
  if (spillMemo.size > 32) spillMemo.delete(spillMemo.keys().next().value);
621
- // Count the tail that is actually forwarded after stub/trim � older
621
+ // Count the tail that is actually forwarded after stub/trim � older
622
622
  // continue-turn rounds after the ask may have been dropped, so
623
623
  // msgs.length - cut would keep lastSend growing with the raw pile.
624
624
  const sent = Math.max(0, stubbed.messages.length - cut);
@@ -787,7 +787,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
787
787
  saveSessionSpend({ spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls });
788
788
  };
789
789
  if (restored.ok && (sessionSpent > 0 || paidCalls > 0)) {
790
- say(`session restored: $${sessionSpent.toFixed(6)} � ${paidCalls} paid call${paidCalls === 1 ? '' : 's'}`);
790
+ say(`session restored: $${sessionSpent.toFixed(6)} � ${paidCalls} paid call${paidCalls === 1 ? '' : 's'}`);
791
791
  }
792
792
  process.on('exit', rememberSpend);
793
793
  const MARKUP = 3; // confirmed constant, see .claude/wiki.md "Margin needs a like-for-like denominator"
@@ -885,7 +885,10 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
885
885
  // whichever surface happens to be asking. Local-only, no auth needed:
886
886
  // it's a number, not a capability.
887
887
  if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/session') {
888
- await refreshCredit();
888
+ // NEVER await fly.dev. Serve last-known; a hung keep-alive on the same
889
+ // undici pool as brainRace used to wedge this handler (LISTEN up, HTTP 000).
890
+ // refreshCredit() is fire-and-forget so HUD "continue" cannot sit on HTTP 000.
891
+ refreshCredit().catch(() => {});
889
892
  refreshPrices();
890
893
  const money = walletMoney();
891
894
  res.writeHead(200, { 'content-type': 'application/json' });
@@ -901,8 +904,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
901
904
  // grokui error path, in particular) can print REAL funding instructions
902
905
  // inline instead of telling the user to go look somewhere else.
903
906
  if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/wallet') {
904
- await refreshCredit();
905
- await refreshPrices();
907
+ refreshCredit().catch(() => {});
908
+ refreshPrices();
906
909
  const money = walletMoney();
907
910
  res.writeHead(200, { 'content-type': 'application/json' });
908
911
  res.end(JSON.stringify({
@@ -1025,8 +1028,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1025
1028
  mcp: `${self.replace(/\/v1$/, '')}/mcp`,
1026
1029
  upstream: config.apiBase,
1027
1030
  payment: subscriptionPublicView().active
1028
- ? 'subscription key � no x402 � wallet/x402 remains the other method'
1029
- : 'x402 per request from the operator\'s local burner wallet � no API key, no account',
1031
+ ? 'subscription key � no x402 � wallet/x402 remains the other method'
1032
+ : 'x402 per request from the operator\'s local burner wallet � no API key, no account',
1030
1033
  auth: viaTunnel
1031
1034
  ? 'this public URL requires the oz_… bearer for paid endpoints; /v1/models and /v1/hrr/bind are free'
1032
1035
  : 'localhost is keyless',
@@ -1247,56 +1250,56 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1247
1250
  // (402 handshake behind a long Grok stream). Pin to a fast
1248
1251
  // non-reasoning catalog id and leave max_tokens alone. Real
1249
1252
  // Grok/DeepSeek chats (max_tokens 2000+ AND a long transcript)
1250
- // still get the 4000 floor � those still go blank without it.
1251
- // 0.48.75 missed grok nubs at max_tokens 128/2000 on a 1�2
1252
- // message body (~3� from the floor, no classifier log).
1253
+ // still get the 4000 floor � those still go blank without it.
1254
+ // 0.48.75 missed grok nubs at max_tokens 128/2000 on a 1�2
1255
+ // message body (~3� from the floor, no classifier log).
1253
1256
  let ids = [];
1254
1257
  try { ids = await zooModelIds(); } catch { /* catalog miss: still skip the floor on a tiny classify */ }
1255
1258
  const policy = rewriteChatModel(parsed, ids, { bodyLen: bodyBuf.length });
1256
1259
  parsed = policy.parsed;
1257
1260
  if (policy.tiny) {
1258
- // `openzoo claude` starts us silent � `log` is a no-op then. say()
1261
+ // `openzoo claude` starts us silent � `log` is a no-op then. say()
1259
1262
  // is the proxy.log channel and never the Claude Code TTY.
1260
1263
  say(`classifier tiny max_tokens=${Number(parsed?.max_tokens)} "${policy.from}" -> ${policy.to} (no reasoning floor)`);
1261
1264
  } else {
1262
1265
  // SAY WHETHER THE OVERRIDE IS ACTUALLY SET, and name it.
1263
1266
  //
1264
1267
  // This used to print "(OPENZOO_DEFAULT_MODEL overrides)" on EVERY
1265
- // rewrite whether or not the variable existed � a hint about a knob,
1268
+ // rewrite whether or not the variable existed � a hint about a knob,
1266
1269
  // phrased as a statement about this request. It cost a real incident:
1267
1270
  // the proxy was restarted from a shell carrying
1268
1271
  // OPENZOO_DEFAULT_MODEL=deepseek/deepseek-v4-pro-0813, so every
1269
1272
  // claude-sonnet-5 ask was served by deepseek, and the log line looked
1270
1273
  // exactly the same as it always had. deepseek matches the reasoning
1271
1274
  // regex, so a 16-token safety classification became a 4,000-token
1272
- // reasoning generation � 11.5s, past the caller's timeout, and Claude
1275
+ // reasoning generation � 11.5s, past the caller's timeout, and Claude
1273
1276
  // Code reported "claude-sonnet-5 is temporarily unavailable".
1274
1277
  // Tiny classify is pinned above and never reaches this path.
1275
1278
  if (policy.to && policy.to !== policy.from) {
1276
1279
  const forced = process.env.OPENZOO_DEFAULT_MODEL;
1277
1280
  log(forced
1278
1281
  ? `model "${policy.from}" -> FORCED to ${forced} by OPENZOO_DEFAULT_MODEL (nearest match would have been ${policy.to})`
1279
- : `model "${policy.from}" is not on the zoo � nearest match ${policy.to}`);
1282
+ : `model "${policy.from}" is not on the zoo � nearest match ${policy.to}`);
1280
1283
  }
1281
1284
  // REASONING MODELS SPEND max_tokens ON THINKING FIRST.
1282
1285
  //
1283
1286
  // The budget covers hidden reasoning AND the visible answer, so a
1284
1287
  // caller that asks for 40 tokens because it wants a short answer often
1285
- // gets ZERO � the whole allowance went to reasoning and the completion
1288
+ // gets ZERO � the whole allowance went to reasoning and the completion
1286
1289
  // truncated to an empty string. Measured across three families in one
1287
1290
  // day: deepseek returned 0 chars at 8k and was fine at 24k; grok-4.6
1288
1291
  // pinned ct at exactly its 16,000 budget with no visible output;
1289
1292
  // sonnet-5 truncated a 600-token file mid-function because Anthropic's
1290
1293
  // max_tokens covers thinking too.
1291
1294
  //
1292
- // An empty completion is not an error � it bills normally and renders
1293
- // as a blank reply � so this fails silently and looks like the retrieval
1295
+ // An empty completion is not an error � it bills normally and renders
1296
+ // as a blank reply � so this fails silently and looks like the retrieval
1294
1297
  // broke. It cost real debugging time tonight for exactly that reason.
1295
1298
  // Multiply the allowance for known reasoning families and let callers
1296
1299
  // keep asking for what they actually want back.
1297
1300
  //
1298
1301
  // A MULTIPLIER ALONE IS NOT ENOUGH. 4x on a caller's 40 is 160, which is
1299
- // still nothing for a model that thinks first � measured, 2 of 3 runs
1302
+ // still nothing for a model that thinks first � measured, 2 of 3 runs
1300
1303
  // still returned empty at 160. Reasoning needs an absolute floor, not a
1301
1304
  // relative bump, so take whichever is larger.
1302
1305
  if (policy.raised) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.97",
3
+ "version": "0.48.99",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",