opencode-zen-freex 0.0.0-stage → 0.2.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.
@@ -0,0 +1,881 @@
1
+ const MAX_BYTES = 32 * 1024 * 1024;
2
+ const encoder = new TextEncoder();
3
+
4
+ class GuardError extends Error {}
5
+
6
+ function headersFor(response) {
7
+ const headers = new Headers(response.headers);
8
+ headers.delete("content-length");
9
+ headers.delete("content-encoding");
10
+ return headers;
11
+ }
12
+
13
+ function failure(protocol, message, stream = false) {
14
+ if (protocol === "anthropic") return { type: "error", error: { type: "api_error", message } };
15
+ if (protocol === "responses") {
16
+ return stream
17
+ ? { type: "error", code: "undeclared_tool", message }
18
+ : { error: { type: "upstream_error", code: "undeclared_tool", message } };
19
+ }
20
+ return { error: { message, type: "upstream_error" } };
21
+ }
22
+
23
+ function object(value) {
24
+ return value !== null && typeof value === "object" && !Array.isArray(value);
25
+ }
26
+
27
+ function list(value) {
28
+ if (value == null) return [];
29
+ if (!Array.isArray(value)) throw new GuardError("Invalid upstream tool snapshot");
30
+ return value;
31
+ }
32
+
33
+ // OpenCode's own models call OpenCode's tool names (bash, todowrite,
34
+ // webfetch) whatever the agent declared. A call is given the agent's tool
35
+ // whose name differs only in case, or else only in case and separators
36
+ // (todowrite → todo_write, webfetch → web_fetch), when exactly one does; a
37
+ // name two of the agent's tools fold to is never guessed between.
38
+ const separators = /[\s_.-]/g;
39
+ function foldsTo(declared, key) {
40
+ const folded = new Map();
41
+ for (const name of declared) {
42
+ const k = key(name);
43
+ // A name of separators only folds to nothing, and would be handed any
44
+ // other such call ("_" for "--"): it matches only itself.
45
+ if (!k) continue;
46
+ folded.set(k, folded.has(k) ? null : name);
47
+ }
48
+ return folded;
49
+ }
50
+
51
+ // nameGuard answers the agent's name for a call, or null for a call of no
52
+ // tool the agent has (a missing name included).
53
+ function nameGuard(policy) {
54
+ const declared = new Set(policy.declared);
55
+ const lower = (name) => name.toLowerCase();
56
+ const bare = (name) => name.toLowerCase().replace(separators, "");
57
+ const byCase = foldsTo(declared, lower);
58
+ const byShape = foldsTo(declared, bare);
59
+ return (name) => {
60
+ if (typeof name !== "string" || !name) return null;
61
+ if (declared.has(name)) return name;
62
+ const match =
63
+ byCase.get(lower(name)) ?? (byCase.has(lower(name)) ? null : byShape.get(bare(name)));
64
+ return match || null;
65
+ };
66
+ }
67
+
68
+ // The models also call tools no agent has: bedit, bcp, ash, a mangled
69
+ // mcp__netcatcatty__unused (#76). Such a call can't be run or named onto
70
+ // one the agent has, and failing the reply for it threw away the whole
71
+ // turn. It is left out instead, and the reply says so in its text, where
72
+ // the agent's user and the model's next turn both read it. A reply left
73
+ // with no call ends as a plain stop.
74
+ export function droppedNote(name) {
75
+ return typeof name === "string" && name
76
+ ? `(The model called a tool this agent doesn't have: ${JSON.stringify(name.slice(0, 128))}. The call was left out.)`
77
+ : "(The model called a tool without a name. The call was left out.)";
78
+ }
79
+
80
+ function appendNotes(message, notes) {
81
+ const text = notes.join("\n\n");
82
+ if (typeof message.content === "string" && message.content) {
83
+ message.content = `${message.content}\n\n${text}`;
84
+ } else if (Array.isArray(message.content)) {
85
+ message.content.push({ type: "text", text });
86
+ } else {
87
+ message.content = text;
88
+ }
89
+ }
90
+
91
+ // checkChatMessage renames message's calls to the agent's tools and leaves
92
+ // out the rest, answering the notes for those left out.
93
+ function checkChatMessage(message, checkName) {
94
+ const result = { changed: false, notes: [] };
95
+ if (!object(message)) return result;
96
+ if (message.function_call != null) {
97
+ const value = message.function_call;
98
+ if (!object(value)) throw new GuardError("Invalid upstream function call");
99
+ const name = checkName(value.name);
100
+ if (name == null) {
101
+ result.notes.push(droppedNote(value.name));
102
+ delete message.function_call;
103
+ result.changed = true;
104
+ } else if (name !== value.name) {
105
+ value.name = name;
106
+ result.changed = true;
107
+ }
108
+ }
109
+ if (message.tool_calls != null) {
110
+ const calls = list(message.tool_calls);
111
+ const kept = [];
112
+ for (const call of calls) {
113
+ if (!object(call) || (call.type != null && call.type !== "function")) {
114
+ throw new GuardError("Unsupported upstream tool call");
115
+ }
116
+ if (!object(call.function)) throw new GuardError("Invalid upstream function call");
117
+ const name = checkName(call.function.name);
118
+ if (name == null) {
119
+ result.notes.push(droppedNote(call.function.name));
120
+ result.changed = true;
121
+ continue;
122
+ }
123
+ if (name !== call.function.name) {
124
+ call.function.name = name;
125
+ result.changed = true;
126
+ }
127
+ kept.push(call);
128
+ }
129
+ if (kept.length !== calls.length) {
130
+ if (kept.length) message.tool_calls = kept;
131
+ else delete message.tool_calls;
132
+ }
133
+ }
134
+ return result;
135
+ }
136
+
137
+ const callFinish = new Set(["tool_calls", "function_call"]);
138
+ function hasCalls(message) {
139
+ return message.function_call != null || (message.tool_calls?.length ?? 0) > 0;
140
+ }
141
+
142
+ function isCall(protocol, value) {
143
+ return (
144
+ (protocol === "responses" && value.type === "function_call") ||
145
+ (protocol === "anthropic" && ["tool_use", "server_tool_use"].includes(value.type))
146
+ );
147
+ }
148
+
149
+ // What a call left out of a Responses or Messages reply becomes: text
150
+ // saying so, in the call's place, so no later index moves.
151
+ function inPlaceOf(protocol, call, note) {
152
+ if (protocol === "anthropic") return { type: "text", text: note };
153
+ return {
154
+ type: "message",
155
+ id: typeof call.id === "string" && call.id ? call.id : `msg_${call.call_id ?? "dropped"}`,
156
+ role: "assistant",
157
+ status: call.status ?? "completed",
158
+ content: [{ type: "output_text", text: note, annotations: [] }],
159
+ };
160
+ }
161
+
162
+ // checkSnapshots checks every call in a reply or event: renamed to the
163
+ // agent's tool, or (Responses, Messages) put in place of by a note. It
164
+ // answers whether data changed, how many calls it kept and how many it
165
+ // left out.
166
+ function checkSnapshots(data, protocol, checkName) {
167
+ let changed = false;
168
+ if (protocol === "chat") {
169
+ for (const choice of list(data.choices)) {
170
+ if (!object(choice)) throw new GuardError("Invalid upstream choice");
171
+ const message = choice.message;
172
+ const checked = checkChatMessage(message, checkName);
173
+ changed = checked.changed || changed;
174
+ if (checked.notes.length) {
175
+ appendNotes(message, checked.notes);
176
+ if (!hasCalls(message) && callFinish.has(choice.finish_reason))
177
+ choice.finish_reason = "stop";
178
+ }
179
+ }
180
+ return { changed, kept: 0, left: 0 };
181
+ }
182
+ let kept = 0;
183
+ let left = 0;
184
+ if (!object(data)) throw new GuardError("Invalid upstream tool snapshot");
185
+ // Each entry is a value and where it sits, so a call can be replaced.
186
+ const stack = [[data, null, null]];
187
+ while (stack.length) {
188
+ const [value, holder, key] = stack.pop();
189
+ if (!object(value)) throw new GuardError("Invalid upstream tool snapshot");
190
+ if (
191
+ protocol === "responses" &&
192
+ typeof value.type === "string" &&
193
+ value.type.endsWith("_call") &&
194
+ value.type !== "function_call"
195
+ ) {
196
+ throw new GuardError("Unsupported upstream tool call type");
197
+ }
198
+ if (isCall(protocol, value)) {
199
+ const name = checkName(value.name);
200
+ if (name == null) {
201
+ if (!holder) throw new GuardError(droppedNote(value.name));
202
+ holder[key] = inPlaceOf(protocol, value, droppedNote(value.name));
203
+ changed = true;
204
+ left++;
205
+ continue;
206
+ }
207
+ if (name !== value.name) {
208
+ value.name = name;
209
+ changed = true;
210
+ }
211
+ kept++;
212
+ }
213
+ const arrays = protocol === "responses" ? ["output", "content"] : ["content"];
214
+ const objects =
215
+ protocol === "responses" ? ["response", "item", "part"] : ["message", "content_block"];
216
+ for (const name of arrays) {
217
+ // Text content is not a snapshot container.
218
+ if (typeof value[name] === "string") continue;
219
+ const items = list(value[name]);
220
+ for (let i = 0; i < items.length; i++) stack.push([items[i], items, i]);
221
+ }
222
+ for (const name of objects) {
223
+ if (value[name] != null) stack.push([value[name], value, name]);
224
+ }
225
+ }
226
+ if (
227
+ protocol === "anthropic" &&
228
+ left &&
229
+ !kept &&
230
+ data.type === "message" &&
231
+ data.stop_reason === "tool_use"
232
+ ) {
233
+ data.stop_reason = "end_turn";
234
+ }
235
+ return { changed, kept, left };
236
+ }
237
+
238
+ function position(value) {
239
+ const result = value ?? 0;
240
+ if (!Number.isSafeInteger(result) || result < 0)
241
+ throw new GuardError("Invalid upstream tool index");
242
+ return result;
243
+ }
244
+
245
+ function appendFunction(target, fragment) {
246
+ if (!object(fragment)) throw new GuardError("Invalid upstream function delta");
247
+ for (const [key, value] of Object.entries(fragment)) {
248
+ if (key === "name" || key === "arguments") {
249
+ if (typeof value !== "string") throw new GuardError("Invalid upstream function delta");
250
+ target[key] = (target[key] ?? "") + value;
251
+ } else {
252
+ Object.defineProperty(target, key, {
253
+ value,
254
+ writable: true,
255
+ enumerable: true,
256
+ configurable: true,
257
+ });
258
+ }
259
+ }
260
+ }
261
+
262
+ function chatBuffer(checkName) {
263
+ const pending = new Map();
264
+ let bytes = 0;
265
+ function charge(state, value) {
266
+ const size = encoder.encode(JSON.stringify(value)).byteLength;
267
+ bytes += size;
268
+ state.bytes += size;
269
+ if (bytes > MAX_BYTES)
270
+ throw new GuardError("Pending upstream tools exceed the 32 MiB buffer limit");
271
+ }
272
+ function stateFor(index, data) {
273
+ if (!pending.has(index)) {
274
+ const metadata = {};
275
+ for (const key of [
276
+ "id",
277
+ "object",
278
+ "created",
279
+ "model",
280
+ "system_fingerprint",
281
+ "service_tier",
282
+ ]) {
283
+ if (data[key] !== undefined) metadata[key] = data[key];
284
+ }
285
+ const state = { calls: new Map(), legacy: null, metadata, bytes: 0 };
286
+ pending.set(index, state);
287
+ charge(state, metadata);
288
+ }
289
+ return pending.get(index);
290
+ }
291
+ // Choices that streamed text, and those whose every call was left out
292
+ // (whose later "tool_calls" finish is a stop).
293
+ const wrote = new Set();
294
+ const ended = new Set();
295
+ function complete(state, index) {
296
+ const delta = {};
297
+ if (state.calls.size) {
298
+ delta.tool_calls = [...state.calls].sort(([a], [b]) => a - b).map(([, value]) => value);
299
+ }
300
+ if (state.legacy) delta.function_call = state.legacy;
301
+ const { notes } = checkChatMessage(delta, checkName);
302
+ if (notes.length) {
303
+ delta.tool_calls?.forEach((call, i) => {
304
+ call.index = i;
305
+ });
306
+ delta.content = (wrote.has(index) ? "\n\n" : "") + notes.join("\n\n");
307
+ if (!hasCalls(delta)) ended.add(index);
308
+ }
309
+ return delta;
310
+ }
311
+ function remove(index) {
312
+ const state = pending.get(index);
313
+ if (state) bytes -= state.bytes;
314
+ pending.delete(index);
315
+ }
316
+ return {
317
+ get size() {
318
+ return pending.size;
319
+ },
320
+ clear() {
321
+ pending.clear();
322
+ bytes = 0;
323
+ },
324
+ apply(data) {
325
+ let { changed } = checkSnapshots(data, "chat", checkName);
326
+ const choices = list(data.choices);
327
+ for (const choice of choices) {
328
+ const delta = choice.delta;
329
+ if (!object(delta)) continue;
330
+ if (typeof delta.content === "string" && delta.content) wrote.add(position(choice.index));
331
+ if (delta.tool_calls == null && delta.function_call == null) continue;
332
+ changed = true;
333
+ const state = stateFor(position(choice.index), data);
334
+ for (const fragment of list(delta.tool_calls)) {
335
+ if (!object(fragment) || (fragment.type != null && fragment.type !== "function")) {
336
+ throw new GuardError("Unsupported upstream tool delta");
337
+ }
338
+ charge(state, fragment);
339
+ const index = position(fragment.index);
340
+ const previous = state.calls.get(index) ?? { index, function: {} };
341
+ const value = { ...previous, ...fragment, index, function: previous.function };
342
+ if (fragment.function != null) appendFunction(value.function, fragment.function);
343
+ state.calls.set(index, value);
344
+ }
345
+ if (delta.function_call != null) {
346
+ charge(state, delta.function_call);
347
+ state.legacy ??= {};
348
+ appendFunction(state.legacy, delta.function_call);
349
+ }
350
+ delete delta.tool_calls;
351
+ delete delta.function_call;
352
+ }
353
+ // Validate every finishing choice before publishing any part of this event.
354
+ for (const choice of choices) {
355
+ if (choice.finish_reason == null) continue;
356
+ const index = position(choice.index);
357
+ const state = pending.get(index);
358
+ if (!state) continue;
359
+ const own = object(choice.delta) ? choice.delta : {};
360
+ const delta = complete(state, index);
361
+ if (typeof own.content === "string" && delta.content)
362
+ delta.content = own.content + delta.content;
363
+ choice.delta = { ...own, ...delta };
364
+ remove(index);
365
+ changed = true;
366
+ }
367
+ for (const choice of choices) {
368
+ if (callFinish.has(choice.finish_reason) && ended.has(position(choice.index))) {
369
+ choice.finish_reason = "stop";
370
+ changed = true;
371
+ }
372
+ }
373
+ if (!changed) return { data, changed: false };
374
+ data.choices = choices.filter(
375
+ (choice) =>
376
+ choice.finish_reason != null ||
377
+ Object.keys(choice.delta ?? {}).length ||
378
+ Object.keys(choice).some((key) => !["index", "delta", "finish_reason"].includes(key)),
379
+ );
380
+ return { data: data.choices.length || data.usage != null ? data : null, changed: true };
381
+ },
382
+ finish() {
383
+ if (!pending.size) return null;
384
+ const choices = [...pending]
385
+ .sort(([a], [b]) => a - b)
386
+ .map(([index, state]) => ({ index, delta: complete(state, index), finish_reason: null }));
387
+ const metadata = pending.values().next().value.metadata;
388
+ pending.clear();
389
+ bytes = 0;
390
+ return { ...metadata, choices };
391
+ },
392
+ };
393
+ }
394
+
395
+ // snapshotStream checks a Responses or Messages stream event by event. A
396
+ // call left out is streamed as text in its place: its item or block starts
397
+ // as text carrying the note, and its arguments are never sent. A message
398
+ // whose every call was left out stops as a turn's end, not as tool use.
399
+ function snapshotStream(protocol, checkName) {
400
+ const left = new Map(); // output_index or content block index → true
401
+ const leftIDs = new Set(); // Responses item ids left out
402
+ let kept = 0;
403
+ let dropped = 0;
404
+ return {
405
+ apply(data, type) {
406
+ if (protocol === "responses") {
407
+ const item = data.item;
408
+ if (
409
+ type === "response.output_item.added" &&
410
+ object(item) &&
411
+ item.type === "function_call" &&
412
+ checkName(item.name) == null
413
+ ) {
414
+ const note = droppedNote(item.name);
415
+ const message = inPlaceOf(protocol, item, note);
416
+ left.set(data.output_index, true);
417
+ leftIDs.add(message.id);
418
+ if (typeof item.id === "string") leftIDs.add(item.id);
419
+ dropped++;
420
+ const where = { item_id: message.id, output_index: data.output_index, content_index: 0 };
421
+ const part = { type: "output_text", text: "", annotations: [] };
422
+ return {
423
+ changed: true,
424
+ data: { ...data, item: { ...message, status: "in_progress", content: [] } },
425
+ extra: [
426
+ { type: "response.content_part.added", ...where, part },
427
+ { type: "response.output_text.delta", ...where, delta: note },
428
+ { type: "response.output_text.done", ...where, text: note },
429
+ { type: "response.content_part.done", ...where, part: { ...part, text: note } },
430
+ ],
431
+ };
432
+ }
433
+ if (
434
+ typeof type === "string" &&
435
+ type.startsWith("response.function_call_arguments.") &&
436
+ ((data.output_index != null && left.has(data.output_index)) || leftIDs.has(data.item_id))
437
+ ) {
438
+ return { changed: true, data: null };
439
+ }
440
+ } else {
441
+ if (type === "message_start") {
442
+ left.clear();
443
+ kept = 0;
444
+ dropped = 0;
445
+ }
446
+ const block = data.content_block;
447
+ if (
448
+ type === "content_block_start" &&
449
+ object(block) &&
450
+ isCall(protocol, block) &&
451
+ checkName(block.name) == null
452
+ ) {
453
+ left.set(data.index, true);
454
+ dropped++;
455
+ return {
456
+ changed: true,
457
+ data: { ...data, content_block: { type: "text", text: "" } },
458
+ extra: [
459
+ {
460
+ type: "content_block_delta",
461
+ index: data.index,
462
+ delta: { type: "text_delta", text: droppedNote(block.name) },
463
+ },
464
+ ],
465
+ };
466
+ }
467
+ if (type === "content_block_delta" && left.has(data.index))
468
+ return { changed: true, data: null };
469
+ if (
470
+ type === "message_delta" &&
471
+ object(data.delta) &&
472
+ data.delta.stop_reason === "tool_use" &&
473
+ dropped &&
474
+ !kept
475
+ ) {
476
+ data.delta.stop_reason = "end_turn";
477
+ return { changed: true, data };
478
+ }
479
+ }
480
+ const checked = checkSnapshots(data, protocol, checkName);
481
+ kept += checked.kept;
482
+ dropped += checked.left;
483
+ return { changed: checked.changed, data };
484
+ },
485
+ };
486
+ }
487
+
488
+ function parseJSON(text) {
489
+ let value;
490
+ try {
491
+ value = JSON.parse(text);
492
+ } catch {
493
+ throw new GuardError("Invalid JSON in upstream response");
494
+ }
495
+ if (!object(value)) throw new GuardError("Invalid JSON object in upstream response");
496
+ return value;
497
+ }
498
+
499
+ function encodeEvent(data, name = "") {
500
+ return `${name ? `event: ${name}\n` : ""}data: ${JSON.stringify(data)}\n\n`;
501
+ }
502
+
503
+ function rewrite(frame, data) {
504
+ const lines = frame.lines.filter((line) => line !== "data" && !line.startsWith("data:"));
505
+ lines.push(`data: ${JSON.stringify(data)}`);
506
+ return `${lines.join("\n")}\n\n`;
507
+ }
508
+
509
+ async function* framesFrom(reader) {
510
+ let parts = [];
511
+ let lineBytes = 0;
512
+ let eventBytes = 0;
513
+ let lines = [];
514
+ let rawLines = [];
515
+ let skipLF = false;
516
+ let pendingFrame;
517
+ let firstLine = true;
518
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
519
+ function append(value) {
520
+ eventBytes += value.byteLength;
521
+ if (eventBytes > MAX_BYTES)
522
+ throw new GuardError("Upstream SSE event exceeds the 32 MiB buffer limit");
523
+ if (value.byteLength) {
524
+ parts.push(value);
525
+ lineBytes += value.byteLength;
526
+ }
527
+ }
528
+ while (true) {
529
+ const { done, value } = await reader.read();
530
+ if (done) {
531
+ if (pendingFrame) {
532
+ yield pendingFrame;
533
+ pendingFrame = null;
534
+ eventBytes = 0;
535
+ }
536
+ if (eventBytes || lineBytes || lines.length)
537
+ throw new GuardError("Upstream SSE ended inside an event");
538
+ return;
539
+ }
540
+ let start = 0;
541
+ if (skipLF && value.length) {
542
+ if (value[0] === 10) {
543
+ eventBytes++;
544
+ if (pendingFrame) pendingFrame.raw += "\n";
545
+ else rawLines[rawLines.length - 1] += "\n";
546
+ start = 1;
547
+ }
548
+ if (eventBytes > MAX_BYTES)
549
+ throw new GuardError("Upstream SSE event exceeds the 32 MiB buffer limit");
550
+ skipLF = false;
551
+ if (pendingFrame) {
552
+ const frame = pendingFrame;
553
+ pendingFrame = null;
554
+ eventBytes = 0;
555
+ yield frame;
556
+ }
557
+ }
558
+ for (let offset = start; offset < value.length; offset++) {
559
+ if (value[offset] !== 10 && value[offset] !== 13) continue;
560
+ append(value.subarray(start, offset));
561
+ if (++eventBytes > MAX_BYTES)
562
+ throw new GuardError("Upstream SSE event exceeds the 32 MiB buffer limit");
563
+ const bytes = new Uint8Array(lineBytes);
564
+ let cursor = 0;
565
+ for (const part of parts) {
566
+ bytes.set(part, cursor);
567
+ cursor += part.length;
568
+ }
569
+ let line;
570
+ try {
571
+ line = decoder.decode(bytes);
572
+ } catch {
573
+ throw new GuardError("Invalid UTF-8 in upstream SSE event");
574
+ }
575
+ parts = [];
576
+ lineBytes = 0;
577
+ let rawLine = line + String.fromCharCode(value[offset]);
578
+ if (firstLine) {
579
+ line = line.replace(/^\uFEFF/, "");
580
+ firstLine = false;
581
+ }
582
+ if (value[offset] === 13) {
583
+ if (value[offset + 1] === 10) {
584
+ rawLine += "\n";
585
+ offset++;
586
+ if (++eventBytes > MAX_BYTES)
587
+ throw new GuardError("Upstream SSE event exceeds the 32 MiB buffer limit");
588
+ } else if (offset + 1 === value.length) skipLF = true;
589
+ }
590
+ start = offset + 1;
591
+ rawLines.push(rawLine);
592
+ if (line) {
593
+ lines.push(line);
594
+ } else {
595
+ const frameLines = lines;
596
+ lines = [];
597
+ let name = "";
598
+ const data = [];
599
+ for (const item of frameLines) {
600
+ const colon = item.indexOf(":");
601
+ const key = colon < 0 ? item : item.slice(0, colon);
602
+ let content = colon < 0 ? "" : item.slice(colon + 1);
603
+ if (content.startsWith(" ")) content = content.slice(1);
604
+ if (key === "event") name = content;
605
+ if (key === "data") data.push(content);
606
+ }
607
+ const frame = {
608
+ lines: frameLines,
609
+ name,
610
+ text: data.join("\n"),
611
+ raw: rawLines.join(""),
612
+ };
613
+ rawLines = [];
614
+ if (skipLF) {
615
+ // Preserve a trailing CRLF even when its LF arrives in the next chunk.
616
+ pendingFrame = frame;
617
+ } else {
618
+ eventBytes = 0;
619
+ yield frame;
620
+ }
621
+ }
622
+ }
623
+ append(value.subarray(start));
624
+ }
625
+ }
626
+
627
+ // A Chat stream Zen closed before its [DONE] (#91: space-bunny-free, about
628
+ // 0.1% of turns) is ended as a turn cut short when it has said something
629
+ // and no tool call of it is pending: each choice still open gets a note
630
+ // saying so and a stop, then [DONE]. Failing it threw away the reasoning
631
+ // and text the agent already had, and a Chat reply through Zen can't be
632
+ // asked to go on from there. A pending call's arguments may be half
633
+ // written, and a reply that said nothing is an empty one, so both still
634
+ // fail, as does one collapsed for a client that doesn't stream, which can
635
+ // still be asked again. Anthropic and Responses streams cut short fail
636
+ // too: magpie's gateway tells such a reply from a whole one itself
637
+ // (#370), and goes on with it where the upstream can (an Anthropic one),
638
+ // which a made-up end here would stop.
639
+ export const cutShortNote =
640
+ "(The reply was cut short: Zen closed the stream before it finished. Ask again to go on.)";
641
+
642
+ function chatTrack() {
643
+ const open = new Set();
644
+ const finished = new Set();
645
+ const wrote = new Set();
646
+ let said = false;
647
+ let meta = null;
648
+ return {
649
+ see(data) {
650
+ if (!object(data)) return;
651
+ meta = {
652
+ id: data.id ?? meta?.id,
653
+ created: data.created ?? meta?.created,
654
+ model: data.model ?? meta?.model,
655
+ };
656
+ for (const choice of Array.isArray(data.choices) ? data.choices : []) {
657
+ if (!object(choice)) continue;
658
+ const index = position(choice.index);
659
+ const delta = object(choice.delta) ? choice.delta : {};
660
+ for (const key of ["content", "reasoning_content", "reasoning"]) {
661
+ if (typeof delta[key] === "string" && delta[key]) said = true;
662
+ }
663
+ if (typeof delta.content === "string" && delta.content) wrote.add(index);
664
+ if (choice.finish_reason != null) {
665
+ finished.add(index);
666
+ open.delete(index);
667
+ } else if (!finished.has(index)) open.add(index);
668
+ }
669
+ },
670
+ // ending answers the frames that end the reply as cut short, or null
671
+ // when it can't be.
672
+ ending() {
673
+ if (!said) return null;
674
+ const choices = [...open]
675
+ .sort((a, b) => a - b)
676
+ .map((index) => ({
677
+ index,
678
+ delta: { content: (wrote.has(index) ? "\n\n" : "") + cutShortNote },
679
+ finish_reason: "stop",
680
+ }));
681
+ const head = {
682
+ id: meta?.id ?? "chatcmpl-cut-short",
683
+ object: "chat.completion.chunk",
684
+ created: meta?.created ?? Math.floor(Date.now() / 1000),
685
+ model: meta?.model ?? "",
686
+ };
687
+ return (choices.length ? encodeEvent({ ...head, choices }) : "") + "data: [DONE]\n\n";
688
+ },
689
+ };
690
+ }
691
+
692
+ function guardedStream(response, protocol, checkName, endCutShort) {
693
+ const source = response.body?.getReader();
694
+ // What came before a stream cut short, for its error to say (#91).
695
+ let bytes = 0;
696
+ let events = 0;
697
+ let replyID;
698
+ const reader = source && {
699
+ async read() {
700
+ const result = await source.read();
701
+ if (result.value) bytes += result.value.byteLength;
702
+ return result;
703
+ },
704
+ releaseLock: () => source.releaseLock(),
705
+ cancel: (reason) => source.cancel(reason),
706
+ };
707
+ const chat = protocol === "chat" ? chatBuffer(checkName) : null;
708
+ const track = chat && endCutShort ? chatTrack() : null;
709
+ const snapshots = chat ? null : snapshotStream(protocol, checkName);
710
+ let frames = reader ? framesFrom(reader) : null;
711
+ let completed = false;
712
+ let stopped = false;
713
+ let released = false;
714
+ let cancellation;
715
+ function release() {
716
+ if (reader && !released) {
717
+ reader.releaseLock();
718
+ released = true;
719
+ }
720
+ chat?.clear();
721
+ frames = null;
722
+ }
723
+ function cancel(reason) {
724
+ cancellation ??= (async () => {
725
+ try {
726
+ await reader?.cancel(reason);
727
+ } finally {
728
+ release();
729
+ }
730
+ })();
731
+ return cancellation;
732
+ }
733
+ return new ReadableStream(
734
+ {
735
+ async pull(controller) {
736
+ try {
737
+ while (!stopped) {
738
+ const next = frames ? await frames.next() : { done: true };
739
+ if (stopped) return;
740
+ if (next.done) {
741
+ const ending = !completed && !chat?.size ? track?.ending() : null;
742
+ if (ending) {
743
+ stopped = true;
744
+ release();
745
+ controller.enqueue(encoder.encode(ending));
746
+ controller.close();
747
+ return;
748
+ }
749
+ if (!completed || chat?.size) {
750
+ const came = [
751
+ replyID ? `reply ${String(replyID).slice(0, 128)}` : "",
752
+ `${events} event${events === 1 ? "" : "s"}`,
753
+ `${bytes} bytes received`,
754
+ chat?.size ? "a tool call unfinished" : "",
755
+ ].filter(Boolean);
756
+ throw new GuardError(
757
+ `Upstream SSE ended without a completion marker (${came.join(", ")})`,
758
+ );
759
+ }
760
+ stopped = true;
761
+ release();
762
+ controller.close();
763
+ return;
764
+ }
765
+ const frame = next.value;
766
+ if (frame.name !== "error" && !frame.text.trim()) {
767
+ controller.enqueue(encoder.encode(frame.raw));
768
+ return;
769
+ }
770
+ if (frame.name !== "error" && frame.text.trim() === "[DONE]") {
771
+ if (protocol !== "chat")
772
+ throw new GuardError("Unexpected upstream completion marker");
773
+ const final = chat.finish();
774
+ completed = true;
775
+ controller.enqueue(encoder.encode((final ? encodeEvent(final) : "") + frame.raw));
776
+ return;
777
+ }
778
+ events++;
779
+ const data = frame.name === "error" ? null : parseJSON(frame.text);
780
+ const type = data?.type ?? frame.name;
781
+ replyID = data?.id ?? data?.message?.id ?? data?.response?.id ?? replyID;
782
+ if (
783
+ frame.name === "error" ||
784
+ type === "error" ||
785
+ data?.error ||
786
+ type === "response.failed" ||
787
+ data?.response?.status === "failed"
788
+ ) {
789
+ stopped = true;
790
+ try {
791
+ await cancel();
792
+ } catch (cause) {
793
+ controller.error(cause);
794
+ return;
795
+ }
796
+ controller.enqueue(encoder.encode(frame.raw));
797
+ controller.close();
798
+ return;
799
+ }
800
+ const checked = chat ? chat.apply(data) : snapshots.apply(data, type);
801
+ const { changed, data: result, extra = [] } = checked;
802
+ if (result) track?.see(result);
803
+ if (
804
+ (protocol === "responses" &&
805
+ ["response.completed", "response.incomplete"].includes(type)) ||
806
+ (protocol === "anthropic" && type === "message_stop")
807
+ )
808
+ completed = true;
809
+ if (result || extra.length) {
810
+ const own = !result ? "" : changed ? rewrite(frame, result) : frame.raw;
811
+ const added = extra.map((value) => encodeEvent(value, value.type)).join("");
812
+ controller.enqueue(encoder.encode(own + added));
813
+ return;
814
+ }
815
+ }
816
+ } catch (error) {
817
+ if (stopped) return;
818
+ stopped = true;
819
+ if (error instanceof GuardError) {
820
+ try {
821
+ await cancel(error);
822
+ } catch (cause) {
823
+ controller.error(cause);
824
+ return;
825
+ }
826
+ controller.enqueue(
827
+ encoder.encode(
828
+ encodeEvent(
829
+ failure(protocol, error.message, true),
830
+ protocol === "chat" ? "" : "error",
831
+ ),
832
+ ),
833
+ );
834
+ controller.close();
835
+ } else {
836
+ release();
837
+ controller.error(error);
838
+ }
839
+ }
840
+ },
841
+ async cancel(reason) {
842
+ stopped = true;
843
+ await cancel(reason);
844
+ },
845
+ },
846
+ { highWaterMark: 0 },
847
+ );
848
+ }
849
+
850
+ // options.endCutShort lets a Chat stream cut short end as one (#91): for a
851
+ // client that streams, which has the reply's start already. A reply
852
+ // collapsed for a client that doesn't stream fails instead, so it can be
853
+ // asked again before anything reached the client.
854
+ export async function guardToolResponse(response, protocol, policy, options = {}) {
855
+ if (!response.ok) return response;
856
+ if (!["chat", "responses", "anthropic"].includes(protocol))
857
+ throw new TypeError(`Unsupported protocol: ${protocol}`);
858
+ const checkName = nameGuard(policy);
859
+ const headers = headersFor(response);
860
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
861
+ if (contentType === "text/event-stream") {
862
+ return new Response(guardedStream(response, protocol, checkName, options.endCutShort === true), {
863
+ status: response.status,
864
+ statusText: response.statusText,
865
+ headers,
866
+ });
867
+ }
868
+ const text = await response.text();
869
+ let value;
870
+ let status = response.status;
871
+ try {
872
+ value = parseJSON(text);
873
+ checkSnapshots(value, protocol, checkName);
874
+ } catch (error) {
875
+ if (!(error instanceof GuardError)) throw error;
876
+ status = 502;
877
+ value = failure(protocol, error.message);
878
+ }
879
+ headers.set("content-type", "application/json");
880
+ return new Response(JSON.stringify(value), { status, headers });
881
+ }