openzoo 0.49.5 → 0.49.7

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
@@ -14,7 +14,11 @@ import { evmTokenBalance } from './evm.js';
14
14
  import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
15
15
  import {
16
16
  loadBoundChars, noteCorpusLedger, filesForCorpus, readFilesForCorpus, boundAbsFromKeys,
17
- createSpillStats, corpusCharsForSend, applySpillCut, msgText, hudDollarX,
17
+ createSpillStats, corpusCharsForSend, msgText, hudDollarX,
18
+ spillPricedLine,
19
+ planConversationBind, rememberSpillMemo, SPILL_CONTENT_ANCHOR_CHARS,
20
+ corpusRecall,
21
+ decideChatSpill, isOneShotCorpusAsk,
18
22
  } from './spill.js';
19
23
  import { rewritablePath, augmentModelList, ALIAS_IDS, rewriteChatModel, zooModelIds, CLASSIFY_MAX_TOKENS } from './models.js';
20
24
  import { forgetContext } from './contexts.js';
@@ -26,7 +30,7 @@ import { loadSessionSpend, saveSessionSpend } from './session.js';
26
30
  import { creditBalance, quotedPrices } from './info.js';
27
31
  import { subscriptionPublicView } from './subscription.js';
28
32
  import { priceHoldings } from './livestatus.js';
29
- import { receiptUsedCogs } from './racesettle.js';
33
+ import { receiptUsedCogs, receiptDirectUsd } from './racesettle.js';
30
34
  import { rewriteWrapClientError } from './wrap.js';
31
35
 
32
36
  const HOP_BY_HOP = new Set([
@@ -423,51 +427,67 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
423
427
  });
424
428
  };
425
429
 
426
- if (msgs.length < 6) {
427
- bindFilesInBackground('conversation under 6 messages, background');
428
- return null;
429
- }
430
-
431
430
  // LIVE SELF-TUNER. Env knobs seed the first cut; after cut+stub the proxy
432
431
  // scores the HUD dollar multiple (spill direct/billed) and retunes
433
432
  // keep/min-turns/budget (stubMore for SEARCH, not live file bodies)
434
433
  // in process memory so this request recuts when the green x is under 10.
435
434
  // No restart. OPENZOO_ADAPT=0 freezes the env defaults. The ask always
436
435
  // stays; we never drop below 2 real user/assistant turns to delete it.
436
+ //
437
+ // 1-model AND raced grokui AUTO both land here (same POST /chat/completions
438
+ // door). The old `msgs.length < 6` bail skipped fat 1-model hops � a 40k
439
+ // command-output turn with 4�5 messages never bound, so spent?direct.
440
+ // decideChatSpill is the shared gate: oversized ? bind prefix + system/tail
441
+ // + x-hrr-context; small ? passthrough. Race fields do not change it.
437
442
  const knownLedger = (sessionKey && spillMemo.get(sessionKey))
438
443
  || (sessionKey && sessionLedger.get(sessionKey))
439
444
  || null;
440
445
  const knownChars = knownLedger?.contextId
441
446
  ? (boundChars.get(knownLedger.contextId) || 0)
442
447
  : 0;
443
- const adapted = applySpillCut(msgs, {
448
+ const decision = decideChatSpill(body, {
444
449
  corpusChars: knownChars,
445
450
  boundAbs: previouslyBoundAbs,
451
+ recall: corpusRecall(typeof knownLedger?.corpus === 'string' ? knownLedger.corpus : ''),
446
452
  log,
447
453
  persist: true,
448
454
  dollarX: extra.dollarX ?? hudDollarX(stats || {}),
449
455
  lastSend: extra.lastSend,
456
+ minPrefixChars: BIND_MIN_CHARS,
450
457
  });
451
- if (adapted.cut <= adapted.firstSpillable) {
458
+ if (decision.mode === 'passthrough') {
459
+ bindFilesInBackground(decision.reason === 'no-cut'
460
+ ? 'no severable cut, files only'
461
+ : 'conversation under spill threshold, background');
462
+ return null;
463
+ }
464
+ if (decision.mode === 'stub-only') {
452
465
  bindFilesInBackground('no severable cut, files only');
453
466
  // Still forward stubbed/trimmed bodies so an un-severable storm is not
454
467
  // shipped at full size just because cutTranscript could not move.
455
- if (adapted.stubbed?.dropped || adapted.stubbed?.stubbed) {
456
- return {
457
- body: Buffer.from(JSON.stringify({ ...body, messages: adapted.stubbed.messages })),
458
- corpus: '',
459
- reused: false,
460
- savedBytes: 0,
461
- sent: adapted.stubbed.messages.length,
462
- msgs: msgs.length,
463
- };
464
- }
465
- return null;
468
+ return {
469
+ body: Buffer.from(JSON.stringify({ ...body, messages: decision.forwarded })),
470
+ corpus: '',
471
+ reused: false,
472
+ savedBytes: 0,
473
+ sent: decision.forwarded.length,
474
+ msgs: msgs.length,
475
+ };
476
+ }
477
+ // oneshot is handled in maybeCacheCorpus; if we reach it here, bind the
478
+ // extracted corpus the same way as a transcript prefix.
479
+ if (decision.mode === 'oneshot' && !decision.adapted) {
480
+ decision.adapted = {
481
+ cut: msgs.length - 1,
482
+ firstSpillable: Math.max(0, msgs.findIndex((m) => m?.role !== 'system')),
483
+ stubbed: { messages: decision.forwarded, stubbed: 0, dropped: 0 },
484
+ };
466
485
  }
486
+ const adapted = decision.adapted;
467
487
  const { cut, firstSpillable } = adapted;
468
488
  const stubbed = adapted.stubbed;
469
489
 
470
- const head = msgs.slice(0, firstSpillable); // system block, always kept
490
+ const head = decision.head.length ? decision.head : msgs.slice(0, firstSpillable); // system block, always kept
471
491
  // EVERY FILE THE AGENT TOUCHED, AT FULL SIZE.
472
492
  //
473
493
  // The saving ratio is corpus/sent, so on a fresh session — where the corpus
@@ -488,35 +508,24 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
488
508
  //
489
509
  // FILES RIDE THE BACKGROUND, NEVER THE CRITICAL PATH.
490
510
  //
491
- // The FIRST bind of a session is necessarily synchronous the request cannot
492
- // go until the context_id exists, because it travels as x-hrr-context. Folding
493
- // file bytes into that bind put a 400KB upload in front of the caller's turn,
494
- // which is the cold-bind stall this whole exercise was meant to remove: the
495
- // bind endpoint measures 0.34-0.48s on a 613KB corpus, and that is 0.34-0.48s
496
- // the user waits before a single token appears.
511
+ // The first conversation bind is also fire-and-forget: turn 1 may go out
512
+ // unspilled while the bind runs, then later turns recall a tail + contextId.
513
+ // Folding file bytes into a synchronous first bind used to put a 400KB
514
+ // upload in front of the caller's turn (0.34-0.48s on a 613KB corpus).
497
515
  //
498
- // Nothing recalls a file during the turn that read it � the model already has
516
+ // Nothing recalls a file during the turn that read it � the model already has
499
517
  // the tool result in its window. Files are only worth having bound for the
500
- // NEXT ask. So the conversation binds inline and the files are appended after
501
- // the fact, off the clock.
502
- const turns = msgs.slice(firstSpillable, cut).map(msgText).filter(Boolean).join('\n\n');
518
+ // NEXT ask, so they append after the conversation bind completes.
519
+ const turns = decision.prefix
520
+ || msgs.slice(firstSpillable, cut).map(msgText).filter(Boolean).join('\n\n');
503
521
  const corpus = turns;
504
- if (!sessionKey) sessionKey = corpus.slice(0, 2048);
522
+ if (!sessionKey) sessionKey = corpus.slice(0, SPILL_CONTENT_ANCHOR_CHARS);
505
523
 
506
- // FILES BIND EVEN WHEN THE CONVERSATION IS TOO SMALL TO SPILL.
507
- //
508
- // The threshold exists to stop us binding a two-line chat � it was never
509
- // meant to gate FILES. But bailing here skipped them entirely, so a fresh
510
- // session that reads a 200KB file bound nothing and scored 1.00x forever:
511
- // OBSERVED on a live session that read files all turn and never produced a
512
- // corpus, because its conversation stayed under the threshold the whole time.
513
- //
514
- // A file is worth binding on its own merit. So when the turns are too small
515
- // to spill but files exist, bind the files anyway � in the background,
516
- // against this session's context � and let this turn go unspilled. The corpus
517
- // is then waiting for the next ask.
518
- if (corpus.length <= BIND_MIN_CHARS) {
519
- bindFilesInBackground('conversation under spill threshold, background');
524
+ // Conversation prefix binds at any size. BIND_MIN_CHARS only gates the
525
+ // one-shot corpus+question path in maybeCacheCorpus � a real but small
526
+ // early-turn prefix used to be discarded here ("only large ones").
527
+ if (!corpus) {
528
+ bindFilesInBackground('empty conversation prefix, files only');
520
529
  return null;
521
530
  }
522
531
 
@@ -524,7 +533,7 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
524
533
  //
525
534
  // A transcript grows by one message per turn, so the whole-corpus hash misses
526
535
  // every time and the old code re-uploaded the ENTIRE prefix on every single
527
- // turn OBSERVED live: 0.4MB bound three turns running, a fresh context id
536
+ // turn OBSERVED live: 0.4MB bound three turns running, a fresh context id
528
537
  // each time, while only a few KB was actually new. Bind cost grew with
529
538
  // conversation length and was re-paid per message.
530
539
  //
@@ -537,7 +546,7 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
537
546
  // The anchor was the first 2KB of corpus, which works only because a
538
547
  // transcript's opening never changes. It is fragile in exactly the cases that
539
548
  // matter: two sessions that open identically (same system block, same first
540
- // instruction the norm for an agent) collide onto ONE bound context and
549
+ // instruction the norm for an agent) collide onto ONE bound context and
541
550
  // interleave their histories, and any edit near the top of a transcript
542
551
  // silently orphans the binding and re-uploads the whole thing.
543
552
  //
@@ -545,60 +554,109 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
545
554
  // back to the content anchor when it is not. Same memo, better key.
546
555
  // CAPTURED FROM A LIVE claude-cli/2.1.232 REQUEST, not guessed. The first
547
556
  // version of this checked x-session-id / x-claude-session-id /
548
- // metadata.user_id none of which Claude Code sends, so it silently fell
557
+ // metadata.user_id none of which Claude Code sends, so it silently fell
549
558
  // back to the content anchor on every request and the feature did nothing.
550
559
  // The real header list is:
551
560
  // anthropic-beta, anthropic-version, x-app, x-claude-code-session-id,
552
561
  // x-stainless-*
553
- const anchor = sessionKey;
554
- const persisted = sessionKey ? sessionLedger.get(sessionKey) : null;
555
- const prior = spillMemo.get(anchor) || (persisted?.contextId
556
- ? { contextId: persisted.contextId, hash: persisted.hash || '', corpus: null, restored: true }
557
- : null);
562
+ //
563
+ // COLD-BIND. Turn 1 may go unspilled (full messages, no x-hrr-context) while
564
+ // the first bind runs in the background � same spirit as file-bind, so the
565
+ // opening ask is not stalled. The in-flight/completed bind is memoized on
566
+ // the session key; later turns recall a tail + contextId. Subsequent
567
+ // appends stay fire-and-forget deltas.
568
+ let bindPlan = planConversationBind({
569
+ sessionKey,
570
+ corpus,
571
+ spillMemo,
572
+ sessionLedger,
573
+ });
574
+ const anchor = bindPlan.key || sessionKey;
575
+
576
+ if (bindPlan.action === 'cold-bind') {
577
+ const ready = bindCorpus(corpus, {
578
+ onStage: (stage, info) => {
579
+ if (stage === 'binding') log(`binding ${mb(info.bytes)}MB of transcript to holographic memory (background)...`);
580
+ },
581
+ }).then((b) => {
582
+ if (!b?.contextId) return b;
583
+ noteCorpusLedger(boundChars, {
584
+ contextId: b.contextId,
585
+ reused: false,
586
+ corpusChars: corpus.length,
587
+ deltaChars: 0,
588
+ fileChars: 0,
589
+ ...ledgerOpts(),
590
+ });
591
+ rememberSpillMemo(spillMemo, anchor, { corpus, contextId: b.contextId, hash: b.hash });
592
+ bindFilesInBackground('background', { appendTo: b.contextId, asAppend: true });
593
+ return b;
594
+ }).catch((e) => {
595
+ log(`bind failed (turn went unspilled): ${e.message}`);
596
+ const cur = spillMemo.get(anchor);
597
+ if (cur?.pending) spillMemo.delete(anchor);
598
+ return null;
599
+ });
600
+ rememberSpillMemo(spillMemo, anchor, { corpus, pending: true, ready });
601
+ return null;
602
+ }
603
+
604
+ if (bindPlan.action === 'await-pending') {
605
+ const finished = await bindPlan.ready.catch((e) => {
606
+ log(`bind failed (history may lag one turn): ${e.message}`);
607
+ return null;
608
+ });
609
+ if (!finished?.contextId) {
610
+ bindFilesInBackground('first bind failed, files only');
611
+ return null;
612
+ }
613
+ bindPlan = planConversationBind({
614
+ sessionKey: anchor,
615
+ corpus,
616
+ spillMemo,
617
+ sessionLedger,
618
+ });
619
+ }
620
+
621
+ if (bindPlan.action !== 'recall' || !bindPlan.contextId) {
622
+ bindFilesInBackground('no context yet, files only');
623
+ return null;
624
+ }
625
+
558
626
  let bind;
559
627
  let deltaChars = 0;
560
628
  let appended = false;
561
- if (prior?.restored && prior.contextId) {
629
+ if (bindPlan.restored && bindPlan.contextId) {
562
630
  // Sidecar came back up: we still know the context_id and the accumulated
563
631
  // char count, but not the prior prefix string, so we cannot slice a delta.
564
632
  // Re-append the current prefix (some overlap is harmless) and keep the
565
- // restored ledger � do not add corpus.length again.
633
+ // restored ledger � do not add corpus.length again.
566
634
  void bindCorpus(corpus, {
567
- appendTo: prior.contextId,
635
+ appendTo: bindPlan.contextId,
568
636
  onStage: (stage, info) => {
569
- if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${prior.contextId} (restored session, background)`);
637
+ if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${bindPlan.contextId} (restored session, background)`);
570
638
  },
571
639
  }).catch((e) => log(`append failed (history may lag one turn): ${e.message}`));
572
640
  appended = true;
573
- bind = { contextId: prior.contextId, hash: prior.hash, reused: true, bytes: 0 };
574
- } else if (prior && typeof prior.corpus === 'string' && corpus.startsWith(prior.corpus) && corpus.length > prior.corpus.length) {
575
- const delta = corpus.slice(prior.corpus.length);
641
+ bind = { contextId: bindPlan.contextId, hash: bindPlan.hash, reused: true, bytes: 0 };
642
+ } else if (bindPlan.append && bindPlan.delta) {
643
+ const delta = bindPlan.delta;
576
644
  deltaChars = delta.length;
577
- // FIRE AND FORGET. This delta is history for FUTURE turns � the answer
645
+ // FIRE AND FORGET. This delta is history for FUTURE turns � the answer
578
646
  // being generated right now is served from the tail plus what is already
579
647
  // bound, so waiting on the upload buys nothing and costs the user the
580
648
  // round trip on every single turn. The context id is already known, so
581
649
  // nothing is lost by not waiting for it.
582
- //
583
- // The FIRST bind is deliberately NOT async: the request must carry
584
- // x-hrr-context, and that id does not exist until the bind returns. Firing
585
- // that one off would send the opening turn with no context at all � a
586
- // silently worse answer traded for a shorter pause, which is the wrong way
587
- // round.
588
650
  void bindCorpus(delta, {
589
- appendTo: prior.contextId,
651
+ appendTo: bindPlan.contextId,
590
652
  onStage: (stage, info) => {
591
- if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${prior.contextId} (delta, background)`);
653
+ if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${bindPlan.contextId} (delta, background)`);
592
654
  },
593
655
  }).catch((e) => log(`append failed (history may lag one turn): ${e.message}`));
594
656
  appended = true;
595
- bind = { contextId: prior.contextId, hash: prior.hash, reused: true, bytes: delta.length };
657
+ bind = { contextId: bindPlan.contextId, hash: bindPlan.hash, reused: true, bytes: delta.length };
596
658
  } else {
597
- bind = await bindCorpus(corpus, {
598
- onStage: (stage, info) => {
599
- if (stage === 'binding') log(`binding ${mb(info.bytes)}MB of transcript to holographic memory...`);
600
- },
601
- });
659
+ bind = { contextId: bindPlan.contextId, hash: bindPlan.hash, reused: true, bytes: 0 };
602
660
  }
603
661
  // CONVERSATION LEDGER � every successful bind AND append, not only when
604
662
  // files exist. First bind initializes to the bound corpus size; each append
@@ -618,8 +676,7 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
618
676
  // uploads each version exactly once no matter how often the agent re-reads it.
619
677
  // Read + readdir are inside setImmediate � they must not run before send().
620
678
  bindFilesInBackground('background', { appendTo: bind.contextId, asAppend: true });
621
- spillMemo.set(anchor, { corpus, contextId: bind.contextId, hash: bind.hash });
622
- if (spillMemo.size > 32) spillMemo.delete(spillMemo.keys().next().value);
679
+ rememberSpillMemo(spillMemo, anchor, { corpus, contextId: bind.contextId, hash: bind.hash });
623
680
  // Count the tail that is actually forwarded after stub/trim � older
624
681
  // continue-turn rounds after the ask may have been dropped, so
625
682
  // msgs.length - cut would keep lastSend growing with the raw pile.
@@ -671,7 +728,10 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
671
728
  const topK = Math.max(4, Math.min(12, Math.round(budget / 320)));
672
729
 
673
730
  return {
674
- body: Buffer.from(JSON.stringify({ ...body, messages: [...head, ...stubbed.messages.slice(cut)] })),
731
+ body: Buffer.from(JSON.stringify({
732
+ ...body,
733
+ messages: decision.forwarded || [...head, ...stubbed.messages.slice(cut)],
734
+ })),
675
735
  topK,
676
736
  contextId: bind.contextId,
677
737
  hash: bind.hash,
@@ -707,10 +767,10 @@ async function maybeCacheCorpus(req, bodyBuf, log, stats, extra = {}) {
707
767
  // the ask verbatim. Anything else (an agent transcript) falls through to the
708
768
  // transcript spill, which used to be a silent no-op.
709
769
  const last = msgs[msgs.length - 1];
710
- const oneShot = typeof last?.content === 'string'
711
- && last.content.length > BIND_MIN_CHARS
712
- && last.content.lastIndexOf('\n\n') >= BIND_MIN_CHARS;
713
- if (!oneShot) return spillTranscript(body, log, req, stats, extra);
770
+ // Same gate decideChatSpill uses 1-model and race share it. zoo_ask
771
+ // stays on the corpus+question bind; everything else (including grokui
772
+ // AUTO, raced or not) falls through to spillTranscript ? decideChatSpill.
773
+ if (!isOneShotCorpusAsk(msgs, BIND_MIN_CHARS)) return spillTranscript(body, log, req, stats, extra);
714
774
  const cut = last.content.lastIndexOf('\n\n');
715
775
  const corpus = last.content.slice(0, cut);
716
776
  const ask = last.content.slice(cut + 2).trim();
@@ -793,7 +853,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
793
853
  say(`session restored: $${sessionSpent.toFixed(6)} � ${paidCalls} paid call${paidCalls === 1 ? '' : 's'}`);
794
854
  }
795
855
  process.on('exit', rememberSpend);
796
- const MARKUP = 3; // confirmed constant, see .claude/wiki.md "Margin needs a like-for-like denominator"
797
856
  const noteQuote = (x) => {
798
857
  const billed = Number(x?.billedUsd);
799
858
  if (!Number.isFinite(billed) || billed < 0) return;
@@ -901,7 +960,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
901
960
  refreshPrices();
902
961
  const money = walletMoney();
903
962
  res.writeHead(200, { 'content-type': 'application/json' });
963
+ // Same package.json the startup banner reads � grokui-app refuses to
964
+ // attach to a leftover :8402 whose version is older than it shipped with.
965
+ const { version } = JSON.parse(
966
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
967
+ );
904
968
  res.end(JSON.stringify({
969
+ version,
905
970
  spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls,
906
971
  creditUsd, chainUsd: money.chainUsd, lastQuoteUsd,
907
972
  subscription: subscriptionPublicView(),
@@ -1519,30 +1584,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1519
1584
  if (paid && receipt) {
1520
1585
  if (receipt.ok && typeof receipt.billedUsd === 'number') {
1521
1586
  sessionSpent += receipt.billedUsd;
1522
- // cogs: no per-call field for it, but MARKUP is a known constant
1523
- // (3x confirmed against the gateway's own margin math), and
1524
- // billedUsd = cogs * markup on a straight-markup call. Close enough
1525
- // on a counterfactual (leCore-discounted) call too since markup is
1526
- // still the ceiling those get capped against.
1527
- // Prefer the gateway's own cogsUsd. Deriving it as billedUsd/MARKUP
1528
- // is only correct on a straight-markup call: under counterfactual
1529
- // pricing billedUsd is min(direct×discount, markupUsd), so the
1530
- // division understates cost and overstates margin.
1531
- sessionCogs += receiptUsedCogs(receipt, MARKUP);
1587
+ // Wallet path: no 3x. Prefer extra.cogsUsd / billedUsd / directUsd /
1588
+ // savedUsd from the 402. billedUsd is the OpenRouter price (plus
1589
+ // zoo's 33% of savings when the caller beat direct).
1590
+ sessionCogs += receiptUsedCogs(receipt);
1532
1591
  noteQuote(receipt);
1533
1592
  // direct = what answering this WITHOUT the zoo would have cost. On an
1534
1593
  // attach call that is the whole bound corpus, which is why it can be
1535
- // orders of magnitude above what was billed. directUsd is exact and
1536
- // always present; savesVsDirect is the same number as a ratio.
1594
+ // orders of magnitude above what was billed. Read extra.directUsd /
1595
+ // extra.savedUsd; do not invent billed * 3.
1537
1596
  if (didSpill) {
1538
1597
  spill.spillSpend += receipt.billedUsd || 0;
1539
- spill.spillDirect += typeof receipt.directUsd === 'number' ? receipt.directUsd : (receipt.billedUsd || 0);
1598
+ spill.spillDirect += receiptDirectUsd(receipt);
1540
1599
  }
1541
- sessionDirect += typeof receipt.directUsd === 'number'
1542
- ? receipt.directUsd
1543
- : typeof receipt.savesVsDirect === 'number'
1544
- ? receipt.savesVsDirect * receipt.billedUsd
1545
- : receipt.billedUsd;
1600
+ sessionDirect += receiptDirectUsd(receipt);
1546
1601
  // The public-URL ceiling meters only public-origin spend — your own
1547
1602
  // local calls never eat into it.
1548
1603
  if (viaTunnel) tunnelSpent += receipt.billedUsd;
@@ -1599,23 +1654,19 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1599
1654
  if (!paid && data?.x402 && typeof data.x402.billedUsd === 'number') {
1600
1655
  const x = data.x402;
1601
1656
  sessionSpent += x.billedUsd;
1602
- sessionCogs += receiptUsedCogs(x, MARKUP);
1657
+ sessionCogs += receiptUsedCogs(x);
1603
1658
  noteQuote(x);
1604
- sessionDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1659
+ sessionDirect += receiptDirectUsd(x);
1605
1660
  if (didSpill) {
1606
1661
  // THE number that settles why a spilled call did or did not save:
1607
1662
  // the gateway only prices a counterfactual when corpusTokens >
1608
1663
  // promptTokens, so a tail that rivals the corpus silently falls
1609
- // back to markup and direct collapses onto billed.
1610
- const lc = x.lecore || {};
1611
- // counterfactualTokensUsed is the basis the gateway ACTUALLY priced
1612
- // on. lecore.corpusTokens is often absent and reading it printed
1613
- // 'corpus ?' on calls that were pricing fine — which sent a whole
1614
- // night's debugging after a number that was never the input.
1615
- const basis = x.counterfactualTokensUsed ?? lc.corpusTokens;
1616
- 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)}`);
1664
+ // back to at-cost and direct collapses onto billed.
1665
+ // Tell-line prints the gateway's actual counterfactual only.
1666
+ // Never fall back to lecore.corpusTokens (often == tokensBefore).
1667
+ log(spillPricedLine(x));
1617
1668
  spill.spillSpend += x.billedUsd;
1618
- spill.spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1669
+ spill.spillDirect += receiptDirectUsd(x);
1619
1670
  }
1620
1671
  paidCalls += 1;
1621
1672
  if (viaTunnel) tunnelSpent += x.billedUsd;
@@ -1665,15 +1716,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1665
1716
  const meterStreamed = (x) => {
1666
1717
  if (paid || typeof x?.billedUsd !== 'number') return;
1667
1718
  sessionSpent += x.billedUsd;
1668
- sessionCogs += receiptUsedCogs(x, MARKUP);
1719
+ sessionCogs += receiptUsedCogs(x);
1669
1720
  noteQuote(x);
1670
- sessionDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1721
+ sessionDirect += receiptDirectUsd(x);
1671
1722
  if (typeof x.actualUsd === 'number' && x.actualUsd >= 0) { sessionActual += x.actualUsd; actualCalls += 1; billedWithActual += x.billedUsd || 0; }
1672
1723
  if (didSpill) {
1673
- const lc = x.lecore || {};
1674
- 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)}`);
1724
+ log(spillPricedLine(x, { streamed: true }));
1675
1725
  spill.spillSpend += x.billedUsd;
1676
- spill.spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1726
+ spill.spillDirect += receiptDirectUsd(x);
1677
1727
  }
1678
1728
  paidCalls += 1;
1679
1729
  if (viaTunnel) tunnelSpent += x.billedUsd;
package/lib/racesettle.js CHANGED
@@ -155,13 +155,29 @@ export function capRaceByCredit(n, { creditUsd, quoteUsd } = {}) {
155
155
  * House cost from the receipt. Do not subtract race_unused — unused
156
156
  * grant-back is not a user refund, and shrinking cogs would hide house loss.
157
157
  * Does not clamp to billed — HUD embers when cogs exceed what was paid.
158
+ *
159
+ * Wallet path: there is no 3× markup. Prefer the gateway's cogsUsd; otherwise
160
+ * billedUsd (OpenRouter price, plus zoo's 33% of savings when the 402 has any).
158
161
  */
159
- export function receiptUsedCogs(x, markup = 3) {
162
+ export function receiptUsedCogs(x) {
160
163
  if (!x || typeof x !== 'object') return 0;
161
- const billedRaw = Number(x.billedUsd);
162
- const billedOk = Number.isFinite(billedRaw) && billedRaw >= 0;
163
164
  if (typeof x.cogsUsd === 'number' && Number.isFinite(x.cogsUsd)) return x.cogsUsd;
164
- return billedOk ? billedRaw / markup : 0;
165
+ const billedRaw = Number(x.billedUsd);
166
+ return Number.isFinite(billedRaw) && billedRaw >= 0 ? billedRaw : 0;
167
+ }
168
+
169
+ /** Counterfactual from the 402: extra.directUsd, else billed + savedUsd. */
170
+ export function receiptDirectUsd(x) {
171
+ if (typeof x?.directUsd === 'number' && Number.isFinite(x.directUsd)) return x.directUsd;
172
+ const billed = Number(x?.billedUsd);
173
+ const billedOk = Number.isFinite(billed) && billed >= 0;
174
+ if (typeof x?.savedUsd === 'number' && Number.isFinite(x.savedUsd) && billedOk) {
175
+ return billed + x.savedUsd;
176
+ }
177
+ if (typeof x?.savesVsDirect === 'number' && Number.isFinite(x.savesVsDirect) && billedOk) {
178
+ return x.savesVsDirect * billed;
179
+ }
180
+ return billedOk ? billed : 0;
165
181
  }
166
182
 
167
183
  /**
@@ -169,12 +185,10 @@ export function receiptUsedCogs(x, markup = 3) {
169
185
  * first-call rewrite, never a race_unused user refund.
170
186
  * cogs is the house cost on that receipt (HUD embers when cogs > spent).
171
187
  */
172
- export function meterRaceReceipt(x, markup = 3) {
188
+ export function meterRaceReceipt(x) {
173
189
  const billed = Number(x?.billedUsd);
174
190
  const spentUsd = Number.isFinite(billed) ? billed : 0;
175
- const usedCogs = receiptUsedCogs(x, markup);
176
- const direct = typeof x?.directUsd === 'number' ? x.directUsd : spentUsd;
177
- return { spentUsd, cogsUsd: usedCogs, directUsd: direct };
191
+ return { spentUsd, cogsUsd: receiptUsedCogs(x), directUsd: receiptDirectUsd(x) };
178
192
  }
179
193
 
180
194
  export function inferRaceTier(models, fallback = 'medium') {