openzoo 0.50.95 → 0.50.97
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/bin/claude-zoo.js +28 -3
- package/lib/aoe.js +8 -2
- package/lib/cursorbackend.js +47 -22
- package/lib/cursorbackend.js.bak-hiddenbots +4072 -0
- package/lib/grokbotAccount.js +4 -2
- package/lib/grokbotAccount.js.bak-hiddenbots +386 -0
- package/lib/grokbotweb.js +10 -1
- package/lib/grokcli.js +89 -7
- package/lib/launch.js +18 -24
- package/lib/modelroute/README.md +1 -0
- package/lib/modelroute/catalog.json +1 -0
- package/lib/modelroute/outcomes.json +1566 -0
- package/lib/modelroute/router.json +1 -0
- package/lib/modelroute.js +737 -0
- package/lib/ozSpendChip.js +187 -29
- package/lib/podagent.mjs +1425 -0
- package/lib/proxy.js +127 -48
- package/lib/runguard.js +31 -0
- package/lib/setup.js +12 -13
- package/lib/spill.js +2031 -0
- package/lib/stripeOnramp.js +50 -0
- package/lib/subscription.js +207 -0
- package/lib/worktree.mjs +424 -0
- package/package.json +1 -1
package/lib/podagent.mjs
ADDED
|
@@ -0,0 +1,1425 @@
|
|
|
1
|
+
// Pod agent — runs INSIDE our box. It IS the Grok Bot sandbox brain, on the
|
|
2
|
+
// ports the app expects (1337 agent, 6080 vnc, 1340 local-exec), but the brain
|
|
3
|
+
// is openzoo (x402-paid) and the sandbox is ours.
|
|
4
|
+
//
|
|
5
|
+
// PROTOCOL, REVERSE-ENGINEERED FROM local-exec-daemon/main.cjs (2026-08-16):
|
|
6
|
+
// GET /local-exec/requests the daemon opens an SSE stream to RECEIVE frames
|
|
7
|
+
// POST /local-exec/responses the daemon SENDS frames: hello, ping, output,
|
|
8
|
+
// stdout, result, exit
|
|
9
|
+
// frames the daemon RECEIVES (we push down the SSE):
|
|
10
|
+
// {kind:"welcome", providerId}
|
|
11
|
+
// {kind:"exec", requestId, approvalId, serverMessage:{shellArgs:{command,
|
|
12
|
+
// workingDirectory, timeout}}} (serverMessage = agent.v1.ExecServerMessage,
|
|
13
|
+
// shellArgs = agent.v1.ShellArgs; the daemon assigns id itself)
|
|
14
|
+
// {kind:"upload"|"download"|"cancel", ...}
|
|
15
|
+
// exec is APPROVAL-GATED on the user's Mac (isLocalUseBlocked / supervised).
|
|
16
|
+
//
|
|
17
|
+
// So: the daemon runs commands on the USER's machine (variant "sand",
|
|
18
|
+
// localRoot=/Users/…), and WE — from the box — decide what to run, paying for
|
|
19
|
+
// the reasoning via openzoo. That is the whole product: a Grok-Bot-shaped agent
|
|
20
|
+
// whose brain is the zoo.
|
|
21
|
+
|
|
22
|
+
import http from 'node:http';
|
|
23
|
+
import { appendFileSync } from 'node:fs';
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
25
|
+
import {
|
|
26
|
+
formatPayStatus, startModelWait, readWithIdleTimeout, STREAM_IDLE_MS,
|
|
27
|
+
createRaceFeed, pickRaceWinner, parseClassifyScore, RACE_MIN_SCORE,
|
|
28
|
+
isRaceCountable, raceLastShip, shouldRetryRaceArrival, raceFailKind,
|
|
29
|
+
summarizeRaceFailures,
|
|
30
|
+
} from './livestatus.js';
|
|
31
|
+
import { messageReasoning, wrapThink, stripThinkTags, reasoningPresent, splitThink } from './think.js';
|
|
32
|
+
import { guardFindCwd } from './runguard.js';
|
|
33
|
+
|
|
34
|
+
function emitReply(onDelta, raw) {
|
|
35
|
+
const parts = splitThink(raw);
|
|
36
|
+
if (parts.thinking) onDelta(parts.thinking, { think: true });
|
|
37
|
+
if (parts.visible) onDelta(parts.visible);
|
|
38
|
+
}
|
|
39
|
+
import {
|
|
40
|
+
probeGatewayRace, capRaceByCredit, inferRaceTier, RACE_NO_CREDIT,
|
|
41
|
+
recutRaceByHud, sessionDollarX,
|
|
42
|
+
} from './racesettle.js';
|
|
43
|
+
import { homedir } from 'node:os';
|
|
44
|
+
|
|
45
|
+
const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
|
|
46
|
+
.split(',').map((s) => Number(s.trim())).filter(Boolean);
|
|
47
|
+
const LOG = process.env.OZ_AGENT_LOG || '/var/log/openzoo/agent.jsonl';
|
|
48
|
+
export const PROXY = process.env.OZ_PROXY || 'http://127.0.0.1:8402/v1';
|
|
49
|
+
/** Live value — tests point OZ_PROXY at a mock after this module loads. */
|
|
50
|
+
function completionsProxy() {
|
|
51
|
+
return process.env.OZ_PROXY || PROXY;
|
|
52
|
+
}
|
|
53
|
+
export const MODEL = process.env.OZ_BRAIN_MODEL || 'openzoo/auto';
|
|
54
|
+
const MAX_STEPS = Number(process.env.OZ_MAX_STEPS || 10);
|
|
55
|
+
|
|
56
|
+
// Matches the Grok Bot chat surface itself (dark canvas, right-aligned grey
|
|
57
|
+
// user pills, pink-avatar left-aligned replies, pill input bar with + / mic) —
|
|
58
|
+
// this renders INSIDE Grok Bot's own sandbox panel (its sidebar/titlebar are
|
|
59
|
+
// the app's own chrome, not ours), so only the message canvas needs to match.
|
|
60
|
+
export const VNC_CHAT_HTML = `<!doctype html>
|
|
61
|
+
<html><head><meta charset="utf-8"><title>openzoo box</title>
|
|
62
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
63
|
+
<style>
|
|
64
|
+
:root { color-scheme: dark; }
|
|
65
|
+
* { box-sizing: border-box; }
|
|
66
|
+
html, body { margin: 0; height: 100%; background: #000; }
|
|
67
|
+
body { color: #ececec; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
68
|
+
display: flex; flex-direction: column; }
|
|
69
|
+
#log { flex: 1; overflow-y: auto; padding: 28px 24px 12px; display: flex; flex-direction: column; gap: 6px; }
|
|
70
|
+
.hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
|
|
71
|
+
color: #8e8e93; font-size: 13px; }
|
|
72
|
+
.hdr .avatar { width: 18px; height: 18px; border-radius: 5px; }
|
|
73
|
+
.hdr .avatar svg { width: 10px; height: 10px; }
|
|
74
|
+
.row { display: flex; max-width: 78%; margin: 2px 0; }
|
|
75
|
+
.row.user { align-self: flex-end; }
|
|
76
|
+
.row.bot { align-self: flex-start; }
|
|
77
|
+
.avatar { width: 30px; height: 30px; border-radius: 8px; flex: 0 0 30px; background: #e91e8c;
|
|
78
|
+
display: flex; align-items: center; justify-content: center; }
|
|
79
|
+
.avatar svg { width: 16px; height: 16px; }
|
|
80
|
+
.bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word; }
|
|
81
|
+
.row.user .bubble { background: #57575c; }
|
|
82
|
+
.row.bot .bubble { background: #262626; color: #ececec; }
|
|
83
|
+
.row.bot.pending .bubble { color: #8e8e93; }
|
|
84
|
+
.dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
|
|
85
|
+
background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
|
|
86
|
+
.dots span:nth-child(2) { animation-delay: .2s; } .dots span:nth-child(3) { animation-delay: .4s; }
|
|
87
|
+
@keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
|
|
88
|
+
#bar { padding: 10px 16px 18px; }
|
|
89
|
+
#row-input { display: flex; align-items: center; gap: 8px; }
|
|
90
|
+
#pill { flex: 1; display: flex; align-items: center; gap: 6px; background: #2c2c2e; border-radius: 26px;
|
|
91
|
+
padding: 8px 10px 8px 14px; }
|
|
92
|
+
.icon-btn { width: 32px; height: 32px; border-radius: 50%; border: none; background: transparent;
|
|
93
|
+
color: #ececec; display: flex; align-items: center; justify-content: center; cursor: pointer;
|
|
94
|
+
flex: 0 0 32px; }
|
|
95
|
+
.icon-btn:hover { background: #3a3a3c; }
|
|
96
|
+
.icon-btn svg { width: 18px; height: 18px; }
|
|
97
|
+
#inp { flex: 1; background: transparent; border: none; color: #ececec; font: inherit;
|
|
98
|
+
padding: 6px 0; min-width: 0; }
|
|
99
|
+
#inp::placeholder { color: #8e8e93; }
|
|
100
|
+
#inp:focus { outline: none; }
|
|
101
|
+
#send { width: 34px; height: 34px; border-radius: 50%; border: none; background: #fff; color: #000;
|
|
102
|
+
display: none; align-items: center; justify-content: center; cursor: pointer; flex: 0 0 34px; }
|
|
103
|
+
#send.show { display: flex; }
|
|
104
|
+
#send svg { width: 16px; height: 16px; }
|
|
105
|
+
</style></head>
|
|
106
|
+
<body>
|
|
107
|
+
<div id="log"></div>
|
|
108
|
+
<div id="bar">
|
|
109
|
+
<div id="row-input">
|
|
110
|
+
<div id="pill">
|
|
111
|
+
<button class="icon-btn" tabindex="-1">
|
|
112
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
113
|
+
</button>
|
|
114
|
+
<input id="inp" placeholder="Message openzoo" autofocus>
|
|
115
|
+
<button class="icon-btn" tabindex="-1">
|
|
116
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10v1a7 7 0 0 1-14 0v-1"/><line x1="12" y1="18" x2="12" y2="22"/></svg>
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
<button id="send">
|
|
120
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M6 11l6-6 6 6"/></svg>
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
<script>
|
|
125
|
+
const log = document.getElementById('log');
|
|
126
|
+
const inp = document.getElementById('inp');
|
|
127
|
+
const send = document.getElementById('send');
|
|
128
|
+
const AVATAR = '<svg viewBox="0 0 24 24" fill="none"><path d="M6 10c0-1 1-2 2-1l2 2 2-2c1-1 2 0 2 1v2c0 2-2 3-4 3s-4-1-4-3v-2z" fill="#fff"/></svg>';
|
|
129
|
+
let lastWho = null;
|
|
130
|
+
function addRow(who, text) {
|
|
131
|
+
if (who === 'bot' && lastWho !== 'bot') {
|
|
132
|
+
const hdr = document.createElement('div');
|
|
133
|
+
hdr.className = 'hdr';
|
|
134
|
+
hdr.innerHTML = '<span class="avatar">' + AVATAR + '</span><span>openzoo</span>';
|
|
135
|
+
log.appendChild(hdr);
|
|
136
|
+
}
|
|
137
|
+
lastWho = who;
|
|
138
|
+
const row = document.createElement('div');
|
|
139
|
+
row.className = 'row ' + who + (text === null ? ' pending' : '');
|
|
140
|
+
const bubble = document.createElement('div');
|
|
141
|
+
bubble.className = 'bubble';
|
|
142
|
+
bubble.innerHTML = text === null ? '<span class="dots"><span></span><span></span><span></span></span>' : '';
|
|
143
|
+
if (text !== null) bubble.textContent = text;
|
|
144
|
+
row.appendChild(bubble);
|
|
145
|
+
log.appendChild(row);
|
|
146
|
+
log.scrollTop = log.scrollHeight;
|
|
147
|
+
return { row, bubble };
|
|
148
|
+
}
|
|
149
|
+
async function submit() {
|
|
150
|
+
const task = inp.value.trim();
|
|
151
|
+
if (!task) return;
|
|
152
|
+
inp.value = '';
|
|
153
|
+
send.classList.remove('show');
|
|
154
|
+
inp.disabled = true;
|
|
155
|
+
addRow('user', task);
|
|
156
|
+
const pending = addRow('bot', null);
|
|
157
|
+
try {
|
|
158
|
+
const r = await fetch('/drive', {
|
|
159
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
160
|
+
body: JSON.stringify({ task }),
|
|
161
|
+
});
|
|
162
|
+
const j = await r.json();
|
|
163
|
+
pending.row.classList.remove('pending');
|
|
164
|
+
pending.bubble.textContent = j.text || '(no response)';
|
|
165
|
+
} catch (e) {
|
|
166
|
+
pending.row.classList.remove('pending');
|
|
167
|
+
pending.bubble.textContent = 'error: ' + e.message;
|
|
168
|
+
}
|
|
169
|
+
inp.disabled = false; inp.focus();
|
|
170
|
+
}
|
|
171
|
+
inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0); });
|
|
172
|
+
send.addEventListener('click', submit);
|
|
173
|
+
inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
|
|
174
|
+
</script>
|
|
175
|
+
</body></html>`;
|
|
176
|
+
|
|
177
|
+
function record(entry) {
|
|
178
|
+
try { appendFileSync(LOG, JSON.stringify(entry) + '\n'); } catch { /* best effort */ }
|
|
179
|
+
console.log(`[agent:${entry.port ?? '-'}] ${entry.method || entry.ev} ${entry.path || ''} ${entry.bodyBytes ?? ''}${entry.frames ? ' frames=' + entry.frames : ''}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ------------------------------------------------------------------ frames --
|
|
183
|
+
|
|
184
|
+
/** Send frames to the daemon down its SSE. The daemon reads the stream with a
|
|
185
|
+
* TextDecoder and parses per-line JSON, so one frame per `data:` line. We wrap
|
|
186
|
+
* as {frames:[…]} to mirror the response side; a bare frame is the fallback. */
|
|
187
|
+
function sseSend(res, frame) {
|
|
188
|
+
const line = `data: ${JSON.stringify({ frames: [frame] })}\n\n`;
|
|
189
|
+
try { res.write(line); return true; } catch { return false; }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function execFrame(command, cwd = '/tmp') {
|
|
193
|
+
return {
|
|
194
|
+
kind: 'exec',
|
|
195
|
+
requestId: randomUUID(),
|
|
196
|
+
approvalId: randomUUID(),
|
|
197
|
+
// agent.v1.ExecServerMessage as protobuf-es JSON (camelCase). The daemon
|
|
198
|
+
// assigns `id`; we only supply the shell variant.
|
|
199
|
+
serverMessage: { shellArgs: { command: guardFindCwd(command, cwd), workingDirectory: cwd, timeout: 120 } },
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ------------------------------------------------------------------- brain --
|
|
204
|
+
|
|
205
|
+
// deepseek-v4-pro-0813 (the default MODEL) doesn't expose modality info via
|
|
206
|
+
// /v1/models, and it's not something we can verify blind — rather than gamble
|
|
207
|
+
// on a text-only model silently ignoring pasted images, any message with
|
|
208
|
+
// multimodal (image_url) content routes to a model KNOWN to support vision.
|
|
209
|
+
const VISION_MODEL = process.env.OZ_VISION_MODEL || 'anthropic/claude-sonnet-5';
|
|
210
|
+
// Output budget per turn. Reasoning models spend this on their chain of
|
|
211
|
+
// thought BEFORE emitting any content, so a budget that is merely "enough for
|
|
212
|
+
// the answer" produces no answer at all on a hard prompt.
|
|
213
|
+
const MAX_TOKENS = Number(process.env.OZ_MAX_TOKENS || 4096);
|
|
214
|
+
const msgHasImage = (m) => Array.isArray(m.content) && m.content.some((c) => c?.type === 'image_url');
|
|
215
|
+
|
|
216
|
+
// How far back an image still counts as "being discussed". Beyond this the
|
|
217
|
+
// thread drops back to the cheaper text model.
|
|
218
|
+
const VISION_WINDOW = Number(process.env.OZ_VISION_WINDOW || 8);
|
|
219
|
+
|
|
220
|
+
// This used to scan the WHOLE history, so a single pasted image pinned every
|
|
221
|
+
// later turn in that thread to the vision model forever — permanently, and
|
|
222
|
+
// invisibly, at 1.5x input and 2.5x output (claude-sonnet-5 $6/$30 per M vs
|
|
223
|
+
// deepseek-v4-pro $3.96/$11.88, priced live off /v1/models). A thread where
|
|
224
|
+
// someone shared one screenshot an hour ago was still paying vision rates for
|
|
225
|
+
// pure text chat.
|
|
226
|
+
function hasImages(messages) {
|
|
227
|
+
return messages.slice(-VISION_WINDOW).some(msgHasImage);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// When we DO fall back to the text model, the old image_url blocks must not go
|
|
231
|
+
// with it — a non-vision model either errors on them or silently ignores them
|
|
232
|
+
// while still being billed for the payload. Replace them with a marker so the
|
|
233
|
+
// model knows an image was there rather than seeing a gap it might deny.
|
|
234
|
+
function stripImages(messages) {
|
|
235
|
+
return messages.map((m) => {
|
|
236
|
+
if (!msgHasImage(m)) return m;
|
|
237
|
+
const text = m.content.filter((c) => c?.type === 'text').map((c) => c.text).join(' ').trim();
|
|
238
|
+
return { ...m, content: `${text}${text ? ' ' : ''}[image sent earlier, no longer in context]` };
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A non-ok response with no usable content used to silently become '', which
|
|
243
|
+
// grokui.mjs then renders as a generic "(no response)" — indistinguishable
|
|
244
|
+
// from a model that genuinely had nothing to say. Callers should know WHY.
|
|
245
|
+
async function httpErrorNote(status) {
|
|
246
|
+
if (status === 402) {
|
|
247
|
+
// print the ACTUAL address in the chat, not a link elsewhere to go find it.
|
|
248
|
+
// fetch() does NOT reject on 4xx/5xx, so an older proxy without /wallet
|
|
249
|
+
// returns an error body that parses fine and yields "undefined" fields —
|
|
250
|
+
// check r.ok and the fields themselves before interpolating them.
|
|
251
|
+
try {
|
|
252
|
+
const r = await fetch(`${PROXY}/wallet`);
|
|
253
|
+
const w = r.ok ? await r.json() : null;
|
|
254
|
+
if (w?.funding && w?.evm) {
|
|
255
|
+
// funded === false is the genuinely-empty case; funded === true after
|
|
256
|
+
// the retries above means the rail/quote failed, not the balance
|
|
257
|
+
if (w.funded === false) {
|
|
258
|
+
return `(payment required — HTTP 402, the wallet is empty. ${w.funding}. EVM (Base/Robinhood): ${w.evm}.)`;
|
|
259
|
+
}
|
|
260
|
+
return `(payment failed — HTTP 402 after ${PAYMENT_RETRIES} retries, though the wallet holds ${w.balances || 'a balance'}. Send it again; if it keeps failing the quoted asset may not be convertible right now. Fund with: ${w.funding})`;
|
|
261
|
+
}
|
|
262
|
+
} catch { /* proxy unreachable — fall through to the generic note */ }
|
|
263
|
+
return `(payment failed — HTTP 402 after ${PAYMENT_RETRIES} retries. Run \`npx openzoo\` to check wallet balances.)`;
|
|
264
|
+
}
|
|
265
|
+
if (status === 429) return '(rate limited — HTTP 429, try again in a moment)';
|
|
266
|
+
if (status >= 500) return `(upstream error — HTTP ${status}, try again)`;
|
|
267
|
+
return status ? `(request failed — HTTP ${status})` : '';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Do not dump raw Solana wrap-sim logs into the chat bubble. */
|
|
271
|
+
function sanitizeProxiedError(msg) {
|
|
272
|
+
if (!msg) return msg;
|
|
273
|
+
const s = String(msg);
|
|
274
|
+
if (/0x6a\b|custom program error:\s*106\b|NotEnoughAccounts/i.test(s)) {
|
|
275
|
+
return 'wrap ix has too few accounts (need 9); old 5-account wrap is dead';
|
|
276
|
+
}
|
|
277
|
+
if (/0x70\b|custom program error:\s*112\b|TokenProgramMismatch/i.test(s)) {
|
|
278
|
+
return 'unwrap ix is missing the unwrapped token program (account 8); 8-account unwrap is dead';
|
|
279
|
+
}
|
|
280
|
+
return s;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// A 402 that reached this layer means the proxy's own x402 retry gave up on
|
|
284
|
+
// this attempt, but the NEXT attempt usually settles (measured: same wallet,
|
|
285
|
+
// same rail, second call pays fine). Surfacing that as a chat message makes
|
|
286
|
+
// the user do the retry by hand — so do it here instead.
|
|
287
|
+
// Empty/underfunded is NOT that handshake: retrying just re-walks wrap.
|
|
288
|
+
const PAYMENT_RETRIES = 3;
|
|
289
|
+
|
|
290
|
+
export function isUnderfunded402Body(body) {
|
|
291
|
+
const msg = String(body?.error?.message || body?.error || body?.message || '');
|
|
292
|
+
return /\b(?:underfunded|wallet is empty|empty wallet)\b/i.test(msg);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* ADAPTIVE top_k. We have learned this one the expensive way already.
|
|
296
|
+
*
|
|
297
|
+
* On leCore the miss was never the ranker — BM25 ranked correctly. It was
|
|
298
|
+
* top_k=16 against a 7,000-chunk corpus: we only ever ASKED for sixteen. Same
|
|
299
|
+
* shape here, and worse: grokui never set the header at all, so every call fell
|
|
300
|
+
* to the gateway default of EIGHT, while a whole project's bots write into one
|
|
301
|
+
* shared context. Six bots working for an hour and the model sees eight chunks
|
|
302
|
+
* of it.
|
|
303
|
+
*
|
|
304
|
+
* So scale with the corpus instead of picking a number. sqrt keeps it sane at
|
|
305
|
+
* both ends — 100 chunks -> 20, 1k -> 63, 7k -> 167, and it saturates at the
|
|
306
|
+
* gateway's 256 ceiling rather than growing without bound. Floor of 16 so a
|
|
307
|
+
* brand-new thread is never worse off than the old default.
|
|
308
|
+
*
|
|
309
|
+
* Cost is real and proportional (measured on leCore: top_k 16 = $0.0070,
|
|
310
|
+
* top_k 128 = $0.0489 on the same question) — which is the point. The extra
|
|
311
|
+
* spend IS the extra corpus actually being read.
|
|
312
|
+
*/
|
|
313
|
+
export function adaptiveTopK(boundItems) {
|
|
314
|
+
const n = Math.max(0, Number(boundItems) || 0);
|
|
315
|
+
return Math.max(16, Math.min(256, Math.ceil(Math.sqrt(n) * 2)));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function postChat(body, contextId, topK, onStatus, signal) {
|
|
319
|
+
void contextId; // spill path: never send as x-hrr-context
|
|
320
|
+
let r;
|
|
321
|
+
for (let attempt = 0; attempt <= PAYMENT_RETRIES; attempt++) {
|
|
322
|
+
if (signal?.aborted) {
|
|
323
|
+
const err = new Error(signal.reason?.message || 'aborted');
|
|
324
|
+
err.name = 'AbortError';
|
|
325
|
+
throw err;
|
|
326
|
+
}
|
|
327
|
+
r = await fetch(`${completionsProxy()}/chat/completions`, {
|
|
328
|
+
method: 'POST',
|
|
329
|
+
headers: {
|
|
330
|
+
'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
|
|
331
|
+
// Only sent when we actually know the corpus size; without it the
|
|
332
|
+
// gateway keeps its own default rather than getting a made-up number.
|
|
333
|
+
...(topK ? { 'x-hrr-top-k': String(topK) } : {}),
|
|
334
|
+
// Do NOT attach x-hrr-context. proxy.js maybeCacheCorpus bails
|
|
335
|
+
// (`if (req.headers['x-hrr-context']) return null`) so Claude CLI's
|
|
336
|
+
// bind-prefix / send-3/131-turns never ran for grokui. Tetris then
|
|
337
|
+
// shipped ~850k chars × race of 4 and every model failed in ~22s.
|
|
338
|
+
// Completions must hit the same sidecar spill path as `npx openzoo claude`.
|
|
339
|
+
// bindThread stays for other things; contextId is kept on the signature
|
|
340
|
+
// so brain / brainStream callers do not change.
|
|
341
|
+
},
|
|
342
|
+
body: JSON.stringify(body),
|
|
343
|
+
...(signal ? { signal } : {}),
|
|
344
|
+
});
|
|
345
|
+
if (r.status !== 402 || attempt === PAYMENT_RETRIES) return r;
|
|
346
|
+
// Handshake 402 (funded, settle flake) → retry. Empty-wallet 402 → stop.
|
|
347
|
+
// Opening Pay / parking happens on the empty body, not on every 402.
|
|
348
|
+
const peek = await r.clone().json().catch(() => null);
|
|
349
|
+
if (isUnderfunded402Body(peek)) return r;
|
|
350
|
+
// A 402 retry used to be silent — grokui sat on mute "…" for the whole
|
|
351
|
+
// settle. Tell the watcher this attempt is paying, not wedged.
|
|
352
|
+
onStatus?.(formatPayStatus(attempt));
|
|
353
|
+
await new Promise((res) => setTimeout(res, 800 * (attempt + 1)));
|
|
354
|
+
}
|
|
355
|
+
return r;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** One openzoo chat turn. Paid per call by the box's own wallet via the local
|
|
359
|
+
* proxy — no key, no account. */
|
|
360
|
+
// Which model actually serves a turn is decided HERE, not by the caller: a
|
|
361
|
+
// thread carrying an image routes to VISION_MODEL. A system prompt written
|
|
362
|
+
// once at thread creation therefore cannot name it correctly, and a bot left
|
|
363
|
+
// to guess asserts whatever its training prior says — MEASURED: a Claude-served
|
|
364
|
+
// turn insisted it was Claude while the app's default is DeepSeek, and burned
|
|
365
|
+
// several paid turns arguing. So the prompt carries a placeholder and the real
|
|
366
|
+
// id is substituted at call time, per turn.
|
|
367
|
+
function withModelId(messages, model) {
|
|
368
|
+
return messages.map((m) => (m.role === 'system' && typeof m.content === 'string' && m.content.includes('__OZ_MODEL__')
|
|
369
|
+
? { ...m, content: m.content.replaceAll('__OZ_MODEL__', model) }
|
|
370
|
+
: m));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function brain(messages, contextId, modelOverride, topK, signal) {
|
|
374
|
+
// explicit plugins, not relying on the gateway's "inject when caller said
|
|
375
|
+
// nothing" default — an explicit array is always respected as-is, so every
|
|
376
|
+
// bot on every model actually has web search. max_tokens 900 was cutting
|
|
377
|
+
// real (especially web-search-backed) answers off mid-sentence.
|
|
378
|
+
//
|
|
379
|
+
// modelOverride is what "/model <id>" sets per thread. VISION still wins
|
|
380
|
+
// when the conversation actually contains images: a text-only model handed
|
|
381
|
+
// image parts just errors, so silently honouring the override there would
|
|
382
|
+
// turn a working thread into a broken one.
|
|
383
|
+
const vision = hasImages(messages);
|
|
384
|
+
const model = vision ? VISION_MODEL : (modelOverride || MODEL);
|
|
385
|
+
messages = vision ? messages : stripImages(messages);
|
|
386
|
+
const r = await postChat(
|
|
387
|
+
{ model, max_tokens: 4096, messages: withModelId(messages, model), plugins: [{ id: 'web' }] },
|
|
388
|
+
contextId, topK, undefined, signal,
|
|
389
|
+
);
|
|
390
|
+
const j = await r.json().catch(() => ({}));
|
|
391
|
+
const msg = j?.choices?.[0]?.message;
|
|
392
|
+
const content = msg?.content;
|
|
393
|
+
const thinking = messageReasoning(msg);
|
|
394
|
+
// Same truncation catch as the streaming path (see brainStream): a reply that
|
|
395
|
+
// stops because the budget ran out is not a finished reply, and this path is
|
|
396
|
+
// what non-streaming callers — including every SPAWNed subagent — go through.
|
|
397
|
+
if (content && j?.choices?.[0]?.finish_reason === 'length') {
|
|
398
|
+
const rest = await brainContinue(messages, content, contextId, modelOverride, 0);
|
|
399
|
+
return wrapThink(thinking, content + rest);
|
|
400
|
+
}
|
|
401
|
+
return wrapThink(thinking, content || (r.ok ? '' : await httpErrorNote(r.status)));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Resume a reply that hit the output cap, non-streaming. Bounded by
|
|
405
|
+
* CONTINUE_ROUNDS for the same runaway reason brainStream is. */
|
|
406
|
+
async function brainContinue(messages, sofar, contextId, modelOverride, round) {
|
|
407
|
+
if (round >= CONTINUE_ROUNDS) return '';
|
|
408
|
+
const vision = hasImages(messages);
|
|
409
|
+
const model = vision ? VISION_MODEL : (modelOverride || MODEL);
|
|
410
|
+
const next = [...messages,
|
|
411
|
+
{ role: 'assistant', content: sofar },
|
|
412
|
+
{ role: 'user', content: CONTINUE_NUDGE }];
|
|
413
|
+
const r = await postChat(
|
|
414
|
+
{ model, max_tokens: Math.min(4096 * (2 ** (round + 1)), MAX_CONTINUE_TOKENS),
|
|
415
|
+
messages: withModelId(vision ? next : stripImages(next), model), plugins: [{ id: 'web' }] },
|
|
416
|
+
contextId, undefined, undefined,
|
|
417
|
+
);
|
|
418
|
+
const j = await r.json().catch(() => ({}));
|
|
419
|
+
const more = j?.choices?.[0]?.message?.content || '';
|
|
420
|
+
if (more && j?.choices?.[0]?.finish_reason === 'length') {
|
|
421
|
+
return more + await brainContinue(messages, sofar + more, contextId, modelOverride, round + 1);
|
|
422
|
+
}
|
|
423
|
+
return more;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Same call, but streamed — invokes onDelta(text) as tokens arrive (for a
|
|
427
|
+
* live-typing UI) and resolves with the full accumulated text at the end, so
|
|
428
|
+
* callers that need to parse a directive out of the complete reply still can.
|
|
429
|
+
* onStatus(detail) is an optional second channel: paying / waiting on model /
|
|
430
|
+
* thinking, so a 20–40s settle is visibly alive instead of mute dots. */
|
|
431
|
+
export async function brainStream(messages, onDelta, contextId, modelOverride, maxTokens, round = 0, topK = 0, onStatus, signal) {
|
|
432
|
+
const vision = hasImages(messages);
|
|
433
|
+
const model = vision ? VISION_MODEL : (modelOverride || MODEL);
|
|
434
|
+
messages = vision ? messages : stripImages(messages);
|
|
435
|
+
const budget = maxTokens || MAX_TOKENS;
|
|
436
|
+
const r = await postChat(
|
|
437
|
+
{ model, max_tokens: budget, messages: withModelId(messages, model), plugins: [{ id: 'web' }], stream: true },
|
|
438
|
+
contextId, topK, onStatus, signal,
|
|
439
|
+
);
|
|
440
|
+
if (!r.ok || !r.body) {
|
|
441
|
+
// fall back to the non-streaming path rather than fail outright
|
|
442
|
+
const j = await r.json().catch(() => ({}));
|
|
443
|
+
const msg = j?.choices?.[0]?.message;
|
|
444
|
+
const content = msg?.content;
|
|
445
|
+
const thinking = messageReasoning(msg);
|
|
446
|
+
const proxied = sanitizeProxiedError(j?.error?.message);
|
|
447
|
+
const text = content || (r.ok ? '' : (proxied ? `(request failed — HTTP ${r.status}: ${proxied})` : await httpErrorNote(r.status)));
|
|
448
|
+
if (thinking) onDelta(thinking, { think: true });
|
|
449
|
+
if (text) onDelta(text);
|
|
450
|
+
return wrapThink(thinking, text);
|
|
451
|
+
}
|
|
452
|
+
const reader = r.body.getReader();
|
|
453
|
+
const decoder = new TextDecoder();
|
|
454
|
+
let buf = '', full = '', reasonedChars = 0, reasonedText = '', finish = '';
|
|
455
|
+
let stopWait = startModelWait(onStatus);
|
|
456
|
+
const noteThinking = () => {
|
|
457
|
+
stopWait();
|
|
458
|
+
onStatus?.('thinking…');
|
|
459
|
+
};
|
|
460
|
+
const takeReasoning = (delta, message) => {
|
|
461
|
+
const chunk = messageReasoning(message, delta);
|
|
462
|
+
if (!chunk) return;
|
|
463
|
+
reasonedChars += chunk.length;
|
|
464
|
+
reasonedText += chunk;
|
|
465
|
+
onDelta(chunk, { think: true });
|
|
466
|
+
if (!full) noteThinking();
|
|
467
|
+
};
|
|
468
|
+
try {
|
|
469
|
+
for (;;) {
|
|
470
|
+
let chunk;
|
|
471
|
+
try {
|
|
472
|
+
chunk = await readWithIdleTimeout(reader, STREAM_IDLE_MS);
|
|
473
|
+
} catch (e) {
|
|
474
|
+
if (e?.code !== 'STREAM_IDLE') throw e;
|
|
475
|
+
try { await reader.cancel(); } catch { /* already closed */ }
|
|
476
|
+
stopWait();
|
|
477
|
+
// A quiet SSE used to hang this loop forever and leave grokui on
|
|
478
|
+
// thinking / "…". Prefer what we have; if we have nothing, one
|
|
479
|
+
// non-stream retry rather than a mute bubble.
|
|
480
|
+
if (full) {
|
|
481
|
+
const note = '\n\n(stream stalled — showing what arrived before the timeout)';
|
|
482
|
+
onDelta(note);
|
|
483
|
+
return wrapThink(reasonedText, full + note);
|
|
484
|
+
}
|
|
485
|
+
onStatus?.('waiting on model…');
|
|
486
|
+
const fallback = await brain(messages, contextId, modelOverride, topK, signal);
|
|
487
|
+
if (fallback) emitReply(onDelta, fallback);
|
|
488
|
+
return fallback || '(stream timed out — no tokens arrived)';
|
|
489
|
+
}
|
|
490
|
+
const { value, done } = chunk;
|
|
491
|
+
if (done) break;
|
|
492
|
+
buf += decoder.decode(value, { stream: true });
|
|
493
|
+
const lines = buf.split('\n');
|
|
494
|
+
buf = lines.pop(); // last line may be incomplete — keep it for next chunk
|
|
495
|
+
for (const line of lines) {
|
|
496
|
+
const s = line.trim();
|
|
497
|
+
if (!s.startsWith('data:')) continue;
|
|
498
|
+
const payload = s.slice(5).trim();
|
|
499
|
+
if (payload === '[DONE]') continue;
|
|
500
|
+
try {
|
|
501
|
+
const c = JSON.parse(payload)?.choices?.[0];
|
|
502
|
+
const d = c?.delta;
|
|
503
|
+
// The LAST chunk carries why generation stopped. "length" means the
|
|
504
|
+
// budget ran out mid-answer — the only way to tell a finished reply
|
|
505
|
+
// from a guillotined one.
|
|
506
|
+
if (c?.finish_reason) finish = c.finish_reason;
|
|
507
|
+
if (d?.content) {
|
|
508
|
+
stopWait();
|
|
509
|
+
full += d.content;
|
|
510
|
+
onDelta(d.content);
|
|
511
|
+
}
|
|
512
|
+
// Reasoning models emit their chain of thought on a SEPARATE field
|
|
513
|
+
// (reasoning_content / reasoning / thinking / thought, or a provider
|
|
514
|
+
// object with a plaintext summary). Forward the plaintext as
|
|
515
|
+
// onDelta(text, { think: true }) so the canvas can fold it — never
|
|
516
|
+
// as visible content. Encrypted blobs stay counted for the empty-
|
|
517
|
+
// after-think retry, but are not forwarded.
|
|
518
|
+
const before = reasonedChars;
|
|
519
|
+
takeReasoning(d, c?.message);
|
|
520
|
+
if (reasonedChars === before && reasoningPresent(d, c?.message)) {
|
|
521
|
+
// Encrypted-only: nothing to fold, but the model DID think —
|
|
522
|
+
// count it so an empty completion retries instead of "(no response)".
|
|
523
|
+
reasonedChars += 1;
|
|
524
|
+
if (!full) noteThinking();
|
|
525
|
+
}
|
|
526
|
+
} catch { /* keep-alive line or partial JSON — ignore */ }
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} finally {
|
|
530
|
+
stopWait();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// EMPTY CONTENT AFTER HEAVY REASONING is a truncation, not an answer. It
|
|
534
|
+
// surfaced in the UI as a bare "(no response)" bubble that cost the user a
|
|
535
|
+
// turn and explained nothing — on exactly the long, complex prompts where a
|
|
536
|
+
// reasoning model thinks the most. Retry ONCE with a bigger budget.
|
|
537
|
+
if (!full && reasonedChars > 0 && !maxTokens) {
|
|
538
|
+
onStatus?.('retrying…');
|
|
539
|
+
return brainStream(messages, onDelta, contextId, modelOverride, budget * 4, round, topK, onStatus, signal);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// CUT OFF MID-ANSWER. finish_reason "length" means the model had more to say
|
|
543
|
+
// and the budget ended the sentence for it — seen live as a reply that stops
|
|
544
|
+
// inside `for (`. Nothing above catches this, because `full` is non-empty:
|
|
545
|
+
// by every other measure the turn succeeded.
|
|
546
|
+
//
|
|
547
|
+
// CONTINUE rather than retry. Re-running the turn with a bigger budget makes
|
|
548
|
+
// the user pay twice for the half we already have (and on a reasoning model,
|
|
549
|
+
// pay for the whole chain of thought again). Handing the model back its own
|
|
550
|
+
// partial and asking for the rest costs only the rest.
|
|
551
|
+
//
|
|
552
|
+
// Bounded, because a model that ignores the nudge would otherwise continue
|
|
553
|
+
// forever on the user's wallet.
|
|
554
|
+
if (full && finish === 'length' && round < CONTINUE_ROUNDS) {
|
|
555
|
+
const more = await brainStream(
|
|
556
|
+
[...messages,
|
|
557
|
+
{ role: 'assistant', content: full },
|
|
558
|
+
{ role: 'user', content: CONTINUE_NUDGE }],
|
|
559
|
+
onDelta, contextId, modelOverride,
|
|
560
|
+
Math.min(budget * 2, MAX_CONTINUE_TOKENS), round + 1, topK, onStatus,
|
|
561
|
+
);
|
|
562
|
+
return wrapThink(reasonedText, full + (more || ''));
|
|
563
|
+
}
|
|
564
|
+
return wrapThink(reasonedText, full);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// How many times a single answer may be resumed after hitting the cap. Three
|
|
568
|
+
// doublings off 4096 is ~57k tokens of answer, which is past any real reply and
|
|
569
|
+
// well short of a runaway.
|
|
570
|
+
const CONTINUE_ROUNDS = Number(process.env.OZ_CONTINUE_ROUNDS || 3);
|
|
571
|
+
const MAX_CONTINUE_TOKENS = Number(process.env.OZ_MAX_CONTINUE_TOKENS || 32768);
|
|
572
|
+
// Deliberately blunt about the seam: the partial usually ends mid-token, and a
|
|
573
|
+
// model that "helpfully" restarts the sentence produces a visible stutter in
|
|
574
|
+
// the middle of the user's code.
|
|
575
|
+
const CONTINUE_NUDGE = 'You were cut off — your previous message hit the output limit mid-way. '
|
|
576
|
+
+ 'Continue from EXACTLY where it stopped. Do not repeat any of it, do not summarise it, '
|
|
577
|
+
+ 'do not add a preamble or an apology, and do not re-open a code fence that is already open. '
|
|
578
|
+
+ 'Resume mid-word if that is where it ended.';
|
|
579
|
+
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
// MODEL TIERS · cheap / medium / expensive
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
// Ranking the live catalog by price alone picks garbage at both ends: the most
|
|
584
|
+
// expensive served model is o1-pro at $1800/Mtok (a bad coding model that would
|
|
585
|
+
// drain the box wallet in a handful of turns), and the cheapest is a roleplay
|
|
586
|
+
// finetune. Price is a proxy for capability only within a band, never across
|
|
587
|
+
// the whole catalog. So each tier is a CURATED, ordered preference list, and
|
|
588
|
+
// the catalog is used to check what is actually served today — the zoo's model
|
|
589
|
+
// list changes under us, and a tier that resolves to a 404 is worse than no
|
|
590
|
+
// tier at all.
|
|
591
|
+
//
|
|
592
|
+
// Each list is ordered best-first (that is what a non-racing "auto" picks) but
|
|
593
|
+
// deliberately WIDE, because a race samples from it at random: a pool of three
|
|
594
|
+
// would race the same three models every time, which is neither a real hedge
|
|
595
|
+
// against a single provider having a bad minute nor a real sample of the tier.
|
|
596
|
+
// Prices in the comments are completion USD per Mtok as served, measured.
|
|
597
|
+
const TIERS = {
|
|
598
|
+
// ≲ $3/Mtok. Fast, good enough for glue work, cheap enough to race widely.
|
|
599
|
+
cheap: [
|
|
600
|
+
'deepseek/deepseek-v4-flash', // 0.45
|
|
601
|
+
'meta-llama/llama-4-scout', // 0.90
|
|
602
|
+
'z-ai/glm-4.7-flash', // 1.20
|
|
603
|
+
'bytedance-seed/seed-2.0-mini', // 1.20
|
|
604
|
+
'meta-llama/llama-4-maverick', // 2.40
|
|
605
|
+
'z-ai/glm-4.5-air', // 2.55
|
|
606
|
+
'minimax/minimax-m2.5', // 2.70
|
|
607
|
+
'z-ai/glm-4.6v', // 2.70
|
|
608
|
+
'minimax/minimax-m2', // 3.06
|
|
609
|
+
'inclusionai/ling-3.0-flash', // 0.19
|
|
610
|
+
],
|
|
611
|
+
// ~$4.5–11/Mtok. The default band; deepseek-v4-pro is the app default.
|
|
612
|
+
medium: [
|
|
613
|
+
'deepseek/deepseek-v4-pro-0813', // 5.94
|
|
614
|
+
'z-ai/glm-4.7', // 5.25
|
|
615
|
+
'google/gemini-3.7-flash', // 5.63
|
|
616
|
+
'x-ai/grok-4.3', // 7.50
|
|
617
|
+
'moonshotai/kimi-k2.7-code', // 10.50
|
|
618
|
+
'z-ai/glm-5', // 5.76
|
|
619
|
+
'moonshotai/kimi-k2.6', // 7.08
|
|
620
|
+
'mistralai/mistral-large-2512', // 4.50
|
|
621
|
+
'bytedance-seed/seed-2.0-code', // 9.00
|
|
622
|
+
'qwen/qwen3.8-27b', // 9.60
|
|
623
|
+
],
|
|
624
|
+
// ≥ $18/Mtok. Frontier. NOTE the ceiling: o1-pro ($1800) and the *-pro tiers
|
|
625
|
+
// ($240–540) are deliberately NOT here. A race of four across that band can
|
|
626
|
+
// cost dollars per turn on a box funded with a few cents.
|
|
627
|
+
expensive: [
|
|
628
|
+
'anthropic/claude-opus-5', // 75
|
|
629
|
+
'openai/gpt-5.5', // 90
|
|
630
|
+
'anthropic/claude-sonnet-5', // 30
|
|
631
|
+
'x-ai/grok-4.6', // 18
|
|
632
|
+
'moonshotai/kimi-k3', // 45
|
|
633
|
+
'anthropic/claude-opus-4.8', // 75
|
|
634
|
+
'openai/gpt-5.4', // 45
|
|
635
|
+
'qwen/qwen3.8-max', // 18
|
|
636
|
+
'x-ai/grok-4.5', // 18
|
|
637
|
+
],
|
|
638
|
+
// Dedicated grok 4.6 band (tweeted as its own dial next to cheap/medium/expensive).
|
|
639
|
+
// One slug cannot fill a 4-wide race without replacement, so the pool is grok
|
|
640
|
+
// chat models with 4.6 first. No imagine / stt / tts / video.
|
|
641
|
+
'grok4.6': [
|
|
642
|
+
'x-ai/grok-4.6',
|
|
643
|
+
'x-ai/grok-4.5',
|
|
644
|
+
'x-ai/grok-4.3',
|
|
645
|
+
'x-ai/grok-4.20',
|
|
646
|
+
],
|
|
647
|
+
};
|
|
648
|
+
export const TIER_NAMES = Object.keys(TIERS);
|
|
649
|
+
export const TIER_ALIASES = {
|
|
650
|
+
grok: 'grok4.6',
|
|
651
|
+
'grok 4.6': 'grok4.6',
|
|
652
|
+
'grok-4.6': 'grok4.6',
|
|
653
|
+
'grok4.6': 'grok4.6',
|
|
654
|
+
};
|
|
655
|
+
export function normalizeTier(s) {
|
|
656
|
+
const raw = String(s || '').trim().toLowerCase();
|
|
657
|
+
if (raw === 'auto') return 'auto';
|
|
658
|
+
if (TIER_NAMES.includes(raw)) return raw;
|
|
659
|
+
if (TIER_ALIASES[raw]) return TIER_ALIASES[raw];
|
|
660
|
+
const compact = raw.replace(/[\s_]/g, '');
|
|
661
|
+
if (TIER_NAMES.includes(compact)) return compact;
|
|
662
|
+
if (TIER_ALIASES[compact]) return TIER_ALIASES[compact];
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
let catalogCache = { at: 0, ids: null };
|
|
667
|
+
async function servedIds() {
|
|
668
|
+
// 5 minutes: long enough that a race does not re-fetch per model, short
|
|
669
|
+
// enough that a model coming back after an outage is picked up the same
|
|
670
|
+
// session.
|
|
671
|
+
if (catalogCache.ids && Date.now() - catalogCache.at < 300_000) return catalogCache.ids;
|
|
672
|
+
try {
|
|
673
|
+
const r = await fetch(`${PROXY}/models`);
|
|
674
|
+
const j = await r.json();
|
|
675
|
+
const ids = new Set((j?.data || []).map((m) => m.id).filter(Boolean));
|
|
676
|
+
if (ids.size) catalogCache = { at: Date.now(), ids };
|
|
677
|
+
} catch { /* proxy down — fall through to whatever we had, or null */ }
|
|
678
|
+
return catalogCache.ids;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* The models a tier resolves to right now, only ones actually served.
|
|
683
|
+
*
|
|
684
|
+
* `random` is what a race uses: pick n from the whole tier at random rather
|
|
685
|
+
* than always the top n. Two reasons it must be random and not top-n — a fixed
|
|
686
|
+
* trio is not a hedge (they can share an upstream having a bad minute, which is
|
|
687
|
+
* precisely the failure racing is meant to survive), and it silently reduces a
|
|
688
|
+
* ten-model tier to three models the user never chose.
|
|
689
|
+
*
|
|
690
|
+
* Falls back to the curated list unchecked if the catalog is unreachable — a
|
|
691
|
+
* stale-but-plausible id beats refusing to answer.
|
|
692
|
+
*/
|
|
693
|
+
export async function tierModels(tier, n = 1, random = false) {
|
|
694
|
+
const want = TIERS[tier] || TIERS.medium;
|
|
695
|
+
const ids = await servedIds();
|
|
696
|
+
const live = ids ? want.filter((m) => ids.has(m)) : want;
|
|
697
|
+
const pool = live.length ? live : want;
|
|
698
|
+
const take = Math.max(1, Math.min(n, pool.length));
|
|
699
|
+
if (!random) return pool.slice(0, take);
|
|
700
|
+
// Fisher-Yates on a copy: sampling without replacement, because racing a
|
|
701
|
+
// model against itself buys nothing and still bills twice.
|
|
702
|
+
const a = pool.slice();
|
|
703
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
704
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
705
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
706
|
+
}
|
|
707
|
+
return a.slice(0, take);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function readProxySession(proxy = completionsProxy()) {
|
|
711
|
+
try {
|
|
712
|
+
const origin = String(proxy || '').replace(/\/+$/, '').replace(/\/v1$/i, '');
|
|
713
|
+
const r = await fetch(`${origin}/v1/session`, { signal: AbortSignal.timeout(800) });
|
|
714
|
+
if (!r.ok) return null;
|
|
715
|
+
return await r.json();
|
|
716
|
+
} catch { return null; }
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function raceBudget(hooks) {
|
|
720
|
+
const injected = hooks.creditUsd != null || hooks.quoteUsd != null
|
|
721
|
+
|| hooks.spentUsd != null || hooks.directUsd != null || hooks.dollarX != null;
|
|
722
|
+
if (injected) {
|
|
723
|
+
return {
|
|
724
|
+
creditUsd: hooks.creditUsd,
|
|
725
|
+
quoteUsd: hooks.quoteUsd,
|
|
726
|
+
spentUsd: hooks.spentUsd,
|
|
727
|
+
directUsd: hooks.directUsd,
|
|
728
|
+
dollarX: hooks.dollarX,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
// Injected stream = unit-test N-parallel path. Do not poke :8402.
|
|
732
|
+
if (hooks.stream) return {};
|
|
733
|
+
const s = await readProxySession(hooks.proxy || completionsProxy());
|
|
734
|
+
return {
|
|
735
|
+
creditUsd: s?.creditUsd,
|
|
736
|
+
quoteUsd: s?.lastQuoteUsd ?? s?.quoteUsd,
|
|
737
|
+
spentUsd: s?.spentUsd,
|
|
738
|
+
directUsd: s?.directUsd,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function raceRacerId(obj, fallback) {
|
|
743
|
+
const r = obj?.race || obj?.racer || {};
|
|
744
|
+
if (r.model) return String(r.model);
|
|
745
|
+
if (r.i != null) return `racer-${r.i}`;
|
|
746
|
+
if (obj?.model) return String(obj.model);
|
|
747
|
+
const idx = obj?.choices?.[0]?.index;
|
|
748
|
+
if (idx != null && idx !== 0) return `racer-${idx}`;
|
|
749
|
+
return fallback || 'gateway';
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function raceEventOf(obj) {
|
|
753
|
+
const r = obj?.race || obj?.racer;
|
|
754
|
+
if (!r || typeof r !== 'object') return null;
|
|
755
|
+
const ev = String(r.event || r.status || '').toLowerCase();
|
|
756
|
+
return { id: raceRacerId(obj, 'gateway'), ev, text: r.text, error: r.error };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* One Fly (or race-capable mock) POST. Streams tokens. First-X-countable:
|
|
761
|
+
* empties / HTTP / pay / fetch-failed do not fill X.
|
|
762
|
+
*/
|
|
763
|
+
async function brainGatewayRace(messages, onDelta, contextId, models, need, maxTokens, onStatus, hooks) {
|
|
764
|
+
const n = models.length;
|
|
765
|
+
const want = Math.max(1, Math.min(Number(need) || 1, n));
|
|
766
|
+
const tier = hooks.tier || inferRaceTier(models, 'medium');
|
|
767
|
+
const classify = hooks.classify || classifyRaceAnswer;
|
|
768
|
+
const pairwise = hooks.pairwise || pairwiseTied;
|
|
769
|
+
const minScore = hooks.minScore != null ? Number(hooks.minScore) : RACE_MIN_SCORE;
|
|
770
|
+
const feed = createRaceFeed(onDelta, onStatus, want, hooks.onRace);
|
|
771
|
+
feed.start(models, hooks.raceRecut ? { recut: hooks.raceRecut } : undefined);
|
|
772
|
+
|
|
773
|
+
const arrivals = [];
|
|
774
|
+
const done = [];
|
|
775
|
+
const noteRace = (arr) => {
|
|
776
|
+
try {
|
|
777
|
+
hooks.onArrivals?.(arr);
|
|
778
|
+
const line = JSON.stringify({
|
|
779
|
+
at: new Date().toISOString(),
|
|
780
|
+
fail: summarizeRaceFailures(arr),
|
|
781
|
+
n: arr.length,
|
|
782
|
+
kinds: arr.map((a) => raceFailKind(a)),
|
|
783
|
+
door: 'gateway',
|
|
784
|
+
});
|
|
785
|
+
appendFileSync(`${homedir()}/.openzoo/grokui-race.log`, line + '\n');
|
|
786
|
+
} catch { /* diagnostic only */ }
|
|
787
|
+
};
|
|
788
|
+
const ship = (cand) => {
|
|
789
|
+
const out = cand && String(cand.text || '').trim() ? cand : raceLastShip(arrivals);
|
|
790
|
+
feed.settle(out);
|
|
791
|
+
noteRace(arrivals);
|
|
792
|
+
return out.text;
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
const vision = hasImages(messages);
|
|
796
|
+
const model = vision ? VISION_MODEL : (models[0] || MODEL);
|
|
797
|
+
const msgs = vision ? messages : stripImages(messages);
|
|
798
|
+
const budget = maxTokens || MAX_TOKENS;
|
|
799
|
+
const body = {
|
|
800
|
+
model,
|
|
801
|
+
max_tokens: budget,
|
|
802
|
+
messages: withModelId(msgs, model),
|
|
803
|
+
plugins: [{ id: 'web' }],
|
|
804
|
+
stream: true,
|
|
805
|
+
race: n,
|
|
806
|
+
race_need: want,
|
|
807
|
+
tier,
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
let lastFail = { model: 'gateway', text: '', error: 'empty body' };
|
|
811
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
812
|
+
try {
|
|
813
|
+
const r = await postChat(body, contextId, 0, onStatus, hooks.signal);
|
|
814
|
+
if (!r.ok || !r.body) {
|
|
815
|
+
const j = await r.json().catch(() => ({}));
|
|
816
|
+
const msg = j?.choices?.[0]?.message;
|
|
817
|
+
const content = msg?.content;
|
|
818
|
+
const proxied = sanitizeProxiedError(j?.error?.message);
|
|
819
|
+
const text = wrapThink(messageReasoning(msg), content || (r.ok ? '' : (proxied ? `(request failed — HTTP ${r.status}: ${proxied})` : await httpErrorNote(r.status))));
|
|
820
|
+
lastFail = { model: 'gateway', text: text || '', error: r.ok ? undefined : `HTTP ${r.status}` };
|
|
821
|
+
if (isRaceCountable(lastFail)) {
|
|
822
|
+
arrivals.push(lastFail);
|
|
823
|
+
done.push(lastFail);
|
|
824
|
+
feed.onBack(lastFail.model);
|
|
825
|
+
if (text) emitReply(onDelta, text);
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
if (!shouldRetryRaceArrival(lastFail) || attempt === 1) break;
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const parsed = await readGatewayRaceStream(r, feed, hooks.signal);
|
|
833
|
+
for (const a of parsed.arrivals) {
|
|
834
|
+
arrivals.push(a);
|
|
835
|
+
if (isRaceCountable(a)) {
|
|
836
|
+
done.push(a);
|
|
837
|
+
feed.onBack(a.model);
|
|
838
|
+
} else {
|
|
839
|
+
feed.onFail(a.model, a);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
lastFail = parsed.arrivals[parsed.arrivals.length - 1] || lastFail;
|
|
843
|
+
if (done.length >= want || !shouldRetryRaceArrival(lastFail) || attempt === 1) break;
|
|
844
|
+
} catch (e) {
|
|
845
|
+
lastFail = { model: 'gateway', text: '', error: e?.message || 'error' };
|
|
846
|
+
arrivals.push(lastFail);
|
|
847
|
+
if (!shouldRetryRaceArrival(lastFail) || attempt === 1) break;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const cands = done.slice(0, want);
|
|
852
|
+
if (!cands.length) return ship(raceLastShip(arrivals));
|
|
853
|
+
if (cands.length === 1) return ship(cands[0]);
|
|
854
|
+
|
|
855
|
+
onStatus?.('judging…');
|
|
856
|
+
feed.judge();
|
|
857
|
+
const scored = await Promise.all(cands.map(async (c) => {
|
|
858
|
+
let score = 0;
|
|
859
|
+
try { score = Number(await classify(messages, c)) || 0; } catch { score = 0; }
|
|
860
|
+
return { ...c, score };
|
|
861
|
+
}));
|
|
862
|
+
let picked = pickRaceWinner(scored, minScore);
|
|
863
|
+
if (picked.reason === 'tie' && picked.tied.length > 1) {
|
|
864
|
+
let broken = null;
|
|
865
|
+
try { broken = await pairwise(messages, picked.tied); } catch { /* last of the tie */ }
|
|
866
|
+
const usable = broken && String(broken.text || '').trim();
|
|
867
|
+
picked = { winner: usable ? broken : picked.tied[picked.tied.length - 1], reason: 'tiebreak', tied: picked.tied };
|
|
868
|
+
}
|
|
869
|
+
return ship(picked.winner || scored[scored.length - 1] || raceLastShip(arrivals));
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function readGatewayRaceStream(r, feed, signal) {
|
|
873
|
+
const reader = r.body.getReader();
|
|
874
|
+
const decoder = new TextDecoder();
|
|
875
|
+
let buf = '';
|
|
876
|
+
const texts = new Map();
|
|
877
|
+
const thinks = new Map();
|
|
878
|
+
const finished = new Map();
|
|
879
|
+
const live = { id: null };
|
|
880
|
+
const stopWait = startModelWait(() => {});
|
|
881
|
+
|
|
882
|
+
const pushText = (id, chunk) => {
|
|
883
|
+
if (chunk == null || chunk === '') return;
|
|
884
|
+
const key = id || live.id || 'gateway';
|
|
885
|
+
live.id = key;
|
|
886
|
+
texts.set(key, (texts.get(key) || '') + chunk);
|
|
887
|
+
feed.onToken(key, chunk);
|
|
888
|
+
};
|
|
889
|
+
const pushThink = (id, chunk) => {
|
|
890
|
+
if (chunk == null || chunk === '') return;
|
|
891
|
+
const key = id || live.id || 'gateway';
|
|
892
|
+
live.id = key;
|
|
893
|
+
thinks.set(key, (thinks.get(key) || '') + chunk);
|
|
894
|
+
};
|
|
895
|
+
const finishOne = (id, extra = {}) => {
|
|
896
|
+
const key = id || live.id || 'gateway';
|
|
897
|
+
if (finished.has(key)) return;
|
|
898
|
+
const text = extra.text != null ? String(extra.text) : (texts.get(key) || '');
|
|
899
|
+
const row = {
|
|
900
|
+
model: extra.model || key,
|
|
901
|
+
text: wrapThink(thinks.get(key) || '', text),
|
|
902
|
+
error: extra.error,
|
|
903
|
+
};
|
|
904
|
+
finished.set(key, row);
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
try {
|
|
908
|
+
for (;;) {
|
|
909
|
+
if (signal?.aborted) break;
|
|
910
|
+
let chunk;
|
|
911
|
+
try {
|
|
912
|
+
chunk = await readWithIdleTimeout(reader, STREAM_IDLE_MS);
|
|
913
|
+
} catch (e) {
|
|
914
|
+
if (e?.code !== 'STREAM_IDLE') throw e;
|
|
915
|
+
try { await reader.cancel(); } catch { /* already */ }
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
const { value, done } = chunk;
|
|
919
|
+
if (done) break;
|
|
920
|
+
buf += decoder.decode(value, { stream: true });
|
|
921
|
+
const lines = buf.split('\n');
|
|
922
|
+
buf = lines.pop();
|
|
923
|
+
for (const line of lines) {
|
|
924
|
+
const s = line.trim();
|
|
925
|
+
if (s.startsWith(': race ')) {
|
|
926
|
+
try {
|
|
927
|
+
const ev = JSON.parse(s.slice(7));
|
|
928
|
+
const id = raceRacerId(ev, 'gateway');
|
|
929
|
+
if (ev.text && ev.event !== 'fail') pushText(id, ev.text);
|
|
930
|
+
if (ev.event === 'token' && ev.delta) pushText(id, ev.delta);
|
|
931
|
+
if (ev.event === 'back' || ev.event === 'done' || ev.finish) {
|
|
932
|
+
finishOne(id, { text: ev.text, model: ev.model });
|
|
933
|
+
}
|
|
934
|
+
if (ev.event === 'fail' || ev.error) {
|
|
935
|
+
finishOne(id, { text: ev.text || '', error: ev.error || 'empty body', model: ev.model });
|
|
936
|
+
}
|
|
937
|
+
} catch { /* keep-alive */ }
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
if (s.startsWith(': x402 ')) continue; // metered by the sidecar; never log
|
|
941
|
+
if (!s.startsWith('data:')) continue;
|
|
942
|
+
const payload = s.slice(5).trim();
|
|
943
|
+
if (payload === '[DONE]') continue;
|
|
944
|
+
try {
|
|
945
|
+
const obj = JSON.parse(payload);
|
|
946
|
+
const ev = raceEventOf(obj);
|
|
947
|
+
const c = obj?.choices?.[0];
|
|
948
|
+
const d = c?.delta;
|
|
949
|
+
const id = raceRacerId(obj, live.id || 'gateway');
|
|
950
|
+
if (d?.content) pushText(id, d.content);
|
|
951
|
+
// Reasoning is not a racer preview. Fold it onto the arrival so
|
|
952
|
+
// the winner's canvas row can show a thinking chip — never dump
|
|
953
|
+
// CoT into the spectator grid.
|
|
954
|
+
const think = messageReasoning(c?.message, d);
|
|
955
|
+
if (think) pushThink(id, think);
|
|
956
|
+
if (c?.finish_reason) finishOne(id, { model: obj.model });
|
|
957
|
+
if (ev?.ev === 'back' || ev?.ev === 'done') finishOne(ev.id, { text: ev.text, model: ev.id });
|
|
958
|
+
if (ev?.ev === 'fail' || ev?.error) finishOne(ev.id, { text: ev.text || '', error: ev.error || 'empty body' });
|
|
959
|
+
const msg = c?.message?.content || obj?.choices?.[0]?.message?.content;
|
|
960
|
+
if (msg && (c?.finish_reason || obj.object === 'chat.completion')) {
|
|
961
|
+
pushText(id, texts.has(id) ? '' : msg);
|
|
962
|
+
if (!texts.get(id)) texts.set(id, String(msg));
|
|
963
|
+
finishOne(id, { text: texts.get(id) || msg, model: obj.model || id });
|
|
964
|
+
}
|
|
965
|
+
} catch { /* partial JSON */ }
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
} finally {
|
|
969
|
+
stopWait();
|
|
970
|
+
try { await reader.cancel(); } catch { /* closed */ }
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (!finished.size && texts.size) {
|
|
974
|
+
for (const [id, text] of texts) finishOne(id, { text });
|
|
975
|
+
}
|
|
976
|
+
const arrivals = [...finished.values()];
|
|
977
|
+
if (!arrivals.length) arrivals.push({ model: 'gateway', text: '', error: 'empty body' });
|
|
978
|
+
return { arrivals };
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Launch N models at once, judge the FIRST K that come back.
|
|
983
|
+
*
|
|
984
|
+
* "/race 2 3" — start three, and the moment two of them have returned a real
|
|
985
|
+
* answer, judge those two and ship the winner. The third is abandoned mid-flight.
|
|
986
|
+
*
|
|
987
|
+
* This is the useful shape, and it is neither of the obvious two:
|
|
988
|
+
* - first-past-the-post (K=1) optimises latency only, and on a hard question
|
|
989
|
+
* it rewards whichever model thought LEAST.
|
|
990
|
+
* - wait-for-all-then-judge (K=N) buys quality with the slowest entrant's
|
|
991
|
+
* latency, and one wedged provider stalls the whole turn.
|
|
992
|
+
* Taking the first K bounds the wait at the Kth-fastest while still giving the
|
|
993
|
+
* judge something to compare. The straggler is exactly the entrant you were
|
|
994
|
+
* least likely to want anyway.
|
|
995
|
+
*
|
|
996
|
+
* Reliability comes free with it: empty completions and provider 5xx are
|
|
997
|
+
* per-model and uncorrelated, which is why "the model returned nothing 4 times"
|
|
998
|
+
* was never fixable by a fourth try at the same model. An empty reply does NOT
|
|
999
|
+
* count toward K — otherwise the fastest model to FAIL would decide the race,
|
|
1000
|
+
* the exact bug this exists to fix.
|
|
1001
|
+
*
|
|
1002
|
+
* Live tokens: the fastest still-alive entrant is forwarded into onDelta as
|
|
1003
|
+
* they arrive (not swallowed). When a winner is picked, if it is that live
|
|
1004
|
+
* stream the bubble keeps going; if it is someone else the bubble is replaced
|
|
1005
|
+
* once (`onDelta(text, { replace: true })`), not left on mute dots.
|
|
1006
|
+
*
|
|
1007
|
+
* After the first X land, a cheap classify call scores each of those X
|
|
1008
|
+
* (correctness, completeness, actually did the asked thing — a RUN:/DONE:
|
|
1009
|
+
* directive is success, not a flaw). Highest score that clears the bar wins;
|
|
1010
|
+
* a tie is pairwise-broken. If nobody clears, the last of the X is shipped
|
|
1011
|
+
* anyway. If X never fills (every entrant empty/5xx/fetch-failed), ship a
|
|
1012
|
+
* race-level error — never a single model's `(name failed: fetch failed)` as
|
|
1013
|
+
* if it won. Never blank, never hang.
|
|
1014
|
+
*
|
|
1015
|
+
* Every entrant is paid for, including the abandoned one — this trades money
|
|
1016
|
+
* for latency and quality, which is why it is opt-in and capped.
|
|
1017
|
+
* grokui does not grant unused or failed racers back to the user.
|
|
1018
|
+
*
|
|
1019
|
+
* `hooks` is for tests: `{ stream, classify, pairwise, minScore }`.
|
|
1020
|
+
*/
|
|
1021
|
+
export async function brainRace(messages, onDelta, contextId, models, need = 1, maxTokens, onStatus, hooks = {}) {
|
|
1022
|
+
const stream = hooks.stream || brainStream;
|
|
1023
|
+
const classify = hooks.classify || classifyRaceAnswer;
|
|
1024
|
+
const pairwise = hooks.pairwise || pairwiseTied;
|
|
1025
|
+
const minScore = hooks.minScore != null ? Number(hooks.minScore) : RACE_MIN_SCORE;
|
|
1026
|
+
let list = (models || []).filter(Boolean).slice(0, RACE_MAX);
|
|
1027
|
+
const budget = await raceBudget(hooks);
|
|
1028
|
+
let tier = hooks.tier || inferRaceTier(list, 'medium');
|
|
1029
|
+
let wantNeed = Math.max(1, Math.min(Number(need) || 1, list.length || 1));
|
|
1030
|
+
const dollarX = sessionDollarX(budget);
|
|
1031
|
+
const hud = recutRaceByHud({
|
|
1032
|
+
y: list.length, need: wantNeed, dollarX, tier,
|
|
1033
|
+
});
|
|
1034
|
+
let raceRecut = '';
|
|
1035
|
+
if (hud.recut) {
|
|
1036
|
+
raceRecut = 'savings';
|
|
1037
|
+
if (hud.tier !== tier && !hooks.stream) {
|
|
1038
|
+
try {
|
|
1039
|
+
const next = await tierModels(hud.tier, hud.y, true);
|
|
1040
|
+
if (next.length) { list = next; tier = hud.tier; }
|
|
1041
|
+
else list = list.slice(0, hud.y);
|
|
1042
|
+
} catch { list = list.slice(0, hud.y); }
|
|
1043
|
+
} else {
|
|
1044
|
+
list = list.slice(0, hud.y);
|
|
1045
|
+
tier = hud.tier;
|
|
1046
|
+
}
|
|
1047
|
+
wantNeed = Math.min(hud.need, list.length);
|
|
1048
|
+
onStatus?.(hud.y < 2
|
|
1049
|
+
? 'race recut to 1 — savings'
|
|
1050
|
+
: `race recut to ${hud.y} — savings`);
|
|
1051
|
+
}
|
|
1052
|
+
hooks = { ...hooks, tier, raceRecut };
|
|
1053
|
+
const capped = capRaceByCredit(list.length, budget);
|
|
1054
|
+
if (capped.n < 1) {
|
|
1055
|
+
onStatus?.('race refused — no credit');
|
|
1056
|
+
return RACE_NO_CREDIT;
|
|
1057
|
+
}
|
|
1058
|
+
if (capped.n < list.length) {
|
|
1059
|
+
list = list.slice(0, capped.n);
|
|
1060
|
+
onStatus?.(capped.n < 2 ? 'race shrunk to 1 — credit' : `race shrunk to ${capped.n} — credit`);
|
|
1061
|
+
}
|
|
1062
|
+
if (list.length < 2) return stream(messages, onDelta, contextId, list[0], maxTokens, 0, 0, onStatus);
|
|
1063
|
+
const want = Math.max(1, Math.min(wantNeed, list.length));
|
|
1064
|
+
|
|
1065
|
+
// One Fly settle when the completions door honors `race:`. Custom stream
|
|
1066
|
+
// hooks (unit tests of the N-parallel judge) keep the old path. Old
|
|
1067
|
+
// sidecar / local mock that does not accept race: also stays N-parallel.
|
|
1068
|
+
const customStream = Boolean(hooks.stream);
|
|
1069
|
+
let gateway = hooks.gatewayRace;
|
|
1070
|
+
if (gateway == null && !customStream) {
|
|
1071
|
+
try { gateway = await probeGatewayRace(hooks.proxy || completionsProxy(), hooks.fetch || fetch); }
|
|
1072
|
+
catch { gateway = false; }
|
|
1073
|
+
}
|
|
1074
|
+
if (gateway && !customStream) {
|
|
1075
|
+
return brainGatewayRace(messages, onDelta, contextId, list, want, maxTokens, onStatus, hooks);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const feed = createRaceFeed(onDelta, onStatus, want, hooks.onRace);
|
|
1079
|
+
feed.start(list, raceRecut ? { recut: raceRecut } : undefined);
|
|
1080
|
+
|
|
1081
|
+
const done = [];
|
|
1082
|
+
const arrivals = [];
|
|
1083
|
+
let finished = 0;
|
|
1084
|
+
let release;
|
|
1085
|
+
const enough = new Promise((r) => { release = r; });
|
|
1086
|
+
const raceAbort = new AbortController();
|
|
1087
|
+
if (hooks.signal) {
|
|
1088
|
+
if (hooks.signal.aborted) raceAbort.abort(hooks.signal.reason);
|
|
1089
|
+
else hooks.signal.addEventListener('abort', () => raceAbort.abort(), { once: true });
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const noteRace = (arr) => {
|
|
1093
|
+
try {
|
|
1094
|
+
hooks.onArrivals?.(arr);
|
|
1095
|
+
const line = JSON.stringify({
|
|
1096
|
+
at: new Date().toISOString(),
|
|
1097
|
+
fail: summarizeRaceFailures(arr),
|
|
1098
|
+
n: arr.length,
|
|
1099
|
+
kinds: arr.map((a) => raceFailKind(a)),
|
|
1100
|
+
});
|
|
1101
|
+
appendFileSync(`${homedir()}/.openzoo/grokui-race.log`, line + '\n');
|
|
1102
|
+
} catch { /* diagnostic only */ }
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
const ship = (cand) => {
|
|
1106
|
+
const out = cand && String(cand.text || '').trim() ? cand : raceLastShip(arrivals);
|
|
1107
|
+
feed.settle(out);
|
|
1108
|
+
noteRace(arrivals);
|
|
1109
|
+
try { raceAbort.abort(); } catch { /* already */ }
|
|
1110
|
+
return out.text;
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
// Do not pass onStatus into each entrant — their "waiting on model…" would
|
|
1114
|
+
// clobber the race line. Race owns the status until a winner ships.
|
|
1115
|
+
// fetch-failed / empty / 5xx are retried ONCE on the same slot — tetris was
|
|
1116
|
+
// shipping every-model-failed after a single ~22s fetch failed per racer.
|
|
1117
|
+
const runOne = async (m) => {
|
|
1118
|
+
let last = { model: m, text: '', error: 'empty body' };
|
|
1119
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1120
|
+
if (raceAbort.signal.aborted && attempt > 0) break;
|
|
1121
|
+
try {
|
|
1122
|
+
const text = await stream(messages, (chunk, meta) => {
|
|
1123
|
+
if (meta?.think) return;
|
|
1124
|
+
feed.onToken(m, chunk);
|
|
1125
|
+
}, contextId, m, maxTokens, 0, 0, undefined, raceAbort.signal);
|
|
1126
|
+
last = { model: m, text: text == null ? '' : String(text) };
|
|
1127
|
+
if (isRaceCountable(last)) {
|
|
1128
|
+
arrivals.push(last);
|
|
1129
|
+
done.push(last);
|
|
1130
|
+
feed.onBack(m);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
} catch (e) {
|
|
1134
|
+
last = { model: m, text: '', error: e?.message || 'error' };
|
|
1135
|
+
}
|
|
1136
|
+
if (!shouldRetryRaceArrival(last) || attempt === 1 || raceAbort.signal.aborted) break;
|
|
1137
|
+
}
|
|
1138
|
+
arrivals.push(last);
|
|
1139
|
+
feed.onFail(m, last);
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
const attempts = list.map((m) => runOne(m).finally(() => {
|
|
1143
|
+
finished += 1;
|
|
1144
|
+
// Either we have what we asked for, or everyone is done and no more is
|
|
1145
|
+
// coming — without the second condition a race where two of three fail
|
|
1146
|
+
// would hang forever waiting for a K that can never arrive.
|
|
1147
|
+
if (done.length >= want || finished === list.length) release();
|
|
1148
|
+
}));
|
|
1149
|
+
// Losers keep running until ship() aborts them; swallow rejections so one
|
|
1150
|
+
// cannot take the process down after the winner has already been returned.
|
|
1151
|
+
for (const p of attempts) p.catch(() => {});
|
|
1152
|
+
|
|
1153
|
+
await enough;
|
|
1154
|
+
// Completion order, so this really is the first X back — not the first X
|
|
1155
|
+
// launched. A slow 3rd never enters this set. Empty/5xx/fetch-failed stay
|
|
1156
|
+
// in arrivals only so an all-fail race can ship one race-level error.
|
|
1157
|
+
const cands = done.slice(0, want);
|
|
1158
|
+
if (!cands.length) return ship(raceLastShip(arrivals));
|
|
1159
|
+
// One real answer — nothing to compare. Ship it; do not spend a classify
|
|
1160
|
+
// call to rubber-stamp the only candidate.
|
|
1161
|
+
if (cands.length === 1) return ship(cands[0]);
|
|
1162
|
+
|
|
1163
|
+
onStatus?.('judging…');
|
|
1164
|
+
feed.judge();
|
|
1165
|
+
const scored = await Promise.all(cands.map(async (c) => {
|
|
1166
|
+
let score = 0;
|
|
1167
|
+
try { score = Number(await classify(messages, c)) || 0; } catch { score = 0; }
|
|
1168
|
+
return { ...c, score };
|
|
1169
|
+
}));
|
|
1170
|
+
|
|
1171
|
+
let picked = pickRaceWinner(scored, minScore);
|
|
1172
|
+
if (picked.reason === 'tie' && picked.tied.length > 1) {
|
|
1173
|
+
let broken = null;
|
|
1174
|
+
try { broken = await pairwise(messages, picked.tied); } catch { /* last of the tie */ }
|
|
1175
|
+
const usable = broken && String(broken.text || '').trim();
|
|
1176
|
+
// Malformed verdict / all equally bad → last finished of the tie, not empty.
|
|
1177
|
+
picked = { winner: usable ? broken : picked.tied[picked.tied.length - 1], reason: 'tiebreak', tied: picked.tied };
|
|
1178
|
+
}
|
|
1179
|
+
return ship(picked.winner || scored[scored.length - 1] || raceLastShip(arrivals));
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function raceQuestion(messages) {
|
|
1183
|
+
const asked = [...messages].reverse().find((m) => m.role === 'user')?.content;
|
|
1184
|
+
return typeof asked === 'string' ? asked : '(see candidates)';
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Cheap structured score of ONE finished answer vs the question.
|
|
1189
|
+
* This is grokui's own classify call — not OpenRouter's async log-tag
|
|
1190
|
+
* Classifiers beta, which does not pick winners.
|
|
1191
|
+
*/
|
|
1192
|
+
async function classifyRaceAnswer(messages, cand) {
|
|
1193
|
+
const prompt = 'Score this answer to one question from 0 to 10.\n\n'
|
|
1194
|
+
+ 'QUESTION:\n' + String(raceQuestion(messages)).slice(0, 4000) + '\n\n'
|
|
1195
|
+
+ 'ANSWER:\n' + stripThinkTags(String(cand?.text || '')).slice(0, 6000) + '\n\n'
|
|
1196
|
+
+ 'Judge on: correctness first, then completeness, then whether it actually did what was asked '
|
|
1197
|
+
+ '(a directive like RUN: or DONE: on one line is the correct format here, not a flaw). '
|
|
1198
|
+
+ 'Ignore length and confidence of tone.\n'
|
|
1199
|
+
+ 'Reply with exactly: SCORE <n>';
|
|
1200
|
+
const verdict = await brainStream(
|
|
1201
|
+
[{ role: 'user', content: prompt }], () => {}, undefined, JUDGE_MODEL, 24,
|
|
1202
|
+
);
|
|
1203
|
+
return parseClassifyScore(verdict);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Pairwise break among same-score passers. Blind A/B/C so the model names
|
|
1208
|
+
* cannot leak the answer. Last of the tied set if the call dies.
|
|
1209
|
+
*/
|
|
1210
|
+
async function pairwiseTied(messages, tied) {
|
|
1211
|
+
const letters = tied.map((_, i) => String.fromCharCode(65 + i));
|
|
1212
|
+
const prompt = 'You are judging answers to one question. Pick the single best one.\n\n'
|
|
1213
|
+
+ 'QUESTION:\n' + String(raceQuestion(messages)).slice(0, 4000) + '\n\n'
|
|
1214
|
+
+ tied.map((c, i) => 'ANSWER ' + letters[i] + ':\n' + stripThinkTags(String(c.text || '')).slice(0, 6000)).join('\n\n')
|
|
1215
|
+
+ '\n\nJudge on: correctness first, then completeness, then whether it actually did what was asked '
|
|
1216
|
+
+ '(a directive like RUN: or DONE: on one line is the correct format here, not a flaw). '
|
|
1217
|
+
+ 'Ignore length and confidence of tone.\n'
|
|
1218
|
+
+ 'Reply with ONE letter and nothing else: ' + letters.join(' or ') + '.';
|
|
1219
|
+
try {
|
|
1220
|
+
const verdict = await brainStream([{ role: 'user', content: prompt }], () => {}, undefined, JUDGE_MODEL, 8);
|
|
1221
|
+
const hit = String(verdict).toUpperCase().split('').find((ch) => {
|
|
1222
|
+
const n = ch.charCodeAt(0) - 65;
|
|
1223
|
+
return n >= 0 && n < tied.length;
|
|
1224
|
+
});
|
|
1225
|
+
if (hit) return tied[hit.charCodeAt(0) - 65];
|
|
1226
|
+
} catch { /* fall through */ }
|
|
1227
|
+
return tied[tied.length - 1];
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const RACE_MAX = Number(process.env.OZ_RACE_MAX || 4);
|
|
1231
|
+
|
|
1232
|
+
// brainBest is GONE — brainRace(models, need) subsumes it. "wait for all N
|
|
1233
|
+
// then judge" is exactly need === N, and keeping a second judged-race entry
|
|
1234
|
+
// point meant two call sites that could disagree about what a race is.
|
|
1235
|
+
|
|
1236
|
+
// Cheapest thing that can reliably output one letter. Overridable because the
|
|
1237
|
+
// catalog moves; if it is delisted the try/catch above falls back cleanly.
|
|
1238
|
+
const JUDGE_MODEL = process.env.OZ_JUDGE_MODEL || 'deepseek/deepseek-v4-flash';
|
|
1239
|
+
|
|
1240
|
+
const SYSTEM = `You are the brain of a Grok-Bot-style coding/ops agent. The polished chat UI
|
|
1241
|
+
the user sees is Grok Bot (Anysphere's app); its "sandbox" has been pointed at THIS box, and
|
|
1242
|
+
your reasoning is served by openzoo (pay-per-call access to ~435 models over x402 — no API key,
|
|
1243
|
+
no account, each call paid from a burner wallet). You are the substitute agent server.
|
|
1244
|
+
|
|
1245
|
+
WHERE YOUR COMMANDS RUN: each shell command you emit is pushed to a local-exec daemon running on
|
|
1246
|
+
the USER'S OWN Mac (a supervised "sand" sandbox rooted at their home dir). Every command is
|
|
1247
|
+
APPROVAL-GATED — the user sees and approves it before it runs. So: act on the user's real machine,
|
|
1248
|
+
be careful, never destructive, prefer read-before-write, and explain nothing to the shell.
|
|
1249
|
+
|
|
1250
|
+
PROTOCOL — reply with EXACTLY one line, no prose, no code fences:
|
|
1251
|
+
RUN: <a single shell command> to execute a step
|
|
1252
|
+
DONE: <a short natural-language answer> when the task is complete (this text is shown in the UI)
|
|
1253
|
+
SPAWN: <name> | <task> to delegate an independent subtask to a fresh subagent
|
|
1254
|
+
SEND: <name> | <message> to message a subagent you (or the user) already spawned
|
|
1255
|
+
PING: <name> to check whether a subagent is still working or has finished
|
|
1256
|
+
You are given each command's output before your next line. SPAWN does not block you — the subagent
|
|
1257
|
+
runs independently and you continue your own reasoning immediately; check on it later with PING or
|
|
1258
|
+
wait for it to SEND you its result. All subagents share the ONE real exec channel (the user's Mac),
|
|
1259
|
+
so their shell commands queue safely behind each other automatically — you never need to worry about
|
|
1260
|
+
that part. If the task needs no shell (a question, an explanation), answer it directly with a single
|
|
1261
|
+
DONE: line. Keep DONE summaries human and useful — they are the assistant's reply to the user, not a
|
|
1262
|
+
log.`;
|
|
1263
|
+
|
|
1264
|
+
// ---------------------------------------------------------------- subagents --
|
|
1265
|
+
|
|
1266
|
+
// name -> { mailbox: string[], done: boolean, result: string|null }. In-process
|
|
1267
|
+
// registry — every agent (root task + SPAWNed subagents) shares the ONE real
|
|
1268
|
+
// exec channel (queueDrive), but reason (call the brain) independently and
|
|
1269
|
+
// concurrently, so SPAWN genuinely doesn't block the spawning agent.
|
|
1270
|
+
const agents = new Map();
|
|
1271
|
+
|
|
1272
|
+
function ensureAgent(name) {
|
|
1273
|
+
if (!agents.has(name)) agents.set(name, { mailbox: [], done: false, result: null });
|
|
1274
|
+
return agents.get(name);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/** The agent loop for one task, executed through the connected daemon. Each
|
|
1278
|
+
* RUN is pushed as an exec frame; the daemon's result frames (captured in
|
|
1279
|
+
* `pendingResults`) feed the next turn. */
|
|
1280
|
+
async function runTask(task, stream, ctx) {
|
|
1281
|
+
const messages = [{ role: 'system', content: SYSTEM }, { role: 'user', content: task }];
|
|
1282
|
+
let answer = '';
|
|
1283
|
+
for (let step = 0; step < MAX_STEPS; step++) {
|
|
1284
|
+
const line = (await brain(messages)).trim();
|
|
1285
|
+
record({ ev: 'brain', step, line });
|
|
1286
|
+
const done = /^DONE:\s*([\s\S]+)/.exec(line);
|
|
1287
|
+
if (done) { answer = done[1].trim(); record({ ev: 'task-done', step, answer }); return answer; }
|
|
1288
|
+
const run = /^RUN:\s*([\s\S]+)/.exec(line);
|
|
1289
|
+
// no RUN and no DONE — treat the whole line as a direct answer to the user
|
|
1290
|
+
if (!run) { answer = line.replace(/^DONE:\s*/i, ''); record({ ev: 'task-freeform', step, answer }); return answer; }
|
|
1291
|
+
const cmd = run[1].trim();
|
|
1292
|
+
const frame = execFrame(cmd);
|
|
1293
|
+
ctx.awaiting = frame.requestId;
|
|
1294
|
+
ctx.output = '';
|
|
1295
|
+
ctx.done = false;
|
|
1296
|
+
sseSend(stream, frame);
|
|
1297
|
+
record({ ev: 'exec-push', step, requestId: frame.requestId, cmd });
|
|
1298
|
+
// wait for the daemon's result frames for this requestId (or timeout)
|
|
1299
|
+
const out = await waitResult(ctx, 60_000);
|
|
1300
|
+
messages.push({ role: 'assistant', content: line });
|
|
1301
|
+
messages.push({ role: 'user', content: `output:\n${out || '(none)'}` });
|
|
1302
|
+
}
|
|
1303
|
+
return answer || '(reached step limit without finishing)';
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function waitResult(ctx, timeoutMs) {
|
|
1307
|
+
return new Promise((resolve) => {
|
|
1308
|
+
const started = Date.now();
|
|
1309
|
+
const iv = setInterval(() => {
|
|
1310
|
+
if (ctx.done || Date.now() - started > timeoutMs) {
|
|
1311
|
+
clearInterval(iv); ctx.done = false; resolve(ctx.output || '');
|
|
1312
|
+
}
|
|
1313
|
+
}, 250);
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// ----------------------------------------------------------------- servers --
|
|
1318
|
+
|
|
1319
|
+
// per-connection context so a result frame can be matched to its exec
|
|
1320
|
+
const ctxByToken = new Map();
|
|
1321
|
+
// the single live daemon (Grok Bot's local-exec on the user's Mac). Set when it
|
|
1322
|
+
// opens its SSE; the /drive endpoint pushes exec frames down THIS stream.
|
|
1323
|
+
let activeDaemon = null;
|
|
1324
|
+
// one exec loop at a time against the shared daemon ctx — two concurrent
|
|
1325
|
+
// runTask calls would interleave result frames onto the same ctx.output.
|
|
1326
|
+
let driveQueue = Promise.resolve();
|
|
1327
|
+
function queueDrive(fn) {
|
|
1328
|
+
const next = driveQueue.then(fn, fn);
|
|
1329
|
+
driveQueue = next.catch(() => {});
|
|
1330
|
+
return next;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
for (const port of PORTS) {
|
|
1334
|
+
const server = http.createServer((req, res) => {
|
|
1335
|
+
// The daemon's receive-channel: hold the SSE open. The TASK does NOT come
|
|
1336
|
+
// from here or from any env var — it arrives via POST /drive, forwarded by
|
|
1337
|
+
// the backend from whatever the user typed into the Grok Bot UI.
|
|
1338
|
+
if (req.method === 'GET' && req.url && req.url.startsWith('/local-exec/requests')) {
|
|
1339
|
+
const token = (req.headers['x-anyrun-network-token'] || 'default');
|
|
1340
|
+
const ctx = { awaiting: null, output: '', done: false };
|
|
1341
|
+
ctxByToken.set(token, ctx);
|
|
1342
|
+
activeDaemon = { stream: res, ctx };
|
|
1343
|
+
record({ port, method: 'GET', path: req.url, note: 'SSE opened — daemon connected', bodyBytes: 0 });
|
|
1344
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
1345
|
+
res.write(':ok\n\n');
|
|
1346
|
+
// announce ourselves; then wait for the UI to drive us
|
|
1347
|
+
sseSend(res, { kind: 'welcome', providerId: 'openzoo' });
|
|
1348
|
+
req.on('close', () => {
|
|
1349
|
+
ctxByToken.delete(token);
|
|
1350
|
+
if (activeDaemon && activeDaemon.stream === res) activeDaemon = null;
|
|
1351
|
+
});
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
const chunks = [];
|
|
1356
|
+
req.on('data', (d) => chunks.push(d));
|
|
1357
|
+
req.on('end', async () => {
|
|
1358
|
+
const body = Buffer.concat(chunks);
|
|
1359
|
+
let parsed, kinds;
|
|
1360
|
+
try { parsed = JSON.parse(body.toString('utf8')); kinds = parsed.frames?.map((f) => f.kind).join(','); } catch { /* not json */ }
|
|
1361
|
+
record({ port, method: req.method, path: req.url, bodyBytes: body.length, frames: kinds,
|
|
1362
|
+
bodyUtf8: body.slice(0, 1024).toString('utf8').replace(/[^\x20-\x7e]/g, '.') });
|
|
1363
|
+
|
|
1364
|
+
// THE TRIGGER: the backend forwards the user's Grok Bot prompt here. Drive
|
|
1365
|
+
// the agent loop against the connected daemon and return the answer text.
|
|
1366
|
+
if (req.method === 'POST' && req.url && req.url.startsWith('/drive')) {
|
|
1367
|
+
let task = '';
|
|
1368
|
+
try { task = (JSON.parse(body.toString('utf8')).task || '').toString(); }
|
|
1369
|
+
catch { task = body.toString('utf8'); }
|
|
1370
|
+
record({ ev: 'drive', task: task.slice(0, 160) });
|
|
1371
|
+
if (!activeDaemon) {
|
|
1372
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1373
|
+
res.end(JSON.stringify({ ok: false, text: '(sandbox not connected yet — give the app a moment and retry)' }));
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
let text;
|
|
1377
|
+
try {
|
|
1378
|
+
text = await queueDrive(() => runTask(task, activeDaemon.stream, activeDaemon.ctx));
|
|
1379
|
+
} catch (e) { text = `agent error: ${e.message}`; record({ ev: 'drive-error', err: String(e) }); }
|
|
1380
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1381
|
+
res.end(JSON.stringify({ ok: true, text }));
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// the daemon posts result frames here — accumulate output for the loop
|
|
1386
|
+
if (parsed?.frames && req.url?.includes('/local-exec/responses')) {
|
|
1387
|
+
const token = (req.headers['x-anyrun-network-token'] || 'default');
|
|
1388
|
+
const ctx = ctxByToken.get(token);
|
|
1389
|
+
for (const f of parsed.frames) {
|
|
1390
|
+
if (ctx && (f.kind === 'output' || f.kind === 'stdout')) ctx.output += (f.data || f.text || f.chunk || '');
|
|
1391
|
+
if (ctx && (f.kind === 'result' || f.kind === 'exit')) ctx.done = true;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// THE REAL TRIGGER. Grok Bot's own chat (StreamUnifiedChat) never reaches
|
|
1396
|
+
// us — inference happens server-side inside whatever pod EnsureSandBox
|
|
1397
|
+
// named, and since we hijacked that to a pod that doesn't exist on
|
|
1398
|
+
// Cursor's side, the UI's chat box just sits silent forever. But the app
|
|
1399
|
+
// ALSO opens this vnc.html URL — OUR box — inside its own sandbox panel.
|
|
1400
|
+
// Serve a real chat page here instead of a stub: the user types inside
|
|
1401
|
+
// Grok Bot's own window, it POSTs straight to /drive on this box.
|
|
1402
|
+
if (req.url && req.url.includes('/vnc')) {
|
|
1403
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
1404
|
+
res.end(VNC_CHAT_HTML);
|
|
1405
|
+
} else {
|
|
1406
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1407
|
+
res.end('{"ok":true}');
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
});
|
|
1411
|
+
server.on('upgrade', (req, socket) => {
|
|
1412
|
+
record({ port, method: 'UPGRADE', path: req.url, bodyBytes: 0 });
|
|
1413
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n');
|
|
1414
|
+
});
|
|
1415
|
+
// NEVER FATAL. These are the Grok-Bot-shaped agent ports, a nicety — but an
|
|
1416
|
+
// unhandled 'error' event takes the whole process down, and the process that
|
|
1417
|
+
// dies is the one also serving the CHAT on :4173. A stale instance holding
|
|
1418
|
+
// :1337 therefore black-screened the app: Electron loaded :4173 and got
|
|
1419
|
+
// ERR_CONNECTION_REFUSED, with the real cause (EADDRINUSE on a port nobody
|
|
1420
|
+
// cares about) buried in a log the user never sees.
|
|
1421
|
+
server.on('error', (e) => console.log(`[agent] :${port} unavailable (${e.code}) — continuing without it`));
|
|
1422
|
+
server.listen(port, '0.0.0.0', () => console.log(`[agent] on :${port}`));
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
console.log(`[agent] brain=${MODEL} via ${PROXY} task=${process.env.OZ_TASK ? JSON.stringify(process.env.OZ_TASK).slice(0, 60) : '(none — waiting)'}`);
|