openzoo 0.48.71 → 0.48.74

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/proxy.js CHANGED
@@ -13,9 +13,10 @@ import { tokenBalance } from './x402.js';
13
13
  import { evmTokenBalance } from './evm.js';
14
14
  import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
15
15
  import {
16
- loadBoundChars, noteCorpusLedger, filesForCorpus, createSpillStats, corpusCharsForSend,
16
+ loadBoundChars, noteCorpusLedger, filesForCorpus, readFilesForCorpus, boundAbsFromKeys,
17
+ createSpillStats, corpusCharsForSend, applySpillCut, msgText,
17
18
  } from './spill.js';
18
- import { maybeRewriteModel, rewritablePath, augmentModelList, ALIAS_IDS } from './models.js';
19
+ import { rewritablePath, augmentModelList, ALIAS_IDS, rewriteChatModel, zooModelIds } from './models.js';
19
20
  import { forgetContext } from './contexts.js';
20
21
  import { injectBrief } from './brief.js';
21
22
  import { withNamespace } from './namespace.js';
@@ -330,26 +331,6 @@ function replayPut(key, data, settle) {
330
331
  * to sending the original body untouched — caching must never break a call.
331
332
  * Returns null (send as-is) or { body, contextId, hash, corpus, reused, savedBytes }.
332
333
  */
333
- /** Flatten one Anthropic content block to text leCore can index. */
334
- function blockText(b) {
335
- if (typeof b === 'string') return b;
336
- if (!b || typeof b !== 'object') return '';
337
- if (b.type === 'text') return b.text || '';
338
- if (b.type === 'tool_use') return `[tool_use ${b.name}] ${JSON.stringify(b.input ?? {})}`;
339
- if (b.type === 'tool_result') {
340
- const c = b.content;
341
- return `[tool_result] ${typeof c === 'string' ? c : (Array.isArray(c) ? c.map(blockText).join('\n') : JSON.stringify(c ?? ''))}`;
342
- }
343
- if (b.type === 'thinking') return ''; // never bind reasoning traces
344
- return '';
345
- }
346
-
347
- function msgText(m) {
348
- const c = m?.content;
349
- const body = typeof c === 'string' ? c : (Array.isArray(c) ? c.map(blockText).filter(Boolean).join('\n') : '');
350
- return body ? `${(m.role || '?').toUpperCase()}: ${body}` : '';
351
- }
352
-
353
334
  /**
354
335
  * Spill the OLD prefix of a long TRANSCRIPT into leCore.
355
336
  *
@@ -374,11 +355,18 @@ async function spillTranscript(body, log, req, stats) {
374
355
  const msgs = Array.isArray(body?.messages) ? body.messages : null;
375
356
  if (!msgs?.length) return null;
376
357
 
377
- // FILES FIRST. The cut/length gates below used to run before filesForCorpus,
378
- // so a short agent turn that Read a file never bound it � and when the
379
- // extract itself returned empty, nothing logged. Extract + log unconditionally.
380
- const fileResult = filesForCorpus(msgs, { boundFiles, log });
381
- const files = fileResult.text;
358
+ // PATHS FIRST, BYTES LATER. The cut/length gates below used to run before
359
+ // filesForCorpus, so a short agent turn that Read a file never bound it �
360
+ // and when the extract itself returned empty, nothing logged. Collect
361
+ // unconditionally, but only paths + cheap stat/mtime: a 2MB Read must not
362
+ // stall this turn. readdir + readFile + bindCorpus run after we return,
363
+ // via setImmediate, so the chat request goes first.
364
+ //
365
+ // Snapshot bound paths BEFORE collect so this turn's first-read files stay
366
+ // verbatim in the tail (not yet in the corpus for recall). Previously
367
+ // bound files get their tool_result bodies stubbed at return time.
368
+ const previouslyBoundAbs = boundAbsFromKeys(boundFiles);
369
+ const fileCollect = filesForCorpus(msgs, { boundFiles });
382
370
  const sessionId = req?.headers?.['x-claude-code-session-id']
383
371
  || req?.headers?.['x-session-id']
384
372
  || req?.headers?.['x-claude-session-id']
@@ -386,31 +374,46 @@ async function spillTranscript(body, log, req, stats) {
386
374
  let sessionKey = sessionId ? `sid:${sessionId}` : null;
387
375
 
388
376
  const ledgerOpts = () => ({ sessionKey, sessions: sessionLedger, boundFiles });
389
- const bindFilesInBackground = (label) => {
390
- if (!files) return;
377
+ const bindFilesInBackground = (label, { appendTo: forcedAppend, asAppend = false } = {}) => {
378
+ if (!fileCollect.pending.length) return;
391
379
  const known = (sessionKey && spillMemo.get(sessionKey))
392
380
  || (sessionKey && sessionLedger.get(sessionKey))
393
381
  || null;
394
- const appendTo = known?.contextId || null;
395
- void bindCorpus(files, {
396
- appendTo,
397
- onStage: (stage, info) => {
398
- if (stage === 'binding') log(`binding ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES (${label})`);
399
- },
400
- }).then((b) => {
401
- if (!b?.contextId) return;
402
- noteCorpusLedger(boundChars, {
403
- contextId: b.contextId,
404
- reused: Boolean(appendTo),
405
- corpusChars: 0,
406
- fileChars: files.length,
407
- ...ledgerOpts(),
408
- });
409
- stats?.noteFileBind(fileResult.files, fileResult.bytes);
410
- if (sessionKey && !spillMemo.has(sessionKey)) {
411
- spillMemo.set(sessionKey, { corpus: '', contextId: b.contextId, hash: b.hash });
382
+ const appendTo = forcedAppend !== undefined ? forcedAppend : (known?.contextId || null);
383
+ setImmediate(() => {
384
+ let read;
385
+ try {
386
+ read = readFilesForCorpus(fileCollect, { boundFiles, log });
387
+ } catch (e) {
388
+ log(`${asAppend ? 'file append failed (corpus lags one turn)' : 'file bind failed'}: ${e.message}`);
389
+ return;
412
390
  }
413
- }).catch((e) => log(`file bind failed: ${e.message}`));
391
+ if (!read.text) return;
392
+ void bindCorpus(read.text, {
393
+ appendTo,
394
+ onStage: (stage, info) => {
395
+ if (stage !== 'binding') return;
396
+ if (asAppend && appendTo) {
397
+ log(`appending ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES to ${appendTo} (background)`);
398
+ } else {
399
+ log(`binding ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES (${label})`);
400
+ }
401
+ },
402
+ }).then((b) => {
403
+ if (!b?.contextId) return;
404
+ noteCorpusLedger(boundChars, {
405
+ contextId: b.contextId,
406
+ reused: Boolean(appendTo),
407
+ corpusChars: 0,
408
+ fileChars: read.bytes,
409
+ ...ledgerOpts(),
410
+ });
411
+ stats?.noteFileBind(read.files, read.bytes);
412
+ if (sessionKey && !spillMemo.has(sessionKey)) {
413
+ spillMemo.set(sessionKey, { corpus: '', contextId: b.contextId, hash: b.hash });
414
+ }
415
+ }).catch((e) => log(`${asAppend ? 'file append failed (corpus lags one turn)' : 'file bind failed'}: ${e.message}`));
416
+ });
414
417
  };
415
418
 
416
419
  if (msgs.length < 6) {
@@ -418,155 +421,29 @@ async function spillTranscript(body, log, req, stats) {
418
421
  return null;
419
422
  }
420
423
 
421
- // Keep the recent tail, but never more than half the transcript: a fixed 8 on
422
- // a 10-message body left only index 2 to search, which is rarely a user turn,
423
- // so a SHORT-but-huge transcript (one giant tool_result) silently never
424
- // spilled the exact case an agent hits first.
425
- const keepTail = Math.min(
426
- Number(process.env.OPENZOO_KEEP_TAIL_MSGS || 8),
427
- Math.max(2, Math.floor(msgs.length / 2)),
428
- );
429
- const firstSpillable = msgs.findIndex((m) => m?.role !== 'system');
430
- if (firstSpillable < 0) return null;
431
-
432
- // A SEVERABLE BOUNDARY IS NOT ONLY A `user` TURN.
433
- //
434
- // That was the whole bug: in a Claude Code agent loop the human speaks ONCE
435
- // and everything after is assistant->tool pairs, so a rule that only cuts at
436
- // role:"user" finds nothing and the spill returns null on EVERY turn.
437
- // MEASURED against the live proxy log: 104 real claude-cli requests, zero
438
- // spills, while every synthetic transcript I tested spilled fine because I
439
- // had written user turns into it.
440
- //
441
- // What actually matters is that no assistant `tool_calls` is left unanswered
442
- // across the cut. So any message is a legal boundary when the one BEFORE it
443
- // is not an unanswered tool call — i.e. the previous message is a plain
444
- // assistant/user/tool with every call already resolved.
445
- const severable = (i) => {
446
- if (i <= firstSpillable || i >= msgs.length) return false;
447
- const prev = msgs[i - 1];
448
- if (!prev) return false;
449
- // an assistant that issued tool_calls must be followed by its tool results
450
- if (prev.role === 'assistant' && Array.isArray(prev.tool_calls) && prev.tool_calls.length) return false;
451
- // never split a tool_call/tool_result run in the middle
452
- return msgs[i].role !== 'tool';
453
- };
454
- let cut = -1;
455
- for (let i = msgs.length - keepTail; i > firstSpillable; i--) {
456
- if (severable(i)) { cut = i; break; }
457
- }
458
- // FALL BACK TO THE LAST SEVERABLE TURN. The keepTail window is a preference,
459
- // not a requirement: a transcript can be enormous and still have very few
460
- // user turns (one huge document, then tool traffic), and on those the window
461
- // contained no `user` message at all — so nothing spilled and the whole body
462
- // went upstream while the counter honestly reported 0. Keep at least the
463
- // final turn; anything earlier that is severable is better than not spilling.
464
- if (cut <= firstSpillable) {
465
- for (let i = msgs.length - 2; i > firstSpillable; i--) {
466
- if (severable(i)) { cut = i; break; }
467
- }
468
- }
469
- if (cut <= firstSpillable) {
424
+ // LIVE SELF-TUNER. Env knobs seed the first cut; after cut+stub the proxy
425
+ // measures corpus/sent and retunes keep/min-turns/budget (and stubs more)
426
+ // in process memory so the next request starts from the last good setting.
427
+ // No restart. OPENZOO_ADAPT=0 freezes the env defaults. The ask always
428
+ // stays; we never drop below 2 real user/assistant turns to delete it.
429
+ const knownLedger = (sessionKey && spillMemo.get(sessionKey))
430
+ || (sessionKey && sessionLedger.get(sessionKey))
431
+ || null;
432
+ const knownChars = knownLedger?.contextId
433
+ ? (boundChars.get(knownLedger.contextId) || 0)
434
+ : 0;
435
+ const adapted = applySpillCut(msgs, {
436
+ corpusChars: knownChars,
437
+ boundAbs: previouslyBoundAbs,
438
+ log,
439
+ persist: true,
440
+ });
441
+ if (adapted.cut <= adapted.firstSpillable) {
470
442
  bindFilesInBackground('no severable cut, files only');
471
- return null; // nothing safely severable
472
- }
473
-
474
- // TRIM THE TAIL BY BYTES, NOT MESSAGE COUNT.
475
- //
476
- // MEASURED on the live session: 9 kept turns of Claude Code tool output made
477
- // promptTokens swamp the counterfactual and the call scored 1.00x, while the
478
- // SAME bound context with a one-line ask scored 8.53x. Nine messages is a
479
- // trivial number and an enormous payload — a single Read or grep result is
480
- // tens of KB — so counting messages measures the wrong thing entirely.
481
- //
482
- // Walk backwards from the newest and stop at a byte budget. The newest turns
483
- // are the ones the model actually needs verbatim; everything older is already
484
- // in the bound corpus and comes back through recall.
485
- let tailStart = cut;
486
- {
487
- // 24000 was still too fat: MEASURED on 9 live spilled calls, direct came
488
- // back identical to billed to the cent, i.e. the gateway never computed a
489
- // counterfactual because promptTokens >= corpusTokens. The tail has to be
490
- // small enough that the BOUND corpus is the bigger number.
491
- const budget = Number(process.env.OPENZOO_TAIL_MAX_CHARS || 6000);
492
- let used = 0;
493
- for (let i = msgs.length - 1; i >= cut; i--) {
494
- used += msgText(msgs[i]).length;
495
- if (used > budget && severable(i)) { tailStart = i; break; }
496
- }
497
- }
498
- if (tailStart > cut) cut = tailStart;
499
-
500
- // COHERENCE IS COUNTED IN TURNS, NOT BYTES.
501
- //
502
- // The byte budget alone produced windows of 2, 5 and 34 turns out of ~600 —
503
- // and one fat tool result is enough to spend the whole 6,000 chars, so a busy
504
- // agent turn collapses the window to almost nothing. OBSERVED live: an agent
505
- // that had just run a command reported "I don't have the preceding turns of
506
- // this conversation in view" and re-derived its own state from files, every
507
- // turn. Retrieval brings back what is RELEVANT to the ask; it does not
508
- // reliably bring back "what I just did", because the model does not know to
509
- // query for it.
510
- //
511
- // So floor the window at a number of turns regardless of size. This costs
512
- // saving — a bigger tail is a bigger `sent` — and that is the correct trade:
513
- // measured 8.13x on the fleet leaves room to spend some of it on an agent
514
- // that remembers its own last few moves.
515
- // 12 was set while the model was ALSO missing its own last turn to a brief
516
- // spliced in behind it (fixed in 0.48.56), so part of that floor was paying
517
- // for a bug rather than for coherence. 6 turns still covers "what did I just
518
- // do" — the case retrieval cannot answer, because the model does not know to
519
- // query for it — and hands the rest back as saving.
520
- // COUNT CONVERSATION TURNS, NOT MESSAGES.
521
- //
522
- // A tool round trip is TWO messages (assistant tool_call + tool result), so a
523
- // floor of 6 messages is three tool calls and nothing else — the agent loses
524
- // the human turn that started the run and every decision it made along the
525
- // way. OBSERVED: "it's when he calls a bunch of tools, he gets lost."
526
- //
527
- // So the floor is measured in user/assistant turns and tool traffic rides
528
- // along for free. A tool-heavy stretch therefore widens the window instead of
529
- // consuming it, which is the opposite of the old behaviour and the whole
530
- // point: what the agent needs verbatim is what it DID, and doing things is
531
- // exactly what fills the window with tool messages.
532
- const minTurns = Number(process.env.OPENZOO_TAIL_MIN_TURNS || 6);
533
- const realTurns = (from) => {
534
- let n = 0;
535
- for (let i = from; i < msgs.length; i++) {
536
- const r = msgs[i]?.role;
537
- if (r === 'user' || r === 'assistant') n += 1;
538
- }
539
- return n;
540
- };
541
- if (realTurns(cut) < minTurns) {
542
- for (let i = cut - 1; i > firstSpillable; i--) {
543
- if (severable(i) && realTurns(i) >= minTurns) { cut = i; break; }
544
- if (i === firstSpillable + 1) { if (severable(i)) cut = i; break; }
545
- }
546
- }
547
-
548
- // NEVER SPILL THE CURRENT ASK.
549
- //
550
- // The tail budget walks BACKWARD accumulating bytes, and in an agent loop the
551
- // last few messages are tool results — file reads, greps, build output. Those
552
- // alone blow through 6,000 chars, so the cut lands AFTER the user's actual
553
- // instruction and the instruction goes into the bound corpus instead of the
554
- // forwarded window. It then only comes back if top-k recall happens to surface
555
- // it against its own text, which is exactly the query it is least likely to
556
- // match.
557
- //
558
- // OBSERVED: "sending 2/578 turns", and the model replying "I don't have a
559
- // specific request to act on — your message came through empty", then
560
- // re-reading the plan doc to work out where it was, every single turn. A loop
561
- // that looks like amnesia and is actually us deleting the question.
562
- //
563
- // Retrieval is for CONTEXT. The ask itself is never context, and must survive
564
- // any budget.
565
- let lastUser = -1;
566
- for (let i = msgs.length - 1; i > firstSpillable; i--) {
567
- if (msgs[i].role === 'user' && msgText(msgs[i]).trim()) { lastUser = i; break; }
443
+ return null;
568
444
  }
569
- if (lastUser > firstSpillable && cut > lastUser) cut = lastUser;
445
+ const { cut, firstSpillable } = adapted;
446
+ const stubbed = adapted.stubbed;
570
447
 
571
448
  const head = msgs.slice(0, firstSpillable); // system block, always kept
572
449
  // EVERY FILE THE AGENT TOUCHED, AT FULL SIZE.
@@ -583,8 +460,9 @@ async function spillTranscript(body, log, req, stats) {
583
460
  // sitting on this machine. Binding it makes the corpus large immediately
584
461
  // instead of eventually, and makes the truncated read whole again.
585
462
  //
586
- // Read-only, bounded, deduped by path+mtime. Path extraction + the file-bind
587
- // log already ran at the top of this function (files / fileResult).
463
+ // Read-only, bounded, deduped by path+mtime. Path collection already ran at
464
+ // the top of this function (fileCollect.pending). Bytes + dir expansion
465
+ // happen in bindFilesInBackground after this turn is forwarded.
588
466
  //
589
467
  // FILES RIDE THE BACKGROUND, NEVER THE CRITICAL PATH.
590
468
  //
@@ -709,22 +587,15 @@ async function spillTranscript(body, log, req, stats) {
709
587
  reused: appended,
710
588
  corpusChars: corpus.length,
711
589
  deltaChars,
712
- fileChars: files.length,
590
+ fileChars: 0,
713
591
  ...ledgerOpts(),
714
592
  });
715
593
  // APPEND THE FILES AFTER, off the clock. Fire-and-forget against the context
716
594
  // we just secured: this turn is already answerable without them, and the next
717
595
  // ask gets them for free. `boundFiles` already deduped by path:mtime, so this
718
596
  // uploads each version exactly once no matter how often the agent re-reads it.
719
- if (files) {
720
- stats?.noteFileBind(fileResult.files, fileResult.bytes);
721
- void bindCorpus(files, {
722
- appendTo: bind.contextId,
723
- onStage: (stage, info) => {
724
- if (stage === 'binding') log(`appending ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES to ${bind.contextId} (background)`);
725
- },
726
- }).catch((e) => log(`file append failed (corpus lags one turn): ${e.message}`));
727
- }
597
+ // Read + readdir are inside setImmediate � they must not run before send().
598
+ bindFilesInBackground('background', { appendTo: bind.contextId, asAppend: true });
728
599
  spillMemo.set(anchor, { corpus, contextId: bind.contextId, hash: bind.hash });
729
600
  if (spillMemo.size > 32) spillMemo.delete(spillMemo.keys().next().value);
730
601
  const sent = msgs.length - cut;
@@ -742,6 +613,13 @@ async function spillTranscript(body, log, req, stats) {
742
613
  ? `transcript prefix already bound (${bind.contextId}, ${keyKind}) — sending ${sent}/${msgs.length} turns`
743
614
  : `transcript prefix bound (${mb(bind.bytes)}MB → ${bind.contextId}, ${keyKind}) — sending ${sent}/${msgs.length} turns`);
744
615
 
616
+ // Bound file bodies in the forwarded tail were stubbed inside applySpillCut
617
+ // (and maybe stubbed more if the tuner was below 10x). The adapt line is
618
+ // already logged there.
619
+ if (stubbed.dropped) {
620
+ log(`file-stub stubbed=${stubbed.stubbed} dropped=${stubbed.dropped}`);
621
+ }
622
+
745
623
  // ADAPTIVE TOP-K. A fixed 32 chunks is what was actually eating the saving:
746
624
  // MEASURED on a 56,265-token corpus, top_k 32 handed 9,990 tokens back and
747
625
  // scored 2.45x, while 8 handed back 2,574 and scored 4.73x — same answer,
@@ -769,7 +647,7 @@ async function spillTranscript(body, log, req, stats) {
769
647
  const topK = Math.max(4, Math.min(12, Math.round(budget / 320)));
770
648
 
771
649
  return {
772
- body: Buffer.from(JSON.stringify({ ...body, messages: [...head, ...msgs.slice(cut)] })),
650
+ body: Buffer.from(JSON.stringify({ ...body, messages: [...head, ...stubbed.messages.slice(cut)] })),
773
651
  topK,
774
652
  contextId: bind.contextId,
775
653
  hash: bind.hash,
@@ -1288,62 +1166,75 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1288
1166
  }
1289
1167
  }
1290
1168
  if (rewritablePath(req.method, req.url)) {
1291
- const rw = await maybeRewriteModel(bodyBuf);
1292
- if (rw) {
1293
- // SAY WHETHER THE OVERRIDE IS ACTUALLY SET, and name it.
1294
- //
1295
- // This used to print "(OPENZOO_DEFAULT_MODEL overrides)" on EVERY
1296
- // rewrite whether or not the variable existed — a hint about a knob,
1297
- // phrased as a statement about this request. It cost a real incident:
1298
- // the proxy was restarted from a shell carrying
1299
- // OPENZOO_DEFAULT_MODEL=deepseek/deepseek-v4-pro-0813, so every
1300
- // claude-sonnet-5 ask was served by deepseek, and the log line looked
1301
- // exactly the same as it always had. deepseek matches the reasoning
1302
- // regex, so a 16-token safety classification became a 4,000-token
1303
- // reasoning generation — 11.5s, past the caller's timeout, and Claude
1304
- // Code reported "claude-sonnet-5 is temporarily unavailable".
1305
- const forced = process.env.OPENZOO_DEFAULT_MODEL;
1306
- log(forced
1307
- ? `model "${rw.from}" -> FORCED to ${forced} by OPENZOO_DEFAULT_MODEL (nearest match would have been ${rw.to})`
1308
- : `model "${rw.from}" is not on the zoo — nearest match ${rw.to}`);
1309
- bodyBuf = rw.body;
1310
- }
1311
1169
  try {
1312
1170
  let parsed = JSON.parse(bodyBuf.toString('utf8'));
1313
1171
  wantsStream = parsed?.stream === true || clientWantsStream;
1314
- // REASONING MODELS SPEND max_tokens ON THINKING FIRST.
1172
+ // TINY CLASSIFY FIRST, ON THE ORIGINAL BODY.
1315
1173
  //
1316
- // The budget covers hidden reasoning AND the visible answer, so a
1317
- // caller that asks for 40 tokens because it wants a short answer often
1318
- // gets ZERO the whole allowance went to reasoning and the completion
1319
- // truncated to an empty string. Measured across three families in one
1320
- // day: deepseek returned 0 chars at 8k and was fine at 24k; grok-4.6
1321
- // pinned ct at exactly its 16,000 budget with no visible output;
1322
- // sonnet-5 truncated a 600-token file mid-function because Anthropic's
1323
- // max_tokens covers thinking too.
1324
- //
1325
- // An empty completion is not an error it bills normally and renders
1326
- // as a blank reply so this fails silently and looks like the retrieval
1327
- // broke. It cost real debugging time tonight for exactly that reason.
1328
- // Multiply the allowance for known reasoning families and let callers
1329
- // keep asking for what they actually want back.
1330
- const REASONING = /(deepseek|grok|o[134](-|$)|reasoner|thinking|-pro\b|sol-pro|qwq)/i;
1331
- const mult = Number(process.env.OPENZOO_REASONING_MAX_TOKENS_X || 4);
1332
- const cap = Number(process.env.OPENZOO_REASONING_MAX_TOKENS_CAP || 32000);
1333
- const mdl = String(parsed?.model || '');
1334
- const mt = Number(parsed?.max_tokens);
1335
- // A MULTIPLIER ALONE IS NOT ENOUGH. 4x on a caller's 40 is 160, which is
1336
- // still nothing for a model that thinks first — measured, 2 of 3 runs
1337
- // still returned empty at 160. Reasoning needs an absolute floor, not a
1338
- // relative bump, so take whichever is larger.
1339
- const floor = Number(process.env.OPENZOO_REASONING_MIN_TOKENS || 4000);
1340
- if (mult > 1 && REASONING.test(mdl) && Number.isFinite(mt) && mt > 0 && mt < cap) {
1341
- const raised = Math.min(cap, Math.max(floor, Math.round(mt * mult)));
1342
- if (raised > mt) {
1343
- parsed.max_tokens = raised;
1344
- bodyBuf = Buffer.from(JSON.stringify(parsed));
1345
- log(`reasoning model ${mdl}: max_tokens ${mt} -> ${raised} (thinking shares the budget; OPENZOO_REASONING_MAX_TOKENS_X=1 disables)`);
1174
+ // Claude Code auto-mode sends a 16-token yes/no to claude-sonnet-5
1175
+ // before Bash/WebSearch. Two things used to eat that call:
1176
+ // 1. OPENZOO_DEFAULT_MODEL rewrote it onto deepseek/grok;
1177
+ // 2. the reasoning floor then raised 16 -> 4000.
1178
+ // Measured: 11.5s, past the caller's timeout, "claude-sonnet-5 is
1179
+ // temporarily unavailable (timed out), so auto mode cannot determine
1180
+ // the safety of WebSearch". Even staying on sonnet-5 is too slow
1181
+ // (402 handshake behind a long Grok stream). Pin to a fast
1182
+ // non-reasoning catalog id and leave max_tokens at 16. Real
1183
+ // Grok/DeepSeek chats (max_tokens > 64 or a body over BIND_MIN)
1184
+ // still get the 4000 floor those still go blank without it.
1185
+ let ids = [];
1186
+ try { ids = await zooModelIds(); } catch { /* catalog miss: still skip the floor on a tiny classify */ }
1187
+ const policy = rewriteChatModel(parsed, ids, { bodyLen: bodyBuf.length });
1188
+ parsed = policy.parsed;
1189
+ if (policy.tiny) {
1190
+ log(`classifier tiny max_tokens=${Number(parsed?.max_tokens)} "${policy.from}" -> ${policy.to} (no reasoning floor)`);
1191
+ } else {
1192
+ // SAY WHETHER THE OVERRIDE IS ACTUALLY SET, and name it.
1193
+ //
1194
+ // This used to print "(OPENZOO_DEFAULT_MODEL overrides)" on EVERY
1195
+ // rewrite whether or not the variable existed a hint about a knob,
1196
+ // phrased as a statement about this request. It cost a real incident:
1197
+ // the proxy was restarted from a shell carrying
1198
+ // OPENZOO_DEFAULT_MODEL=deepseek/deepseek-v4-pro-0813, so every
1199
+ // claude-sonnet-5 ask was served by deepseek, and the log line looked
1200
+ // exactly the same as it always had. deepseek matches the reasoning
1201
+ // regex, so a 16-token safety classification became a 4,000-token
1202
+ // reasoning generation � 11.5s, past the caller's timeout, and Claude
1203
+ // Code reported "claude-sonnet-5 is temporarily unavailable".
1204
+ // Tiny classify is pinned above and never reaches this path.
1205
+ if (policy.to && policy.to !== policy.from) {
1206
+ const forced = process.env.OPENZOO_DEFAULT_MODEL;
1207
+ log(forced
1208
+ ? `model "${policy.from}" -> FORCED to ${forced} by OPENZOO_DEFAULT_MODEL (nearest match would have been ${policy.to})`
1209
+ : `model "${policy.from}" is not on the zoo � nearest match ${policy.to}`);
1346
1210
  }
1211
+ // REASONING MODELS SPEND max_tokens ON THINKING FIRST.
1212
+ //
1213
+ // The budget covers hidden reasoning AND the visible answer, so a
1214
+ // caller that asks for 40 tokens because it wants a short answer often
1215
+ // gets ZERO � the whole allowance went to reasoning and the completion
1216
+ // truncated to an empty string. Measured across three families in one
1217
+ // day: deepseek returned 0 chars at 8k and was fine at 24k; grok-4.6
1218
+ // pinned ct at exactly its 16,000 budget with no visible output;
1219
+ // sonnet-5 truncated a 600-token file mid-function because Anthropic's
1220
+ // max_tokens covers thinking too.
1221
+ //
1222
+ // An empty completion is not an error � it bills normally and renders
1223
+ // as a blank reply � so this fails silently and looks like the retrieval
1224
+ // broke. It cost real debugging time tonight for exactly that reason.
1225
+ // Multiply the allowance for known reasoning families and let callers
1226
+ // keep asking for what they actually want back.
1227
+ //
1228
+ // A MULTIPLIER ALONE IS NOT ENOUGH. 4x on a caller's 40 is 160, which is
1229
+ // still nothing for a model that thinks first � measured, 2 of 3 runs
1230
+ // still returned empty at 160. Reasoning needs an absolute floor, not a
1231
+ // relative bump, so take whichever is larger.
1232
+ if (policy.raised) {
1233
+ log(`reasoning model ${parsed.model}: max_tokens ${policy.raisedFrom} -> ${policy.raisedTo} (thinking shares the budget; OPENZOO_REASONING_MAX_TOKENS_X=1 disables)`);
1234
+ }
1235
+ }
1236
+ if (policy.tiny || policy.raised || (policy.to && policy.to !== policy.from)) {
1237
+ bodyBuf = Buffer.from(JSON.stringify(parsed));
1347
1238
  }
1348
1239
  // Tell the agent what it is actually connected to — in band, where it
1349
1240
  // will read it, instead of leaving it to guess (and to chunk corpora
@@ -1365,10 +1256,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1365
1256
  // advice nobody will read.
1366
1257
  //
1367
1258
  // Threshold is the same one the spill uses: below it there is no
1368
- // corpus and nothing the brief could help with.
1369
- const tiny = bodyBuf.length < BIND_MIN_CHARS
1370
- && Number(parsed?.max_tokens ?? 0) > 0
1371
- && Number(parsed?.max_tokens) <= 64;
1259
+ // corpus and nothing the brief could help with. policy.tiny is
1260
+ // that check on the ORIGINAL body, before the reasoning floor.
1261
+ const tiny = policy.tiny
1262
+ || (bodyBuf.length < BIND_MIN_CHARS
1263
+ && Number(parsed?.max_tokens ?? 0) > 0
1264
+ && Number(parsed?.max_tokens) <= 64);
1372
1265
  const briefed = tiny ? null : injectBrief(parsed, selfUrl);
1373
1266
  if (briefed) parsed = briefed;
1374
1267
  // SYSTEM MESSAGES BELONG AT THE FRONT, OR GOOGLE 400s.