opencode-zen-freex 0.2.11 → 0.2.12

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/core-tools.mjs CHANGED
@@ -106,7 +106,12 @@ export function generationOf(tools) {
106
106
  // `.agent` with a TypeError that names nothing. Ownership of the key is half of
107
107
  // it — a capture holding `{"1.x": null}` owns the key and still has no profile,
108
108
  // and the `.agent` of that is the same unnamed TypeError.
109
- function profileFor(generation) {
109
+ //
110
+ // Exported, because the same two mistakes are reachable from outside this file
111
+ // and are made there: `provider.mjs` indexes `PROFILES[...]` directly to read a
112
+ // profile's `tools`, and a capture missing that generation then fails with
113
+ // "Cannot read properties of undefined" instead of the sentence below.
114
+ export function profileFor(generation) {
110
115
  if (!Object.hasOwn(PROFILES, generation)) {
111
116
  throw new Error(
112
117
  `core-tools: no profile for generation ${JSON.stringify(generation)}; ` +
package/limits.mjs CHANGED
@@ -109,7 +109,7 @@ export const LIMITS = {
109
109
  // says nothing attributable and is left alone; §7 of the measured notes has a
110
110
  // context overflow and an invalid parameter answering with byte-identical 400s
111
111
  // on another Zen model.
112
- const NAMED_CONTEXT = /maximum context length is (\d+) tokens/i;
112
+ const NAMED_CONTEXT = /maximum context length is (\d+) tokens/id;
113
113
 
114
114
  // "max_tokens is too large: 200000. This model supports at most 131072 completion
115
115
  // tokens, whereas you provided 200000." — an upstream that validates the ceiling
@@ -119,10 +119,10 @@ const NAMED_CONTEXT = /maximum context length is (\d+) tokens/i;
119
119
  // answers 200 there, so silence from this one says nothing about a model, and a
120
120
  // request that is served is not evidence of a ceiling either way.
121
121
  const NAMED_OUTPUT = [
122
- /supports at most (\d+) completion tokens/i,
122
+ /supports at most (\d+) completion tokens/id,
123
123
  // "/max_tokens: 1000000 is not less or equal to 262144" — LongCat's wording for
124
124
  // the same fact, where the ceiling is the number it has to stay under.
125
- /max_tokens:?\s*\d+\s+is not less or equal to\s+(\d+)/i,
125
+ /max_tokens:?\s*\d+\s+is not less or equal to\s+(\d+)/id,
126
126
  ];
127
127
 
128
128
  // Names a refusal made while this process is running, by model id. Not
@@ -349,25 +349,33 @@ export async function observeRouteStatement(id, response, limit = 4096, { signal
349
349
  }
350
350
  }
351
351
 
352
- // How close to the end of what was read a match has to end before a truncated
353
- // body makes it unusable. A number ending inside this margin of the last
354
- // character read may have had digits past it — `2621` where the body said
355
- // `262144` — and a smaller limit that looks like a real one is the one failure
356
- // this guard exists to prevent. Numbers further back are inside the buffer
357
- // entirely and are read as they stand.
358
- const CUT_MARGIN = 8;
352
+ // Where the number a match named ends, in the text it was matched against. The
353
+ // `d` flag is what makes this exact: `NAMED_CONTEXT` and the first
354
+ // `NAMED_OUTPUT` run on past the digits (" completion tokens"), so judging the
355
+ // cut by the match's own end threw away numbers that were provably whole — the
356
+ // digits ended well before the read did, with text after them that the greedy
357
+ // `(\d+)` had refused to consume.
358
+ function digitsEnd(found) {
359
+ const at = found?.match?.indices?.[1]?.[1];
360
+ // The match's own end is the conservative answer when the pattern cannot say
361
+ // where the digits are: it drops a whole number rather than reading a cut one,
362
+ // which is the direction this guard has to fail in.
363
+ return Number.isSafeInteger(at) ? at : found.match.index + found.match[0].length;
364
+ }
359
365
 
360
366
  // The axes the first `limit` characters of `text` name. `cut` says the read
361
- // stopped at the budget with the body going on, so a match ending at the margin
362
- // may have been a longer number: that axis is left unread rather than reported a
363
- // digit short. Each axis is read from the match its number came from, so the
364
- // match the guard judges and the number that is returned cannot be two answers.
367
+ // stopped before the body did, so a number whose own digits reach the last
368
+ // character read may have had digits past it: that axis is left unread rather
369
+ // than reported a digit short. Any character after the digits inside the head
370
+ // proves the number ended, since a greedy `\d+` would have taken it — only
371
+ // digits touching the boundary are ambiguous. Each axis is read from the match
372
+ // its number came from, so the match the guard judges and the number that is
373
+ // returned cannot be two answers.
365
374
  function namedAxes(text, limit, cut) {
366
375
  const head = text.slice(0, limit);
367
376
  const context = namedFrom([NAMED_CONTEXT], head);
368
377
  const output = namedFrom(NAMED_OUTPUT, head);
369
- const onCut = (found) =>
370
- cut && Boolean(found) && found.match.index + found.match[0].length >= head.length - CUT_MARGIN;
378
+ const onCut = (found) => cut && found !== undefined && digitsEnd(found) >= head.length;
371
379
  return {
372
380
  context: onCut(context) ? undefined : context?.value,
373
381
  output: onCut(output) ? undefined : output?.value,
@@ -394,7 +402,12 @@ export function limitOverrides(
394
402
  // declared, from a table that never named it.
395
403
  const record = table && Object.hasOwn(table, id) ? table[id] : undefined;
396
404
  for (const axis of ["context", "output"]) {
397
- const declaredValue = declared?.[axis];
405
+ // Own property, like the table lookup two lines up: `declared` is handed in
406
+ // by a caller, and a `context` inherited from a custom prototype — or from a
407
+ // polluted `Object.prototype` — would otherwise be read as a declaration the
408
+ // catalogue never made, feeding the staleness compare and the raise-only
409
+ // check with a number nobody declared.
410
+ const declaredValue = declared && Object.hasOwn(declared, axis) ? declared[axis] : undefined;
398
411
  const entry = record?.[axis];
399
412
  const live = (axis === "context" ? observed : observedOutput)?.get(id);
400
413
  // A route statement made while this process was running is the same tier as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-zen-freex",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "description": "OpenCode Zen's free models, for hosts that are not OpenCode: the public credential, automatic discovery and native streaming protocols",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/provider.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { PROFILES, agentFor, generationOf } from "./core-tools.mjs";
2
+ import { agentFor, generationOf, profileFor } from "./core-tools.mjs";
3
3
  import {
4
4
  PROVIDER,
5
5
  SDK,
@@ -158,6 +158,22 @@ function relayedRefusal(response, observed) {
158
158
  );
159
159
  }
160
160
 
161
+ // The response this path hands on, from what the limits reader made of the one
162
+ // that arrived: the same response when it never read it — the reader touches no
163
+ // 2xx body, so that is the common branch — the bytes it read under headers
164
+ // corrected for them when it read the whole body, and a refusal that says so
165
+ // when it stopped short. One decision, written once, so the three outcomes
166
+ // cannot be read as a chain of ternaries at the call site.
167
+ function relayed(response, observed) {
168
+ if (observed === undefined) return response;
169
+ if (!observed.ended) return relayedRefusal(response, observed);
170
+ return new Response(observed.text, {
171
+ status: response.status,
172
+ statusText: response.statusText,
173
+ headers: relayedHeaders(response),
174
+ });
175
+ }
176
+
161
177
  // How long a request for a model the last list didn't have waits before it
162
178
  // asks Zen's list again: a model Zen made free since is served at once,
163
179
  // and a model typed in by hand doesn't ask the list on every request.
@@ -210,20 +226,23 @@ function normalizeTools(body, protocol) {
210
226
  const declared = new Set([...present].filter((value) => typeof value === "string"));
211
227
  // The legacy top-level `functions` array is the older way of declaring the
212
228
  // same tools, and one a chat host may still use. Its names are declarations,
213
- // so they count as declared — and, since they are not in `body.tools`, they
214
- // are also remembered as "do not stand in for this name": the fill loop
215
- // below looks at where a name already sits, and a name that only arrived
216
- // through `functions` has no index there. Without this the same tool would go
217
- // out twice, which is what the note on `nameOf` says must not happen.
229
+ // so they count as declared, and the fill loop below finds them by name: a
230
+ // name that only arrived through `functions` has no index in `body.tools`, so
231
+ // it is looked up in the array itself and reworded where it sits rather than
232
+ // being stood in for. Without that the same tool would go out twice, which is
233
+ // what the note on `nameOf` says must not happen.
218
234
  const legacy = Array.isArray(body.functions) && protocol === "chat" ? body.functions : [];
219
235
  for (const tool of legacy) if (typeof tool?.name === "string") declared.add(tool.name);
220
- const declaredElsewhere = new Set(legacy.map((tool) => tool?.name));
221
236
  // Zen takes a request for one generation of OpenCode or the other, and the tools
222
237
  // it carries say which: `shell` is 2.x, `bash` is 1.x. Names move far less
223
238
  // often than descriptions do, so this survives a rewording that a pinned
224
239
  // User-Agent would not.
225
240
  const generation = generationOf(body.tools) ?? DEFAULT_GENERATION;
226
- const profile = PROFILES[generation];
241
+ // Through `profileFor`, not `PROFILES[generation]`: a capture that holds one
242
+ // generation leaves the other key absent, and the direct read fails below on
243
+ // `profile.tools` with a TypeError that names nothing — the capture is what
244
+ // has to change, and it is the one thing that error does not say.
245
+ const profile = profileFor(generation);
227
246
  const filled = [];
228
247
  const at = new Map();
229
248
  body.tools.forEach((tool, index) => {
@@ -252,8 +271,17 @@ function normalizeTools(body, protocol) {
252
271
  // tightened check would otherwise find missing.
253
272
  if (!profile.required.includes(name)) continue;
254
273
  // Declared through `functions` rather than `tools`: this host does have the
255
- // tool, so it is not one to stand in for.
256
- if (declaredElsewhere.has(name)) continue;
274
+ // tool, so it is not one to stand in for. Its wording is still the host's,
275
+ // though, and Zen reads the wording of a tool it knows by name whichever
276
+ // array carried it — leaving that one entry as the host wrote it is the
277
+ // refusal this loop exists to prevent, and it is the entry `explain()`
278
+ // would then describe wrongly. The legacy shape carries `description` at
279
+ // the top level, which `setDescription` already handles.
280
+ const throughFunctions = legacy.find((tool) => tool?.name === name);
281
+ if (throughFunctions) {
282
+ setDescription(throughFunctions, spoken.description);
283
+ continue;
284
+ }
257
285
  filled.push(name);
258
286
  // A tool added here has nothing behind it: the agent has no such tool and
259
287
  // a call to it is dropped. Describing it as OpenCode describes it is what
@@ -308,7 +336,10 @@ function explain(text, source, toolPolicy) {
308
336
  // A 403 that is not Zen's free tier is somebody else's; it is left as sent —
309
337
  // its bytes are what arrived, and so are the headers describing them.
310
338
  if (source.status === 403 && !text.includes("FreeTierError")) return source;
311
- const profile = PROFILES[toolPolicy.generation];
339
+ // The same lookup `normalizeTools` made, through the same owner: the refusal
340
+ // this message explains is about the generation that request was sent as, and
341
+ // a capture that lost that profile has to say so rather than fail on `.tools`.
342
+ const profile = profileFor(toolPolicy.generation);
312
343
  const names = Object.keys(profile.tools).join(", ");
313
344
  const { filled } = toolPolicy;
314
345
  // 426 is not a refusal of the wording: it is this plugin presenting itself as
@@ -588,28 +619,16 @@ async function server(_input, options = {}) {
588
619
  // quietly never used.
589
620
  //
590
621
  // A response body can only be read once, so what the limits reader
591
- // took is handed on as the response the rest of the path works with:
592
- // never the upstream's own headers over bytes this package wrote
593
- // again, and never a prefix presented as the whole reply. A read
594
- // that reached the end of the body is that body, relayed under
595
- // headers corrected for it; one that stopped short is answered with
596
- // a refusal that says so (see `relayedRefusal`). A response the
597
- // reader did not consume is used as it arrived.
622
+ // took is handed on as the response the rest of the path works with
623
+ // (see `relayed`): never the upstream's own headers over bytes this
624
+ // package wrote again, and never a prefix presented as the whole
625
+ // reply. A response the reader did not consume is used as it arrived.
598
626
  const observed = await observeRouteStatement(body.model, response, undefined, {
599
627
  // The caller's own cancellation reaches this read as well: a host
600
628
  // that has given up is not waiting for a number out of a refusal.
601
629
  signal: request.signal,
602
630
  });
603
- const answered =
604
- observed === undefined
605
- ? response
606
- : observed.ended
607
- ? new Response(observed.text, {
608
- status: response.status,
609
- statusText: response.statusText,
610
- headers: relayedHeaders(response),
611
- })
612
- : relayedRefusal(response, observed);
631
+ const answered = relayed(response, observed);
613
632
  const guarded = await guardToolResponse(
614
633
  explain(observed?.text ?? null, answered, toolPolicy),
615
634
  protocol,
package/stream.mjs CHANGED
@@ -187,14 +187,19 @@ function responsesAccumulator() {
187
187
  // naming an item the stream never opened has no slot at all — slot 0 is
188
188
  // only for a frame that names nothing, which a stream that never indexed
189
189
  // its items means as the first one. Merging an unknown item into slot 0
190
- // was measured handing a function_call there the text of a message.
191
- function slot(data, id) {
190
+ // was measured handing a function_call there the text of a message. An
191
+ // `added` frame opens an item, so the id it names is new there by
192
+ // definition and takes the next free slot; a frame that has to find an
193
+ // item that is already open still throws.
194
+ function slot(data, id, fresh = false) {
192
195
  if (data.output_index !== undefined) return index(data.output_index);
193
196
  if (id === undefined || id === null || id === "") return index(undefined);
194
197
  const position = [...output].find(([, value]) => value.id === id)?.[0];
195
- if (position === undefined)
196
- throw new Error("Upstream frame names an unknown item without an output index");
197
- return index(position);
198
+ if (position !== undefined) return index(position);
199
+ if (!fresh) throw new Error("Upstream frame names an unknown item without an output index");
200
+ let next = 0;
201
+ for (const held of output.keys()) next = Math.max(next, held + 1);
202
+ return index(next);
198
203
  }
199
204
  // A snapshot replaces the item in its slot only when it is a whole view of
200
205
  // it; a partial one leaves what the deltas delivered standing.
@@ -225,11 +230,19 @@ function responsesAccumulator() {
225
230
  keep(position, value);
226
231
  }
227
232
  } else if (type === "response.output_item.added" || type === "response.output_item.done") {
228
- if (data.item) keep(slot(data, data.item.id), data.item);
233
+ if (data.item)
234
+ keep(slot(data, data.item.id, type === "response.output_item.added"), data.item);
229
235
  } else if (type === "response.content_part.added" || type === "response.content_part.done") {
230
236
  const value = item(data);
231
237
  value.content ??= [];
232
- value.content[index(data.content_index)] = data.part;
238
+ // A re-emitted opening part carries an empty text where the deltas
239
+ // have already filled the slot — the partial-snapshot hazard the item
240
+ // guard above and content_block_start exist for, so the same test.
241
+ // `refusal` is weighed too: a part whose only content is a refusal
242
+ // weighs nothing on text alone and would be taken for an empty one.
243
+ const at = index(data.content_index);
244
+ if (carries(data.part, value.content[at], ["text", "refusal"]))
245
+ value.content[at] = data.part;
233
246
  } else if (
234
247
  type === "response.output_text.delta" ||
235
248
  type === "response.output_text.done" ||
@@ -258,7 +271,9 @@ function responsesAccumulator() {
258
271
  ) {
259
272
  const value = item(data, "reasoning");
260
273
  value.summary ??= [];
261
- value.summary[index(data.summary_index)] = data.part;
274
+ // The same re-emitted-opening-frame hazard as content_part above.
275
+ const at = index(data.summary_index);
276
+ if (carries(data.part, value.summary[at], ["text"])) value.summary[at] = data.part;
262
277
  } else if (/^response\.reasoning_(summary_)?text\.(delta|done)$/.test(type)) {
263
278
  const value = item(data, "reasoning");
264
279
  const summary = type.includes("summary");
@@ -301,8 +316,18 @@ function responsesAccumulator() {
301
316
  const whole = Array.isArray(final.output)
302
317
  ? final.output.length > 0
303
318
  : final.output !== null && final.output !== undefined;
304
- if (whole || !kept.length || Array.isArray(final)) return final;
305
- return { ...final, output: kept };
319
+ // An array is not a reply object at all, and is returned as it came.
320
+ if (Array.isArray(final)) return final;
321
+ // The terminal is not the whole reply either. What apply merged onto
322
+ // the body from the events' top level — usage, status,
323
+ // incomplete_details, finish_reason, and the object this reply is — is
324
+ // the upstream's too, and answering with the terminal alone dropped it:
325
+ // a terminal carrying its whole response beside a top-level usage, or
326
+ // one that omits object, answered without them. The body is the base,
327
+ // the terminal overrides what it carries, and the items it lost come
328
+ // back from the accumulation.
329
+ if (whole || !kept.length) return { ...body, ...final };
330
+ return { ...body, ...final, output: kept };
306
331
  },
307
332
  };
308
333
  }
package/tool-response.mjs CHANGED
@@ -158,22 +158,33 @@ function isCall(protocol, value) {
158
158
  );
159
159
  }
160
160
 
161
- // Counts the ids invented for calls that came without one, so that no two of
162
- // them can be handed the same id (see inPlaceOf).
161
+ // Counts the ids invented for calls the reply gives nothing to place: a
162
+ // counter cannot be stable, but it is the only thing that keeps two of them
163
+ // apart (see inPlaceOf).
163
164
  let synthesized = 0;
164
165
 
165
166
  // What a call left out of a Responses or Messages reply becomes: text
166
- // saying so, in the call's place, so no later index moves.
167
- function inPlaceOf(protocol, call, note) {
167
+ // saying so, in the call's place, so no later index moves. `at` is where the
168
+ // call sits in the reply as far as the caller can tell — a frame's
169
+ // output_index, or the index the walk found it at.
170
+ function inPlaceOf(protocol, call, note, at) {
168
171
  if (protocol === "anthropic") return { type: "text", text: note };
169
172
  // An item id is what a Responses client keys the item by, and what the
170
- // streaming path suppresses arguments by: two calls that arrived without
171
- // one and shared the id invented here would be one item to the client, and
172
- // the arguments of one would be read as the other's.
173
+ // streaming path suppresses arguments by. One call is replaced more than
174
+ // once — its output_item.added, its output_item.done, and the copy inside
175
+ // the terminal response's output — so the invented id has to be the same
176
+ // every time: a counter made one call into several items to the client, and
177
+ // left leftIDs unable to match its arguments frames. A call's place is the
178
+ // same in all three, and two calls cannot share one, so the id names it.
179
+ //
180
+ // A call the reply places nowhere keeps the counter — and names it under a
181
+ // different word, because both arms are inventing ids in one reply: a counter
182
+ // that happened to reach another call's index would hand two unrelated calls
183
+ // the same item, which is the failure the counter was introduced to prevent.
173
184
  const id =
174
185
  typeof call.id === "string" && call.id
175
186
  ? call.id
176
- : `msg_${call.call_id ?? `dropped_${++synthesized}`}`;
187
+ : `msg_${call.call_id ?? (at === undefined ? `anon_${++synthesized}` : `dropped_${at}`)}`;
177
188
  return {
178
189
  type: "message",
179
190
  id,
@@ -206,10 +217,15 @@ function checkSnapshots(data, protocol, checkName) {
206
217
  let kept = 0;
207
218
  let left = 0;
208
219
  if (!object(data)) throw new GuardError("Invalid upstream tool snapshot");
209
- // Each entry is a value and where it sits, so a call can be replaced.
210
- const stack = [[data, null, null]];
220
+ // Each entry is a value, where it sits, and the place it holds in the reply
221
+ // as far as the walk knows: an item of an output array is at its index, and
222
+ // anything reached through an object — a frame's own `item` — keeps the
223
+ // output_index the frame carries, which is where that item sits. A call
224
+ // left out is replaced by an item whose invented id names that place, so
225
+ // the added, done and terminal copies of one call are one item (inPlaceOf).
226
+ const stack = [[data, null, null, object(data) ? data.output_index : undefined]];
211
227
  while (stack.length) {
212
- const [value, holder, key] = stack.pop();
228
+ const [value, holder, key, at] = stack.pop();
213
229
  if (!object(value)) throw new GuardError("Invalid upstream tool snapshot");
214
230
  if (
215
231
  protocol === "responses" &&
@@ -223,7 +239,7 @@ function checkSnapshots(data, protocol, checkName) {
223
239
  const name = checkName(value.name);
224
240
  if (name == null) {
225
241
  if (!holder) throw new GuardError(droppedNote(value.name), "undeclared_tool");
226
- holder[key] = inPlaceOf(protocol, value, droppedNote(value.name));
242
+ holder[key] = inPlaceOf(protocol, value, droppedNote(value.name), at);
227
243
  changed = true;
228
244
  left++;
229
245
  continue;
@@ -241,10 +257,10 @@ function checkSnapshots(data, protocol, checkName) {
241
257
  // Text content is not a snapshot container.
242
258
  if (typeof value[name] === "string") continue;
243
259
  const items = list(value[name]);
244
- for (let i = 0; i < items.length; i++) stack.push([items[i], items, i]);
260
+ for (let i = 0; i < items.length; i++) stack.push([items[i], items, i, i]);
245
261
  }
246
262
  for (const name of objects) {
247
- if (value[name] != null) stack.push([value[name], value, name]);
263
+ if (value[name] != null) stack.push([value[name], value, name, at]);
248
264
  }
249
265
  }
250
266
  if (
@@ -463,7 +479,10 @@ function snapshotStream(protocol, checkName) {
463
479
  checkName(item.name) == null
464
480
  ) {
465
481
  const note = droppedNote(item.name);
466
- const message = inPlaceOf(protocol, item, note);
482
+ // The frame's output_index is this call's place in the reply, which
483
+ // is the id its later frames (done, the terminal's output) invent
484
+ // for it again (inPlaceOf).
485
+ const message = inPlaceOf(protocol, item, note, data.output_index);
467
486
  left.set(data.output_index, true);
468
487
  leftIDs.add(message.id);
469
488
  if (typeof item.id === "string") leftIDs.add(item.id);
@@ -544,6 +563,7 @@ async function readBounded(response) {
544
563
  if (!reader) return "";
545
564
  const parts = [];
546
565
  let bytes = 0;
566
+ let failure;
547
567
  try {
548
568
  while (true) {
549
569
  const { done, value } = await reader.read();
@@ -553,20 +573,28 @@ async function readBounded(response) {
553
573
  throw new GuardError(`Upstream response exceeds the ${LIMIT} buffer limit`);
554
574
  parts.push(value);
555
575
  }
576
+ } catch (error) {
577
+ failure = error;
578
+ }
579
+ // Whatever ended the read — the limit, a malformed body, or the end of it —
580
+ // this body is never read again, so the stream is cancelled and the reader
581
+ // loosed. This was the one place in the package that abandoned a body
582
+ // without cancelling it (measured: no cancellation past the limit, and the
583
+ // source still pulled after the 502), which leaves an upstream writing into
584
+ // a socket nobody reads. A cancel that fails must not stand in for what
585
+ // ended the read: an oversized body is the 502 this function promises, not
586
+ // an opaque transport error, so the cancel's failure is kept as that
587
+ // error's cause — and is thrown itself only when nothing else went wrong.
588
+ // The lock is released either way.
589
+ try {
590
+ await reader.cancel();
591
+ } catch (error) {
592
+ if (!failure) throw error;
593
+ if (failure instanceof Error && !("cause" in failure)) failure.cause = error;
556
594
  } finally {
557
- // Whatever ended the read — the limit, a malformed body, or the end of
558
- // it — this body is never read again, so the reader is loosed and the
559
- // stream cancelled. This was the one place in the package that abandoned
560
- // a body without cancelling it (measured: no cancellation past the limit,
561
- // and the source still pulled after the 502), which leaves an upstream
562
- // writing into a socket nobody reads. The cancel's own failure is not
563
- // swallowed, and the lock is released whether it threw or not.
564
- try {
565
- await reader.cancel();
566
- } finally {
567
- reader.releaseLock();
568
- }
595
+ reader.releaseLock();
569
596
  }
597
+ if (failure) throw failure;
570
598
  const all = new Uint8Array(bytes);
571
599
  let offset = 0;
572
600
  for (const part of parts) {
@@ -755,7 +783,14 @@ function chatTrack() {
755
783
  if (choice.finish_reason != null) {
756
784
  finished.add(index);
757
785
  open.delete(index);
758
- } else if (!finished.has(index)) open.add(index);
786
+ } else {
787
+ // A frame for this choice after it finished opens a new round of it
788
+ // (chatBuffer.apply judges a later round the same way), so the
789
+ // choice is open again: latching it shut for good left a round cut
790
+ // short with no note and no finish_reason, only a bare [DONE].
791
+ finished.delete(index);
792
+ open.add(index);
793
+ }
759
794
  }
760
795
  },
761
796
  // ending answers the frames that end the reply as cut short, or null