openzoo 0.20.5 → 0.20.7
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 +13 -0
- package/lib/pay.js +101 -2
- package/package.json +1 -1
package/lib/models.js
CHANGED
|
@@ -99,6 +99,19 @@ export function resolveModel(requested, ids) {
|
|
|
99
99
|
const bare = String(requested).replace(/^openzoo[-/]/i, '');
|
|
100
100
|
if (bare !== requested) {
|
|
101
101
|
if (ids.includes(bare)) return bare;
|
|
102
|
+
// EXACT INVERSE OF MINTING. augmentModelList creates each twin as
|
|
103
|
+
// `openzoo-<suffix>` where suffix = id.split('/')[1], so the way back is a
|
|
104
|
+
// suffix match — not the similarity scorer below. Without this, a twin fell
|
|
105
|
+
// through to scoring and could land on a NEIGHBOUR of its own source:
|
|
106
|
+
// openzoo-claude-opus-5 resolved to anthropic/claude-opus-5-FAST, and with
|
|
107
|
+
// OPENZOO_DEFAULT_MODEL set in the environment every twin resolved to that
|
|
108
|
+
// one model instead — the user picked a model in the editor and silently got
|
|
109
|
+
// a different one. A twin is an EXPLICIT choice, so it is honoured before
|
|
110
|
+
// both the env override and the scorer.
|
|
111
|
+
const suffixed = ids.filter((id) => id.slice(id.indexOf('/') + 1) === bare);
|
|
112
|
+
// Full-strength before :free/:batch, mirroring the ordering everywhere else.
|
|
113
|
+
const exact = suffixed.find((id) => !id.includes(':')) || suffixed[0];
|
|
114
|
+
if (exact) return exact;
|
|
102
115
|
requested = bare;
|
|
103
116
|
}
|
|
104
117
|
const env = process.env.OPENZOO_DEFAULT_MODEL;
|
package/lib/pay.js
CHANGED
|
@@ -41,6 +41,82 @@ export class UnderfundedError extends Error {
|
|
|
41
41
|
* The proxy, the demo, and the MCP server all go through PayClient.fetch:
|
|
42
42
|
* request → 402 → pick rail (Solana first) → sign → retry with X-PAYMENT.
|
|
43
43
|
*/
|
|
44
|
+
/**
|
|
45
|
+
* Which offered asset last paid, and which are known underfunded — see the
|
|
46
|
+
* comment at the call site in fetch(). Process-lifetime with a TTL so a wallet
|
|
47
|
+
* that gets funded mid-session recovers on its own.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* BALANCE CACHE — checked semi-frequently in the background, not per call.
|
|
51
|
+
*
|
|
52
|
+
* MEASURED: a per-request balance probe put a mainnet round trip (and, when the
|
|
53
|
+
* row came back short, a full top-up probe: resolvePool + poolState + rent) on
|
|
54
|
+
* the critical path of EVERY inference — 4.52s of the 7s a one-line completion
|
|
55
|
+
* took. Balances do not move at request rate; a wallet spending $0.00004 a call
|
|
56
|
+
* has the same balance it had a minute ago.
|
|
57
|
+
*
|
|
58
|
+
* Stale-while-revalidate: a cached value is returned IMMEDIATELY and a refresh is
|
|
59
|
+
* kicked off in the background when it is older than the TTL, so no request ever
|
|
60
|
+
* waits on the network for it. Only a cold cache (first call of the process)
|
|
61
|
+
* blocks. A payment decrements the cached figure locally so a burst of calls
|
|
62
|
+
* cannot overdraw between refreshes.
|
|
63
|
+
*/
|
|
64
|
+
const BALANCE_TTL_MS = Number(process.env.OPENZOO_BALANCE_TTL_MS || 60_000);
|
|
65
|
+
const balanceCache = new Map(); // key -> { raw, ui, at, refreshing }
|
|
66
|
+
|
|
67
|
+
const balKey = (owner, mint) => `${owner}|${mint}`;
|
|
68
|
+
|
|
69
|
+
async function cachedTokenBalance(connection, owner, mint, { force = false } = {}) {
|
|
70
|
+
const key = balKey(owner.toBase58 ? owner.toBase58() : String(owner), mint);
|
|
71
|
+
const hit = balanceCache.get(key);
|
|
72
|
+
const fresh = hit && !force && (Date.now() - hit.at) < BALANCE_TTL_MS;
|
|
73
|
+
if (hit && !force) {
|
|
74
|
+
if (!fresh && !hit.refreshing) {
|
|
75
|
+
// SEMI-FREQUENT REFRESH: fire and forget, so the caller is never blocked.
|
|
76
|
+
hit.refreshing = true;
|
|
77
|
+
tokenBalance(connection, owner, mint)
|
|
78
|
+
.then((b) => balanceCache.set(key, { raw: b.raw, ui: b.ui, at: Date.now(), refreshing: false }))
|
|
79
|
+
.catch(() => { hit.refreshing = false; }); // keep the last good value
|
|
80
|
+
}
|
|
81
|
+
return { raw: hit.raw, ui: hit.ui, cached: true, ageMs: Date.now() - hit.at };
|
|
82
|
+
}
|
|
83
|
+
const b = await tokenBalance(connection, owner, mint);
|
|
84
|
+
balanceCache.set(key, { raw: b.raw, ui: b.ui, at: Date.now(), refreshing: false });
|
|
85
|
+
return { ...b, cached: false, ageMs: 0 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Debit what we just spent so a burst cannot overdraw a cached figure. */
|
|
89
|
+
function debitCachedBalance(owner, mint, amount) {
|
|
90
|
+
const key = balKey(owner.toBase58 ? owner.toBase58() : String(owner), mint);
|
|
91
|
+
const hit = balanceCache.get(key);
|
|
92
|
+
if (hit) hit.raw = hit.raw > amount ? hit.raw - amount : 0n;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Test seam. */
|
|
96
|
+
export function resetBalanceCache() { balanceCache.clear(); }
|
|
97
|
+
|
|
98
|
+
const RAIL_MEMO_MS = Number(process.env.OPENZOO_RAIL_MEMO_MS || 120_000);
|
|
99
|
+
const underfundedUntil = new Map(); // asset -> epoch ms after which to re-try it
|
|
100
|
+
let lastGoodAsset = null;
|
|
101
|
+
|
|
102
|
+
const memoKey = (a) => `${a?.network || ''}|${a?.asset || ''}`;
|
|
103
|
+
|
|
104
|
+
/** lastGood first, known-underfunded last (never dropped — only deprioritised). */
|
|
105
|
+
export function orderCandidatesByMemory(cands, now = Date.now()) {
|
|
106
|
+
const skip = (c) => (underfundedUntil.get(memoKey(c)) ?? 0) > now;
|
|
107
|
+
const good = (c) => lastGoodAsset && memoKey(c) === lastGoodAsset;
|
|
108
|
+
// Stable partition: proven payer, then untried, then recently-underfunded. The
|
|
109
|
+
// last group is KEPT so a wallet funded seconds ago is still reachable in the
|
|
110
|
+
// same call if nothing else works.
|
|
111
|
+
return [...cands].sort((a, b) => (good(b) - good(a)) || (skip(a) - skip(b)));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Test seam: forget everything the rail memory learned. */
|
|
115
|
+
export function resetRailMemory() {
|
|
116
|
+
underfundedUntil.clear();
|
|
117
|
+
lastGoodAsset = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
44
120
|
export class PayClient {
|
|
45
121
|
constructor() {
|
|
46
122
|
const w = loadOrCreateWallet();
|
|
@@ -65,7 +141,12 @@ export class PayClient {
|
|
|
65
141
|
const rail = railOf(accept);
|
|
66
142
|
if (rail === 'solana') {
|
|
67
143
|
const need = BigInt(accept.maxAmountRequired);
|
|
68
|
-
|
|
144
|
+
let bal = await cachedTokenBalance(this.connection, this.keypair.publicKey, accept.asset);
|
|
145
|
+
// Never declare a wallet short on a STALE read — the expensive top-up path
|
|
146
|
+
// and the underfunded error both deserve a live number.
|
|
147
|
+
if (bal.raw < need && bal.cached) {
|
|
148
|
+
bal = await cachedTokenBalance(this.connection, this.keypair.publicKey, accept.asset, { force: true });
|
|
149
|
+
}
|
|
69
150
|
if (bal.raw < need) {
|
|
70
151
|
const topUp = await this.topUpQuotedAsset(accept, need, onStage);
|
|
71
152
|
if (topUp.preInstructions) {
|
|
@@ -188,7 +269,18 @@ export class PayClient {
|
|
|
188
269
|
// it HOLDS: every offered row is tried best-first, and only when NONE is
|
|
189
270
|
// affordable does the call fail — never because the first-choice asset
|
|
190
271
|
// alone ran dry while another funded one sat in the wallet.
|
|
191
|
-
const
|
|
272
|
+
const ordered = orderAccepts(quote, config.token, { allowRH: this.allowRH, forceRail: config.rail });
|
|
273
|
+
// RAIL MEMORY — the single biggest source of per-call latency.
|
|
274
|
+
// MEASURED: the first offered row (yUSDCx) was underfunded, and finding that
|
|
275
|
+
// out costs 4.52s, because an underfunded Solana row attempts a top-up first
|
|
276
|
+
// (resolvePool + poolState + balances + rent-exemption lookups). The row that
|
|
277
|
+
// actually pays (wTOKENx) then builds in 0.36s. That 4.5s was being re-paid on
|
|
278
|
+
// EVERY request to rediscover a fact that had not changed.
|
|
279
|
+
// So: remember which asset just paid and try it first, and skip assets known
|
|
280
|
+
// underfunded within the TTL. Bounded and self-healing — the memo expires, a
|
|
281
|
+
// funded wallet is re-tried automatically, and if every row is memoized we fall
|
|
282
|
+
// back to the full list rather than manufacturing a dead end.
|
|
283
|
+
const candidates = orderCandidatesByMemory(ordered);
|
|
192
284
|
onStage?.('quoted');
|
|
193
285
|
let accept = null;
|
|
194
286
|
let payment = null;
|
|
@@ -203,11 +295,18 @@ export class PayClient {
|
|
|
203
295
|
try {
|
|
204
296
|
payment = await this.buildPaymentFor(cand, onStage);
|
|
205
297
|
accept = cand;
|
|
298
|
+
lastGoodAsset = memoKey(cand);
|
|
299
|
+
underfundedUntil.delete(memoKey(cand));
|
|
300
|
+
if (railOf(cand) === 'solana') {
|
|
301
|
+
try { debitCachedBalance(this.keypair.publicKey, cand.asset, BigInt(cand.maxAmountRequired)); } catch { /* advisory */ }
|
|
302
|
+
}
|
|
206
303
|
break;
|
|
207
304
|
} catch (e) {
|
|
208
305
|
const funding = e instanceof UnderfundedError
|
|
209
306
|
|| e?.name === 'UnderlyingShortError' || e?.name === 'NeedsGasError';
|
|
210
307
|
if (!funding) throw e;
|
|
308
|
+
// Remember it so the next call does not re-pay the discovery cost.
|
|
309
|
+
underfundedUntil.set(memoKey(cand), Date.now() + RAIL_MEMO_MS);
|
|
211
310
|
fundErrs.push({ sym: cand?.extra?.symbol || cand?.asset, err: e });
|
|
212
311
|
}
|
|
213
312
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.7",
|
|
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",
|