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