openzoo 0.48.87 → 0.48.89
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 +395 -64
- package/lib/info.js +3 -1
- package/lib/livestatus.js +102 -0
- package/lib/podagent.mjs +78 -32
- package/lib/proxy.js +56 -13
- package/lib/session.js +58 -0
- package/package.json +1 -1
package/lib/info.js
CHANGED
|
@@ -33,7 +33,7 @@ function fmtUi(raw, decimals) {
|
|
|
33
33
|
* Symbols come back wrapped (wTOKENx, wLEOSx, wUSDGx); strip the wrapper so a
|
|
34
34
|
* row for the plain token the user actually holds finds its price.
|
|
35
35
|
*/
|
|
36
|
-
async function quotedPrices() {
|
|
36
|
+
export async function quotedPrices() {
|
|
37
37
|
const out = {};
|
|
38
38
|
try {
|
|
39
39
|
// Imported here, not at module scope, matching affordableUsd below — this
|
|
@@ -244,6 +244,8 @@ export async function topUp(usdArg) {
|
|
|
244
244
|
console.log('calls now settle against this balance instead of paying on-chain each time.');
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
export { priceHoldings, formatHoldingMoney } from './livestatus.js';
|
|
248
|
+
|
|
247
249
|
/** Current prepaid credit for this wallet's namespace. */
|
|
248
250
|
export async function creditBalance() {
|
|
249
251
|
const { withNamespace } = await import('./namespace.js');
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live turn status + stream idle timeout.
|
|
3
|
+
*
|
|
4
|
+
* A long x402 pay or a quiet SSE used to leave grokui on mute "…" dots.
|
|
5
|
+
* Callers paint ONE mutating status line (paying / waiting on model / current
|
|
6
|
+
* tool) and abort a reader that has gone silent.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const STREAM_IDLE_MS = Number(process.env.OZ_STREAM_IDLE_MS || 55_000);
|
|
10
|
+
export const STALE_THINKING_MS = Number(process.env.OZ_STALE_THINKING_MS || 90_000);
|
|
11
|
+
export const MODEL_WAIT_TICK_MS = 1000;
|
|
12
|
+
export const MODEL_WAIT_SECONDS_AFTER_MS = 2000;
|
|
13
|
+
|
|
14
|
+
const DIRECTIVE = /^(?:[ \t>*-]*)(SPAWN|SEND|PING|PEEK|WRITE|READ|EDIT|MULTIEDIT|NOTEBOOK|LS|LIST|DIR|GLOB|FIND|GREP|TODO|SERVE|FETCH|MCP|RUN):\s*(.*)$/im;
|
|
15
|
+
|
|
16
|
+
export function clipStatusArg(s, n = 42) {
|
|
17
|
+
const t = String(s || '').replace(/\s+/g, ' ').trim();
|
|
18
|
+
if (!t) return '';
|
|
19
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatModelWait(elapsedMs) {
|
|
23
|
+
const s = Math.floor(Math.max(0, Number(elapsedMs) || 0) / 1000);
|
|
24
|
+
return s >= 2 ? `waiting on model… ${s}s` : 'waiting on model…';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatPayStatus(attempt = 0) {
|
|
28
|
+
return Number(attempt) > 0 ? 'waiting on x402…' : 'paying…';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function peekDirectiveStatus(reply, runCmd) {
|
|
32
|
+
if (runCmd) return `RUN: ${clipStatusArg(runCmd)}`;
|
|
33
|
+
const raw = String(reply || '');
|
|
34
|
+
const m = DIRECTIVE.exec(raw);
|
|
35
|
+
if (!m) return '';
|
|
36
|
+
let kind = m[1].toUpperCase();
|
|
37
|
+
if (kind === 'LS' || kind === 'LIST' || kind === 'DIR' || kind === 'FIND') kind = 'GLOB';
|
|
38
|
+
const rest = clipStatusArg(m[2]);
|
|
39
|
+
return rest ? `${kind}: ${rest}` : `${kind}:`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One-shot wait clock. Calls onStatus immediately, then every 1s with elapsed. */
|
|
43
|
+
export function startModelWait(onStatus, now = Date.now) {
|
|
44
|
+
if (typeof onStatus !== 'function') return () => {};
|
|
45
|
+
const t0 = now();
|
|
46
|
+
let stopped = false;
|
|
47
|
+
const tick = () => {
|
|
48
|
+
if (stopped) return;
|
|
49
|
+
onStatus(formatModelWait(now() - t0));
|
|
50
|
+
};
|
|
51
|
+
tick();
|
|
52
|
+
const iv = setInterval(tick, MODEL_WAIT_TICK_MS);
|
|
53
|
+
iv.unref?.();
|
|
54
|
+
return () => { stopped = true; clearInterval(iv); };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* On-chain holdings as money. Stables are $1 even without a quote; everything
|
|
59
|
+
* else needs tokenUsd from the chat 402 (same prices `openzoo balance` uses).
|
|
60
|
+
* A TOKEN pile that used to print as "18584 TOKEN" becomes $4.25 here.
|
|
61
|
+
*/
|
|
62
|
+
export function priceHoldings(snap, prices = {}) {
|
|
63
|
+
let chainUsd = 0;
|
|
64
|
+
const holdings = [];
|
|
65
|
+
for (const b of snap || []) {
|
|
66
|
+
const symbol = String(b.symbol || '');
|
|
67
|
+
const ui = Number(b.ui) || 0;
|
|
68
|
+
const key = symbol.toUpperCase();
|
|
69
|
+
const listed = prices[key] ?? prices[symbol];
|
|
70
|
+
const stable = (key === 'USDC' || key === 'USDG') ? 1 : null;
|
|
71
|
+
const tokenUsd = listed != null && Number.isFinite(Number(listed)) ? Number(listed) : stable;
|
|
72
|
+
const usd = tokenUsd != null ? ui * tokenUsd : null;
|
|
73
|
+
if (usd != null) chainUsd += usd;
|
|
74
|
+
holdings.push({ symbol, ui, chain: b.chain || 'solana', tokenUsd, usd });
|
|
75
|
+
}
|
|
76
|
+
return { chainUsd, holdings };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatHoldingMoney(h) {
|
|
80
|
+
const qty = `${h.ui} ${h.symbol}`;
|
|
81
|
+
if (h.usd == null || !Number.isFinite(h.usd)) return qty;
|
|
82
|
+
const money = h.usd >= 0.01 || h.usd === 0 ? h.usd.toFixed(2) : h.usd.toFixed(4);
|
|
83
|
+
return `${qty} ($${money})`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function readWithIdleTimeout(reader, idleMs = STREAM_IDLE_MS) {
|
|
87
|
+
let to;
|
|
88
|
+
try {
|
|
89
|
+
return await Promise.race([
|
|
90
|
+
reader.read(),
|
|
91
|
+
new Promise((_, reject) => {
|
|
92
|
+
to = setTimeout(() => {
|
|
93
|
+
const err = new Error('stream idle timeout');
|
|
94
|
+
err.code = 'STREAM_IDLE';
|
|
95
|
+
reject(err);
|
|
96
|
+
}, idleMs);
|
|
97
|
+
}),
|
|
98
|
+
]);
|
|
99
|
+
} finally {
|
|
100
|
+
clearTimeout(to);
|
|
101
|
+
}
|
|
102
|
+
}
|
package/lib/podagent.mjs
CHANGED
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
import http from 'node:http';
|
|
23
23
|
import { appendFileSync } from 'node:fs';
|
|
24
24
|
import { randomUUID } from 'node:crypto';
|
|
25
|
+
import {
|
|
26
|
+
formatPayStatus, startModelWait, readWithIdleTimeout, STREAM_IDLE_MS,
|
|
27
|
+
} from './livestatus.js';
|
|
25
28
|
|
|
26
29
|
const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
|
|
27
30
|
.split(',').map((s) => Number(s.trim())).filter(Boolean);
|
|
@@ -273,7 +276,7 @@ export function adaptiveTopK(boundItems) {
|
|
|
273
276
|
return Math.max(16, Math.min(256, Math.ceil(Math.sqrt(n) * 2)));
|
|
274
277
|
}
|
|
275
278
|
|
|
276
|
-
async function postChat(body, contextId, topK) {
|
|
279
|
+
async function postChat(body, contextId, topK, onStatus) {
|
|
277
280
|
let r;
|
|
278
281
|
for (let attempt = 0; attempt <= PAYMENT_RETRIES; attempt++) {
|
|
279
282
|
r = await fetch(`${PROXY}/chat/completions`, {
|
|
@@ -291,6 +294,9 @@ async function postChat(body, contextId, topK) {
|
|
|
291
294
|
body: JSON.stringify(body),
|
|
292
295
|
});
|
|
293
296
|
if (r.status !== 402 || attempt === PAYMENT_RETRIES) return r;
|
|
297
|
+
// A 402 retry used to be silent — grokui sat on mute "…" for the whole
|
|
298
|
+
// settle. Tell the watcher this attempt is paying, not wedged.
|
|
299
|
+
onStatus?.(formatPayStatus(attempt));
|
|
294
300
|
await new Promise((res) => setTimeout(res, 800 * (attempt + 1)));
|
|
295
301
|
}
|
|
296
302
|
return r;
|
|
@@ -326,7 +332,7 @@ export async function brain(messages, contextId, modelOverride, topK) {
|
|
|
326
332
|
messages = vision ? messages : stripImages(messages);
|
|
327
333
|
const r = await postChat(
|
|
328
334
|
{ model, max_tokens: 4096, messages: withModelId(messages, model), plugins: [{ id: 'web' }] },
|
|
329
|
-
contextId, topK,
|
|
335
|
+
contextId, topK, undefined,
|
|
330
336
|
);
|
|
331
337
|
const j = await r.json().catch(() => ({}));
|
|
332
338
|
const content = j?.choices?.[0]?.message?.content;
|
|
@@ -352,7 +358,7 @@ async function brainContinue(messages, sofar, contextId, modelOverride, round) {
|
|
|
352
358
|
const r = await postChat(
|
|
353
359
|
{ model, max_tokens: Math.min(4096 * (2 ** (round + 1)), MAX_CONTINUE_TOKENS),
|
|
354
360
|
messages: withModelId(vision ? next : stripImages(next), model), plugins: [{ id: 'web' }] },
|
|
355
|
-
contextId,
|
|
361
|
+
contextId, undefined, undefined,
|
|
356
362
|
);
|
|
357
363
|
const j = await r.json().catch(() => ({}));
|
|
358
364
|
const more = j?.choices?.[0]?.message?.content || '';
|
|
@@ -364,15 +370,17 @@ async function brainContinue(messages, sofar, contextId, modelOverride, round) {
|
|
|
364
370
|
|
|
365
371
|
/** Same call, but streamed — invokes onDelta(text) as tokens arrive (for a
|
|
366
372
|
* live-typing UI) and resolves with the full accumulated text at the end, so
|
|
367
|
-
* callers that need to parse a directive out of the complete reply still can.
|
|
368
|
-
|
|
373
|
+
* callers that need to parse a directive out of the complete reply still can.
|
|
374
|
+
* onStatus(detail) is an optional second channel: paying / waiting on model /
|
|
375
|
+
* thinking, so a 20–40s settle is visibly alive instead of mute dots. */
|
|
376
|
+
export async function brainStream(messages, onDelta, contextId, modelOverride, maxTokens, round = 0, topK = 0, onStatus) {
|
|
369
377
|
const vision = hasImages(messages);
|
|
370
378
|
const model = vision ? VISION_MODEL : (modelOverride || MODEL);
|
|
371
379
|
messages = vision ? messages : stripImages(messages);
|
|
372
380
|
const budget = maxTokens || MAX_TOKENS;
|
|
373
381
|
const r = await postChat(
|
|
374
382
|
{ model, max_tokens: budget, messages: withModelId(messages, model), plugins: [{ id: 'web' }], stream: true },
|
|
375
|
-
contextId, topK,
|
|
383
|
+
contextId, topK, onStatus,
|
|
376
384
|
);
|
|
377
385
|
if (!r.ok || !r.body) {
|
|
378
386
|
// fall back to the non-streaming path rather than fail outright
|
|
@@ -384,32 +392,69 @@ export async function brainStream(messages, onDelta, contextId, modelOverride, m
|
|
|
384
392
|
const reader = r.body.getReader();
|
|
385
393
|
const decoder = new TextDecoder();
|
|
386
394
|
let buf = '', full = '', reasonedChars = 0, finish = '';
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
for (
|
|
394
|
-
|
|
395
|
-
if (!s.startsWith('data:')) continue;
|
|
396
|
-
const payload = s.slice(5).trim();
|
|
397
|
-
if (payload === '[DONE]') continue;
|
|
395
|
+
let stopWait = startModelWait(onStatus);
|
|
396
|
+
const noteThinking = () => {
|
|
397
|
+
stopWait();
|
|
398
|
+
onStatus?.('thinking…');
|
|
399
|
+
};
|
|
400
|
+
try {
|
|
401
|
+
for (;;) {
|
|
402
|
+
let chunk;
|
|
398
403
|
try {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
404
|
+
chunk = await readWithIdleTimeout(reader, STREAM_IDLE_MS);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
if (e?.code !== 'STREAM_IDLE') throw e;
|
|
407
|
+
try { await reader.cancel(); } catch { /* already closed */ }
|
|
408
|
+
stopWait();
|
|
409
|
+
// A quiet SSE used to hang this loop forever and leave grokui on
|
|
410
|
+
// thinking / "…". Prefer what we have; if we have nothing, one
|
|
411
|
+
// non-stream retry rather than a mute bubble.
|
|
412
|
+
if (full) {
|
|
413
|
+
const note = '\n\n(stream stalled — showing what arrived before the timeout)';
|
|
414
|
+
onDelta(note);
|
|
415
|
+
return full + note;
|
|
416
|
+
}
|
|
417
|
+
onStatus?.('waiting on model…');
|
|
418
|
+
const fallback = await brain(messages, contextId, modelOverride, topK);
|
|
419
|
+
if (fallback) onDelta(fallback);
|
|
420
|
+
return fallback || '(stream timed out — no tokens arrived)';
|
|
421
|
+
}
|
|
422
|
+
const { value, done } = chunk;
|
|
423
|
+
if (done) break;
|
|
424
|
+
buf += decoder.decode(value, { stream: true });
|
|
425
|
+
const lines = buf.split('\n');
|
|
426
|
+
buf = lines.pop(); // last line may be incomplete — keep it for next chunk
|
|
427
|
+
for (const line of lines) {
|
|
428
|
+
const s = line.trim();
|
|
429
|
+
if (!s.startsWith('data:')) continue;
|
|
430
|
+
const payload = s.slice(5).trim();
|
|
431
|
+
if (payload === '[DONE]') continue;
|
|
432
|
+
try {
|
|
433
|
+
const c = JSON.parse(payload)?.choices?.[0];
|
|
434
|
+
const d = c?.delta;
|
|
435
|
+
// The LAST chunk carries why generation stopped. "length" means the
|
|
436
|
+
// budget ran out mid-answer — the only way to tell a finished reply
|
|
437
|
+
// from a guillotined one.
|
|
438
|
+
if (c?.finish_reason) finish = c.finish_reason;
|
|
439
|
+
if (d?.content) {
|
|
440
|
+
stopWait();
|
|
441
|
+
full += d.content;
|
|
442
|
+
onDelta(d.content);
|
|
443
|
+
}
|
|
444
|
+
// Reasoning models emit their chain of thought on a SEPARATE field and
|
|
445
|
+
// only then start producing content. Count it — not to show it, but to
|
|
446
|
+
// tell "the model said nothing" apart from "the model spent its whole
|
|
447
|
+
// budget thinking and got cut off". Surface "thinking…" so the wait
|
|
448
|
+
// is not mute dots.
|
|
449
|
+
else if (d?.reasoning || d?.reasoning_content) {
|
|
450
|
+
reasonedChars += (d.reasoning || d.reasoning_content).length;
|
|
451
|
+
if (!full) noteThinking();
|
|
452
|
+
}
|
|
453
|
+
} catch { /* keep-alive line or partial JSON — ignore */ }
|
|
454
|
+
}
|
|
412
455
|
}
|
|
456
|
+
} finally {
|
|
457
|
+
stopWait();
|
|
413
458
|
}
|
|
414
459
|
|
|
415
460
|
// EMPTY CONTENT AFTER HEAVY REASONING is a truncation, not an answer. It
|
|
@@ -417,7 +462,8 @@ export async function brainStream(messages, onDelta, contextId, modelOverride, m
|
|
|
417
462
|
// turn and explained nothing — on exactly the long, complex prompts where a
|
|
418
463
|
// reasoning model thinks the most. Retry ONCE with a bigger budget.
|
|
419
464
|
if (!full && reasonedChars > 0 && !maxTokens) {
|
|
420
|
-
|
|
465
|
+
onStatus?.('retrying…');
|
|
466
|
+
return brainStream(messages, onDelta, contextId, modelOverride, budget * 4, round, topK, onStatus);
|
|
421
467
|
}
|
|
422
468
|
|
|
423
469
|
// CUT OFF MID-ANSWER. finish_reason "length" means the model had more to say
|
|
@@ -438,7 +484,7 @@ export async function brainStream(messages, onDelta, contextId, modelOverride, m
|
|
|
438
484
|
{ role: 'assistant', content: full },
|
|
439
485
|
{ role: 'user', content: CONTINUE_NUDGE }],
|
|
440
486
|
onDelta, contextId, modelOverride,
|
|
441
|
-
Math.min(budget * 2, MAX_CONTINUE_TOKENS), round + 1,
|
|
487
|
+
Math.min(budget * 2, MAX_CONTINUE_TOKENS), round + 1, topK, onStatus,
|
|
442
488
|
);
|
|
443
489
|
return full + (more || '');
|
|
444
490
|
}
|
package/lib/proxy.js
CHANGED
|
@@ -22,6 +22,9 @@ import { injectBrief } from './brief.js';
|
|
|
22
22
|
import { withNamespace } from './namespace.js';
|
|
23
23
|
import { anthropicToOpenAI, openAIToAnthropic, streamOpenAIToAnthropic, writeAnthropicSse } from './anthropic.js';
|
|
24
24
|
import { responsesToChat, chatToResponses, writeResponsesSse } from './responses.js';
|
|
25
|
+
import { loadSessionSpend, saveSessionSpend } from './session.js';
|
|
26
|
+
import { creditBalance, quotedPrices } from './info.js';
|
|
27
|
+
import { priceHoldings } from './livestatus.js';
|
|
25
28
|
|
|
26
29
|
const HOP_BY_HOP = new Set([
|
|
27
30
|
'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
|
|
@@ -759,7 +762,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
759
762
|
// lines / payment receipts there corrupts that program's output (observed: the
|
|
760
763
|
// Solana receipt leaking into the Claude Code CLI). When silent, route this
|
|
761
764
|
// channel to a log file instead; only print to the console when we own it.
|
|
762
|
-
|
|
765
|
+
const restored = loadSessionSpend();
|
|
766
|
+
let paidCalls = restored.paidCalls;
|
|
763
767
|
let sayFile = null;
|
|
764
768
|
if (silent) {
|
|
765
769
|
try {
|
|
@@ -775,9 +779,16 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
775
779
|
if (sayFile) { try { appendFileSync(sayFile, line + '\n'); return; } catch { /* fall through */ } }
|
|
776
780
|
console.log(line);
|
|
777
781
|
};
|
|
778
|
-
let sessionSpent =
|
|
779
|
-
let sessionCogs =
|
|
780
|
-
let sessionDirect =
|
|
782
|
+
let sessionSpent = restored.spentUsd;
|
|
783
|
+
let sessionCogs = restored.cogsUsd;
|
|
784
|
+
let sessionDirect = restored.directUsd;
|
|
785
|
+
const rememberSpend = () => {
|
|
786
|
+
saveSessionSpend({ spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls });
|
|
787
|
+
};
|
|
788
|
+
if (restored.ok && (sessionSpent > 0 || paidCalls > 0)) {
|
|
789
|
+
say(`session restored: $${sessionSpent.toFixed(6)} � ${paidCalls} paid call${paidCalls === 1 ? '' : 's'}`);
|
|
790
|
+
}
|
|
791
|
+
process.on('exit', rememberSpend);
|
|
781
792
|
const MARKUP = 3; // confirmed constant, see .claude/wiki.md "Margin needs a like-for-like denominator"
|
|
782
793
|
let tunnelSpent = 0;
|
|
783
794
|
// Live balance refresh state — the real implementation is assigned in the
|
|
@@ -836,14 +847,31 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
836
847
|
// latency to the thing it is describing.
|
|
837
848
|
let creditUsd = null;
|
|
838
849
|
let creditAt = 0;
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
850
|
+
let creditInflight = null;
|
|
851
|
+
let lastPrices = {};
|
|
852
|
+
let pricesAt = 0;
|
|
853
|
+
const refreshCredit = async (force = false) => {
|
|
854
|
+
if (!force && Date.now() - creditAt < 20000 && creditUsd != null) return creditUsd;
|
|
855
|
+
if (creditInflight) return creditInflight;
|
|
856
|
+
creditInflight = (async () => {
|
|
857
|
+
try {
|
|
858
|
+
creditUsd = await creditBalance();
|
|
859
|
+
creditAt = Date.now();
|
|
860
|
+
} catch { /* keep last known */ }
|
|
861
|
+
creditInflight = null;
|
|
862
|
+
return creditUsd;
|
|
863
|
+
})();
|
|
864
|
+
return creditInflight;
|
|
846
865
|
};
|
|
866
|
+
const refreshPrices = async () => {
|
|
867
|
+
if (Date.now() - pricesAt < 60000 && Object.keys(lastPrices).length) return lastPrices;
|
|
868
|
+
try {
|
|
869
|
+
lastPrices = await quotedPrices();
|
|
870
|
+
pricesAt = Date.now();
|
|
871
|
+
} catch { /* keep last */ }
|
|
872
|
+
return lastPrices;
|
|
873
|
+
};
|
|
874
|
+
const walletMoney = () => priceHoldings(lastSnap || [], lastPrices);
|
|
847
875
|
let tunnelError = null;
|
|
848
876
|
|
|
849
877
|
const server = http.createServer(async (req, res) => {
|
|
@@ -856,9 +884,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
856
884
|
// whichever surface happens to be asking. Local-only, no auth needed:
|
|
857
885
|
// it's a number, not a capability.
|
|
858
886
|
if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/session') {
|
|
859
|
-
refreshCredit();
|
|
887
|
+
await refreshCredit();
|
|
888
|
+
refreshPrices();
|
|
889
|
+
const money = walletMoney();
|
|
860
890
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
861
|
-
res.end(JSON.stringify({
|
|
891
|
+
res.end(JSON.stringify({
|
|
892
|
+
spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls,
|
|
893
|
+
creditUsd, chainUsd: money.chainUsd,
|
|
894
|
+
}));
|
|
862
895
|
return;
|
|
863
896
|
}
|
|
864
897
|
|
|
@@ -866,6 +899,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
866
899
|
// grokui error path, in particular) can print REAL funding instructions
|
|
867
900
|
// inline instead of telling the user to go look somewhere else.
|
|
868
901
|
if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/wallet') {
|
|
902
|
+
await refreshCredit();
|
|
903
|
+
await refreshPrices();
|
|
904
|
+
const money = walletMoney();
|
|
869
905
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
870
906
|
res.end(JSON.stringify({
|
|
871
907
|
solana: client.address,
|
|
@@ -876,6 +912,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
876
912
|
balances: balanceLine(lastSnap || []) || null,
|
|
877
913
|
funded: (lastSnap || []).some((b) => b.ui > 0),
|
|
878
914
|
creditUsd,
|
|
915
|
+
chainUsd: money.chainUsd,
|
|
916
|
+
holdings: money.holdings,
|
|
879
917
|
}));
|
|
880
918
|
return;
|
|
881
919
|
}
|
|
@@ -1504,6 +1542,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1504
1542
|
// an OSC escape, which updates the window/tab title without touching the
|
|
1505
1543
|
// TUI's content. `openzoo ◝ $0.0042 · 12 calls` in the title bar, live.
|
|
1506
1544
|
if (receipt.ok && typeof receipt.billedUsd === 'number') { paidCalls += 1; }
|
|
1545
|
+
if (receipt.ok && typeof receipt.billedUsd === 'number') rememberSpend();
|
|
1507
1546
|
if (sayFile) {
|
|
1508
1547
|
try { process.stderr.write(`]0;openzoo ◝ $${sessionSpent.toFixed(4)} · ${paidCalls} call${paidCalls === 1 ? '' : 's'}`); } catch { /* no tty */ }
|
|
1509
1548
|
}
|
|
@@ -1563,6 +1602,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1563
1602
|
}
|
|
1564
1603
|
paidCalls += 1;
|
|
1565
1604
|
if (viaTunnel) tunnelSpent += x.billedUsd;
|
|
1605
|
+
rememberSpend();
|
|
1566
1606
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
1567
1607
|
}
|
|
1568
1608
|
if (data?.object === 'chat.completion') {
|
|
@@ -1619,6 +1659,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1619
1659
|
}
|
|
1620
1660
|
paidCalls += 1;
|
|
1621
1661
|
if (viaTunnel) tunnelSpent += x.billedUsd;
|
|
1662
|
+
rememberSpend();
|
|
1622
1663
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
1623
1664
|
};
|
|
1624
1665
|
|
|
@@ -1793,6 +1834,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1793
1834
|
console.log(`fund it: ${fundingLine('the address above')} — a few cents goes a long way.`);
|
|
1794
1835
|
}
|
|
1795
1836
|
} catch { /* RPC hiccup: balance is advisory */ }
|
|
1837
|
+
refreshCredit();
|
|
1838
|
+
refreshPrices();
|
|
1796
1839
|
// LIVE REFRESH: the startup line goes stale the moment a call settles or
|
|
1797
1840
|
// the user funds mid-session. Poll on an interval (and shortly after each
|
|
1798
1841
|
// paid call), print ONLY on change, and call out arrivals explicitly so
|
package/lib/session.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable proxy-session spend. The HUD's /v1/session counters used to live
|
|
3
|
+
* only in RAM, so launching a fresh openzoo on :8402 (opening the desktop
|
|
4
|
+
* app, ensureProxy, a crash) reset spent/cogs/direct/paidCalls to $0 even
|
|
5
|
+
* though ~/.openzoo/proxy.log still showed a real session.
|
|
6
|
+
*
|
|
7
|
+
* Same home as the wallet and corpus ledger: ~/.openzoo/session.json.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
export function sessionSpendFile(home = os.homedir()) {
|
|
14
|
+
return process.env.OPENZOO_SESSION_PATH
|
|
15
|
+
|| path.join(home, '.openzoo', 'session.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const EMPTY = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0 };
|
|
19
|
+
|
|
20
|
+
function num(v) {
|
|
21
|
+
const n = Number(v);
|
|
22
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function loadSessionSpend(file = sessionSpendFile()) {
|
|
26
|
+
let raw;
|
|
27
|
+
try { raw = fs.readFileSync(file, 'utf8'); }
|
|
28
|
+
catch { return { ...EMPTY, ok: false, reason: 'missing' }; }
|
|
29
|
+
let data;
|
|
30
|
+
try { data = JSON.parse(raw); }
|
|
31
|
+
catch { return { ...EMPTY, ok: false, reason: 'corrupt' }; }
|
|
32
|
+
return {
|
|
33
|
+
spentUsd: num(data.spentUsd),
|
|
34
|
+
cogsUsd: num(data.cogsUsd),
|
|
35
|
+
directUsd: num(data.directUsd),
|
|
36
|
+
paidCalls: Math.floor(num(data.paidCalls)),
|
|
37
|
+
ok: true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function saveSessionSpend(stats, file = sessionSpendFile()) {
|
|
42
|
+
const payload = {
|
|
43
|
+
spentUsd: num(stats?.spentUsd),
|
|
44
|
+
cogsUsd: num(stats?.cogsUsd),
|
|
45
|
+
directUsd: num(stats?.directUsd),
|
|
46
|
+
paidCalls: Math.floor(num(stats?.paidCalls)),
|
|
47
|
+
updatedAt: Date.now(),
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
51
|
+
const tmp = `${file}.tmp`;
|
|
52
|
+
fs.writeFileSync(tmp, JSON.stringify(payload), { mode: 0o600 });
|
|
53
|
+
fs.renameSync(tmp, file);
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.89",
|
|
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",
|