openzoo 0.49.9 → 0.49.11
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/README.md +18 -4
- package/bin/openzoo.js +2 -3
- package/lib/claudecode.js +107 -723
- package/lib/grokui.mjs +125 -548
- package/lib/launch.js +110 -150
- package/lib/models.js +57 -19
- package/lib/proxy.js +79 -5
- package/lib/racesettle.js +23 -2
- package/lib/responses-stream.js +176 -0
- package/package.json +2 -2
- package/lib/relay.js +0 -275
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI chat-completions SSE -> Responses API SSE, frame by frame.
|
|
3
|
+
*
|
|
4
|
+
* WHY: Codex Security pins wire_api=responses and waits ~10s for
|
|
5
|
+
* `response.created`. The previous path buffered the whole chat completion
|
|
6
|
+
* (Opus reasoning can sit silent for longer than that) then dumped the
|
|
7
|
+
* event sequence at the end. Codex reported "connection interrupted".
|
|
8
|
+
*
|
|
9
|
+
* Same shape as streamOpenAIToAnthropic: write headers immediately, ping on
|
|
10
|
+
* silent reasoning chunks, translate content/tool deltas as they arrive.
|
|
11
|
+
*/
|
|
12
|
+
export function streamOpenAIToResponses(res, upstream, requestedModel, custom) {
|
|
13
|
+
const headers = {
|
|
14
|
+
'content-type': 'text/event-stream',
|
|
15
|
+
'cache-control': 'no-cache',
|
|
16
|
+
connection: 'keep-alive',
|
|
17
|
+
'x-accel-buffering': 'no',
|
|
18
|
+
};
|
|
19
|
+
const settle = upstream?.headers?.get?.('x-payment-response');
|
|
20
|
+
if (settle) headers['x-payment-response'] = settle;
|
|
21
|
+
res.writeHead(200, headers);
|
|
22
|
+
|
|
23
|
+
let seq = 0;
|
|
24
|
+
const send = (type, payload = {}) => {
|
|
25
|
+
res.write(`event: ${type}\n`);
|
|
26
|
+
res.write(`data: ${JSON.stringify({ type, sequence_number: seq++, ...payload })}\n\n`);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const customNames = custom instanceof Set ? custom : new Set(custom || []);
|
|
30
|
+
const responseId = `resp_${Date.now().toString(36)}`;
|
|
31
|
+
const itemId = `msg_${Math.random().toString(36).slice(2, 10)}`;
|
|
32
|
+
const shell = {
|
|
33
|
+
id: responseId,
|
|
34
|
+
object: 'response',
|
|
35
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
36
|
+
status: 'in_progress',
|
|
37
|
+
model: requestedModel,
|
|
38
|
+
output: [],
|
|
39
|
+
output_text: '',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// First bytes NOW — do not wait for a content token. Codex's watchdog is ~10s
|
|
43
|
+
// and a reasoning model can think for longer than that before delta.content.
|
|
44
|
+
send('response.created', { response: shell });
|
|
45
|
+
send('response.in_progress', { response: shell });
|
|
46
|
+
|
|
47
|
+
let textStarted = false;
|
|
48
|
+
let text = '';
|
|
49
|
+
const tools = [];
|
|
50
|
+
|
|
51
|
+
const unwrapCustom = (args) => {
|
|
52
|
+
try {
|
|
53
|
+
const o = JSON.parse(args);
|
|
54
|
+
if (o && typeof o.input === 'string') return o.input;
|
|
55
|
+
} catch { /* raw text */ }
|
|
56
|
+
return args;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const ensureText = () => {
|
|
60
|
+
if (textStarted) return;
|
|
61
|
+
textStarted = true;
|
|
62
|
+
send('response.output_item.added', {
|
|
63
|
+
output_index: 0,
|
|
64
|
+
item: { id: itemId, type: 'message', role: 'assistant', status: 'in_progress', content: [] },
|
|
65
|
+
});
|
|
66
|
+
send('response.content_part.added', {
|
|
67
|
+
item_id: itemId, output_index: 0, content_index: 0,
|
|
68
|
+
part: { type: 'output_text', text: '', annotations: [] },
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const toolIndexBase = () => (textStarted ? 1 : 0);
|
|
73
|
+
|
|
74
|
+
const onChunk = (d) => {
|
|
75
|
+
const ch = (d.choices ?? [])[0];
|
|
76
|
+
if (!ch) {
|
|
77
|
+
send('response.in_progress', { response: shell });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const delta = ch.delta ?? {};
|
|
81
|
+
let spoke = false;
|
|
82
|
+
if (typeof delta.content === 'string' && delta.content.length) {
|
|
83
|
+
spoke = true;
|
|
84
|
+
ensureText();
|
|
85
|
+
text += delta.content;
|
|
86
|
+
send('response.output_text.delta', {
|
|
87
|
+
item_id: itemId, output_index: 0, content_index: 0, delta: delta.content,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
for (const tc of (delta.tool_calls ?? [])) {
|
|
91
|
+
spoke = true;
|
|
92
|
+
const idx = Number.isInteger(tc.index) ? tc.index : tools.length;
|
|
93
|
+
if (!tools[idx]) {
|
|
94
|
+
const id = tc.id || `call_${idx}`;
|
|
95
|
+
const name = tc.function?.name || '';
|
|
96
|
+
const isCustom = customNames.has(name);
|
|
97
|
+
tools[idx] = {
|
|
98
|
+
id, call_id: id, name, arguments: '',
|
|
99
|
+
type: isCustom ? 'custom_tool_call' : 'function_call',
|
|
100
|
+
};
|
|
101
|
+
send('response.output_item.added', {
|
|
102
|
+
output_index: toolIndexBase() + idx,
|
|
103
|
+
item: {
|
|
104
|
+
id, type: tools[idx].type, status: 'in_progress',
|
|
105
|
+
call_id: id, name, arguments: '',
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const piece = tc.function?.arguments || '';
|
|
110
|
+
if (!piece) continue;
|
|
111
|
+
tools[idx].arguments += piece;
|
|
112
|
+
const isCustom = tools[idx].type === 'custom_tool_call';
|
|
113
|
+
// Custom tools want raw text, but stream pieces are JSON fragments of
|
|
114
|
+
// {input:"..."}. Buffer and emit once at finish. Function tools stream.
|
|
115
|
+
if (!isCustom) {
|
|
116
|
+
send('response.function_call_arguments.delta', {
|
|
117
|
+
item_id: tools[idx].id,
|
|
118
|
+
output_index: toolIndexBase() + idx,
|
|
119
|
+
delta: piece,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!spoke) send('response.in_progress', { response: shell });
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const finish = () => {
|
|
127
|
+
if (textStarted) {
|
|
128
|
+
send('response.output_text.done', {
|
|
129
|
+
item_id: itemId, output_index: 0, content_index: 0, text,
|
|
130
|
+
});
|
|
131
|
+
send('response.content_part.done', {
|
|
132
|
+
item_id: itemId, output_index: 0, content_index: 0,
|
|
133
|
+
part: { type: 'output_text', text, annotations: [] },
|
|
134
|
+
});
|
|
135
|
+
send('response.output_item.done', {
|
|
136
|
+
output_index: 0,
|
|
137
|
+
item: {
|
|
138
|
+
id: itemId, type: 'message', role: 'assistant', status: 'completed',
|
|
139
|
+
content: [{ type: 'output_text', text, annotations: [] }],
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const output = [];
|
|
144
|
+
if (textStarted) {
|
|
145
|
+
output.push({
|
|
146
|
+
id: itemId, type: 'message', role: 'assistant', status: 'completed',
|
|
147
|
+
content: [{ type: 'output_text', text, annotations: [] }],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
tools.forEach((call, i) => {
|
|
151
|
+
if (!call) return;
|
|
152
|
+
const idx = toolIndexBase() + i;
|
|
153
|
+
const isCustom = call.type === 'custom_tool_call';
|
|
154
|
+
const raw = isCustom ? unwrapCustom(call.arguments) : call.arguments;
|
|
155
|
+
send(isCustom ? 'response.custom_tool_call_input.delta' : 'response.function_call_arguments.delta', {
|
|
156
|
+
item_id: call.id, output_index: idx, delta: isCustom ? raw : '',
|
|
157
|
+
});
|
|
158
|
+
send(isCustom ? 'response.custom_tool_call_input.done' : 'response.function_call_arguments.done', {
|
|
159
|
+
item_id: call.id, output_index: idx,
|
|
160
|
+
...(isCustom ? { input: raw } : { arguments: call.arguments }),
|
|
161
|
+
});
|
|
162
|
+
const item = isCustom
|
|
163
|
+
? { id: call.id, type: 'custom_tool_call', status: 'completed', call_id: call.call_id, name: call.name, input: raw }
|
|
164
|
+
: { id: call.id, type: 'function_call', status: 'completed', call_id: call.call_id, name: call.name, arguments: call.arguments };
|
|
165
|
+
send('response.output_item.done', { output_index: idx, item });
|
|
166
|
+
output.push(item);
|
|
167
|
+
});
|
|
168
|
+
send('response.completed', {
|
|
169
|
+
response: { ...shell, status: 'completed', output, output_text: text },
|
|
170
|
+
});
|
|
171
|
+
res.write('data: [DONE]\n\n');
|
|
172
|
+
res.end();
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return { onChunk, finish };
|
|
176
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.49.
|
|
4
|
-
"description": "Local x402-paying proxy + MCP server for openzoo.fun
|
|
3
|
+
"version": "0.49.11",
|
|
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": {
|
package/lib/relay.js
DELETED
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pipe an upstream fetch Response to the client.
|
|
3
|
-
*
|
|
4
|
-
* Fly (and other CDNs) gzip error bodies. Node fetch does not always inflate
|
|
5
|
-
* those — MEASURED: a 400 from x402-tokens.fly.dev arrived still gzip-wrapped
|
|
6
|
-
* with `Content-Encoding: gzip`. The old relay stripped that header and
|
|
7
|
-
* `Content-Length`, then piped `response.body` RAW. Claude Code printed
|
|
8
|
-
* `API Error: 400` plus diamond/binary mojibake.
|
|
9
|
-
*
|
|
10
|
-
* Contract: never emit a compressed body without Content-Encoding. Prefer
|
|
11
|
-
* decompressing so the client sees application/json or text (Claude CLI can
|
|
12
|
-
* parse the error). Unknown encodings keep the header and the bytes together.
|
|
13
|
-
*/
|
|
14
|
-
import zlib from 'node:zlib';
|
|
15
|
-
import { promisify } from 'node:util';
|
|
16
|
-
import { Readable, PassThrough } from 'node:stream';
|
|
17
|
-
|
|
18
|
-
const gunzipAsync = promisify(zlib.gunzip);
|
|
19
|
-
const inflateAsync = promisify(zlib.inflate);
|
|
20
|
-
const inflateRawAsync = promisify(zlib.inflateRaw);
|
|
21
|
-
const brotliAsync = promisify(zlib.brotliDecompress);
|
|
22
|
-
|
|
23
|
-
const HOP = new Set(['transfer-encoding', 'connection', 'content-length']);
|
|
24
|
-
|
|
25
|
-
export function looksGzip(buf) {
|
|
26
|
-
return Buffer.isBuffer(buf) && buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function headerEncoding(headers) {
|
|
30
|
-
return String(headers?.get?.('content-encoding') || '').trim();
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Single known codec, or null if identity / absent / unknown / stacked. */
|
|
34
|
-
export function knownCodec(encoding) {
|
|
35
|
-
const parts = String(encoding || '')
|
|
36
|
-
.split(',')
|
|
37
|
-
.map((s) => s.trim().toLowerCase())
|
|
38
|
-
.filter((s) => s && s !== 'identity');
|
|
39
|
-
if (parts.length !== 1) return null;
|
|
40
|
-
if (parts[0] === 'gzip' || parts[0] === 'x-gzip') return 'gzip';
|
|
41
|
-
if (parts[0] === 'deflate') return 'deflate';
|
|
42
|
-
if (parts[0] === 'br') return 'br';
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export async function inflateEncoded(buf, encoding) {
|
|
47
|
-
const codec = knownCodec(encoding) || (looksGzip(buf) ? 'gzip' : null);
|
|
48
|
-
if (!codec) return buf;
|
|
49
|
-
// Leftover Content-Encoding after fetch already inflated: do not gunzip JSON.
|
|
50
|
-
if (codec === 'gzip' && !looksGzip(buf)) return buf;
|
|
51
|
-
try {
|
|
52
|
-
if (codec === 'gzip') return await gunzipAsync(buf);
|
|
53
|
-
if (codec === 'br') return await brotliAsync(buf);
|
|
54
|
-
try {
|
|
55
|
-
return await inflateAsync(buf);
|
|
56
|
-
} catch {
|
|
57
|
-
return await inflateRawAsync(buf);
|
|
58
|
-
}
|
|
59
|
-
} catch (err) {
|
|
60
|
-
if (!looksBinary(buf)) return buf;
|
|
61
|
-
throw err;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function looksBinary(buf) {
|
|
66
|
-
if (!buf || !buf.length) return false;
|
|
67
|
-
if (looksGzip(buf)) return true;
|
|
68
|
-
const n = Math.min(buf.length, 256);
|
|
69
|
-
let ctrl = 0;
|
|
70
|
-
for (let i = 0; i < n; i++) {
|
|
71
|
-
const c = buf[i];
|
|
72
|
-
if (c === 0 || c < 8 || (c > 13 && c < 32)) ctrl += 1;
|
|
73
|
-
}
|
|
74
|
-
return ctrl >= 4;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function copyHeaders(upstream, { decoded }) {
|
|
78
|
-
const headers = {};
|
|
79
|
-
upstream.headers.forEach((v, k) => {
|
|
80
|
-
const key = String(k).toLowerCase();
|
|
81
|
-
if (HOP.has(key)) return;
|
|
82
|
-
// Drop Content-Encoding only when the bytes we send are already inflated.
|
|
83
|
-
if (decoded && key === 'content-encoding') return;
|
|
84
|
-
headers[k] = v;
|
|
85
|
-
});
|
|
86
|
-
return headers;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function preferReadableType(headers, buf) {
|
|
90
|
-
const ct = String(headers['content-type'] || '');
|
|
91
|
-
if (ct && !/octet-stream|(?:x-)?gzip|br\b/i.test(ct)) return;
|
|
92
|
-
const c = buf[0];
|
|
93
|
-
headers['content-type'] = (c === 0x7b || c === 0x5b)
|
|
94
|
-
? 'application/json; charset=utf-8'
|
|
95
|
-
: 'text/plain; charset=utf-8';
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function jsonUpstreamErr(res, status) {
|
|
99
|
-
res.writeHead(status || 502, { 'content-type': 'application/json' });
|
|
100
|
-
res.end(JSON.stringify({ error: { message: `upstream HTTP ${status || 502}` } }));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async function readBody(upstream) {
|
|
104
|
-
if (!upstream.body) return Buffer.alloc(0);
|
|
105
|
-
const chunks = [];
|
|
106
|
-
for await (const c of Readable.fromWeb(upstream.body)) chunks.push(c);
|
|
107
|
-
return Buffer.concat(chunks);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Non-OK: buffer, inflate gzip/br/deflate (or gzip-magic with no header),
|
|
112
|
-
* send uncompressed JSON/text. Never forward gzip bytes with the encoding
|
|
113
|
-
* header stripped. Un-decodable compressed bodies become a short JSON error
|
|
114
|
-
* so Claude / grokui do not paint diamond mojibake — the canvas fold to
|
|
115
|
-
* `upstream HTTP 400` lives in #65; we just refuse to emit the binary.
|
|
116
|
-
*/
|
|
117
|
-
async function relayError(res, upstream) {
|
|
118
|
-
const encoding = headerEncoding(upstream.headers);
|
|
119
|
-
const codec = knownCodec(encoding);
|
|
120
|
-
let buf;
|
|
121
|
-
try {
|
|
122
|
-
buf = await readBody(upstream);
|
|
123
|
-
} catch {
|
|
124
|
-
jsonUpstreamErr(res, upstream.status);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (codec || looksGzip(buf)) {
|
|
129
|
-
try {
|
|
130
|
-
buf = await inflateEncoded(buf, encoding);
|
|
131
|
-
} catch {
|
|
132
|
-
jsonUpstreamErr(res, upstream.status);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
if (looksBinary(buf)) {
|
|
136
|
-
jsonUpstreamErr(res, upstream.status);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
const headers = copyHeaders(upstream, { decoded: true });
|
|
140
|
-
preferReadableType(headers, buf);
|
|
141
|
-
res.writeHead(upstream.status, headers);
|
|
142
|
-
res.end(buf);
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (encoding && !codec) {
|
|
147
|
-
// Unknown / stacked coding: keep the label with the bytes.
|
|
148
|
-
res.writeHead(upstream.status, copyHeaders(upstream, { decoded: false }));
|
|
149
|
-
res.end(buf);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (looksBinary(buf)) {
|
|
154
|
-
jsonUpstreamErr(res, upstream.status);
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
const headers = copyHeaders(upstream, { decoded: true });
|
|
158
|
-
preferReadableType(headers, buf);
|
|
159
|
-
res.writeHead(upstream.status, headers);
|
|
160
|
-
res.end(buf);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function createDecoder(codec) {
|
|
164
|
-
if (codec === 'gzip') return zlib.createGunzip();
|
|
165
|
-
if (codec === 'deflate') return zlib.createInflate();
|
|
166
|
-
if (codec === 'br') return zlib.createBrotliDecompress();
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function prependChunk(chunk, rest) {
|
|
171
|
-
const out = new PassThrough();
|
|
172
|
-
out.write(chunk);
|
|
173
|
-
rest.pipe(out);
|
|
174
|
-
return out;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function attachBody(res, body, onReceipt, sse, resolve) {
|
|
178
|
-
body.on('error', () => res.destroy());
|
|
179
|
-
res.on('close', () => body.destroy());
|
|
180
|
-
if (!sse || typeof onReceipt !== 'function') {
|
|
181
|
-
body.pipe(res);
|
|
182
|
-
body.on('end', resolve);
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
let pending = '';
|
|
186
|
-
body.on('data', (c) => {
|
|
187
|
-
res.write(c);
|
|
188
|
-
pending += c.toString('utf8');
|
|
189
|
-
const lines = pending.split('\n');
|
|
190
|
-
pending = lines.pop() ?? '';
|
|
191
|
-
for (const line of lines) {
|
|
192
|
-
if (!line.startsWith(': x402 ')) continue;
|
|
193
|
-
try { onReceipt(JSON.parse(line.slice(7))); } catch { /* not our frame */ }
|
|
194
|
-
}
|
|
195
|
-
});
|
|
196
|
-
body.on('end', () => { res.end(); resolve(); });
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* 2xx stream. Uncompressed (the cheap Fly 200) is the old pipe. Encoded
|
|
201
|
-
* bodies are inflated when we know the codec, otherwise the encoding
|
|
202
|
-
* header stays with the bytes.
|
|
203
|
-
*/
|
|
204
|
-
function relayOk(res, upstream, onReceipt) {
|
|
205
|
-
const encoding = headerEncoding(upstream.headers);
|
|
206
|
-
const codec = knownCodec(encoding);
|
|
207
|
-
const sse = (upstream.headers.get('content-type') || '').includes('text/event-stream');
|
|
208
|
-
|
|
209
|
-
return new Promise((resolve) => {
|
|
210
|
-
const raw = Readable.fromWeb(upstream.body);
|
|
211
|
-
raw.on('error', () => res.destroy());
|
|
212
|
-
res.on('close', () => raw.destroy());
|
|
213
|
-
|
|
214
|
-
if (!encoding) {
|
|
215
|
-
res.writeHead(upstream.status, copyHeaders(upstream, { decoded: true }));
|
|
216
|
-
attachBody(res, raw, onReceipt, sse, resolve);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Peek so a leftover Content-Encoding on already-plain bytes is stripped,
|
|
221
|
-
// and a real gzip body is never sent naked.
|
|
222
|
-
let decided = false;
|
|
223
|
-
raw.once('data', (chunk) => {
|
|
224
|
-
if (decided) return;
|
|
225
|
-
decided = true;
|
|
226
|
-
const head = Buffer.from(chunk);
|
|
227
|
-
const gzip = looksGzip(head);
|
|
228
|
-
if (gzip || (codec && codec !== 'gzip')) {
|
|
229
|
-
const dec = codec ? createDecoder(codec) : zlib.createGunzip();
|
|
230
|
-
if (!dec) {
|
|
231
|
-
res.writeHead(upstream.status, copyHeaders(upstream, { decoded: false }));
|
|
232
|
-
attachBody(res, prependChunk(head, raw), onReceipt, sse, resolve);
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
dec.write(head);
|
|
236
|
-
raw.pipe(dec);
|
|
237
|
-
res.writeHead(upstream.status, copyHeaders(upstream, { decoded: true }));
|
|
238
|
-
attachBody(res, dec, onReceipt, sse, resolve);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
// Header leftover on plain JSON/SSE — drop encoding, replay the peek.
|
|
242
|
-
res.writeHead(upstream.status, copyHeaders(upstream, { decoded: true }));
|
|
243
|
-
attachBody(res, prependChunk(head, raw), onReceipt, sse, resolve);
|
|
244
|
-
});
|
|
245
|
-
raw.once('end', () => {
|
|
246
|
-
if (decided) return;
|
|
247
|
-
decided = true;
|
|
248
|
-
res.writeHead(upstream.status, copyHeaders(upstream, { decoded: true }));
|
|
249
|
-
res.end();
|
|
250
|
-
resolve();
|
|
251
|
-
});
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** Pipe an upstream fetch Response to the client, unbuffered (SSE-safe).
|
|
256
|
-
*
|
|
257
|
-
* `onReceipt` is called with the gateway's x402 block when it arrives. On a
|
|
258
|
-
* STREAMED call there is no JSON body to carry that block, so the gateway
|
|
259
|
-
* emits it as an SSE COMMENT (`: x402 {...}`) after the last frame —
|
|
260
|
-
* comments are discarded by every compliant client, so nothing downstream
|
|
261
|
-
* sees it, but without reading it here every spend and savings figure on
|
|
262
|
-
* the status line would silently read zero the moment real streaming was
|
|
263
|
-
* switched on.
|
|
264
|
-
*
|
|
265
|
-
* Sniffing NEVER delays a byte: each chunk is written to the client first
|
|
266
|
-
* and only then scanned. */
|
|
267
|
-
export function relay(res, upstream, onReceipt) {
|
|
268
|
-
if (!upstream.body) {
|
|
269
|
-
res.writeHead(upstream.status, copyHeaders(upstream, { decoded: true }));
|
|
270
|
-
res.end();
|
|
271
|
-
return Promise.resolve();
|
|
272
|
-
}
|
|
273
|
-
if (!upstream.ok) return relayError(res, upstream);
|
|
274
|
-
return relayOk(res, upstream, onReceipt);
|
|
275
|
-
}
|