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/LICENSE +2 -9
- package/MAINTAINING.md +44 -3
- package/NOTICE +18 -0
- package/README.md +6 -4
- package/core-tools.mjs +85 -7
- package/index.mjs +14 -4
- package/limits.mjs +221 -57
- package/models.mjs +27 -3
- package/package.json +14 -6
- package/provider.mjs +111 -22
- package/stream.mjs +119 -21
- package/tool-response.mjs +56 -11
- package/capture.mjs +0 -231
- package/limits-audit.mjs +0 -340
- package/tools-smoke.mjs +0 -234
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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:
|
|
286
|
+
error: { type: source.status === 426 ? "upgrade_required" : "FreeTierError", message },
|
|
234
287
|
}),
|
|
235
|
-
{ status:
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
+
keep(position, value);
|
|
153
212
|
}
|
|
154
213
|
} else if (type === "response.output_item.added" || type === "response.output_item.done") {
|
|
155
|
-
if (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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
|
193
|
-
value[
|
|
194
|
-
const
|
|
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
|
-
|
|
199
|
-
|
|
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)
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|