opencode-zen-freex 0.2.10 → 0.2.11

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/MAINTAINING.md CHANGED
@@ -354,8 +354,9 @@ What ships is `package.json`'s `files` list, and it is deliberately narrower tha
354
354
  the repository: the three maintenance scripts (`capture.mjs`, `tools-smoke.mjs`,
355
355
  `limits-audit.mjs`) stay here, because a host never loads them and one of them
356
356
  rewrites the shipped `fingerprint.json` if it is ever imported. `LICENSE` is
357
- verbatim MIT; the fork's provenance is in `NOTICE`, so a licence scanner reads a
358
- standard MIT file and a reader still finds why this fork exists.
357
+ MIT with its text unmodified and both copyright lines in place — upstream's and
358
+ this fork's — so what a licence scanner reads is a standard MIT file; the fork's
359
+ provenance is in `NOTICE`, which is where a reader finds why this fork exists.
359
360
 
360
361
  **The second factor is a browser, not a password.** Run `npm publish` in a
361
362
  terminal. When the account has 2FA on, the command stops and offers the whole
package/NOTICE CHANGED
@@ -3,7 +3,9 @@
3
3
  This package is a maintained fork of the OpenCode Zen Free plugin from
4
4
  [magpie-community/plugins](https://github.com/magpie-community/plugins), which
5
5
  is MIT licensed. Upstream's notice, `Copyright (c) 2026 xiaozhou26`, is kept in
6
- [LICENSE](./LICENSE) alongside this fork's own.
6
+ [LICENSE](./LICENSE) alongside this fork's own — the second copyright line
7
+ there, `Copyright (c) 2026 OpenCode-Zen-FreeX contributors`. The license text
8
+ itself is unmodified MIT.
7
9
 
8
10
  The fork is maintained separately from upstream under the name
9
11
  `opencode-zen-freex` (https://github.com/tkhs101/OpenCode-Zen-FreeX) because the
package/README.md CHANGED
@@ -125,6 +125,8 @@ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时
125
125
 
126
126
  偶尔 Zen 会在发出 `[DONE]` 之前就结束一个流式的 Chat Completions 回复。如果回复已经说了内容、且其中没有未完成的工具调用,就以普通回答结束,正文里会说明它被截断了;再问一次就会接着往下走。如果是带着未完成的工具调用被截断、或者什么都没说就断了、或者被折叠给一个不支持流式的客户端,请求就会失败,错误信息里写明这个回复的 id 和收到了多少。走 Anthropic Messages 或 Responses 的回复被截断同样会失败:magpie 能把它和完整的回复区分开,并且在该模型支持的情况下继续往下走。
127
127
 
128
+ 失败回复的正文会被读一小段,用来找 Zen 自己说出的上下文窗口或输出上限(见下)。因此透传给你的错误正文有两个规矩:读完的按原样给,只去掉描述旧字节的长度、编码与校验头;**没读完的不会冒充完整正文**——状态码仍是上游的,正文换成一段说明(写明上游的状态、读到了多少),并把读到的开头放在 `upstream` 字段里。不这么做的话,超长的错误正文会被截断成一段解析不了的 JSON,看起来像是解析出错而不是 Zen 说了什么。
129
+
128
130
  ## 排查
129
131
 
130
132
  | 看到的现象 | 这是什么 | 怎么办 |
@@ -134,7 +136,7 @@ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时
134
136
  | `404 … not available as an OpenCode Zen free model now` | Zen 不再免费开放这个模型 | 从错误信息列出的模型里换一个 |
135
137
  | `Model is not an available free model for this protocol` | 请求发来时列表变了 | 让宿主刷新模型列表再试 |
136
138
  | `uses the public credential` | 存的密钥不是 `public` | 用 `public` 重新登录 |
137
- | 不带 `FreeTierError` 的 403 | 是别的 403,原样透传 | 按 Zen 自己的错误来读,别当成这个插件的 |
139
+ | 不带 `FreeTierError` 的 403 | 是别的 403:正文原样透传,只去掉描述旧字节的头(`content-length`、`content-encoding`、`transfer-encoding`、`content-md5`、`etag`、`last-modified`) | 按 Zen 自己的错误来读,别当成这个插件的 |
138
140
 
139
141
  这个插件给出的 403 会写明发出去的措辞来自哪个 OpenCode 版本。那正是 `check:gate` 在问的那份指纹,所以从它开始查,而不是从请求头开始查。
140
142
 
@@ -142,4 +144,4 @@ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时
142
144
 
143
145
  ## 许可
144
146
 
145
- MIT,分叉自 [zen-free](https://github.com/magpie-community/plugins/tree/main/packages/zen-free)。版权署名:`Copyright (c) 2026 xiaozhou26` 是上游原作者,本分支的贡献者另有署名。`LICENSE` 是原样的 MIT 正文,本分支的来源与维护缘由写在 [NOTICE](https://github.com/tkhs101/OpenCode-Zen-FreeX/blob/main/NOTICE),这样许可证扫描器读到的是一份标准的 MIT 文件,而想知道这个分支为什么存在的人仍然找得到答案。
147
+ MIT,分叉自 [zen-free](https://github.com/magpie-community/plugins/tree/main/packages/zen-free)。`LICENSE` 的 MIT 正文原样未改,版权行有两条:上游原作者的 `Copyright (c) 2026 xiaozhou26`,和本分支贡献者的 `Copyright (c) 2026 OpenCode-Zen-FreeX contributors`。本分支的来源与维护缘由写在 [NOTICE](https://github.com/tkhs101/OpenCode-Zen-FreeX/blob/main/NOTICE),这样许可证扫描器读到的是一份标准的 MIT 文件,而想知道这个分支为什么存在的人仍然找得到答案。
package/core-tools.mjs CHANGED
@@ -22,19 +22,38 @@ import { fileURLToPath } from "node:url";
22
22
 
23
23
  const CAPTURE = new URL("./fingerprint.json", import.meta.url);
24
24
 
25
+ // The one remedy every failure below points at, written once: only a capture
26
+ // writes this file, so a message that names a command instead of the file to fix
27
+ // would have to be edited in three places the day that command is renamed.
28
+ const REMEDY = "run `bun run capture` to write a new one";
29
+
30
+ // Every failure in this module is the same shape — what could not be read or
31
+ // used, then the remedy — so the wording lives in one place and each throw names
32
+ // only its own half.
33
+ function captureError(what) {
34
+ return `core-tools: ${what} — ${REMEDY}`;
35
+ }
36
+
25
37
  // Read once, here, because every answer in this module comes out of it. Guarded
26
38
  // because the failure this replaces is a bare ENOENT or SyntaxError thrown out of
27
39
  // whichever module imported this one, which reads as *that* module being broken
28
- // and never names the file to fix. One message covers both, since the remedy is
29
- // the same for either: only a capture writes this file.
40
+ // and never names the file to fix. The remedy is the same whatever went wrong,
41
+ // but the diagnosis is not: an unreadable file (EACCES, EISDIR, a directory
42
+ // where a file should be) is neither missing nor invalid JSON, and saying it is
43
+ // sends the reader to a command that cannot fix a permission problem.
30
44
  function readProfiles() {
31
45
  let captured;
32
46
  try {
33
47
  captured = JSON.parse(readFileSync(CAPTURE, "utf8"));
34
48
  } catch (error) {
49
+ const reason =
50
+ error.code === "ENOENT"
51
+ ? "missing"
52
+ : error instanceof SyntaxError
53
+ ? "invalid JSON"
54
+ : `could not be read (${error.code ?? error.message})`;
35
55
  throw new Error(
36
- `core-tools: could not load the capture at ${fileURLToPath(CAPTURE)}; ` +
37
- "missing or invalid JSON — run `bun run capture` to write a new one",
56
+ captureError(`the capture at ${fileURLToPath(CAPTURE)} is ${reason}`),
38
57
  { cause: error },
39
58
  );
40
59
  }
@@ -51,8 +70,8 @@ function readProfiles() {
51
70
  !Object.keys(profiles).length
52
71
  ) {
53
72
  throw new Error(
54
- `core-tools: the capture at ${fileURLToPath(CAPTURE)} has no profiles; ` +
55
- "run `bun run capture` to write a new one",
73
+ captureError(`the capture at ${fileURLToPath(CAPTURE)} has no profiles`),
74
+ { cause: captured },
56
75
  );
57
76
  }
58
77
  return profiles;
@@ -84,7 +103,9 @@ export function generationOf(tools) {
84
103
  // asked for and what the capture holds. Ownership rather than truthiness: a
85
104
  // plain object answers `constructor` and `toString` out of Object.prototype, so
86
105
  // `PROFILES[generation]` alone would hand back a function and then fail on its
87
- // `.agent` with a TypeError that names nothing.
106
+ // `.agent` with a TypeError that names nothing. Ownership of the key is half of
107
+ // it — a capture holding `{"1.x": null}` owns the key and still has no profile,
108
+ // and the `.agent` of that is the same unnamed TypeError.
88
109
  function profileFor(generation) {
89
110
  if (!Object.hasOwn(PROFILES, generation)) {
90
111
  throw new Error(
@@ -92,9 +113,25 @@ function profileFor(generation) {
92
113
  `the capture holds ${Object.keys(PROFILES).join(", ")}`,
93
114
  );
94
115
  }
95
- return PROFILES[generation];
116
+ const profile = PROFILES[generation];
117
+ if (typeof profile !== "object" || profile === null || Array.isArray(profile)) {
118
+ throw new Error(
119
+ captureError(
120
+ `the ${JSON.stringify(generation)} profile in the capture is not an object`,
121
+ ),
122
+ );
123
+ }
124
+ return profile;
96
125
  }
97
126
 
127
+ // A field interpolated into the User-Agent has to be a string with something in
128
+ // it: each one is read by Zen, and a missing or mistyped one used to compose
129
+ // `opencode/undefined/1.19.0/undefined` — a header that gets a refusal saying
130
+ // nothing about the capture it came out of. `Object.hasOwn` alone would only
131
+ // prove the key is there: `{"chat": null}` and `{"chat": ""}` both compose a
132
+ // version of `null`.
133
+ const filledIn = (value) => typeof value === "string" && value !== "";
134
+
98
135
  /**
99
136
  * The User-Agent that generation of OpenCode sends, for a protocol. Each
100
137
  * protocol's ai-sdk package bundles its own provider-utils, so 1.x differs by
@@ -102,15 +139,30 @@ function profileFor(generation) {
102
139
  */
103
140
  export function agentFor(generation, protocol = "chat") {
104
141
  const agent = profileFor(generation).agent;
105
- if (typeof agent !== "object" || agent === null) {
142
+ if (typeof agent !== "object" || agent === null || Array.isArray(agent)) {
106
143
  throw new Error(
107
- `core-tools: the ${JSON.stringify(generation)} profile in the capture has no agent; ` +
108
- "run `bun run capture` to write a new one",
144
+ captureError(`the ${JSON.stringify(generation)} profile in the capture has no agent`),
109
145
  );
110
146
  }
111
147
  // The release shape carries no provider-utils at all, and is composed the same
112
148
  // way whatever protocol was asked for.
113
- if (!agent.providerUtils) return `opencode/${agent.channel}/${agent.opencode}/${agent.client}`;
149
+ if (!agent.providerUtils) {
150
+ if (![agent.channel, agent.opencode, agent.client].every(filledIn)) {
151
+ throw new Error(
152
+ captureError(
153
+ `the ${JSON.stringify(generation)} profile in the capture is not a release agent`,
154
+ ),
155
+ );
156
+ }
157
+ return `opencode/${agent.channel}/${agent.opencode}/${agent.client}`;
158
+ }
159
+ if (typeof agent.providerUtils !== "object" || agent.providerUtils === null) {
160
+ throw new Error(
161
+ captureError(
162
+ `the ${JSON.stringify(generation)} profile in the capture has no per-protocol provider-utils`,
163
+ ),
164
+ );
165
+ }
114
166
  // `?? "undefined"` used to be interpolated here for a protocol the capture has
115
167
  // no provider-utils for, and a prototype member (`toString`) as the version.
116
168
  // Zen reads this header, so a malformed one is rejected upstream with an error
@@ -122,5 +174,13 @@ export function agentFor(generation, protocol = "chat") {
122
174
  `it holds ${Object.keys(agent.providerUtils).join(", ")}`,
123
175
  );
124
176
  }
177
+ if (!filledIn(agent.providerUtils[protocol]) || !filledIn(agent.opencode) || !filledIn(agent.runtime)) {
178
+ throw new Error(
179
+ captureError(
180
+ `the ${JSON.stringify(generation)} profile in the capture does not fill in every field of ` +
181
+ `its ${JSON.stringify(protocol)} User-Agent`,
182
+ ),
183
+ );
184
+ }
125
185
  return `opencode/${agent.opencode} ai-sdk/provider-utils/${agent.providerUtils[protocol]} ${agent.runtime}`;
126
186
  }
package/index.mjs CHANGED
@@ -11,9 +11,13 @@ import plugin from "./provider.mjs"
11
11
  //
12
12
  // The named export stays for anything that already imported it by name, and
13
13
  // `server` is offered as a name too, so `mod.default.server` and `mod.server`
14
- // both resolve.
15
- if (typeof plugin?.server !== "function") {
16
- throw new TypeError("provider.mjs must default-export a plugin with a `server` factory")
14
+ // both resolve. Both halves of what the object carries are checked here, the
15
+ // `id` as well as the factory: a provider.mjs that lost either one would
16
+ // otherwise load cleanly and hand the host a plugin it cannot key or start.
17
+ if (typeof plugin?.server !== "function" || typeof plugin?.id !== "string" || !plugin.id) {
18
+ throw new TypeError(
19
+ "provider.mjs must default-export a plugin with a `server` factory and an `id`",
20
+ )
17
21
  }
18
22
 
19
23
  export const ZenFreePlugin = plugin.server
package/limits.mjs CHANGED
@@ -318,6 +318,14 @@ export async function readBoundedText(response, limit = 4096, { signal } = {}) {
318
318
  // the stream is still going, and a number sliced there (`262144` read as
319
319
  // `2621`) must not be taken for a limit the route stated.
320
320
  //
321
+ // `ended` is the read's own answer to "is this the whole body?": true when the
322
+ // body was read to its end, false when the budget, the deadline or a failed
323
+ // read stopped it short — a read that got nothing at all included, which is the
324
+ // same answer for a different reason. A caller that hands these bytes on has to
325
+ // know which of the two it holds: a prefix presented as the whole reply is a
326
+ // lie the caller cannot see, and the bytes cannot be gone back for, since this
327
+ // read is the body's one reader.
328
+ //
321
329
  // Returns undefined only when nothing was read — a response that is not a
322
330
  // refusal, or one with no body to read — and otherwise whichever axes the text
323
331
  // named, with that text alongside them. An axis the body did not name is
@@ -327,13 +335,15 @@ export async function observeRouteStatement(id, response, limit = 4096, { signal
327
335
  try {
328
336
  if (typeof id !== "string" || !id || response?.ok !== false) return undefined;
329
337
  // A response with no body was not read and is not consumed: the caller
330
- // still holds the one it had, and undefined says so.
331
- if (response.body == null) return undefined;
332
- const { text, over } = await readBoundedText(response, limit, { signal });
338
+ // still holds the one it had, and undefined says so. Written out rather
339
+ // than left to a loose comparison, which the project's rules do not allow
340
+ // and which would also answer for a body that is not one of these two.
341
+ if (response.body === null || response.body === undefined) return undefined;
342
+ const { text, over, ended } = await readBoundedText(response, limit, { signal });
333
343
  const { context, output } = namedAxes(text, limit, over);
334
344
  if (context !== undefined) OBSERVED.set(id, context);
335
345
  if (output !== undefined) OBSERVED_OUTPUT.set(id, output);
336
- return { context, output, text };
346
+ return { context, output, text, ended };
337
347
  } catch {
338
348
  return undefined;
339
349
  }
package/models.mjs CHANGED
@@ -33,7 +33,9 @@ function sdkProtocol(npm) {
33
33
  }
34
34
 
35
35
  // Set, and not merely present: the catalogue writes both a missing date and an
36
- // explicit null, and neither retires a model.
36
+ // explicit null, and neither retires a model. A blank string is not a date
37
+ // either — see `isSet` below, which is the same rule stated for the two axes
38
+ // the retirement checks read.
37
39
  const hasValue = (value) => value !== null && value !== undefined;
38
40
 
39
41
  // A status has to say something to be one. The catalogue writes an empty string
@@ -48,8 +50,11 @@ function isRetired(model) {
48
50
  ["deprecated", "retired", "disabled"].includes(
49
51
  isSet(model.status) ? model.status : model.lifecycle,
50
52
  ) ||
51
- hasValue(model.deprecated_at) ||
52
- hasValue(model.retirement_date)
53
+ // Read through `isSet`, not `hasValue`: a blank `deprecated_at` is the empty
54
+ // string the catalogue writes for a date it does not have, and reading it as
55
+ // a date retired a model that was still on offer.
56
+ isSet(model.deprecated_at) ||
57
+ isSet(model.retirement_date)
53
58
  );
54
59
  }
55
60
 
@@ -67,8 +72,13 @@ export function isFree(id, model = {}) {
67
72
  // model is offered the hour Zen lists it rather than the hour the catalogue
68
73
  // catches up. An id that does not call itself free still needs the catalogue's
69
74
  // word (a paid one, or a zero-cost one Zen never named).
75
+ //
76
+ // The same question `isFree` asks, and answered the same way: case-insensitive.
77
+ // Asked case-sensitively, an id like `new-model-FREE` passed the free gate above
78
+ // and then found no protocol here, so it was dropped at the `SDK` lookup below —
79
+ // silently, which is the one outcome the fallback exists to prevent.
70
80
  function listedFree(id) {
71
- return /-free$/.test(id);
81
+ return /-free$/i.test(id);
72
82
  }
73
83
 
74
84
  // Which protocol a model is asked at, in the order the answer is trusted: the
@@ -99,6 +109,22 @@ const modalities = (values) =>
99
109
  ]),
100
110
  );
101
111
 
112
+ // A window is a positive whole number of tokens. The catalogue writes an
113
+ // explicit `null` — and an empty string, and a zero — for an axis it does not
114
+ // have, and every one of those used to be copied over the 32768/4096 defaults by
115
+ // the spread below, so the host was handed `context: null` as the model's window.
116
+ const usable = (value) => Number.isSafeInteger(value) && value > 0;
117
+
118
+ // The limit the model is offered with, from the axes that are usable: the
119
+ // defaults first, then the catalogue's own numbers, then what the plugin has
120
+ // evidence for — each level only contributing axes it can actually state.
121
+ function limitFor(id, declared) {
122
+ const limit = { context: 32768, output: 4096 };
123
+ for (const [axis, value] of Object.entries(declared ?? {})) if (usable(value)) limit[axis] = value;
124
+ for (const [axis, value] of Object.entries(limitOverrides(id, declared))) limit[axis] = value;
125
+ return limit;
126
+ }
127
+
102
128
  export function buildModels(ids, provider, protocols, baseURL) {
103
129
  const result = {};
104
130
  for (const id of ids) {
@@ -128,7 +154,7 @@ export function buildModels(ids, provider, protocols, baseURL) {
128
154
  providerID: PROVIDER,
129
155
  name: model.name ?? id,
130
156
  api: { id, url: baseURL, npm: SDK[protocol] },
131
- limit: { context: 32768, output: 4096, ...model.limit, ...limitOverrides(id, model.limit) },
157
+ limit: limitFor(id, model.limit),
132
158
  capabilities: {
133
159
  temperature: model.temperature ?? true,
134
160
  reasoning: model.reasoning ?? false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-zen-freex",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
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
@@ -114,6 +114,50 @@ function rewrittenHeaders(response) {
114
114
  return headers;
115
115
  }
116
116
 
117
+ // The headers a body this package read once and writes again unchanged is
118
+ // described by. The staleness rule is the same one `rewrittenHeaders` applies —
119
+ // a length, an encoding and the validators all describe the bytes that arrived,
120
+ // not the ones going out — but the content type is left as the upstream sent
121
+ // it: a refusal relayed from a gateway may be HTML, and calling it JSON here
122
+ // would be this package's word rather than the upstream's.
123
+ function relayedHeaders(response) {
124
+ const headers = new Headers(response.headers);
125
+ for (const name of STALE_BODY_HEADERS) headers.delete(name);
126
+ return headers;
127
+ }
128
+
129
+ // What was read of a refusal is a prefix, not necessarily the refusal. Handing
130
+ // that prefix on as the whole body is a lie the caller cannot see: a host that
131
+ // parses it reports a JSON error of its own instead of Zen's, which is the one
132
+ // message this path exists to keep readable. So a read that did not reach the
133
+ // end of the body — one that got nothing at all included — is answered with a
134
+ // refusal that says what happened, carries the opening that was read under
135
+ // `upstream`, and keeps the status the upstream sent.
136
+ const KEPT_PREFIX = 512;
137
+
138
+ function relayedRefusal(response, observed) {
139
+ const read = typeof observed.text === "string" ? observed.text : "";
140
+ const why = read
141
+ ? `its body ran past the ${read.length} characters this plugin reads of a refusal, so only the opening of it came back`
142
+ : "its body could not be read at all";
143
+ return new Response(
144
+ JSON.stringify({
145
+ type: "error",
146
+ error: {
147
+ type: "upstream_error",
148
+ message:
149
+ `Upstream answered HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}, and ${why}.`,
150
+ upstream: read.slice(0, KEPT_PREFIX) + (read.length > KEPT_PREFIX ? "…" : ""),
151
+ },
152
+ }),
153
+ {
154
+ status: response.status,
155
+ statusText: response.statusText,
156
+ headers: rewrittenHeaders(response),
157
+ },
158
+ );
159
+ }
160
+
117
161
  // How long a request for a model the last list didn't have waits before it
118
162
  // asks Zen's list again: a model Zen made free since is served at once,
119
163
  // and a model typed in by hand doesn't ask the list on every request.
@@ -545,9 +589,12 @@ async function server(_input, options = {}) {
545
589
  //
546
590
  // A response body can only be read once, so what the limits reader
547
591
  // 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.
592
+ // never the upstream's own headers over bytes this package wrote
593
+ // again, and never a prefix presented as the whole reply. A read
594
+ // that reached the end of the body is that body, relayed under
595
+ // headers corrected for it; one that stopped short is answered with
596
+ // a refusal that says so (see `relayedRefusal`). A response the
597
+ // reader did not consume is used as it arrived.
551
598
  const observed = await observeRouteStatement(body.model, response, undefined, {
552
599
  // The caller's own cancellation reaches this read as well: a host
553
600
  // that has given up is not waiting for a number out of a refusal.
@@ -556,11 +603,13 @@ async function server(_input, options = {}) {
556
603
  const answered =
557
604
  observed === undefined
558
605
  ? response
559
- : new Response(observed.text, {
560
- status: response.status,
561
- statusText: response.statusText,
562
- headers: response.headers,
563
- });
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);
564
613
  const guarded = await guardToolResponse(
565
614
  explain(observed?.text ?? null, answered, toolPolicy),
566
615
  protocol,
package/stream.mjs CHANGED
@@ -143,10 +143,24 @@ function chatAccumulator() {
143
143
  choice.finish_reason = part.finish_reason;
144
144
  if (part.logprobs) {
145
145
  choice.logprobs ??= {};
146
+ // Written as a defined own property, and read back as one. A
147
+ // "__proto__" key arrives as an own property of the parsed object,
148
+ // and `choice.logprobs[key] = value` would take the inherited
149
+ // accessor instead: an object value silently re-parented the
150
+ // accumulated object and the key never reached the reply, while an
151
+ // array value read Object.prototype back and spread it, throwing
152
+ // "not iterable" — a 502 on an otherwise valid stream. The delta
153
+ // written by tool-response.mjs follows the same rule.
146
154
  for (const [key, value] of Object.entries(part.logprobs)) {
147
- if (Array.isArray(value))
148
- choice.logprobs[key] = [...(choice.logprobs[key] ?? []), ...value];
149
- else choice.logprobs[key] = value;
155
+ const held = Object.hasOwn(choice.logprobs, key) ? choice.logprobs[key] : undefined;
156
+ Object.defineProperty(choice.logprobs, key, {
157
+ value: Array.isArray(value)
158
+ ? [...(Array.isArray(held) ? held : []), ...value]
159
+ : value,
160
+ writable: true,
161
+ enumerable: true,
162
+ configurable: true,
163
+ });
150
164
  }
151
165
  }
152
166
  }
@@ -317,7 +331,15 @@ function anthropicAccumulator() {
317
331
  for (const [position, block] of (data.message?.content ?? []).entries())
318
332
  if (carries(block, blocks.get(position), fields)) blocks.set(position, block);
319
333
  } else if (type === "content_block_start") {
320
- blocks.set(index(data.index), data.content_block);
334
+ // The same test message_start's blocks get, for the same reason: a
335
+ // gateway re-emitting a block's opening frame carries a block that
336
+ // was never filled in (a text one opens with text: ""), and taking
337
+ // it would discard the text, thinking or signature the deltas
338
+ // already delivered. A start with nothing accumulated applies, as
339
+ // does one that carries content of its own.
340
+ const position = index(data.index);
341
+ if (carries(data.content_block, blocks.get(position), fields))
342
+ blocks.set(position, data.content_block);
321
343
  } else if (type === "content_block_delta") {
322
344
  const position = index(data.index);
323
345
  const block = blocks.get(position);
package/tool-response.mjs CHANGED
@@ -158,13 +158,25 @@ function isCall(protocol, value) {
158
158
  );
159
159
  }
160
160
 
161
+ // Counts the ids invented for calls that came without one, so that no two of
162
+ // them can be handed the same id (see inPlaceOf).
163
+ let synthesized = 0;
164
+
161
165
  // What a call left out of a Responses or Messages reply becomes: text
162
166
  // saying so, in the call's place, so no later index moves.
163
167
  function inPlaceOf(protocol, call, note) {
164
168
  if (protocol === "anthropic") return { type: "text", text: note };
169
+ // An item id is what a Responses client keys the item by, and what the
170
+ // streaming path suppresses arguments by: two calls that arrived without
171
+ // one and shared the id invented here would be one item to the client, and
172
+ // the arguments of one would be read as the other's.
173
+ const id =
174
+ typeof call.id === "string" && call.id
175
+ ? call.id
176
+ : `msg_${call.call_id ?? `dropped_${++synthesized}`}`;
165
177
  return {
166
178
  type: "message",
167
- id: typeof call.id === "string" && call.id ? call.id : `msg_${call.call_id ?? "dropped"}`,
179
+ id,
168
180
  role: "assistant",
169
181
  status: call.status ?? "completed",
170
182
  content: [{ type: "output_text", text: note, annotations: [] }],
@@ -919,7 +931,17 @@ function guardedStream(response, protocol, checkName, endCutShort) {
919
931
  );
920
932
  controller.close();
921
933
  } else {
922
- release();
934
+ // A failure that is not the guard's own is a transport one: the
935
+ // stream is over either way, and what is left of the body is not
936
+ // something to keep writing into a socket nobody reads — the
937
+ // same rule readBounded follows. Cancel's own failure is not
938
+ // allowed to replace the error the client has to see, which is
939
+ // the one that got here.
940
+ try {
941
+ await cancel(error);
942
+ } catch {
943
+ /* the original error is the one the client must see */
944
+ }
923
945
  controller.error(error);
924
946
  }
925
947
  }
@@ -938,9 +960,12 @@ function guardedStream(response, protocol, checkName, endCutShort) {
938
960
  // collapsed for a client that doesn't stream fails instead, so it can be
939
961
  // asked again before anything reached the client.
940
962
  export async function guardToolResponse(response, protocol, policy, options = {}) {
941
- if (!response.ok) return response;
963
+ // A protocol this guard has no rules for is the caller's mistake, and it
964
+ // is one whichever the upstream answered: checked before the status is
965
+ // read, so an error response cannot return it unexamined.
942
966
  if (!["chat", "responses", "anthropic"].includes(protocol))
943
967
  throw new TypeError(`Unsupported protocol: ${protocol}`);
968
+ if (!response.ok) return response;
944
969
  const checkName = nameGuard(policy);
945
970
  const headers = headersFor(response);
946
971
  const contentType = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();