opencode-zen-freex 0.2.8 → 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,9 +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
- }
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));
139
177
  // Zen takes a request for one generation of OpenCode or the other, and the tools
140
178
  // it carries say which: `shell` is 2.x, `bash` is 1.x. Names move far less
141
179
  // often than descriptions do, so this survives a rewording that a pinned
@@ -169,6 +207,9 @@ function normalizeTools(body, protocol) {
169
207
  // tokens of a tool it will not call, and they are the first thing a
170
208
  // tightened check would otherwise find missing.
171
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;
172
213
  filled.push(name);
173
214
  // A tool added here has nothing behind it: the agent has no such tool and
174
215
  // a call to it is dropped. Describing it as OpenCode describes it is what
@@ -205,19 +246,31 @@ function shapeOf(definition, protocol) {
205
246
  // sends apart from the tool wording is what Zen expects — the endpoint, the
206
247
  // shape of the User-Agent, both session headers — so a refusal this far in is
207
248
  // about the wording, and this says so, and says which wording went out.
208
- async function explain(response, toolPolicy) {
209
- if (response.status !== 403 && response.status !== 426) return response;
210
- const text = await response.text().catch(() => "");
211
- // A 403 that is not Zen's free tier is somebody else's; it is left as sent.
212
- if (response.status === 403 && !text.includes("FreeTierError"))
213
- 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;
214
267
  const profile = PROFILES[toolPolicy.generation];
215
268
  const names = Object.keys(profile.tools).join(", ");
216
269
  const { filled } = toolPolicy;
217
270
  // 426 is not a refusal of the wording: it is this plugin presenting itself as
218
271
  // an OpenCode older than Zen accepts, which only a newer copy of it answers.
219
272
  const message =
220
- response.status === 426
273
+ source.status === 426
221
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.`
222
275
  : [
223
276
  "OpenCode Zen's free tier refused this request as not coming from OpenCode.",
@@ -230,9 +283,9 @@ async function explain(response, toolPolicy) {
230
283
  return new Response(
231
284
  JSON.stringify({
232
285
  type: "error",
233
- error: { type: response.status === 426 ? "upgrade_required" : "FreeTierError", message },
286
+ error: { type: source.status === 426 ? "upgrade_required" : "FreeTierError", message },
234
287
  }),
235
- { status: response.status, statusText: response.statusText, headers: response.headers },
288
+ { status: source.status, statusText: source.statusText, headers: rewrittenHeaders(source) },
236
289
  );
237
290
  }
238
291
 
@@ -245,12 +298,19 @@ let counter = 0;
245
298
  // for a session, so newer sorts first), then 14 random base62 characters.
246
299
  // Zen's free tier checks a session id has this shape, a small counter
247
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.
248
308
  export function opencodeID(prefix, descending, now = Date.now()) {
249
309
  if (now !== lastStamp) {
250
310
  lastStamp = now;
251
311
  counter = 0;
252
312
  }
253
- counter++;
313
+ counter = counter >= 15 ? 1 : counter + 1;
254
314
  const current = BigInt(now) * 0x1000n + BigInt(counter);
255
315
  const value = (descending ? ~current : current) & 0xffffffffffffn;
256
316
  const random = randomBytes(14);
@@ -284,13 +344,18 @@ export function sessionID(headers, body) {
284
344
  break;
285
345
  }
286
346
  }
287
- 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;
288
348
  if (realSessionID(signal)) return signal;
289
349
  if (!signal) {
290
350
  const messages = body.messages ?? body.input;
291
351
  const first = Array.isArray(messages)
292
352
  ? messages.find((message) => message.role === "user")?.content
293
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.
294
359
  if (!first) return opencodeID("ses", true);
295
360
  signal = JSON.stringify(first);
296
361
  }
@@ -456,7 +521,10 @@ async function server(_input, options = {}) {
456
521
  const project = _input?.project;
457
522
  const headers = outboundHeaders({
458
523
  session,
459
- 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),
460
528
  protocol,
461
529
  given,
462
530
  project:
@@ -474,10 +542,31 @@ async function server(_input, options = {}) {
474
542
  // it has to be read before explain() replaces the body. See
475
543
  // limits.mjs: an under-declared window is never refused, it is just
476
544
  // quietly never used.
477
- await observeRouteStatement(body.model, response);
478
- const guarded = await guardToolResponse(await explain(response, toolPolicy), protocol, toolPolicy, {
479
- 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,
480
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
+ );
481
570
  return streaming ? guarded : collapseResponse(guarded, protocol);
482
571
  },
483
572
  };
package/stream.mjs CHANGED
@@ -1,4 +1,8 @@
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;
2
6
 
3
7
  // The headers that describe the bytes a reply arrived as. A reply whose body
4
8
  // is written again — a stream collapsed into one JSON document, or a guarded
@@ -57,6 +61,39 @@ function appendFunction(target, delta) {
57
61
  }
58
62
  }
59
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
+
60
97
  function chatAccumulator() {
61
98
  const body = { object: "chat.completion", choices: [] };
62
99
  const choices = new Map();
@@ -99,7 +136,11 @@ function chatAccumulator() {
99
136
  }
100
137
  if (fragment.function) appendFunction(call.function, fragment.function);
101
138
  }
102
- 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;
103
144
  if (part.logprobs) {
104
145
  choice.logprobs ??= {};
105
146
  for (const [key, value] of Object.entries(part.logprobs)) {
@@ -124,13 +165,28 @@ function responsesAccumulator() {
124
165
  let body = { object: "response", output: [] };
125
166
  let final;
126
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"];
127
171
  // The slot a frame belongs to: the output_index it carries, or the slot
128
- // of the item it names when the upstream left output_index out.
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.
129
177
  function slot(data, id) {
130
178
  if (data.output_index !== undefined) return index(data.output_index);
131
- const position = id ? [...output].find(([, value]) => value.id === id)?.[0] : undefined;
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");
132
183
  return index(position);
133
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
+ }
134
190
  function item(data, type = "message") {
135
191
  const position = slot(data, data.item_id);
136
192
  if (!output.has(position)) {
@@ -148,11 +204,14 @@ function responsesAccumulator() {
148
204
  if (type === "response.created" || type === "response.in_progress") {
149
205
  if (data.response) {
150
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.
151
210
  for (const [position, value] of (data.response.output ?? []).entries())
152
- output.set(position, value);
211
+ keep(position, value);
153
212
  }
154
213
  } else if (type === "response.output_item.added" || type === "response.output_item.done") {
155
- if (data.item) output.set(slot(data, data.item.id), data.item);
214
+ if (data.item) keep(slot(data, data.item.id), data.item);
156
215
  } else if (type === "response.content_part.added" || type === "response.content_part.done") {
157
216
  const value = item(data);
158
217
  value.content ??= [];
@@ -170,15 +229,15 @@ function responsesAccumulator() {
170
229
  const part = (value.content[index(data.content_index)] ??= refusal
171
230
  ? { type: "refusal", refusal: "" }
172
231
  : { type: "output_text", text: "", annotations: [] });
173
- if (type.endsWith(".delta")) part[field] = (part[field] ?? "") + (data.delta ?? "");
174
- 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);
175
234
  } else if (
176
235
  type === "response.function_call_arguments.delta" ||
177
236
  type === "response.function_call_arguments.done"
178
237
  ) {
179
238
  const value = item(data, "function_call");
180
- if (type.endsWith(".delta")) value.arguments = (value.arguments ?? "") + (data.delta ?? "");
181
- 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);
182
241
  } else if (
183
242
  type === "response.reasoning_summary_part.added" ||
184
243
  type === "response.reasoning_summary_part.done"
@@ -189,14 +248,18 @@ function responsesAccumulator() {
189
248
  } else if (/^response\.reasoning_(summary_)?text\.(delta|done)$/.test(type)) {
190
249
  const value = item(data, "reasoning");
191
250
  const summary = type.includes("summary");
192
- const field = summary ? "summary" : "content";
193
- value[field] ??= [];
194
- 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] ??= {
195
255
  type: summary ? "summary_text" : "reasoning_text",
196
256
  text: "",
197
257
  });
198
- if (type.endsWith(".delta")) part.text += data.delta ?? "";
199
- 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);
200
263
  } else if (type === "response.reasoning_content.delta") {
201
264
  body.reasoning_content = (body.reasoning_content ?? "") + (data.delta ?? "");
202
265
  } else if (type === "response.completed" || type === "response.incomplete") {
@@ -209,9 +272,23 @@ function responsesAccumulator() {
209
272
  if (data.usage) body.usage = { ...body.usage, ...data.usage };
210
273
  },
211
274
  finish() {
212
- if (final) return final;
213
- body.output = sorted(output);
214
- 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 };
215
292
  },
216
293
  };
217
294
  }
@@ -226,12 +303,19 @@ function anthropicAccumulator() {
226
303
  };
227
304
  const blocks = new Map();
228
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"];
229
309
  return {
230
310
  apply(data, type) {
231
311
  if (type === "message_start") {
232
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.
233
317
  for (const [position, block] of (data.message?.content ?? []).entries())
234
- blocks.set(position, block);
318
+ if (carries(block, blocks.get(position), fields)) blocks.set(position, block);
235
319
  } else if (type === "content_block_start") {
236
320
  blocks.set(index(data.index), data.content_block);
237
321
  } else if (type === "content_block_delta") {
@@ -244,7 +328,7 @@ function anthropicAccumulator() {
244
328
  thinking_delta: "thinking",
245
329
  signature_delta: "signature",
246
330
  }[delta.type];
247
- if (field) block[field] = (block[field] ?? "") + (delta[field] ?? "");
331
+ if (field) applyText(block, field, delta[field], true);
248
332
  if (delta.type === "input_json_delta") {
249
333
  argumentsJSON.set(
250
334
  position,
@@ -252,7 +336,13 @@ function anthropicAccumulator() {
252
336
  );
253
337
  }
254
338
  } else if (type === "message_delta") {
255
- 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
+ }
256
346
  if (data.usage) body.usage = { ...body.usage, ...data.usage };
257
347
  }
258
348
  },
@@ -312,7 +402,15 @@ export async function collapseResponse(response, protocol) {
312
402
  }
313
403
  if (!text.trim()) return;
314
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.
315
412
  if (protocol === "chat") completed = true;
413
+ else if (!completed) failure = "Unexpected upstream completion marker";
316
414
  return;
317
415
  }
318
416
  let data;
package/tool-response.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { STALE_BODY_HEADERS } from "./stream.mjs";
1
+ import { MAX_BYTES, STALE_BODY_HEADERS } from "./stream.mjs";
2
2
 
3
- const MAX_BYTES = 32 * 1024 * 1024;
4
- // The limit as a failure's message names it, from the one place it is set.
3
+ // The limit as a failure's message names it, from the one place it is set:
4
+ // stream.mjs owns the number, and this guard reads the same bytes through it.
5
5
  const LIMIT = `${MAX_BYTES / (1024 * 1024)} MiB`;
6
6
  const encoder = new TextEncoder();
7
7
 
@@ -301,7 +301,10 @@ function chatBuffer(checkName) {
301
301
  return pending.get(index);
302
302
  }
303
303
  // Choices that streamed text, and those whose every call was left out
304
- // (whose later "tool_calls" finish is a stop).
304
+ // (whose later "tool_calls" finish is a stop). The second is per round: a
305
+ // later round that buffers a call for the same choice is judged on what it
306
+ // holds, not on the round before it, so the mark is dropped as soon as that
307
+ // call arrives.
305
308
  const wrote = new Set();
306
309
  const ended = new Set();
307
310
  function complete(state, index) {
@@ -320,6 +323,14 @@ function chatBuffer(checkName) {
320
323
  }
321
324
  return delta;
322
325
  }
326
+ // What a choice the buffer flushes at [DONE] ended as: tool use when the
327
+ // delta it publishes carries calls, the legacy field when it carries that,
328
+ // and a plain stop otherwise.
329
+ function ending(delta) {
330
+ if (delta.tool_calls?.length) return "tool_calls";
331
+ if (delta.function_call) return "function_call";
332
+ return "stop";
333
+ }
323
334
  function remove(index) {
324
335
  const state = pending.get(index);
325
336
  if (state) bytes -= state.bytes;
@@ -331,6 +342,10 @@ function chatBuffer(checkName) {
331
342
  },
332
343
  clear() {
333
344
  pending.clear();
345
+ // Everything this buffer holds, not only the bytes: a cleared buffer
346
+ // has no round to judge.
347
+ wrote.clear();
348
+ ended.clear();
334
349
  bytes = 0;
335
350
  },
336
351
  apply(data) {
@@ -342,7 +357,12 @@ function chatBuffer(checkName) {
342
357
  if (typeof delta.content === "string" && delta.content) wrote.add(position(choice.index));
343
358
  if (delta.tool_calls == null && delta.function_call == null) continue;
344
359
  changed = true;
345
- const state = stateFor(position(choice.index), data);
360
+ // A call buffered now opens a round of its own for this choice: a
361
+ // choice that finished as a plain stop because every call of an
362
+ // earlier round was left out is judged again on what this one holds.
363
+ const at = position(choice.index);
364
+ ended.delete(at);
365
+ const state = stateFor(at, data);
346
366
  for (const fragment of list(delta.tool_calls)) {
347
367
  if (!object(fragment) || (fragment.type != null && fragment.type !== "function")) {
348
368
  throw new GuardError("Unsupported upstream tool delta");
@@ -393,9 +413,16 @@ function chatBuffer(checkName) {
393
413
  },
394
414
  finish() {
395
415
  if (!pending.size) return null;
396
- const choices = [...pending]
397
- .sort(([a], [b]) => a - b)
398
- .map(([index, state]) => ({ index, delta: complete(state, index), finish_reason: null }));
416
+ const choices = [...pending].sort(([a], [b]) => a - b).map(([index, state]) => {
417
+ const delta = complete(state, index);
418
+ // The case this buffer exists for is an upstream that closes with
419
+ // [DONE] before sending a finish chunk: the calls it buffered are
420
+ // published here, and the choice has to say the turn ended in tool
421
+ // use. A null reason reads as no ending at all, and "stop" as a plain
422
+ // one — either way a client keyed on finish_reason never runs a call
423
+ // it was handed.
424
+ return { index, delta, finish_reason: ending(delta) };
425
+ });
399
426
  const metadata = pending.values().next().value.metadata;
400
427
  pending.clear();
401
428
  bytes = 0;
@@ -515,7 +542,18 @@ async function readBounded(response) {
515
542
  parts.push(value);
516
543
  }
517
544
  } finally {
518
- reader.releaseLock();
545
+ // Whatever ended the read — the limit, a malformed body, or the end of
546
+ // it — this body is never read again, so the reader is loosed and the
547
+ // stream cancelled. This was the one place in the package that abandoned
548
+ // a body without cancelling it (measured: no cancellation past the limit,
549
+ // and the source still pulled after the 502), which leaves an upstream
550
+ // writing into a socket nobody reads. The cancel's own failure is not
551
+ // swallowed, and the lock is released whether it threw or not.
552
+ try {
553
+ await reader.cancel();
554
+ } finally {
555
+ reader.releaseLock();
556
+ }
519
557
  }
520
558
  const all = new Uint8Array(bytes);
521
559
  let offset = 0;
@@ -809,9 +847,16 @@ function guardedStream(response, protocol, checkName, endCutShort) {
809
847
  return;
810
848
  }
811
849
  if (frame.name !== "error" && frame.text.trim() === "[DONE]") {
812
- if (protocol !== "chat")
850
+ // [DONE] is what ends a Chat stream, and a valid end-of-stream
851
+ // sentinel for a Responses or Messages one that has already
852
+ // reported its own terminal event (response.completed,
853
+ // response.incomplete, message_stop) — a gateway in front of the
854
+ // upstream may append it. Before that event it claims an ending
855
+ // the protocol never reported, and the frame is refused; the
856
+ // collapse path reads the same bytes the same way.
857
+ if (protocol !== "chat" && !completed)
813
858
  throw new GuardError("Unexpected upstream completion marker");
814
- const final = chat.finish();
859
+ const final = chat ? chat.finish() : null;
815
860
  completed = true;
816
861
  controller.enqueue(encoder.encode((final ? encodeEvent(final) : "") + frame.raw));
817
862
  return;