openzoo 0.49.6 → 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/boxes.js CHANGED
@@ -77,7 +77,7 @@ const ENTRYPOINT = [
77
77
  // its HTTP port mappings). The app is read-only at runtime; mutable state
78
78
  // lives in /root/.openzoo.
79
79
  + 'until OPENZOO_BIND=0.0.0.0 node /opt/openzoo/bin/openzoo.js >> /var/log/openzoo/proxy.log 2>&1; do echo "proxy exited, restarting" >> /var/log/openzoo/proxy.log; sleep 2; done & '
80
- + 'until OZ_GROKUI_BIND=0.0.0.0 OZ_GROKUI_PORT=4173 node /opt/grokui/grokui.mjs >> /var/log/openzoo/grokui.log 2>&1; do echo "grokui exited, restarting" >> /var/log/openzoo/grokui.log; sleep 2; done & '
80
+ + 'until OZ_GROKUI_BIND=0.0.0.0 OZ_GROKUI_PORT=4173 node /opt/openzoo/lib/grokui.mjs >> /var/log/openzoo/grokui.log 2>&1; do echo "grokui exited, restarting" >> /var/log/openzoo/grokui.log; sleep 2; done & '
81
81
  // the capture agent answers the ports Grok Bot expects a Cursor sandbox on,
82
82
  // logging the protocol we do not yet speak (see lib/podagent.mjs)
83
83
  + 'if [ -n "$OZ_PODAGENT_B64" ]; then printf %s "$OZ_PODAGENT_B64" | base64 -d > /opt/podagent.mjs; node /opt/podagent.mjs > /var/log/openzoo/agent.log 2>&1 & fi; '
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
@@ -323,10 +409,24 @@ export function isTinyClassify(body, bodyLen) {
323
409
  return messages.length <= CLASSIFY_MAX_MSGS && len < CLASSIFY_MAX_BODY;
324
410
  }
325
411
 
412
+ /**
413
+ * Ids that must never serve Claude Code / grokui AUTO's tiny yes/no classify.
414
+ * REASONING_MODEL_RE is the thinking floor; HEAVY_RE is the flagship set
415
+ * (opus/pro/max/…). opus-5 is openzoo's default session model and does NOT
416
+ * match the reasoning regex, so a catalog that lists opus before flash — or
417
+ * lists only opus + grok — used to pick opus as "first non-reasoner". That
418
+ * classify is a 402 handshake on a big model; AUTO's timeout then hard-blocks
419
+ * Bash instead of prompting.
420
+ */
421
+ function isSlowClassifier(id) {
422
+ const s = String(id || '');
423
+ return REASONING_MODEL_RE.test(s) || HEAVY_RE.test(s);
424
+ }
425
+
326
426
  /**
327
427
  * Fast non-reasoning id that is actually on the zoo. Prefer an explicit
328
428
  * OPENZOO_CLASSIFIER_MODEL, then flash, then haiku, then the first catalog
329
- * id that does not match the reasoning regex.
429
+ * id that is neither a reasoner nor a heavy/flagship (opus/pro/max/…).
330
430
  */
331
431
  export function pickClassifierModel(ids, preferred = process.env.OPENZOO_CLASSIFIER_MODEL) {
332
432
  if (!Array.isArray(ids) || !ids.length) return null;
@@ -334,7 +434,7 @@ export function pickClassifierModel(ids, preferred = process.env.OPENZOO_CLASSIF
334
434
  for (const id of CLASSIFIER_PREFS) {
335
435
  if (ids.includes(id)) return id;
336
436
  }
337
- return ids.find((id) => !REASONING_MODEL_RE.test(id)) || null;
437
+ return ids.find((id) => !isSlowClassifier(id)) || null;
338
438
  }
339
439
 
340
440
  /**
@@ -362,14 +462,22 @@ export function raiseReasoningMaxTokens(parsed, env = process.env) {
362
462
  * Model + max_tokens policy for one chat body.
363
463
  *
364
464
  * Tiny classify: pin to a fast non-reasoning catalog id, leave max_tokens
365
- * alone, ignore OPENZOO_DEFAULT_MODEL. Everything else: resolveModel (which
366
- * honours the default) then the reasoning floor.
465
+ * alone, ignore OPENZOO_DEFAULT_MODEL. Never fall back to `from` when that
466
+ * id is a reasoner or a heavy/flagship (the zoo default is opus-5). A
467
+ * catalog miss or an opus-only list used to keep the classify on opus-5
468
+ * and AUTO hard-blocked Bash. Everything else: resolveModel (which honours
469
+ * the default) then the reasoning floor.
367
470
  */
368
471
  export function rewriteChatModel(parsed, ids, { bodyLen } = {}) {
369
472
  const from = parsed?.model;
370
473
  const len = bodyLen ?? (parsed == null ? 0 : Buffer.byteLength(JSON.stringify(parsed)));
371
474
  if (isTinyClassify(parsed, len)) {
372
- const to = (typeof from === 'string' && pickClassifierModel(ids)) || from;
475
+ const picked = pickClassifierModel(ids);
476
+ // pickClassifierModel returns null on an empty catalog or a zoo that
477
+ // only lists reasoners/heavies. `(picked) || from` left those on
478
+ // anthropic/claude-opus-5 (openzoo's default). Pin to flash instead —
479
+ // never ship a classify body AUTO would time out and hard-block on.
480
+ const to = picked || (typeof from === 'string' && !isSlowClassifier(from) ? from : CLASSIFIER_PREFS[0]);
373
481
  return {
374
482
  parsed: (to && to !== from) ? { ...parsed, model: to } : parsed,
375
483
  tiny: true,
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));