openzoo 0.49.18 → 0.50.0
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/openzoo.js +0 -9
- package/lib/models.js +27 -211
- package/lib/proxy.js +113 -1371
- package/lib/xbot.js +1569 -0
- package/lib/xburner.js +80 -0
- package/package.json +4 -5
- package/lib/anthropic.js +0 -369
- package/lib/boxes.js +0 -299
- package/lib/brief.js +0 -76
- package/lib/grokbot.js +0 -120
- package/lib/grokui.mjs +0 -6295
- package/lib/modelroute/README.md +0 -1
- package/lib/modelroute/catalog.json +0 -1
- package/lib/modelroute/outcomes.json +0 -1566
- package/lib/modelroute/router.json +0 -1
- package/lib/modelroute.js +0 -737
- package/lib/podagent.mjs +0 -1414
- package/lib/responses-stream.js +0 -176
- package/lib/responses.js +0 -425
- package/lib/runguard.js +0 -31
- package/lib/spill.js +0 -2031
- package/lib/worktree.mjs +0 -424
- package/vendor/modelroute/CURRENT_STATE.md +0 -132
- package/vendor/modelroute/FOR_MOOSE.md +0 -110
- package/vendor/modelroute/HANDOFF.md +0 -159
- package/vendor/modelroute/catalog.json +0 -1
- package/vendor/modelroute/holographic_modelroute.py +0 -809
- package/vendor/modelroute/outcomes.json +0 -1566
- package/vendor/modelroute/router.json +0 -1
package/lib/proxy.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { readFileSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import fs from 'node:fs';
|
|
5
4
|
import http from 'node:http';
|
|
6
5
|
import crypto from 'node:crypto';
|
|
7
6
|
import { Readable } from 'node:stream';
|
|
@@ -11,32 +10,27 @@ import {
|
|
|
11
10
|
import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
|
|
12
11
|
import { tokenBalance } from './x402.js';
|
|
13
12
|
import { evmTokenBalance } from './evm.js';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
loadBoundChars, noteCorpusLedger, filesForCorpus, readFilesForCorpus, boundAbsFromKeys,
|
|
17
|
-
createSpillStats, corpusCharsForSend, msgText, hudDollarX,
|
|
18
|
-
spillPricedLine,
|
|
19
|
-
planConversationBind, rememberSpillMemo, SPILL_CONTENT_ANCHOR_CHARS,
|
|
20
|
-
corpusRecall,
|
|
21
|
-
decideChatSpill, isOneShotCorpusAsk,
|
|
22
|
-
} from './spill.js';
|
|
23
|
-
import { rewritablePath, modelsListForRequest, isHarnessAliasId, rewriteChatModel, zooModelIds, CLASSIFY_MAX_TOKENS, raiseReasoningMaxTokens, isAutoModel } from './models.js';
|
|
24
|
-
import {
|
|
25
|
-
route as routeTask, routeChatBody, fallbackChain, isRetryableStatus,
|
|
26
|
-
outcomeFromResponse, recordRouteOutcome, autoModelListEntry,
|
|
27
|
-
} from './modelroute.js';
|
|
28
|
-
import { forgetContext } from './contexts.js';
|
|
29
|
-
import { injectBrief } from './brief.js';
|
|
13
|
+
import { modelsListForRequest, isHarnessAliasId } from './models.js';
|
|
30
14
|
import { withNamespace } from './namespace.js';
|
|
31
|
-
import { anthropicToOpenAI, openAIToAnthropic, streamOpenAIToAnthropic, writeAnthropicSse } from './anthropic.js';
|
|
32
|
-
import { responsesToChat, chatToResponses, writeResponsesSse } from './responses.js';
|
|
33
|
-
import { streamOpenAIToResponses } from './responses-stream.js';
|
|
34
15
|
import { loadSessionSpend, saveSessionSpend } from './session.js';
|
|
35
16
|
import { creditBalance, quotedPrices } from './info.js';
|
|
36
17
|
import { priceHoldings } from './livestatus.js';
|
|
37
18
|
import { receiptUsedCogs, receiptDirectUsd, pairActualBilled } from './racesettle.js';
|
|
38
19
|
import { fetchHeaders } from './fetch.js';
|
|
39
20
|
|
|
21
|
+
// THE SHIM IS A FACILITATOR, NOT A MIDDLEBOX.
|
|
22
|
+
//
|
|
23
|
+
// Everything that used to rewrite the request on the way through — model-id
|
|
24
|
+
// rewriting, the tiny-classify pin, the reasoning max_tokens floor, transcript
|
|
25
|
+
// spilling / corpus binding, brief injection, system-message hoisting,
|
|
26
|
+
// Anthropic /v1/messages and Responses translation, AUTO routing — now lives
|
|
27
|
+
// on the backend. This proxy does exactly three jobs:
|
|
28
|
+
// 1. x402: answer the gateway's 402 with a signed payment (PayClient),
|
|
29
|
+
// 2. tunnel: publish + gate the public URL,
|
|
30
|
+
// 3. meter: read receipts so the operator can see spend vs direct.
|
|
31
|
+
// The request body is forwarded byte-for-byte. If a body looks wrong upstream,
|
|
32
|
+
// the fix belongs on the backend, never here.
|
|
33
|
+
|
|
40
34
|
const HOP_BY_HOP = new Set([
|
|
41
35
|
'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
|
|
42
36
|
'proxy-authorization', 'proxy-connection', 'te', 'trailer', 'content-length',
|
|
@@ -54,14 +48,12 @@ const HOP_BY_HOP = new Set([
|
|
|
54
48
|
* Be forgiving about the path, the same way we are about model ids.
|
|
55
49
|
* Harnesses are configured with a base_url that ALREADY ends in /v1, so an
|
|
56
50
|
* agent building "{base}/v1/hrr/bind" sends /v1/v1/hrr/bind and gets a 404 it
|
|
57
|
-
* cannot diagnose
|
|
58
|
-
* functioning as advertised" and fell back to stuffing the corpus inline).
|
|
59
|
-
* Collapse repeated /v1 and add a missing one.
|
|
51
|
+
* cannot diagnose. Collapse repeated /v1 and add a missing one.
|
|
60
52
|
*/
|
|
61
53
|
function normalizePath(url) {
|
|
62
54
|
const [path, query] = (url || '/').split(/(?=\?)/);
|
|
63
55
|
let p = path.replace(/^(?:\/v1)+(?=\/v1\/)/, ''); // /v1/v1/x -> /v1/x
|
|
64
|
-
if (!/^\/v1(\/|$)/.test(p) && /^\/(hrr|chat|models|completions|embeddings|usage|responses)/.test(p)) p = `/v1${p}`;
|
|
56
|
+
if (!/^\/v1(\/|$)/.test(p) && /^\/(hrr|chat|models|completions|embeddings|usage|responses|messages)/.test(p)) p = `/v1${p}`;
|
|
65
57
|
return p === path ? url : `${p}${query || ''}`;
|
|
66
58
|
}
|
|
67
59
|
|
|
@@ -70,12 +62,8 @@ function upstreamHeaders(req) {
|
|
|
70
62
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
71
63
|
if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
|
|
72
64
|
}
|
|
73
|
-
// EVERY forwarded request carries this wallet's context namespace
|
|
74
|
-
// the
|
|
75
|
-
// (an agent posting to /v1/hrr/bind) landed in the shared tenant while the
|
|
76
|
-
// chat that referenced it looked in the wallet's tenant — the context was
|
|
77
|
-
// unreachable and every spill bind came back 400, silently forwarding the
|
|
78
|
-
// whole body at full price.
|
|
65
|
+
// EVERY forwarded request carries this wallet's context namespace — binds
|
|
66
|
+
// and the chats that reference them must land in the same tenant.
|
|
79
67
|
return withNamespace(out);
|
|
80
68
|
}
|
|
81
69
|
|
|
@@ -130,66 +118,6 @@ function jsonErr(res, status, message, extraFields = {}) {
|
|
|
130
118
|
res.end(JSON.stringify({ error: { message }, ...extraFields }));
|
|
131
119
|
}
|
|
132
120
|
|
|
133
|
-
const mb = (n) => (n / 1048576).toFixed(1);
|
|
134
|
-
|
|
135
|
-
// anchor -> { corpus, contextId, hash } for append-only transcript spills
|
|
136
|
-
const spillMemo = new Map();
|
|
137
|
-
// path:mtime of every file already bound — a file is bound once per version,
|
|
138
|
-
// never re-uploaded because the agent read it again.
|
|
139
|
-
const boundFiles = new Set();
|
|
140
|
-
// context_id -> total chars BOUND (turns + files appended). The counterfactual
|
|
141
|
-
// basis must reflect what the corpus actually holds, not just this turn's slice.
|
|
142
|
-
const boundChars = new Map();
|
|
143
|
-
// sessionKey -> { contextId, chars } so a sidecar restart can keep appending
|
|
144
|
-
// to the same context and still send the accumulated x-hrr-corpus-chars.
|
|
145
|
-
const sessionLedger = new Map();
|
|
146
|
-
loadBoundChars(boundChars, { sessions: sessionLedger, boundFiles });
|
|
147
|
-
|
|
148
|
-
// THIS MAP SURVIVES A RESTART, OR THE COUNTERFACTUAL DOES NOT.
|
|
149
|
-
//
|
|
150
|
-
// boundChars is the only record of how large a bound corpus has GROWN. It feeds
|
|
151
|
-
// `x-hrr-corpus-chars`, which is what lets the gateway price the counterfactual
|
|
152
|
-
// against the whole corpus instead of against the turn in front of it. Held
|
|
153
|
-
// purely in memory, every restart silently reset that basis to the live body and
|
|
154
|
-
// savings fell to exactly 1.00x until a session re-accumulated — with nothing in
|
|
155
|
-
// the log to say why, because the corpus in the DAEMON was still there. Only our
|
|
156
|
-
// accounting of it was gone.
|
|
157
|
-
//
|
|
158
|
-
// MEASURED minutes after a restart: `basis 16112 tok vs sent 16112 -> 4238 ·
|
|
159
|
-
// billed 1.14063 direct 1.14063`. The body spilled 3.8x and the bill did not
|
|
160
|
-
// move, because basis fell back to `cached.corpus?.length`.
|
|
161
|
-
//
|
|
162
|
-
// Advisory, never load-bearing: a corrupt or missing file just means we start
|
|
163
|
-
// counting again, which is exactly today's behaviour.
|
|
164
|
-
const BOUND_CHARS_FILE = path.join(os.homedir(), '.openzoo', 'bound-chars.json');
|
|
165
|
-
const BOUND_CHARS_MAX = 500; // newest contexts only; this is a ledger, not a log
|
|
166
|
-
|
|
167
|
-
let boundCharsRestored = 0;
|
|
168
|
-
try {
|
|
169
|
-
const saved = JSON.parse(fs.readFileSync(BOUND_CHARS_FILE, 'utf8'));
|
|
170
|
-
for (const [ctx, chars] of Object.entries(saved)) {
|
|
171
|
-
if (typeof chars === 'number' && chars > 0) boundChars.set(ctx, chars);
|
|
172
|
-
}
|
|
173
|
-
boundCharsRestored = boundChars.size;
|
|
174
|
-
} catch { /* absent or unreadable — start empty, same as before */ }
|
|
175
|
-
|
|
176
|
-
let boundCharsTimer = null;
|
|
177
|
-
/** Debounced: appends land in bursts, and the basis only has to survive a
|
|
178
|
-
* restart, not every keystroke. Never rejects — persistence is a nicety. */
|
|
179
|
-
const persistBoundChars = () => {
|
|
180
|
-
if (boundCharsTimer) return;
|
|
181
|
-
boundCharsTimer = setTimeout(() => {
|
|
182
|
-
boundCharsTimer = null;
|
|
183
|
-
try {
|
|
184
|
-
// Map preserves insertion order, so the tail is the newest.
|
|
185
|
-
const entries = [...boundChars.entries()].slice(-BOUND_CHARS_MAX);
|
|
186
|
-
mkdirSync(path.dirname(BOUND_CHARS_FILE), { recursive: true });
|
|
187
|
-
fs.writeFileSync(BOUND_CHARS_FILE, JSON.stringify(Object.fromEntries(entries)));
|
|
188
|
-
} catch { /* advisory */ }
|
|
189
|
-
}, 2000);
|
|
190
|
-
boundCharsTimer.unref?.(); // must never hold the process open
|
|
191
|
-
};
|
|
192
|
-
|
|
193
121
|
/**
|
|
194
122
|
* Every fundable balance across all three chains, for the startup line and
|
|
195
123
|
* the live refresh. Each read is independent and advisory — one lagging RPC
|
|
@@ -227,22 +155,19 @@ function balanceLine(snap) {
|
|
|
227
155
|
}
|
|
228
156
|
|
|
229
157
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* completion is re-emitted as spec-shaped chat.completion.chunk events.
|
|
158
|
+
* When the zoo answers a chat completion as ONE JSON object but the harness
|
|
159
|
+
* sent `stream: true`, honour the streaming contract ourselves — a JSON body
|
|
160
|
+
* on an expected SSE socket reads as a dead connection (Cursor shows
|
|
161
|
+
* "Reconnecting…", RETRIES, and every retry is a fresh payment). An upstream
|
|
162
|
+
* that truly streams passes straight through relay(), untouched.
|
|
236
163
|
*/
|
|
237
164
|
function serveAsSse(res, data, upstream) {
|
|
238
165
|
const headers = {
|
|
239
166
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
240
167
|
'cache-control': 'no-cache',
|
|
241
168
|
// Tell any proxy in front of us (cloudflare quick tunnel, nginx) NOT to
|
|
242
|
-
// buffer the stream
|
|
243
|
-
//
|
|
244
|
-
// an agent parses incrementally — observed as "provider-side tool-call
|
|
245
|
-
// protocol error" over the tunnel while localhost (no proxy) is fine.
|
|
169
|
+
// buffer the stream — buffering reorders/merges tool_call frames an agent
|
|
170
|
+
// parses incrementally.
|
|
246
171
|
'x-accel-buffering': 'no',
|
|
247
172
|
connection: 'keep-alive',
|
|
248
173
|
};
|
|
@@ -259,9 +184,8 @@ function serveAsSse(res, data, upstream) {
|
|
|
259
184
|
ev({ ...base, choices: [{ index: c.index ?? 0, delta: { content: c.message.content }, finish_reason: null }] });
|
|
260
185
|
}
|
|
261
186
|
// Agent mode lives or dies here: a finish_reason of "tool_calls" with the
|
|
262
|
-
// calls themselves dropped strands the harness mid-turn
|
|
263
|
-
//
|
|
264
|
-
// arguments as a string chunk — one full chunk per call is valid SSE.
|
|
187
|
+
// calls themselves dropped strands the harness mid-turn. Streaming spec:
|
|
188
|
+
// tool_calls ride the delta with an index, arguments as a string chunk.
|
|
265
189
|
if (Array.isArray(c.message?.tool_calls) && c.message.tool_calls.length) {
|
|
266
190
|
ev({
|
|
267
191
|
...base,
|
|
@@ -290,24 +214,15 @@ function serveAsSse(res, data, upstream) {
|
|
|
290
214
|
* body within seconds, and each retry used to be a fresh payment (observed:
|
|
291
215
|
* six identical $0.06 settles for one Cursor message). An identical POST body
|
|
292
216
|
* arriving within the window is served the cached completion, not re-paid.
|
|
293
|
-
* Window is deliberately short: a genuinely new turn always differs
|
|
294
|
-
* resend the whole conversation), so only true retries can hit.
|
|
217
|
+
* Window is deliberately short: a genuinely new turn always differs.
|
|
295
218
|
*/
|
|
296
219
|
const REPLAY_TTL_MS = 30_000;
|
|
297
|
-
const replayCache = new Map(); // sha256(body) -> { at, data, settle }
|
|
220
|
+
const replayCache = new Map(); // sha256(body+headers) -> { at, data, settle }
|
|
298
221
|
/**
|
|
299
|
-
* The key MUST include the routing headers, not just the body
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
* byte-identical bodies and differ only in X-HRR-Context. They collided on one
|
|
304
|
-
* key, so shards 2..N were served shard 1's answer — REPRODUCED in the field:
|
|
305
|
-
* 10 shards, 7 byte-identical replies across corpora known to differ, and the
|
|
306
|
-
* batch finished in 12s where a single uncached call took ~7s.
|
|
307
|
-
*
|
|
308
|
-
* Wrong answers attributed to the wrong corpus is a far worse failure than the
|
|
309
|
-
* double-billing this cache exists to prevent, so every header that can change
|
|
310
|
-
* the ANSWER joins the key.
|
|
222
|
+
* The key MUST include the routing headers, not just the body: N shards asking
|
|
223
|
+
* the SAME question of N DIFFERENT bound corpora send byte-identical bodies and
|
|
224
|
+
* differ only in X-HRR-Context. Wrong answers attributed to the wrong corpus is
|
|
225
|
+
* a far worse failure than the double-billing this cache exists to prevent.
|
|
311
226
|
*/
|
|
312
227
|
const REPLAY_KEY_HEADERS = ['x-hrr-context', 'x-hrr-top-k', 'x-hrr-gate', 'x-openzoo-namespace'];
|
|
313
228
|
|
|
@@ -333,483 +248,6 @@ function replayPut(key, data, settle) {
|
|
|
333
248
|
}
|
|
334
249
|
}
|
|
335
250
|
|
|
336
|
-
/**
|
|
337
|
-
* "The body never ships twice" at the proxy. A chat body whose LAST message
|
|
338
|
-
* carries a huge pasted corpus gets split at its last blank line — corpus vs
|
|
339
|
-
* ask — so the corpus can be bound ONCE on the zoo and every later call ships
|
|
340
|
-
* only the ask plus X-HRR-Context. The split point is deterministic, which is
|
|
341
|
-
* what makes the sha256 manifest hit on run 2 even when the question changed.
|
|
342
|
-
*
|
|
343
|
-
* Conservative on purpose: only a single big STRING content on the final
|
|
344
|
-
* message, only when a blank-line boundary exists, and any failure falls back
|
|
345
|
-
* to sending the original body untouched — caching must never break a call.
|
|
346
|
-
* Returns null (send as-is) or { body, contextId, hash, corpus, reused, savedBytes }.
|
|
347
|
-
*/
|
|
348
|
-
/**
|
|
349
|
-
* Spill the OLD prefix of a long TRANSCRIPT into leCore.
|
|
350
|
-
*
|
|
351
|
-
* WHY THIS EXISTS. The only spill was the corpus+question shape below, which
|
|
352
|
-
* requires the last message to be one big string ending in `\n\n<question>` —
|
|
353
|
-
* true for zoo_ask, never true for an agent. `npx openzoo claude` therefore
|
|
354
|
-
* spilled NOTHING, hit Claude Code's own context ceiling, and auto-compacted,
|
|
355
|
-
* on a product whose pitch is that it does not have to. Compaction was honest
|
|
356
|
-
* given nothing was offloaded; this is what makes the claim true.
|
|
357
|
-
*
|
|
358
|
-
* Runs on the OpenAI shape ON PURPOSE. /v1/messages is translated by
|
|
359
|
-
* anthropicToOpenAI and rewritten to /v1/chat/completions BEFORE this is
|
|
360
|
-
* reached, so operating here covers Claude Code, Cursor and the raw API with
|
|
361
|
-
* one implementation instead of three that can drift.
|
|
362
|
-
*
|
|
363
|
-
* THE CUT POINT IS NOT NEGOTIABLE. An assistant `tool_calls` must be answered
|
|
364
|
-
* by role:"tool" messages or the upstream 400s, so the transcript may only be
|
|
365
|
-
* severed at a plain `user` message — everything before one is self-contained.
|
|
366
|
-
* A system message is never spilled: it is the operating contract, not history.
|
|
367
|
-
*/
|
|
368
|
-
async function spillTranscript(body, log, req, stats, extra = {}) {
|
|
369
|
-
const msgs = Array.isArray(body?.messages) ? body.messages : null;
|
|
370
|
-
if (!msgs?.length) return null;
|
|
371
|
-
|
|
372
|
-
// PATHS FIRST, BYTES LATER. The cut/length gates below used to run before
|
|
373
|
-
// filesForCorpus, so a short agent turn that Read a file never bound it �
|
|
374
|
-
// and when the extract itself returned empty, nothing logged. Collect
|
|
375
|
-
// unconditionally, but only paths + cheap stat/mtime: a 2MB Read must not
|
|
376
|
-
// stall this turn. readdir + readFile + bindCorpus run after we return,
|
|
377
|
-
// via setImmediate, so the chat request goes first.
|
|
378
|
-
//
|
|
379
|
-
// Snapshot bound paths BEFORE collect so this turn's first-read files stay
|
|
380
|
-
// verbatim in the tail (not yet in the corpus for recall). Previously
|
|
381
|
-
// bound files before the last ask may stub; remaining post-ask Read/Edit/
|
|
382
|
-
// Write / Bash-file bodies stay real (drop older rounds instead).
|
|
383
|
-
const previouslyBoundAbs = boundAbsFromKeys(boundFiles);
|
|
384
|
-
const fileCollect = filesForCorpus(msgs, { boundFiles });
|
|
385
|
-
const sessionId = req?.headers?.['x-claude-code-session-id']
|
|
386
|
-
|| req?.headers?.['x-session-id']
|
|
387
|
-
|| req?.headers?.['x-claude-session-id']
|
|
388
|
-
|| (typeof body?.metadata?.user_id === 'string' ? body.metadata.user_id : null);
|
|
389
|
-
let sessionKey = sessionId ? `sid:${sessionId}` : null;
|
|
390
|
-
|
|
391
|
-
const ledgerOpts = () => ({ sessionKey, sessions: sessionLedger, boundFiles });
|
|
392
|
-
const bindFilesInBackground = (label, { appendTo: forcedAppend, asAppend = false } = {}) => {
|
|
393
|
-
if (!fileCollect.pending.length) return;
|
|
394
|
-
const known = (sessionKey && spillMemo.get(sessionKey))
|
|
395
|
-
|| (sessionKey && sessionLedger.get(sessionKey))
|
|
396
|
-
|| null;
|
|
397
|
-
const appendTo = forcedAppend !== undefined ? forcedAppend : (known?.contextId || null);
|
|
398
|
-
setImmediate(() => {
|
|
399
|
-
let read;
|
|
400
|
-
try {
|
|
401
|
-
read = readFilesForCorpus(fileCollect, { boundFiles, log });
|
|
402
|
-
} catch (e) {
|
|
403
|
-
log(`${asAppend ? 'file append failed (corpus lags one turn)' : 'file bind failed'}: ${e.message}`);
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
if (!read.text) return;
|
|
407
|
-
void bindCorpus(read.text, {
|
|
408
|
-
appendTo,
|
|
409
|
-
onStage: (stage, info) => {
|
|
410
|
-
if (stage !== 'binding') return;
|
|
411
|
-
if (asAppend && appendTo) {
|
|
412
|
-
log(`appending ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES to ${appendTo} (background)`);
|
|
413
|
-
} else {
|
|
414
|
-
log(`binding ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES (${label})`);
|
|
415
|
-
}
|
|
416
|
-
},
|
|
417
|
-
}).then((b) => {
|
|
418
|
-
if (!b?.contextId) return;
|
|
419
|
-
noteCorpusLedger(boundChars, {
|
|
420
|
-
contextId: b.contextId,
|
|
421
|
-
reused: Boolean(appendTo),
|
|
422
|
-
corpusChars: 0,
|
|
423
|
-
fileChars: read.bytes,
|
|
424
|
-
...ledgerOpts(),
|
|
425
|
-
});
|
|
426
|
-
stats?.noteFileBind(read.files, read.bytes);
|
|
427
|
-
if (sessionKey && !spillMemo.has(sessionKey)) {
|
|
428
|
-
spillMemo.set(sessionKey, { corpus: '', contextId: b.contextId, hash: b.hash });
|
|
429
|
-
}
|
|
430
|
-
}).catch((e) => log(`${asAppend ? 'file append failed (corpus lags one turn)' : 'file bind failed'}: ${e.message}`));
|
|
431
|
-
});
|
|
432
|
-
};
|
|
433
|
-
|
|
434
|
-
// LIVE SELF-TUNER. Env knobs seed the first cut; after cut+stub the proxy
|
|
435
|
-
// scores the HUD dollar multiple (spill direct/billed) and retunes
|
|
436
|
-
// keep/min-turns/budget (stubMore for SEARCH, not live file bodies)
|
|
437
|
-
// in process memory so this request recuts when the green x is under 10.
|
|
438
|
-
// No restart. OPENZOO_ADAPT=0 freezes the env defaults. The ask always
|
|
439
|
-
// stays; we never drop below 2 real user/assistant turns to delete it.
|
|
440
|
-
//
|
|
441
|
-
// 1-model AND raced grokui AUTO both land here (same POST /chat/completions
|
|
442
|
-
// door). The old `msgs.length < 6` bail skipped fat 1-model hops � a 40k
|
|
443
|
-
// command-output turn with 4�5 messages never bound, so spent?direct.
|
|
444
|
-
// decideChatSpill is the shared gate: oversized ? bind prefix + system/tail
|
|
445
|
-
// + x-hrr-context; small ? passthrough. Race fields do not change it.
|
|
446
|
-
const knownLedger = (sessionKey && spillMemo.get(sessionKey))
|
|
447
|
-
|| (sessionKey && sessionLedger.get(sessionKey))
|
|
448
|
-
|| null;
|
|
449
|
-
const knownChars = knownLedger?.contextId
|
|
450
|
-
? (boundChars.get(knownLedger.contextId) || 0)
|
|
451
|
-
: 0;
|
|
452
|
-
const decision = decideChatSpill(body, {
|
|
453
|
-
corpusChars: knownChars,
|
|
454
|
-
boundAbs: previouslyBoundAbs,
|
|
455
|
-
recall: corpusRecall(typeof knownLedger?.corpus === 'string' ? knownLedger.corpus : ''),
|
|
456
|
-
log,
|
|
457
|
-
persist: true,
|
|
458
|
-
dollarX: extra.dollarX ?? hudDollarX(stats || {}),
|
|
459
|
-
lastSend: extra.lastSend,
|
|
460
|
-
minPrefixChars: BIND_MIN_CHARS,
|
|
461
|
-
});
|
|
462
|
-
if (decision.mode === 'passthrough') {
|
|
463
|
-
bindFilesInBackground(decision.reason === 'no-cut'
|
|
464
|
-
? 'no severable cut, files only'
|
|
465
|
-
: 'conversation under spill threshold, background');
|
|
466
|
-
return null;
|
|
467
|
-
}
|
|
468
|
-
if (decision.mode === 'stub-only') {
|
|
469
|
-
bindFilesInBackground('no severable cut, files only');
|
|
470
|
-
// Still forward stubbed/trimmed bodies so an un-severable storm is not
|
|
471
|
-
// shipped at full size just because cutTranscript could not move.
|
|
472
|
-
return {
|
|
473
|
-
body: Buffer.from(JSON.stringify({ ...body, messages: decision.forwarded })),
|
|
474
|
-
corpus: '',
|
|
475
|
-
reused: false,
|
|
476
|
-
savedBytes: 0,
|
|
477
|
-
sent: decision.forwarded.length,
|
|
478
|
-
msgs: msgs.length,
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
// oneshot is handled in maybeCacheCorpus; if we reach it here, bind the
|
|
482
|
-
// extracted corpus the same way as a transcript prefix.
|
|
483
|
-
if (decision.mode === 'oneshot' && !decision.adapted) {
|
|
484
|
-
decision.adapted = {
|
|
485
|
-
cut: msgs.length - 1,
|
|
486
|
-
firstSpillable: Math.max(0, msgs.findIndex((m) => m?.role !== 'system')),
|
|
487
|
-
stubbed: { messages: decision.forwarded, stubbed: 0, dropped: 0 },
|
|
488
|
-
};
|
|
489
|
-
}
|
|
490
|
-
const adapted = decision.adapted;
|
|
491
|
-
const { cut, firstSpillable } = adapted;
|
|
492
|
-
const stubbed = adapted.stubbed;
|
|
493
|
-
|
|
494
|
-
const head = decision.head.length ? decision.head : msgs.slice(0, firstSpillable); // system block, always kept
|
|
495
|
-
// EVERY FILE THE AGENT TOUCHED, AT FULL SIZE.
|
|
496
|
-
//
|
|
497
|
-
// The saving ratio is corpus/sent, so on a fresh session — where the corpus
|
|
498
|
-
// IS the conversation — it starts near 1x and only climbs as you talk. That
|
|
499
|
-
// is exactly what the live agent numbers showed: 1.0-1.3x early, 8x once a
|
|
500
|
-
// 149k-token history existed. MEASURED the same day: prompt compressed 2.67x
|
|
501
|
-
// but billed savings was 1.13x, because output does not compress and a small
|
|
502
|
-
// corpus leaves nothing to compress on the other side either.
|
|
503
|
-
//
|
|
504
|
-
// Files are the fix. An agent reads far more bytes than it discusses, the
|
|
505
|
-
// harness has usually TRUNCATED them on the way in, and the full text is
|
|
506
|
-
// sitting on this machine. Binding it makes the corpus large immediately
|
|
507
|
-
// instead of eventually, and makes the truncated read whole again.
|
|
508
|
-
//
|
|
509
|
-
// Read-only, bounded, deduped by path+mtime. Path collection already ran at
|
|
510
|
-
// the top of this function (fileCollect.pending). Bytes + dir expansion
|
|
511
|
-
// happen in bindFilesInBackground after this turn is forwarded.
|
|
512
|
-
//
|
|
513
|
-
// FILES RIDE THE BACKGROUND, NEVER THE CRITICAL PATH.
|
|
514
|
-
//
|
|
515
|
-
// The first conversation bind is also fire-and-forget: turn 1 may go out
|
|
516
|
-
// unspilled while the bind runs, then later turns recall a tail + contextId.
|
|
517
|
-
// Folding file bytes into a synchronous first bind used to put a 400KB
|
|
518
|
-
// upload in front of the caller's turn (0.34-0.48s on a 613KB corpus).
|
|
519
|
-
//
|
|
520
|
-
// Nothing recalls a file during the turn that read it � the model already has
|
|
521
|
-
// the tool result in its window. Files are only worth having bound for the
|
|
522
|
-
// NEXT ask, so they append after the conversation bind completes.
|
|
523
|
-
const turns = decision.prefix
|
|
524
|
-
|| msgs.slice(firstSpillable, cut).map(msgText).filter(Boolean).join('\n\n');
|
|
525
|
-
const corpus = turns;
|
|
526
|
-
if (!sessionKey) sessionKey = corpus.slice(0, SPILL_CONTENT_ANCHOR_CHARS);
|
|
527
|
-
|
|
528
|
-
// Conversation prefix binds at any size. BIND_MIN_CHARS only gates the
|
|
529
|
-
// one-shot corpus+question path in maybeCacheCorpus � a real but small
|
|
530
|
-
// early-turn prefix used to be discarded here ("only large ones").
|
|
531
|
-
if (!corpus) {
|
|
532
|
-
bindFilesInBackground('empty conversation prefix, files only');
|
|
533
|
-
return null;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// CONTINUE THE CONTEXT, BIND ONLY THE DELTA.
|
|
537
|
-
//
|
|
538
|
-
// A transcript grows by one message per turn, so the whole-corpus hash misses
|
|
539
|
-
// every time and the old code re-uploaded the ENTIRE prefix on every single
|
|
540
|
-
// turn � OBSERVED live: 0.4MB bound three turns running, a fresh context id
|
|
541
|
-
// each time, while only a few KB was actually new. Bind cost grew with
|
|
542
|
-
// conversation length and was re-paid per message.
|
|
543
|
-
//
|
|
544
|
-
// The corpus is append-only: each turn's corpus starts with the previous
|
|
545
|
-
// one. So when it does, send just the tail and keep the same context_id.
|
|
546
|
-
// Anchored on the FIRST 2KB, which is stable for the life of a conversation
|
|
547
|
-
// and distinguishes concurrent ones.
|
|
548
|
-
// KEY ON THE SESSION, NOT ON THE CONTENT.
|
|
549
|
-
//
|
|
550
|
-
// The anchor was the first 2KB of corpus, which works only because a
|
|
551
|
-
// transcript's opening never changes. It is fragile in exactly the cases that
|
|
552
|
-
// matter: two sessions that open identically (same system block, same first
|
|
553
|
-
// instruction � the norm for an agent) collide onto ONE bound context and
|
|
554
|
-
// interleave their histories, and any edit near the top of a transcript
|
|
555
|
-
// silently orphans the binding and re-uploads the whole thing.
|
|
556
|
-
//
|
|
557
|
-
// Claude Code identifies its session, so use that when it is offered and fall
|
|
558
|
-
// back to the content anchor when it is not. Same memo, better key.
|
|
559
|
-
// CAPTURED FROM A LIVE claude-cli/2.1.232 REQUEST, not guessed. The first
|
|
560
|
-
// version of this checked x-session-id / x-claude-session-id /
|
|
561
|
-
// metadata.user_id � none of which Claude Code sends, so it silently fell
|
|
562
|
-
// back to the content anchor on every request and the feature did nothing.
|
|
563
|
-
// The real header list is:
|
|
564
|
-
// anthropic-beta, anthropic-version, x-app, x-claude-code-session-id,
|
|
565
|
-
// x-stainless-*
|
|
566
|
-
//
|
|
567
|
-
// COLD-BIND. Turn 1 may go unspilled (full messages, no x-hrr-context) while
|
|
568
|
-
// the first bind runs in the background � same spirit as file-bind, so the
|
|
569
|
-
// opening ask is not stalled. The in-flight/completed bind is memoized on
|
|
570
|
-
// the session key; later turns recall a tail + contextId. Subsequent
|
|
571
|
-
// appends stay fire-and-forget deltas.
|
|
572
|
-
let bindPlan = planConversationBind({
|
|
573
|
-
sessionKey,
|
|
574
|
-
corpus,
|
|
575
|
-
spillMemo,
|
|
576
|
-
sessionLedger,
|
|
577
|
-
});
|
|
578
|
-
const anchor = bindPlan.key || sessionKey;
|
|
579
|
-
|
|
580
|
-
if (bindPlan.action === 'cold-bind') {
|
|
581
|
-
const ready = bindCorpus(corpus, {
|
|
582
|
-
onStage: (stage, info) => {
|
|
583
|
-
if (stage === 'binding') log(`binding ${mb(info.bytes)}MB of transcript to holographic memory (background)...`);
|
|
584
|
-
},
|
|
585
|
-
}).then((b) => {
|
|
586
|
-
if (!b?.contextId) return b;
|
|
587
|
-
noteCorpusLedger(boundChars, {
|
|
588
|
-
contextId: b.contextId,
|
|
589
|
-
reused: false,
|
|
590
|
-
corpusChars: corpus.length,
|
|
591
|
-
deltaChars: 0,
|
|
592
|
-
fileChars: 0,
|
|
593
|
-
...ledgerOpts(),
|
|
594
|
-
});
|
|
595
|
-
rememberSpillMemo(spillMemo, anchor, { corpus, contextId: b.contextId, hash: b.hash });
|
|
596
|
-
bindFilesInBackground('background', { appendTo: b.contextId, asAppend: true });
|
|
597
|
-
return b;
|
|
598
|
-
}).catch((e) => {
|
|
599
|
-
log(`bind failed (turn went unspilled): ${e.message}`);
|
|
600
|
-
const cur = spillMemo.get(anchor);
|
|
601
|
-
if (cur?.pending) spillMemo.delete(anchor);
|
|
602
|
-
return null;
|
|
603
|
-
});
|
|
604
|
-
rememberSpillMemo(spillMemo, anchor, { corpus, pending: true, ready });
|
|
605
|
-
return null;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
if (bindPlan.action === 'await-pending') {
|
|
609
|
-
const finished = await bindPlan.ready.catch((e) => {
|
|
610
|
-
log(`bind failed (history may lag one turn): ${e.message}`);
|
|
611
|
-
return null;
|
|
612
|
-
});
|
|
613
|
-
if (!finished?.contextId) {
|
|
614
|
-
bindFilesInBackground('first bind failed, files only');
|
|
615
|
-
return null;
|
|
616
|
-
}
|
|
617
|
-
bindPlan = planConversationBind({
|
|
618
|
-
sessionKey: anchor,
|
|
619
|
-
corpus,
|
|
620
|
-
spillMemo,
|
|
621
|
-
sessionLedger,
|
|
622
|
-
});
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
if (bindPlan.action !== 'recall' || !bindPlan.contextId) {
|
|
626
|
-
bindFilesInBackground('no context yet, files only');
|
|
627
|
-
return null;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
let bind;
|
|
631
|
-
let deltaChars = 0;
|
|
632
|
-
let appended = false;
|
|
633
|
-
if (bindPlan.restored && bindPlan.contextId) {
|
|
634
|
-
// Sidecar came back up: we still know the context_id and the accumulated
|
|
635
|
-
// char count, but not the prior prefix string, so we cannot slice a delta.
|
|
636
|
-
// Re-append the current prefix (some overlap is harmless) and keep the
|
|
637
|
-
// restored ledger � do not add corpus.length again.
|
|
638
|
-
void bindCorpus(corpus, {
|
|
639
|
-
appendTo: bindPlan.contextId,
|
|
640
|
-
onStage: (stage, info) => {
|
|
641
|
-
if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${bindPlan.contextId} (restored session, background)`);
|
|
642
|
-
},
|
|
643
|
-
}).catch((e) => log(`append failed (history may lag one turn): ${e.message}`));
|
|
644
|
-
appended = true;
|
|
645
|
-
bind = { contextId: bindPlan.contextId, hash: bindPlan.hash, reused: true, bytes: 0 };
|
|
646
|
-
} else if (bindPlan.append && bindPlan.delta) {
|
|
647
|
-
const delta = bindPlan.delta;
|
|
648
|
-
deltaChars = delta.length;
|
|
649
|
-
// FIRE AND FORGET. This delta is history for FUTURE turns � the answer
|
|
650
|
-
// being generated right now is served from the tail plus what is already
|
|
651
|
-
// bound, so waiting on the upload buys nothing and costs the user the
|
|
652
|
-
// round trip on every single turn. The context id is already known, so
|
|
653
|
-
// nothing is lost by not waiting for it.
|
|
654
|
-
void bindCorpus(delta, {
|
|
655
|
-
appendTo: bindPlan.contextId,
|
|
656
|
-
onStage: (stage, info) => {
|
|
657
|
-
if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${bindPlan.contextId} (delta, background)`);
|
|
658
|
-
},
|
|
659
|
-
}).catch((e) => log(`append failed (history may lag one turn): ${e.message}`));
|
|
660
|
-
appended = true;
|
|
661
|
-
bind = { contextId: bindPlan.contextId, hash: bindPlan.hash, reused: true, bytes: delta.length };
|
|
662
|
-
} else {
|
|
663
|
-
bind = { contextId: bindPlan.contextId, hash: bindPlan.hash, reused: true, bytes: 0 };
|
|
664
|
-
}
|
|
665
|
-
// CONVERSATION LEDGER � every successful bind AND append, not only when
|
|
666
|
-
// files exist. First bind initializes to the bound corpus size; each append
|
|
667
|
-
// adds the delta; file bytes ride on top. This is what makes x-hrr-corpus-chars
|
|
668
|
-
// the accumulated bound corpus instead of this-turn's prefix.
|
|
669
|
-
noteCorpusLedger(boundChars, {
|
|
670
|
-
contextId: bind.contextId,
|
|
671
|
-
reused: appended,
|
|
672
|
-
corpusChars: corpus.length,
|
|
673
|
-
deltaChars,
|
|
674
|
-
fileChars: 0,
|
|
675
|
-
...ledgerOpts(),
|
|
676
|
-
});
|
|
677
|
-
// APPEND THE FILES AFTER, off the clock. Fire-and-forget against the context
|
|
678
|
-
// we just secured: this turn is already answerable without them, and the next
|
|
679
|
-
// ask gets them for free. `boundFiles` already deduped by path:mtime, so this
|
|
680
|
-
// uploads each version exactly once no matter how often the agent re-reads it.
|
|
681
|
-
// Read + readdir are inside setImmediate � they must not run before send().
|
|
682
|
-
bindFilesInBackground('background', { appendTo: bind.contextId, asAppend: true });
|
|
683
|
-
rememberSpillMemo(spillMemo, anchor, { corpus, contextId: bind.contextId, hash: bind.hash });
|
|
684
|
-
// Count the tail that is actually forwarded after stub/trim � older
|
|
685
|
-
// continue-turn rounds after the ask may have been dropped, so
|
|
686
|
-
// msgs.length - cut would keep lastSend growing with the raw pile.
|
|
687
|
-
const sent = Math.max(0, stubbed.messages.length - cut);
|
|
688
|
-
// NAME THE KEY. A memo keyed on the wrong thing fails silently — it just
|
|
689
|
-
// re-binds forever and collides sessions — so the log says which key was used.
|
|
690
|
-
const keyKind = sessionId ? `sid ${String(sessionId).slice(0, 8)}` : 'content-anchor';
|
|
691
|
-
// WHAT ACTUALLY REACHES THE MODEL. Inference about this cut has been wrong
|
|
692
|
-
// twice; the roles of the forwarded tail settle it in one line.
|
|
693
|
-
if (process.env.OPENZOO_LOG_TAIL === '1') {
|
|
694
|
-
const roles = msgs.slice(cut).map((m) => (m.role || '?')[0]).join('');
|
|
695
|
-
const lastU = msgs.slice(cut).some((m) => m.role === 'user' && msgText(m).trim());
|
|
696
|
-
log(` tail roles=${roles} firstSpillable=${firstSpillable} cut=${cut} hasUserText=${lastU}`);
|
|
697
|
-
}
|
|
698
|
-
log(bind.reused
|
|
699
|
-
? `transcript prefix already bound (${bind.contextId}, ${keyKind}) — sending ${sent}/${msgs.length} turns`
|
|
700
|
-
: `transcript prefix bound (${mb(bind.bytes)}MB → ${bind.contextId}, ${keyKind}) — sending ${sent}/${msgs.length} turns`);
|
|
701
|
-
|
|
702
|
-
// applySpillCut cut/stubbed the tail (post-ask file bodies stay real;
|
|
703
|
-
// search may stub). The adapt line is already logged there.
|
|
704
|
-
if (stubbed.dropped) {
|
|
705
|
-
log(`file-stub stubbed=${stubbed.stubbed} dropped=${stubbed.dropped}`);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// ADAPTIVE TOP-K. A fixed 32 chunks is what was actually eating the saving:
|
|
709
|
-
// MEASURED on a 56,265-token corpus, top_k 32 handed 9,990 tokens back and
|
|
710
|
-
// scored 2.45x, while 8 handed back 2,574 and scored 4.73x — same answer,
|
|
711
|
-
// same corpus, nearly double the saving. Recall breadth, not markup, is the
|
|
712
|
-
// lever, and spilling 34k tokens only to recall 22k of them back is not a
|
|
713
|
-
// saving, it is a round trip.
|
|
714
|
-
//
|
|
715
|
-
// So budget the recall in TOKENS and derive k from it, rather than fixing the
|
|
716
|
-
// chunk count and letting the token cost fall where it may. ~320 tokens per
|
|
717
|
-
// chunk measured. The budget scales with the ask — a one-line question needs
|
|
718
|
-
// far less context than a detailed one — and is clamped so a huge ask cannot
|
|
719
|
-
// drag the whole corpus back in.
|
|
720
|
-
const askChars = msgText(msgs[msgs.length - 1] || {}).length;
|
|
721
|
-
// BREADTH IS THE LEVER, AND MORE OF IT IS WORSE. MEASURED on a 56,265-token
|
|
722
|
-
// corpus: top_k 32 handed back 9,990 tokens and scored 2.45x, top_k 8 handed
|
|
723
|
-
// back 2,574 and scored 4.73x — same question, same answer, nearly double the
|
|
724
|
-
// saving. Spilling 34k tokens to recall 22k of them back is a round trip, not
|
|
725
|
-
// a saving. So budget the recall tighter and cap k where the measurement says
|
|
726
|
-
// the value is, rather than at the largest number that still fits.
|
|
727
|
-
const budget = Math.min(
|
|
728
|
-
Number(process.env.OPENZOO_RECALL_MAX_TOKENS || 3000),
|
|
729
|
-
Math.max(Number(process.env.OPENZOO_RECALL_MIN_TOKENS || 1200),
|
|
730
|
-
Math.round(askChars / 3)),
|
|
731
|
-
);
|
|
732
|
-
const topK = Math.max(4, Math.min(12, Math.round(budget / 320)));
|
|
733
|
-
|
|
734
|
-
return {
|
|
735
|
-
body: Buffer.from(JSON.stringify({
|
|
736
|
-
...body,
|
|
737
|
-
messages: decision.forwarded || [...head, ...stubbed.messages.slice(cut)],
|
|
738
|
-
})),
|
|
739
|
-
topK,
|
|
740
|
-
contextId: bind.contextId,
|
|
741
|
-
hash: bind.hash,
|
|
742
|
-
corpus,
|
|
743
|
-
reused: bind.reused,
|
|
744
|
-
savedBytes: bind.bytes,
|
|
745
|
-
sent,
|
|
746
|
-
msgs: msgs.length,
|
|
747
|
-
};
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
async function maybeCacheCorpus(req, bodyBuf, log, stats, extra = {}) {
|
|
751
|
-
if (contextCacheDisabled()) return null;
|
|
752
|
-
if (req.method !== 'POST' || !(req.url || '').includes('/chat/completions')) return null;
|
|
753
|
-
if (req.headers['x-hrr-context']) return null; // harness manages its own context
|
|
754
|
-
if (bodyBuf.length <= BIND_MIN_CHARS) {
|
|
755
|
-
// Still walk the transcript: a short agent turn that Read a file should
|
|
756
|
-
// bind it even when there is nothing large enough to spill.
|
|
757
|
-
try {
|
|
758
|
-
const body = JSON.parse(bodyBuf.toString('utf8'));
|
|
759
|
-
if (Array.isArray(body?.messages) && body.messages.length) {
|
|
760
|
-
return spillTranscript(body, log, req, stats, extra);
|
|
761
|
-
}
|
|
762
|
-
} catch { /* not json */ }
|
|
763
|
-
return null;
|
|
764
|
-
}
|
|
765
|
-
let body;
|
|
766
|
-
try { body = JSON.parse(bodyBuf.toString('utf8')); } catch { return null; }
|
|
767
|
-
const msgs = Array.isArray(body?.messages) ? body.messages : null;
|
|
768
|
-
if (!msgs?.length) return null;
|
|
769
|
-
// CORPUS+QUESTION first — one huge final message ending in \n\n<ask>. That is
|
|
770
|
-
// what zoo_ask and the chat surface send, and binding exactly that body keeps
|
|
771
|
-
// the ask verbatim. Anything else (an agent transcript) falls through to the
|
|
772
|
-
// transcript spill, which used to be a silent no-op.
|
|
773
|
-
const last = msgs[msgs.length - 1];
|
|
774
|
-
// Same gate decideChatSpill uses � 1-model and race share it. zoo_ask
|
|
775
|
-
// stays on the corpus+question bind; everything else (including grokui
|
|
776
|
-
// AUTO, raced or not) falls through to spillTranscript ? decideChatSpill.
|
|
777
|
-
if (!isOneShotCorpusAsk(msgs, BIND_MIN_CHARS)) return spillTranscript(body, log, req, stats, extra);
|
|
778
|
-
const cut = last.content.lastIndexOf('\n\n');
|
|
779
|
-
const corpus = last.content.slice(0, cut);
|
|
780
|
-
const ask = last.content.slice(cut + 2).trim();
|
|
781
|
-
if (!ask || ask.length > 8000) return spillTranscript(body, log, req, stats, extra);
|
|
782
|
-
|
|
783
|
-
const bind = await bindCorpus(corpus, {
|
|
784
|
-
onStage: (stage, info) => {
|
|
785
|
-
if (stage === 'binding') log(`binding ${mb(info.bytes)}MB corpus to holographic memory (one-time)...`);
|
|
786
|
-
},
|
|
787
|
-
});
|
|
788
|
-
noteCorpusLedger(boundChars, {
|
|
789
|
-
contextId: bind.contextId,
|
|
790
|
-
reused: false,
|
|
791
|
-
corpusChars: corpus.length,
|
|
792
|
-
sessions: sessionLedger,
|
|
793
|
-
boundFiles,
|
|
794
|
-
});
|
|
795
|
-
if (bind.reused) {
|
|
796
|
-
log(`corpus already bound (${bind.hash.slice(0, 12)}… → ${bind.contextId}) — skipped ${mb(bind.bytes)}MB upload`);
|
|
797
|
-
} else {
|
|
798
|
-
log(`corpus bound once (${mb(bind.bytes)}MB → ${bind.contextId}) — repeats of this body are near-free`);
|
|
799
|
-
}
|
|
800
|
-
const rewritten = { ...body, messages: [...msgs.slice(0, -1), { ...last, content: ask }] };
|
|
801
|
-
return {
|
|
802
|
-
body: Buffer.from(JSON.stringify(rewritten)),
|
|
803
|
-
contextId: bind.contextId,
|
|
804
|
-
hash: bind.hash,
|
|
805
|
-
corpus,
|
|
806
|
-
reused: bind.reused,
|
|
807
|
-
savedBytes: bind.bytes,
|
|
808
|
-
sent: 1,
|
|
809
|
-
msgs: msgs.length,
|
|
810
|
-
};
|
|
811
|
-
}
|
|
812
|
-
|
|
813
251
|
/**
|
|
814
252
|
* `requireToken` / `sessionMaxUsd` are TUNNEL MODE (see lib/tunnel.js): once the
|
|
815
253
|
* proxy is reachable from the internet, the api key stops being decorative and
|
|
@@ -819,16 +257,10 @@ async function maybeCacheCorpus(req, bodyBuf, log, stats, extra = {}) {
|
|
|
819
257
|
export async function startProxy({ silent = false, requireToken = null, sessionMaxUsd = null, autoTunnel = false } = {}) {
|
|
820
258
|
const client = new PayClient();
|
|
821
259
|
const log = silent ? () => {} : (...a) => console.log(...a);
|
|
822
|
-
// ALWAYS-ON. `silent: true` is used by the editor path to keep startup tidy,
|
|
823
|
-
// but it also swallowed the per-request lines and the payment receipts — so the
|
|
824
|
-
// terminal sat blank and there was no way to tell a working setup from an editor
|
|
825
|
-
// quietly answering from its own backend. Traffic and receipts are the whole
|
|
826
|
-
// point of watching this window; they are never silenced.
|
|
827
260
|
// ALWAYS-ON, BUT NEVER INTO A HARNESS'S TERMINAL. `silent` means another
|
|
828
261
|
// process (openzoo claude, the editor launcher) owns stdio — printing request
|
|
829
|
-
// lines / payment receipts there corrupts that program's output
|
|
830
|
-
//
|
|
831
|
-
// channel to a log file instead; only print to the console when we own it.
|
|
262
|
+
// lines / payment receipts there corrupts that program's output. When silent,
|
|
263
|
+
// route this channel to a log file instead; only print when we own the console.
|
|
832
264
|
const restored = loadSessionSpend();
|
|
833
265
|
let paidCalls = restored.paidCalls;
|
|
834
266
|
let sayFile = null;
|
|
@@ -838,9 +270,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
838
270
|
mkdirSync(path.dirname(sayFile), { recursive: true });
|
|
839
271
|
} catch { sayFile = null; }
|
|
840
272
|
}
|
|
841
|
-
// Spill/adapt/classifier lines must use this channel: `log` is a no-op when
|
|
842
|
-
// `openzoo claude` starts us with silent:true, and printing them on stdout
|
|
843
|
-
// corrupts the Claude Code TTY. say() writes ~/.openzoo/proxy.log then.
|
|
844
273
|
const say = (...a) => {
|
|
845
274
|
const line = a.join(' ');
|
|
846
275
|
if (sayFile) { try { appendFileSync(sayFile, line + '\n'); return; } catch { /* fall through */ } }
|
|
@@ -854,7 +283,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
854
283
|
saveSessionSpend({ spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls });
|
|
855
284
|
};
|
|
856
285
|
if (restored.ok && (sessionSpent > 0 || paidCalls > 0)) {
|
|
857
|
-
say(`session restored: $${sessionSpent.toFixed(6)}
|
|
286
|
+
say(`session restored: $${sessionSpent.toFixed(6)} · ${paidCalls} paid call${paidCalls === 1 ? '' : 's'}`);
|
|
858
287
|
}
|
|
859
288
|
process.on('exit', rememberSpend);
|
|
860
289
|
const noteQuote = (x) => {
|
|
@@ -880,44 +309,18 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
880
309
|
// where public traffic slips through ungated.
|
|
881
310
|
let tunnelGate = null;
|
|
882
311
|
// How many chat requests actually ARRIVED. The single number that answers
|
|
883
|
-
// "is the editor really routing through us?"
|
|
884
|
-
// using its own backend leaves this at 0 while looking perfectly healthy.
|
|
312
|
+
// "is the editor really routing through us?"
|
|
885
313
|
let servedRequests = 0;
|
|
886
|
-
// SPILL ACCOUNTING. The product's whole claim is that context is offloaded
|
|
887
|
-
// instead of re-sent, and nothing measured it — the status line showed spend
|
|
888
|
-
// and call count, which is the cost side with none of the benefit.
|
|
889
|
-
const spill = createSpillStats();
|
|
890
|
-
let lastSpillSend = null;
|
|
891
|
-
// Spend/direct for ONLY the calls that spilled. The session-wide savingX
|
|
892
|
-
// averages these with every small turn that had nothing to offload, so it
|
|
893
|
-
// slides toward 1.0 as a conversation grows — which reads as the mechanism
|
|
894
|
-
// degrading when it is just the mix changing. OBSERVED: 1.3166 -> 1.1823
|
|
895
|
-
// while spilled calls and offloaded tokens both sat completely still.
|
|
896
|
-
// spillSpend / spillDirect live on `spill` (createSpillStats).
|
|
897
314
|
// ACTUAL UPSTREAM SPEND, FROM THE PROVIDER — not our estimate of it.
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
// times a token count we guessed, and the guess is bad in a specific
|
|
901
|
-
// direction: output is priced on `max_tokens`, which callers set as a ceiling.
|
|
902
|
-
// MEASURED 2026-08-19: a call we quoted at $0.9858 (32,000 reserved output
|
|
903
|
-
// tokens) actually cost $0.007962 — OpenRouter's own number, 124x smaller.
|
|
904
|
-
//
|
|
905
|
-
// OpenRouter returns `usage.cost` on every completion, so the real figure is
|
|
906
|
-
// already in the response body and needs no extra request and no account-level
|
|
907
|
-
// lookup. That last part matters: /api/v1/credits reports the WHOLE key's
|
|
908
|
-
// lifetime usage, and this key also pays for ttfx direct runs and other work,
|
|
909
|
-
// so the account total can never attribute a dollar to this proxy. Per-call
|
|
910
|
-
// cost can.
|
|
315
|
+
// OpenRouter returns `usage.cost` on every completion; per-call cost is the
|
|
316
|
+
// only figure attributable to THIS proxy.
|
|
911
317
|
let sessionActual = 0;
|
|
912
318
|
let actualCalls = 0;
|
|
913
319
|
// billed for ONLY those calls whose real cost we learned — the honest
|
|
914
|
-
// numerator for markupX.
|
|
320
|
+
// numerator for markupX.
|
|
915
321
|
let billedWithActual = 0;
|
|
916
|
-
// CREDIT, CACHED.
|
|
917
|
-
//
|
|
918
|
-
// pay this at all"). The status line runs EVERY turn, so this is refreshed at
|
|
919
|
-
// most every 20s and served stale in between — a status line must never add
|
|
920
|
-
// latency to the thing it is describing.
|
|
322
|
+
// CREDIT, CACHED. Refreshed at most every 20s and served stale in between —
|
|
323
|
+
// a status line must never add latency to the thing it is describing.
|
|
921
324
|
let creditUsd = null;
|
|
922
325
|
let creditAt = 0;
|
|
923
326
|
let creditInflight = null;
|
|
@@ -949,23 +352,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
949
352
|
|
|
950
353
|
const server = http.createServer(async (req, res) => {
|
|
951
354
|
// MCP on the SAME port as the proxy. One `npx openzoo` gives a harness
|
|
952
|
-
// both surfaces: point base_url at /v1
|
|
953
|
-
// or add /mcp for tools (zoo_bind, zoo_ask...). Running two commands to
|
|
954
|
-
// get both was friction nobody should pay.
|
|
955
|
-
// This wallet's own running total for THIS proxy process — every paid
|
|
956
|
-
// call through this port counts (GUI, MCP, CLI, any harness), not just
|
|
957
|
-
// whichever surface happens to be asking. Local-only, no auth needed:
|
|
958
|
-
// it's a number, not a capability.
|
|
355
|
+
// both surfaces: point base_url at /v1, or add /mcp for tools.
|
|
959
356
|
if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/session') {
|
|
960
|
-
// NEVER await fly.dev. Serve last-known;
|
|
961
|
-
//
|
|
962
|
-
// refreshCredit() is fire-and-forget so HUD "continue" cannot sit on HTTP 000.
|
|
357
|
+
// NEVER await fly.dev. Serve last-known; refreshCredit() is
|
|
358
|
+
// fire-and-forget so HUD "continue" cannot sit on HTTP 000.
|
|
963
359
|
refreshCredit().catch(() => {});
|
|
964
360
|
refreshPrices();
|
|
965
361
|
const money = walletMoney();
|
|
966
362
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
967
|
-
// Same package.json the startup banner reads � grokui-app refuses to
|
|
968
|
-
// attach to a leftover :8402 whose version is older than it shipped with.
|
|
969
363
|
const { version } = JSON.parse(
|
|
970
364
|
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
971
365
|
);
|
|
@@ -977,9 +371,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
977
371
|
return;
|
|
978
372
|
}
|
|
979
373
|
|
|
980
|
-
// Public addresses only — never the private key. Exists so a caller
|
|
981
|
-
//
|
|
982
|
-
// inline instead of telling the user to go look somewhere else.
|
|
374
|
+
// Public addresses only — never the private key. Exists so a caller can
|
|
375
|
+
// print REAL funding instructions inline.
|
|
983
376
|
if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/wallet') {
|
|
984
377
|
refreshCredit().catch(() => {});
|
|
985
378
|
refreshPrices();
|
|
@@ -989,8 +382,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
989
382
|
solana: client.address,
|
|
990
383
|
evm: client.evmAddress,
|
|
991
384
|
funding: fundingLine(client.address),
|
|
992
|
-
// from the same background poll the startup/refresh lines use, so a
|
|
993
|
-
// caller can tell a genuinely empty wallet from a transient 402
|
|
994
385
|
balances: balanceLine(lastSnap || []) || null,
|
|
995
386
|
funded: (lastSnap || []).some((b) => b.ui > 0),
|
|
996
387
|
creditUsd,
|
|
@@ -1010,10 +401,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1010
401
|
return;
|
|
1011
402
|
}
|
|
1012
403
|
|
|
1013
|
-
// THE CUTESY GUI. Local browsers hitting GET / get a little chat app
|
|
1014
|
-
//
|
|
1015
|
-
// receipts. Tunnel traffic (cf headers) keeps the JSON discovery below:
|
|
1016
|
-
// the GUI is the operator's, not the public's.
|
|
404
|
+
// THE CUTESY GUI. Local browsers hitting GET / get a little chat app.
|
|
405
|
+
// Tunnel traffic (cf headers) keeps the JSON discovery below.
|
|
1017
406
|
{
|
|
1018
407
|
const p0 = (req.url || '').split('?')[0];
|
|
1019
408
|
const local = !(req.headers['cf-connecting-ip'] || req.headers['cf-ray']);
|
|
@@ -1032,7 +421,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1032
421
|
log(`path ${req.url} -> ${normalized} (base_url already ends in /v1)`);
|
|
1033
422
|
req.url = normalized;
|
|
1034
423
|
}
|
|
1035
|
-
|
|
424
|
+
const url = `${config.apiBase}${req.url}`;
|
|
1036
425
|
// Requests that arrived over the public quick-tunnel URL carry cloudflared's
|
|
1037
426
|
// headers; nothing dialing 127.0.0.1 directly does. That distinction is what
|
|
1038
427
|
// lets localhost stay keyless while the SAME port is safely public.
|
|
@@ -1051,11 +440,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1051
440
|
return;
|
|
1052
441
|
}
|
|
1053
442
|
}
|
|
1054
|
-
// ROUTING TRUTH, SERVED FROM WHATEVER URL YOU REACHED US ON.
|
|
1055
|
-
//
|
|
1056
|
-
// routing" is the wrong question — the answer has to come from the tunnel
|
|
1057
|
-
// itself, and name the tunnel. Free and unauthenticated: discovery must
|
|
1058
|
-
// never be the thing that is gated.
|
|
443
|
+
// ROUTING TRUTH, SERVED FROM WHATEVER URL YOU REACHED US ON. Free and
|
|
444
|
+
// unauthenticated: discovery must never be the thing that is gated.
|
|
1059
445
|
{
|
|
1060
446
|
const p0 = (req.url || '').split('?')[0];
|
|
1061
447
|
if (req.method === 'GET' && (p0 === '/v1/info' || p0 === '/info')) {
|
|
@@ -1070,30 +456,16 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1070
456
|
reachedVia: viaTunnel ? 'public tunnel' : 'localhost',
|
|
1071
457
|
publicTunnel: tunnelGate?.publicUrl ? `${tunnelGate.publicUrl}/v1` : null,
|
|
1072
458
|
servedRequests,
|
|
1073
|
-
spilled: (() => {
|
|
1074
|
-
const ledgerTotal = [...boundChars.values()].reduce((a, b) => a + b, 0);
|
|
1075
|
-
return {
|
|
1076
|
-
...spill.snapshot({ boundChars: ledgerTotal }),
|
|
1077
|
-
boundChars: ledgerTotal,
|
|
1078
|
-
lastSend: lastSpillSend,
|
|
1079
|
-
};
|
|
1080
|
-
})(),
|
|
1081
459
|
spendUsd: sessionSpent,
|
|
1082
460
|
creditUsd,
|
|
1083
461
|
// WHAT THE SAME CALLS WOULD HAVE COST DIRECT. Spend on its own is a
|
|
1084
|
-
// bill; spend beside the counterfactual is the product.
|
|
1085
|
-
// already carries directUsd per call — it simply never reached the
|
|
1086
|
-
// status line, so the one number that justifies the tool was the one
|
|
1087
|
-
// the user could not see.
|
|
462
|
+
// bill; spend beside the counterfactual is the product.
|
|
1088
463
|
directUsd: sessionDirect,
|
|
1089
464
|
savedUsd: Math.max(0, sessionDirect - sessionSpent),
|
|
1090
465
|
savingX: sessionSpent > 0 ? Number((sessionDirect / sessionSpent).toFixed(4)) : null,
|
|
1091
466
|
paidCalls,
|
|
1092
|
-
// WHAT THE INFERENCE ACTUALLY COST, as reported by
|
|
1093
|
-
// completion (`usage.cost`).
|
|
1094
|
-
// estimate built on `max_tokens`; this is metered. `margin` is the
|
|
1095
|
-
// honest gross on this session — the number that says whether the
|
|
1096
|
-
// pricing is sane, which no estimate can.
|
|
467
|
+
// WHAT THE INFERENCE ACTUALLY COST, as reported by the provider on
|
|
468
|
+
// each completion (`usage.cost`). `margin` is the honest gross.
|
|
1097
469
|
actual: {
|
|
1098
470
|
calls: actualCalls,
|
|
1099
471
|
upstreamUsd: Number(sessionActual.toFixed(6)),
|
|
@@ -1107,12 +479,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1107
479
|
auth: viaTunnel
|
|
1108
480
|
? 'this public URL requires the oz_… bearer for paid endpoints; /v1/models and /v1/hrr/bind are free'
|
|
1109
481
|
: 'localhost is keyless',
|
|
1110
|
-
|
|
1111
|
-
yourAttentionWindow: 'unchanged — openzoo does not enlarge it',
|
|
1112
|
-
boundCeiling: '~128M tokens client-usable via bind + retrieval',
|
|
1113
|
-
singleRequestLimit: '~8MB per request; larger corpora bind in parts',
|
|
1114
|
-
retrieval: 'lossy top-k retrieval, NOT lossless compression',
|
|
1115
|
-
},
|
|
482
|
+
shim: 'pure passthrough — request bodies are forwarded byte-for-byte; all message/model handling is on the backend',
|
|
1116
483
|
tools: ['zoo_bind', 'zoo_ask', 'zoo_status', 'zoo_models', 'zoo_wallet', 'zoo_contexts'],
|
|
1117
484
|
docs: 'https://openzoo.fun',
|
|
1118
485
|
}, null, 2));
|
|
@@ -1123,18 +490,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1123
490
|
if (viaTunnel) {
|
|
1124
491
|
const got = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
|
|
1125
492
|
// TRUST ON FIRST USE, so a stale key still works without weakening the
|
|
1126
|
-
// tunnel to "any key forever".
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1129
|
-
// earlier run silently failed — and the only fixes were re-pasting by
|
|
1130
|
-
// hand or writing into the editor's OS-encrypted credential store, which
|
|
1131
|
-
// would mean prompting for keychain access to install a value the user
|
|
1132
|
-
// never chose. Instead: the printed token always works, and the FIRST
|
|
1133
|
-
// other key to present itself claims the tunnel for the rest of the
|
|
1134
|
-
// session. Your editor (which reaches the URL first, from this machine)
|
|
1135
|
-
// adopts it; anyone who finds the URL afterwards is refused because the
|
|
1136
|
-
// slot is taken. The URL is unguessable and ephemeral, the spend ceiling
|
|
1137
|
-
// still applies, and OPENZOO_TUNNEL_STRICT=1 restores exact-match only.
|
|
493
|
+
// tunnel to "any key forever". The printed token always works, and the
|
|
494
|
+
// FIRST other key to present itself claims the tunnel for the session.
|
|
495
|
+
// OPENZOO_TUNNEL_STRICT=1 restores exact-match only.
|
|
1138
496
|
const strict = process.env.OPENZOO_TUNNEL_STRICT === '1';
|
|
1139
497
|
let authed = got === tunnelGate.token;
|
|
1140
498
|
if (!authed && !strict && got.length >= 8) {
|
|
@@ -1147,11 +505,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1147
505
|
}
|
|
1148
506
|
}
|
|
1149
507
|
const p = (req.url || '').split('?')[0];
|
|
1150
|
-
// DISCOVERY IS FREE.
|
|
1151
|
-
//
|
|
1152
|
-
// every path just sends it spelunking through the operator's machine.
|
|
1153
|
-
// Reads that cost nothing and leak nothing (the catalog is public on
|
|
1154
|
-
// the zoo anyway) go through without the key; money paths stay gated.
|
|
508
|
+
// DISCOVERY IS FREE. Reads that cost nothing and leak nothing go through
|
|
509
|
+
// without the key; money paths stay gated.
|
|
1155
510
|
if (!authed) {
|
|
1156
511
|
if (req.method === 'GET' && (p === '/' || p === '/v1' || p === '/v1/info')) {
|
|
1157
512
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -1162,18 +517,15 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1162
517
|
endpoints: {
|
|
1163
518
|
'GET /v1/models': 'model catalog with pricing + context_length — no key needed',
|
|
1164
519
|
'GET /v1/models/{id}': 'single-model probe — no key needed',
|
|
1165
|
-
'POST /v1/chat/completions': 'chat (streaming supported
|
|
520
|
+
'POST /v1/chat/completions': 'chat (streaming supported) — key required',
|
|
1166
521
|
},
|
|
1167
522
|
docs: 'https://openzoo.fun · https://www.npmjs.com/package/openzoo',
|
|
1168
523
|
}, null, 2));
|
|
1169
524
|
return;
|
|
1170
525
|
}
|
|
1171
|
-
// Binding COSTS NOTHING — no 402, no wallet, no settlement.
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1174
|
-
// correct multi-part bind script, got 401 on the final append, and
|
|
1175
|
-
// fell back to stuffing the corpus inline. The money paths below stay
|
|
1176
|
-
// gated; the worst a stranger can do here is spend our sidecar's disk.
|
|
526
|
+
// Binding COSTS NOTHING — no 402, no wallet, no settlement. The money
|
|
527
|
+
// paths below stay gated; the worst a stranger can do here is spend
|
|
528
|
+
// the sidecar's disk.
|
|
1177
529
|
const freeRead = req.method === 'GET' && (p === '/v1/models' || p.startsWith('/v1/models/'));
|
|
1178
530
|
const freeBind = req.method === 'POST' && p === '/v1/hrr/bind';
|
|
1179
531
|
if (freeBind) log(`public url: unauthenticated bind allowed (free endpoint) from ${req.socket.remoteAddress}`);
|
|
@@ -1196,348 +548,28 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1196
548
|
return;
|
|
1197
549
|
}
|
|
1198
550
|
|
|
1199
|
-
// Local decision � no upstream, no payment. Same keys as Python route().
|
|
1200
|
-
{
|
|
1201
|
-
const routePath = (req.url || '').split('?')[0];
|
|
1202
|
-
if (req.method === 'POST' && (routePath === '/route' || routePath === '/v1/route')) {
|
|
1203
|
-
let payload;
|
|
1204
|
-
try { payload = JSON.parse(bodyBuf.toString('utf8') || '{}'); } catch {
|
|
1205
|
-
jsonErr(res, 400, 'invalid /route body');
|
|
1206
|
-
return;
|
|
1207
|
-
}
|
|
1208
|
-
try {
|
|
1209
|
-
const r = routeTask(payload.text ?? '', {
|
|
1210
|
-
allow_free: payload.allow_free ?? false,
|
|
1211
|
-
bindable: payload.bindable ?? true,
|
|
1212
|
-
context: payload.context,
|
|
1213
|
-
input_tokens: payload.input_tokens,
|
|
1214
|
-
has_image: payload.has_image,
|
|
1215
|
-
needs_tools: payload.needs_tools,
|
|
1216
|
-
needs_json: payload.needs_json,
|
|
1217
|
-
...(payload.constraints && typeof payload.constraints === 'object' ? payload.constraints : {}),
|
|
1218
|
-
});
|
|
1219
|
-
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1220
|
-
res.end(JSON.stringify(r));
|
|
1221
|
-
} catch (err) {
|
|
1222
|
-
jsonErr(res, 500, `openzoo /route: ${err.message}`);
|
|
1223
|
-
}
|
|
1224
|
-
return;
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
// ANTHROPIC MESSAGES SHAPE. A harness pointed here via ANTHROPIC_BASE_URL
|
|
1229
|
-
// (Claude Code, the Anthropic SDKs) speaks POST /v1/messages, not chat
|
|
1230
|
-
// completions — this is how such a harness routes its inference through
|
|
1231
|
-
// x402 without any DNS or TLS trickery. Translate the body to OpenAI shape
|
|
1232
|
-
// and rewrite the path so EVERYTHING downstream (model rewrite, brief,
|
|
1233
|
-
// corpus cache, payment, replay, streaming) runs unchanged; translate the
|
|
1234
|
-
// answer back on the way out. See lib/anthropic.js.
|
|
1235
|
-
let anthropicMode = false;
|
|
1236
|
-
let anthropicModel = null;
|
|
1237
|
-
// The CLIENT's streaming intent, kept separate from the body we send
|
|
1238
|
-
// upstream. The Anthropic lane asks the gateway for a complete message (we
|
|
1239
|
-
// can only translate a finished one) while still owing the caller SSE.
|
|
1240
|
-
let clientWantsStream = false;
|
|
1241
|
-
let responsesMode = false;
|
|
1242
|
-
let responsesModel = null;
|
|
1243
|
-
let responsesCustom = null; // names of freeform tools needing custom_tool_call on the way back
|
|
1244
551
|
const rawPath = (req.url || '').split('?')[0];
|
|
1245
|
-
|
|
1246
|
-
//
|
|
1247
|
-
//
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
if (req.method === 'POST' && (rawPath === '/v1/responses' || rawPath === '/responses')) {
|
|
1252
|
-
try {
|
|
1253
|
-
const inbound = JSON.parse(bodyBuf.toString('utf8'));
|
|
1254
|
-
// TEMP CAPTURE: dump the first few Responses requests so the agent's
|
|
1255
|
-
// actual wire usage (store / previous_response_id / tool shapes) can be
|
|
1256
|
-
// read rather than inferred. Guarded by an env var so it is off unless
|
|
1257
|
-
// asked for.
|
|
1258
|
-
if (process.env.OZ_CAPTURE_RESPONSES) {
|
|
1259
|
-
try {
|
|
1260
|
-
const fs = await import('node:fs');
|
|
1261
|
-
const dir = process.env.OZ_CAPTURE_RESPONSES;
|
|
1262
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
1263
|
-
const n = fs.readdirSync(dir).length;
|
|
1264
|
-
// Capture the LATER turns too. Turn 1 is already understood; the
|
|
1265
|
-
// unknown is what codex sends back after running a tool, so bias
|
|
1266
|
-
// the capture toward requests that carry a tool result.
|
|
1267
|
-
const hasResult = Array.isArray(inbound.input)
|
|
1268
|
-
&& inbound.input.some((i) => i && String(i.type || '').endsWith('_call_output'));
|
|
1269
|
-
const tag = hasResult ? 'result' : 'plain';
|
|
1270
|
-
if (n < 24) fs.writeFileSync(`${dir}/${tag}-${n}.json`, JSON.stringify(inbound, null, 2));
|
|
1271
|
-
} catch { /* capture must never break a paid call */ }
|
|
1272
|
-
}
|
|
1273
|
-
responsesModel = inbound.model;
|
|
1274
|
-
const meta = {};
|
|
1275
|
-
const translated = responsesToChat(inbound, meta);
|
|
1276
|
-
// Codex's watchdog is ~10s to first SSE byte. Force stream so we can
|
|
1277
|
-
// emit response.created immediately instead of buffering Opus.
|
|
1278
|
-
translated.stream = true;
|
|
1279
|
-
bodyBuf = Buffer.from(JSON.stringify(translated));
|
|
1280
|
-
responsesCustom = meta.custom;
|
|
1281
|
-
responsesMode = true;
|
|
1282
|
-
req.url = '/v1/chat/completions';
|
|
1283
|
-
url = `${config.apiBase}${req.url}`;
|
|
1284
|
-
} catch {
|
|
1285
|
-
jsonErr(res, 400, 'invalid responses body');
|
|
1286
|
-
return;
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
if (req.method === 'POST' && (rawPath === '/v1/messages' || rawPath === '/messages')) {
|
|
1291
|
-
try {
|
|
1292
|
-
const inbound = JSON.parse(bodyBuf.toString('utf8'));
|
|
1293
|
-
anthropicModel = inbound.model;
|
|
1294
|
-
// ANTHROPIC CLIENTS CANNOT READ AN OPENAI STREAM.
|
|
1295
|
-
//
|
|
1296
|
-
// The gateway now streams for real, and `relay()` pipes those frames
|
|
1297
|
-
// through untouched — which is correct for an OpenAI client and
|
|
1298
|
-
// unreadable to Claude Code, which speaks the Anthropic SSE grammar
|
|
1299
|
-
// (message_start / content_block_delta / message_stop). It surfaced as
|
|
1300
|
-
// "API returned an empty or malformed response (HTTP 200)": a 200 whose
|
|
1301
|
-
// body the client cannot parse.
|
|
1302
|
-
//
|
|
1303
|
-
// The translation we have (openAIToAnthropic + writeAnthropicSse) works
|
|
1304
|
-
// on a COMPLETE message, so this lane asks the gateway not to stream and
|
|
1305
|
-
// keeps the buffered translation. That costs Claude Code the
|
|
1306
|
-
// time-to-first-byte win until an incremental OpenAI->Anthropic frame
|
|
1307
|
-
// translator exists; a readable answer late beats an unreadable one now.
|
|
1308
|
-
const converted = anthropicToOpenAI(inbound);
|
|
1309
|
-
clientWantsStream = converted.stream === true || inbound.stream === true;
|
|
1310
|
-
// STREAM AGAIN. This forced `stream:false` for a few hours because
|
|
1311
|
-
// relay() piped the gateway's OpenAI frames straight to a client that
|
|
1312
|
-
// speaks message_start / content_block_delta, producing a 200 nobody
|
|
1313
|
-
// could parse. Buffering fixed the parse and cost the whole point:
|
|
1314
|
-
// Claude Code sends max_tokens=32000 against a 600-turn transcript, so
|
|
1315
|
-
// every turn became minutes of zero bytes and read as a hang.
|
|
1316
|
-
// streamOpenAIToAnthropic() translates the grammar frame by frame, so
|
|
1317
|
-
// the lane can be fast AND readable.
|
|
1318
|
-
converted.stream = clientWantsStream;
|
|
1319
|
-
bodyBuf = Buffer.from(JSON.stringify(converted));
|
|
1320
|
-
anthropicMode = true;
|
|
1321
|
-
req.url = '/v1/chat/completions';
|
|
1322
|
-
url = `${config.apiBase}${req.url}`;
|
|
1323
|
-
} catch {
|
|
1324
|
-
jsonErr(res, 400, 'invalid anthropic messages body');
|
|
1325
|
-
return;
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
// Harness model ids ("gpt-5.6-sol", "claude-…") are rewritten onto the
|
|
1330
|
-
// NEAREST zoo model BEFORE anything else sees the body — any POST that
|
|
1331
|
-
// carries a model field, not just chat/completions, so /completions,
|
|
1332
|
-
// /responses and future shapes all work. Never silent.
|
|
552
|
+
// Paid inference paths: chat completions, Anthropic messages, Responses.
|
|
553
|
+
// The BODY IS NOT TOUCHED — the parse below is read-only, to learn the
|
|
554
|
+
// client's streaming intent for the JSON→SSE compatibility path.
|
|
555
|
+
const isChat = req.method === 'POST' && rawPath.includes('/chat/completions');
|
|
556
|
+
const isPaidPost = req.method === 'POST'
|
|
557
|
+
&& /\/(chat\/completions|completions|messages|responses)$/.test(rawPath);
|
|
1333
558
|
let wantsStream = false;
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
// TEMPORARY: name the headers (not values) so we can see whether the
|
|
1339
|
-
// client offers a session id at all. Values are never logged — several
|
|
1340
|
-
// of these carry auth.
|
|
1341
|
-
if (process.env.OPENZOO_LOG_HEADERS === '1') {
|
|
1342
|
-
log(` headers: ${Object.keys(req.headers).sort().join(', ')}`);
|
|
1343
|
-
}
|
|
1344
|
-
}
|
|
1345
|
-
if (rewritablePath(req.method, req.url)) {
|
|
1346
|
-
try {
|
|
1347
|
-
let parsed = JSON.parse(bodyBuf.toString('utf8'));
|
|
1348
|
-
wantsStream = parsed?.stream === true || clientWantsStream;
|
|
1349
|
-
// TINY CLASSIFY FIRST, ON THE ORIGINAL BODY.
|
|
1350
|
-
//
|
|
1351
|
-
// Claude Code auto-mode sends a 16-token yes/no to claude-sonnet-5
|
|
1352
|
-
// before Bash/WebSearch. Two things used to eat that call:
|
|
1353
|
-
// 1. OPENZOO_DEFAULT_MODEL rewrote it onto deepseek/grok;
|
|
1354
|
-
// 2. the reasoning floor then raised 16 -> 4000.
|
|
1355
|
-
// Measured: 11.5s, past the caller's timeout, "claude-sonnet-5 is
|
|
1356
|
-
// temporarily unavailable (timed out), so auto mode cannot determine
|
|
1357
|
-
// the safety of WebSearch". Even staying on sonnet-5 is too slow
|
|
1358
|
-
// (402 handshake behind a long Grok stream). Pin to a fast
|
|
1359
|
-
// non-reasoning catalog id and leave max_tokens alone. Real
|
|
1360
|
-
// Grok/DeepSeek chats (max_tokens 2000+ AND a long transcript)
|
|
1361
|
-
// still get the 4000 floor � those still go blank without it.
|
|
1362
|
-
// 0.48.75 missed grok nubs at max_tokens 128/2000 on a 1�2
|
|
1363
|
-
// message body (~3� from the floor, no classifier log).
|
|
1364
|
-
let ids = [];
|
|
1365
|
-
try { ids = await zooModelIds(); } catch { /* catalog miss: still skip the floor on a tiny classify */ }
|
|
1366
|
-
const policy = rewriteChatModel(parsed, ids, { bodyLen: bodyBuf.length });
|
|
1367
|
-
parsed = policy.parsed;
|
|
1368
|
-
if (!policy.tiny && (policy.auto || isAutoModel(parsed?.model))) {
|
|
1369
|
-
autoRoute = routeChatBody(parsed, {
|
|
1370
|
-
allow_free: false,
|
|
1371
|
-
bindable: true,
|
|
1372
|
-
// Live quoteable ids only � Auto must never emit :batch / $0 /
|
|
1373
|
-
// missing-price rows that 500 `bad openrouter price` on Fly.
|
|
1374
|
-
...(ids.length ? { allow_ids: ids } : {}),
|
|
1375
|
-
});
|
|
1376
|
-
if (!autoRoute.model) {
|
|
1377
|
-
jsonErr(res, 422, autoRoute.reason || 'openzoo/auto: no feasible model', { route: autoRoute });
|
|
1378
|
-
return;
|
|
1379
|
-
}
|
|
1380
|
-
parsed = { ...parsed, model: autoRoute.model };
|
|
1381
|
-
const bump = raiseReasoningMaxTokens(parsed);
|
|
1382
|
-
parsed = bump.parsed;
|
|
1383
|
-
say(`openzoo/auto -> ${autoRoute.model} p=${autoRoute.p_success} cleared=${autoRoute.cleared_bar} ${autoRoute.task_class}${autoRoute.bind_first ? ' bind_first' : ''}`);
|
|
1384
|
-
if (!autoRoute.cleared_bar) {
|
|
1385
|
-
say(`openzoo/auto cleared_bar=false � strongest fallback, not a normal pick`);
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
if (policy.tiny) {
|
|
1389
|
-
// `openzoo claude` starts us silent � `log` is a no-op then. say()
|
|
1390
|
-
// is the proxy.log channel and never the Claude Code TTY.
|
|
1391
|
-
say(`classifier tiny max_tokens=${Number(parsed?.max_tokens)} "${policy.from}" -> ${policy.to} (no reasoning floor)`);
|
|
1392
|
-
} else {
|
|
1393
|
-
// SAY WHETHER THE OVERRIDE IS ACTUALLY SET, and name it.
|
|
1394
|
-
//
|
|
1395
|
-
// This used to print "(OPENZOO_DEFAULT_MODEL overrides)" on EVERY
|
|
1396
|
-
// rewrite whether or not the variable existed � a hint about a knob,
|
|
1397
|
-
// phrased as a statement about this request. It cost a real incident:
|
|
1398
|
-
// the proxy was restarted from a shell carrying
|
|
1399
|
-
// OPENZOO_DEFAULT_MODEL=deepseek/deepseek-v4-pro-0813, so every
|
|
1400
|
-
// claude-sonnet-5 ask was served by deepseek, and the log line looked
|
|
1401
|
-
// exactly the same as it always had. deepseek matches the reasoning
|
|
1402
|
-
// regex, so a 16-token safety classification became a 4,000-token
|
|
1403
|
-
// reasoning generation � 11.5s, past the caller's timeout, and Claude
|
|
1404
|
-
// Code reported "claude-sonnet-5 is temporarily unavailable".
|
|
1405
|
-
// Tiny classify is pinned above and never reaches this path.
|
|
1406
|
-
if (policy.to && policy.to !== policy.from) {
|
|
1407
|
-
const forced = process.env.OPENZOO_DEFAULT_MODEL;
|
|
1408
|
-
log(forced
|
|
1409
|
-
? `model "${policy.from}" -> FORCED to ${forced} by OPENZOO_DEFAULT_MODEL (nearest match would have been ${policy.to})`
|
|
1410
|
-
: `model "${policy.from}" is not on the zoo � nearest match ${policy.to}`);
|
|
1411
|
-
}
|
|
1412
|
-
// REASONING MODELS SPEND max_tokens ON THINKING FIRST.
|
|
1413
|
-
//
|
|
1414
|
-
// The budget covers hidden reasoning AND the visible answer, so a
|
|
1415
|
-
// caller that asks for 40 tokens because it wants a short answer often
|
|
1416
|
-
// gets ZERO � the whole allowance went to reasoning and the completion
|
|
1417
|
-
// truncated to an empty string. Measured across three families in one
|
|
1418
|
-
// day: deepseek returned 0 chars at 8k and was fine at 24k; grok-4.6
|
|
1419
|
-
// pinned ct at exactly its 16,000 budget with no visible output;
|
|
1420
|
-
// sonnet-5 truncated a 600-token file mid-function because Anthropic's
|
|
1421
|
-
// max_tokens covers thinking too.
|
|
1422
|
-
//
|
|
1423
|
-
// An empty completion is not an error � it bills normally and renders
|
|
1424
|
-
// as a blank reply � so this fails silently and looks like the retrieval
|
|
1425
|
-
// broke. It cost real debugging time tonight for exactly that reason.
|
|
1426
|
-
// Multiply the allowance for known reasoning families and let callers
|
|
1427
|
-
// keep asking for what they actually want back.
|
|
1428
|
-
//
|
|
1429
|
-
// A MULTIPLIER ALONE IS NOT ENOUGH. 4x on a caller's 40 is 160, which is
|
|
1430
|
-
// still nothing for a model that thinks first � measured, 2 of 3 runs
|
|
1431
|
-
// still returned empty at 160. Reasoning needs an absolute floor, not a
|
|
1432
|
-
// relative bump, so take whichever is larger.
|
|
1433
|
-
if (policy.raised) {
|
|
1434
|
-
log(`reasoning model ${parsed.model}: max_tokens ${policy.raisedFrom} -> ${policy.raisedTo} (thinking shares the budget; OPENZOO_REASONING_MAX_TOKENS_X=1 disables)`);
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
if (policy.tiny || policy.raised || autoRoute || (policy.to && policy.to !== policy.from)) {
|
|
1438
|
-
bodyBuf = Buffer.from(JSON.stringify(parsed));
|
|
1439
|
-
}
|
|
1440
|
-
// Tell the agent what it is actually connected to — in band, where it
|
|
1441
|
-
// will read it, instead of leaving it to guess (and to chunk corpora
|
|
1442
|
-
// it could bind whole). See lib/brief.js.
|
|
1443
|
-
if ((req.url || '').includes('/chat/completions')) {
|
|
1444
|
-
// Tell it the URL it actually reached us on — the public tunnel for
|
|
1445
|
-
// a remote harness, localhost for a local one. An agent that has to
|
|
1446
|
-
// guess its own endpoint guesses a website.
|
|
1447
|
-
const selfUrl = viaTunnel && tunnelGate?.publicUrl
|
|
1448
|
-
? `${tunnelGate.publicUrl}/v1`
|
|
1449
|
-
: `http://localhost:${config.port}/v1`;
|
|
1450
|
-
// NOT ON A YES/NO. The brief is ~2.2KB describing corpus binding,
|
|
1451
|
-
// request-size limits and payment — none of which a tiny call can
|
|
1452
|
-
// use. Claude Code's auto-mode safety classifier asks a 16-token
|
|
1453
|
-
// question before it will run Bash, and it has a short timeout:
|
|
1454
|
-
// MEASURED, that call takes 3.5s cold through here against a 0.09s
|
|
1455
|
-
// gateway 402, and it times out on a machine paying on-chain. Adding
|
|
1456
|
-
// 2.2KB of prose to a body that small is latency and tokens spent on
|
|
1457
|
-
// advice nobody will read.
|
|
1458
|
-
//
|
|
1459
|
-
// Threshold is the same one the spill uses: below it there is no
|
|
1460
|
-
// corpus and nothing the brief could help with. policy.tiny is
|
|
1461
|
-
// that check on the ORIGINAL body, before the reasoning floor.
|
|
1462
|
-
const tiny = policy.tiny
|
|
1463
|
-
|| (bodyBuf.length < BIND_MIN_CHARS
|
|
1464
|
-
&& Number(parsed?.max_tokens ?? 0) > 0
|
|
1465
|
-
&& Number(parsed?.max_tokens) <= CLASSIFY_MAX_TOKENS);
|
|
1466
|
-
const briefed = tiny ? null : injectBrief(parsed, selfUrl);
|
|
1467
|
-
if (briefed) parsed = briefed;
|
|
1468
|
-
// SYSTEM MESSAGES BELONG AT THE FRONT, OR GOOGLE 400s.
|
|
1469
|
-
//
|
|
1470
|
-
// Claude Code emits <system-reminder> blocks mid-conversation, which
|
|
1471
|
-
// is legal for Anthropic natively. Several upstreams behind OpenRouter
|
|
1472
|
-
// are not: fable-5 is served by GOOGLE, whose API takes a system
|
|
1473
|
-
// instruction only before the conversation starts and rejects one
|
|
1474
|
-
// after. CAPTURED live — provider_error code 400,
|
|
1475
|
-
// roles="sssusatatus", 311KB body: two system messages sitting after
|
|
1476
|
-
// user turns, on a model that answers a simple call fine.
|
|
1477
|
-
//
|
|
1478
|
-
// So fold every later system message into the leading block, in
|
|
1479
|
-
// order. The content survives and its position moves; the alternative
|
|
1480
|
-
// is a 400 that ends the turn and tells the caller nothing.
|
|
1481
|
-
const nm = Array.isArray(parsed?.messages) ? parsed.messages : null;
|
|
1482
|
-
if (nm && nm.length > 1) {
|
|
1483
|
-
let lead = 0;
|
|
1484
|
-
while (lead < nm.length && nm[lead]?.role === 'system') lead += 1;
|
|
1485
|
-
const strays = [];
|
|
1486
|
-
const kept = [];
|
|
1487
|
-
nm.forEach((m, i) => {
|
|
1488
|
-
if (i >= lead && m?.role === 'system') strays.push(m);
|
|
1489
|
-
else kept.push(m);
|
|
1490
|
-
});
|
|
1491
|
-
if (strays.length) {
|
|
1492
|
-
const merged = strays.map((m) => (typeof m.content === 'string' ? m.content : msgText(m))).filter(Boolean).join('\n\n');
|
|
1493
|
-
const head = kept.slice(0, lead);
|
|
1494
|
-
const tailMsgs = kept.slice(lead);
|
|
1495
|
-
if (head.length) head[head.length - 1] = { ...head[head.length - 1], content: `${typeof head[head.length - 1].content === 'string' ? head[head.length - 1].content : msgText(head[head.length - 1])}\n\n${merged}` };
|
|
1496
|
-
else head.push({ role: 'system', content: merged });
|
|
1497
|
-
parsed = { ...parsed, messages: [...head, ...tailMsgs] };
|
|
1498
|
-
log(`hoisted ${strays.length} interleaved system message(s) to the leading block (some providers 400 otherwise)`);
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
bodyBuf = Buffer.from(JSON.stringify(parsed));
|
|
1502
|
-
}
|
|
1503
|
-
} catch { /* not JSON */ }
|
|
559
|
+
if (isPaidPost) {
|
|
560
|
+
servedRequests += 1;
|
|
561
|
+
say(`\n<- request #${servedRequests} from ${(req.headers['user-agent'] || 'unknown').slice(0, 40)}`);
|
|
562
|
+
try { wantsStream = JSON.parse(bodyBuf.toString('utf8'))?.stream === true; } catch { /* not JSON */ }
|
|
1504
563
|
}
|
|
1505
564
|
|
|
1506
565
|
// Retry of a body we answered seconds ago? Serve the cached completion —
|
|
1507
|
-
// never pay twice for a harness's reconnect loop.
|
|
1508
|
-
|
|
1509
|
-
// GROUND TRUTH ON THE OUTGOING BODY. Three sessions have now reported "no
|
|
1510
|
-
// actual question or task from you" while the proxy log showed a healthy
|
|
1511
|
-
// forward, and two rounds of reasoning about the cut were wrong. Log what
|
|
1512
|
-
// is actually in messages[] on the way out — roles, and the tail of the
|
|
1513
|
-
// last user turn — so the question stops being a matter of opinion.
|
|
1514
|
-
if (process.env.OPENZOO_LOG_BODY === '1' && isChat) {
|
|
1515
|
-
try {
|
|
1516
|
-
const b = JSON.parse(bodyBuf.toString('utf8'));
|
|
1517
|
-
const ms = Array.isArray(b?.messages) ? b.messages : [];
|
|
1518
|
-
const roles = ms.map((m) => (m.role || '?')[0]).join('');
|
|
1519
|
-
const lastUser = [...ms].reverse().find((m) => m.role === 'user');
|
|
1520
|
-
const txt = lastUser ? String(msgText(lastUser)).slice(-160).replace(/\s+/g, ' ') : '(NO USER MESSAGE)';
|
|
1521
|
-
log(` OUT roles=${roles} n=${ms.length} lastUser="${txt}"`);
|
|
1522
|
-
} catch { /* not json */ }
|
|
1523
|
-
}
|
|
566
|
+
// never pay twice for a harness's reconnect loop. Chat-completions JSON
|
|
567
|
+
// only: it is the one shape we know how to re-emit (including as SSE).
|
|
1524
568
|
const rKey = isChat ? replayKey(bodyBuf, req.headers) : null;
|
|
1525
569
|
if (rKey) {
|
|
1526
570
|
const hit = replayGet(rKey);
|
|
1527
571
|
if (hit) {
|
|
1528
572
|
log('identical request within 30s — served the cached completion, NOT re-paid');
|
|
1529
|
-
// The cache stores the CHAT shape. A Responses caller must get its own
|
|
1530
|
-
// wire format back, or the replay path silently answers in a format the
|
|
1531
|
-
// client cannot parse — a bug that only appears on the SECOND identical
|
|
1532
|
-
// request, which is exactly when nobody is watching.
|
|
1533
|
-
if (responsesMode) {
|
|
1534
|
-
if (wantsStream) { writeResponsesSse(res, hit.data, responsesModel, null, responsesCustom); return; }
|
|
1535
|
-
const rh = { 'content-type': 'application/json' };
|
|
1536
|
-
if (hit.settle) rh['x-payment-response'] = hit.settle;
|
|
1537
|
-
res.writeHead(200, rh);
|
|
1538
|
-
res.end(JSON.stringify(chatToResponses(hit.data, responsesModel, responsesCustom)));
|
|
1539
|
-
return;
|
|
1540
|
-
}
|
|
1541
573
|
if (wantsStream) { serveAsSse(res, hit.data, null); return; }
|
|
1542
574
|
const h = { 'content-type': 'application/json' };
|
|
1543
575
|
if (hit.settle) h['x-payment-response'] = hit.settle;
|
|
@@ -1551,8 +583,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1551
583
|
|
|
1552
584
|
// Harnesses validate their configured model BEFORE ever POSTing — some
|
|
1553
585
|
// list /v1/models, some probe /v1/models/<id>. Both must succeed for the
|
|
1554
|
-
// ids
|
|
1555
|
-
// rewrite never gets its chance.
|
|
586
|
+
// alias ids the launchers write into configs.
|
|
1556
587
|
const path = (req.url || '').split('?')[0];
|
|
1557
588
|
if (req.method === 'GET' && path === '/v1/models') {
|
|
1558
589
|
// Catalog is chrome, not a paid call. Paying the list would put a 402
|
|
@@ -1561,25 +592,18 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1561
592
|
const response = await fetchHeaders(url, init);
|
|
1562
593
|
if (response.ok) {
|
|
1563
594
|
const payload = await response.json();
|
|
1564
|
-
// Quoteable catalog only
|
|
1565
|
-
// $0 / missing prices, and we used to mint openzoo-* twins of each
|
|
1566
|
-
// � Claude Code /model then showed 63 clones and Auto 500'd.
|
|
595
|
+
// Quoteable catalog only: no :batch twins, no $0 rows, no clones.
|
|
1567
596
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1568
597
|
res.end(JSON.stringify(modelsListForRequest(payload, req.headers)));
|
|
1569
598
|
return;
|
|
1570
599
|
}
|
|
1571
600
|
await response.text().catch(() => {});
|
|
1572
|
-
} catch { /* gateway 402/down
|
|
601
|
+
} catch { /* gateway 402/down — serve aliases so chrome still paints */ }
|
|
1573
602
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1574
603
|
res.end(JSON.stringify(modelsListForRequest({ object: 'list', data: [] }, req.headers)));
|
|
1575
604
|
return;
|
|
1576
605
|
}
|
|
1577
606
|
const probe = req.method === 'GET' && /^\/v1\/models\/(.+)$/.exec(path);
|
|
1578
|
-
if (probe && isAutoModel(decodeURIComponent(probe[1]))) {
|
|
1579
|
-
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1580
|
-
res.end(JSON.stringify(autoModelListEntry()));
|
|
1581
|
-
return;
|
|
1582
|
-
}
|
|
1583
607
|
if (probe && isHarnessAliasId(decodeURIComponent(probe[1]))) {
|
|
1584
608
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1585
609
|
res.end(JSON.stringify({ id: decodeURIComponent(probe[1]), object: 'model', owned_by: 'openzoo-alias' }));
|
|
@@ -1587,127 +611,24 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1587
611
|
}
|
|
1588
612
|
|
|
1589
613
|
try {
|
|
1590
|
-
|
|
1591
|
-
try {
|
|
1592
|
-
// Spill/adapt diagnostics (adapt, file-stub, sending N/M) must hit
|
|
1593
|
-
// ~/.openzoo/proxy.log when we are silent. `log` is a no-op then.
|
|
1594
|
-
cached = await maybeCacheCorpus(req, bodyBuf, say, spill, {
|
|
1595
|
-
lastSend: lastSpillSend?.sent,
|
|
1596
|
-
dollarX: hudDollarX({
|
|
1597
|
-
spillDirect: spill.spillDirect,
|
|
1598
|
-
spillSpend: spill.spillSpend,
|
|
1599
|
-
sessionDirect,
|
|
1600
|
-
sessionSpent,
|
|
1601
|
-
}),
|
|
1602
|
-
});
|
|
1603
|
-
} catch (err) {
|
|
1604
|
-
log(`context cache skipped for this call: ${err.message}`);
|
|
1605
|
-
}
|
|
1606
|
-
const send = (buf, ctxId, topK, corpusChars) => client.fetch(url, {
|
|
1607
|
-
...init,
|
|
1608
|
-
body: buf,
|
|
1609
|
-
headers: ctxId
|
|
1610
|
-
? {
|
|
1611
|
-
...init.headers,
|
|
1612
|
-
'x-hrr-context': ctxId,
|
|
1613
|
-
// Only on the spill path — a caller that set its own top-k keeps it.
|
|
1614
|
-
...(topK ? { 'x-hrr-top-k': String(topK) } : {}),
|
|
1615
|
-
// TELL THE GATEWAY HOW BIG THE CORPUS IS. It has been guessing:
|
|
1616
|
-
// `contextChars` is populated only when a bind passes through the
|
|
1617
|
-
// gateway itself, so on an APPEND — and on every corpus that
|
|
1618
|
-
// includes files the agent read, which never appear in a request
|
|
1619
|
-
// body at all — it has no idea and falls back to the body size.
|
|
1620
|
-
// That is why the counterfactual logged `corpus ?` all day and why
|
|
1621
|
-
// a call whose corpus held 40,777 tokens priced as if it held
|
|
1622
|
-
// 4,303. The proxy assembled the corpus; it is the only party that
|
|
1623
|
-
// knows.
|
|
1624
|
-
...(corpusChars ? { 'x-hrr-corpus-chars': String(corpusChars) } : {}),
|
|
1625
|
-
}
|
|
1626
|
-
: init.headers,
|
|
1627
|
-
});
|
|
1628
|
-
let didSpill = false;
|
|
1629
|
-
let result;
|
|
1630
|
-
if (cached) {
|
|
1631
|
-
spill.noteSpill({ corpusChars: cached.corpus?.length || 0, reused: cached.reused });
|
|
1632
|
-
didSpill = true;
|
|
1633
|
-
if (cached.sent != null) lastSpillSend = { sent: cached.sent, msgs: cached.msgs };
|
|
1634
|
-
result = await send(cached.body, cached.contextId, cached.topK, corpusCharsForSend(boundChars, cached.contextId, cached.corpus?.length));
|
|
1635
|
-
// Sidecar wiped between runs: the gateway 404s BEFORE the 402 (nothing
|
|
1636
|
-
// paid). Never fail on a stale manifest — re-bind once and retry.
|
|
1637
|
-
if (result.response.status === 404) {
|
|
1638
|
-
const text = await result.response.text();
|
|
1639
|
-
if (/context_not_found/.test(text)) {
|
|
1640
|
-
log('bound context is gone on the zoo — re-binding once...');
|
|
1641
|
-
forgetContext(config.apiBase, cached.hash);
|
|
1642
|
-
const rebound = await bindCorpus(cached.corpus, { force: true });
|
|
1643
|
-
result = await send(cached.body, rebound.contextId, cached.topK, corpusCharsForSend(boundChars, rebound.contextId, cached.corpus?.length));
|
|
1644
|
-
} else {
|
|
1645
|
-
res.writeHead(404, { 'content-type': 'application/json' });
|
|
1646
|
-
res.end(text);
|
|
1647
|
-
return;
|
|
1648
|
-
}
|
|
1649
|
-
}
|
|
1650
|
-
} else {
|
|
1651
|
-
result = await client.fetch(url, init);
|
|
1652
|
-
}
|
|
1653
|
-
// Walk the auto shortlist on 429/5xx � cheapest-first among models that
|
|
1654
|
-
// cleared the bar. Finite: one pass over fallbackChain(), never a loop.
|
|
1655
|
-
if (autoRoute?.cleared_bar && isRetryableStatus(result.response.status)) {
|
|
1656
|
-
const chain = fallbackChain(autoRoute);
|
|
1657
|
-
for (const next of chain) {
|
|
1658
|
-
const failed = result.response.status;
|
|
1659
|
-
try { await result.response.arrayBuffer(); } catch { /* drain */ }
|
|
1660
|
-
say(`openzoo/auto fallback HTTP ${failed} -> ${next}`);
|
|
1661
|
-
const rewriteModel = (buf) => {
|
|
1662
|
-
try {
|
|
1663
|
-
const b = JSON.parse(buf.toString('utf8'));
|
|
1664
|
-
b.model = next;
|
|
1665
|
-
return Buffer.from(JSON.stringify(b));
|
|
1666
|
-
} catch { return buf; }
|
|
1667
|
-
};
|
|
1668
|
-
bodyBuf = rewriteModel(bodyBuf);
|
|
1669
|
-
init.body = bodyBuf;
|
|
1670
|
-
if (cached) {
|
|
1671
|
-
cached = { ...cached, body: rewriteModel(cached.body) };
|
|
1672
|
-
result = await send(cached.body, cached.contextId, cached.topK, corpusCharsForSend(boundChars, cached.contextId, cached.corpus?.length));
|
|
1673
|
-
} else {
|
|
1674
|
-
result = await client.fetch(url, { ...init, body: bodyBuf });
|
|
1675
|
-
}
|
|
1676
|
-
if (!isRetryableStatus(result.response.status)) break;
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
614
|
+
const result = await client.fetch(url, init);
|
|
1679
615
|
const { response, paid, receipt } = result;
|
|
1680
616
|
if (paid && receipt) {
|
|
1681
617
|
if (receipt.ok && typeof receipt.billedUsd === 'number') {
|
|
1682
618
|
sessionSpent += receipt.billedUsd;
|
|
1683
|
-
// Wallet path: no 3x. Prefer extra.cogsUsd / billedUsd / directUsd /
|
|
1684
|
-
// savedUsd from the 402. billedUsd is the OpenRouter price (plus
|
|
1685
|
-
// zoo's 33% of savings when the caller beat direct).
|
|
1686
619
|
sessionCogs += receiptUsedCogs(receipt);
|
|
1687
620
|
noteQuote(receipt);
|
|
1688
|
-
// direct = what answering this WITHOUT the zoo would have cost. On an
|
|
1689
|
-
// attach call that is the whole bound corpus, which is why it can be
|
|
1690
|
-
// orders of magnitude above what was billed. Read extra.directUsd /
|
|
1691
|
-
// extra.savedUsd; do not invent billed * 3.
|
|
1692
|
-
if (didSpill) {
|
|
1693
|
-
spill.spillSpend += receipt.billedUsd || 0;
|
|
1694
|
-
spill.spillDirect += receiptDirectUsd(receipt);
|
|
1695
|
-
}
|
|
1696
621
|
sessionDirect += receiptDirectUsd(receipt);
|
|
1697
622
|
// The public-URL ceiling meters only public-origin spend — your own
|
|
1698
623
|
// local calls never eat into it.
|
|
1699
624
|
if (viaTunnel) tunnelSpent += receipt.billedUsd;
|
|
1700
625
|
}
|
|
1701
626
|
const line = receipt.ok ? receipt.line : `paid retry -> HTTP ${receipt.status}`;
|
|
1702
|
-
// Wherever a running total is the thing to watch, it rides the receipt.
|
|
1703
627
|
if (requireToken) say(`${line} · session $${sessionSpent.toFixed(6)}`);
|
|
1704
628
|
else if (viaTunnel) say(`${line} · public-url session $${tunnelSpent.toFixed(6)}`);
|
|
1705
629
|
else say(line);
|
|
1706
|
-
// ALWAYS-ON SPEND, TUI-SAFE
|
|
1707
|
-
//
|
|
1708
|
-
// total should still be visible — so write it to the terminal TITLE via
|
|
1709
|
-
// an OSC escape, which updates the window/tab title without touching the
|
|
1710
|
-
// TUI's content. `openzoo ◝ $0.0042 · 12 calls` in the title bar, live.
|
|
630
|
+
// ALWAYS-ON SPEND, TUI-SAFE: running total in the terminal TITLE via
|
|
631
|
+
// an OSC escape, never in a TUI's content.
|
|
1711
632
|
if (receipt.ok && typeof receipt.billedUsd === 'number') { paidCalls += 1; }
|
|
1712
633
|
if (receipt.ok && typeof receipt.billedUsd === 'number') rememberSpend();
|
|
1713
634
|
if (sayFile) {
|
|
@@ -1715,39 +636,15 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1715
636
|
}
|
|
1716
637
|
scheduleRefresh(4000); // settlement lands on-chain in a few seconds
|
|
1717
638
|
}
|
|
1718
|
-
// Chat completions come back as one JSON object (settle-before-serve).
|
|
1719
|
-
// Cache it against retries, and if the harness asked to stream, honour
|
|
1720
|
-
// that contract ourselves. An upstream that someday truly streams (SSE
|
|
1721
|
-
// content-type) passes straight through the relay below, untouched.
|
|
1722
639
|
const upCt = response.headers.get('content-type') || '';
|
|
1723
|
-
|
|
1724
|
-
if (!autoRoute) return;
|
|
1725
|
-
let ok = outcomeFromResponse(status, data);
|
|
1726
|
-
if (ok == null) return;
|
|
1727
|
-
if (streamed && status >= 200 && status < 300) ok = true;
|
|
1728
|
-
recordRouteOutcome(autoRoute, ok);
|
|
1729
|
-
};
|
|
1730
|
-
if (isChat && response.ok && upCt.includes('application/json')) {
|
|
640
|
+
if (isPaidPost && response.ok && upCt.includes('application/json')) {
|
|
1731
641
|
let data = null;
|
|
1732
642
|
try { data = await response.clone().json(); } catch { /* not JSON after all */ }
|
|
1733
|
-
// WHAT IT REALLY COST, straight from the provider
|
|
1734
|
-
// completion already
|
|
1735
|
-
// /api/v1/credits total it is attributable to THIS proxy even though the
|
|
1736
|
-
// same OpenRouter key also pays for ttfx and everything else.
|
|
643
|
+
// WHAT IT REALLY COST, straight from the provider — rides every
|
|
644
|
+
// completion already; no extra call.
|
|
1737
645
|
{
|
|
1738
|
-
// PAIR THE NUMERATOR WITH THE DENOMINATOR.
|
|
1739
|
-
//
|
|
1740
|
-
// by the SUBSET that reported a real cost � a 402-receipt call added
|
|
1741
|
-
// to billed and nothing to real, and the ratio read 12.55x on a stack
|
|
1742
|
-
// running at ~1.0x. Track the billed side of exactly the calls whose
|
|
1743
|
-
// cost we actually learned.
|
|
1744
|
-
// Both figures ride the SAME response object, so read them together
|
|
1745
|
-
// rather than carrying one across sites and hoping the order holds.
|
|
1746
|
-
//
|
|
1747
|
-
// x402.billedUsd is often the QUOTE reserve (max_tokens � catalog),
|
|
1748
|
-
// not the settled charge. MEASURED: $0.9858 reserved vs $0.007962
|
|
1749
|
-
// usage.cost -> markupX lied at 124x on a ~1x call. Pair usage.cost
|
|
1750
|
-
// with post-completion billed, never the reserve.
|
|
646
|
+
// PAIR THE NUMERATOR WITH THE DENOMINATOR: usage.cost with the
|
|
647
|
+
// post-completion billed twin, never the quote reserve.
|
|
1751
648
|
const pair = pairActualBilled(data?.x402, data?.usage);
|
|
1752
649
|
if (pair) {
|
|
1753
650
|
sessionActual += pair.upstreamUsd;
|
|
@@ -1755,64 +652,22 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1755
652
|
billedWithActual += pair.billedUsd;
|
|
1756
653
|
}
|
|
1757
654
|
}
|
|
1758
|
-
// PREPAID CALLS STILL COST MONEY.
|
|
1759
|
-
//
|
|
1760
|
-
//
|
|
1761
|
-
// no 402, no payment and no receipt — and the session read $0.05 / 2
|
|
1762
|
-
// calls while the credit balance had actually fallen $3.017 -> $1.395
|
|
1763
|
-
// over a 30-question run. The receipt still rides the response body,
|
|
1764
|
-
// so meter it from there.
|
|
655
|
+
// PREPAID CALLS STILL COST MONEY. When prepaid credit covers the quote
|
|
656
|
+
// the gateway serves 200 on the FIRST request — no 402, no payment, no
|
|
657
|
+
// receipt. The receipt still rides the response body, so meter it there.
|
|
1765
658
|
if (!paid && data?.x402 && typeof data.x402.billedUsd === 'number') {
|
|
1766
659
|
const x = data.x402;
|
|
1767
660
|
sessionSpent += x.billedUsd;
|
|
1768
661
|
sessionCogs += receiptUsedCogs(x);
|
|
1769
662
|
noteQuote(x);
|
|
1770
663
|
sessionDirect += receiptDirectUsd(x);
|
|
1771
|
-
if (didSpill) {
|
|
1772
|
-
// THE number that settles why a spilled call did or did not save:
|
|
1773
|
-
// the gateway only prices a counterfactual when corpusTokens >
|
|
1774
|
-
// promptTokens, so a tail that rivals the corpus silently falls
|
|
1775
|
-
// back to at-cost and direct collapses onto billed.
|
|
1776
|
-
// Tell-line prints the gateway's actual counterfactual only.
|
|
1777
|
-
// Never fall back to lecore.corpusTokens (often == tokensBefore).
|
|
1778
|
-
log(spillPricedLine(x));
|
|
1779
|
-
spill.spillSpend += x.billedUsd;
|
|
1780
|
-
spill.spillDirect += receiptDirectUsd(x);
|
|
1781
|
-
}
|
|
1782
664
|
paidCalls += 1;
|
|
1783
665
|
if (viaTunnel) tunnelSpent += x.billedUsd;
|
|
1784
666
|
rememberSpend();
|
|
1785
667
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
1786
668
|
}
|
|
1787
669
|
if (data?.object === 'chat.completion') {
|
|
1788
|
-
noteAutoOutcome(response.status, data);
|
|
1789
670
|
if (rKey) replayPut(rKey, data, response.headers.get('x-payment-response'));
|
|
1790
|
-
// Anthropic-shaped caller gets an Anthropic-shaped answer, streamed
|
|
1791
|
-
// or not, so Claude Code and the SDKs parse it natively.
|
|
1792
|
-
if (anthropicMode) {
|
|
1793
|
-
const msg = openAIToAnthropic(data, anthropicModel);
|
|
1794
|
-
if (wantsStream) { writeAnthropicSse(res, msg, response); return; }
|
|
1795
|
-
const h = { 'content-type': 'application/json' };
|
|
1796
|
-
const settleHdr = response.headers.get('x-payment-response');
|
|
1797
|
-
if (settleHdr) h['x-payment-response'] = settleHdr;
|
|
1798
|
-
res.writeHead(200, h);
|
|
1799
|
-
res.end(JSON.stringify(msg));
|
|
1800
|
-
return;
|
|
1801
|
-
}
|
|
1802
|
-
if (responsesMode) {
|
|
1803
|
-
// A Responses client that asked to stream is WAITING for
|
|
1804
|
-
// `response.completed`; handing it a JSON body closes the socket
|
|
1805
|
-
// mid-stream and it reports "stream disconnected before
|
|
1806
|
-
// completion". Honour the streaming contract when it asked for it.
|
|
1807
|
-
if (wantsStream) { writeResponsesSse(res, data, responsesModel, response, responsesCustom); return; }
|
|
1808
|
-
const out = chatToResponses(data, responsesModel, responsesCustom);
|
|
1809
|
-
const h = { 'content-type': 'application/json' };
|
|
1810
|
-
const settleHdr = response.headers.get('x-payment-response');
|
|
1811
|
-
if (settleHdr) h['x-payment-response'] = settleHdr;
|
|
1812
|
-
res.writeHead(200, h);
|
|
1813
|
-
res.end(JSON.stringify(out));
|
|
1814
|
-
return;
|
|
1815
|
-
}
|
|
1816
671
|
if (wantsStream) { serveAsSse(res, data, response); return; }
|
|
1817
672
|
const h = { 'content-type': 'application/json' };
|
|
1818
673
|
const settleHdr = response.headers.get('x-payment-response');
|
|
@@ -1826,9 +681,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1826
681
|
// same figures the JSON path reads out of `data.x402`, same counters, so
|
|
1827
682
|
// the status line does not care which transport served the answer.
|
|
1828
683
|
const meterStreamed = (x) => {
|
|
1829
|
-
// Same pairing rule as the JSON path: actualUsd / usage.cost with the
|
|
1830
|
-
// settled billed twin, even on a wallet-paid stream (do not skip just
|
|
1831
|
-
// because `paid` already recorded the quote-time receipt).
|
|
1832
684
|
const pair = pairActualBilled(x, x?.usage);
|
|
1833
685
|
if (pair) {
|
|
1834
686
|
sessionActual += pair.upstreamUsd;
|
|
@@ -1840,86 +692,11 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1840
692
|
sessionCogs += receiptUsedCogs(x);
|
|
1841
693
|
noteQuote(x);
|
|
1842
694
|
sessionDirect += receiptDirectUsd(x);
|
|
1843
|
-
if (didSpill) {
|
|
1844
|
-
log(spillPricedLine(x, { streamed: true }));
|
|
1845
|
-
spill.spillSpend += x.billedUsd;
|
|
1846
|
-
spill.spillDirect += receiptDirectUsd(x);
|
|
1847
|
-
}
|
|
1848
695
|
paidCalls += 1;
|
|
1849
696
|
if (viaTunnel) tunnelSpent += x.billedUsd;
|
|
1850
697
|
rememberSpend();
|
|
1851
698
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
1852
699
|
};
|
|
1853
|
-
|
|
1854
|
-
// RESPONSES CLIENTS (Codex Security) GET THE SAME STREAM, IN THEIR GRAMMAR.
|
|
1855
|
-
// Without this, relay() pipes OpenAI SSE frames at a client waiting for
|
|
1856
|
-
// response.created, and the 10s watchdog fires ("connection interrupted").
|
|
1857
|
-
if (responsesMode && wantsStream
|
|
1858
|
-
&& (response.headers.get('content-type') || '').includes('text/event-stream')
|
|
1859
|
-
&& response.ok && response.body) {
|
|
1860
|
-
const tr = streamOpenAIToResponses(res, response, responsesModel, responsesCustom);
|
|
1861
|
-
let pending = '';
|
|
1862
|
-
const body = Readable.fromWeb(response.body);
|
|
1863
|
-
res.on('close', () => body.destroy());
|
|
1864
|
-
try {
|
|
1865
|
-
for await (const c of body) {
|
|
1866
|
-
pending += c.toString('utf8');
|
|
1867
|
-
const lines = pending.split('\n');
|
|
1868
|
-
pending = lines.pop() ?? '';
|
|
1869
|
-
for (const line of lines) {
|
|
1870
|
-
if (line.startsWith(': x402 ')) {
|
|
1871
|
-
try { meterStreamed(JSON.parse(line.slice(7))); } catch { /* not ours */ }
|
|
1872
|
-
continue;
|
|
1873
|
-
}
|
|
1874
|
-
if (!line.startsWith('data:')) continue;
|
|
1875
|
-
const payload = line.slice(5).trim();
|
|
1876
|
-
if (!payload || payload === '[DONE]') continue;
|
|
1877
|
-
try { tr.onChunk(JSON.parse(payload)); } catch { /* frame split */ }
|
|
1878
|
-
}
|
|
1879
|
-
}
|
|
1880
|
-
} catch (e) {
|
|
1881
|
-
log(`responses stream aborted: ${e.message}`);
|
|
1882
|
-
}
|
|
1883
|
-
tr.finish();
|
|
1884
|
-
return;
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
// ANTHROPIC CLIENTS GET THE SAME STREAM, IN THEIR OWN GRAMMAR.
|
|
1888
|
-
// Translated frame by frame rather than buffered, so Claude Code sees
|
|
1889
|
-
// tokens as they are produced instead of nothing until the turn ends.
|
|
1890
|
-
if (anthropicMode && wantsStream
|
|
1891
|
-
&& (response.headers.get('content-type') || '').includes('text/event-stream')
|
|
1892
|
-
&& response.ok && response.body) {
|
|
1893
|
-
const tr = streamOpenAIToAnthropic(res, response, anthropicModel, meterStreamed);
|
|
1894
|
-
let pending = '';
|
|
1895
|
-
const body = Readable.fromWeb(response.body);
|
|
1896
|
-
res.on('close', () => body.destroy());
|
|
1897
|
-
try {
|
|
1898
|
-
for await (const c of body) {
|
|
1899
|
-
pending += c.toString('utf8');
|
|
1900
|
-
const lines = pending.split('\n');
|
|
1901
|
-
pending = lines.pop() ?? '';
|
|
1902
|
-
for (const line of lines) {
|
|
1903
|
-
if (line.startsWith(': x402 ')) {
|
|
1904
|
-
try { meterStreamed(JSON.parse(line.slice(7))); } catch { /* not ours */ }
|
|
1905
|
-
continue;
|
|
1906
|
-
}
|
|
1907
|
-
if (!line.startsWith('data:')) continue;
|
|
1908
|
-
const payload = line.slice(5).trim();
|
|
1909
|
-
if (!payload || payload === '[DONE]') continue;
|
|
1910
|
-
try { tr.onChunk(JSON.parse(payload)); } catch { /* frame split across chunks */ }
|
|
1911
|
-
}
|
|
1912
|
-
}
|
|
1913
|
-
} catch (e) {
|
|
1914
|
-
log(`anthropic stream aborted: ${e.message}`);
|
|
1915
|
-
}
|
|
1916
|
-
tr.finish();
|
|
1917
|
-
return;
|
|
1918
|
-
}
|
|
1919
|
-
|
|
1920
|
-
if (autoRoute) {
|
|
1921
|
-
noteAutoOutcome(response.status, null, (upCt.includes('text/event-stream') && response.ok));
|
|
1922
|
-
}
|
|
1923
700
|
await relay(res, response, meterStreamed);
|
|
1924
701
|
} catch (err) {
|
|
1925
702
|
if (err instanceof QuoteTooHighError) {
|
|
@@ -1930,8 +707,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1930
707
|
jsonErr(res, 402, err.message);
|
|
1931
708
|
} else {
|
|
1932
709
|
// "fetch failed" alone is undiagnosable — undici hides the real
|
|
1933
|
-
// network error in `cause`. Surface it
|
|
1934
|
-
//
|
|
710
|
+
// network error in `cause`. Surface it or every transport hiccup looks
|
|
711
|
+
// identical to a payment bug.
|
|
1935
712
|
const cause = err.cause?.message || err.cause?.code || err.cause;
|
|
1936
713
|
const raw = cause ? `${err.message} (${cause})` : err.message;
|
|
1937
714
|
const detail = raw;
|
|
@@ -1943,22 +720,15 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1943
720
|
});
|
|
1944
721
|
|
|
1945
722
|
// BIND HOST. Default 127.0.0.1 — the keyless localhost path must never be
|
|
1946
|
-
// world-reachable on an ordinary machine. A RunPod box
|
|
1947
|
-
//
|
|
1948
|
-
//
|
|
1949
|
-
// Ready, the 127.0.0.1 proxy never did). The box sets OPENZOO_BIND=0.0.0.0
|
|
1950
|
-
// AND a tunnel token, so the RunPod-fronted port stays gated exactly like the
|
|
1951
|
-
// public tunnel path.
|
|
723
|
+
// world-reachable on an ordinary machine. A RunPod box sets
|
|
724
|
+
// OPENZOO_BIND=0.0.0.0 AND a tunnel token, so that port stays gated exactly
|
|
725
|
+
// like the public tunnel path.
|
|
1952
726
|
const bindHost = process.env.OPENZOO_BIND || '127.0.0.1';
|
|
1953
|
-
// SELF-HEAL A TAKEN PORT.
|
|
1954
|
-
//
|
|
1955
|
-
//
|
|
1956
|
-
//
|
|
1957
|
-
//
|
|
1958
|
-
// every URL printed afterwards is the one we actually bound.
|
|
1959
|
-
//
|
|
1960
|
-
// EXCEPTION: if the thing already on the port is a HEALTHY openzoo proxy,
|
|
1961
|
-
// reuse it rather than starting a rival that splits spend across two wallets.
|
|
727
|
+
// SELF-HEAL A TAKEN PORT. Walk up to the next free port instead of dying;
|
|
728
|
+
// the caller reads config.port back out, so every URL printed afterwards is
|
|
729
|
+
// the one we actually bound. EXCEPTION: if the thing already on the port is
|
|
730
|
+
// a HEALTHY openzoo proxy, reuse it rather than starting a rival that splits
|
|
731
|
+
// spend across two wallets.
|
|
1962
732
|
const wanted = config.port;
|
|
1963
733
|
for (let attempt = 0; ; attempt++) {
|
|
1964
734
|
try {
|
|
@@ -1985,23 +755,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1985
755
|
}
|
|
1986
756
|
if (config.port !== wanted) say(`openzoo: listening on :${config.port} (:${wanted} was busy)`);
|
|
1987
757
|
|
|
1988
|
-
// AUTO-PREPAY. Paying on-chain per call is where the latency lives:
|
|
1989
|
-
//
|
|
1990
|
-
//
|
|
1991
|
-
//
|
|
1992
|
-
// call skip verify+settle entirely.
|
|
1993
|
-
//
|
|
1994
|
-
// Runs in the background — never block the listener on a payment — and only
|
|
1995
|
-
// when this wallet actually has funds, so a fresh/empty wallet is untouched.
|
|
758
|
+
// AUTO-PREPAY. Paying on-chain per call is where the latency lives: credit
|
|
759
|
+
// is applied automatically server-side whenever a balance covers the quote,
|
|
760
|
+
// so buying it once makes every later call skip verify+settle entirely.
|
|
761
|
+
// Runs in the background and only when this wallet actually has funds.
|
|
1996
762
|
// Opt out with OPENZOO_NO_AUTOTOPUP=1; size it with OPENZOO_AUTOTOPUP_USD.
|
|
1997
763
|
if (!process.env.OPENZOO_NO_AUTOTOPUP) {
|
|
1998
|
-
// Keep credit topped up, forever, from whatever the wallet holds.
|
|
1999
|
-
//
|
|
2000
|
-
// The first version ran ONCE at startup and bought a fixed $5, so funding
|
|
2001
|
-
// the wallet later did nothing at all — the user sent TOKEN and kept
|
|
2002
|
-
// paying on-chain per call. This checks on an interval and spends what the
|
|
2003
|
-
// wallet can actually cover, priced by the gateway's own live quote (so
|
|
2004
|
-
// TOKEN is valued exactly as it settles).
|
|
2005
764
|
const FLOOR = Number(process.env.OPENZOO_AUTOTOPUP_FLOOR || 2);
|
|
2006
765
|
const EVERY = Number(process.env.OPENZOO_AUTOTOPUP_EVERY_MS || 60_000);
|
|
2007
766
|
let topping = false;
|
|
@@ -2029,19 +788,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
2029
788
|
|
|
2030
789
|
if (!silent) {
|
|
2031
790
|
// VERSION IN THE BANNER, deliberately. `npx openzoo` can serve a STALE
|
|
2032
|
-
// cached copy
|
|
2033
|
-
// user running the newest published version still gets old behaviour and
|
|
2034
|
-
// no clue why (observed: a missing tunnel and a missing token row, both
|
|
2035
|
-
// "fixed" releases ago). Printing the version makes that one glance.
|
|
791
|
+
// cached copy; printing the version makes that one glance.
|
|
2036
792
|
const { version } = JSON.parse(
|
|
2037
793
|
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
2038
794
|
);
|
|
2039
795
|
console.log(`openzoo v${version} -> ${config.apiBase}`);
|
|
2040
796
|
console.log(`listening on http://localhost:${config.port}/v1`);
|
|
2041
|
-
// LAND THEM IN THE APP.
|
|
2042
|
-
//
|
|
2043
|
-
// to notice. Never in CI/agents (no TTY), never twice, opt out with
|
|
2044
|
-
// OPENZOO_NO_OPEN=1.
|
|
797
|
+
// LAND THEM IN THE APP. Never in CI/agents (no TTY), never twice, opt out
|
|
798
|
+
// with OPENZOO_NO_OPEN=1.
|
|
2045
799
|
if (process.stdout.isTTY && !process.env.OPENZOO_NO_OPEN) {
|
|
2046
800
|
const opener = process.platform === 'darwin' ? 'open'
|
|
2047
801
|
: process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
@@ -2061,10 +815,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
2061
815
|
} catch { /* RPC hiccup: balance is advisory */ }
|
|
2062
816
|
refreshCredit();
|
|
2063
817
|
refreshPrices();
|
|
2064
|
-
// LIVE REFRESH:
|
|
2065
|
-
//
|
|
2066
|
-
// paid call), print ONLY on change, and call out arrivals explicitly so
|
|
2067
|
-
// "did my top-up land?" answers itself in the running log.
|
|
818
|
+
// LIVE REFRESH: poll on an interval (and shortly after each paid call),
|
|
819
|
+
// print ONLY on change, and call out arrivals explicitly.
|
|
2068
820
|
refreshBalances = async () => {
|
|
2069
821
|
try {
|
|
2070
822
|
const snap = await snapshotBalances(client);
|
|
@@ -2094,12 +846,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
2094
846
|
const rails = await liveRails();
|
|
2095
847
|
if (rails) {
|
|
2096
848
|
console.log(`rails live now: ${rails.live.join(' · ')}`);
|
|
2097
|
-
// Funding advice is derived from those rails, never hardcoded — the
|
|
2098
|
-
// zoo can add a chain without this package shipping again.
|
|
2099
849
|
const hint = railFundingHint(rails.live);
|
|
2100
850
|
if (hint) console.log(`fund with: ${hint}`);
|
|
2101
|
-
// The exact contracts those symbols mean — every chain has impersonator
|
|
2102
|
-
// mints, so a symbol without its CA is an invitation to fund the wrong one.
|
|
2103
851
|
for (const row of railFundingAddresses(rails.live)) {
|
|
2104
852
|
row.assets.forEach((a, i) => {
|
|
2105
853
|
const label = i === 0 ? row.label : '';
|
|
@@ -2143,10 +891,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
2143
891
|
process.once('SIGTERM', () => { bye(); process.exit(0); });
|
|
2144
892
|
process.once('exit', bye);
|
|
2145
893
|
log('');
|
|
2146
|
-
if (boundCharsRestored) {
|
|
2147
|
-
const tot = [...boundChars.values()].reduce((a, b) => a + b, 0);
|
|
2148
|
-
log(`corpus ledger restored: ${boundCharsRestored} context(s), ${mb(tot)}MB bound — counterfactual survives restarts`);
|
|
2149
|
-
}
|
|
2150
894
|
log('cloud IDE / remote harness? use the public URL (they cannot reach localhost):');
|
|
2151
895
|
log(` base_url = ${url}/v1`);
|
|
2152
896
|
log(` api_key = ${token}`);
|
|
@@ -2154,17 +898,15 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
2154
898
|
log(' OPENZOO_NO_TUNNEL=1 for localhost-only)');
|
|
2155
899
|
} catch (err) {
|
|
2156
900
|
// Record it: a caller polling for publicUrl (openzoo cursor) would
|
|
2157
|
-
// otherwise spin the full timeout on a tunnel that already died
|
|
2158
|
-
// silent:true swallowing this very message.
|
|
901
|
+
// otherwise spin the full timeout on a tunnel that already died.
|
|
2159
902
|
tunnelError = err.message;
|
|
2160
903
|
log(`public URL unavailable (${err.message}) — localhost still works; OPENZOO_NO_TUNNEL=1 hides this line`);
|
|
2161
904
|
}
|
|
2162
905
|
})();
|
|
2163
906
|
}
|
|
2164
907
|
// Expose live tunnel details so a caller that starts the proxy in-process
|
|
2165
|
-
// (openzoo cursor/vscode) can surface the public URL + key
|
|
2166
|
-
//
|
|
2167
|
-
// this returns — a snapshot would always be null.
|
|
908
|
+
// (openzoo cursor/vscode) can surface the public URL + key. Getters, because
|
|
909
|
+
// the tunnel resolves ASYNC after this returns.
|
|
2168
910
|
return {
|
|
2169
911
|
server,
|
|
2170
912
|
client,
|