openzoo 0.49.18 → 0.50.1
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 +112 -1377
- 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/xburner.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A managed x402 burner per X account, DERIVED not stored.
|
|
3
|
+
*
|
|
4
|
+
* @openzoobot's paid lane needs the ASKER to pay, and an X reply is a terrible
|
|
5
|
+
* place to ask someone to install a wallet. So each X account id gets a burner
|
|
6
|
+
* the zoo drives on their behalf, funded by them and auto-topped-up, holding
|
|
7
|
+
* only a working balance — the same shape as the local burner `npx openzoo`
|
|
8
|
+
* already creates, one per account instead of one per machine.
|
|
9
|
+
*
|
|
10
|
+
* DERIVED, NOT STORED, and that is the whole security argument:
|
|
11
|
+
* seed(user) = HMAC-SHA512(master, "openzoo-xbot-v1:" + userId)
|
|
12
|
+
* There is exactly ONE secret on disk no matter how many accounts ever mention
|
|
13
|
+
* the bot. A per-user keyfile store would mean thousands of secrets, a backup
|
|
14
|
+
* problem, a deletion problem, and a breach that scales with adoption. Here the
|
|
15
|
+
* blast radius is one file that already had to be protected, and a burner can
|
|
16
|
+
* be re-derived on any machine from that file alone — nothing to lose, nothing
|
|
17
|
+
* to migrate, no keypair that exists only on whichever laptop ran the poller.
|
|
18
|
+
*
|
|
19
|
+
* The tradeoff, stated plainly: the master file CAN derive every burner, so it
|
|
20
|
+
* is as sensitive as all of them combined. That is why it is 0600, never
|
|
21
|
+
* logged, never sent anywhere, and why balances are kept at working size by
|
|
22
|
+
* auto top-up rather than being allowed to accumulate.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import crypto from 'node:crypto';
|
|
29
|
+
import { Keypair } from '@solana/web3.js';
|
|
30
|
+
|
|
31
|
+
const MASTER_FILE = process.env.OPENZOO_XBOT_MASTER
|
|
32
|
+
|| path.join(os.homedir(), '.openzoo', 'xbot-master.key');
|
|
33
|
+
|
|
34
|
+
/** Bump if the derivation ever changes — old burners must keep deriving. */
|
|
35
|
+
const DERIVATION = 'openzoo-xbot-v1';
|
|
36
|
+
|
|
37
|
+
export function loadOrCreateMaster(file = MASTER_FILE) {
|
|
38
|
+
try {
|
|
39
|
+
const hex = fs.readFileSync(file, 'utf8').trim();
|
|
40
|
+
const buf = Buffer.from(hex, 'hex');
|
|
41
|
+
if (buf.length === 32) return buf;
|
|
42
|
+
throw new Error('bad length');
|
|
43
|
+
} catch {
|
|
44
|
+
const buf = crypto.randomBytes(32);
|
|
45
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
46
|
+
// wx: never clobber an existing master. Overwriting it would orphan every
|
|
47
|
+
// burner ever handed out — funds still on-chain, key unrecoverable.
|
|
48
|
+
try {
|
|
49
|
+
fs.writeFileSync(file, buf.toString('hex') + '\n', { mode: 0o600, flag: 'wx' });
|
|
50
|
+
} catch {
|
|
51
|
+
return Buffer.from(fs.readFileSync(file, 'utf8').trim(), 'hex');
|
|
52
|
+
}
|
|
53
|
+
return buf;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Deterministic burner for an X user id.
|
|
59
|
+
* Keyed on the numeric id, never the handle: handles are reassignable, and a
|
|
60
|
+
* burner that follows a renamed handle would hand a new owner the old wallet.
|
|
61
|
+
*/
|
|
62
|
+
export function deriveBurner(xUserId, master = loadOrCreateMaster()) {
|
|
63
|
+
if (!xUserId) throw new Error('deriveBurner needs an X user id');
|
|
64
|
+
const mac = crypto.createHmac('sha512', master)
|
|
65
|
+
.update(`${DERIVATION}:${String(xUserId)}`)
|
|
66
|
+
.digest();
|
|
67
|
+
const keypair = Keypair.fromSeed(Uint8Array.from(mac.subarray(0, 32)));
|
|
68
|
+
const evmPrivateKey = `0x${mac.subarray(32, 64).toString('hex')}`;
|
|
69
|
+
return {
|
|
70
|
+
keypair,
|
|
71
|
+
evmPrivateKey,
|
|
72
|
+
address: keypair.publicKey.toBase58(),
|
|
73
|
+
xUserId: String(xUserId),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Address only — for a reply that tells someone where to send funds. */
|
|
78
|
+
export function burnerAddress(xUserId, master) {
|
|
79
|
+
return deriveBurner(xUserId, master).address;
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Local x402-paying proxy + MCP server for openzoo.fun
|
|
3
|
+
"version": "0.50.1",
|
|
4
|
+
"description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -10,14 +10,13 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
12
|
"lib",
|
|
13
|
-
"README.md"
|
|
14
|
-
"vendor/modelroute"
|
|
13
|
+
"README.md"
|
|
15
14
|
],
|
|
16
15
|
"engines": {
|
|
17
16
|
"node": ">=18"
|
|
18
17
|
},
|
|
19
18
|
"scripts": {
|
|
20
|
-
"test": "node --test test/*.test.js
|
|
19
|
+
"test": "node --test test/*.test.js"
|
|
21
20
|
},
|
|
22
21
|
"dependencies": {
|
|
23
22
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
package/lib/anthropic.js
DELETED
|
@@ -1,369 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Anthropic Messages API shape, served by the openzoo proxy.
|
|
3
|
-
*
|
|
4
|
-
* WHY: harnesses that speak Anthropic (Claude Code via ANTHROPIC_BASE_URL, the
|
|
5
|
-
* Anthropic SDKs) could not use the zoo at all — it speaks OpenAI chat
|
|
6
|
-
* completions. Pointing DNS or /etc/hosts at localhost does not work: the TLS
|
|
7
|
-
* cert will not match and the client refuses the connection. A translating
|
|
8
|
-
* endpoint is the only mechanism that actually routes such a harness through
|
|
9
|
-
* x402 payment, and it needs no system changes.
|
|
10
|
-
*
|
|
11
|
-
* Translation is deliberately conservative: what maps cleanly is mapped, and
|
|
12
|
-
* anything unrecognised is passed through rather than dropped, so a field this
|
|
13
|
-
* file has never heard of still reaches the model.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/** Anthropic content blocks -> an OpenAI message content value. */
|
|
17
|
-
function blocksToOpenAI(content) {
|
|
18
|
-
if (typeof content === 'string') return content;
|
|
19
|
-
if (!Array.isArray(content)) return '';
|
|
20
|
-
const parts = [];
|
|
21
|
-
for (const b of content) {
|
|
22
|
-
if (b?.type === 'text') parts.push({ type: 'text', text: b.text ?? '' });
|
|
23
|
-
else if (b?.type === 'image' && b.source?.type === 'base64') {
|
|
24
|
-
parts.push({
|
|
25
|
-
type: 'image_url',
|
|
26
|
-
image_url: { url: `data:${b.source.media_type};base64,${b.source.data}` },
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
if (parts.length === 1 && parts[0].type === 'text') return parts[0].text;
|
|
31
|
-
return parts.length ? parts : '';
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Anthropic request -> OpenAI request.
|
|
36
|
-
*
|
|
37
|
-
* The two shapes disagree on three things that matter: `system` is a top-level
|
|
38
|
-
* field (OpenAI wants a system MESSAGE), tool results are user-turn blocks
|
|
39
|
-
* (OpenAI wants role:"tool" messages), and tool schemas live under
|
|
40
|
-
* `input_schema` rather than `parameters`.
|
|
41
|
-
*/
|
|
42
|
-
export function anthropicToOpenAI(body) {
|
|
43
|
-
const messages = [];
|
|
44
|
-
if (body.system) {
|
|
45
|
-
const text = typeof body.system === 'string'
|
|
46
|
-
? body.system
|
|
47
|
-
: (Array.isArray(body.system) ? body.system.map((b) => b?.text ?? '').join('\n') : '');
|
|
48
|
-
if (text) messages.push({ role: 'system', content: text });
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
for (const m of body.messages ?? []) {
|
|
52
|
-
const blocks = Array.isArray(m.content) ? m.content : null;
|
|
53
|
-
const toolResults = blocks?.filter((b) => b?.type === 'tool_result') ?? [];
|
|
54
|
-
const toolUses = blocks?.filter((b) => b?.type === 'tool_use') ?? [];
|
|
55
|
-
|
|
56
|
-
// A user turn carrying tool_result blocks becomes one OpenAI tool message
|
|
57
|
-
// per result — they are answers to specific calls, not prose.
|
|
58
|
-
for (const tr of toolResults) {
|
|
59
|
-
messages.push({
|
|
60
|
-
role: 'tool',
|
|
61
|
-
tool_call_id: tr.tool_use_id,
|
|
62
|
-
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content ?? ''),
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (toolUses.length) {
|
|
67
|
-
messages.push({
|
|
68
|
-
role: 'assistant',
|
|
69
|
-
content: blocksToOpenAI(blocks.filter((b) => b?.type === 'text')) || null,
|
|
70
|
-
tool_calls: toolUses.map((t) => ({
|
|
71
|
-
id: t.id,
|
|
72
|
-
type: 'function',
|
|
73
|
-
function: { name: t.name, arguments: JSON.stringify(t.input ?? {}) },
|
|
74
|
-
})),
|
|
75
|
-
});
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const rest = blocks ? blocks.filter((b) => b?.type !== 'tool_result') : m.content;
|
|
80
|
-
const content = blocksToOpenAI(rest);
|
|
81
|
-
if (content && (!Array.isArray(content) || content.length)) {
|
|
82
|
-
messages.push({ role: m.role, content });
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const out = { ...body, messages };
|
|
87
|
-
delete out.system;
|
|
88
|
-
delete out.anthropic_version;
|
|
89
|
-
delete out.metadata;
|
|
90
|
-
// A SERVER-SIDE web_search TOOL IS A PLUGIN, NOT A FUNCTION — TRANSLATE IT.
|
|
91
|
-
//
|
|
92
|
-
// Claude Code's Web Search arrives as an Anthropic server tool (type
|
|
93
|
-
// "web_search_20250305", name "web_search") with NO input_schema, because
|
|
94
|
-
// Anthropic runs it itself. Routed to OpenRouter->some other provider, nobody
|
|
95
|
-
// runs it, and the old code simply DROPPED it (no schema) — so the model got
|
|
96
|
-
// no search capability and every Web Search came back "0 results". Silent,
|
|
97
|
-
// and worse than the 400 it replaced: the agent believes it searched.
|
|
98
|
-
//
|
|
99
|
-
// OpenRouter's `web` plugin is the cross-model equivalent — search-then-inject
|
|
100
|
-
// middleware that works on every model. Detect the server tool and switch it
|
|
101
|
-
// on, mapping max_uses -> max_results, rather than discarding the intent.
|
|
102
|
-
const webTool = (Array.isArray(body.tools) ? body.tools : [])
|
|
103
|
-
.find((t) => t?.type?.startsWith?.('web_search') || t?.name === 'web_search');
|
|
104
|
-
if (webTool) {
|
|
105
|
-
const existing = Array.isArray(body.plugins) ? body.plugins : [];
|
|
106
|
-
if (!existing.some((p) => p?.id === 'web')) {
|
|
107
|
-
const web = { id: 'web' };
|
|
108
|
-
if (Number.isFinite(webTool.max_uses)) web.max_results = webTool.max_uses;
|
|
109
|
-
out.plugins = [...existing, web];
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
if (Array.isArray(body.tools) && body.tools.length) {
|
|
113
|
-
out.tools = body.tools
|
|
114
|
-
.filter((t) => t?.name && t?.input_schema)
|
|
115
|
-
.map((t) => ({
|
|
116
|
-
type: 'function',
|
|
117
|
-
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
118
|
-
}));
|
|
119
|
-
if (!out.tools.length) delete out.tools;
|
|
120
|
-
}
|
|
121
|
-
// TOOL_CHOICE MAY NOT OUTLIVE TOOLS.
|
|
122
|
-
//
|
|
123
|
-
// The block above DROPS any tool without a name+input_schema — which is every
|
|
124
|
-
// server-side tool (web_search, computer, bash), because those carry no schema
|
|
125
|
-
// in the Anthropic shape. When that empties the list, `out.tools` is deleted.
|
|
126
|
-
// But tool_choice was set unconditionally, so the body went upstream with a
|
|
127
|
-
// choice and nothing to choose from.
|
|
128
|
-
//
|
|
129
|
-
// OBSERVED on grok-4.6: a Claude Code Web Search 400'd with "A tool_choice was
|
|
130
|
-
// set on the request but no tools were specified." Every provider rejects this
|
|
131
|
-
// shape; it just words it differently. Gate the choice on tools surviving.
|
|
132
|
-
const hasTools = Array.isArray(out.tools) && out.tools.length > 0;
|
|
133
|
-
if (!hasTools) {
|
|
134
|
-
delete out.tool_choice;
|
|
135
|
-
} else if (body.tool_choice?.type === 'auto') out.tool_choice = 'auto';
|
|
136
|
-
else if (body.tool_choice?.type === 'any') out.tool_choice = 'required';
|
|
137
|
-
else if (body.tool_choice?.type === 'tool') {
|
|
138
|
-
out.tool_choice = { type: 'function', function: { name: body.tool_choice.name } };
|
|
139
|
-
}
|
|
140
|
-
if (body.stop_sequences) { out.stop = body.stop_sequences; delete out.stop_sequences; }
|
|
141
|
-
return out;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const STOP_REASON = {
|
|
145
|
-
stop: 'end_turn', length: 'max_tokens', tool_calls: 'tool_use', content_filter: 'stop_sequence',
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
/** OpenAI completion -> Anthropic message. */
|
|
149
|
-
export function openAIToAnthropic(data, requestedModel) {
|
|
150
|
-
const choice = (data.choices ?? [])[0] ?? {};
|
|
151
|
-
const msg = choice.message ?? {};
|
|
152
|
-
const content = [];
|
|
153
|
-
if (msg.content) content.push({ type: 'text', text: msg.content });
|
|
154
|
-
// Same reason as the streaming path: a refusal arrives with `content: null`
|
|
155
|
-
// and the sentence in `refusal`, so dropping it yields an empty message that
|
|
156
|
-
// reads as a broken model rather than as the answer it is.
|
|
157
|
-
else if (typeof msg.refusal === 'string' && msg.refusal.length) {
|
|
158
|
-
content.push({ type: 'text', text: msg.refusal });
|
|
159
|
-
}
|
|
160
|
-
for (const t of msg.tool_calls ?? []) {
|
|
161
|
-
let input = {};
|
|
162
|
-
try { input = JSON.parse(t.function?.arguments || '{}'); } catch { input = {}; }
|
|
163
|
-
content.push({ type: 'tool_use', id: t.id, name: t.function?.name, input });
|
|
164
|
-
}
|
|
165
|
-
return {
|
|
166
|
-
id: data.id ?? `msg_${Date.now()}`,
|
|
167
|
-
type: 'message',
|
|
168
|
-
role: 'assistant',
|
|
169
|
-
model: requestedModel ?? data.model,
|
|
170
|
-
content,
|
|
171
|
-
stop_reason: STOP_REASON[choice.finish_reason] ?? 'end_turn',
|
|
172
|
-
stop_sequence: null,
|
|
173
|
-
usage: {
|
|
174
|
-
input_tokens: data.usage?.prompt_tokens ?? 0,
|
|
175
|
-
output_tokens: data.usage?.completion_tokens ?? 0,
|
|
176
|
-
},
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Emit a finished completion as an Anthropic SSE stream.
|
|
182
|
-
*
|
|
183
|
-
* The zoo settles payment before it answers, so there is nothing to stream
|
|
184
|
-
* until generation is done — same reason the OpenAI path re-emits chunks. A
|
|
185
|
-
* client that asked for a stream and got a JSON body treats the connection as
|
|
186
|
-
* dead and RETRIES, and every retry is another payment.
|
|
187
|
-
*/
|
|
188
|
-
export function writeAnthropicSse(res, message, upstream) {
|
|
189
|
-
// x-accel-buffering: no keeps the cloudflare tunnel / nginx from buffering
|
|
190
|
-
// the stream and merging the tool_use frames a client parses incrementally.
|
|
191
|
-
const headers = {
|
|
192
|
-
'content-type': 'text/event-stream; charset=utf-8',
|
|
193
|
-
'cache-control': 'no-cache',
|
|
194
|
-
'x-accel-buffering': 'no',
|
|
195
|
-
connection: 'keep-alive',
|
|
196
|
-
};
|
|
197
|
-
const settle = upstream?.headers?.get?.('x-payment-response');
|
|
198
|
-
if (settle) headers['x-payment-response'] = settle;
|
|
199
|
-
res.writeHead(200, headers);
|
|
200
|
-
const ev = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
|
|
201
|
-
|
|
202
|
-
ev('message_start', {
|
|
203
|
-
message: { ...message, content: [], stop_reason: null, usage: { ...message.usage, output_tokens: 0 } },
|
|
204
|
-
});
|
|
205
|
-
message.content.forEach((block, index) => {
|
|
206
|
-
if (block.type === 'text') {
|
|
207
|
-
ev('content_block_start', { index, content_block: { type: 'text', text: '' } });
|
|
208
|
-
ev('content_block_delta', { index, delta: { type: 'text_delta', text: block.text } });
|
|
209
|
-
} else {
|
|
210
|
-
ev('content_block_start', { index, content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} } });
|
|
211
|
-
ev('content_block_delta', { index, delta: { type: 'input_json_delta', partial_json: JSON.stringify(block.input ?? {}) } });
|
|
212
|
-
}
|
|
213
|
-
ev('content_block_stop', { index });
|
|
214
|
-
});
|
|
215
|
-
ev('message_delta', {
|
|
216
|
-
delta: { stop_reason: message.stop_reason, stop_sequence: null },
|
|
217
|
-
usage: { output_tokens: message.usage.output_tokens },
|
|
218
|
-
});
|
|
219
|
-
ev('message_stop', {});
|
|
220
|
-
res.end();
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Translate an OpenAI SSE stream into an Anthropic one, INCREMENTALLY.
|
|
225
|
-
*
|
|
226
|
-
* The buffered path above exists because the gateway used to answer with a
|
|
227
|
-
* finished JSON body. It streams for real now, and piping those frames straight
|
|
228
|
-
* through gives an Anthropic client a 200 whose body it cannot parse — observed
|
|
229
|
-
* as "API returned an empty or malformed response (HTTP 200)". The stopgap was
|
|
230
|
-
* to ask the gateway not to stream and keep buffering, which is correct and
|
|
231
|
-
* silent: Claude Code sends max_tokens=32000 against a 600-turn transcript, so a
|
|
232
|
-
* turn is MINUTES of zero bytes and reads as a hang.
|
|
233
|
-
*
|
|
234
|
-
* So: same grammar, emitted as it arrives.
|
|
235
|
-
*
|
|
236
|
-
* BLOCK INDICES ARE NOT THE OPENAI ONES. Anthropic numbers content blocks
|
|
237
|
-
* sequentially across the whole message, while OpenAI numbers tool_calls in
|
|
238
|
-
* their own space starting at 0 — which collides with the text block. Tool
|
|
239
|
-
* indices are therefore remapped on first sight and remembered.
|
|
240
|
-
*
|
|
241
|
-
* `onReceipt` takes the gateway's trailing `: x402 {...}` SSE comment, which is
|
|
242
|
-
* swallowed here rather than forwarded: it is ours, and the Anthropic grammar
|
|
243
|
-
* has no room for it.
|
|
244
|
-
*/
|
|
245
|
-
export function streamOpenAIToAnthropic(res, upstream, requestedModel, onReceipt) {
|
|
246
|
-
const headers = {
|
|
247
|
-
'content-type': 'text/event-stream; charset=utf-8',
|
|
248
|
-
'cache-control': 'no-cache, no-transform',
|
|
249
|
-
'x-accel-buffering': 'no',
|
|
250
|
-
connection: 'keep-alive',
|
|
251
|
-
};
|
|
252
|
-
const settle = upstream?.headers?.get?.('x-payment-response');
|
|
253
|
-
if (settle) headers['x-payment-response'] = settle;
|
|
254
|
-
res.writeHead(200, headers);
|
|
255
|
-
const ev = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
|
|
256
|
-
|
|
257
|
-
let started = false;
|
|
258
|
-
let textIndex = -1; // Anthropic index of the text block, -1 until opened
|
|
259
|
-
let nextIndex = 0; // next free Anthropic block index
|
|
260
|
-
const toolIndex = new Map(); // OpenAI tool_calls[].index -> Anthropic index
|
|
261
|
-
const open = new Set();
|
|
262
|
-
let stopReason = 'end_turn';
|
|
263
|
-
let usage = null;
|
|
264
|
-
let msgId = null;
|
|
265
|
-
|
|
266
|
-
const start = () => {
|
|
267
|
-
if (started) return;
|
|
268
|
-
started = true;
|
|
269
|
-
ev('message_start', {
|
|
270
|
-
message: {
|
|
271
|
-
id: msgId ?? `msg_${Date.now()}`, type: 'message', role: 'assistant',
|
|
272
|
-
model: requestedModel, content: [], stop_reason: null, stop_sequence: null,
|
|
273
|
-
usage: { input_tokens: 0, output_tokens: 0 },
|
|
274
|
-
},
|
|
275
|
-
});
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
const onChunk = (d) => {
|
|
279
|
-
if (d.id && !msgId) msgId = d.id;
|
|
280
|
-
if (d.usage) usage = d.usage;
|
|
281
|
-
const ch = (d.choices ?? [])[0];
|
|
282
|
-
if (!ch) return;
|
|
283
|
-
start();
|
|
284
|
-
const delta = ch.delta ?? {};
|
|
285
|
-
// A REASONING CHUNK IS NOT A SILENT CHUNK.
|
|
286
|
-
//
|
|
287
|
-
// Only `delta.content` produced output here, so a reasoning model emitted
|
|
288
|
-
// NOTHING on the socket for the whole time it was thinking. fable reasons
|
|
289
|
-
// before it answers — measured at 53 completion tokens to say one word — so
|
|
290
|
-
// a real turn is minutes of zero bytes, and any hop in between is entitled
|
|
291
|
-
// to drop a connection that has gone idle.
|
|
292
|
-
//
|
|
293
|
-
// OBSERVED: a fable turn rendering as "I'll ground this in your own context
|
|
294
|
-
// first (holo), scout the monorepo layout, then f" and stopping mid-word.
|
|
295
|
-
// Claude Code 2.1.232 PRESERVES partial responses on a mid-stream drop
|
|
296
|
-
// instead of erroring, which is why this reads as "empty/truncated output"
|
|
297
|
-
// rather than as the connection failure it is.
|
|
298
|
-
//
|
|
299
|
-
// `ping` is in Anthropic's own SSE grammar and carries no content, so a
|
|
300
|
-
// client either ignores it or treats it as liveness — never as text.
|
|
301
|
-
if (!(typeof delta.content === 'string' && delta.content.length)
|
|
302
|
-
&& !(delta.tool_calls ?? []).length
|
|
303
|
-
&& !(typeof delta.refusal === 'string' && delta.refusal.length)) {
|
|
304
|
-
ev('ping', {});
|
|
305
|
-
}
|
|
306
|
-
// A REFUSAL IS A RESPONSE. SHOW IT.
|
|
307
|
-
//
|
|
308
|
-
// Upstream returns refusals out-of-band: `finish_reason: "content_filter"`,
|
|
309
|
-
// `content: null`, and the actual sentence in `refusal`. We only ever read
|
|
310
|
-
// `delta.content`, so the whole turn translated to a VALID, EMPTY stream —
|
|
311
|
-
// message_start, message_stop, output_tokens 0 — and rendered as silence.
|
|
312
|
-
//
|
|
313
|
-
// OBSERVED: a security audit of the user's OWN exploited protocol returning
|
|
314
|
-
// nothing at all, repeatedly, and reading as "fable is broken" when the
|
|
315
|
-
// provider had in fact answered: "This request triggered restrictions on
|
|
316
|
-
// violative cyber content and was blocked under Anthropic's Usage Policy."
|
|
317
|
-
// Being told you were refused is actionable — you can rephrase, or route to
|
|
318
|
-
// another model. Being shown an empty box is not.
|
|
319
|
-
if (typeof delta.refusal === 'string' && delta.refusal.length) {
|
|
320
|
-
if (textIndex < 0) {
|
|
321
|
-
textIndex = nextIndex++;
|
|
322
|
-
ev('content_block_start', { index: textIndex, content_block: { type: 'text', text: '' } });
|
|
323
|
-
open.add(textIndex);
|
|
324
|
-
}
|
|
325
|
-
ev('content_block_delta', { index: textIndex, delta: { type: 'text_delta', text: delta.refusal } });
|
|
326
|
-
}
|
|
327
|
-
if (typeof delta.content === 'string' && delta.content.length) {
|
|
328
|
-
if (textIndex < 0) {
|
|
329
|
-
textIndex = nextIndex++;
|
|
330
|
-
ev('content_block_start', { index: textIndex, content_block: { type: 'text', text: '' } });
|
|
331
|
-
open.add(textIndex);
|
|
332
|
-
}
|
|
333
|
-
ev('content_block_delta', { index: textIndex, delta: { type: 'text_delta', text: delta.content } });
|
|
334
|
-
}
|
|
335
|
-
for (const t of delta.tool_calls ?? []) {
|
|
336
|
-
const k = t.index ?? 0;
|
|
337
|
-
if (!toolIndex.has(k)) {
|
|
338
|
-
const idx = nextIndex++;
|
|
339
|
-
toolIndex.set(k, idx);
|
|
340
|
-
ev('content_block_start', {
|
|
341
|
-
index: idx,
|
|
342
|
-
content_block: { type: 'tool_use', id: t.id ?? `toolu_${idx}`, name: t.function?.name ?? '', input: {} },
|
|
343
|
-
});
|
|
344
|
-
open.add(idx);
|
|
345
|
-
}
|
|
346
|
-
const frag = t.function?.arguments;
|
|
347
|
-
if (typeof frag === 'string' && frag.length) {
|
|
348
|
-
ev('content_block_delta', {
|
|
349
|
-
index: toolIndex.get(k),
|
|
350
|
-
delta: { type: 'input_json_delta', partial_json: frag },
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
if (ch.finish_reason) stopReason = STOP_REASON[ch.finish_reason] ?? 'end_turn';
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
const finish = () => {
|
|
358
|
-
start(); // a stream that produced nothing still owes the client a message
|
|
359
|
-
for (const i of open) ev('content_block_stop', { index: i });
|
|
360
|
-
ev('message_delta', {
|
|
361
|
-
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
362
|
-
usage: { output_tokens: usage?.completion_tokens ?? 0 },
|
|
363
|
-
});
|
|
364
|
-
ev('message_stop', {});
|
|
365
|
-
res.end();
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
return { onChunk, finish, receipt: onReceipt, usageOf: () => usage };
|
|
369
|
-
}
|