openzoo 0.48.89 → 0.48.94
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/grokui.mjs +736 -81
- package/lib/mcp.js +2 -0
- package/lib/pay.js +10 -2
- package/lib/proxy.js +6 -1
- package/lib/subscription.js +207 -0
- package/package.json +1 -1
package/lib/mcp.js
CHANGED
|
@@ -8,6 +8,7 @@ import { tokenBalance } from './x402.js';
|
|
|
8
8
|
import { askWithContext, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
|
|
9
9
|
import { listContexts } from './contexts.js';
|
|
10
10
|
import { withNamespace } from './namespace.js';
|
|
11
|
+
import { subscriptionPublicView } from './subscription.js';
|
|
11
12
|
|
|
12
13
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
13
14
|
// The model zoo_ask uses when the caller does not name one. Opus 5 by default:
|
|
@@ -383,6 +384,7 @@ export function buildMcpServer() {
|
|
|
383
384
|
fundHint: 'send a few cents of a listed asset to the address for that rail — Solana assets to solanaAddress, Base assets to evmAddress. The shim converts to whatever the 402 quotes, at payment time. Force a rail with OPENZOO_RAIL=solana|base|robinhood.',
|
|
384
385
|
balances,
|
|
385
386
|
receipts: client.receipts.map((r) => ({ at: r.at, line: r.line })),
|
|
387
|
+
subscription: subscriptionPublicView(),
|
|
386
388
|
});
|
|
387
389
|
});
|
|
388
390
|
|
package/lib/pay.js
CHANGED
|
@@ -13,6 +13,7 @@ import { withNamespace } from './namespace.js';
|
|
|
13
13
|
import {
|
|
14
14
|
resolvePool, poolState, depositForShares, buildWrapInstructions, sendWrap,
|
|
15
15
|
} from './wrap.js';
|
|
16
|
+
import { applySubscriptionHeaders, loadSubscription, stripAuthorization } from './subscription.js';
|
|
16
17
|
|
|
17
18
|
export class QuoteTooHighError extends Error {
|
|
18
19
|
constructor(billedUsd, quote) {
|
|
@@ -333,8 +334,15 @@ export class PayClient {
|
|
|
333
334
|
// Contexts are tenanted by this namespace server-side — a request without
|
|
334
335
|
// it cannot see corpora this wallet bound.
|
|
335
336
|
init = { ...init, headers: withNamespace(init.headers || {}) };
|
|
337
|
+
// Subscription key · no x402. A stored Stripe key is a bearer on the zoo
|
|
338
|
+
// API (same as the public /billing/done snippet). Wallet/x402 stays if
|
|
339
|
+
// there is no key, or if the gateway still answers 402.
|
|
340
|
+
const sub = loadSubscription();
|
|
341
|
+
if (sub?.key) init = { ...init, headers: applySubscriptionHeaders(init.headers, sub) };
|
|
336
342
|
const first = await fetch(url, init);
|
|
337
|
-
if (first.status !== 402)
|
|
343
|
+
if (first.status !== 402) {
|
|
344
|
+
return { response: first, paid: false, subscription: Boolean(sub?.key && first.ok) };
|
|
345
|
+
}
|
|
338
346
|
|
|
339
347
|
const quote = parse402(await first.json());
|
|
340
348
|
// config.rail (OPENZOO_RAIL) steers every front — proxy, demo, MCP — since
|
|
@@ -394,7 +402,7 @@ export class PayClient {
|
|
|
394
402
|
onStage?.('paying');
|
|
395
403
|
const response = await fetch(url, {
|
|
396
404
|
...init,
|
|
397
|
-
headers: { ...(init.headers || {}), 'X-PAYMENT': payment.header },
|
|
405
|
+
headers: { ...stripAuthorization(init.headers || {}), 'X-PAYMENT': payment.header },
|
|
398
406
|
});
|
|
399
407
|
const settle = decodeSettleHeader(response.headers.get('x-payment-response'))
|
|
400
408
|
|| { signature: payment.ownerSignature };
|
package/lib/proxy.js
CHANGED
|
@@ -24,6 +24,7 @@ import { anthropicToOpenAI, openAIToAnthropic, streamOpenAIToAnthropic, writeAnt
|
|
|
24
24
|
import { responsesToChat, chatToResponses, writeResponsesSse } from './responses.js';
|
|
25
25
|
import { loadSessionSpend, saveSessionSpend } from './session.js';
|
|
26
26
|
import { creditBalance, quotedPrices } from './info.js';
|
|
27
|
+
import { subscriptionPublicView } from './subscription.js';
|
|
27
28
|
import { priceHoldings } from './livestatus.js';
|
|
28
29
|
|
|
29
30
|
const HOP_BY_HOP = new Set([
|
|
@@ -891,6 +892,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
891
892
|
res.end(JSON.stringify({
|
|
892
893
|
spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls,
|
|
893
894
|
creditUsd, chainUsd: money.chainUsd,
|
|
895
|
+
subscription: subscriptionPublicView(),
|
|
894
896
|
}));
|
|
895
897
|
return;
|
|
896
898
|
}
|
|
@@ -914,6 +916,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
914
916
|
creditUsd,
|
|
915
917
|
chainUsd: money.chainUsd,
|
|
916
918
|
holdings: money.holdings,
|
|
919
|
+
subscription: subscriptionPublicView(),
|
|
917
920
|
}));
|
|
918
921
|
return;
|
|
919
922
|
}
|
|
@@ -1021,7 +1024,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1021
1024
|
},
|
|
1022
1025
|
mcp: `${self.replace(/\/v1$/, '')}/mcp`,
|
|
1023
1026
|
upstream: config.apiBase,
|
|
1024
|
-
payment:
|
|
1027
|
+
payment: subscriptionPublicView().active
|
|
1028
|
+
? 'subscription key � no x402 � wallet/x402 remains the other method'
|
|
1029
|
+
: 'x402 per request from the operator\'s local burner wallet � no API key, no account',
|
|
1025
1030
|
auth: viaTunnel
|
|
1026
1031
|
? 'this public URL requires the oz_… bearer for paid endpoints; /v1/models and /v1/hrr/bind are free'
|
|
1027
1032
|
: 'localhost is keyless',
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe subscription keys for the zoo API — the other pay lane next to
|
|
3
|
+
* wallet/x402. The live billing API is zoo.openzoo.fun; this file does not
|
|
4
|
+
* invent a second backend.
|
|
5
|
+
*
|
|
6
|
+
* After checkout the site lands on /billing/done?session=<cs_…> and polls
|
|
7
|
+
* GET /api/billing/key?session=… until Stripe confirms. That response is how
|
|
8
|
+
* a desktop client receives the key (no Stripe cookie on Electron). A user
|
|
9
|
+
* who already subscribed can paste the same key, or that success URL.
|
|
10
|
+
*
|
|
11
|
+
* Use: Authorization: Bearer <key> against x402-tokens.fly.dev — no 402
|
|
12
|
+
* signing. Wallet/x402 stays if no key is stored.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
export const BILLING_ORIGIN = 'https://zoo.openzoo.fun';
|
|
19
|
+
export const SUBSCRIPTIONS_PAGE = 'https://zoo.openzoo.fun/subscriptions';
|
|
20
|
+
|
|
21
|
+
export function subscriptionFile(home = os.homedir()) {
|
|
22
|
+
return process.env.OPENZOO_SUBSCRIPTION_PATH
|
|
23
|
+
|| path.join(home, '.openzoo', 'subscription.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function titleCase(id) {
|
|
27
|
+
const s = String(id || '').trim();
|
|
28
|
+
if (!s) return '';
|
|
29
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function asKey(v) {
|
|
33
|
+
return String(v || '').trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Persist a subscription key (chmod 600). Never log the value. */
|
|
37
|
+
export function saveSubscription(rec, file = subscriptionFile()) {
|
|
38
|
+
const key = asKey(rec?.key);
|
|
39
|
+
if (!key) return null;
|
|
40
|
+
const payload = {
|
|
41
|
+
key,
|
|
42
|
+
tier: rec.tier ? String(rec.tier) : null,
|
|
43
|
+
tierName: rec.tierName ? String(rec.tierName) : (rec.tier ? titleCase(rec.tier) : null),
|
|
44
|
+
sessionId: rec.sessionId ? String(rec.sessionId) : null,
|
|
45
|
+
savedAt: Date.now(),
|
|
46
|
+
};
|
|
47
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
48
|
+
const tmp = `${file}.tmp`;
|
|
49
|
+
fs.writeFileSync(tmp, JSON.stringify(payload), { mode: 0o600 });
|
|
50
|
+
fs.renameSync(tmp, file);
|
|
51
|
+
cached = { file, mtime: fs.statSync(file).mtimeMs, data: payload };
|
|
52
|
+
return payload;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function clearSubscription(file = subscriptionFile()) {
|
|
56
|
+
try { fs.unlinkSync(file); } catch { /* already gone */ }
|
|
57
|
+
if (cached.file === file) cached = { file: '', mtime: 0, data: null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let cached = { file: '', mtime: 0, data: null };
|
|
61
|
+
|
|
62
|
+
export function loadSubscription(file = subscriptionFile()) {
|
|
63
|
+
const envKey = asKey(process.env.OPENZOO_SUBSCRIPTION_KEY);
|
|
64
|
+
if (envKey) {
|
|
65
|
+
return {
|
|
66
|
+
key: envKey,
|
|
67
|
+
tier: process.env.OPENZOO_SUBSCRIPTION_TIER || null,
|
|
68
|
+
tierName: process.env.OPENZOO_SUBSCRIPTION_TIER
|
|
69
|
+
? titleCase(process.env.OPENZOO_SUBSCRIPTION_TIER)
|
|
70
|
+
: null,
|
|
71
|
+
sessionId: null,
|
|
72
|
+
source: 'env',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const st = fs.statSync(file);
|
|
77
|
+
if (cached.file === file && cached.mtime === st.mtimeMs && cached.data) return cached.data;
|
|
78
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
79
|
+
if (!asKey(data?.key)) {
|
|
80
|
+
cached = { file, mtime: st.mtimeMs, data: null };
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const rec = {
|
|
84
|
+
key: asKey(data.key),
|
|
85
|
+
tier: data.tier || null,
|
|
86
|
+
tierName: data.tierName || (data.tier ? titleCase(data.tier) : null),
|
|
87
|
+
sessionId: data.sessionId || null,
|
|
88
|
+
source: 'file',
|
|
89
|
+
};
|
|
90
|
+
cached = { file, mtime: st.mtimeMs, data: rec };
|
|
91
|
+
return rec;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Public HUD/wallet view — never includes the secret. */
|
|
98
|
+
export function subscriptionPublicView(sub = loadSubscription()) {
|
|
99
|
+
if (!asKey(sub?.key)) return { active: false };
|
|
100
|
+
const name = String(sub.tierName || titleCase(sub.tier) || '').trim();
|
|
101
|
+
return {
|
|
102
|
+
active: true,
|
|
103
|
+
tier: sub.tier || null,
|
|
104
|
+
tierName: name || null,
|
|
105
|
+
label: name ? `${name} · no x402` : 'Subscription key · no x402',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A paste is either the bearer key itself, or the site's success URL
|
|
111
|
+
* (`/billing/done?session=cs_…`). session_id is accepted too — Stripe's
|
|
112
|
+
* default query name — but the live page uses `session`.
|
|
113
|
+
*/
|
|
114
|
+
export function parseSubscriptionPaste(text) {
|
|
115
|
+
const raw = String(text || '').trim();
|
|
116
|
+
if (!raw) return { error: 'empty' };
|
|
117
|
+
let session = '';
|
|
118
|
+
try {
|
|
119
|
+
if (/^https?:\/\//i.test(raw) || raw.includes('session=')) {
|
|
120
|
+
const url = new URL(raw, BILLING_ORIGIN);
|
|
121
|
+
session = url.searchParams.get('session') || url.searchParams.get('session_id') || '';
|
|
122
|
+
}
|
|
123
|
+
} catch { /* not a URL */ }
|
|
124
|
+
if (!session) {
|
|
125
|
+
const m = /(?:session_id|session)=([A-Za-z0-9_]+)/.exec(raw);
|
|
126
|
+
if (m) session = m[1];
|
|
127
|
+
}
|
|
128
|
+
if (session) return { session };
|
|
129
|
+
if (/^https?:\/\//i.test(raw)) return { error: 'no session in URL' };
|
|
130
|
+
if (raw.length < 8 || /\s/.test(raw)) return { error: 'not a key' };
|
|
131
|
+
return { key: raw };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function applySubscriptionHeaders(headers = {}, sub = loadSubscription()) {
|
|
135
|
+
const key = asKey(sub?.key);
|
|
136
|
+
if (!key) return headers;
|
|
137
|
+
return { ...headers, authorization: `Bearer ${key}` };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function stripAuthorization(headers = {}) {
|
|
141
|
+
const out = { ...headers };
|
|
142
|
+
delete out.authorization;
|
|
143
|
+
delete out.Authorization;
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function billingJson(url, init) {
|
|
148
|
+
const r = await fetch(url, init);
|
|
149
|
+
const body = await r.json().catch(() => ({}));
|
|
150
|
+
return { http: r.status, body };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function billingTiers() {
|
|
154
|
+
const { http, body } = await billingJson(`${BILLING_ORIGIN}/api/billing/tiers`);
|
|
155
|
+
if (!body?.ok || !Array.isArray(body.tiers)) {
|
|
156
|
+
throw new Error(body?.error || `tiers HTTP ${http}`);
|
|
157
|
+
}
|
|
158
|
+
return body;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function billingCheckout(tier) {
|
|
162
|
+
const id = String(tier || '').trim();
|
|
163
|
+
if (!id) throw new Error('tier required');
|
|
164
|
+
const { http, body } = await billingJson(`${BILLING_ORIGIN}/api/billing/checkout`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'content-type': 'application/json' },
|
|
167
|
+
body: JSON.stringify({ tier: id }),
|
|
168
|
+
});
|
|
169
|
+
if (!body?.ok || !body.url) {
|
|
170
|
+
throw new Error(body?.error || `checkout HTTP ${http}`);
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, url: body.url, sessionId: body.sessionId || null, tier: id };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Poll the same endpoint the public /billing/done page uses. */
|
|
176
|
+
export async function fetchBillingKey(session) {
|
|
177
|
+
const sid = String(session || '').trim();
|
|
178
|
+
if (!sid) return { ok: false, error: 'session required' };
|
|
179
|
+
const { body } = await billingJson(
|
|
180
|
+
`${BILLING_ORIGIN}/api/billing/key?session=${encodeURIComponent(sid)}`,
|
|
181
|
+
);
|
|
182
|
+
return body && typeof body === 'object' ? body : { ok: false, error: 'empty key response' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* If the live key endpoint returned a key, persist it and return a public
|
|
187
|
+
* view (the secret stays on disk). Pending/error bodies pass through.
|
|
188
|
+
*/
|
|
189
|
+
export function ingestBillingKeyResponse(body, extra = {}, file = subscriptionFile()) {
|
|
190
|
+
const key = asKey(body?.key);
|
|
191
|
+
if (!key) {
|
|
192
|
+
if (body?.pending) return { ok: true, pending: true, saved: false };
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
pending: false,
|
|
196
|
+
saved: false,
|
|
197
|
+
error: body?.error || 'no key yet',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const rec = saveSubscription({
|
|
201
|
+
key,
|
|
202
|
+
tier: body.tier || extra.tier || null,
|
|
203
|
+
tierName: body.tierName || body.name || extra.tierName || null,
|
|
204
|
+
sessionId: extra.sessionId || extra.session || null,
|
|
205
|
+
}, file);
|
|
206
|
+
return { ok: true, pending: false, saved: true, ...subscriptionPublicView(rec) };
|
|
207
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.94",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 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",
|