openzoo 0.49.4 → 0.49.6

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
@@ -15,6 +15,8 @@ import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
15
15
  import {
16
16
  loadBoundChars, noteCorpusLedger, filesForCorpus, readFilesForCorpus, boundAbsFromKeys,
17
17
  createSpillStats, corpusCharsForSend, applySpillCut, msgText, hudDollarX,
18
+ spillPricedLine,
19
+ planConversationBind, rememberSpillMemo, SPILL_CONTENT_ANCHOR_CHARS,
18
20
  } from './spill.js';
19
21
  import { rewritablePath, augmentModelList, ALIAS_IDS, rewriteChatModel, zooModelIds, CLASSIFY_MAX_TOKENS } from './models.js';
20
22
  import { forgetContext } from './contexts.js';
@@ -26,7 +28,7 @@ import { loadSessionSpend, saveSessionSpend } from './session.js';
26
28
  import { creditBalance, quotedPrices } from './info.js';
27
29
  import { subscriptionPublicView } from './subscription.js';
28
30
  import { priceHoldings } from './livestatus.js';
29
- import { receiptUsedCogs } from './racesettle.js';
31
+ import { receiptUsedCogs, receiptDirectUsd } from './racesettle.js';
30
32
  import { rewriteWrapClientError } from './wrap.js';
31
33
 
32
34
  const HOP_BY_HOP = new Set([
@@ -423,11 +425,6 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
423
425
  });
424
426
  };
425
427
 
426
- if (msgs.length < 6) {
427
- bindFilesInBackground('conversation under 6 messages, background');
428
- return null;
429
- }
430
-
431
428
  // LIVE SELF-TUNER. Env knobs seed the first cut; after cut+stub the proxy
432
429
  // scores the HUD dollar multiple (spill direct/billed) and retunes
433
430
  // keep/min-turns/budget (stubMore for SEARCH, not live file bodies)
@@ -488,35 +485,23 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
488
485
  //
489
486
  // FILES RIDE THE BACKGROUND, NEVER THE CRITICAL PATH.
490
487
  //
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.
488
+ // The first conversation bind is also fire-and-forget: turn 1 may go out
489
+ // unspilled while the bind runs, then later turns recall a tail + contextId.
490
+ // Folding file bytes into a synchronous first bind used to put a 400KB
491
+ // upload in front of the caller's turn (0.34-0.48s on a 613KB corpus).
497
492
  //
498
- // Nothing recalls a file during the turn that read it � the model already has
493
+ // Nothing recalls a file during the turn that read it � the model already has
499
494
  // 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.
495
+ // NEXT ask, so they append after the conversation bind completes.
502
496
  const turns = msgs.slice(firstSpillable, cut).map(msgText).filter(Boolean).join('\n\n');
503
497
  const corpus = turns;
504
- if (!sessionKey) sessionKey = corpus.slice(0, 2048);
498
+ if (!sessionKey) sessionKey = corpus.slice(0, SPILL_CONTENT_ANCHOR_CHARS);
505
499
 
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');
500
+ // Conversation prefix binds at any size. BIND_MIN_CHARS only gates the
501
+ // one-shot corpus+question path in maybeCacheCorpus � a real but small
502
+ // early-turn prefix used to be discarded here ("only large ones").
503
+ if (!corpus) {
504
+ bindFilesInBackground('empty conversation prefix, files only');
520
505
  return null;
521
506
  }
522
507
 
@@ -524,7 +509,7 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
524
509
  //
525
510
  // A transcript grows by one message per turn, so the whole-corpus hash misses
526
511
  // 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
512
+ // turn OBSERVED live: 0.4MB bound three turns running, a fresh context id
528
513
  // each time, while only a few KB was actually new. Bind cost grew with
529
514
  // conversation length and was re-paid per message.
530
515
  //
@@ -537,7 +522,7 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
537
522
  // The anchor was the first 2KB of corpus, which works only because a
538
523
  // transcript's opening never changes. It is fragile in exactly the cases that
539
524
  // matter: two sessions that open identically (same system block, same first
540
- // instruction the norm for an agent) collide onto ONE bound context and
525
+ // instruction the norm for an agent) collide onto ONE bound context and
541
526
  // interleave their histories, and any edit near the top of a transcript
542
527
  // silently orphans the binding and re-uploads the whole thing.
543
528
  //
@@ -545,60 +530,109 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
545
530
  // back to the content anchor when it is not. Same memo, better key.
546
531
  // CAPTURED FROM A LIVE claude-cli/2.1.232 REQUEST, not guessed. The first
547
532
  // 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
533
+ // metadata.user_id none of which Claude Code sends, so it silently fell
549
534
  // back to the content anchor on every request and the feature did nothing.
550
535
  // The real header list is:
551
536
  // anthropic-beta, anthropic-version, x-app, x-claude-code-session-id,
552
537
  // 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);
538
+ //
539
+ // COLD-BIND. Turn 1 may go unspilled (full messages, no x-hrr-context) while
540
+ // the first bind runs in the background � same spirit as file-bind, so the
541
+ // opening ask is not stalled. The in-flight/completed bind is memoized on
542
+ // the session key; later turns recall a tail + contextId. Subsequent
543
+ // appends stay fire-and-forget deltas.
544
+ let bindPlan = planConversationBind({
545
+ sessionKey,
546
+ corpus,
547
+ spillMemo,
548
+ sessionLedger,
549
+ });
550
+ const anchor = bindPlan.key || sessionKey;
551
+
552
+ if (bindPlan.action === 'cold-bind') {
553
+ const ready = bindCorpus(corpus, {
554
+ onStage: (stage, info) => {
555
+ if (stage === 'binding') log(`binding ${mb(info.bytes)}MB of transcript to holographic memory (background)...`);
556
+ },
557
+ }).then((b) => {
558
+ if (!b?.contextId) return b;
559
+ noteCorpusLedger(boundChars, {
560
+ contextId: b.contextId,
561
+ reused: false,
562
+ corpusChars: corpus.length,
563
+ deltaChars: 0,
564
+ fileChars: 0,
565
+ ...ledgerOpts(),
566
+ });
567
+ rememberSpillMemo(spillMemo, anchor, { corpus, contextId: b.contextId, hash: b.hash });
568
+ bindFilesInBackground('background', { appendTo: b.contextId, asAppend: true });
569
+ return b;
570
+ }).catch((e) => {
571
+ log(`bind failed (turn went unspilled): ${e.message}`);
572
+ const cur = spillMemo.get(anchor);
573
+ if (cur?.pending) spillMemo.delete(anchor);
574
+ return null;
575
+ });
576
+ rememberSpillMemo(spillMemo, anchor, { corpus, pending: true, ready });
577
+ return null;
578
+ }
579
+
580
+ if (bindPlan.action === 'await-pending') {
581
+ const finished = await bindPlan.ready.catch((e) => {
582
+ log(`bind failed (history may lag one turn): ${e.message}`);
583
+ return null;
584
+ });
585
+ if (!finished?.contextId) {
586
+ bindFilesInBackground('first bind failed, files only');
587
+ return null;
588
+ }
589
+ bindPlan = planConversationBind({
590
+ sessionKey: anchor,
591
+ corpus,
592
+ spillMemo,
593
+ sessionLedger,
594
+ });
595
+ }
596
+
597
+ if (bindPlan.action !== 'recall' || !bindPlan.contextId) {
598
+ bindFilesInBackground('no context yet, files only');
599
+ return null;
600
+ }
601
+
558
602
  let bind;
559
603
  let deltaChars = 0;
560
604
  let appended = false;
561
- if (prior?.restored && prior.contextId) {
605
+ if (bindPlan.restored && bindPlan.contextId) {
562
606
  // Sidecar came back up: we still know the context_id and the accumulated
563
607
  // char count, but not the prior prefix string, so we cannot slice a delta.
564
608
  // Re-append the current prefix (some overlap is harmless) and keep the
565
- // restored ledger � do not add corpus.length again.
609
+ // restored ledger � do not add corpus.length again.
566
610
  void bindCorpus(corpus, {
567
- appendTo: prior.contextId,
611
+ appendTo: bindPlan.contextId,
568
612
  onStage: (stage, info) => {
569
- if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${prior.contextId} (restored session, background)`);
613
+ if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${bindPlan.contextId} (restored session, background)`);
570
614
  },
571
615
  }).catch((e) => log(`append failed (history may lag one turn): ${e.message}`));
572
616
  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);
617
+ bind = { contextId: bindPlan.contextId, hash: bindPlan.hash, reused: true, bytes: 0 };
618
+ } else if (bindPlan.append && bindPlan.delta) {
619
+ const delta = bindPlan.delta;
576
620
  deltaChars = delta.length;
577
- // FIRE AND FORGET. This delta is history for FUTURE turns � the answer
621
+ // FIRE AND FORGET. This delta is history for FUTURE turns � the answer
578
622
  // being generated right now is served from the tail plus what is already
579
623
  // bound, so waiting on the upload buys nothing and costs the user the
580
624
  // round trip on every single turn. The context id is already known, so
581
625
  // 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
626
  void bindCorpus(delta, {
589
- appendTo: prior.contextId,
627
+ appendTo: bindPlan.contextId,
590
628
  onStage: (stage, info) => {
591
- if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${prior.contextId} (delta, background)`);
629
+ if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${bindPlan.contextId} (delta, background)`);
592
630
  },
593
631
  }).catch((e) => log(`append failed (history may lag one turn): ${e.message}`));
594
632
  appended = true;
595
- bind = { contextId: prior.contextId, hash: prior.hash, reused: true, bytes: delta.length };
633
+ bind = { contextId: bindPlan.contextId, hash: bindPlan.hash, reused: true, bytes: delta.length };
596
634
  } 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
- });
635
+ bind = { contextId: bindPlan.contextId, hash: bindPlan.hash, reused: true, bytes: 0 };
602
636
  }
603
637
  // CONVERSATION LEDGER � every successful bind AND append, not only when
604
638
  // files exist. First bind initializes to the bound corpus size; each append
@@ -618,8 +652,7 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
618
652
  // uploads each version exactly once no matter how often the agent re-reads it.
619
653
  // Read + readdir are inside setImmediate � they must not run before send().
620
654
  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);
655
+ rememberSpillMemo(spillMemo, anchor, { corpus, contextId: bind.contextId, hash: bind.hash });
623
656
  // Count the tail that is actually forwarded after stub/trim � older
624
657
  // continue-turn rounds after the ask may have been dropped, so
625
658
  // msgs.length - cut would keep lastSend growing with the raw pile.
@@ -793,7 +826,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
793
826
  say(`session restored: $${sessionSpent.toFixed(6)} � ${paidCalls} paid call${paidCalls === 1 ? '' : 's'}`);
794
827
  }
795
828
  process.on('exit', rememberSpend);
796
- const MARKUP = 3; // confirmed constant, see .claude/wiki.md "Margin needs a like-for-like denominator"
797
829
  const noteQuote = (x) => {
798
830
  const billed = Number(x?.billedUsd);
799
831
  if (!Number.isFinite(billed) || billed < 0) return;
@@ -1519,30 +1551,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1519
1551
  if (paid && receipt) {
1520
1552
  if (receipt.ok && typeof receipt.billedUsd === 'number') {
1521
1553
  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);
1554
+ // Wallet path: no 3x. Prefer extra.cogsUsd / billedUsd / directUsd /
1555
+ // savedUsd from the 402. billedUsd is the OpenRouter price (plus
1556
+ // zoo's 33% of savings when the caller beat direct).
1557
+ sessionCogs += receiptUsedCogs(receipt);
1532
1558
  noteQuote(receipt);
1533
1559
  // direct = what answering this WITHOUT the zoo would have cost. On an
1534
1560
  // 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.
1561
+ // orders of magnitude above what was billed. Read extra.directUsd /
1562
+ // extra.savedUsd; do not invent billed * 3.
1537
1563
  if (didSpill) {
1538
1564
  spill.spillSpend += receipt.billedUsd || 0;
1539
- spill.spillDirect += typeof receipt.directUsd === 'number' ? receipt.directUsd : (receipt.billedUsd || 0);
1565
+ spill.spillDirect += receiptDirectUsd(receipt);
1540
1566
  }
1541
- sessionDirect += typeof receipt.directUsd === 'number'
1542
- ? receipt.directUsd
1543
- : typeof receipt.savesVsDirect === 'number'
1544
- ? receipt.savesVsDirect * receipt.billedUsd
1545
- : receipt.billedUsd;
1567
+ sessionDirect += receiptDirectUsd(receipt);
1546
1568
  // The public-URL ceiling meters only public-origin spend — your own
1547
1569
  // local calls never eat into it.
1548
1570
  if (viaTunnel) tunnelSpent += receipt.billedUsd;
@@ -1599,23 +1621,19 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1599
1621
  if (!paid && data?.x402 && typeof data.x402.billedUsd === 'number') {
1600
1622
  const x = data.x402;
1601
1623
  sessionSpent += x.billedUsd;
1602
- sessionCogs += receiptUsedCogs(x, MARKUP);
1624
+ sessionCogs += receiptUsedCogs(x);
1603
1625
  noteQuote(x);
1604
- sessionDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1626
+ sessionDirect += receiptDirectUsd(x);
1605
1627
  if (didSpill) {
1606
1628
  // THE number that settles why a spilled call did or did not save:
1607
1629
  // the gateway only prices a counterfactual when corpusTokens >
1608
1630
  // 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)}`);
1631
+ // back to at-cost and direct collapses onto billed.
1632
+ // Tell-line prints the gateway's actual counterfactual only.
1633
+ // Never fall back to lecore.corpusTokens (often == tokensBefore).
1634
+ log(spillPricedLine(x));
1617
1635
  spill.spillSpend += x.billedUsd;
1618
- spill.spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1636
+ spill.spillDirect += receiptDirectUsd(x);
1619
1637
  }
1620
1638
  paidCalls += 1;
1621
1639
  if (viaTunnel) tunnelSpent += x.billedUsd;
@@ -1665,15 +1683,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1665
1683
  const meterStreamed = (x) => {
1666
1684
  if (paid || typeof x?.billedUsd !== 'number') return;
1667
1685
  sessionSpent += x.billedUsd;
1668
- sessionCogs += receiptUsedCogs(x, MARKUP);
1686
+ sessionCogs += receiptUsedCogs(x);
1669
1687
  noteQuote(x);
1670
- sessionDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1688
+ sessionDirect += receiptDirectUsd(x);
1671
1689
  if (typeof x.actualUsd === 'number' && x.actualUsd >= 0) { sessionActual += x.actualUsd; actualCalls += 1; billedWithActual += x.billedUsd || 0; }
1672
1690
  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)}`);
1691
+ log(spillPricedLine(x, { streamed: true }));
1675
1692
  spill.spillSpend += x.billedUsd;
1676
- spill.spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1693
+ spill.spillDirect += receiptDirectUsd(x);
1677
1694
  }
1678
1695
  paidCalls += 1;
1679
1696
  if (viaTunnel) tunnelSpent += x.billedUsd;
package/lib/racesettle.js CHANGED
@@ -8,6 +8,62 @@
8
8
 
9
9
  export const FLY_GATEWAY_HOST = 'x402-tokens.fly.dev';
10
10
  export const RACE_NO_CREDIT = '(race: not enough prepaid credit — shrink N or top up, rather than fire on $0)';
11
+ /** Low end of the pitched 5–10× vs frontier. Session green HUD below this recuts Y. */
12
+ export const RACE_HUD_TARGET = 5;
13
+
14
+ const TIER_DOWN = {
15
+ 'grok4.6': 'medium',
16
+ grok46: 'medium',
17
+ expensive: 'medium',
18
+ medium: 'cheap',
19
+ cheap: 'cheap',
20
+ };
21
+
22
+ export function cheaperRaceTier(tier) {
23
+ const t = String(tier || 'medium');
24
+ return TIER_DOWN[t] || 'cheap';
25
+ }
26
+
27
+ /** Same number the HUD green `x` uses: direct/spent. */
28
+ export function sessionDollarX({ dollarX, spentUsd, directUsd } = {}) {
29
+ const given = Number(dollarX);
30
+ if (Number.isFinite(given) && given > 0) return given;
31
+ const spent = Number(spentUsd);
32
+ const direct = Number(directUsd);
33
+ if (spent > 0 && Number.isFinite(direct)) return direct / spent;
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Recut launched Y (and maybe drop a band) when session green HUD is thin.
39
+ * Assumes the current multiple already includes this Y tax, so implied
40
+ * single-model x ≈ dollarX × y. Need (X) scales with Y. No user refunds.
41
+ *
42
+ * 2.09x on a 4-racer → implied ~8.4x single → Y=1 (back in the 5–10× band).
43
+ */
44
+ export function recutRaceByHud({
45
+ y, need = 1, dollarX, tier = 'medium', target = RACE_HUD_TARGET,
46
+ } = {}) {
47
+ const launched = Math.max(1, Math.floor(Number(y) || 1));
48
+ const k = Math.max(1, Math.min(Math.floor(Number(need) || 1), launched));
49
+ const x = Number(dollarX);
50
+ const band = String(tier || 'medium');
51
+ if (!Number.isFinite(x) || x <= 0 || x >= target) {
52
+ return { y: launched, need: k, tier: band, recut: false, reason: null };
53
+ }
54
+ const impliedSingle = x * launched;
55
+ const maxY = Math.max(1, Math.min(launched, Math.floor(impliedSingle / target)));
56
+ const nextTier = impliedSingle < target ? cheaperRaceTier(band) : band;
57
+ const nextNeed = Math.max(1, Math.min(k, maxY));
58
+ const recut = maxY < launched || nextTier !== band;
59
+ return {
60
+ y: maxY,
61
+ need: nextNeed,
62
+ tier: nextTier,
63
+ recut,
64
+ reason: recut ? 'savings' : null,
65
+ };
66
+ }
11
67
 
12
68
  const FLY_RE = /x402-tokens\.fly\.dev/i;
13
69
 
@@ -99,13 +155,29 @@ export function capRaceByCredit(n, { creditUsd, quoteUsd } = {}) {
99
155
  * House cost from the receipt. Do not subtract race_unused — unused
100
156
  * grant-back is not a user refund, and shrinking cogs would hide house loss.
101
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).
102
161
  */
103
- export function receiptUsedCogs(x, markup = 3) {
162
+ export function receiptUsedCogs(x) {
104
163
  if (!x || typeof x !== 'object') return 0;
105
- const billedRaw = Number(x.billedUsd);
106
- const billedOk = Number.isFinite(billedRaw) && billedRaw >= 0;
107
164
  if (typeof x.cogsUsd === 'number' && Number.isFinite(x.cogsUsd)) return x.cogsUsd;
108
- 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;
109
181
  }
110
182
 
111
183
  /**
@@ -113,12 +185,10 @@ export function receiptUsedCogs(x, markup = 3) {
113
185
  * first-call rewrite, never a race_unused user refund.
114
186
  * cogs is the house cost on that receipt (HUD embers when cogs > spent).
115
187
  */
116
- export function meterRaceReceipt(x, markup = 3) {
188
+ export function meterRaceReceipt(x) {
117
189
  const billed = Number(x?.billedUsd);
118
190
  const spentUsd = Number.isFinite(billed) ? billed : 0;
119
- const usedCogs = receiptUsedCogs(x, markup);
120
- const direct = typeof x?.directUsd === 'number' ? x.directUsd : spentUsd;
121
- return { spentUsd, cogsUsd: usedCogs, directUsd: direct };
191
+ return { spentUsd, cogsUsd: receiptUsedCogs(x), directUsd: receiptDirectUsd(x) };
122
192
  }
123
193
 
124
194
  export function inferRaceTier(models, fallback = 'medium') {