openzoo 0.49.7 → 0.49.8
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/README.md +20 -1
- package/lib/grokui.mjs +29 -2
- package/lib/launch.js +31 -68
- package/lib/livestatus.js +2 -2
- package/lib/models.js +86 -0
- package/lib/pay.js +67 -3
- package/lib/podagent.mjs +11 -1
- package/lib/proxy.js +42 -14
- package/lib/racesettle.js +127 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,26 @@ paid $0.002137 (9.5× cheaper than direct) · rail solana · tx 5Kd…
|
|
|
23
23
|
|
|
24
24
|
**Cursor** (Settings → Models → OpenAI API): set *Override OpenAI Base URL* to `http://localhost:8402/v1`, API key `sk-openzoo`. (Cursor Hobby can't BYOK; Pro can.)
|
|
25
25
|
|
|
26
|
-
**Claude Code
|
|
26
|
+
**Claude Code (zoo catalog, not opus-5-only):**
|
|
27
|
+
|
|
28
|
+
Prefer this. `ANTHROPIC_API_KEY` stays unset (it would bill api.anthropic.com). Base URL is `http://localhost:8402/v1`. `/model` should list zoo animals from the live OpenRouter catalog (plus `openzoo-*` twins), not a single opus-5.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx openzoo claude
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Leave a healthy `:8402` sidecar alone. Manual equivalent:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
unset ANTHROPIC_API_KEY
|
|
38
|
+
export ANTHROPIC_BASE_URL=http://localhost:8402/v1
|
|
39
|
+
export ANTHROPIC_AUTH_TOKEN=sk-openzoo
|
|
40
|
+
export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
|
|
41
|
+
curl -s "$ANTHROPIC_BASE_URL/models" # many OpenRouter ids, not 1
|
|
42
|
+
claude # /model lists those zoo animals
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Any OpenAI-env tool:**
|
|
27
46
|
```bash
|
|
28
47
|
export OPENAI_BASE_URL=http://localhost:8402/v1
|
|
29
48
|
export OPENAI_API_KEY=sk-openzoo
|
package/lib/grokui.mjs
CHANGED
|
@@ -716,8 +716,13 @@ function isTransientModelFail(text) {
|
|
|
716
716
|
if (/returned nothing \d+ times|each returned nothing/i.test(s)) return true;
|
|
717
717
|
return false;
|
|
718
718
|
}
|
|
719
|
+
function isEmptyWalletPayment(text) {
|
|
720
|
+
// Empty/underfunded only — not a generic HTTP 402 handshake.
|
|
721
|
+
return /\b(?:wallet is empty|empty wallet|wallet underfunded|underfunded)\b/i.test(String(text || ''));
|
|
722
|
+
}
|
|
719
723
|
function isPaymentFailed(text) {
|
|
720
|
-
return
|
|
724
|
+
return isEmptyWalletPayment(text)
|
|
725
|
+
|| /\b(?:payment failed|HTTP 402|payment required)\b/i.test(String(text || ''));
|
|
721
726
|
}
|
|
722
727
|
// Empty stdout, "(no output)", or a directive that found nothing. That is
|
|
723
728
|
// still a command-output hop today, so AUTO used to chain once and then park
|
|
@@ -3212,6 +3217,10 @@ const APP_HTML = `<!doctype html>
|
|
|
3212
3217
|
#walletOverlay, #sitrepOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.66); z-index: 1200;
|
|
3213
3218
|
display: none; align-items: center; justify-content: center; padding: 24px; }
|
|
3214
3219
|
#walletOverlay.show, #sitrepOverlay.show { display: flex; }
|
|
3220
|
+
.payneed-btn { display: block; margin-top: 10px; border: 0; cursor: pointer;
|
|
3221
|
+
background: #b8f240; color: #0b0b0d; font: 700 12px/1.2 inherit;
|
|
3222
|
+
letter-spacing: .04em; padding: 7px 14px; border-radius: 999px; }
|
|
3223
|
+
.payneed-btn:hover { filter: brightness(1.05); }
|
|
3215
3224
|
#walletBox, #sitrepBox { width: 100%; max-width: 560px; max-height: 82vh; overflow-y: auto; background: #111113;
|
|
3216
3225
|
border: 1px solid #2c2c2e; border-radius: 16px; padding: 20px 22px; }
|
|
3217
3226
|
#walletBox h3, #sitrepBox h3 { margin: 0 0 2px; font-size: 15px; font-weight: 600; }
|
|
@@ -4056,6 +4065,15 @@ const APP_HTML = `<!doctype html>
|
|
|
4056
4065
|
});
|
|
4057
4066
|
return row;
|
|
4058
4067
|
}
|
|
4068
|
+
function isEmptyWalletPayment(text) {
|
|
4069
|
+
return /\b(?:wallet is empty|empty wallet|wallet underfunded|underfunded)\b/i.test(String(text || ''));
|
|
4070
|
+
}
|
|
4071
|
+
var openedPayForEmpty = false;
|
|
4072
|
+
function maybeOpenPayForEmptyWallet(text) {
|
|
4073
|
+
if (openedPayForEmpty || !isEmptyWalletPayment(text)) return;
|
|
4074
|
+
openedPayForEmpty = true;
|
|
4075
|
+
openWallet();
|
|
4076
|
+
}
|
|
4059
4077
|
async function openWallet() {
|
|
4060
4078
|
walletOverlay.classList.add('show');
|
|
4061
4079
|
walletBody.textContent = 'loading…';
|
|
@@ -4865,6 +4883,15 @@ const APP_HTML = `<!doctype html>
|
|
|
4865
4883
|
const preview = htmlPreviewUrl(text);
|
|
4866
4884
|
if (preview) textEl.appendChild(previewFrame(preview, htmlPreviewKey(text, preview)));
|
|
4867
4885
|
}
|
|
4886
|
+
if (who === 'bot' && isEmptyWalletPayment(text)) {
|
|
4887
|
+
const pay = document.createElement('button');
|
|
4888
|
+
pay.type = 'button';
|
|
4889
|
+
pay.className = 'payneed-btn';
|
|
4890
|
+
pay.textContent = 'payment required';
|
|
4891
|
+
pay.addEventListener('click', function (e) { e.preventDefault(); openWallet(); });
|
|
4892
|
+
textEl.appendChild(pay);
|
|
4893
|
+
maybeOpenPayForEmptyWallet(text);
|
|
4894
|
+
}
|
|
4868
4895
|
bubble.appendChild(textEl);
|
|
4869
4896
|
row.appendChild(bubble);
|
|
4870
4897
|
// Copy the message SOURCE, not rendered HTML — markdown, code fences and
|
|
@@ -5889,7 +5916,7 @@ export {
|
|
|
5889
5916
|
parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
|
|
5890
5917
|
handleSlash, newThread, setRunTurnForTest, setBrainAskForTest, runTurn,
|
|
5891
5918
|
AUTO_CONTINUE, AUTO_RACE_RETRY, AUTO_EMPTY_RETRY, pingWakeText, pingCanWake, shouldKeepAuto,
|
|
5892
|
-
isDoneReply, isTransientModelFail, isEmptyToolResult, enqueueAutoHop, childKickoff, findByName,
|
|
5919
|
+
isDoneReply, isTransientModelFail, isPaymentFailed, isEmptyWalletPayment, isEmptyToolResult, enqueueAutoHop, childKickoff, findByName,
|
|
5893
5920
|
attachChildDir, finishChildDir,
|
|
5894
5921
|
lockWorktree, unlockWorktree, parsePrRef, fetchSpecsForOrigin, agentSlug,
|
|
5895
5922
|
filesForCorpus, noteFileForCorpus, noteRunForCorpus, resetFilesForCorpus,
|
package/lib/launch.js
CHANGED
|
@@ -15,6 +15,30 @@ import os from 'node:os';
|
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { config } from './config.js';
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Claude Code ≥2.1.129 only reads GET /v1/models when gateway discovery is
|
|
20
|
+
* opted in. Without this flag the picker stays the built-in opus-5 / tiny
|
|
21
|
+
* claude-* set instead of the zoo catalog the proxy publishes.
|
|
22
|
+
* CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC (even "0") blocks that GET.
|
|
23
|
+
*
|
|
24
|
+
* Do NOT invent ANTHROPIC_MODEL=openzoo-claude-sonnet-5. That pin hid the
|
|
25
|
+
* live OpenRouter catalog behind one Anthropic-native row. Only alias a
|
|
26
|
+
* model the caller already chose (OPENZOO_CLAUDE_ALIAS or ANTHROPIC_MODEL).
|
|
27
|
+
*/
|
|
28
|
+
export function applyClaudeCodeCatalogEnv(env) {
|
|
29
|
+
if (env.OPENZOO_NO_GATEWAY_DISCOVERY !== '1') {
|
|
30
|
+
env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY || '1';
|
|
31
|
+
delete env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
|
32
|
+
}
|
|
33
|
+
if (env.OPENZOO_NO_ALIAS === '1') return env;
|
|
34
|
+
const want = env.OPENZOO_CLAUDE_ALIAS || env.ANTHROPIC_MODEL;
|
|
35
|
+
if (!want) return env;
|
|
36
|
+
const bare = String(want).replace(/^openzoo-/, '').replace(/^[^/]+\//, '');
|
|
37
|
+
const SAFE = /^claude-(opus|sonnet|fable)-/;
|
|
38
|
+
if (SAFE.test(bare)) env.ANTHROPIC_MODEL = `openzoo-${bare}`;
|
|
39
|
+
return env;
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
/** Resolve the Claude DESKTOP app binary, platform-agnostically. Spawn the
|
|
19
43
|
* binary directly (not `open -a`) so the env — ANTHROPIC_BASE_URL — survives;
|
|
20
44
|
* macOS `open` hands off to launchd and drops it. */
|
|
@@ -143,40 +167,9 @@ export async function launchClaude(argv) {
|
|
|
143
167
|
delete env.ANTHROPIC_API_KEY;
|
|
144
168
|
env.ANTHROPIC_BASE_URL = base;
|
|
145
169
|
env.ANTHROPIC_AUTH_TOKEN = process.env.ANTHROPIC_AUTH_TOKEN || 'sk-openzoo';
|
|
146
|
-
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
// up from the MODEL NAME — hardcoded 200k for a recognised Claude id, which is
|
|
150
|
-
// why `/autocompact 1m` answered "capped to 200k by model" and why raising
|
|
151
|
-
// CLAUDE_CODE_MAX_CONTEXT_TOKENS alone did nothing. It never sees that the
|
|
152
|
-
// proxy binds the prefix and forwards ~8 of 166 turns.
|
|
153
|
-
//
|
|
154
|
-
// An `openzoo-` alias is the same model upstream (models.js resolves by
|
|
155
|
-
// SUFFIX, so openzoo-claude-sonnet-5 -> anthropic/claude-sonnet-5) but is not
|
|
156
|
-
// in that table, so the client falls back to the advertised context_length —
|
|
157
|
-
// which this gateway publishes as 128,000,000.
|
|
158
|
-
//
|
|
159
|
-
// OPENZOO_NO_ALIAS=1 opts out. Worth knowing: model-specific client behaviour
|
|
160
|
-
// (thinking budget, tool formats) also keys off the name, so if a session
|
|
161
|
-
// behaves oddly this is the first thing to turn off.
|
|
162
|
-
// Applies to WHATEVER model you are on — opus, fable, sonnet, haiku. The
|
|
163
|
-
// 200k table is keyed by name, so every recognised Claude id hits it; the
|
|
164
|
-
// alias is minted from the requested model rather than pinned to sonnet.
|
|
165
|
-
if (process.env.OPENZOO_NO_ALIAS !== '1') {
|
|
166
|
-
const want = process.env.OPENZOO_CLAUDE_ALIAS
|
|
167
|
-
|| process.env.ANTHROPIC_MODEL
|
|
168
|
-
|| 'claude-sonnet-5';
|
|
169
|
-
// idempotent: never openzoo-openzoo-…; strip a vendor prefix so the SUFFIX
|
|
170
|
-
// resolver in models.js gets the bare family name it matches on.
|
|
171
|
-
const bare = String(want).replace(/^openzoo-/, '').replace(/^[^/]+\//, '');
|
|
172
|
-
// ONLY families whose alias was VERIFIED to resolve back to themselves.
|
|
173
|
-
// openzoo-claude-haiku-4-5 resolved to deepseek/deepseek-v4-pro-0813 — the
|
|
174
|
-
// suffix scorer found no haiku in the catalog and picked a neighbour, so
|
|
175
|
-
// aliasing it would silently swap the user's model. A compaction fix is
|
|
176
|
-
// never worth answering as a different model than the one asked for.
|
|
177
|
-
const SAFE = /^claude-(opus|sonnet|fable)-/;
|
|
178
|
-
if (SAFE.test(bare)) env.ANTHROPIC_MODEL = `openzoo-${bare}`;
|
|
179
|
-
}
|
|
170
|
+
applyClaudeCodeCatalogEnv(env);
|
|
171
|
+
// ANTHROPIC_MODEL is only aliased (never invented) in applyClaudeCodeCatalogEnv.
|
|
172
|
+
// A default pin to openzoo-claude-sonnet-5 hid the live zoo catalog.
|
|
180
173
|
// THE ACTUAL SWITCHES. Three earlier attempts missed these entirely:
|
|
181
174
|
// autoCompactEnabled in settings (global, trapped a session), a raised
|
|
182
175
|
// CLAUDE_CODE_MAX_CONTEXT_TOKENS (clamped to 200k by the model table), and an
|
|
@@ -319,6 +312,7 @@ export async function launchClaude(argv) {
|
|
|
319
312
|
console.error('');
|
|
320
313
|
console.error(' \x1b[38;5;208m●\x1b[0m openzoo — this Claude Code session routes through the zoo');
|
|
321
314
|
console.error(` endpoint : ${base}`);
|
|
315
|
+
console.error(' models : GET /v1/models (zoo catalog — /model lists zoo animals, not opus-5-only)');
|
|
322
316
|
console.error(' auth : gateway token (ANTHROPIC_API_KEY unset — no api.anthropic.com billing)');
|
|
323
317
|
if (wallet) console.error(` wallet : ${wallet}`);
|
|
324
318
|
console.error(' spend : live in the status line below (bottom of screen); receipts in ~/.openzoo/proxy.log');
|
|
@@ -425,40 +419,9 @@ export async function launchHarness(cmd, args) {
|
|
|
425
419
|
delete env.ANTHROPIC_API_KEY; // conflicts with the gateway auth-token path
|
|
426
420
|
env.ANTHROPIC_BASE_URL = base;
|
|
427
421
|
env.ANTHROPIC_AUTH_TOKEN = process.env.ANTHROPIC_AUTH_TOKEN || 'sk-openzoo';
|
|
428
|
-
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
// up from the MODEL NAME — hardcoded 200k for a recognised Claude id, which is
|
|
432
|
-
// why `/autocompact 1m` answered "capped to 200k by model" and why raising
|
|
433
|
-
// CLAUDE_CODE_MAX_CONTEXT_TOKENS alone did nothing. It never sees that the
|
|
434
|
-
// proxy binds the prefix and forwards ~8 of 166 turns.
|
|
435
|
-
//
|
|
436
|
-
// An `openzoo-` alias is the same model upstream (models.js resolves by
|
|
437
|
-
// SUFFIX, so openzoo-claude-sonnet-5 -> anthropic/claude-sonnet-5) but is not
|
|
438
|
-
// in that table, so the client falls back to the advertised context_length —
|
|
439
|
-
// which this gateway publishes as 128,000,000.
|
|
440
|
-
//
|
|
441
|
-
// OPENZOO_NO_ALIAS=1 opts out. Worth knowing: model-specific client behaviour
|
|
442
|
-
// (thinking budget, tool formats) also keys off the name, so if a session
|
|
443
|
-
// behaves oddly this is the first thing to turn off.
|
|
444
|
-
// Applies to WHATEVER model you are on — opus, fable, sonnet, haiku. The
|
|
445
|
-
// 200k table is keyed by name, so every recognised Claude id hits it; the
|
|
446
|
-
// alias is minted from the requested model rather than pinned to sonnet.
|
|
447
|
-
if (process.env.OPENZOO_NO_ALIAS !== '1') {
|
|
448
|
-
const want = process.env.OPENZOO_CLAUDE_ALIAS
|
|
449
|
-
|| process.env.ANTHROPIC_MODEL
|
|
450
|
-
|| 'claude-sonnet-5';
|
|
451
|
-
// idempotent: never openzoo-openzoo-…; strip a vendor prefix so the SUFFIX
|
|
452
|
-
// resolver in models.js gets the bare family name it matches on.
|
|
453
|
-
const bare = String(want).replace(/^openzoo-/, '').replace(/^[^/]+\//, '');
|
|
454
|
-
// ONLY families whose alias was VERIFIED to resolve back to themselves.
|
|
455
|
-
// openzoo-claude-haiku-4-5 resolved to deepseek/deepseek-v4-pro-0813 — the
|
|
456
|
-
// suffix scorer found no haiku in the catalog and picked a neighbour, so
|
|
457
|
-
// aliasing it would silently swap the user's model. A compaction fix is
|
|
458
|
-
// never worth answering as a different model than the one asked for.
|
|
459
|
-
const SAFE = /^claude-(opus|sonnet|fable)-/;
|
|
460
|
-
if (SAFE.test(bare)) env.ANTHROPIC_MODEL = `openzoo-${bare}`;
|
|
461
|
-
}
|
|
422
|
+
applyClaudeCodeCatalogEnv(env);
|
|
423
|
+
// ANTHROPIC_MODEL is only aliased (never invented) in applyClaudeCodeCatalogEnv.
|
|
424
|
+
// A default pin to openzoo-claude-sonnet-5 hid the live zoo catalog.
|
|
462
425
|
// THE ACTUAL SWITCHES. Three earlier attempts missed these entirely:
|
|
463
426
|
// autoCompactEnabled in settings (global, trapped a session), a raised
|
|
464
427
|
// CLAUDE_CODE_MAX_CONTEXT_TOKENS (clamped to 200k by the model table), and an
|
package/lib/livestatus.js
CHANGED
|
@@ -118,7 +118,7 @@ export function clipRacePreview(text, maxLines = 8, maxChars = 420) {
|
|
|
118
118
|
/** Race-level failure when no countable answer exists. Never a single model name. */
|
|
119
119
|
export const RACE_EVERY_FAILED = '(race: every model failed — no reply)';
|
|
120
120
|
|
|
121
|
-
const RACE_HTTP_NOTE = /^\((?:upstream error|request failed|payment failed|rate limited|stream timed out|stream stalled)/i;
|
|
121
|
+
const RACE_HTTP_NOTE = /^\((?:upstream error|request failed|payment failed|payment required|rate limited|stream timed out|stream stalled)/i;
|
|
122
122
|
const RACE_MODEL_FAILED = /^\([^)]+ (?:failed:|returned nothing)/i;
|
|
123
123
|
const RACE_FETCH_FAILED = /^(?:typeerror:\s*)?fetch failed$/i;
|
|
124
124
|
|
|
@@ -159,7 +159,7 @@ export function raceFailKind(arrival) {
|
|
|
159
159
|
const s = `${err} ${text}`.trim();
|
|
160
160
|
if (!s) return 'empty body';
|
|
161
161
|
if (/timeout|STREAM_IDLE|aborted|AbortError/i.test(s)) return 'timeout';
|
|
162
|
-
if (/402|payment failed/i.test(s)) return 'pay';
|
|
162
|
+
if (/402|payment failed|payment required/i.test(s)) return 'pay';
|
|
163
163
|
if (/fetch failed/i.test(s)) return 'fetch failed';
|
|
164
164
|
const http = /HTTP\s+(\d{3})/i.exec(s);
|
|
165
165
|
if (http) return `HTTP ${http[1]}`;
|
package/lib/models.js
CHANGED
|
@@ -251,6 +251,92 @@ export function augmentModelList(payload) {
|
|
|
251
251
|
return { ...payload, object: payload?.object || 'list', data: [...data, ...branded, ...aliases] };
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Label a catalog id for a picker without minting a new id.
|
|
256
|
+
* `x-ai/grok-4.6` stays `x-ai/grok-4.6` on the wire; the label is just
|
|
257
|
+
* "grok-4.6 (x-ai)". Never invents a `claude-*` name for a non-Anthropic animal.
|
|
258
|
+
*/
|
|
259
|
+
export function displayNameFor(id) {
|
|
260
|
+
const raw = String(id || '').trim();
|
|
261
|
+
if (!raw) return raw;
|
|
262
|
+
if (raw.includes('/')) {
|
|
263
|
+
const slash = raw.indexOf('/');
|
|
264
|
+
return `${raw.slice(slash + 1)} (${raw.slice(0, slash)})`;
|
|
265
|
+
}
|
|
266
|
+
return raw;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function decorateModelEntry(m) {
|
|
270
|
+
if (!m || typeof m !== 'object') return m;
|
|
271
|
+
return {
|
|
272
|
+
...m,
|
|
273
|
+
object: m.object || 'model',
|
|
274
|
+
type: m.type || 'model',
|
|
275
|
+
display_name: m.display_name || displayNameFor(m.id),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* OpenAI-compatible /v1/models body Claude Code and every other harness can
|
|
281
|
+
* read. The full zoo catalog is kept — no claude-* filter, no single opus-5
|
|
282
|
+
* collapse. Extra Anthropic fields (`type`, `display_name`) sit alongside
|
|
283
|
+
* OpenAI ones so a Messages-API client can label rows without a second
|
|
284
|
+
* endpoint. Existing OpenAI clients ignore the extras.
|
|
285
|
+
*/
|
|
286
|
+
export function publishModelList(payload) {
|
|
287
|
+
const merged = augmentModelList(payload);
|
|
288
|
+
const data = (merged.data || []).map(decorateModelEntry);
|
|
289
|
+
return {
|
|
290
|
+
...merged,
|
|
291
|
+
object: merged.object || 'list',
|
|
292
|
+
data,
|
|
293
|
+
has_more: false,
|
|
294
|
+
first_id: data[0]?.id ?? null,
|
|
295
|
+
last_id: data[data.length - 1]?.id ?? null,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Anthropic GET /v1/models shape (id + display_name + type).
|
|
301
|
+
* Claude Code gateway discovery reads `data[].id` and optional `display_name`.
|
|
302
|
+
* Ids are the zoo's real catalog ids — never rewritten to fake `claude-*`
|
|
303
|
+
* aliases for grok / deepseek / gemini / etc.
|
|
304
|
+
*/
|
|
305
|
+
export function anthropicModelList(payload) {
|
|
306
|
+
const published = publishModelList(payload);
|
|
307
|
+
const data = (published.data || []).map((m) => ({
|
|
308
|
+
type: 'model',
|
|
309
|
+
id: m.id,
|
|
310
|
+
display_name: m.display_name || displayNameFor(m.id),
|
|
311
|
+
...(m.created_at ? { created_at: m.created_at } : {}),
|
|
312
|
+
...(m.served_by ? { served_by: m.served_by } : {}),
|
|
313
|
+
}));
|
|
314
|
+
return {
|
|
315
|
+
data,
|
|
316
|
+
has_more: false,
|
|
317
|
+
first_id: data[0]?.id ?? null,
|
|
318
|
+
last_id: data[data.length - 1]?.id ?? null,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Claude Code / Anthropic SDKs send anthropic-version or an x-app / UA hint. */
|
|
323
|
+
export function wantsAnthropicModelList(headers = {}) {
|
|
324
|
+
const h = headers && typeof headers === 'object' ? headers : {};
|
|
325
|
+
const get = (k) => h[k] ?? h[k.toLowerCase()] ?? '';
|
|
326
|
+
return Boolean(get('anthropic-version'))
|
|
327
|
+
|| /claude|anthropic/i.test(String(get('x-app')))
|
|
328
|
+
|| /claude|anthropic/i.test(String(get('user-agent')));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Body for GET /v1/models. Always the full zoo catalog (never opus-5-only).
|
|
333
|
+
* Anthropic-shaped clients get id+display_name; OpenAI clients keep object:list.
|
|
334
|
+
*/
|
|
335
|
+
export function modelsListForRequest(payload, headers) {
|
|
336
|
+
const published = publishModelList(payload);
|
|
337
|
+
return wantsAnthropicModelList(headers) ? anthropicModelList(published) : published;
|
|
338
|
+
}
|
|
339
|
+
|
|
254
340
|
/**
|
|
255
341
|
* Which request paths carry a rewritable model field. POST-only; embeddings /
|
|
256
342
|
* audio / image / moderation models are DIFFERENT model families — rewriting
|
package/lib/pay.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Connection, PublicKey } from '@solana/web3.js';
|
|
2
2
|
import { getAssociatedTokenAddressSync } from '@solana/spl-token';
|
|
3
|
-
import { config, fundingLine, evmRpcFor } from './config.js';
|
|
3
|
+
import { config, fundingLine, evmRpcFor, FUNDING_ASSETS } from './config.js';
|
|
4
4
|
import { loadOrCreateWallet } from './wallet.js';
|
|
5
5
|
import {
|
|
6
6
|
parse402, orderAccepts, railOf, buildPaymentOnline, tokenBalance,
|
|
@@ -120,6 +120,37 @@ export function resetRailMemory() {
|
|
|
120
120
|
lastGoodAsset = null;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Skip wrap / pool / sendWrap when there is nothing to convert.
|
|
125
|
+
* wrapped < need AND underlying == 0 → UnderfundedError, no RPC walk.
|
|
126
|
+
* A funded wallet (underlying > 0) still wraps.
|
|
127
|
+
*/
|
|
128
|
+
export function skipWrapWhenEmpty(wrappedRaw, underlyingRaw, need) {
|
|
129
|
+
const wrapped = wrappedRaw == null ? 0n : BigInt(wrappedRaw);
|
|
130
|
+
const underlying = underlyingRaw == null ? 0n : BigInt(underlyingRaw);
|
|
131
|
+
const want = need == null ? 0n : BigInt(need);
|
|
132
|
+
return wrapped < want && underlying <= 0n;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Cheap underlying mint for a 402 row — directory acquire address, or the
|
|
137
|
+
* known USDC / TOKEN / LEOS twin named in extra.symbol. No chain probe.
|
|
138
|
+
*/
|
|
139
|
+
export function hintedUnderlyingMint(accept) {
|
|
140
|
+
const acq = accept?.extra?.acquire?.underlying;
|
|
141
|
+
const addr = typeof acq === 'string' ? acq : acq?.address;
|
|
142
|
+
if (addr && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(addr)) return addr;
|
|
143
|
+
const plain = String(accept?.extra?.symbol || '').replace(/^[wy]/i, '').replace(/x$/i, '').toUpperCase();
|
|
144
|
+
return FUNDING_ASSETS.find((a) => a.symbol === plain)?.mint || null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** True when every Solana funding mint (USDC / TOKEN / LEOS) reads 0. */
|
|
148
|
+
export function solanaFundingEmpty(balances) {
|
|
149
|
+
const rows = Array.isArray(balances) ? balances : [];
|
|
150
|
+
if (!rows.length) return false;
|
|
151
|
+
return rows.every((b) => (b?.raw == null ? 0n : BigInt(b.raw)) <= 0n);
|
|
152
|
+
}
|
|
153
|
+
|
|
123
154
|
export class PayClient {
|
|
124
155
|
constructor() {
|
|
125
156
|
const w = loadOrCreateWallet();
|
|
@@ -213,6 +244,11 @@ export class PayClient {
|
|
|
213
244
|
}
|
|
214
245
|
}
|
|
215
246
|
|
|
247
|
+
async solanaFundingBalances() {
|
|
248
|
+
const owner = this.keypair.publicKey;
|
|
249
|
+
return Promise.all(FUNDING_ASSETS.map((a) => cachedTokenBalance(this.connection, owner, a.mint)));
|
|
250
|
+
}
|
|
251
|
+
|
|
216
252
|
async buildPaymentFor(accept, onStage) {
|
|
217
253
|
const rail = railOf(accept);
|
|
218
254
|
if (rail === 'solana') {
|
|
@@ -224,6 +260,22 @@ export class PayClient {
|
|
|
224
260
|
bal = await cachedTokenBalance(this.connection, this.keypair.publicKey, accept.asset, { force: true });
|
|
225
261
|
}
|
|
226
262
|
if (bal.raw < need) {
|
|
263
|
+
// Empty first-run burner: 0 wrapped + 0 USDC/TOKEN/LEOS. Do not
|
|
264
|
+
// resolvePool / poolState / sendWrap just to learn that.
|
|
265
|
+
const funding = await this.solanaFundingBalances();
|
|
266
|
+
if (solanaFundingEmpty(funding)) {
|
|
267
|
+
throw new UnderfundedError(accept, 0, this.address);
|
|
268
|
+
}
|
|
269
|
+
const underMint = hintedUnderlyingMint(accept);
|
|
270
|
+
if (underMint) {
|
|
271
|
+
let under = await cachedTokenBalance(this.connection, this.keypair.publicKey, underMint);
|
|
272
|
+
if (under.raw <= 0n && under.cached) {
|
|
273
|
+
under = await cachedTokenBalance(this.connection, this.keypair.publicKey, underMint, { force: true });
|
|
274
|
+
}
|
|
275
|
+
if (skipWrapWhenEmpty(bal.raw, under.raw, need)) {
|
|
276
|
+
throw new UnderfundedError(accept, under.ui, this.address);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
227
279
|
const topUp = await this.topUpQuotedAsset(accept, need, onStage);
|
|
228
280
|
if (topUp.preInstructions) {
|
|
229
281
|
return buildPaymentOnline(this.connection, this.keypair, accept, {
|
|
@@ -290,14 +342,26 @@ export class PayClient {
|
|
|
290
342
|
const pool = await resolvePool(this.connection, accept.asset).catch(() => null);
|
|
291
343
|
if (!pool) throw new UnderfundedError(accept, null, this.address);
|
|
292
344
|
|
|
345
|
+
// Balances first. 0 wrapped + 0 underlying → throw. Never poolState or
|
|
346
|
+
// sendWrap just to discover an empty burner (measured ~4.5s per row).
|
|
347
|
+
const [wrappedNow, underlyingNow] = await Promise.all([
|
|
348
|
+
tokenBalance(this.connection, owner, accept.asset),
|
|
349
|
+
tokenBalance(this.connection, owner, pool.underlying.toBase58()),
|
|
350
|
+
]);
|
|
351
|
+
if (skipWrapWhenEmpty(wrappedNow.raw, underlyingNow.raw, need)) {
|
|
352
|
+
throw new UnderfundedError(accept, underlyingNow.ui, this.address);
|
|
353
|
+
}
|
|
354
|
+
|
|
293
355
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
294
|
-
const bal = await tokenBalance(this.connection, owner, accept.asset);
|
|
356
|
+
const bal = attempt === 0 ? wrappedNow : await tokenBalance(this.connection, owner, accept.asset);
|
|
295
357
|
const short = need - bal.raw;
|
|
296
358
|
if (short <= 0n) return {};
|
|
297
359
|
|
|
298
360
|
const { reserves, supply } = await poolState(this.connection, pool);
|
|
299
361
|
const deposit = depositForShares(short, reserves, supply);
|
|
300
|
-
const underlyingBal =
|
|
362
|
+
const underlyingBal = attempt === 0
|
|
363
|
+
? underlyingNow
|
|
364
|
+
: await tokenBalance(this.connection, owner, pool.underlying.toBase58());
|
|
301
365
|
if (underlyingBal.raw < deposit) {
|
|
302
366
|
throw new UnderfundedError(accept, underlyingBal.ui, this.address);
|
|
303
367
|
}
|
package/lib/podagent.mjs
CHANGED
|
@@ -247,7 +247,7 @@ async function httpErrorNote(status) {
|
|
|
247
247
|
// funded === false is the genuinely-empty case; funded === true after
|
|
248
248
|
// the retries above means the rail/quote failed, not the balance
|
|
249
249
|
if (w.funded === false) {
|
|
250
|
-
return `(payment
|
|
250
|
+
return `(payment required — HTTP 402, the wallet is empty. ${w.funding}. EVM (Base/Robinhood): ${w.evm}.)`;
|
|
251
251
|
}
|
|
252
252
|
return `(payment failed — HTTP 402 after ${PAYMENT_RETRIES} retries, though the wallet holds ${w.balances || 'a balance'}. Send it again; if it keeps failing the quoted asset may not be convertible right now. Fund with: ${w.funding})`;
|
|
253
253
|
}
|
|
@@ -276,7 +276,13 @@ function sanitizeProxiedError(msg) {
|
|
|
276
276
|
// this attempt, but the NEXT attempt usually settles (measured: same wallet,
|
|
277
277
|
// same rail, second call pays fine). Surfacing that as a chat message makes
|
|
278
278
|
// the user do the retry by hand — so do it here instead.
|
|
279
|
+
// Empty/underfunded is NOT that handshake: retrying just re-walks wrap.
|
|
279
280
|
const PAYMENT_RETRIES = 3;
|
|
281
|
+
|
|
282
|
+
export function isUnderfunded402Body(body) {
|
|
283
|
+
const msg = String(body?.error?.message || body?.error || body?.message || '');
|
|
284
|
+
return /\b(?:underfunded|wallet is empty|empty wallet)\b/i.test(msg);
|
|
285
|
+
}
|
|
280
286
|
/**
|
|
281
287
|
* ADAPTIVE top_k. We have learned this one the expensive way already.
|
|
282
288
|
*
|
|
@@ -329,6 +335,10 @@ async function postChat(body, contextId, topK, onStatus, signal) {
|
|
|
329
335
|
...(signal ? { signal } : {}),
|
|
330
336
|
});
|
|
331
337
|
if (r.status !== 402 || attempt === PAYMENT_RETRIES) return r;
|
|
338
|
+
// Handshake 402 (funded, settle flake) → retry. Empty-wallet 402 → stop.
|
|
339
|
+
// Opening Pay / parking happens on the empty body, not on every 402.
|
|
340
|
+
const peek = await r.clone().json().catch(() => null);
|
|
341
|
+
if (isUnderfunded402Body(peek)) return r;
|
|
332
342
|
// A 402 retry used to be silent — grokui sat on mute "…" for the whole
|
|
333
343
|
// settle. Tell the watcher this attempt is paying, not wedged.
|
|
334
344
|
onStatus?.(formatPayStatus(attempt));
|
package/lib/proxy.js
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
corpusRecall,
|
|
21
21
|
decideChatSpill, isOneShotCorpusAsk,
|
|
22
22
|
} from './spill.js';
|
|
23
|
-
import { rewritablePath,
|
|
23
|
+
import { rewritablePath, modelsListForRequest, ALIAS_IDS, rewriteChatModel, zooModelIds, CLASSIFY_MAX_TOKENS } from './models.js';
|
|
24
24
|
import { forgetContext } from './contexts.js';
|
|
25
25
|
import { injectBrief } from './brief.js';
|
|
26
26
|
import { withNamespace } from './namespace.js';
|
|
@@ -30,8 +30,9 @@ import { loadSessionSpend, saveSessionSpend } from './session.js';
|
|
|
30
30
|
import { creditBalance, quotedPrices } from './info.js';
|
|
31
31
|
import { subscriptionPublicView } from './subscription.js';
|
|
32
32
|
import { priceHoldings } from './livestatus.js';
|
|
33
|
-
import { receiptUsedCogs, receiptDirectUsd } from './racesettle.js';
|
|
33
|
+
import { receiptUsedCogs, receiptDirectUsd, pairActualBilled } from './racesettle.js';
|
|
34
34
|
import { rewriteWrapClientError } from './wrap.js';
|
|
35
|
+
import { fetchHeaders } from './fetch.js';
|
|
35
36
|
|
|
36
37
|
const HOP_BY_HOP = new Set([
|
|
37
38
|
'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
|
|
@@ -1501,13 +1502,24 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1501
1502
|
// rewrite never gets its chance.
|
|
1502
1503
|
const path = (req.url || '').split('?')[0];
|
|
1503
1504
|
if (req.method === 'GET' && path === '/v1/models') {
|
|
1505
|
+
// Catalog is chrome, not a paid call. Paying the list would wrap-walk
|
|
1506
|
+
// an empty burner (~4.5s/row) and stall first paint / harness probe.
|
|
1504
1507
|
try {
|
|
1505
|
-
const
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1508
|
+
const response = await fetchHeaders(url, init);
|
|
1509
|
+
if (response.ok) {
|
|
1510
|
+
const payload = await response.json();
|
|
1511
|
+
// Full zoo catalog � never collapse to a single opus-5 or a claude-*
|
|
1512
|
+
// allowlist. Claude Code (ANTHROPIC_BASE_URL + gateway discovery) reads
|
|
1513
|
+
// data[].id / display_name; OpenAI clients keep object:"list".
|
|
1514
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1515
|
+
res.end(JSON.stringify(modelsListForRequest(payload, req.headers)));
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
await response.text().catch(() => {});
|
|
1519
|
+
} catch { /* gateway 402/down � serve aliases so chrome still paints */ }
|
|
1520
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1521
|
+
res.end(JSON.stringify(modelsListForRequest({ object: 'list', data: [] }, req.headers)));
|
|
1522
|
+
return;
|
|
1511
1523
|
}
|
|
1512
1524
|
const probe = req.method === 'GET' && /^\/v1\/models\/(.+)$/.exec(path);
|
|
1513
1525
|
if (probe && ALIAS_IDS.includes(decodeURIComponent(probe[1]))) {
|
|
@@ -1631,18 +1643,26 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1631
1643
|
// completion already — no extra call, and unlike the account-level
|
|
1632
1644
|
// /api/v1/credits total it is attributable to THIS proxy even though the
|
|
1633
1645
|
// same OpenRouter key also pays for ttfx and everything else.
|
|
1634
|
-
|
|
1635
|
-
sessionActual += data.usage.cost;
|
|
1636
|
-
actualCalls += 1;
|
|
1646
|
+
{
|
|
1637
1647
|
// PAIR THE NUMERATOR WITH THE DENOMINATOR. sessionSpent is summed on
|
|
1638
1648
|
// three paths and sessionActual on two, so markupX divided ALL billed
|
|
1639
|
-
// by the SUBSET that reported a real cost
|
|
1649
|
+
// by the SUBSET that reported a real cost � a 402-receipt call added
|
|
1640
1650
|
// to billed and nothing to real, and the ratio read 12.55x on a stack
|
|
1641
1651
|
// running at ~1.0x. Track the billed side of exactly the calls whose
|
|
1642
1652
|
// cost we actually learned.
|
|
1643
1653
|
// Both figures ride the SAME response object, so read them together
|
|
1644
1654
|
// rather than carrying one across sites and hoping the order holds.
|
|
1645
|
-
|
|
1655
|
+
//
|
|
1656
|
+
// x402.billedUsd is often the QUOTE reserve (max_tokens � catalog),
|
|
1657
|
+
// not the settled charge. MEASURED: $0.9858 reserved vs $0.007962
|
|
1658
|
+
// usage.cost -> markupX lied at 124x on a ~1x call. Pair usage.cost
|
|
1659
|
+
// with post-completion billed, never the reserve.
|
|
1660
|
+
const pair = pairActualBilled(data?.x402, data?.usage);
|
|
1661
|
+
if (pair) {
|
|
1662
|
+
sessionActual += pair.upstreamUsd;
|
|
1663
|
+
actualCalls += 1;
|
|
1664
|
+
billedWithActual += pair.billedUsd;
|
|
1665
|
+
}
|
|
1646
1666
|
}
|
|
1647
1667
|
// PREPAID CALLS STILL COST MONEY. The block above only meters calls
|
|
1648
1668
|
// where THIS proxy answered a 402 and paid. When prepaid credit covers
|
|
@@ -1714,12 +1734,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1714
1734
|
// same figures the JSON path reads out of `data.x402`, same counters, so
|
|
1715
1735
|
// the status line does not care which transport served the answer.
|
|
1716
1736
|
const meterStreamed = (x) => {
|
|
1737
|
+
// Same pairing rule as the JSON path: actualUsd / usage.cost with the
|
|
1738
|
+
// settled billed twin, even on a wallet-paid stream (do not skip just
|
|
1739
|
+
// because `paid` already recorded the quote-time receipt).
|
|
1740
|
+
const pair = pairActualBilled(x, x?.usage);
|
|
1741
|
+
if (pair) {
|
|
1742
|
+
sessionActual += pair.upstreamUsd;
|
|
1743
|
+
actualCalls += 1;
|
|
1744
|
+
billedWithActual += pair.billedUsd;
|
|
1745
|
+
}
|
|
1717
1746
|
if (paid || typeof x?.billedUsd !== 'number') return;
|
|
1718
1747
|
sessionSpent += x.billedUsd;
|
|
1719
1748
|
sessionCogs += receiptUsedCogs(x);
|
|
1720
1749
|
noteQuote(x);
|
|
1721
1750
|
sessionDirect += receiptDirectUsd(x);
|
|
1722
|
-
if (typeof x.actualUsd === 'number' && x.actualUsd >= 0) { sessionActual += x.actualUsd; actualCalls += 1; billedWithActual += x.billedUsd || 0; }
|
|
1723
1751
|
if (didSpill) {
|
|
1724
1752
|
log(spillPricedLine(x, { streamed: true }));
|
|
1725
1753
|
spill.spillSpend += x.billedUsd;
|
package/lib/racesettle.js
CHANGED
|
@@ -180,6 +180,133 @@ export function receiptDirectUsd(x) {
|
|
|
180
180
|
return billedOk ? billed : 0;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/** Zoo's share of (direct − OpenRouter) when the 402 has real savings. */
|
|
184
|
+
export const SAVINGS_SHARE = 0.33;
|
|
185
|
+
/**
|
|
186
|
+
* billed / usage.cost above this, without settled house cogs, is the
|
|
187
|
+
* max_tokens quote reserve — not the charge after completion.
|
|
188
|
+
* MEASURED 2026-08-19: $0.9858 reserved / $0.007962 usage.cost ≈ 124× on a ~1× call.
|
|
189
|
+
*/
|
|
190
|
+
export const QUOTE_RESERVE_X = 2;
|
|
191
|
+
|
|
192
|
+
function money(v) {
|
|
193
|
+
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function closeRatio(a, b, rel = 0.25) {
|
|
197
|
+
const den = Math.max(b, 1e-12);
|
|
198
|
+
return Math.abs(a - b) / den <= rel;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* After-completion billed fields the gateway may already put on x402 / usage.
|
|
203
|
+
* Read only if present — do not invent them on the wire.
|
|
204
|
+
*/
|
|
205
|
+
function explicitSettledBilled(x, usage) {
|
|
206
|
+
const bags = [x, usage, x?.used, x?.settled, x?.receipt, usage?.used, usage?.settled]
|
|
207
|
+
.filter((o) => o && typeof o === 'object' && !Array.isArray(o));
|
|
208
|
+
const keys = [
|
|
209
|
+
'billedActual', 'billedActualUsd', 'settledUsd', 'settledBilledUsd',
|
|
210
|
+
'chargedUsd', 'usedUsd', 'actualBilledUsd',
|
|
211
|
+
];
|
|
212
|
+
for (const bag of bags) {
|
|
213
|
+
for (const k of keys) {
|
|
214
|
+
const n = money(bag[k]);
|
|
215
|
+
if (n != null) return n;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** tokens actually used × unit prices, only when both sides are already on the object. */
|
|
222
|
+
function billedFromUsedTokens(x, usage) {
|
|
223
|
+
const prompt = money(usage?.prompt_tokens ?? usage?.promptTokens ?? x?.prompt_tokens);
|
|
224
|
+
const completion = money(usage?.completion_tokens ?? usage?.completionTokens ?? x?.completion_tokens);
|
|
225
|
+
const inPrice = money(x?.promptPriceUsd ?? x?.inputPriceUsd ?? x?.priceInUsd);
|
|
226
|
+
const outPrice = money(x?.completionPriceUsd ?? x?.outputPriceUsd ?? x?.priceOutUsd);
|
|
227
|
+
if (prompt != null && completion != null && inPrice != null && outPrice != null) {
|
|
228
|
+
return prompt * inPrice + completion * outPrice;
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* True when `billed` is the quote-time max_tokens ceiling, not the settled charge.
|
|
235
|
+
* A large billed/cost is honest when cogs already matches usage.cost (33% of
|
|
236
|
+
* real savings). The 124× lie is billed >> cost with at-cost / reserved cogs.
|
|
237
|
+
*/
|
|
238
|
+
export function isQuoteReserveBilled(billed, cost, x = {}) {
|
|
239
|
+
if (money(billed) == null || money(cost) == null) return false;
|
|
240
|
+
if (cost === 0) return billed > 0;
|
|
241
|
+
if (billed <= cost * QUOTE_RESERVE_X) return false;
|
|
242
|
+
const cogs = money(x?.cogsUsd);
|
|
243
|
+
if (cogs != null && closeRatio(cogs, cost)) return false;
|
|
244
|
+
const saved = money(x?.savedUsd);
|
|
245
|
+
if (saved == null || saved <= cost * 0.5) return true;
|
|
246
|
+
if (cogs != null && cogs > cost * QUOTE_RESERVE_X) return true;
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Post-completion billed USD to pair with usage.cost / x.actualUsd.
|
|
252
|
+
* `x.billedUsd` is often the quote reserve (max_tokens × catalog), which made
|
|
253
|
+
* HUD markupX read 124× on a ~1× call. Prefer a settled field; otherwise
|
|
254
|
+
* reconstruct from tokens used × price or from usage.cost (+ 33% of settled
|
|
255
|
+
* savings). Never return the reserve when we learned the real upstream cost.
|
|
256
|
+
*/
|
|
257
|
+
export function receiptSettledBilled(x, usage) {
|
|
258
|
+
const cost = money(usage?.cost) ?? money(x?.actualUsd) ?? money(usage?.actualUsd);
|
|
259
|
+
const explicit = explicitSettledBilled(x, usage);
|
|
260
|
+
if (explicit != null) return explicit;
|
|
261
|
+
|
|
262
|
+
const fromTokens = billedFromUsedTokens(x, usage);
|
|
263
|
+
if (fromTokens != null) {
|
|
264
|
+
const billed = money(x?.billedUsd);
|
|
265
|
+
if (billed == null || isQuoteReserveBilled(billed, fromTokens, x)
|
|
266
|
+
|| (cost != null && isQuoteReserveBilled(billed, cost, x))) {
|
|
267
|
+
return fromTokens;
|
|
268
|
+
}
|
|
269
|
+
return billed;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const billed = money(x?.billedUsd);
|
|
273
|
+
if (billed != null && (cost == null || !isQuoteReserveBilled(billed, cost, x))) {
|
|
274
|
+
return billed;
|
|
275
|
+
}
|
|
276
|
+
if (cost != null) {
|
|
277
|
+
const saved = settledSavedUsd(x, cost);
|
|
278
|
+
return cost + SAVINGS_SHARE * saved;
|
|
279
|
+
}
|
|
280
|
+
return billed ?? 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function settledSavedUsd(x, cost) {
|
|
284
|
+
const saved = money(x?.savedUsd);
|
|
285
|
+
if (saved == null || saved <= 0) return 0;
|
|
286
|
+
const cogs = money(x?.cogsUsd);
|
|
287
|
+
// Quote-time savedUsd rides the same max_tokens reserve. Only keep it when
|
|
288
|
+
// house cost already matches the metered upstream (settled cogs).
|
|
289
|
+
if (cogs != null && closeRatio(cogs, cost)) return saved;
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Pair the HUD denominator (real upstream) with the post-completion billed
|
|
295
|
+
* twin. Null when this call did not report a real cost — do not mix populations.
|
|
296
|
+
*/
|
|
297
|
+
export function pairActualBilled(x402, usage) {
|
|
298
|
+
const fromUsage = money(usage?.cost);
|
|
299
|
+
const fromX = money(x402?.actualUsd) ?? money(x402?.usage?.cost);
|
|
300
|
+
const upstreamUsd = fromUsage ?? fromX;
|
|
301
|
+
if (upstreamUsd == null) return null;
|
|
302
|
+
const bag = x402 && typeof x402 === 'object' ? x402 : {};
|
|
303
|
+
const usageBag = usage && typeof usage === 'object' ? usage : {};
|
|
304
|
+
return {
|
|
305
|
+
upstreamUsd,
|
|
306
|
+
billedUsd: receiptSettledBilled(bag, { ...usageBag, cost: upstreamUsd }),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
183
310
|
/**
|
|
184
311
|
* Session meter. spent/direct are the receipt totals as billed — never a
|
|
185
312
|
* first-call rewrite, never a race_unused user refund.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.49.
|
|
3
|
+
"version": "0.49.8",
|
|
4
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.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|