llm-switcher 1.6.5 → 1.6.6
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/CHANGELOG.md +5 -0
- package/compaction.mjs +72 -0
- package/formats.mjs +107 -35
- package/maintenance/gateway-upstream-watch/REPOSITORIES.md +64 -0
- package/maintenance/gateway-upstream-watch/SKILL.md +18 -0
- package/maintenance/gateway-upstream-watch/prompt.vi.md +11 -0
- package/maintenance/gateway-upstream-watch/references/repos.json +570 -0
- package/maintenance/gateway-upstream-watch/watch.py +222 -0
- package/package.json +1 -1
- package/proxy.mjs +52 -51
- package/tests/codex-daemon.test.mjs +1 -0
- package/tests/compaction.e2e.test.mjs +79 -0
- package/tests/gateway-contract.test.mjs +59 -0
- package/tests/gateway.e2e.test.mjs +47 -0
- package/tests/tool-repair.test.mjs +119 -2
- package/tests/update.test.mjs +2 -2
- package/tests/upstream-stream.test.mjs +30 -0
- package/toolvocab.mjs +59 -11
- package/upstream-stream.mjs +37 -0
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// declared. Sources for every name here are collected in docs/tool-vocabulary.md.
|
|
3
3
|
import test from 'node:test';
|
|
4
4
|
import assert from 'node:assert/strict';
|
|
5
|
-
import { createToolVocab, NULL_TOOL_VOCAB, tokenize, sanitizeToolName } from '../toolvocab.mjs';
|
|
5
|
+
import { createToolVocab, NULL_TOOL_VOCAB, tokenize, sanitizeToolName, toolNameMapForIR } from '../toolvocab.mjs';
|
|
6
6
|
import {
|
|
7
|
-
anthropicToIR, responsesToIR, irToChatBody, buildAnthropicMessage, buildChatMessage, buildResponsesMessage,
|
|
7
|
+
anthropicToIR, responsesToIR, irToChatBody, irToAnthropicBody, irToVertexBody, buildAnthropicMessage, buildChatMessage, buildResponsesMessage,
|
|
8
8
|
createAnthropicStream, createChatStream, createResponsesStream
|
|
9
9
|
} from '../formats.mjs';
|
|
10
10
|
|
|
@@ -17,6 +17,123 @@ const claudeTools = () => anthropicToIR({
|
|
|
17
17
|
]
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
const consoleTool = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages';
|
|
21
|
+
const networkTool = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_network_requests';
|
|
22
|
+
|
|
23
|
+
function toolSearchHistory(extraTools = []) {
|
|
24
|
+
return anthropicToIR({
|
|
25
|
+
model: 'claude-opus-5-5', system: `Inspect errors with ${consoleTool} and ${networkTool}.`,
|
|
26
|
+
messages: [
|
|
27
|
+
{ role: 'user', content: 'Inspect console errors.' },
|
|
28
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 'search1', name: 'ToolSearch', input: { query: `select:${consoleTool},${networkTool}`, max_results: 5 } }] },
|
|
29
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'search1', content: [
|
|
30
|
+
{ type: 'tool_reference', tool_name: consoleTool }, { type: 'tool_reference', tool_name: networkTool }
|
|
31
|
+
] }] }
|
|
32
|
+
],
|
|
33
|
+
tools: [
|
|
34
|
+
{ name: 'ToolSearch', description: `Find tools such as ${consoleTool}.`, input_schema: { type: 'object', properties: { query: { type: 'string' }, max_results: { type: 'number' } } } },
|
|
35
|
+
...[consoleTool, networkTool, ...extraTools].map(name => ({ name, input_schema: { type: 'object', properties: { pageSize: { type: 'integer' } } } }))
|
|
36
|
+
]
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('ToolSearch references, query history and instructions agree with declared aliases across upstream formats', () => {
|
|
41
|
+
const ir = toolSearchHistory();
|
|
42
|
+
const original = JSON.stringify(ir);
|
|
43
|
+
for (const [emit, model, opts] of [
|
|
44
|
+
[irToChatBody, 'zencore/space-bunny-free', {}],
|
|
45
|
+
[irToChatBody, 'zencore/space-bunny-free', { thinkingMode: 'off' }],
|
|
46
|
+
[irToChatBody, 'zencore/space-bunny-free', { thinkingMode: 'native' }],
|
|
47
|
+
[irToAnthropicBody, 'claude-opus-5-5', {}],
|
|
48
|
+
[irToVertexBody, 'gemini-3.8-flash', {}]
|
|
49
|
+
]) {
|
|
50
|
+
const body = emit(ir, model, opts);
|
|
51
|
+
const serialized = JSON.stringify(body);
|
|
52
|
+
const names = toolNameMapForIR(ir, { allowHyphens: emit !== irToVertexBody });
|
|
53
|
+
for (const name of [consoleTool, networkTool]) {
|
|
54
|
+
assert.ok(serialized.includes(names.name(name)), 'model sees the declared alias');
|
|
55
|
+
assert.ok(!serialized.includes(name), 'original no longer competes with the alias');
|
|
56
|
+
}
|
|
57
|
+
assert.equal(JSON.stringify(ir), original, 'conversion never mutates caller history');
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('ToolSearch instructions use collision-resolved aliases instead of the reserved original name', () => {
|
|
62
|
+
const reserved = sanitizeToolName(consoleTool);
|
|
63
|
+
const ir = toolSearchHistory([reserved]);
|
|
64
|
+
const names = toolNameMapForIR(ir);
|
|
65
|
+
assert.notEqual(names.name(consoleTool), reserved);
|
|
66
|
+
const body = irToChatBody(ir, 'zencore/space-bunny-free');
|
|
67
|
+
assert.equal(body.tools.find(t => t.function.name === reserved).function.name, reserved);
|
|
68
|
+
const references = body.messages.find(m => m.role === 'tool').content.split('\n').map(JSON.parse);
|
|
69
|
+
assert.equal(references[0].tool_name, names.name(consoleTool));
|
|
70
|
+
assert.ok(body.messages[0].content.includes(names.name(consoleTool)));
|
|
71
|
+
const notice = irToChatBody({ ...ir, system: '' }, 'x').messages[0].content;
|
|
72
|
+
assert.ok(notice.includes(names.name(consoleTool)), 'notice uses the collision-resolved declaration too');
|
|
73
|
+
const vocab = createToolVocab(ir.tools);
|
|
74
|
+
const response = buildAnthropicMessage({ model: 'x', tools: [{ name: 'ToolSearch', args: JSON.stringify({ query: `select:${names.name(consoleTool)},${reserved}` }) }], vocab });
|
|
75
|
+
assert.equal(response.content[0].input.query, `select:${consoleTool},${reserved}`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('only structured references and ToolSearch queries change; caller data, schemas and unknown tools survive', () => {
|
|
79
|
+
const ir = toolSearchHistory();
|
|
80
|
+
const unknown = 'mcp__not_declared__inspect';
|
|
81
|
+
ir.messages[0].content = `User data mentions ${consoleTool}.`;
|
|
82
|
+
ir.messages[1].toolCalls.push({ id: 'data1', name: consoleTool, args: { pageSize: 0, query: consoleTool } });
|
|
83
|
+
ir.messages.push({ role: 'tool', toolCallId: 'data1', content: `Log contains ${consoleTool}.` });
|
|
84
|
+
ir.messages[2].toolResultParts.push({ type: 'tool_reference', tool_name: unknown });
|
|
85
|
+
ir.tools[1].parameters.properties.query = { type: 'string', const: consoleTool };
|
|
86
|
+
const body = irToChatBody(ir, 'x');
|
|
87
|
+
assert.equal(body.messages.find(m => m.role === 'user').content, ir.messages[0].content);
|
|
88
|
+
assert.equal(body.messages.find(m => m.tool_call_id === 'data1').content, `Log contains ${consoleTool}.`);
|
|
89
|
+
const calls = body.messages.find(m => m.tool_calls).tool_calls;
|
|
90
|
+
assert.deepEqual(JSON.parse(calls[1].function.arguments), { pageSize: 0, query: consoleTool });
|
|
91
|
+
assert.equal(JSON.parse(calls[0].function.arguments).max_results, 5);
|
|
92
|
+
assert.ok(body.messages.find(m => m.tool_call_id === 'search1').content.includes(unknown));
|
|
93
|
+
assert.equal(body.tools[1].function.parameters.properties.query.const, consoleTool);
|
|
94
|
+
const vocab = createToolVocab(ir.tools);
|
|
95
|
+
assert.equal(vocab.name(unknown), unknown);
|
|
96
|
+
const malformed = '{"query":"select:';
|
|
97
|
+
assert.equal(vocab.args('ToolSearch', malformed), malformed);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('fragmented streamed ToolSearch queries restore original names before the client executes them', () => {
|
|
101
|
+
const ir = toolSearchHistory();
|
|
102
|
+
const vocab = createToolVocab(ir.tools);
|
|
103
|
+
const args = JSON.stringify({ query: `select:${sanitizeToolName(consoleTool)},${sanitizeToolName(networkTool)}`, max_results: 5 });
|
|
104
|
+
for (const create of [createAnthropicStream, createChatStream, createResponsesStream]) {
|
|
105
|
+
const events = [];
|
|
106
|
+
const renderer = create((event, data) => events.push({ event, data }), 'x', { vocab });
|
|
107
|
+
renderer.start();
|
|
108
|
+
renderer.tool({ index: 0, id: 'search2', name: 'ToolSearch', args: '' });
|
|
109
|
+
for (let i = 0; i < args.length; i += 7) renderer.tool({ index: 0, args: args.slice(i, i + 7) });
|
|
110
|
+
renderer.finish('tool_calls', { hasTools: true });
|
|
111
|
+
let restored;
|
|
112
|
+
if (create === createAnthropicStream) restored = events.filter(e => e.data?.delta?.type === 'input_json_delta').map(e => e.data.delta.partial_json).join('');
|
|
113
|
+
else if (create === createChatStream) restored = events.flatMap(e => e.data?.choices?.[0]?.delta?.tool_calls || []).map(c => c.function?.arguments || '').join('');
|
|
114
|
+
else restored = events.find(e => e.event === 'response.output_item.done').data.item.arguments;
|
|
115
|
+
assert.deepEqual(JSON.parse(restored), { query: `select:${consoleTool},${networkTool}`, max_results: 5 });
|
|
116
|
+
}
|
|
117
|
+
const argsUnrelated = JSON.stringify({ query: sanitizeToolName(consoleTool) });
|
|
118
|
+
assert.equal(vocab.args(consoleTool, argsUnrelated), argsUnrelated, 'other tools keep their arguments');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('repairing one parallel Chat ToolSearch call never flushes another unfinished query', () => {
|
|
122
|
+
const vocab = createToolVocab(toolSearchHistory().tools);
|
|
123
|
+
const events = [];
|
|
124
|
+
const renderer = createChatStream((event, data) => events.push(data), 'x', { vocab });
|
|
125
|
+
renderer.start();
|
|
126
|
+
renderer.tool({ index: 0, name: 'ToolSearch', args: '{"query":"select:' });
|
|
127
|
+
renderer.tool({ index: 1, name: 'ToolSearch', args: JSON.stringify({ query: `select:${sanitizeToolName(networkTool)}` }) });
|
|
128
|
+
renderer.tool({ index: 0, args: `${sanitizeToolName(consoleTool)}"}` });
|
|
129
|
+
renderer.finish('tool_calls');
|
|
130
|
+
for (const [index, original] of [[0, consoleTool], [1, networkTool]]) {
|
|
131
|
+
const args = events.flatMap(e => e.choices?.[0]?.delta?.tool_calls || [])
|
|
132
|
+
.filter(c => c.index === index).map(c => c.function?.arguments || '').join('');
|
|
133
|
+
assert.equal(JSON.parse(args).query, `select:${original}`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
20
137
|
// A model that answers `read` when the caller declared `Read` ends the turn with
|
|
21
138
|
// "No such tool available: read". The name comes back in the caller's words.
|
|
22
139
|
test('a name the model reached for comes back in the caller own words', () => {
|
package/tests/update.test.mjs
CHANGED
|
@@ -184,7 +184,7 @@ test('a release on npm is installed before the checkout is asked at all', async
|
|
|
184
184
|
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.1'), run: recording(calls) });
|
|
185
185
|
|
|
186
186
|
const order = calls.map(c => c[0]);
|
|
187
|
-
assert.deepEqual(order.slice(0, 2), ['npm', 'git'], `npm first, then the checkout: ${order.join(' ')}`);
|
|
187
|
+
assert.deepEqual(order.slice(0, 2), [process.platform === 'win32' ? 'cmd.exe' : 'npm', 'git'], `npm first, then the checkout: ${order.join(' ')}`);
|
|
188
188
|
assert.deepEqual(calls[0].slice(-3), ['install', '-g', 'llm-switcher@1.0.1']);
|
|
189
189
|
// npm put the release in the global prefix, which is not this directory, so the checkout is what
|
|
190
190
|
// moved -- and that is the version this gateway runs, which is the only reason the pull happened.
|
|
@@ -226,7 +226,7 @@ test('an npm install that fails does not stop a checkout from updating', async (
|
|
|
226
226
|
const logs = [];
|
|
227
227
|
// npm refuses the version a fixture registry names, the way a real registry would.
|
|
228
228
|
const run = async (cmd, args, opts) => {
|
|
229
|
-
if (cmd === 'npm') throw new Error('npm install failed: notarget No matching version found');
|
|
229
|
+
if (cmd === 'npm' || cmd === 'cmd.exe') throw new Error('npm install failed: notarget No matching version found');
|
|
230
230
|
return recording([])(cmd, args, opts);
|
|
231
231
|
};
|
|
232
232
|
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.1'), run, logger: m => logs.push(m) });
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readUpstreamPayloads } from '../upstream-stream.mjs';
|
|
4
|
+
|
|
5
|
+
async function collect(text, widths = [1, 3, 7]) {
|
|
6
|
+
const bytes = Buffer.from(text); let offset = 0, i = 0;
|
|
7
|
+
const body = new ReadableStream({ pull(controller) {
|
|
8
|
+
if (offset === bytes.length) return controller.close();
|
|
9
|
+
const end = Math.min(bytes.length, offset + widths[i++ % widths.length]);
|
|
10
|
+
controller.enqueue(bytes.subarray(offset, end)); offset = end;
|
|
11
|
+
} });
|
|
12
|
+
const values = [];
|
|
13
|
+
for await (const value of readUpstreamPayloads({ body })) values.push(value);
|
|
14
|
+
return values;
|
|
15
|
+
}
|
|
16
|
+
test('multiline SSE joins data fields across CRLF, byte chunks and UTF-8', async () => {
|
|
17
|
+
const text = ': ping\r\nevent: message\r\nid: 1\r\nretry: 1000\r\ndata: {"choices":\r\ndata: [{"delta":{"content":"giữ ngữ cảnh"}}]}\r\n\r\ndata: [DONE]\r\n\r\n';
|
|
18
|
+
assert.deepEqual(await collect(text), [{ choices: [{ delta: { content: 'giữ ngữ cảnh' } }] }]);
|
|
19
|
+
});
|
|
20
|
+
test('Vertex JSON lines and final SSE event without delimiter both survive', async () => {
|
|
21
|
+
assert.deepEqual(await collect('{"one":1}\n{"two":2}'), [{ one: 1 }, { two: 2 }]);
|
|
22
|
+
assert.deepEqual(await collect('data: {"one":1}'), [{ one: 1 }]);
|
|
23
|
+
});
|
|
24
|
+
test('malformed JSON is surfaced and stopping consumption cancels the upstream', async () => {
|
|
25
|
+
await assert.rejects(collect('data: {"partial":\n\n'), SyntaxError);
|
|
26
|
+
let canceled = false;
|
|
27
|
+
const body = new ReadableStream({ start(c) { c.enqueue(Buffer.from('data: {"one":1}\n\n')); }, cancel() { canceled = true; } });
|
|
28
|
+
for await (const item of readUpstreamPayloads({ body })) { assert.equal(item.one, 1); break; }
|
|
29
|
+
assert.equal(canceled, true);
|
|
30
|
+
});
|
package/toolvocab.mjs
CHANGED
|
@@ -220,7 +220,55 @@ export function sanitizeToolName(name, { allowHyphens = true } = {}) {
|
|
|
220
220
|
*
|
|
221
221
|
* A list with no tools, or one where nothing can be confused, leaves every call untouched.
|
|
222
222
|
*/
|
|
223
|
-
export function
|
|
223
|
+
export function createToolNameMap(names, options = {}) {
|
|
224
|
+
const originals = [...new Set(names.filter(n => typeof n === 'string' && n))].sort();
|
|
225
|
+
const forward = new Map();
|
|
226
|
+
const reverse = new Map();
|
|
227
|
+
// Reserve unchanged names before allocating aliases, independent of declaration order.
|
|
228
|
+
for (const name of originals) {
|
|
229
|
+
if (sanitizeToolName(name, options) === name) { forward.set(name, name); reverse.set(name, name); }
|
|
230
|
+
}
|
|
231
|
+
for (const name of originals) {
|
|
232
|
+
if (forward.has(name)) continue;
|
|
233
|
+
const base = sanitizeToolName(name, options);
|
|
234
|
+
let alias = base;
|
|
235
|
+
for (let salt = 0; reverse.has(alias); salt++) {
|
|
236
|
+
const hash = crypto.createHash('sha256').update(`${name}\0${salt}`).digest('hex').slice(0, 12);
|
|
237
|
+
alias = `${base.slice(0, 51)}_${hash}`;
|
|
238
|
+
}
|
|
239
|
+
forward.set(name, alias); reverse.set(alias, name);
|
|
240
|
+
}
|
|
241
|
+
return { forward, reverse, name: n => forward.get(n) || sanitizeToolName(n, options) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function toolNameMapForIR(ir, options = {}) {
|
|
245
|
+
const names = [...(ir.tools || []).map(t => t.name),
|
|
246
|
+
...(ir.messages || []).flatMap(m => [...(m.toolCalls || []).map(t => t.name), ...(m.name ? [m.name] : [])]),
|
|
247
|
+
...(ir.toolChoice?.name ? [ir.toolChoice.name] : [])];
|
|
248
|
+
return createToolNameMap(names, options);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Rewrite exact name mentions once, using the same collision-resolved map as declarations.
|
|
252
|
+
// A longer identifier containing a name is not a mention of that tool.
|
|
253
|
+
export function rewriteToolNameMentions(text, names) {
|
|
254
|
+
if (typeof text !== 'string') return text;
|
|
255
|
+
const changed = [...names].filter(([from, to]) => from && from !== to);
|
|
256
|
+
if (!changed.length) return text;
|
|
257
|
+
const lookup = new Map(changed);
|
|
258
|
+
const alternatives = changed.map(([from]) => from).sort((a, b) => b.length - a.length)
|
|
259
|
+
.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
260
|
+
const pattern = new RegExp(`(?<![A-Za-z0-9_-])(?:${alternatives.join('|')})(?![A-Za-z0-9_-])`, 'g');
|
|
261
|
+
return text.replace(pattern, name => lookup.get(name));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Only ToolSearch's query names other tools. Arbitrary tool arguments are caller data.
|
|
265
|
+
export function rewriteToolSearchArgs(name, args, names) {
|
|
266
|
+
if (name !== 'ToolSearch' || !args || typeof args !== 'object' || Array.isArray(args) || typeof args.query !== 'string') return args;
|
|
267
|
+
const query = rewriteToolNameMentions(args.query, names);
|
|
268
|
+
return query === args.query ? args : { ...args, query };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function createToolVocab(declared, { allowHyphens = true, names = [] } = {}) {
|
|
224
272
|
// Only a tool the caller executes can be a rename target. A tool the provider runs answers in
|
|
225
273
|
// band and its result reaches the caller without the caller executing anything; handing such a
|
|
226
274
|
// call back as a client tool moves the work, silently, to the machine the user is sitting at.
|
|
@@ -229,17 +277,12 @@ export function createToolVocab(declared) {
|
|
|
229
277
|
const byName = new Map();
|
|
230
278
|
const candidates = [];
|
|
231
279
|
const shortenedToOriginal = new Map();
|
|
280
|
+
const aliases = createToolNameMap([...tools.map(t => t.name), ...names], { allowHyphens });
|
|
232
281
|
|
|
233
282
|
for (const t of tools) {
|
|
234
283
|
const caps = capabilitiesOf(t.name);
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
if (safeChat !== t.name) {
|
|
238
|
-
shortenedToOriginal.set(safeChat, t.name);
|
|
239
|
-
}
|
|
240
|
-
if (safeVertex !== t.name) {
|
|
241
|
-
shortenedToOriginal.set(safeVertex, t.name);
|
|
242
|
-
}
|
|
284
|
+
const alias = aliases.forward.get(t.name);
|
|
285
|
+
if (alias !== t.name) shortenedToOriginal.set(alias, t.name);
|
|
243
286
|
const entry = { tool: t, name: t.name, namespace: namespaceOf(t.name), caps };
|
|
244
287
|
// Keyed by the exact spelling: a client checks tool names exactly, so `read` is not the same
|
|
245
288
|
// tool as `Read` to it, and that difference is the whole reason this layer exists.
|
|
@@ -283,7 +326,7 @@ export function createToolVocab(declared) {
|
|
|
283
326
|
|
|
284
327
|
function args(upstream, argsJson) {
|
|
285
328
|
const fixedName = name(upstream);
|
|
286
|
-
if (!useful && !shortenedToOriginal.has(String(upstream || ''))) return argsJson;
|
|
329
|
+
if (!useful && !shortenedToOriginal.has(String(upstream || '')) && !needsArgsRewrite(upstream)) return argsJson;
|
|
287
330
|
const target = byName.get(fixedName);
|
|
288
331
|
if (!target) return argsJson;
|
|
289
332
|
let parsed;
|
|
@@ -292,15 +335,20 @@ export function createToolVocab(declared) {
|
|
|
292
335
|
} catch {
|
|
293
336
|
return argsJson; // a truncated payload is passed through: changing bytes helps nobody
|
|
294
337
|
}
|
|
295
|
-
const repaired = repairArgs(parsed, target.tool);
|
|
338
|
+
const repaired = rewriteToolSearchArgs(fixedName, repairArgs(parsed, target.tool), shortenedToOriginal);
|
|
296
339
|
if (repaired === parsed) return argsJson;
|
|
297
340
|
return JSON.stringify(repaired);
|
|
298
341
|
}
|
|
299
342
|
|
|
343
|
+
function needsArgsRewrite(upstream) {
|
|
344
|
+
return name(upstream) === 'ToolSearch' && byName.has('ToolSearch') && shortenedToOriginal.size > 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
300
347
|
return {
|
|
301
348
|
name,
|
|
302
349
|
args,
|
|
303
350
|
isShortened,
|
|
351
|
+
needsArgsRewrite,
|
|
304
352
|
/** Both halves at once, for a call that is already whole. */
|
|
305
353
|
repair(upstream, argsJson) {
|
|
306
354
|
const fixedName = name(upstream);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SSE dispatches one event at a blank line; data fields are joined with newlines.
|
|
2
|
+
// Vertex also sends bare JSON lines. Keep both framings without parsing data fields separately.
|
|
3
|
+
export async function* readUpstreamPayloads(upstreamRes) {
|
|
4
|
+
const reader = upstreamRes.body.getReader();
|
|
5
|
+
const decoder = new TextDecoder();
|
|
6
|
+
let buffer = '', data = [], finished = false;
|
|
7
|
+
const parse = value => {
|
|
8
|
+
const text = value.trim();
|
|
9
|
+
if (!text || text === '[DONE]') return null;
|
|
10
|
+
return JSON.parse(text);
|
|
11
|
+
};
|
|
12
|
+
function line(value) {
|
|
13
|
+
if (value.endsWith('\r')) value = value.slice(0, -1);
|
|
14
|
+
if (!value) { const payload = data.join('\n'); data = []; return parse(payload); }
|
|
15
|
+
if (value.startsWith('data:')) { data.push(value.slice(5).replace(/^ /, '')); return null; }
|
|
16
|
+
if (value.startsWith('{') && !data.length) return parse(value);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
while (true) {
|
|
21
|
+
const chunk = await reader.read();
|
|
22
|
+
if (chunk.done) { finished = true; buffer += decoder.decode(); break; }
|
|
23
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
24
|
+
let newline;
|
|
25
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
26
|
+
const payload = line(buffer.slice(0, newline)); buffer = buffer.slice(newline + 1);
|
|
27
|
+
if (payload) yield payload;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (buffer) { const payload = line(buffer); if (payload) yield payload; }
|
|
31
|
+
// Tolerate an upstream's missing final blank line; completion is validated by the caller.
|
|
32
|
+
const tail = parse(data.join('\n')); if (tail) yield tail;
|
|
33
|
+
} finally {
|
|
34
|
+
if (!finished) await reader.cancel().catch(() => {});
|
|
35
|
+
reader.releaseLock();
|
|
36
|
+
}
|
|
37
|
+
}
|