opencode-zen-freex 0.2.7 → 0.2.9

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/provider.mjs CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  parseProtocolDocs,
11
11
  } from "./models.mjs";
12
12
  import { observeRouteStatement } from "./limits.mjs";
13
- import { collapseResponse } from "./stream.mjs";
13
+ import { STALE_BODY_HEADERS, collapseResponse } from "./stream.mjs";
14
14
  import { guardToolResponse } from "./tool-response.mjs";
15
15
 
16
16
  // Zen moved behind opencode.ai/inference; everything under /zen/ answers 403
@@ -36,12 +36,28 @@ const PLACEHOLDER =
36
36
  // says which one it is, and that answer is above whatever floor Zen is holding,
37
37
  // so it is passed through; otherwise the generation is taken from the tools
38
38
  // the request actually carries, which is what the gate compares anyway.
39
- const RELEASE = /^opencode\/(?:latest|stable|beta)\/\d[^/]*\/[^/]+$/;
39
+ //
40
+ // Both current shapes answer to this: 1.x sends
41
+ // `opencode/<version> ai-sdk/provider-utils/<version> runtime/bun/<version>`,
42
+ // 2.x sends `opencode/<channel>/<version>/<client>`. Testing only the channel
43
+ // form — which is what this did — silently re-pinned every 1.x host to the
44
+ // version in fingerprint.json, so a host newer than the floor was told, on a
45
+ // 426 or a refusal, that this package was too old to update. A host's own
46
+ // answer is passed through whichever shape it comes in.
47
+ //
48
+ // The version has to start at 1-9 in both: that is what tells a release from a
49
+ // development build (`opencode/dev/0.0.0-dev-1/cli`), whose word is not one Zen
50
+ // would serve and so is not taken.
51
+ const RELEASE = /^opencode\/(?:[a-z-]+\/[1-9][^/]*\/[^/]+|[1-9][^ ]*\s+ai-sdk\/\S+\s+\S+)$/;
40
52
 
41
- function agentForRequest(protocol, tools, given) {
53
+ // The generation and the agent belong together: the agent says which OpenCode
54
+ // this is, and the generation is what the tools in the request say. Deciding
55
+ // them in one place is what `normalizeTools` reports back, so the User-Agent
56
+ // cannot describe a generation the body does not.
57
+ function agentForRequest(protocol, generation, given) {
42
58
  const said = given?.get("user-agent");
43
59
  if (said && RELEASE.test(said)) return said;
44
- return agentFor(generationOf(tools) ?? DEFAULT_GENERATION, protocol);
60
+ return agentFor(generation, protocol);
45
61
  }
46
62
 
47
63
  // The complete set of headers that leaves here, and nothing else: one owner for
@@ -83,6 +99,21 @@ export function outboundHeaders({
83
99
  return headers;
84
100
  }
85
101
 
102
+ // The headers a reply's bytes are described by, for a reply whose body is
103
+ // written again. A response rebuilt from a body this package read no longer is
104
+ // the response that arrived: a length, an encoding and the validators a cache
105
+ // or a conditional request compares against all describe the old bytes only.
106
+ // `stream.mjs` keeps the list, and the two places that write an upstream body
107
+ // again (here, and `tool-response.mjs`) must both use it — a body re-served
108
+ // under the upstream's `content-encoding` cannot be read by the caller at all,
109
+ // which for a refusal is the one message a user has to be able to read.
110
+ function rewrittenHeaders(response) {
111
+ const headers = new Headers(response.headers);
112
+ for (const name of STALE_BODY_HEADERS) headers.delete(name);
113
+ headers.set("content-type", "application/json");
114
+ return headers;
115
+ }
116
+
86
117
  // How long a request for a model the last list didn't have waits before it
87
118
  // asks Zen's list again: a model Zen made free since is served at once,
88
119
  // and a model typed in by hand doesn't ask the list on every request.
@@ -133,13 +164,16 @@ function normalizeTools(body, protocol) {
133
164
  const nameOf = (tool) => tool?.function?.name ?? tool?.name;
134
165
  const present = new Set(body.tools.map(nameOf));
135
166
  const declared = new Set([...present].filter((value) => typeof value === "string"));
136
- if (protocol === "chat" && Array.isArray(body.functions)) {
137
- for (const tool of body.functions) if (typeof tool?.name === "string") declared.add(tool.name);
138
- }
139
- // Zen takes a request for one generation of OpenCode or the other, and the tools
140
- // it carries say which: `shell` is 2.x, `bash` is 1.x. Names move far less
141
- // often than descriptions do, so this survives a rewording that a pinned
142
- // User-Agent would not.
167
+ // The legacy top-level `functions` array is the older way of declaring the
168
+ // same tools, and one a chat host may still use. Its names are declarations,
169
+ // so they count as declared — and, since they are not in `body.tools`, they
170
+ // are also remembered as "do not stand in for this name": the fill loop
171
+ // below looks at where a name already sits, and a name that only arrived
172
+ // through `functions` has no index there. Without this the same tool would go
173
+ // out twice, which is what the note on `nameOf` says must not happen.
174
+ const legacy = Array.isArray(body.functions) && protocol === "chat" ? body.functions : [];
175
+ for (const tool of legacy) if (typeof tool?.name === "string") declared.add(tool.name);
176
+ const declaredElsewhere = new Set(legacy.map((tool) => tool?.name));
143
177
  // Zen takes a request for one generation of OpenCode or the other, and the tools
144
178
  // it carries say which: `shell` is 2.x, `bash` is 1.x. Names move far less
145
179
  // often than descriptions do, so this survives a rewording that a pinned
@@ -173,6 +207,9 @@ function normalizeTools(body, protocol) {
173
207
  // tokens of a tool it will not call, and they are the first thing a
174
208
  // tightened check would otherwise find missing.
175
209
  if (!profile.required.includes(name)) continue;
210
+ // Declared through `functions` rather than `tools`: this host does have the
211
+ // tool, so it is not one to stand in for.
212
+ if (declaredElsewhere.has(name)) continue;
176
213
  filled.push(name);
177
214
  // A tool added here has nothing behind it: the agent has no such tool and
178
215
  // a call to it is dropped. Describing it as OpenCode describes it is what
@@ -187,40 +224,53 @@ function normalizeTools(body, protocol) {
187
224
  // on the other two, and which one a host used is its own business: it is
188
225
  // matched by where the name was found, so a tool sent in the other shape still
189
226
  // has its wording read and replaced.
190
- function descriptionAt(tool) {
191
- return tool?.function ? tool.function.description : tool?.description;
192
- }
193
-
194
227
  function setDescription(tool, description) {
195
228
  if (tool?.function) tool.function.description = description;
196
229
  else if (tool) tool.description = description;
197
230
  }
198
231
 
199
- const shapeOf = (definition, protocol) =>
200
- protocol === "chat"
201
- ? { type: "function", function: definition }
202
- : protocol === "anthropic"
203
- ? { name: definition.name, description: definition.description, input_schema: definition.parameters }
204
- : { type: "function", ...definition };
232
+ function shapeOf(definition, protocol) {
233
+ if (protocol === "chat") return { type: "function", function: definition };
234
+ if (protocol === "anthropic") {
235
+ return {
236
+ name: definition.name,
237
+ description: definition.description,
238
+ input_schema: definition.parameters,
239
+ };
240
+ }
241
+ return { type: "function", ...definition };
242
+ }
205
243
 
206
244
  // Zen's own wording for a refusal says only that the request did not look like
207
245
  // OpenCode's, which sends people off to change headers. Everything the plugin
208
246
  // sends apart from the tool wording is what Zen expects — the endpoint, the
209
247
  // shape of the User-Agent, both session headers — so a refusal this far in is
210
248
  // about the wording, and this says so, and says which wording went out.
211
- async function explain(response, toolPolicy) {
212
- if (response.status !== 403 && response.status !== 426) return response;
213
- const text = await response.text().catch(() => "");
214
- // A 403 that is not Zen's free tier is somebody else's; it is left as sent.
215
- if (response.status === 403 && !text.includes("FreeTierError"))
216
- return new Response(text, response);
249
+ //
250
+ // The body arrives already read: on this path the refusal has to be read for
251
+ // the window it may name before the message that replaces it is built (see
252
+ // limits.mjs), and a response body can only be read once. So the text is passed
253
+ // in rather than read here, and every reply this function rebuilds is built
254
+ // under headers that describe the bytes it returns. A refusal that is not this
255
+ // route's — or one whose body could not be read at all — is handed on as it
256
+ // arrived.
257
+ function explain(text, source, toolPolicy) {
258
+ if (source.status !== 403 && source.status !== 426) return source;
259
+ // Zen's free tier names itself in the body. Without the body there is no way
260
+ // to tell its refusal from anybody else's 403, and this is an error path on a
261
+ // request whose caller is waiting, so the answer is passed on rather than
262
+ // guessed at.
263
+ if (text === null) return source;
264
+ // A 403 that is not Zen's free tier is somebody else's; it is left as sent —
265
+ // its bytes are what arrived, and so are the headers describing them.
266
+ if (source.status === 403 && !text.includes("FreeTierError")) return source;
217
267
  const profile = PROFILES[toolPolicy.generation];
218
268
  const names = Object.keys(profile.tools).join(", ");
219
269
  const { filled } = toolPolicy;
220
270
  // 426 is not a refusal of the wording: it is this plugin presenting itself as
221
271
  // an OpenCode older than Zen accepts, which only a newer copy of it answers.
222
272
  const message =
223
- response.status === 426
273
+ source.status === 426
224
274
  ? `OpenCode Zen answered 426 Upgrade Required: this plugin presents itself as OpenCode ${profile.agent.opencode} and Zen now wants a newer one. Update the plugin, or run inside a newer OpenCode.`
225
275
  : [
226
276
  "OpenCode Zen's free tier refused this request as not coming from OpenCode.",
@@ -233,9 +283,9 @@ async function explain(response, toolPolicy) {
233
283
  return new Response(
234
284
  JSON.stringify({
235
285
  type: "error",
236
- error: { type: response.status === 426 ? "upgrade_required" : "FreeTierError", message },
286
+ error: { type: source.status === 426 ? "upgrade_required" : "FreeTierError", message },
237
287
  }),
238
- { status: response.status, statusText: response.statusText, headers: response.headers },
288
+ { status: source.status, statusText: source.statusText, headers: rewrittenHeaders(source) },
239
289
  );
240
290
  }
241
291
 
@@ -248,12 +298,19 @@ let counter = 0;
248
298
  // for a session, so newer sorts first), then 14 random base62 characters.
249
299
  // Zen's free tier checks a session id has this shape, a small counter
250
300
  // included, and answers 403 FreeTierError to one that doesn't (#100).
301
+ //
302
+ // The counter is the low 12 bits of the id and realSessionID reads it back as
303
+ // a counter, so it is kept inside the 4 bits OpenCode's own counter lives in
304
+ // and wraps there rather than carrying into the time field: the 16th id of one
305
+ // millisecond would otherwise say it was made later than it was, and stop
306
+ // reading as an id OpenCode could have made. A millisecond that produces
307
+ // sixteen ids is a burst, and every id of it still has the shape Zen checks.
251
308
  export function opencodeID(prefix, descending, now = Date.now()) {
252
309
  if (now !== lastStamp) {
253
310
  lastStamp = now;
254
311
  counter = 0;
255
312
  }
256
- counter++;
313
+ counter = counter >= 15 ? 1 : counter + 1;
257
314
  const current = BigInt(now) * 0x1000n + BigInt(counter);
258
315
  const value = (descending ? ~current : current) & 0xffffffffffffn;
259
316
  const random = randomBytes(14);
@@ -287,13 +344,18 @@ export function sessionID(headers, body) {
287
344
  break;
288
345
  }
289
346
  }
290
- if (!signal && typeof body.metadata?.session_id === "string") signal = body.metadata.session_id;
347
+ if (typeof body.metadata?.session_id === "string") signal ??= body.metadata.session_id;
291
348
  if (realSessionID(signal)) return signal;
292
349
  if (!signal) {
293
350
  const messages = body.messages ?? body.input;
294
351
  const first = Array.isArray(messages)
295
352
  ? messages.find((message) => message.role === "user")?.content
296
353
  : messages;
354
+ // Nothing names this conversation: no session header, no metadata, and no
355
+ // user message to key one by. There is no identity to keep for the next
356
+ // turn, so this one gets an id of its own rather than being folded into
357
+ // whichever conversation happened to arrive first — but it is still an id
358
+ // of OpenCode's shape, because that shape is what the tier checks.
297
359
  if (!first) return opencodeID("ses", true);
298
360
  signal = JSON.stringify(first);
299
361
  }
@@ -321,7 +383,10 @@ async function server(_input, options = {}) {
321
383
  );
322
384
  let metadata = bootstrapProvider;
323
385
  let protocols = { ...bootstrapProtocols };
324
- let listed = false;
386
+ // When the list was last read, or last asked for: 0 until the first ask. A
387
+ // failed ask sets it too, so a host whose listing can't be read waits out the
388
+ // same interval as one whose listing can, instead of asking three times with
389
+ // a 15 s timeout each on every request that names a model it doesn't know.
325
390
  let listedAt = 0;
326
391
 
327
392
  async function discover(signal) {
@@ -346,6 +411,7 @@ async function server(_input, options = {}) {
346
411
  partial = true;
347
412
  }
348
413
  if (listing.status !== "fulfilled" || !Array.isArray(listing.value?.data)) {
414
+ listedAt = Date.now();
349
415
  const fallback = { ...known };
350
416
  fallback[Symbol.for("magpie.fellBack")] = true;
351
417
  return fallback;
@@ -368,7 +434,6 @@ async function server(_input, options = {}) {
368
434
  // list there was.
369
435
  }
370
436
  known = discovered;
371
- listed = true;
372
437
  listedAt = Date.now();
373
438
  return discovered;
374
439
  }
@@ -416,10 +481,7 @@ async function server(_input, options = {}) {
416
481
  const body = await request.json();
417
482
  if (!body || typeof body !== "object" || Array.isArray(body))
418
483
  throw new Error("Expected a JSON request object");
419
- if (
420
- !Object.hasOwn(known, body.model) &&
421
- (!listed || Date.now() - listedAt >= RELIST_AFTER)
422
- ) {
484
+ if (!Object.hasOwn(known, body.model) && Date.now() - listedAt >= RELIST_AFTER) {
423
485
  await discover(request.signal);
424
486
  }
425
487
  const model = Object.hasOwn(known, body.model) ? known[body.model] : undefined;
@@ -459,7 +521,10 @@ async function server(_input, options = {}) {
459
521
  const project = _input?.project;
460
522
  const headers = outboundHeaders({
461
523
  session,
462
- agent: agentForRequest(protocol, body.tools, given),
524
+ // The generation the tools in this request say, which is the one
525
+ // normalizeTools has already decided: deciding it twice is how a
526
+ // User-Agent and a body come to describe different generations.
527
+ agent: agentForRequest(protocol, toolPolicy.generation, given),
463
528
  protocol,
464
529
  given,
465
530
  project:
@@ -477,10 +542,31 @@ async function server(_input, options = {}) {
477
542
  // it has to be read before explain() replaces the body. See
478
543
  // limits.mjs: an under-declared window is never refused, it is just
479
544
  // quietly never used.
480
- await observeRouteStatement(body.model, response);
481
- const guarded = await guardToolResponse(await explain(response, toolPolicy), protocol, toolPolicy, {
482
- endCutShort: streaming,
545
+ //
546
+ // A response body can only be read once, so what the limits reader
547
+ // took is handed on as the response the rest of the path works with:
548
+ // the bytes are the ones that arrived, under headers that still
549
+ // describe them, and nothing re-reads a body that is already gone.
550
+ // A response the reader did not consume is used as it arrived.
551
+ const observed = await observeRouteStatement(body.model, response, undefined, {
552
+ // The caller's own cancellation reaches this read as well: a host
553
+ // that has given up is not waiting for a number out of a refusal.
554
+ signal: request.signal,
483
555
  });
556
+ const answered =
557
+ observed === undefined
558
+ ? response
559
+ : new Response(observed.text, {
560
+ status: response.status,
561
+ statusText: response.statusText,
562
+ headers: response.headers,
563
+ });
564
+ const guarded = await guardToolResponse(
565
+ explain(observed?.text ?? null, answered, toolPolicy),
566
+ protocol,
567
+ toolPolicy,
568
+ { endCutShort: streaming },
569
+ );
484
570
  return streaming ? guarded : collapseResponse(guarded, protocol);
485
571
  },
486
572
  };
package/stream.mjs CHANGED
@@ -1,9 +1,29 @@
1
- const MAX_BYTES = 32 * 1024 * 1024;
1
+ // The one byte limit a reply held in memory is read through: a streamed one
2
+ // event by event, a whole one in one read. Exported because tool-response.mjs
3
+ // reads the same bytes through the same limit and names the same number in its
4
+ // failures — a second copy of it is a second thing to keep in step.
5
+ export const MAX_BYTES = 32 * 1024 * 1024;
6
+
7
+ // The headers that describe the bytes a reply arrived as. A reply whose body
8
+ // is written again — a stream collapsed into one JSON document, or a guarded
9
+ // stream whose frames may be rewritten or added to — no longer describes
10
+ // those bytes: a length, an encoding, hop-by-hop framing, and the validators
11
+ // a cache or a conditional request would compare against all describe the
12
+ // old ones only. What says something about the reply itself (a retry-after,
13
+ // a request id) is left alone. One list, so the two places that write a body
14
+ // again cannot drift apart.
15
+ export const STALE_BODY_HEADERS = [
16
+ "content-length",
17
+ "content-encoding",
18
+ "transfer-encoding",
19
+ "content-md5",
20
+ "etag",
21
+ "last-modified",
22
+ ];
2
23
 
3
24
  function jsonResponse(source, protocol, value, error) {
4
25
  const headers = new Headers(source.headers);
5
- headers.delete("content-length");
6
- headers.delete("content-encoding");
26
+ for (const name of STALE_BODY_HEADERS) headers.delete(name);
7
27
  headers.set("content-type", "application/json");
8
28
  if (error) {
9
29
  const detail = { type: "upstream_error", message: error };
@@ -41,6 +61,39 @@ function appendFunction(target, delta) {
41
61
  }
42
62
  }
43
63
 
64
+ // The rule every streamed text field follows: a .delta appends to what came
65
+ // before it, a .done replaces it with the whole text. Written once, so a
66
+ // protocol tweak is applied to all of them together rather than five times.
67
+ function applyText(target, field, value, isDelta) {
68
+ if (isDelta) target[field] = (target[field] ?? "") + (value ?? "");
69
+ else if (value !== undefined) target[field] = value;
70
+ }
71
+
72
+ // How much of a streamed reply a snapshot carries, counted over the fields a
73
+ // stream fills in.
74
+ function weight(snapshot, fields) {
75
+ let total = 0;
76
+ for (const field of fields) {
77
+ const value = snapshot?.[field];
78
+ if (typeof value === "string") total += value.length;
79
+ else if (Array.isArray(value)) total += value.length;
80
+ else if (value && typeof value === "object") total += Object.keys(value).length;
81
+ }
82
+ return total;
83
+ }
84
+
85
+ // Whether a snapshot may stand in for what was accumulated. An upstream puts
86
+ // snapshots beside the deltas they describe — a repeated response.created, a
87
+ // message_start whose block has not been filled in, a terminal response — and
88
+ // one that carries nothing where the accumulation has content is a partial
89
+ // view of it rather than a newer one: taking it would discard what the stream
90
+ // already delivered. One that does carry the collection settles it, which is
91
+ // how response.output_item.done and a whole terminal response replace
92
+ // accumulated arguments with the complete ones.
93
+ function carries(snapshot, accumulated, fields) {
94
+ return weight(snapshot, fields) > 0 || weight(accumulated, fields) === 0;
95
+ }
96
+
44
97
  function chatAccumulator() {
45
98
  const body = { object: "chat.completion", choices: [] };
46
99
  const choices = new Map();
@@ -83,7 +136,11 @@ function chatAccumulator() {
83
136
  }
84
137
  if (fragment.function) appendFunction(call.function, fragment.function);
85
138
  }
86
- if (part.finish_reason != null) choice.finish_reason = part.finish_reason;
139
+ // Both null and undefined are "this chunk carried no finish reason";
140
+ // a loose comparison is the only way to say that in one test, and the
141
+ // project's rules do not allow it.
142
+ if (part.finish_reason !== null && part.finish_reason !== undefined)
143
+ choice.finish_reason = part.finish_reason;
87
144
  if (part.logprobs) {
88
145
  choice.logprobs ??= {};
89
146
  for (const [key, value] of Object.entries(part.logprobs)) {
@@ -108,12 +165,30 @@ function responsesAccumulator() {
108
165
  let body = { object: "response", output: [] };
109
166
  let final;
110
167
  const output = new Map();
168
+ // The fields a streamed item fills in: a message its content, a call its
169
+ // arguments, a reasoning item its summary.
170
+ const fields = ["content", "arguments", "summary", "text"];
171
+ // The slot a frame belongs to: the output_index it carries, or the slot
172
+ // of the item it names when the upstream left output_index out. A frame
173
+ // naming an item the stream never opened has no slot at all — slot 0 is
174
+ // only for a frame that names nothing, which a stream that never indexed
175
+ // its items means as the first one. Merging an unknown item into slot 0
176
+ // was measured handing a function_call there the text of a message.
177
+ function slot(data, id) {
178
+ if (data.output_index !== undefined) return index(data.output_index);
179
+ if (id === undefined || id === null || id === "") return index(undefined);
180
+ const position = [...output].find(([, value]) => value.id === id)?.[0];
181
+ if (position === undefined)
182
+ throw new Error("Upstream frame names an unknown item without an output index");
183
+ return index(position);
184
+ }
185
+ // A snapshot replaces the item in its slot only when it is a whole view of
186
+ // it; a partial one leaves what the deltas delivered standing.
187
+ function keep(position, value) {
188
+ if (carries(value, output.get(position), fields)) output.set(position, value);
189
+ }
111
190
  function item(data, type = "message") {
112
- let position = data.output_index;
113
- if (position === undefined && data.item_id) {
114
- position = [...output].find(([, value]) => value.id === data.item_id)?.[0];
115
- }
116
- position = index(position);
191
+ const position = slot(data, data.item_id);
117
192
  if (!output.has(position)) {
118
193
  const value = { type };
119
194
  if (data.item_id) value.id = data.item_id;
@@ -129,11 +204,14 @@ function responsesAccumulator() {
129
204
  if (type === "response.created" || type === "response.in_progress") {
130
205
  if (data.response) {
131
206
  body = { ...body, ...data.response };
207
+ // A snapshot that carries an item where the reply already has one
208
+ // adds only what it actually carries: the accumulated items stand
209
+ // against an output list it does not carry, or carries empty.
132
210
  for (const [position, value] of (data.response.output ?? []).entries())
133
- output.set(position, value);
211
+ keep(position, value);
134
212
  }
135
213
  } else if (type === "response.output_item.added" || type === "response.output_item.done") {
136
- if (data.item) output.set(index(data.output_index), data.item);
214
+ if (data.item) keep(slot(data, data.item.id), data.item);
137
215
  } else if (type === "response.content_part.added" || type === "response.content_part.done") {
138
216
  const value = item(data);
139
217
  value.content ??= [];
@@ -151,15 +229,15 @@ function responsesAccumulator() {
151
229
  const part = (value.content[index(data.content_index)] ??= refusal
152
230
  ? { type: "refusal", refusal: "" }
153
231
  : { type: "output_text", text: "", annotations: [] });
154
- if (type.endsWith(".delta")) part[field] = (part[field] ?? "") + (data.delta ?? "");
155
- else if (data[field] !== undefined) part[field] = data[field];
232
+ const isDelta = type.endsWith(".delta");
233
+ applyText(part, field, isDelta ? data.delta : data[field], isDelta);
156
234
  } else if (
157
235
  type === "response.function_call_arguments.delta" ||
158
236
  type === "response.function_call_arguments.done"
159
237
  ) {
160
238
  const value = item(data, "function_call");
161
- if (type.endsWith(".delta")) value.arguments = (value.arguments ?? "") + (data.delta ?? "");
162
- else if (data.arguments !== undefined) value.arguments = data.arguments;
239
+ const isDelta = type.endsWith(".delta");
240
+ applyText(value, "arguments", isDelta ? data.delta : data.arguments, isDelta);
163
241
  } else if (
164
242
  type === "response.reasoning_summary_part.added" ||
165
243
  type === "response.reasoning_summary_part.done"
@@ -170,14 +248,18 @@ function responsesAccumulator() {
170
248
  } else if (/^response\.reasoning_(summary_)?text\.(delta|done)$/.test(type)) {
171
249
  const value = item(data, "reasoning");
172
250
  const summary = type.includes("summary");
173
- const field = summary ? "summary" : "content";
174
- value[field] ??= [];
175
- const part = (value[field][index(summary ? data.summary_index : data.content_index)] ??= {
251
+ const collection = summary ? "summary" : "content";
252
+ value[collection] ??= [];
253
+ const at = index(summary ? data.summary_index : data.content_index);
254
+ const part = (value[collection][at] ??= {
176
255
  type: summary ? "summary_text" : "reasoning_text",
177
256
  text: "",
178
257
  });
179
- if (type.endsWith(".delta")) part.text += data.delta ?? "";
180
- else if (data.text !== undefined) part.text = data.text;
258
+ // A summary's whole text arrives in `summary` and a reasoning text's
259
+ // in `text`. Reading `text` for a summary lost a done event that had
260
+ // no delta before it (measured: the summary came back empty).
261
+ const isDelta = type.endsWith(".delta");
262
+ applyText(part, "text", isDelta ? data.delta : summary ? data.summary : data.text, isDelta);
181
263
  } else if (type === "response.reasoning_content.delta") {
182
264
  body.reasoning_content = (body.reasoning_content ?? "") + (data.delta ?? "");
183
265
  } else if (type === "response.completed" || type === "response.incomplete") {
@@ -190,9 +272,23 @@ function responsesAccumulator() {
190
272
  if (data.usage) body.usage = { ...body.usage, ...data.usage };
191
273
  },
192
274
  finish() {
193
- if (final) return final;
194
- body.output = sorted(output);
195
- return body;
275
+ if (!final) {
276
+ body.output = sorted(output);
277
+ return body;
278
+ }
279
+ const kept = sorted(output);
280
+ // A terminal object that carries no output of its own — absent, or
281
+ // empty while the stream accumulated some — is not a whole view of the
282
+ // reply: returning it verbatim discards every delta already delivered,
283
+ // and the client reads a 200 as a model that said nothing. The
284
+ // accumulation stands in for the collection it lost; everything else
285
+ // about the terminal is what the upstream said last, and comes back
286
+ // exactly as it arrived.
287
+ const whole = Array.isArray(final.output)
288
+ ? final.output.length > 0
289
+ : final.output !== null && final.output !== undefined;
290
+ if (whole || !kept.length || Array.isArray(final)) return final;
291
+ return { ...final, output: kept };
196
292
  },
197
293
  };
198
294
  }
@@ -207,12 +303,19 @@ function anthropicAccumulator() {
207
303
  };
208
304
  const blocks = new Map();
209
305
  const argumentsJSON = new Map();
306
+ // The fields a Messages block fills in: its text, thinking, signature, or
307
+ // the input a tool call opened with.
308
+ const fields = ["text", "thinking", "signature", "partial_json", "input"];
210
309
  return {
211
310
  apply(data, type) {
212
311
  if (type === "message_start") {
213
312
  body = { ...body, ...data.message };
313
+ // A message_start that repeats beside the deltas it opened, or one
314
+ // whose block has not been filled in yet, is a partial view of the
315
+ // reply: a block that carries nothing where the accumulated one has
316
+ // content leaves it standing.
214
317
  for (const [position, block] of (data.message?.content ?? []).entries())
215
- blocks.set(position, block);
318
+ if (carries(block, blocks.get(position), fields)) blocks.set(position, block);
216
319
  } else if (type === "content_block_start") {
217
320
  blocks.set(index(data.index), data.content_block);
218
321
  } else if (type === "content_block_delta") {
@@ -225,7 +328,7 @@ function anthropicAccumulator() {
225
328
  thinking_delta: "thinking",
226
329
  signature_delta: "signature",
227
330
  }[delta.type];
228
- if (field) block[field] = (block[field] ?? "") + (delta[field] ?? "");
331
+ if (field) applyText(block, field, delta[field], true);
229
332
  if (delta.type === "input_json_delta") {
230
333
  argumentsJSON.set(
231
334
  position,
@@ -233,14 +336,26 @@ function anthropicAccumulator() {
233
336
  );
234
337
  }
235
338
  } else if (type === "message_delta") {
236
- Object.assign(body, data.delta);
339
+ // Only the fields a message_delta is defined to carry are taken from
340
+ // it. Object.assign copied with [[Set]] semantics: a "__proto__" key
341
+ // in the upstream's delta re-parented the message being accumulated,
342
+ // and any other key silently overwrote its id, role, type or content.
343
+ for (const field of ["stop_reason", "stop_sequence"]) {
344
+ if (data.delta?.[field] !== undefined) body[field] = data.delta[field];
345
+ }
237
346
  if (data.usage) body.usage = { ...body.usage, ...data.usage };
238
347
  }
239
348
  },
240
349
  finish() {
241
350
  for (const [position, value] of argumentsJSON) {
351
+ // An upstream may send an empty delta set for a call that takes no
352
+ // arguments: there are no bytes to parse, and the block's own input
353
+ // stands. JSON.parse("") would fail a valid call.
354
+ if (!value.trim()) continue;
355
+ const block = blocks.get(position);
356
+ if (!block) throw new Error("Tool input for no content block in upstream stream");
242
357
  try {
243
- blocks.get(position).input = JSON.parse(value);
358
+ block.input = JSON.parse(value);
244
359
  } catch {
245
360
  throw new Error("Invalid tool input JSON in upstream stream");
246
361
  }
@@ -287,7 +402,15 @@ export async function collapseResponse(response, protocol) {
287
402
  }
288
403
  if (!text.trim()) return;
289
404
  if (text.trim() === "[DONE]") {
405
+ // [DONE] is what ends a Chat stream, and it is a valid end-of-stream
406
+ // sentinel for a Responses or Messages one that has already reported
407
+ // its own terminal event (response.completed, response.incomplete,
408
+ // message_stop) — a gateway in front of the upstream may append it.
409
+ // Before that event it is an ending the protocol never reported, so
410
+ // what was streamed is not a whole reply: the guard refuses the same
411
+ // bytes with the same words rather than reading them as a success.
290
412
  if (protocol === "chat") completed = true;
413
+ else if (!completed) failure = "Unexpected upstream completion marker";
291
414
  return;
292
415
  }
293
416
  let data;
@@ -370,7 +493,7 @@ export async function collapseResponse(response, protocol) {
370
493
  if (done) break;
371
494
  bytes += value.byteLength;
372
495
  if (bytes > MAX_BYTES) {
373
- failure = "Upstream SSE exceeds the 32 MiB buffer limit";
496
+ failure = `Upstream SSE exceeds the ${MAX_BYTES / (1024 * 1024)} MiB limit`;
374
497
  await reader.cancel();
375
498
  break;
376
499
  }