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 +6 -1
- package/limits.mjs +30 -17
- package/package.json +1 -1
- package/provider.mjs +47 -28
- package/stream.mjs +35 -10
- package/tool-response.mjs +63 -28
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
|
-
|
|
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/
|
|
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/
|
|
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+)/
|
|
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
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
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
|
|
362
|
-
// may have
|
|
363
|
-
// digit short.
|
|
364
|
-
//
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
593
|
-
// again, and never a prefix presented as the whole
|
|
594
|
-
//
|
|
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
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
|
162
|
-
//
|
|
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
|
-
|
|
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
|
|
171
|
-
//
|
|
172
|
-
// the
|
|
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 ?? `
|
|
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
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|