openzoo 0.48.68 → 0.48.70

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/anthropic.js CHANGED
@@ -87,6 +87,28 @@ export function anthropicToOpenAI(body) {
87
87
  delete out.system;
88
88
  delete out.anthropic_version;
89
89
  delete out.metadata;
90
+ // A SERVER-SIDE web_search TOOL IS A PLUGIN, NOT A FUNCTION — TRANSLATE IT.
91
+ //
92
+ // Claude Code's Web Search arrives as an Anthropic server tool (type
93
+ // "web_search_20250305", name "web_search") with NO input_schema, because
94
+ // Anthropic runs it itself. Routed to OpenRouter->some other provider, nobody
95
+ // runs it, and the old code simply DROPPED it (no schema) — so the model got
96
+ // no search capability and every Web Search came back "0 results". Silent,
97
+ // and worse than the 400 it replaced: the agent believes it searched.
98
+ //
99
+ // OpenRouter's `web` plugin is the cross-model equivalent — search-then-inject
100
+ // middleware that works on every model. Detect the server tool and switch it
101
+ // on, mapping max_uses -> max_results, rather than discarding the intent.
102
+ const webTool = (Array.isArray(body.tools) ? body.tools : [])
103
+ .find((t) => t?.type?.startsWith?.('web_search') || t?.name === 'web_search');
104
+ if (webTool) {
105
+ const existing = Array.isArray(body.plugins) ? body.plugins : [];
106
+ if (!existing.some((p) => p?.id === 'web')) {
107
+ const web = { id: 'web' };
108
+ if (Number.isFinite(webTool.max_uses)) web.max_results = webTool.max_uses;
109
+ out.plugins = [...existing, web];
110
+ }
111
+ }
90
112
  if (Array.isArray(body.tools) && body.tools.length) {
91
113
  out.tools = body.tools
92
114
  .filter((t) => t?.name && t?.input_schema)
package/lib/launch.js CHANGED
@@ -230,7 +230,13 @@ export async function launchClaude(argv) {
230
230
  + 'const sp=j.spilled||{};'
231
231
  + 'const tk=Number(sp.tokensApprox)||0;'
232
232
  + 'const ht=tk>=1e6?(tk/1e6).toFixed(1)+"M":tk>=1e3?Math.round(tk/1e3)+"k":String(tk);'
233
- + 'const spill=tk?(" \\u00b7 "+ht+" tok offloaded"):"";'
233
+ + 'const sc=Number(sp.calls)||0;'
234
+ + 'const fb=Number(sp.fileBinds)||0;'
235
+ + 'const bits=[];'
236
+ + 'if(sc||j.paidCalls)bits.push(sc+" spilled");'
237
+ + 'if(fb||sc)bits.push(fb+" filebind");'
238
+ + 'if(tk)bits.push(ht+" tok offloaded");'
239
+ + 'const spill=bits.length?(" \\u00b7 "+bits.join(" \\u00b7 ")):"";'
234
240
  // "how can I easily see what credit i'm left" — asked by a user who
235
241
  // could not tell whether x402 had charged them at all.
236
242
  + 'const cr=(j.creditUsd==null)?"":(" \\u00b7 $"+Number(j.creditUsd).toFixed(2)+" credit");'
package/lib/proxy.js CHANGED
@@ -12,6 +12,9 @@ import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
12
12
  import { tokenBalance } from './x402.js';
13
13
  import { evmTokenBalance } from './evm.js';
14
14
  import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
15
+ import {
16
+ loadBoundChars, noteCorpusLedger, filesForCorpus, createSpillStats,
17
+ } from './spill.js';
15
18
  import { maybeRewriteModel, rewritablePath, augmentModelList, ALIAS_IDS } from './models.js';
16
19
  import { forgetContext } from './contexts.js';
17
20
  import { injectBrief } from './brief.js';
@@ -122,6 +125,10 @@ const boundFiles = new Set();
122
125
  // context_id -> total chars BOUND (turns + files appended). The counterfactual
123
126
  // basis must reflect what the corpus actually holds, not just this turn's slice.
124
127
  const boundChars = new Map();
128
+ // sessionKey -> { contextId, chars } so a sidecar restart can keep appending
129
+ // to the same context and still send the accumulated x-hrr-corpus-chars.
130
+ const sessionLedger = new Map();
131
+ loadBoundChars(boundChars, { sessions: sessionLedger, boundFiles });
125
132
 
126
133
  // THIS MAP SURVIVES A RESTART, OR THE COUNTERFACTUAL DOES NOT.
127
134
  //
@@ -363,9 +370,53 @@ function msgText(m) {
363
370
  * severed at a plain `user` message — everything before one is self-contained.
364
371
  * A system message is never spilled: it is the operating contract, not history.
365
372
  */
366
- async function spillTranscript(body, log, req) {
373
+ async function spillTranscript(body, log, req, stats) {
367
374
  const msgs = Array.isArray(body?.messages) ? body.messages : null;
368
- if (!msgs || msgs.length < 6) return null;
375
+ if (!msgs?.length) return null;
376
+
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;
382
+ const sessionId = req?.headers?.['x-claude-code-session-id']
383
+ || req?.headers?.['x-session-id']
384
+ || req?.headers?.['x-claude-session-id']
385
+ || (typeof body?.metadata?.user_id === 'string' ? body.metadata.user_id : null);
386
+ let sessionKey = sessionId ? `sid:${sessionId}` : null;
387
+
388
+ const ledgerOpts = () => ({ sessionKey, sessions: sessionLedger, boundFiles });
389
+ const bindFilesInBackground = (label) => {
390
+ if (!files) return;
391
+ const known = (sessionKey && spillMemo.get(sessionKey))
392
+ || (sessionKey && sessionLedger.get(sessionKey))
393
+ || null;
394
+ const appendTo = known?.contextId || null;
395
+ void bindCorpus(files, {
396
+ appendTo,
397
+ onStage: (stage, info) => {
398
+ if (stage === 'binding') log(`binding ${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 });
412
+ }
413
+ }).catch((e) => log(`file bind failed: ${e.message}`));
414
+ };
415
+
416
+ if (msgs.length < 6) {
417
+ bindFilesInBackground('conversation under 6 messages, background');
418
+ return null;
419
+ }
369
420
 
370
421
  // Keep the recent tail, but never more than half the transcript: a fixed 8 on
371
422
  // a 10-message body left only index 2 to search, which is rarely a user turn,
@@ -415,7 +466,10 @@ async function spillTranscript(body, log, req) {
415
466
  if (severable(i)) { cut = i; break; }
416
467
  }
417
468
  }
418
- if (cut <= firstSpillable) return null; // nothing safely severable
469
+ if (cut <= firstSpillable) {
470
+ bindFilesInBackground('no severable cut, files only');
471
+ return null; // nothing safely severable
472
+ }
419
473
 
420
474
  // TRIM THE TAIL BY BYTES, NOT MESSAGE COUNT.
421
475
  //
@@ -529,93 +583,40 @@ async function spillTranscript(body, log, req) {
529
583
  // sitting on this machine. Binding it makes the corpus large immediately
530
584
  // instead of eventually, and makes the truncated read whole again.
531
585
  //
532
- // Read-only, bounded, deduped by path+mtime, and failures are silent: this
533
- // runs on the request path and must never be the reason a turn does not go.
534
- const filesForCorpus = () => {
535
- if (process.env.OPENZOO_BIND_FILES === '0') return '';
536
- const cap = Number(process.env.OPENZOO_BIND_FILE_MAX || 400_000);
537
- const out = [];
538
- const seen = new Set();
539
- for (const m of msgs) {
540
- const blocks = Array.isArray(m?.content) ? m.content : [];
541
- const calls = Array.isArray(m?.tool_calls) ? m.tool_calls : [];
542
- const paths = [];
543
- for (const b of blocks) {
544
- const p = b?.input?.file_path || b?.input?.path;
545
- if (typeof p === 'string') paths.push(p);
546
- }
547
- for (const c of calls) {
548
- try {
549
- const a = JSON.parse(c?.function?.arguments || '{}');
550
- if (typeof a.file_path === 'string') paths.push(a.file_path);
551
- else if (typeof a.path === 'string') paths.push(a.path);
552
- } catch { /* not json args */ }
553
- }
554
- for (const p of paths) {
555
- if (seen.has(p) || !path.isAbsolute(p)) continue;
556
- seen.add(p);
557
- try {
558
- const st = fs.statSync(p);
559
- if (!st.isFile() || st.size > cap) continue;
560
- const key = `${p}:${st.mtimeMs}`;
561
- if (boundFiles.has(key)) continue;
562
- boundFiles.add(key);
563
- out.push(`FILE ${p}\n${fs.readFileSync(p, 'utf8')}`);
564
- } catch { /* unreadable, gone, or binary — simply not corpus */ }
565
- }
566
- }
567
- return out.join('\n\n');
568
- };
569
-
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).
588
+ //
570
589
  // FILES RIDE THE BACKGROUND, NEVER THE CRITICAL PATH.
571
590
  //
572
- // The FIRST bind of a session is necessarily synchronous the request cannot
591
+ // The FIRST bind of a session is necessarily synchronous the request cannot
573
592
  // go until the context_id exists, because it travels as x-hrr-context. Folding
574
593
  // file bytes into that bind put a 400KB upload in front of the caller's turn,
575
594
  // which is the cold-bind stall this whole exercise was meant to remove: the
576
595
  // bind endpoint measures 0.34-0.48s on a 613KB corpus, and that is 0.34-0.48s
577
596
  // the user waits before a single token appears.
578
597
  //
579
- // Nothing recalls a file during the turn that read it the model already has
598
+ // Nothing recalls a file during the turn that read it the model already has
580
599
  // the tool result in its window. Files are only worth having bound for the
581
600
  // NEXT ask. So the conversation binds inline and the files are appended after
582
601
  // the fact, off the clock.
583
602
  const turns = msgs.slice(firstSpillable, cut).map(msgText).filter(Boolean).join('\n\n');
584
- const files = filesForCorpus();
585
603
  const corpus = turns;
604
+ if (!sessionKey) sessionKey = corpus.slice(0, 2048);
586
605
 
587
606
  // FILES BIND EVEN WHEN THE CONVERSATION IS TOO SMALL TO SPILL.
588
607
  //
589
- // The threshold exists to stop us binding a two-line chat it was never
608
+ // The threshold exists to stop us binding a two-line chat it was never
590
609
  // meant to gate FILES. But bailing here skipped them entirely, so a fresh
591
610
  // session that reads a 200KB file bound nothing and scored 1.00x forever:
592
611
  // OBSERVED on a live session that read files all turn and never produced a
593
612
  // corpus, because its conversation stayed under the threshold the whole time.
594
613
  //
595
614
  // A file is worth binding on its own merit. So when the turns are too small
596
- // to spill but files exist, bind the files anyway in the background,
597
- // against this session's context and let this turn go unspilled. The corpus
615
+ // to spill but files exist, bind the files anyway in the background,
616
+ // against this session's context and let this turn go unspilled. The corpus
598
617
  // is then waiting for the next ask.
599
- const sessionId = req?.headers?.['x-claude-code-session-id']
600
- || req?.headers?.['x-session-id']
601
- || req?.headers?.['x-claude-session-id']
602
- || (typeof body?.metadata?.user_id === 'string' ? body.metadata.user_id : null);
603
- const sessionKey = sessionId ? `sid:${sessionId}` : corpus.slice(0, 2048);
604
618
  if (corpus.length <= BIND_MIN_CHARS) {
605
- if (files) {
606
- const known = spillMemo.get(sessionKey);
607
- void bindCorpus(files, {
608
- appendTo: known?.contextId || null,
609
- onStage: (stage, info) => {
610
- if (stage === 'binding') log(`binding ${mb(info.bytes)}MB of FILES (conversation under spill threshold, background)`);
611
- },
612
- }).then((b) => {
613
- if (!b?.contextId) return;
614
- boundChars.set(b.contextId, (boundChars.get(b.contextId) || 0) + files.length);
615
- persistBoundChars();
616
- if (!known) spillMemo.set(sessionKey, { corpus, contextId: b.contextId, hash: b.hash });
617
- }).catch((e) => log(`file bind failed: ${e.message}`));
618
- }
619
+ bindFilesInBackground('conversation under spill threshold, background');
619
620
  return null;
620
621
  }
621
622
 
@@ -650,11 +651,30 @@ async function spillTranscript(body, log, req) {
650
651
  // anthropic-beta, anthropic-version, x-app, x-claude-code-session-id,
651
652
  // x-stainless-*
652
653
  const anchor = sessionKey;
653
- const prior = spillMemo.get(anchor);
654
+ const persisted = sessionKey ? sessionLedger.get(sessionKey) : null;
655
+ const prior = spillMemo.get(anchor) || (persisted?.contextId
656
+ ? { contextId: persisted.contextId, hash: persisted.hash || '', corpus: null, restored: true }
657
+ : null);
654
658
  let bind;
655
- if (prior && corpus.startsWith(prior.corpus) && corpus.length > prior.corpus.length) {
659
+ let deltaChars = 0;
660
+ let appended = false;
661
+ if (prior?.restored && prior.contextId) {
662
+ // Sidecar came back up: we still know the context_id and the accumulated
663
+ // char count, but not the prior prefix string, so we cannot slice a delta.
664
+ // Re-append the current prefix (some overlap is harmless) and keep the
665
+ // restored ledger � do not add corpus.length again.
666
+ void bindCorpus(corpus, {
667
+ appendTo: prior.contextId,
668
+ onStage: (stage, info) => {
669
+ if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${prior.contextId} (restored session, background)`);
670
+ },
671
+ }).catch((e) => log(`append failed (history may lag one turn): ${e.message}`));
672
+ appended = true;
673
+ bind = { contextId: prior.contextId, hash: prior.hash, reused: true, bytes: 0 };
674
+ } else if (prior && typeof prior.corpus === 'string' && corpus.startsWith(prior.corpus) && corpus.length > prior.corpus.length) {
656
675
  const delta = corpus.slice(prior.corpus.length);
657
- // FIRE AND FORGET. This delta is history for FUTURE turns — the answer
676
+ deltaChars = delta.length;
677
+ // FIRE AND FORGET. This delta is history for FUTURE turns � the answer
658
678
  // being generated right now is served from the tail plus what is already
659
679
  // bound, so waiting on the upload buys nothing and costs the user the
660
680
  // round trip on every single turn. The context id is already known, so
@@ -662,7 +682,7 @@ async function spillTranscript(body, log, req) {
662
682
  //
663
683
  // The FIRST bind is deliberately NOT async: the request must carry
664
684
  // x-hrr-context, and that id does not exist until the bind returns. Firing
665
- // that one off would send the opening turn with no context at all a
685
+ // that one off would send the opening turn with no context at all a
666
686
  // silently worse answer traded for a shorter pause, which is the wrong way
667
687
  // round.
668
688
  void bindCorpus(delta, {
@@ -671,6 +691,7 @@ async function spillTranscript(body, log, req) {
671
691
  if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${prior.contextId} (delta, background)`);
672
692
  },
673
693
  }).catch((e) => log(`append failed (history may lag one turn): ${e.message}`));
694
+ appended = true;
674
695
  bind = { contextId: prior.contextId, hash: prior.hash, reused: true, bytes: delta.length };
675
696
  } else {
676
697
  bind = await bindCorpus(corpus, {
@@ -679,13 +700,24 @@ async function spillTranscript(body, log, req) {
679
700
  },
680
701
  });
681
702
  }
703
+ // CONVERSATION LEDGER � every successful bind AND append, not only when
704
+ // files exist. First bind initializes to the bound corpus size; each append
705
+ // adds the delta; file bytes ride on top. This is what makes x-hrr-corpus-chars
706
+ // the accumulated bound corpus instead of this-turn's prefix.
707
+ noteCorpusLedger(boundChars, {
708
+ contextId: bind.contextId,
709
+ reused: appended,
710
+ corpusChars: corpus.length,
711
+ deltaChars,
712
+ fileChars: files.length,
713
+ ...ledgerOpts(),
714
+ });
682
715
  // APPEND THE FILES AFTER, off the clock. Fire-and-forget against the context
683
716
  // we just secured: this turn is already answerable without them, and the next
684
717
  // ask gets them for free. `boundFiles` already deduped by path:mtime, so this
685
718
  // uploads each version exactly once no matter how often the agent re-reads it.
686
719
  if (files) {
687
- boundChars.set(bind.contextId, (boundChars.get(bind.contextId) || corpus.length) + files.length);
688
- persistBoundChars();
720
+ stats?.noteFileBind(fileResult.files, fileResult.bytes);
689
721
  void bindCorpus(files, {
690
722
  appendTo: bind.contextId,
691
723
  onStage: (stage, info) => {
@@ -747,11 +779,21 @@ async function spillTranscript(body, log, req) {
747
779
  };
748
780
  }
749
781
 
750
- async function maybeCacheCorpus(req, bodyBuf, log) {
782
+ async function maybeCacheCorpus(req, bodyBuf, log, stats) {
751
783
  if (contextCacheDisabled()) return null;
752
784
  if (req.method !== 'POST' || !(req.url || '').includes('/chat/completions')) return null;
753
785
  if (req.headers['x-hrr-context']) return null; // harness manages its own context
754
- if (bodyBuf.length <= BIND_MIN_CHARS) return null;
786
+ if (bodyBuf.length <= BIND_MIN_CHARS) {
787
+ // Still walk the transcript: a short agent turn that Read a file should
788
+ // bind it even when there is nothing large enough to spill.
789
+ try {
790
+ const body = JSON.parse(bodyBuf.toString('utf8'));
791
+ if (Array.isArray(body?.messages) && body.messages.length) {
792
+ return spillTranscript(body, log, req, stats);
793
+ }
794
+ } catch { /* not json */ }
795
+ return null;
796
+ }
755
797
  let body;
756
798
  try { body = JSON.parse(bodyBuf.toString('utf8')); } catch { return null; }
757
799
  const msgs = Array.isArray(body?.messages) ? body.messages : null;
@@ -764,17 +806,24 @@ async function maybeCacheCorpus(req, bodyBuf, log) {
764
806
  const oneShot = typeof last?.content === 'string'
765
807
  && last.content.length > BIND_MIN_CHARS
766
808
  && last.content.lastIndexOf('\n\n') >= BIND_MIN_CHARS;
767
- if (!oneShot) return spillTranscript(body, log, req);
809
+ if (!oneShot) return spillTranscript(body, log, req, stats);
768
810
  const cut = last.content.lastIndexOf('\n\n');
769
811
  const corpus = last.content.slice(0, cut);
770
812
  const ask = last.content.slice(cut + 2).trim();
771
- if (!ask || ask.length > 8000) return spillTranscript(body, log, req);
813
+ if (!ask || ask.length > 8000) return spillTranscript(body, log, req, stats);
772
814
 
773
815
  const bind = await bindCorpus(corpus, {
774
816
  onStage: (stage, info) => {
775
817
  if (stage === 'binding') log(`binding ${mb(info.bytes)}MB corpus to holographic memory (one-time)...`);
776
818
  },
777
819
  });
820
+ noteCorpusLedger(boundChars, {
821
+ contextId: bind.contextId,
822
+ reused: false,
823
+ corpusChars: corpus.length,
824
+ sessions: sessionLedger,
825
+ boundFiles,
826
+ });
778
827
  if (bind.reused) {
779
828
  log(`corpus already bound (${bind.hash.slice(0, 12)}… → ${bind.contextId}) — skipped ${mb(bind.bytes)}MB upload`);
780
829
  } else {
@@ -850,16 +899,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
850
899
  // SPILL ACCOUNTING. The product's whole claim is that context is offloaded
851
900
  // instead of re-sent, and nothing measured it — the status line showed spend
852
901
  // and call count, which is the cost side with none of the benefit.
853
- let spillCalls = 0;
854
- let spilledChars = 0;
855
- let spillReuses = 0;
902
+ const spill = createSpillStats();
856
903
  // Spend/direct for ONLY the calls that spilled. The session-wide savingX
857
904
  // averages these with every small turn that had nothing to offload, so it
858
905
  // slides toward 1.0 as a conversation grows — which reads as the mechanism
859
906
  // degrading when it is just the mix changing. OBSERVED: 1.3166 -> 1.1823
860
907
  // while spilled calls and offloaded tokens both sat completely still.
861
- let spillSpend = 0;
862
- let spillDirect = 0;
908
+ // spillSpend / spillDirect live on `spill` (createSpillStats).
863
909
  // ACTUAL UPSTREAM SPEND, FROM THE PROVIDER — not our estimate of it.
864
910
  //
865
911
  // Every other dollar figure here is derived from OpenRouter's CATALOG price
@@ -999,17 +1045,11 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
999
1045
  publicTunnel: tunnelGate?.publicUrl ? `${tunnelGate.publicUrl}/v1` : null,
1000
1046
  servedRequests,
1001
1047
  spilled: {
1002
- calls: spillCalls,
1003
- chars: spilledChars,
1048
+ ...spill.snapshot(),
1004
1049
  // ~4 chars/token is the usual rough rule; this is the context that
1005
1050
  // did NOT ride upstream on those calls, which is the number the
1006
1051
  // saving is actually made of.
1007
- tokensApprox: Math.round(spilledChars / 4),
1008
- reusedBinds: spillReuses,
1009
- spend: spillSpend,
1010
- direct: spillDirect,
1011
- savedUsd: Math.max(0, spillDirect - spillSpend),
1012
- savingX: spillSpend > 0 ? Number((spillDirect / spillSpend).toFixed(4)) : null,
1052
+ boundChars: [...boundChars.values()].reduce((a, b) => a + b, 0),
1013
1053
  },
1014
1054
  spendUsd: sessionSpent,
1015
1055
  creditUsd,
@@ -1433,7 +1473,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1433
1473
  try {
1434
1474
  let cached = null;
1435
1475
  try {
1436
- cached = await maybeCacheCorpus(req, bodyBuf, log);
1476
+ cached = await maybeCacheCorpus(req, bodyBuf, log, spill);
1437
1477
  } catch (err) {
1438
1478
  log(`context cache skipped for this call: ${err.message}`);
1439
1479
  }
@@ -1462,10 +1502,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1462
1502
  let didSpill = false;
1463
1503
  let result;
1464
1504
  if (cached) {
1465
- spillCalls += 1;
1505
+ spill.noteSpill({ corpusChars: cached.corpus?.length || 0, reused: cached.reused });
1466
1506
  didSpill = true;
1467
- spilledChars += cached.corpus?.length || 0;
1468
- if (cached.reused) spillReuses += 1;
1469
1507
  result = await send(cached.body, cached.contextId, cached.topK, boundChars.get(cached.contextId) || cached.corpus?.length);
1470
1508
  // Sidecar wiped between runs: the gateway 404s BEFORE the 402 (nothing
1471
1509
  // paid). Never fail on a stale manifest — re-bind once and retry.
@@ -1506,8 +1544,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1506
1544
  // orders of magnitude above what was billed. directUsd is exact and
1507
1545
  // always present; savesVsDirect is the same number as a ratio.
1508
1546
  if (didSpill) {
1509
- spillSpend += receipt.billedUsd || 0;
1510
- spillDirect += typeof receipt.directUsd === 'number' ? receipt.directUsd : (receipt.billedUsd || 0);
1547
+ spill.spillSpend += receipt.billedUsd || 0;
1548
+ spill.spillDirect += typeof receipt.directUsd === 'number' ? receipt.directUsd : (receipt.billedUsd || 0);
1511
1549
  }
1512
1550
  sessionDirect += typeof receipt.directUsd === 'number'
1513
1551
  ? receipt.directUsd
@@ -1527,10 +1565,10 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1527
1565
  // the receipt lines go to a file (they corrupt a TUI). But the running
1528
1566
  // total should still be visible — so write it to the terminal TITLE via
1529
1567
  // an OSC escape, which updates the window/tab title without touching the
1530
- // TUI's content. `openzoo $0.0042 · 12 calls` in the title bar, live.
1568
+ // TUI's content. `openzoo $0.0042 · 12 calls` in the title bar, live.
1531
1569
  if (receipt.ok && typeof receipt.billedUsd === 'number') { paidCalls += 1; }
1532
1570
  if (sayFile) {
1533
- try { process.stderr.write(`]0;openzoo $${sessionSpent.toFixed(4)} · ${paidCalls} call${paidCalls === 1 ? '' : 's'}`); } catch { /* no tty */ }
1571
+ try { process.stderr.write(`]0;openzoo $${sessionSpent.toFixed(4)} · ${paidCalls} call${paidCalls === 1 ? '' : 's'}`); } catch { /* no tty */ }
1534
1572
  }
1535
1573
  scheduleRefresh(4000); // settlement lands on-chain in a few seconds
1536
1574
  }
@@ -1583,8 +1621,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1583
1621
  // night's debugging after a number that was never the input.
1584
1622
  const basis = x.counterfactualTokensUsed ?? lc.corpusTokens;
1585
1623
  log(`spill priced: ${x.pricing} · basis ${basis ?? '?'} tok vs sent ${lc.tokensBefore ?? '?'} -> ${lc.tokensAfter ?? '?'} · billed ${(x.billedUsd ?? 0).toFixed(5)} direct ${(x.directUsd ?? 0).toFixed(5)}`);
1586
- spillSpend += x.billedUsd;
1587
- spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1624
+ spill.spillSpend += x.billedUsd;
1625
+ spill.spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1588
1626
  }
1589
1627
  paidCalls += 1;
1590
1628
  if (viaTunnel) tunnelSpent += x.billedUsd;
@@ -1639,8 +1677,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1639
1677
  if (didSpill) {
1640
1678
  const lc = x.lecore || {};
1641
1679
  log(`spill priced (streamed): ${x.pricing} · basis ${x.counterfactualTokensUsed ?? '?'} tok vs sent ${lc.tokensBefore ?? '?'} -> ${lc.tokensAfter ?? '?'} · billed ${(x.billedUsd ?? 0).toFixed(5)} direct ${(x.directUsd ?? 0).toFixed(5)}`);
1642
- spillSpend += x.billedUsd;
1643
- spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1680
+ spill.spillSpend += x.billedUsd;
1681
+ spill.spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1644
1682
  }
1645
1683
  paidCalls += 1;
1646
1684
  if (viaTunnel) tunnelSpent += x.billedUsd;
package/lib/spill.js ADDED
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Spill-side bookkeeping that used to live (and die) inside proxy.js.
3
+ *
4
+ * Three things the status line needs, and that a live session was not getting:
5
+ * 1. A corpus ledger that accumulates bind + append + file bytes, persisted
6
+ * so a sidecar restart still knows how big the bound context is.
7
+ * 2. File-path extraction that matches Claude Code / Anthropic tool_use
8
+ * (and the OpenAI shape those requests are translated into).
9
+ * 3. Counters for spilled calls, file-bind events, and offloaded chars —
10
+ * incremented on the spill path, never on a pass-through.
11
+ *
12
+ * Kept as pure-enough helpers so test/spill.test.js can cover them without
13
+ * standing up the proxy or the gateway.
14
+ */
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ const PATH_KEYS = new Set([
20
+ 'file_path', 'path', 'target_file', 'filePath', 'filename', 'file',
21
+ 'targetFile', 'filepath',
22
+ ]);
23
+ const PATH_ARRAY_KEYS = new Set(['files', 'file_paths', 'paths', 'filePaths']);
24
+
25
+ export function boundCharsFile(home = os.homedir()) {
26
+ return process.env.OPENZOO_BOUND_CHARS_PATH
27
+ || path.join(home, '.openzoo', 'bound-chars.json');
28
+ }
29
+
30
+ /**
31
+ * Load ~/.openzoo/bound-chars.json into the maps the proxy holds.
32
+ * Missing / corrupt file is a cold start, not an error.
33
+ */
34
+ export function loadBoundChars(boundChars, extra = {}) {
35
+ const file = extra.file || boundCharsFile(extra.home);
36
+ let raw;
37
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { return { ok: false, reason: 'missing' }; }
38
+ let data;
39
+ try { data = JSON.parse(raw); } catch { return { ok: false, reason: 'corrupt' }; }
40
+ const chars = data?.chars && typeof data.chars === 'object' ? data.chars
41
+ : (data && typeof data === 'object' && !data.sessions && !data.files ? data : {});
42
+ for (const [id, n] of Object.entries(chars)) {
43
+ const v = Number(n);
44
+ if (id && Number.isFinite(v) && v > 0) boundChars.set(id, v);
45
+ }
46
+ if (extra.sessions && data?.sessions && typeof data.sessions === 'object') {
47
+ for (const [k, v] of Object.entries(data.sessions)) {
48
+ if (k && v && typeof v.contextId === 'string') extra.sessions.set(k, v);
49
+ }
50
+ }
51
+ if (extra.boundFiles && Array.isArray(data?.files)) {
52
+ for (const key of data.files) {
53
+ if (typeof key === 'string') extra.boundFiles.add(key);
54
+ }
55
+ }
56
+ return { ok: true, contexts: boundChars.size };
57
+ }
58
+
59
+ export function persistBoundChars(boundChars, extra = {}) {
60
+ const file = extra.file || boundCharsFile(extra.home);
61
+ try {
62
+ fs.mkdirSync(path.dirname(file), { recursive: true });
63
+ const payload = {
64
+ chars: Object.fromEntries(boundChars),
65
+ };
66
+ if (extra.sessions) payload.sessions = Object.fromEntries(extra.sessions);
67
+ if (extra.boundFiles) payload.files = [...extra.boundFiles];
68
+ const tmp = `${file}.tmp`;
69
+ fs.writeFileSync(tmp, JSON.stringify(payload));
70
+ fs.renameSync(tmp, file);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Accumulate bound bytes for a context.
79
+ *
80
+ * init — first bind: ledger becomes `chars` (the bound corpus size)
81
+ * add — append / file: ledger += chars
82
+ *
83
+ * Persist after every successful update so a crash mid-session keeps the count.
84
+ */
85
+ export function accumulateBoundChars(boundChars, contextId, chars, opts = {}) {
86
+ if (!contextId || !Number.isFinite(chars) || chars < 0) return boundChars.get(contextId) || 0;
87
+ const next = opts.init ? chars : (boundChars.get(contextId) || 0) + chars;
88
+ boundChars.set(contextId, next);
89
+ if (opts.sessionKey && opts.sessions) {
90
+ opts.sessions.set(opts.sessionKey, { contextId, chars: next });
91
+ }
92
+ if (opts.persist !== false) persistBoundChars(boundChars, opts);
93
+ return next;
94
+ }
95
+
96
+ /**
97
+ * Apply the bind/append/file rule in one place so spillTranscript and the
98
+ * tests cannot drift.
99
+ *
100
+ * First bind initializes to the conversation corpus; each append adds the
101
+ * delta; file bytes are added on top either way.
102
+ */
103
+ export function noteCorpusLedger(boundChars, {
104
+ contextId, reused, corpusChars = 0, deltaChars = 0, fileChars = 0, ...opts
105
+ } = {}) {
106
+ if (!contextId) return 0;
107
+ if (reused) {
108
+ if (deltaChars) accumulateBoundChars(boundChars, contextId, deltaChars, { ...opts, persist: false });
109
+ } else {
110
+ accumulateBoundChars(boundChars, contextId, corpusChars, { ...opts, init: true, persist: false });
111
+ }
112
+ if (fileChars) accumulateBoundChars(boundChars, contextId, fileChars, { ...opts, persist: false });
113
+ persistBoundChars(boundChars, opts);
114
+ return boundChars.get(contextId) || 0;
115
+ }
116
+
117
+ /** Expand ~ and resolve relative paths against cwd. Returns null if unusable. */
118
+ export function resolveReadablePath(p, cwd = process.cwd()) {
119
+ if (typeof p !== 'string') return null;
120
+ let s = p.trim();
121
+ if (!s || s.length > 1024 || /[\n\r]/.test(s)) return null;
122
+ if (/^https?:\/\//i.test(s)) return null;
123
+ if (s.startsWith('~/') || s === '~') s = path.join(os.homedir(), s.slice(1).replace(/^\//, '') || '');
124
+ if (!path.isAbsolute(s)) s = path.resolve(cwd, s);
125
+ return s;
126
+ }
127
+
128
+ function looksLikePath(s) {
129
+ if (typeof s !== 'string') return false;
130
+ const t = s.trim();
131
+ if (t.length < 2 || t.length > 1024 || /[\n\r]/.test(t)) return false;
132
+ if (/^https?:\/\//i.test(t)) return false;
133
+ return /[\\/]/.test(t) || /\.\w{1,10}$/.test(t) || t.startsWith('~') || t.startsWith('.');
134
+ }
135
+
136
+ function parseArgs(args) {
137
+ if (args == null) return {};
138
+ if (typeof args === 'object' && !Array.isArray(args)) return args;
139
+ if (typeof args === 'string') {
140
+ const t = args.trim();
141
+ if (!t) return {};
142
+ try { return JSON.parse(t); } catch { return {}; }
143
+ }
144
+ return {};
145
+ }
146
+
147
+ function collectFromValue(value, out, depth) {
148
+ if (depth > 6 || value == null) return;
149
+ if (typeof value === 'string') {
150
+ if (looksLikePath(value)) out.push(value);
151
+ const t = value.trim();
152
+ if ((t.startsWith('{') || t.startsWith('[')) && t.length < 100_000) {
153
+ try { collectFromValue(JSON.parse(t), out, depth + 1); } catch { /* not json */ }
154
+ }
155
+ return;
156
+ }
157
+ if (Array.isArray(value)) {
158
+ for (const x of value) collectFromValue(x, out, depth + 1);
159
+ return;
160
+ }
161
+ if (typeof value !== 'object') return;
162
+ for (const [k, v] of Object.entries(value)) {
163
+ if (PATH_KEYS.has(k) && typeof v === 'string' && v) out.push(v);
164
+ else if (PATH_ARRAY_KEYS.has(k) && Array.isArray(v)) {
165
+ for (const x of v) {
166
+ if (typeof x === 'string') out.push(x);
167
+ else collectFromValue(x, out, depth + 1);
168
+ }
169
+ } else if (k === 'input' || k === 'arguments' || k === 'params' || k === 'parameters') {
170
+ collectFromValue(typeof v === 'string' ? parseArgs(v) : v, out, depth + 1);
171
+ }
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Pull file paths out of a transcript that may still be Anthropic-shaped,
177
+ * already translated to OpenAI tool_calls, or a mix (Responses → chat).
178
+ *
179
+ * Structured fields only — never walks tool_result *bodies*, which are file
180
+ * contents and would harvest every import path in the source.
181
+ */
182
+ export function extractFilePaths(msgs) {
183
+ const raw = [];
184
+ if (!Array.isArray(msgs)) return [];
185
+ for (const m of msgs) {
186
+ if (!m || typeof m !== 'object') continue;
187
+ const blocks = Array.isArray(m.content) ? m.content : [];
188
+ for (const b of blocks) {
189
+ if (!b || typeof b !== 'object') continue;
190
+ if (b.input) collectFromValue(b.input, raw, 0);
191
+ for (const k of PATH_KEYS) {
192
+ if (typeof b[k] === 'string') raw.push(b[k]);
193
+ }
194
+ // tool_result: only structured content, never a long body string
195
+ if (b.type === 'tool_result' && b.content && typeof b.content === 'object') {
196
+ collectFromValue(b.content, raw, 0);
197
+ } else if (b.type === 'tool_result' && typeof b.content === 'string' && b.content.length < 512 && looksLikePath(b.content)) {
198
+ raw.push(b.content.trim());
199
+ }
200
+ }
201
+ const calls = [
202
+ ...(Array.isArray(m.tool_calls) ? m.tool_calls : []),
203
+ ...(m.function_call ? [m.function_call] : []),
204
+ ];
205
+ for (const c of calls) {
206
+ collectFromValue(parseArgs(c?.function?.arguments ?? c?.arguments), raw, 0);
207
+ if (c?.input) collectFromValue(c.input, raw, 0);
208
+ if (typeof c?.function?.name === 'string' && c.function.arguments == null && typeof c.name === 'string') {
209
+ collectFromValue(c, raw, 0);
210
+ }
211
+ }
212
+ }
213
+ const seen = new Set();
214
+ const out = [];
215
+ for (const p of raw) {
216
+ if (typeof p !== 'string') continue;
217
+ const t = p.trim();
218
+ if (!t || seen.has(t)) continue;
219
+ seen.add(t);
220
+ out.push(t);
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /**
226
+ * Read every new file the agent touched and return the corpus slice to bind.
227
+ *
228
+ * Read-only, size-capped, path+mtime deduped. Failures never throw — this
229
+ * runs on the request path.
230
+ *
231
+ * Always reports `file-bind N files / X bytes` or `file-bind 0 because …`
232
+ * so a silent empty extract cannot hide again.
233
+ */
234
+ export function filesForCorpus(msgs, {
235
+ boundFiles,
236
+ cwd = process.cwd(),
237
+ cap = Number(process.env.OPENZOO_BIND_FILE_MAX || 400_000),
238
+ disabled = process.env.OPENZOO_BIND_FILES === '0',
239
+ log = () => {},
240
+ statSync = (p) => fs.statSync(p),
241
+ readFileSync = (p) => fs.readFileSync(p, 'utf8'),
242
+ } = {}) {
243
+ if (disabled) {
244
+ log('file-bind 0 because OPENZOO_BIND_FILES=0');
245
+ return { text: '', files: 0, bytes: 0, reason: 'disabled' };
246
+ }
247
+ const paths = extractFilePaths(msgs);
248
+ if (!paths.length) {
249
+ log('file-bind 0 because no file paths in tool_use / tool_calls');
250
+ return { text: '', files: 0, bytes: 0, reason: 'no-paths' };
251
+ }
252
+ const seen = new Set();
253
+ const chunks = [];
254
+ let skippedCap = 0;
255
+ let skippedBound = 0;
256
+ let skippedMissing = 0;
257
+ let skippedRelative = 0;
258
+ for (const raw of paths) {
259
+ const p = resolveReadablePath(raw, cwd);
260
+ if (!p) { skippedRelative += 1; continue; }
261
+ if (seen.has(p)) continue;
262
+ seen.add(p);
263
+ try {
264
+ const st = statSync(p);
265
+ if (!st.isFile()) { skippedMissing += 1; continue; }
266
+ if (st.size > cap) { skippedCap += 1; continue; }
267
+ const key = `${p}:${st.mtimeMs}`;
268
+ if (boundFiles?.has(key)) { skippedBound += 1; continue; }
269
+ boundFiles?.add(key);
270
+ chunks.push(`FILE ${p}\n${readFileSync(p)}`);
271
+ } catch {
272
+ skippedMissing += 1;
273
+ }
274
+ }
275
+ if (!chunks.length) {
276
+ const why = skippedBound && !skippedMissing && !skippedCap
277
+ ? 'already bound'
278
+ : skippedCap && !skippedMissing
279
+ ? `over OPENZOO_BIND_FILE_MAX (${cap})`
280
+ : skippedMissing
281
+ ? 'unreadable or not a file'
282
+ : 'paths did not resolve';
283
+ log(`file-bind 0 because ${why}`);
284
+ return { text: '', files: 0, bytes: 0, reason: why };
285
+ }
286
+ const text = chunks.join('\n\n');
287
+ log(`file-bind ${chunks.length} files / ${text.length} bytes`);
288
+ return { text, files: chunks.length, bytes: text.length, reason: null };
289
+ }
290
+
291
+ /** Session counters the HUD reads off /v1/info. */
292
+ export function createSpillStats() {
293
+ return {
294
+ spillCalls: 0,
295
+ spilledChars: 0,
296
+ spillReuses: 0,
297
+ fileBinds: 0,
298
+ fileBindBytes: 0,
299
+ spillSpend: 0,
300
+ spillDirect: 0,
301
+ noteSpill({ corpusChars = 0, reused = false } = {}) {
302
+ this.spillCalls += 1;
303
+ this.spilledChars += corpusChars;
304
+ if (reused) this.spillReuses += 1;
305
+ },
306
+ noteFileBind(n, bytes = 0) {
307
+ if (!n) return;
308
+ this.fileBinds += n;
309
+ this.fileBindBytes += bytes;
310
+ this.spilledChars += bytes;
311
+ },
312
+ snapshot() {
313
+ return {
314
+ calls: this.spillCalls,
315
+ chars: this.spilledChars,
316
+ tokensApprox: Math.round(this.spilledChars / 4),
317
+ reusedBinds: this.spillReuses,
318
+ fileBinds: this.fileBinds,
319
+ fileBindBytes: this.fileBindBytes,
320
+ spend: this.spillSpend,
321
+ direct: this.spillDirect,
322
+ savedUsd: Math.max(0, this.spillDirect - this.spillSpend),
323
+ savingX: this.spillSpend > 0 ? Number((this.spillDirect / this.spillSpend).toFixed(4)) : null,
324
+ };
325
+ },
326
+ };
327
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.68",
4
- "description": "Local x402-paying proxy + MCP server for openzoo.fun 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.",
3
+ "version": "0.48.70",
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",
7
7
  "bin": {