openzoo 0.49.0 → 0.49.2
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 +159 -90
- package/lib/podagent.mjs +276 -2
- package/lib/proxy.js +15 -6
- package/lib/racesettle.js +143 -0
- package/package.json +1 -1
package/lib/grokui.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSyn
|
|
|
12
12
|
import { cpus, homedir } from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES, normalizeTier } from './podagent.mjs';
|
|
15
|
-
import { peekDirectiveStatus, formatRaceStatus, STALE_THINKING_MS, summarizeRaceFailures } from './livestatus.js';
|
|
15
|
+
import { peekDirectiveStatus, formatRaceStatus, STALE_THINKING_MS, summarizeRaceFailures, RACE_EVERY_FAILED } from './livestatus.js';
|
|
16
16
|
import { creditBalance } from './info.js';
|
|
17
17
|
import {
|
|
18
18
|
SUBSCRIPTIONS_PAGE,
|
|
@@ -491,7 +491,7 @@ function newThread(name, parent, members, spec) {
|
|
|
491
491
|
const p = parent ? threads.get(parent) : null;
|
|
492
492
|
const t = { id, name, color: members ? members[0].color : colorFor(name), parent: parent || null,
|
|
493
493
|
messages: members ? null : [{ role: 'system', content: SYSTEM }],
|
|
494
|
-
members: members || null, history: [], status: 'idle', createdAt: Date.now(), lastActivityAt: Date.now(),
|
|
494
|
+
members: members || null, history: [], status: 'idle', turnSeq: 0, createdAt: Date.now(), lastActivityAt: Date.now(),
|
|
495
495
|
...(p?.runMode ? { runMode: p.runMode } : {}),
|
|
496
496
|
...(p?.model ? { model: p.model } : {}),
|
|
497
497
|
...(p?.tier ? { tier: p.tier } : {}),
|
|
@@ -687,6 +687,47 @@ const NUDGE = 'That reply announced work instead of doing it — no directive li
|
|
|
687
687
|
const AUTO_CONTINUE = 'AUTO is still on — do not stop and do not ask the user to type continue. '
|
|
688
688
|
+ 'Emit the next directive now (RUN:/SPAWN:/SEND:/READ:/WRITE:/GLOB:/FETCH:/MCP:/SERVE:), '
|
|
689
689
|
+ 'or DONE: if the job is actually finished.';
|
|
690
|
+
const AUTO_RACE_RETRY = 'AUTO is still on — the last model call failed (race/empty/error). '
|
|
691
|
+
+ 'Do not stop and do not ask the user to type continue. '
|
|
692
|
+
+ 'Emit the next directive now (RUN:/SPAWN:/SEND:/READ:/WRITE:/GLOB:/FETCH:/MCP:/SERVE:), '
|
|
693
|
+
+ 'or DONE: if the job is actually finished.';
|
|
694
|
+
// Said it would, without a directive line. "Spawned X" and "working on it" are
|
|
695
|
+
// in here because they are FALSE without a SPAWN: in the same reply — the bot
|
|
696
|
+
// reports success for something the harness never saw.
|
|
697
|
+
const ANNOUNCEMENT = /\b(?:I(?:'| a)?ll |I will |let me |I'm going to |I am going to |first,? |next,? |now I'll |starting|kicking off|spawn(?:ing|ed)|about to|going to (?:check|run|create|start|install))\b/i;
|
|
698
|
+
const STALLED_OFFER = /\b(?:if you want(?:ed)?,? I can|should I\b|let me know(?: and I['’]ll)?|ready to proceed|want me to|would you like(?: me to)?|spawned \S[\s\S]{0,80}working on it|kicked that off)\b/i;
|
|
699
|
+
function isDoneReply(text) {
|
|
700
|
+
return /^[ \t>*-]*DONE:/m.test(String(text || ''));
|
|
701
|
+
}
|
|
702
|
+
function isTransientModelFail(text) {
|
|
703
|
+
const s = String(text || '').trim();
|
|
704
|
+
if (s === RACE_EVERY_FAILED || /^\(race:\s*every model failed/i.test(s)) return true;
|
|
705
|
+
if (/^error:/i.test(s)) return true;
|
|
706
|
+
if (/returned nothing \d+ times|each returned nothing/i.test(s)) return true;
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
function isPaymentFailed(text) {
|
|
710
|
+
return /\b(?:payment failed|HTTP 402|wallet is empty|empty wallet)\b/i.test(String(text || ''));
|
|
711
|
+
}
|
|
712
|
+
// Park only: ask mode, pendingRun, DONE:, 402/empty-wallet, or the hard cap.
|
|
713
|
+
function shouldKeepAuto(t, reply) {
|
|
714
|
+
if (!t || t.runMode !== 'auto') return false;
|
|
715
|
+
if (t.pendingRun) return false;
|
|
716
|
+
if ((t.autoSteps || 0) >= AUTO_MAX_STEPS) return false;
|
|
717
|
+
if (isDoneReply(reply)) return false;
|
|
718
|
+
if (isPaymentFailed(reply)) return false;
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
function enqueueAutoHop(t, threadId, userText, onEvent) {
|
|
722
|
+
bindThread(t).catch(() => {});
|
|
723
|
+
t.autoSteps = (t.autoSteps || 0) + 1;
|
|
724
|
+
if (t.autoSteps >= AUTO_MAX_STEPS) return false;
|
|
725
|
+
kickTurn(threadId, userText, onEvent).catch(() => {});
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
function autoHopText(reply) {
|
|
729
|
+
return isTransientModelFail(reply) ? AUTO_RACE_RETRY : AUTO_CONTINUE;
|
|
730
|
+
}
|
|
690
731
|
// PING used to be a read: last-line status, no turn. Idle children stayed idle
|
|
691
732
|
// while the parent treated the dump as evidence they had acted. A ping is a
|
|
692
733
|
// wake — the same harness continue AUTO already uses, unless a custom message
|
|
@@ -695,6 +736,10 @@ let runTurnOverride = null;
|
|
|
695
736
|
function setRunTurnForTest(fn) {
|
|
696
737
|
runTurnOverride = typeof fn === 'function' ? fn : null;
|
|
697
738
|
}
|
|
739
|
+
let brainAskOverride = null;
|
|
740
|
+
function setBrainAskForTest(fn) {
|
|
741
|
+
brainAskOverride = typeof fn === 'function' ? fn : null;
|
|
742
|
+
}
|
|
698
743
|
function kickTurn(threadId, userText, onEvent, images) {
|
|
699
744
|
// Default to emitToThread so a spawned/pinged kid streams when someone has
|
|
700
745
|
// that thread open. emitToThread is a no-op if nobody is watching.
|
|
@@ -731,14 +776,14 @@ const SPAWN_MAX_CHILDREN = Number(process.env.OZ_SPAWN_MAX_CHILDREN)
|
|
|
731
776
|
|
|
732
777
|
// Injected fresh on every AUTO turn, never persisted into the thread.
|
|
733
778
|
//
|
|
734
|
-
// The auto loop
|
|
735
|
-
// merely OFFERS
|
|
736
|
-
// model
|
|
737
|
-
//
|
|
738
|
-
// nothing spawned, and a
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
779
|
+
// The auto loop keeps going until DONE:, a real blocking question, or the
|
|
780
|
+
// step cap. A reply that merely OFFERS used to end the run — auto silently
|
|
781
|
+
// degraded to ask the moment the model hedged. Observed live: "If you want,
|
|
782
|
+
// I can rewrite the prompt", "Spawned mcp-integration — working on it" with
|
|
783
|
+
// nothing spawned, and a kid that RAN ls then sat idle. Models are trained
|
|
784
|
+
// to close on a consent question; in auto that instinct is the bug. The
|
|
785
|
+
// system prompt is frozen into a thread at creation, so an existing thread
|
|
786
|
+
// can only be reached by a per-turn message.
|
|
742
787
|
const AUTO_DIRECTIVE = `AUTO MODE IS ON for this thread.
|
|
743
788
|
|
|
744
789
|
Do the work in this turn. Do not ask whether to proceed, do not offer to do it,
|
|
@@ -756,7 +801,11 @@ Announcing an action does not perform it. "Spawned X", "working on it" and
|
|
|
756
801
|
If a task needs several commands, emit the FIRST one now; you get its real
|
|
757
802
|
output back and continue from there. Only stop to ask when the next step is
|
|
758
803
|
genuinely destructive and irreversible, or when you truly cannot proceed
|
|
759
|
-
without a fact only the user has
|
|
804
|
+
without a fact only the user has.
|
|
805
|
+
|
|
806
|
+
When the job is actually finished, emit DONE: as the first line. A status
|
|
807
|
+
sentence is not a stop — the harness keeps this thread working until DONE:,
|
|
808
|
+
a real blocking question, or the step cap.`;
|
|
760
809
|
async function bindThread(t) {
|
|
761
810
|
// Only bind what's NEW since the last successful bind, continuing the
|
|
762
811
|
// existing context_id — previously this rebuilt and re-sent the WHOLE
|
|
@@ -1264,6 +1313,7 @@ async function handleSlash(task, t) {
|
|
|
1264
1313
|
const crew = subtreeOf(t.id);
|
|
1265
1314
|
if (!crew.length) return 'You have no subagents to send to.';
|
|
1266
1315
|
for (const x of crew) kickTurn(x.id, arg).catch(() => {});
|
|
1316
|
+
if (t.runMode === 'auto' && pingCanWake(t)) wakeOnPing(t, arg);
|
|
1267
1317
|
return `Sent down your branch to ${crew.length} bot(s): ${crew.map((x) => x.name).join(', ')}`;
|
|
1268
1318
|
}
|
|
1269
1319
|
|
|
@@ -1277,6 +1327,10 @@ async function handleSlash(task, t) {
|
|
|
1277
1327
|
return crew.map((x) => {
|
|
1278
1328
|
const mark = x.id === t.id ? ' (here)' : '';
|
|
1279
1329
|
if (x.id === t.id) {
|
|
1330
|
+
if (t.runMode === 'auto' && pingCanWake(t)) {
|
|
1331
|
+
wakeOnPing(t, arg);
|
|
1332
|
+
return ` ${x.name}${mark}: pinged, working`;
|
|
1333
|
+
}
|
|
1280
1334
|
const last = x.history[x.history.length - 1];
|
|
1281
1335
|
return x.pendingRun ? ` ${x.name}${mark}: BLOCKED — waiting for your approval`
|
|
1282
1336
|
: x.status === 'thinking' ? ` ${x.name}${mark}: working`
|
|
@@ -1354,9 +1408,15 @@ setInterval(() => {
|
|
|
1354
1408
|
if (!last || now - last < STALE_THINKING_MS) continue;
|
|
1355
1409
|
t.turnSeq = (t.turnSeq || 0) + 1;
|
|
1356
1410
|
try { turnAborts.get(t)?.abort(); } catch { /* none */ }
|
|
1357
|
-
t.
|
|
1358
|
-
|
|
1359
|
-
|
|
1411
|
+
const lastBot = [...(t.history || [])].reverse().find((h) => h.who === 'bot');
|
|
1412
|
+
const lastText = lastBot?.text || '';
|
|
1413
|
+
if (shouldKeepAuto(t, lastText)) {
|
|
1414
|
+
kickTurn(t.id, isTransientModelFail(lastText) ? AUTO_RACE_RETRY : AUTO_CONTINUE).catch(() => {});
|
|
1415
|
+
} else {
|
|
1416
|
+
t.status = 'idle';
|
|
1417
|
+
t.liveStatus = '';
|
|
1418
|
+
unlockWorktree(t);
|
|
1419
|
+
}
|
|
1360
1420
|
dirty = true;
|
|
1361
1421
|
}
|
|
1362
1422
|
if (dirty) saveThreads();
|
|
@@ -1615,7 +1675,8 @@ function unwrapDirectiveLine(reply) {
|
|
|
1615
1675
|
function isHarnessUserText(text) {
|
|
1616
1676
|
return /^\((command output|directive result)\)/.test(String(text || ''))
|
|
1617
1677
|
|| String(text || '') === NUDGE
|
|
1618
|
-
|| String(text || '') === AUTO_CONTINUE
|
|
1678
|
+
|| String(text || '') === AUTO_CONTINUE
|
|
1679
|
+
|| String(text || '') === AUTO_RACE_RETRY;
|
|
1619
1680
|
}
|
|
1620
1681
|
|
|
1621
1682
|
function firstUserAsk(t) {
|
|
@@ -2365,7 +2426,11 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2365
2426
|
// status=thinking and the UI on "…".
|
|
2366
2427
|
if (!isHarnessUserText(userText)) t.turnSeq = (t.turnSeq || 0) + 1;
|
|
2367
2428
|
const seq = t.turnSeq || 0;
|
|
2368
|
-
|
|
2429
|
+
// Missing turnSeq is 0, not undefined. A ping/AUTO_CONTINUE as the first
|
|
2430
|
+
// message used to make stillMine() always false (undefined === 0), so the
|
|
2431
|
+
// RUN ran or the model was paid and then the hop bailed before chaining —
|
|
2432
|
+
// kids looked "pinged" then dead.
|
|
2433
|
+
const stillMine = () => (threads.get(threadId)?.turnSeq || 0) === seq;
|
|
2369
2434
|
const paint = (ev) => {
|
|
2370
2435
|
if (!stillMine()) return;
|
|
2371
2436
|
if (ev.type === 'status' && ev.detail && t.status === 'thinking') t.liveStatus = ev.detail;
|
|
@@ -2386,11 +2451,13 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2386
2451
|
t.liveStatus = (!t.model && raceN >= 2) ? formatRaceStatus(0, raceNeed) : 'waiting on model…';
|
|
2387
2452
|
let chained = false;
|
|
2388
2453
|
let parked = false;
|
|
2454
|
+
let lastReply = '';
|
|
2389
2455
|
try {
|
|
2390
2456
|
if (t.members) {
|
|
2391
2457
|
// sequential, not parallel: each member's context is rebuilt from
|
|
2392
2458
|
// t.history right before its turn, so it sees every reply (including
|
|
2393
2459
|
// spawns/sends) the earlier members in THIS round already made
|
|
2460
|
+
let memberReply = '';
|
|
2394
2461
|
for (const m of t.members) {
|
|
2395
2462
|
if (!stillMine()) return;
|
|
2396
2463
|
const msgs = buildMemberMessages(t, m);
|
|
@@ -2404,6 +2471,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2404
2471
|
} catch (e) { r = `error: ${e.message}`; }
|
|
2405
2472
|
if (!stillMine()) return;
|
|
2406
2473
|
r = stripThinkTags(r);
|
|
2474
|
+
memberReply = r;
|
|
2407
2475
|
const runCmd = parseRun(r);
|
|
2408
2476
|
if (runCmd) {
|
|
2409
2477
|
const command = runCmd;
|
|
@@ -2413,6 +2481,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2413
2481
|
const shown = `$ ${command}\n${output}`;
|
|
2414
2482
|
t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
|
|
2415
2483
|
paint({ type: 'final', name: m.name, color: m.color, text: shown });
|
|
2484
|
+
memberReply = shown;
|
|
2416
2485
|
// this member's turn is done; the round continues to the next member
|
|
2417
2486
|
continue;
|
|
2418
2487
|
}
|
|
@@ -2429,8 +2498,13 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2429
2498
|
const finalText = ack ?? (r || '(no response)');
|
|
2430
2499
|
t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
|
|
2431
2500
|
paint({ type: 'final', name: m.name, color: m.color, text: finalText });
|
|
2501
|
+
memberReply = r;
|
|
2432
2502
|
}
|
|
2433
2503
|
bindThread(t).catch(() => {});
|
|
2504
|
+
lastReply = memberReply;
|
|
2505
|
+
if (shouldKeepAuto(t, memberReply)) {
|
|
2506
|
+
chained = enqueueAutoHop(t, threadId, autoHopText(memberReply), onEvent);
|
|
2507
|
+
}
|
|
2434
2508
|
return;
|
|
2435
2509
|
}
|
|
2436
2510
|
// A real message from the user resets the auto budget AND the announcement
|
|
@@ -2469,6 +2543,11 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2469
2543
|
// `attempt` exists because a retry must be allowed to land somewhere else:
|
|
2470
2544
|
// see the empty-completion loop below.
|
|
2471
2545
|
const ask = async (attempt = 0) => {
|
|
2546
|
+
if (brainAskOverride) {
|
|
2547
|
+
return String(await brainAskOverride({
|
|
2548
|
+
thread: t, attempt, userText, messages: callMsgs,
|
|
2549
|
+
}) ?? '').trim();
|
|
2550
|
+
}
|
|
2472
2551
|
const emit = (delta, meta) => paint({
|
|
2473
2552
|
type: 'delta', name: t.name, color: t.color, delta,
|
|
2474
2553
|
...(meta?.replace ? { replace: true } : {}),
|
|
@@ -2490,6 +2569,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2490
2569
|
return (await brainRace(callMsgs, emit, t.contextId, models, need, undefined, emitStatus, {
|
|
2491
2570
|
signal: turnAbort.signal,
|
|
2492
2571
|
onArrivals: (arr) => { t.lastRaceFail = summarizeRaceFailures(arr); },
|
|
2572
|
+
tier: t.tier || 'medium',
|
|
2493
2573
|
})).trim();
|
|
2494
2574
|
}
|
|
2495
2575
|
// A retry draws a DIFFERENT model from the tier rather than the same one.
|
|
@@ -2516,7 +2596,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2516
2596
|
// sick provider. Empties are per-model and uncorrelated, so moving is the
|
|
2517
2597
|
// fix. A thread pinned with /model stays pinned; that was an explicit
|
|
2518
2598
|
// choice and silently answering as something else would be worse.
|
|
2519
|
-
for (let i = 0; !reply && i < AUTO_EMPTY_RETRIES; i++) {
|
|
2599
|
+
for (let i = 0; (!reply || isTransientModelFail(reply)) && i < AUTO_EMPTY_RETRIES; i++) {
|
|
2520
2600
|
paint({ type: 'status', name: t.name, color: t.color, detail: 'retrying…' });
|
|
2521
2601
|
await new Promise((r) => setTimeout(r, 400 * (i + 1)));
|
|
2522
2602
|
if (!stillMine()) return;
|
|
@@ -2532,6 +2612,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2532
2612
|
}
|
|
2533
2613
|
if (!stillMine()) return;
|
|
2534
2614
|
reply = stripThinkTags(reply);
|
|
2615
|
+
lastReply = reply;
|
|
2535
2616
|
t.messages.push({ role: 'assistant', content: reply });
|
|
2536
2617
|
const runCmd = parseRun(reply);
|
|
2537
2618
|
if (runCmd) {
|
|
@@ -2551,24 +2632,13 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2551
2632
|
// Bounded, because this is a loop that spends real money on every hop:
|
|
2552
2633
|
// AUTO_MAX_STEPS chained commands per user message, reset whenever the
|
|
2553
2634
|
// user speaks again.
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
bindThread(t).catch(() => {});
|
|
2562
|
-
chained = true;
|
|
2563
|
-
runTurn(threadId, condense('(command output)', output), onEvent).catch(() => {});
|
|
2564
|
-
} else {
|
|
2565
|
-
// Do not park. A "say continue" note is the failure mode auto exists
|
|
2566
|
-
// to avoid — inject continue and keep going until DONE or ask.
|
|
2567
|
-
t.autoSteps = 0;
|
|
2568
|
-
bindThread(t).catch(() => {});
|
|
2569
|
-
chained = true;
|
|
2570
|
-
runTurn(threadId, AUTO_CONTINUE, onEvent).catch(() => {});
|
|
2571
|
-
}
|
|
2635
|
+
// BIND BEFORE CHAINING. bindThread only ran at the end of a normal
|
|
2636
|
+
// turn, and both auto paths return before reaching it — so in auto
|
|
2637
|
+
// mode nothing was ever bound, exactly when the agent produces the
|
|
2638
|
+
// most material (command output, GLOB results, MCP tool lists). The
|
|
2639
|
+
// holographic context stopped growing precisely when it mattered.
|
|
2640
|
+
lastReply = shown;
|
|
2641
|
+
chained = enqueueAutoHop(t, threadId, condense('(command output)', output), onEvent);
|
|
2572
2642
|
return;
|
|
2573
2643
|
}
|
|
2574
2644
|
{
|
|
@@ -2598,16 +2668,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2598
2668
|
// Same budget as RUN (shared t.autoSteps, reset when the user speaks), so
|
|
2599
2669
|
// this cannot spend more than a chained RUN loop already could.
|
|
2600
2670
|
if (t.runMode === 'auto' && ack !== null && ack !== undefined
|
|
2601
|
-
&&
|
|
2602
|
-
|
|
2603
|
-
bindThread(t).catch(() => {}); // bind every hop, not just the last one
|
|
2604
|
-
chained = true;
|
|
2605
|
-
if (t.autoSteps < AUTO_MAX_STEPS) {
|
|
2606
|
-
runTurn(threadId, condense('(directive result)', ack), onEvent).catch(() => {});
|
|
2607
|
-
} else {
|
|
2608
|
-
t.autoSteps = 0;
|
|
2609
|
-
runTurn(threadId, AUTO_CONTINUE, onEvent).catch(() => {});
|
|
2610
|
-
}
|
|
2671
|
+
&& !isDoneReply(reply)) {
|
|
2672
|
+
chained = enqueueAutoHop(t, threadId, condense('(directive result)', ack), onEvent);
|
|
2611
2673
|
return;
|
|
2612
2674
|
}
|
|
2613
2675
|
|
|
@@ -2622,50 +2684,43 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2622
2684
|
// AUTO_DIRECTIVE already forbids this in the prompt, and models do it anyway.
|
|
2623
2685
|
// A prompt rule with no enforcement is a suggestion. Re-ask once, in-band.
|
|
2624
2686
|
//
|
|
2625
|
-
//
|
|
2626
|
-
//
|
|
2627
|
-
//
|
|
2628
|
-
// succeeded. Once per turn (autoNudged), so a model that announces twice
|
|
2629
|
-
// still stops instead of looping on the user's wallet.
|
|
2687
|
+
// NUDGE announcements (stronger than a bare continue). Plain replies used
|
|
2688
|
+
// to park here; they now fall through to AUTO_CONTINUE unless DONE: or a
|
|
2689
|
+
// real blocking question. The step cap is the wallet bound.
|
|
2630
2690
|
if (t.runMode === 'auto' && (ack === null || ack === undefined)
|
|
2631
2691
|
&& (STALLED_OFFER.test(reply) || ANNOUNCEMENT.test(reply))) {
|
|
2632
2692
|
// Offers and "Spawned X — working on it" with no directive must not end
|
|
2633
2693
|
// the run. The old once-only autoNudged gate parked the thread after one
|
|
2634
|
-
// hedge and the user typed continue.
|
|
2635
|
-
// another continue rather than stopping.
|
|
2694
|
+
// hedge and the user typed continue.
|
|
2636
2695
|
t.autoNudged = true;
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
}
|
|
2696
|
+
chained = enqueueAutoHop(t, threadId, NUDGE, onEvent);
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
// After any auto reply that is not DONE: and not waiting on approval,
|
|
2701
|
+
// kick immediately. Race/empty/error uses AUTO_RACE_RETRY.
|
|
2702
|
+
if (shouldKeepAuto(t, reply)) {
|
|
2703
|
+
chained = enqueueAutoHop(t, threadId, autoHopText(reply), onEvent);
|
|
2646
2704
|
return;
|
|
2647
2705
|
}
|
|
2648
2706
|
bindThread(t).catch(() => {});
|
|
2649
2707
|
} finally {
|
|
2650
|
-
//
|
|
2651
|
-
//
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
t.
|
|
2656
|
-
|
|
2708
|
+
// Idle only when this hop should not keep AUTO going: DONE:, pendingRun,
|
|
2709
|
+
// ask mode, 402/empty-wallet, or the hard cap. Otherwise kick again.
|
|
2710
|
+
if (stillMine() && !chained && !parked) {
|
|
2711
|
+
if (shouldKeepAuto(t, lastReply)) {
|
|
2712
|
+
enqueueAutoHop(t, threadId, autoHopText(lastReply), onEvent);
|
|
2713
|
+
} else if (!t.pendingRun) {
|
|
2714
|
+
t.status = 'idle';
|
|
2715
|
+
t.liveStatus = '';
|
|
2716
|
+
unlockWorktree(t);
|
|
2717
|
+
}
|
|
2657
2718
|
}
|
|
2658
2719
|
t.lastActivityAt = Date.now();
|
|
2659
2720
|
saveThreads();
|
|
2660
2721
|
}
|
|
2661
2722
|
}
|
|
2662
2723
|
|
|
2663
|
-
// Said it would, without a directive line. "Spawned X" and "working on it" are
|
|
2664
|
-
// in here because they are FALSE without a SPAWN: in the same reply — the bot
|
|
2665
|
-
// reports success for something the harness never saw.
|
|
2666
|
-
const ANNOUNCEMENT = /\b(?:I(?:'| a)?ll |I will |let me |I'm going to |I am going to |first,? |next,? |now I'll |starting|kicking off|spawn(?:ing|ed)|about to|going to (?:check|run|create|start|install))\b/i;
|
|
2667
|
-
const STALLED_OFFER = /\b(?:if you want(?:ed)?,? I can|should I\b|let me know(?: and I['’]ll)?|ready to proceed|want me to|would you like(?: me to)?|spawned \S[\s\S]{0,80}working on it|kicked that off)\b/i;
|
|
2668
|
-
|
|
2669
2724
|
/**
|
|
2670
2725
|
* The PROJECT a thread belongs to = the root of its spawn tree, and how deep
|
|
2671
2726
|
* it sits. Every thread already carried the parent id; nothing ever walked it, so
|
|
@@ -4898,9 +4953,14 @@ const APP_HTML = `<!doctype html>
|
|
|
4898
4953
|
const cogs = Number(you.cogsUsd) || 0;
|
|
4899
4954
|
const direct = Number(you.directUsd) || 0;
|
|
4900
4955
|
const margin = spent > 0 ? Math.round((spent - cogs) / spent * 100) + '%' : '—';
|
|
4956
|
+
const cogsOver = cogs > spent;
|
|
4901
4957
|
document.getElementById('hYouSpent').textContent = usd(spent);
|
|
4902
|
-
document.getElementById('hYouCogs')
|
|
4903
|
-
|
|
4958
|
+
const cogsEl = document.getElementById('hYouCogs');
|
|
4959
|
+
cogsEl.textContent = usd(cogs);
|
|
4960
|
+
cogsEl.className = cogsOver ? 'hember' : '';
|
|
4961
|
+
const marginEl = document.getElementById('hYouMargin');
|
|
4962
|
+
marginEl.textContent = margin;
|
|
4963
|
+
marginEl.className = cogsOver ? 'hember' : 'hlime';
|
|
4904
4964
|
document.getElementById('hYouDirect').textContent = usd(direct);
|
|
4905
4965
|
const savedEl = document.getElementById('hYouSaved');
|
|
4906
4966
|
const hintEl = document.getElementById('hHint');
|
|
@@ -4913,19 +4973,26 @@ const APP_HTML = `<!doctype html>
|
|
|
4913
4973
|
// is shipping the WHOLE corpus), so 2dp would read as noise up there.
|
|
4914
4974
|
savedEl.textContent = (mult >= 100 ? Math.round(mult) : mult.toFixed(mult >= 10 ? 1 : 2)) + 'x';
|
|
4915
4975
|
savedEl.className = mult >= 1 ? 'hlime' : 'hember';
|
|
4916
|
-
//
|
|
4917
|
-
//
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4976
|
+
// Session direct/spent (never first-call). Cogs-over-paid is the
|
|
4977
|
+
// louder warn — unused grant-back should have kept used cogs ≤ billed.
|
|
4978
|
+
if (cogsOver) {
|
|
4979
|
+
hintEl.className = 'hhint show';
|
|
4980
|
+
hintEl.innerHTML = '<b>cogs above paid.</b> our cost exceeded what you were billed '
|
|
4981
|
+
+ '— this line is used racers, not the N+judge ceiling.';
|
|
4982
|
+
} else {
|
|
4983
|
+
hintEl.className = mult >= 1 ? 'hhint' : 'hhint show';
|
|
4984
|
+
hintEl.innerHTML = '<b>feed it more.</b> you\\'re billed on the slice actually sent, '
|
|
4985
|
+
+ 'not the corpus — so the more you bind, the further ahead this gets. '
|
|
4986
|
+
+ 'small inputs cost more than sending them straight.';
|
|
4987
|
+
}
|
|
4926
4988
|
} else {
|
|
4927
4989
|
savedEl.textContent = '—';
|
|
4928
|
-
|
|
4990
|
+
if (cogsOver) {
|
|
4991
|
+
hintEl.className = 'hhint show';
|
|
4992
|
+
hintEl.innerHTML = '<b>cogs above paid.</b> our cost exceeded what you were billed.';
|
|
4993
|
+
} else {
|
|
4994
|
+
hintEl.className = 'hhint';
|
|
4995
|
+
}
|
|
4929
4996
|
}
|
|
4930
4997
|
document.getElementById('hFoot').textContent = (you.paidCalls || 0) + ' paid calls this session';
|
|
4931
4998
|
} catch (e) {
|
|
@@ -5355,7 +5422,9 @@ server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0
|
|
|
5355
5422
|
export {
|
|
5356
5423
|
tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
|
|
5357
5424
|
parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
|
|
5358
|
-
handleSlash, newThread, setRunTurnForTest,
|
|
5359
|
-
|
|
5360
|
-
|
|
5425
|
+
handleSlash, newThread, setRunTurnForTest, setBrainAskForTest, runTurn,
|
|
5426
|
+
AUTO_CONTINUE, AUTO_RACE_RETRY, pingWakeText, pingCanWake, shouldKeepAuto,
|
|
5427
|
+
isDoneReply, isTransientModelFail, enqueueAutoHop, childKickoff, findByName,
|
|
5428
|
+
attachChildDir, finishChildDir,
|
|
5429
|
+
lockWorktree, unlockWorktree, parsePrRef, fetchSpecsForOrigin, agentSlug,
|
|
5361
5430
|
};
|
package/lib/podagent.mjs
CHANGED
|
@@ -28,12 +28,19 @@ import {
|
|
|
28
28
|
isRaceCountable, raceLastShip, shouldRetryRaceArrival, raceFailKind,
|
|
29
29
|
summarizeRaceFailures,
|
|
30
30
|
} from './livestatus.js';
|
|
31
|
+
import {
|
|
32
|
+
probeGatewayRace, capRaceByCredit, inferRaceTier, RACE_NO_CREDIT,
|
|
33
|
+
} from './racesettle.js';
|
|
31
34
|
import { homedir } from 'node:os';
|
|
32
35
|
|
|
33
36
|
const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
|
|
34
37
|
.split(',').map((s) => Number(s.trim())).filter(Boolean);
|
|
35
38
|
const LOG = process.env.OZ_AGENT_LOG || '/var/log/openzoo/agent.jsonl';
|
|
36
39
|
export const PROXY = process.env.OZ_PROXY || 'http://127.0.0.1:8402/v1';
|
|
40
|
+
/** Live value — tests point OZ_PROXY at a mock after this module loads. */
|
|
41
|
+
function completionsProxy() {
|
|
42
|
+
return process.env.OZ_PROXY || PROXY;
|
|
43
|
+
}
|
|
37
44
|
export const MODEL = process.env.OZ_BRAIN_MODEL || 'deepseek/deepseek-v4-pro-0813';
|
|
38
45
|
const MAX_STEPS = Number(process.env.OZ_MAX_STEPS || 10);
|
|
39
46
|
|
|
@@ -289,7 +296,7 @@ async function postChat(body, contextId, topK, onStatus, signal) {
|
|
|
289
296
|
err.name = 'AbortError';
|
|
290
297
|
throw err;
|
|
291
298
|
}
|
|
292
|
-
r = await fetch(`${
|
|
299
|
+
r = await fetch(`${completionsProxy()}/chat/completions`, {
|
|
293
300
|
method: 'POST',
|
|
294
301
|
headers: {
|
|
295
302
|
'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
|
|
@@ -649,6 +656,250 @@ export async function tierModels(tier, n = 1, random = false) {
|
|
|
649
656
|
return a.slice(0, take);
|
|
650
657
|
}
|
|
651
658
|
|
|
659
|
+
async function readProxySession(proxy = completionsProxy()) {
|
|
660
|
+
try {
|
|
661
|
+
const origin = String(proxy || '').replace(/\/+$/, '').replace(/\/v1$/i, '');
|
|
662
|
+
const r = await fetch(`${origin}/v1/session`, { signal: AbortSignal.timeout(800) });
|
|
663
|
+
if (!r.ok) return null;
|
|
664
|
+
return await r.json();
|
|
665
|
+
} catch { return null; }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function raceBudget(hooks) {
|
|
669
|
+
if (hooks.creditUsd != null || hooks.quoteUsd != null) {
|
|
670
|
+
return { creditUsd: hooks.creditUsd, quoteUsd: hooks.quoteUsd };
|
|
671
|
+
}
|
|
672
|
+
// Injected stream = unit-test N-parallel path. Do not poke :8402.
|
|
673
|
+
if (hooks.stream) return {};
|
|
674
|
+
const s = await readProxySession(hooks.proxy || completionsProxy());
|
|
675
|
+
return {
|
|
676
|
+
creditUsd: s?.creditUsd,
|
|
677
|
+
quoteUsd: s?.lastQuoteUsd ?? s?.quoteUsd,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function raceRacerId(obj, fallback) {
|
|
682
|
+
const r = obj?.race || obj?.racer || {};
|
|
683
|
+
if (r.model) return String(r.model);
|
|
684
|
+
if (r.i != null) return `racer-${r.i}`;
|
|
685
|
+
if (obj?.model) return String(obj.model);
|
|
686
|
+
const idx = obj?.choices?.[0]?.index;
|
|
687
|
+
if (idx != null && idx !== 0) return `racer-${idx}`;
|
|
688
|
+
return fallback || 'gateway';
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function raceEventOf(obj) {
|
|
692
|
+
const r = obj?.race || obj?.racer;
|
|
693
|
+
if (!r || typeof r !== 'object') return null;
|
|
694
|
+
const ev = String(r.event || r.status || '').toLowerCase();
|
|
695
|
+
return { id: raceRacerId(obj, 'gateway'), ev, text: r.text, error: r.error };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* One Fly (or race-capable mock) POST. Streams tokens. First-X-countable:
|
|
700
|
+
* empties / HTTP / pay / fetch-failed do not fill X.
|
|
701
|
+
*/
|
|
702
|
+
async function brainGatewayRace(messages, onDelta, contextId, models, need, maxTokens, onStatus, hooks) {
|
|
703
|
+
const n = models.length;
|
|
704
|
+
const want = Math.max(1, Math.min(Number(need) || 1, n));
|
|
705
|
+
const tier = hooks.tier || inferRaceTier(models, 'medium');
|
|
706
|
+
const classify = hooks.classify || classifyRaceAnswer;
|
|
707
|
+
const pairwise = hooks.pairwise || pairwiseTied;
|
|
708
|
+
const minScore = hooks.minScore != null ? Number(hooks.minScore) : RACE_MIN_SCORE;
|
|
709
|
+
const feed = createRaceFeed(onDelta, onStatus, want);
|
|
710
|
+
feed.start();
|
|
711
|
+
|
|
712
|
+
const arrivals = [];
|
|
713
|
+
const done = [];
|
|
714
|
+
const noteRace = (arr) => {
|
|
715
|
+
try {
|
|
716
|
+
hooks.onArrivals?.(arr);
|
|
717
|
+
const line = JSON.stringify({
|
|
718
|
+
at: new Date().toISOString(),
|
|
719
|
+
fail: summarizeRaceFailures(arr),
|
|
720
|
+
n: arr.length,
|
|
721
|
+
kinds: arr.map((a) => raceFailKind(a)),
|
|
722
|
+
door: 'gateway',
|
|
723
|
+
});
|
|
724
|
+
appendFileSync(`${homedir()}/.openzoo/grokui-race.log`, line + '\n');
|
|
725
|
+
} catch { /* diagnostic only */ }
|
|
726
|
+
};
|
|
727
|
+
const ship = (cand) => {
|
|
728
|
+
const out = cand && String(cand.text || '').trim() ? cand : raceLastShip(arrivals);
|
|
729
|
+
feed.settle(out);
|
|
730
|
+
noteRace(arrivals);
|
|
731
|
+
return out.text;
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
const vision = hasImages(messages);
|
|
735
|
+
const model = vision ? VISION_MODEL : (models[0] || MODEL);
|
|
736
|
+
const msgs = vision ? messages : stripImages(messages);
|
|
737
|
+
const budget = maxTokens || MAX_TOKENS;
|
|
738
|
+
const body = {
|
|
739
|
+
model,
|
|
740
|
+
max_tokens: budget,
|
|
741
|
+
messages: withModelId(msgs, model),
|
|
742
|
+
plugins: [{ id: 'web' }],
|
|
743
|
+
stream: true,
|
|
744
|
+
race: n,
|
|
745
|
+
race_need: want,
|
|
746
|
+
tier,
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
let lastFail = { model: 'gateway', text: '', error: 'empty body' };
|
|
750
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
751
|
+
try {
|
|
752
|
+
const r = await postChat(body, contextId, 0, onStatus, hooks.signal);
|
|
753
|
+
if (!r.ok || !r.body) {
|
|
754
|
+
const j = await r.json().catch(() => ({}));
|
|
755
|
+
const content = j?.choices?.[0]?.message?.content;
|
|
756
|
+
const proxied = j?.error?.message;
|
|
757
|
+
const text = content || (r.ok ? '' : (proxied ? `(request failed — HTTP ${r.status}: ${proxied})` : await httpErrorNote(r.status)));
|
|
758
|
+
lastFail = { model: 'gateway', text: text || '', error: r.ok ? undefined : `HTTP ${r.status}` };
|
|
759
|
+
if (isRaceCountable(lastFail)) {
|
|
760
|
+
arrivals.push(lastFail);
|
|
761
|
+
done.push(lastFail);
|
|
762
|
+
feed.onBack();
|
|
763
|
+
if (text) onDelta(text);
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
if (!shouldRetryRaceArrival(lastFail) || attempt === 1) break;
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const parsed = await readGatewayRaceStream(r, feed, hooks.signal);
|
|
771
|
+
for (const a of parsed.arrivals) {
|
|
772
|
+
arrivals.push(a);
|
|
773
|
+
if (isRaceCountable(a)) {
|
|
774
|
+
done.push(a);
|
|
775
|
+
feed.onBack();
|
|
776
|
+
} else {
|
|
777
|
+
feed.onFail(a.model);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
lastFail = parsed.arrivals[parsed.arrivals.length - 1] || lastFail;
|
|
781
|
+
if (done.length >= want || !shouldRetryRaceArrival(lastFail) || attempt === 1) break;
|
|
782
|
+
} catch (e) {
|
|
783
|
+
lastFail = { model: 'gateway', text: '', error: e?.message || 'error' };
|
|
784
|
+
arrivals.push(lastFail);
|
|
785
|
+
if (!shouldRetryRaceArrival(lastFail) || attempt === 1) break;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const cands = done.slice(0, want);
|
|
790
|
+
if (!cands.length) return ship(raceLastShip(arrivals));
|
|
791
|
+
if (cands.length === 1) return ship(cands[0]);
|
|
792
|
+
|
|
793
|
+
onStatus?.('judging…');
|
|
794
|
+
const scored = await Promise.all(cands.map(async (c) => {
|
|
795
|
+
let score = 0;
|
|
796
|
+
try { score = Number(await classify(messages, c)) || 0; } catch { score = 0; }
|
|
797
|
+
return { ...c, score };
|
|
798
|
+
}));
|
|
799
|
+
let picked = pickRaceWinner(scored, minScore);
|
|
800
|
+
if (picked.reason === 'tie' && picked.tied.length > 1) {
|
|
801
|
+
let broken = null;
|
|
802
|
+
try { broken = await pairwise(messages, picked.tied); } catch { /* last of the tie */ }
|
|
803
|
+
const usable = broken && String(broken.text || '').trim();
|
|
804
|
+
picked = { winner: usable ? broken : picked.tied[picked.tied.length - 1], reason: 'tiebreak', tied: picked.tied };
|
|
805
|
+
}
|
|
806
|
+
return ship(picked.winner || scored[scored.length - 1] || raceLastShip(arrivals));
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function readGatewayRaceStream(r, feed, signal) {
|
|
810
|
+
const reader = r.body.getReader();
|
|
811
|
+
const decoder = new TextDecoder();
|
|
812
|
+
let buf = '';
|
|
813
|
+
const texts = new Map();
|
|
814
|
+
const finished = new Map();
|
|
815
|
+
const live = { id: null };
|
|
816
|
+
const stopWait = startModelWait(() => {});
|
|
817
|
+
|
|
818
|
+
const pushText = (id, chunk) => {
|
|
819
|
+
if (chunk == null || chunk === '') return;
|
|
820
|
+
const key = id || live.id || 'gateway';
|
|
821
|
+
live.id = key;
|
|
822
|
+
texts.set(key, (texts.get(key) || '') + chunk);
|
|
823
|
+
feed.onToken(key, chunk);
|
|
824
|
+
};
|
|
825
|
+
const finishOne = (id, extra = {}) => {
|
|
826
|
+
const key = id || live.id || 'gateway';
|
|
827
|
+
if (finished.has(key)) return;
|
|
828
|
+
const text = extra.text != null ? String(extra.text) : (texts.get(key) || '');
|
|
829
|
+
const row = { model: extra.model || key, text, error: extra.error };
|
|
830
|
+
finished.set(key, row);
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
try {
|
|
834
|
+
for (;;) {
|
|
835
|
+
if (signal?.aborted) break;
|
|
836
|
+
let chunk;
|
|
837
|
+
try {
|
|
838
|
+
chunk = await readWithIdleTimeout(reader, STREAM_IDLE_MS);
|
|
839
|
+
} catch (e) {
|
|
840
|
+
if (e?.code !== 'STREAM_IDLE') throw e;
|
|
841
|
+
try { await reader.cancel(); } catch { /* already */ }
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
const { value, done } = chunk;
|
|
845
|
+
if (done) break;
|
|
846
|
+
buf += decoder.decode(value, { stream: true });
|
|
847
|
+
const lines = buf.split('\n');
|
|
848
|
+
buf = lines.pop();
|
|
849
|
+
for (const line of lines) {
|
|
850
|
+
const s = line.trim();
|
|
851
|
+
if (s.startsWith(': race ')) {
|
|
852
|
+
try {
|
|
853
|
+
const ev = JSON.parse(s.slice(7));
|
|
854
|
+
const id = raceRacerId(ev, 'gateway');
|
|
855
|
+
if (ev.text && ev.event !== 'fail') pushText(id, ev.text);
|
|
856
|
+
if (ev.event === 'token' && ev.delta) pushText(id, ev.delta);
|
|
857
|
+
if (ev.event === 'back' || ev.event === 'done' || ev.finish) {
|
|
858
|
+
finishOne(id, { text: ev.text, model: ev.model });
|
|
859
|
+
}
|
|
860
|
+
if (ev.event === 'fail' || ev.error) {
|
|
861
|
+
finishOne(id, { text: ev.text || '', error: ev.error || 'empty body', model: ev.model });
|
|
862
|
+
}
|
|
863
|
+
} catch { /* keep-alive */ }
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
if (s.startsWith(': x402 ')) continue; // metered by the sidecar; never log
|
|
867
|
+
if (!s.startsWith('data:')) continue;
|
|
868
|
+
const payload = s.slice(5).trim();
|
|
869
|
+
if (payload === '[DONE]') continue;
|
|
870
|
+
try {
|
|
871
|
+
const obj = JSON.parse(payload);
|
|
872
|
+
const ev = raceEventOf(obj);
|
|
873
|
+
const c = obj?.choices?.[0];
|
|
874
|
+
const d = c?.delta;
|
|
875
|
+
const id = raceRacerId(obj, live.id || 'gateway');
|
|
876
|
+
if (d?.content) pushText(id, d.content);
|
|
877
|
+
else if (d?.reasoning || d?.reasoning_content) { /* thinking — not content */ }
|
|
878
|
+
if (c?.finish_reason) finishOne(id, { model: obj.model });
|
|
879
|
+
if (ev?.ev === 'back' || ev?.ev === 'done') finishOne(ev.id, { text: ev.text, model: ev.id });
|
|
880
|
+
if (ev?.ev === 'fail' || ev?.error) finishOne(ev.id, { text: ev.text || '', error: ev.error || 'empty body' });
|
|
881
|
+
const msg = c?.message?.content || obj?.choices?.[0]?.message?.content;
|
|
882
|
+
if (msg && (c?.finish_reason || obj.object === 'chat.completion')) {
|
|
883
|
+
pushText(id, texts.has(id) ? '' : msg);
|
|
884
|
+
if (!texts.get(id)) texts.set(id, String(msg));
|
|
885
|
+
finishOne(id, { text: texts.get(id) || msg, model: obj.model || id });
|
|
886
|
+
}
|
|
887
|
+
} catch { /* partial JSON */ }
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
} finally {
|
|
891
|
+
stopWait();
|
|
892
|
+
try { await reader.cancel(); } catch { /* closed */ }
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (!finished.size && texts.size) {
|
|
896
|
+
for (const [id, text] of texts) finishOne(id, { text });
|
|
897
|
+
}
|
|
898
|
+
const arrivals = [...finished.values()];
|
|
899
|
+
if (!arrivals.length) arrivals.push({ model: 'gateway', text: '', error: 'empty body' });
|
|
900
|
+
return { arrivals };
|
|
901
|
+
}
|
|
902
|
+
|
|
652
903
|
/**
|
|
653
904
|
* Launch N models at once, judge the FIRST K that come back.
|
|
654
905
|
*
|
|
@@ -693,10 +944,33 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
|
|
|
693
944
|
const classify = hooks.classify || classifyRaceAnswer;
|
|
694
945
|
const pairwise = hooks.pairwise || pairwiseTied;
|
|
695
946
|
const minScore = hooks.minScore != null ? Number(hooks.minScore) : RACE_MIN_SCORE;
|
|
696
|
-
|
|
947
|
+
let list = (models || []).filter(Boolean).slice(0, RACE_MAX);
|
|
948
|
+
const budget = await raceBudget(hooks);
|
|
949
|
+
const capped = capRaceByCredit(list.length, budget);
|
|
950
|
+
if (capped.n < 1) {
|
|
951
|
+
onStatus?.('race refused — no credit');
|
|
952
|
+
return RACE_NO_CREDIT;
|
|
953
|
+
}
|
|
954
|
+
if (capped.n < list.length) {
|
|
955
|
+
list = list.slice(0, capped.n);
|
|
956
|
+
onStatus?.(capped.n < 2 ? 'race shrunk to 1 — credit' : `race shrunk to ${capped.n} — credit`);
|
|
957
|
+
}
|
|
697
958
|
if (list.length < 2) return stream(messages, onDelta, contextId, list[0], maxTokens, 0, 0, onStatus);
|
|
698
959
|
const want = Math.max(1, Math.min(Number(need) || 1, list.length));
|
|
699
960
|
|
|
961
|
+
// One Fly settle when the completions door honors `race:`. Custom stream
|
|
962
|
+
// hooks (unit tests of the N-parallel judge) keep the old path. Old
|
|
963
|
+
// sidecar / local mock that does not accept race: also stays N-parallel.
|
|
964
|
+
const customStream = Boolean(hooks.stream);
|
|
965
|
+
let gateway = hooks.gatewayRace;
|
|
966
|
+
if (gateway == null && !customStream) {
|
|
967
|
+
try { gateway = await probeGatewayRace(hooks.proxy || completionsProxy(), hooks.fetch || fetch); }
|
|
968
|
+
catch { gateway = false; }
|
|
969
|
+
}
|
|
970
|
+
if (gateway && !customStream) {
|
|
971
|
+
return brainGatewayRace(messages, onDelta, contextId, list, want, maxTokens, onStatus, hooks);
|
|
972
|
+
}
|
|
973
|
+
|
|
700
974
|
const feed = createRaceFeed(onDelta, onStatus, want);
|
|
701
975
|
feed.start();
|
|
702
976
|
|
package/lib/proxy.js
CHANGED
|
@@ -26,6 +26,7 @@ import { loadSessionSpend, saveSessionSpend } from './session.js';
|
|
|
26
26
|
import { creditBalance, quotedPrices } from './info.js';
|
|
27
27
|
import { subscriptionPublicView } from './subscription.js';
|
|
28
28
|
import { priceHoldings } from './livestatus.js';
|
|
29
|
+
import { receiptUsedCogs } from './racesettle.js';
|
|
29
30
|
|
|
30
31
|
const HOP_BY_HOP = new Set([
|
|
31
32
|
'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
|
|
@@ -781,6 +782,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
781
782
|
console.log(line);
|
|
782
783
|
};
|
|
783
784
|
let sessionSpent = restored.spentUsd;
|
|
785
|
+
let lastQuoteUsd = null;
|
|
784
786
|
let sessionCogs = restored.cogsUsd;
|
|
785
787
|
let sessionDirect = restored.directUsd;
|
|
786
788
|
const rememberSpend = () => {
|
|
@@ -791,6 +793,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
791
793
|
}
|
|
792
794
|
process.on('exit', rememberSpend);
|
|
793
795
|
const MARKUP = 3; // confirmed constant, see .claude/wiki.md "Margin needs a like-for-like denominator"
|
|
796
|
+
const noteQuote = (x) => {
|
|
797
|
+
const billed = Number(x?.billedUsd);
|
|
798
|
+
if (!Number.isFinite(billed) || billed < 0) return;
|
|
799
|
+
const n = Number(x?.race || x?.race_n || 1);
|
|
800
|
+
lastQuoteUsd = n > 1 ? billed / n : billed;
|
|
801
|
+
};
|
|
794
802
|
let tunnelSpent = 0;
|
|
795
803
|
// Live balance refresh state — the real implementation is assigned in the
|
|
796
804
|
// banner section below; the handler only ever calls scheduleRefresh().
|
|
@@ -894,7 +902,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
894
902
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
895
903
|
res.end(JSON.stringify({
|
|
896
904
|
spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls,
|
|
897
|
-
creditUsd, chainUsd: money.chainUsd,
|
|
905
|
+
creditUsd, chainUsd: money.chainUsd, lastQuoteUsd,
|
|
898
906
|
subscription: subscriptionPublicView(),
|
|
899
907
|
}));
|
|
900
908
|
return;
|
|
@@ -1519,9 +1527,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1519
1527
|
// is only correct on a straight-markup call: under counterfactual
|
|
1520
1528
|
// pricing billedUsd is min(direct×discount, markupUsd), so the
|
|
1521
1529
|
// division understates cost and overstates margin.
|
|
1522
|
-
sessionCogs +=
|
|
1523
|
-
|
|
1524
|
-
: receipt.billedUsd / MARKUP;
|
|
1530
|
+
sessionCogs += receiptUsedCogs(receipt, MARKUP);
|
|
1531
|
+
noteQuote(receipt);
|
|
1525
1532
|
// direct = what answering this WITHOUT the zoo would have cost. On an
|
|
1526
1533
|
// attach call that is the whole bound corpus, which is why it can be
|
|
1527
1534
|
// orders of magnitude above what was billed. directUsd is exact and
|
|
@@ -1591,7 +1598,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1591
1598
|
if (!paid && data?.x402 && typeof data.x402.billedUsd === 'number') {
|
|
1592
1599
|
const x = data.x402;
|
|
1593
1600
|
sessionSpent += x.billedUsd;
|
|
1594
|
-
sessionCogs +=
|
|
1601
|
+
sessionCogs += receiptUsedCogs(x, MARKUP);
|
|
1602
|
+
noteQuote(x);
|
|
1595
1603
|
sessionDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
|
|
1596
1604
|
if (didSpill) {
|
|
1597
1605
|
// THE number that settles why a spilled call did or did not save:
|
|
@@ -1656,7 +1664,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1656
1664
|
const meterStreamed = (x) => {
|
|
1657
1665
|
if (paid || typeof x?.billedUsd !== 'number') return;
|
|
1658
1666
|
sessionSpent += x.billedUsd;
|
|
1659
|
-
sessionCogs +=
|
|
1667
|
+
sessionCogs += receiptUsedCogs(x, MARKUP);
|
|
1668
|
+
noteQuote(x);
|
|
1660
1669
|
sessionDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
|
|
1661
1670
|
if (typeof x.actualUsd === 'number' && x.actualUsd >= 0) { sessionActual += x.actualUsd; actualCalls += 1; billedWithActual += x.billedUsd || 0; }
|
|
1662
1671
|
if (didSpill) {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly gateway race: one POST { race, race_need, tier }, unused grant-back,
|
|
3
|
+
* and HUD cogs for the racers that actually ran — never the N+judge ceiling.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const FLY_GATEWAY_HOST = 'x402-tokens.fly.dev';
|
|
7
|
+
export const RACE_NO_CREDIT = '(race: not enough prepaid credit — shrink N or top up, rather than fire on $0)';
|
|
8
|
+
|
|
9
|
+
const FLY_RE = /x402-tokens\.fly\.dev/i;
|
|
10
|
+
|
|
11
|
+
/** Completions door is the Fly gateway (sidecar → x402-tokens.fly.dev). */
|
|
12
|
+
export function isFlyGatewayUpstream(upstream) {
|
|
13
|
+
return FLY_RE.test(String(upstream || ''));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether this completions door will honor `race:` on POST /chat/completions.
|
|
18
|
+
* Fly (and a test mock that advertises it) → true. Old sidecar / local mock → false.
|
|
19
|
+
*/
|
|
20
|
+
export function doorAcceptsRace(info) {
|
|
21
|
+
if (!info || typeof info !== 'object') return false;
|
|
22
|
+
if (info.race === true || info.gatewayRace === true) return true;
|
|
23
|
+
if (info.race === false || info.gatewayRace === false) return false;
|
|
24
|
+
const features = info.features || info.caps || info.capabilities;
|
|
25
|
+
if (Array.isArray(features) && features.some((f) => String(f).toLowerCase() === 'race')) return true;
|
|
26
|
+
if (features && typeof features === 'object' && (features.race === true || features.gatewayRace === true)) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return isFlyGatewayUpstream(info.upstream || info.apiBase || info.gateway);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let probeCache = { at: 0, ok: null, proxy: '' };
|
|
33
|
+
|
|
34
|
+
export function resetGatewayRaceProbe() {
|
|
35
|
+
probeCache = { at: 0, ok: null, proxy: '' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function proxyOrigin(proxy) {
|
|
39
|
+
const raw = String(proxy || '').replace(/\/+$/, '');
|
|
40
|
+
return raw.replace(/\/v1$/i, '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Probe the sidecar / mock once. GET /v1/info (and /info) — never a paid
|
|
45
|
+
* completions call. Cached briefly so a race of 4 does not fan out probes.
|
|
46
|
+
*/
|
|
47
|
+
export async function probeGatewayRace(proxy, fetchFn = fetch, ttlMs = 60_000) {
|
|
48
|
+
const key = String(proxy || '');
|
|
49
|
+
if (probeCache.ok != null && probeCache.proxy === key && Date.now() - probeCache.at < ttlMs) {
|
|
50
|
+
return probeCache.ok;
|
|
51
|
+
}
|
|
52
|
+
const origin = proxyOrigin(key);
|
|
53
|
+
if (!origin) {
|
|
54
|
+
probeCache = { at: Date.now(), ok: false, proxy: key };
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const paths = ['/v1/info', '/info'];
|
|
58
|
+
for (const p of paths) {
|
|
59
|
+
try {
|
|
60
|
+
const r = await fetchFn(`${origin}${p}`, { signal: AbortSignal.timeout(1500) });
|
|
61
|
+
if (!r.ok) continue;
|
|
62
|
+
const j = await r.json().catch(() => null);
|
|
63
|
+
const ok = doorAcceptsRace(j);
|
|
64
|
+
probeCache = { at: Date.now(), ok, proxy: key };
|
|
65
|
+
return ok;
|
|
66
|
+
} catch { /* try next */ }
|
|
67
|
+
}
|
|
68
|
+
probeCache = { at: Date.now(), ok: false, proxy: key };
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* If prepaid credit is known and `n × quote > credit`, shrink n (or 0 = refuse)
|
|
74
|
+
* rather than fire 4 groks on $0 credit. Unknown credit/quote → leave n alone.
|
|
75
|
+
*/
|
|
76
|
+
export function capRaceByCredit(n, { creditUsd, quoteUsd } = {}) {
|
|
77
|
+
const want = Math.max(0, Math.floor(Number(n) || 0));
|
|
78
|
+
if (creditUsd == null || !Number.isFinite(Number(creditUsd))) {
|
|
79
|
+
return { n: want, reason: null };
|
|
80
|
+
}
|
|
81
|
+
const credit = Number(creditUsd);
|
|
82
|
+
if (credit <= 0) return { n: 0, reason: 'no-credit' };
|
|
83
|
+
const quote = Number(quoteUsd);
|
|
84
|
+
if (!Number.isFinite(quote) || quote <= 0) {
|
|
85
|
+
// Credit is known and positive but we have no per-entrant quote — do not
|
|
86
|
+
// invent one. A $0 balance already refused above.
|
|
87
|
+
return { n: want, reason: null };
|
|
88
|
+
}
|
|
89
|
+
if (want * quote <= credit) return { n: want, reason: null };
|
|
90
|
+
const maxN = Math.floor(credit / quote);
|
|
91
|
+
if (maxN < 1) return { n: 0, reason: 'no-credit' };
|
|
92
|
+
return { n: Math.min(want, maxN), reason: 'shrunk' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function unusedGrant(x) {
|
|
96
|
+
const u = x?.race_unused ?? x?.raceUnused ?? x?.unused;
|
|
97
|
+
if (u == null) return { billed: 0, cogs: 0 };
|
|
98
|
+
if (typeof u === 'number' && Number.isFinite(u)) return { billed: u, cogs: u };
|
|
99
|
+
if (typeof u !== 'object') return { billed: 0, cogs: 0 };
|
|
100
|
+
const billed = Number(u.billedUsd ?? u.refundUsd ?? u.unusedBilledUsd ?? u.usd ?? 0);
|
|
101
|
+
const cogs = Number(u.cogsUsd ?? u.unusedCogsUsd ?? u.refundCogsUsd ?? billed);
|
|
102
|
+
return {
|
|
103
|
+
billed: Number.isFinite(billed) && billed > 0 ? billed : 0,
|
|
104
|
+
cogs: Number.isFinite(cogs) && cogs > 0 ? cogs : 0,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Actual used-racer cogs after unused grant-back.
|
|
110
|
+
* Never the N+judge ceiling. Does not clamp to billed — HUD embers when
|
|
111
|
+
* used cogs still exceed what was paid.
|
|
112
|
+
*/
|
|
113
|
+
export function receiptUsedCogs(x, markup = 3) {
|
|
114
|
+
if (!x || typeof x !== 'object') return 0;
|
|
115
|
+
const billedRaw = Number(x.billedUsd);
|
|
116
|
+
const billedOk = Number.isFinite(billedRaw) && billedRaw >= 0;
|
|
117
|
+
let cogs = typeof x.cogsUsd === 'number' && Number.isFinite(x.cogsUsd)
|
|
118
|
+
? x.cogsUsd
|
|
119
|
+
: (billedOk ? billedRaw / markup : 0);
|
|
120
|
+
const grant = unusedGrant(x);
|
|
121
|
+
if (grant.cogs > 0) cogs = Math.max(0, cogs - grant.cogs);
|
|
122
|
+
return cogs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Session meter. spent/direct are the receipt totals (already net of unused
|
|
127
|
+
* when the gateway refunds into billedUsd) — never a first-call rewrite.
|
|
128
|
+
* cogs is used racers after unused grant-back.
|
|
129
|
+
*/
|
|
130
|
+
export function meterRaceReceipt(x, markup = 3) {
|
|
131
|
+
const billed = Number(x?.billedUsd);
|
|
132
|
+
const spentUsd = Number.isFinite(billed) ? billed : 0;
|
|
133
|
+
const usedCogs = receiptUsedCogs(x, markup);
|
|
134
|
+
const direct = typeof x?.directUsd === 'number' ? x.directUsd : spentUsd;
|
|
135
|
+
return { spentUsd, cogsUsd: usedCogs, directUsd: direct };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function inferRaceTier(models, fallback = 'medium') {
|
|
139
|
+
const list = Array.isArray(models) ? models : [];
|
|
140
|
+
const grok = list.filter((m) => /^x-ai\/grok/i.test(String(m)));
|
|
141
|
+
if (list.length && grok.length === list.length) return 'grok4.6';
|
|
142
|
+
return fallback;
|
|
143
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.49.
|
|
3
|
+
"version": "0.49.2",
|
|
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",
|