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/models.js +131 -6
- package/lib/proxy.js +156 -263
- package/lib/spill.js +738 -26
- package/package.json +1 -1
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,
|
|
16
|
+
loadBoundChars, noteCorpusLedger, filesForCorpus, readFilesForCorpus, boundAbsFromKeys,
|
|
17
|
+
createSpillStats, corpusCharsForSend, applySpillCut, msgText,
|
|
17
18
|
} from './spill.js';
|
|
18
|
-
import {
|
|
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
|
-
//
|
|
378
|
-
// so a short agent turn that Read a file never bound it �
|
|
379
|
-
// extract itself returned empty, nothing logged.
|
|
380
|
-
|
|
381
|
-
|
|
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 (!
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
422
|
-
//
|
|
423
|
-
// so
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
587
|
-
//
|
|
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:
|
|
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
|
-
|
|
720
|
-
|
|
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, ...
|
|
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
|
-
//
|
|
1172
|
+
// TINY CLASSIFY FIRST, ON THE ORIGINAL BODY.
|
|
1315
1173
|
//
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
//
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1324
|
-
//
|
|
1325
|
-
//
|
|
1326
|
-
//
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
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.
|