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 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 / any OpenAI-env tool:**
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 /\b(?:payment failed|HTTP 402|wallet is empty|empty wallet)\b/i.test(String(text || ''));
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
- // STOP CLAUDE CODE COMPACTING ON A WINDOW THAT IS NOT REAL.
147
- //
148
- // It counts the WHOLE local transcript and compacts against a window it looks
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
- // STOP CLAUDE CODE COMPACTING ON A WINDOW THAT IS NOT REAL.
429
- //
430
- // It counts the WHOLE local transcript and compacts against a window it looks
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 = await tokenBalance(this.connection, owner, pool.underlying.toBase58());
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 failed — HTTP 402, the wallet is empty. ${w.funding}. EVM (Base/Robinhood): ${w.evm}.)`;
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, augmentModelList, ALIAS_IDS, rewriteChatModel, zooModelIds, CLASSIFY_MAX_TOKENS } from './models.js';
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 { response } = await client.fetch(url, init);
1506
- const payload = await response.json();
1507
- res.writeHead(response.status, { 'content-type': 'application/json' });
1508
- res.end(JSON.stringify(response.ok ? augmentModelList(payload) : payload));
1509
- return;
1510
- } catch { /* fall through to the plain relay below */ }
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
- if (typeof data?.usage?.cost === 'number' && data.usage.cost >= 0) {
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 a 402-receipt call added
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
- billedWithActual += Number(data?.x402?.billedUsd) || 0;
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.7",
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",