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 +20 -1
- package/lib/boxes.js +1 -1
- package/lib/grokui.mjs +29 -2
- package/lib/launch.js +31 -68
- package/lib/livestatus.js +2 -2
- package/lib/models.js +113 -5
- package/lib/pay.js +67 -3
- package/lib/podagent.mjs +11 -1
- package/lib/proxy.js +96 -35
- package/lib/racesettle.js +127 -0
- package/lib/spill.js +285 -24
- 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/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/
|
|
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
|
|
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
|
|
@@ -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
|
|
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) => !
|
|
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.
|
|
366
|
-
*
|
|
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
|
|
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 =
|
|
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));
|