openzoo 0.49.7 → 0.49.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/models.js CHANGED
@@ -1,5 +1,11 @@
1
1
  import { config } from './config.js';
2
2
  import { fetchHeaders } from './fetch.js';
3
+ import {
4
+ AUTO_MODEL_ID, autoHasPricedModels, autoModelListEntry, isAutoModel,
5
+ isPricedTokenPair, isUnservableRouteId,
6
+ } from './modelroute.js';
7
+
8
+ export { AUTO_MODEL_ID, isAutoModel, isPricedTokenPair, isUnservableRouteId };
3
9
 
4
10
  /** Same threshold as BIND_MIN_CHARS in hrr.js — kept local so this
5
11
  * module stays importable without the wallet/rpc stack. */
@@ -50,18 +56,43 @@ export function editorSlot(id) {
50
56
  }
51
57
 
52
58
  const CATALOG_TTL_MS = 5 * 60 * 1000;
53
- let cache = { at: 0, ids: null };
59
+ let cache = { at: 0, ids: null, base: null };
60
+
61
+ export function resetZooModelIdsCache() {
62
+ cache = { at: 0, ids: null, base: null };
63
+ }
54
64
 
55
65
  export async function zooModelIds() {
56
- if (cache.ids && Date.now() - cache.at < CATALOG_TTL_MS) return cache.ids;
66
+ if (cache.ids && cache.base === config.apiBase && Date.now() - cache.at < CATALOG_TTL_MS) return cache.ids;
57
67
  const r = await fetchHeaders(`${config.apiBase}/v1/models`);
58
68
  if (!r.ok) throw new Error(`model catalog fetch failed: HTTP ${r.status}`);
59
69
  const d = await r.json();
60
- const ids = (d.data || []).map((m) => m.id).filter(Boolean);
61
- if (ids.length) cache = { at: Date.now(), ids };
70
+ const ids = quoteableRows(d.data).map((m) => m.id).filter((id) => id && !isAutoModel(id));
71
+ if (ids.length) cache = { at: Date.now(), ids, base: config.apiBase };
62
72
  return ids;
63
73
  }
64
74
 
75
+ /** OpenRouter / gateway token price pair. Image/video rows have no prompt. */
76
+ export function tokenPricePair(pricing) {
77
+ if (!pricing || typeof pricing !== 'object') return [NaN, NaN];
78
+ return [Number(pricing.prompt ?? pricing.input), Number(pricing.completion ?? pricing.output)];
79
+ }
80
+
81
+ /**
82
+ * A row the gateway can actually quote for chat. Drops :batch, ~latest
83
+ * pointers, openzoo-* twins, $0 / missing / non-token OpenRouter prices.
84
+ */
85
+ export function isQuoteableModel(m) {
86
+ const id = m?.id;
87
+ if (isAutoModel(id)) return true;
88
+ if (isUnservableRouteId(id)) return false;
89
+ return isPricedTokenPair(...tokenPricePair(m?.pricing));
90
+ }
91
+
92
+ export function quoteableRows(data) {
93
+ return (Array.isArray(data) ? data : []).filter(isQuoteableModel);
94
+ }
95
+
65
96
  /** Vendor fingerprints in harness model ids → zoo catalog prefixes. Order
66
97
  * matters only for overlapping hints; first match wins. */
67
98
  const FAMILIES = [
@@ -119,6 +150,14 @@ const tokensOf = (id) => id.toLowerCase().split(/[^a-z0-9.]+/).filter((t) => t &
119
150
  * OPENZOO_DEFAULT_MODEL is an explicit user override, not a fallback tier.
120
151
  */
121
152
  export function resolveModel(requested, ids) {
153
+ // Virtual router id — never family-match, never steal via OPENZOO_DEFAULT_MODEL.
154
+ if (isAutoModel(requested)) return null;
155
+ // Bare Anthropic / Claude Code ids are never live on Fly/OpenRouter
156
+ // (`claude-opus-5` → 500 unknown model). Rewrite even on a catalog miss
157
+ // or if a gateway row lists the bare name — the request must not leave
158
+ // the sidecar as that id.
159
+ const native = anthropicNativeAlias(requested);
160
+ if (native) return native;
122
161
  if (!requested || !ids?.length || ids.includes(requested)) return null;
123
162
  // `openzoo-` prefixed names exist so an editor cannot mistake them for its
124
163
  // OWN models: Cursor claims any name in its catalog (claude-opus-5, grok-4.6)
@@ -187,6 +226,46 @@ export function resolveModel(requested, ids) {
187
226
  return best;
188
227
  }
189
228
 
229
+ /**
230
+ * Bare Anthropic / Claude Code ids. MEASURED 2026-08-20 against Fly
231
+ * x402-tokens: POST /v1/chat/completions model=claude-opus-5 → 500
232
+ * `unknown model claude-opus-5`. The vendor-prefixed twin
233
+ * (`anthropic/claude-opus-5`) 402s and is priced. Claude Code's Auto
234
+ * permission classifier calls these ids on ANTHROPIC_BASE_URL; if the
235
+ * sidecar forwards the bare name, Bash hangs with
236
+ * "claude-opus-5 is temporarily unavailable, so auto mode cannot
237
+ * determine the safety of Bash". Always rewrite. Never send the bare
238
+ * id to OpenRouter / Fly. Do not push x402-tokens — alias here.
239
+ */
240
+ export const ANTHROPIC_NATIVE_ALIASES = {
241
+ 'claude-opus-5': 'anthropic/claude-opus-5',
242
+ 'claude-opus-5-fast': 'anthropic/claude-opus-5-fast',
243
+ 'claude-3-5-opus': 'anthropic/claude-opus-5',
244
+ 'claude-sonnet-5': 'anthropic/claude-sonnet-5',
245
+ 'claude-sonnet-5-fast': 'anthropic/claude-sonnet-5',
246
+ 'claude-opus-4-8': 'anthropic/claude-opus-4.8',
247
+ 'claude-opus-4.8': 'anthropic/claude-opus-4.8',
248
+ 'claude-fable-5': 'anthropic/claude-fable-5',
249
+ 'claude-haiku-4.5': 'anthropic/claude-haiku-4.5',
250
+ };
251
+
252
+ /** Claude Code sometimes suffixes a window marker (`claude-opus-5[1m]`). */
253
+ function stripAnthropicWindowSuffix(id) {
254
+ return String(id || '').trim().replace(/\[[\d]+m\]$/i, '');
255
+ }
256
+
257
+ /**
258
+ * Priced zoo twin for a bare Anthropic / Claude Code id, or null.
259
+ * Vendor-prefixed ids and openzoo-* twins are left to the rest of resolveModel.
260
+ */
261
+ export function anthropicNativeAlias(requested) {
262
+ if (!requested || typeof requested !== 'string') return null;
263
+ const stripped = stripAnthropicWindowSuffix(requested);
264
+ if (!stripped || stripped.includes('/')) return null;
265
+ if (/^openzoo[-/]/i.test(stripped)) return null;
266
+ return ANTHROPIC_NATIVE_ALIASES[stripped] || ANTHROPIC_NATIVE_ALIASES[stripped.toLowerCase()] || null;
267
+ }
268
+
190
269
  /**
191
270
  * Ids harnesses ship as DEFAULTS (Cursor, Continue, Aider, Codex CLI, Cline,
192
271
  * OpenClaw, LangChain templates…). Merged into GET /v1/models so a harness
@@ -197,58 +276,153 @@ export const ALIAS_IDS = [
197
276
  'gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4-turbo', 'gpt-3.5-turbo',
198
277
  'gpt-5', 'gpt-5-mini', 'chatgpt-4o-latest', 'o1', 'o3', 'o3-mini', 'o4-mini',
199
278
  'claude-3-5-sonnet-latest', 'claude-sonnet-4-0', 'claude-opus-4-1',
279
+ 'claude-opus-5', 'claude-opus-5-fast', 'claude-3-5-opus', 'claude-sonnet-5',
280
+ 'claude-opus-4-8', 'claude-fable-5',
200
281
  'gemini-2.5-pro', 'gemini-2.5-flash', 'grok-4', 'grok-3',
201
282
  'deepseek-chat', 'deepseek-reasoner', 'qwen-max', 'llama-3.3-70b',
202
283
  ];
203
284
 
285
+ export function isHarnessAliasId(id) {
286
+ const stripped = stripAnthropicWindowSuffix(id);
287
+ return ALIAS_IDS.includes(stripped) || Boolean(anthropicNativeAlias(stripped));
288
+ }
289
+
204
290
  /**
205
291
  * Merge alias rows into a /v1/models payload without duplicating real ids.
206
292
  * Each alias inherits context_length and pricing from the model it RESOLVES
207
293
  * to — a harness sizing its corpus off "gpt-4o" gets the real ceiling of the
208
294
  * model that will actually serve it, not a blank.
295
+ *
296
+ * Does not mint openzoo-* twins. Those duplicated every real id (and every
297
+ * :batch id) in Claude Code's /model picker.
209
298
  */
210
- export function augmentModelList(payload) {
211
- const data = Array.isArray(payload?.data) ? payload.data : [];
299
+ export function augmentModelList(payload, { aliases: withAliases = true } = {}) {
300
+ const data = quoteableRows(payload?.data);
212
301
  const have = new Set(data.map((m) => m.id));
213
302
  const ids = data.map((m) => m.id);
214
- // openzoo-* twins of the popular models. An editor that validates a custom
215
- // model against THIS list (Cursor's "Add model" box reports "No models
216
- // available" for anything missing here) can only offer what we publish — and
217
- // the openzoo- prefix is what stops it claiming the name as one of its own
218
- // built-ins and routing to its backend instead of to us.
219
- const branded = [];
220
- for (const src0 of data) {
221
- const id = src0.id;
222
- // Brand only REAL upstream models. Anything we synthesised (a twin or a
223
- // harness alias) must be skipped, or augmenting an already-augmented
224
- // payload mints openzoo-openzoo-* and the catalog grows every pass.
225
- if (!id || id.startsWith('openzoo-') || String(src0.owned_by || '').startsWith('openzoo')) continue;
226
- const short = id.includes('/') ? id.split('/')[1] : id;
227
- const name = `openzoo-${short}`;
228
- if (have.has(name)) continue;
229
- const src = data.find((m) => m.id === id);
230
- branded.push({
231
- id: name,
232
- object: 'model',
233
- owned_by: 'openzoo',
234
- served_by: id,
235
- ...(src?.context_length ? { context_length: src.context_length, context_window: src.context_window ?? src.context_length } : {}),
236
- ...(src?.pricing ? { pricing: src.pricing } : {}),
237
- });
238
- have.add(name);
303
+ const aliases = withAliases
304
+ ? ALIAS_IDS.filter((id) => !have.has(id)).map((id) => {
305
+ const target = data.find((m) => m.id === resolveModel(id, ids));
306
+ return {
307
+ id,
308
+ object: 'model',
309
+ owned_by: 'openzoo-alias',
310
+ ...(target?.context_length ? { context_length: target.context_length, context_window: target.context_window ?? target.context_length } : {}),
311
+ ...(target?.pricing ? { pricing: target.pricing } : {}),
312
+ ...(target ? { served_by: target.id } : {}),
313
+ };
314
+ })
315
+ : [];
316
+ const virtual = [];
317
+ // Auto is listed only when its own shortlist is priced models — never an
318
+ // unquoted OpenRouter id that 500s `bad openrouter price`.
319
+ if (!have.has(AUTO_MODEL_ID) && autoHasPricedModels(undefined, ids.length ? ids : null)) {
320
+ virtual.push(autoModelListEntry());
321
+ have.add(AUTO_MODEL_ID);
239
322
  }
240
- const aliases = ALIAS_IDS.filter((id) => !have.has(id)).map((id) => {
241
- const target = data.find((m) => m.id === resolveModel(id, ids));
242
- return {
243
- id,
244
- object: 'model',
245
- owned_by: 'openzoo-alias',
246
- ...(target?.context_length ? { context_length: target.context_length, context_window: target.context_window ?? target.context_length } : {}),
247
- ...(target?.pricing ? { pricing: target.pricing } : {}),
248
- ...(target ? { served_by: target.id } : {}),
249
- };
250
- });
251
- return { ...payload, object: payload?.object || 'list', data: [...data, ...branded, ...aliases] };
323
+ return { ...payload, object: payload?.object || 'list', data: [...virtual, ...data, ...aliases] };
324
+ }
325
+
326
+ /**
327
+ * Label a catalog id for a picker without minting a new id.
328
+ * `x-ai/grok-4.6` stays `x-ai/grok-4.6` on the wire; the label is just
329
+ * "grok-4.6 (x-ai)". Never invents a `claude-*` name for a non-Anthropic animal.
330
+ */
331
+ export function displayNameFor(id) {
332
+ const raw = String(id || '').trim();
333
+ if (!raw) return raw;
334
+ if (raw.includes('/')) {
335
+ const slash = raw.indexOf('/');
336
+ return `${raw.slice(slash + 1)} (${raw.slice(0, slash)})`;
337
+ }
338
+ return raw;
339
+ }
340
+
341
+ function decorateModelEntry(m) {
342
+ if (!m || typeof m !== 'object') return m;
343
+ return {
344
+ ...m,
345
+ object: m.object || 'model',
346
+ type: m.type || 'model',
347
+ display_name: m.display_name || displayNameFor(m.id),
348
+ };
349
+ }
350
+
351
+ /**
352
+ * OpenAI-compatible /v1/models body. Quoteable chat models only — no :batch,
353
+ * no unpriced / image-video rows, no openzoo-* twins. Extra Anthropic fields
354
+ * (`type`, `display_name`) sit alongside OpenAI ones.
355
+ */
356
+ export function publishModelList(payload, opts) {
357
+ const merged = augmentModelList(payload, opts);
358
+ const data = (merged.data || []).map(decorateModelEntry);
359
+ return {
360
+ ...merged,
361
+ object: merged.object || 'list',
362
+ data,
363
+ has_more: false,
364
+ first_id: data[0]?.id ?? null,
365
+ last_id: data[data.length - 1]?.id ?? null,
366
+ };
367
+ }
368
+
369
+ function anthropicRows(rows) {
370
+ return rows.map((m) => ({
371
+ type: 'model',
372
+ id: m.id,
373
+ display_name: m.display_name || displayNameFor(m.id),
374
+ ...(m.created_at ? { created_at: m.created_at } : {}),
375
+ ...(m.served_by ? { served_by: m.served_by } : {}),
376
+ }));
377
+ }
378
+
379
+ /** openzoo/auto first so Claude Code's picker default is the router, not opus. */
380
+ function withAutoFirst(rows) {
381
+ const list = Array.isArray(rows) ? rows.slice() : [];
382
+ const i = list.findIndex((m) => isAutoModel(m?.id));
383
+ if (i > 0) {
384
+ const [auto] = list.splice(i, 1);
385
+ list.unshift(auto);
386
+ }
387
+ return list;
388
+ }
389
+
390
+ /**
391
+ * Anthropic GET /v1/models shape (id + display_name + type).
392
+ * Claude Code gateway discovery reads `data[].id` and optional `display_name`.
393
+ * Full quoteable catalog — every priced published id, including OpenRouter
394
+ * grok/gemini/gpt rows. Not a 4-id Anthropic cap, not openzoo-* clones.
395
+ * Picker ≠ classifier: rewriteChatModel still pins 16-token classify off opus-5.
396
+ */
397
+ export function anthropicModelList(payload) {
398
+ const published = publishModelList(payload, { aliases: false });
399
+ const data = anthropicRows(withAutoFirst(published.data || []));
400
+ return {
401
+ data,
402
+ has_more: false,
403
+ first_id: data[0]?.id ?? null,
404
+ last_id: data[data.length - 1]?.id ?? null,
405
+ };
406
+ }
407
+
408
+ /** Claude Code / Anthropic SDKs send anthropic-version or an x-app / UA hint. */
409
+ export function wantsAnthropicModelList(headers = {}) {
410
+ const h = headers && typeof headers === 'object' ? headers : {};
411
+ const get = (k) => h[k] ?? h[k.toLowerCase()] ?? '';
412
+ return Boolean(get('anthropic-version'))
413
+ || /claude|anthropic/i.test(String(get('x-app')))
414
+ || /claude|anthropic/i.test(String(get('user-agent')));
415
+ }
416
+
417
+ /**
418
+ * Body for GET /v1/models. Quoteable catalog only (never opus-5-only, never
419
+ * unpriced / :batch / openzoo-* clones). Anthropic-shaped clients get the
420
+ * same quoteable ids in Anthropic shape (type / id / display_name) so Claude
421
+ * Code can select grok, gemini, gpt, etc. OpenAI clients keep object:list
422
+ * of every quoteable id + harness aliases.
423
+ */
424
+ export function modelsListForRequest(payload, headers) {
425
+ return wantsAnthropicModelList(headers) ? anthropicModelList(payload) : publishModelList(payload);
252
426
  }
253
427
 
254
428
  /**
@@ -385,6 +559,9 @@ export function raiseReasoningMaxTokens(parsed, env = process.env) {
385
559
  export function rewriteChatModel(parsed, ids, { bodyLen } = {}) {
386
560
  const from = parsed?.model;
387
561
  const len = bodyLen ?? (parsed == null ? 0 : Buffer.byteLength(JSON.stringify(parsed)));
562
+ if (isAutoModel(from) && !isTinyClassify(parsed, len)) {
563
+ return { parsed, tiny: false, auto: true, from, to: AUTO_MODEL_ID, raised: false };
564
+ }
388
565
  if (isTinyClassify(parsed, len)) {
389
566
  const picked = pickClassifierModel(ids);
390
567
  // pickClassifierModel returns null on an empty catalog or a zoo that
@@ -403,7 +580,7 @@ export function rewriteChatModel(parsed, ids, { bodyLen } = {}) {
403
580
  if (typeof from !== 'string') {
404
581
  return { parsed, tiny: false, from, to: from, raised: false };
405
582
  }
406
- const resolved = resolveModel(from, ids);
583
+ const resolved = resolveModel(from, ids) || anthropicNativeAlias(from);
407
584
  const next = resolved ? { ...parsed, model: resolved } : parsed;
408
585
  const bump = raiseReasoningMaxTokens(next);
409
586
  return {
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
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
  }