opencode-zen-freex 0.2.10 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,17 +103,40 @@ 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.
88
- function profileFor(generation) {
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.
109
+ //
110
+ // Exported, because the same two mistakes are reachable from outside this file
111
+ // and are made there: `provider.mjs` indexes `PROFILES[...]` directly to read a
112
+ // profile's `tools`, and a capture missing that generation then fails with
113
+ // "Cannot read properties of undefined" instead of the sentence below.
114
+ export function profileFor(generation) {
89
115
  if (!Object.hasOwn(PROFILES, generation)) {
90
116
  throw new Error(
91
117
  `core-tools: no profile for generation ${JSON.stringify(generation)}; ` +
92
118
  `the capture holds ${Object.keys(PROFILES).join(", ")}`,
93
119
  );
94
120
  }
95
- return PROFILES[generation];
121
+ const profile = PROFILES[generation];
122
+ if (typeof profile !== "object" || profile === null || Array.isArray(profile)) {
123
+ throw new Error(
124
+ captureError(
125
+ `the ${JSON.stringify(generation)} profile in the capture is not an object`,
126
+ ),
127
+ );
128
+ }
129
+ return profile;
96
130
  }
97
131
 
132
+ // A field interpolated into the User-Agent has to be a string with something in
133
+ // it: each one is read by Zen, and a missing or mistyped one used to compose
134
+ // `opencode/undefined/1.19.0/undefined` — a header that gets a refusal saying
135
+ // nothing about the capture it came out of. `Object.hasOwn` alone would only
136
+ // prove the key is there: `{"chat": null}` and `{"chat": ""}` both compose a
137
+ // version of `null`.
138
+ const filledIn = (value) => typeof value === "string" && value !== "";
139
+
98
140
  /**
99
141
  * The User-Agent that generation of OpenCode sends, for a protocol. Each
100
142
  * protocol's ai-sdk package bundles its own provider-utils, so 1.x differs by
@@ -102,15 +144,30 @@ function profileFor(generation) {
102
144
  */
103
145
  export function agentFor(generation, protocol = "chat") {
104
146
  const agent = profileFor(generation).agent;
105
- if (typeof agent !== "object" || agent === null) {
147
+ if (typeof agent !== "object" || agent === null || Array.isArray(agent)) {
106
148
  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",
149
+ captureError(`the ${JSON.stringify(generation)} profile in the capture has no agent`),
109
150
  );
110
151
  }
111
152
  // The release shape carries no provider-utils at all, and is composed the same
112
153
  // way whatever protocol was asked for.
113
- if (!agent.providerUtils) return `opencode/${agent.channel}/${agent.opencode}/${agent.client}`;
154
+ if (!agent.providerUtils) {
155
+ if (![agent.channel, agent.opencode, agent.client].every(filledIn)) {
156
+ throw new Error(
157
+ captureError(
158
+ `the ${JSON.stringify(generation)} profile in the capture is not a release agent`,
159
+ ),
160
+ );
161
+ }
162
+ return `opencode/${agent.channel}/${agent.opencode}/${agent.client}`;
163
+ }
164
+ if (typeof agent.providerUtils !== "object" || agent.providerUtils === null) {
165
+ throw new Error(
166
+ captureError(
167
+ `the ${JSON.stringify(generation)} profile in the capture has no per-protocol provider-utils`,
168
+ ),
169
+ );
170
+ }
114
171
  // `?? "undefined"` used to be interpolated here for a protocol the capture has
115
172
  // no provider-utils for, and a prototype member (`toString`) as the version.
116
173
  // Zen reads this header, so a malformed one is rejected upstream with an error
@@ -122,5 +179,13 @@ export function agentFor(generation, protocol = "chat") {
122
179
  `it holds ${Object.keys(agent.providerUtils).join(", ")}`,
123
180
  );
124
181
  }
182
+ if (!filledIn(agent.providerUtils[protocol]) || !filledIn(agent.opencode) || !filledIn(agent.runtime)) {
183
+ throw new Error(
184
+ captureError(
185
+ `the ${JSON.stringify(generation)} profile in the capture does not fill in every field of ` +
186
+ `its ${JSON.stringify(protocol)} User-Agent`,
187
+ ),
188
+ );
189
+ }
125
190
  return `opencode/${agent.opencode} ai-sdk/provider-utils/${agent.providerUtils[protocol]} ${agent.runtime}`;
126
191
  }
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
@@ -109,7 +109,7 @@ export const LIMITS = {
109
109
  // says nothing attributable and is left alone; §7 of the measured notes has a
110
110
  // context overflow and an invalid parameter answering with byte-identical 400s
111
111
  // on another Zen model.
112
- const NAMED_CONTEXT = /maximum context length is (\d+) tokens/i;
112
+ const NAMED_CONTEXT = /maximum context length is (\d+) tokens/id;
113
113
 
114
114
  // "max_tokens is too large: 200000. This model supports at most 131072 completion
115
115
  // tokens, whereas you provided 200000." — an upstream that validates the ceiling
@@ -119,10 +119,10 @@ const NAMED_CONTEXT = /maximum context length is (\d+) tokens/i;
119
119
  // answers 200 there, so silence from this one says nothing about a model, and a
120
120
  // request that is served is not evidence of a ceiling either way.
121
121
  const NAMED_OUTPUT = [
122
- /supports at most (\d+) completion tokens/i,
122
+ /supports at most (\d+) completion tokens/id,
123
123
  // "/max_tokens: 1000000 is not less or equal to 262144" — LongCat's wording for
124
124
  // the same fact, where the ceiling is the number it has to stay under.
125
- /max_tokens:?\s*\d+\s+is not less or equal to\s+(\d+)/i,
125
+ /max_tokens:?\s*\d+\s+is not less or equal to\s+(\d+)/id,
126
126
  ];
127
127
 
128
128
  // Names a refusal made while this process is running, by model id. Not
@@ -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,37 +335,47 @@ 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
  }
340
350
  }
341
351
 
342
- // How close to the end of what was read a match has to end before a truncated
343
- // body makes it unusable. A number ending inside this margin of the last
344
- // character read may have had digits past it — `2621` where the body said
345
- // `262144` — and a smaller limit that looks like a real one is the one failure
346
- // this guard exists to prevent. Numbers further back are inside the buffer
347
- // entirely and are read as they stand.
348
- const CUT_MARGIN = 8;
352
+ // Where the number a match named ends, in the text it was matched against. The
353
+ // `d` flag is what makes this exact: `NAMED_CONTEXT` and the first
354
+ // `NAMED_OUTPUT` run on past the digits (" completion tokens"), so judging the
355
+ // cut by the match's own end threw away numbers that were provably whole — the
356
+ // digits ended well before the read did, with text after them that the greedy
357
+ // `(\d+)` had refused to consume.
358
+ function digitsEnd(found) {
359
+ const at = found?.match?.indices?.[1]?.[1];
360
+ // The match's own end is the conservative answer when the pattern cannot say
361
+ // where the digits are: it drops a whole number rather than reading a cut one,
362
+ // which is the direction this guard has to fail in.
363
+ return Number.isSafeInteger(at) ? at : found.match.index + found.match[0].length;
364
+ }
349
365
 
350
366
  // The axes the first `limit` characters of `text` name. `cut` says the read
351
- // stopped at the budget with the body going on, so a match ending at the margin
352
- // may have been a longer number: that axis is left unread rather than reported a
353
- // digit short. Each axis is read from the match its number came from, so the
354
- // match the guard judges and the number that is returned cannot be two answers.
367
+ // stopped before the body did, so a number whose own digits reach the last
368
+ // character read may have had digits past it: that axis is left unread rather
369
+ // than reported a digit short. Any character after the digits inside the head
370
+ // proves the number ended, since a greedy `\d+` would have taken it — only
371
+ // digits touching the boundary are ambiguous. Each axis is read from the match
372
+ // its number came from, so the match the guard judges and the number that is
373
+ // returned cannot be two answers.
355
374
  function namedAxes(text, limit, cut) {
356
375
  const head = text.slice(0, limit);
357
376
  const context = namedFrom([NAMED_CONTEXT], head);
358
377
  const output = namedFrom(NAMED_OUTPUT, head);
359
- const onCut = (found) =>
360
- cut && Boolean(found) && found.match.index + found.match[0].length >= head.length - CUT_MARGIN;
378
+ const onCut = (found) => cut && found !== undefined && digitsEnd(found) >= head.length;
361
379
  return {
362
380
  context: onCut(context) ? undefined : context?.value,
363
381
  output: onCut(output) ? undefined : output?.value,
@@ -384,7 +402,12 @@ export function limitOverrides(
384
402
  // declared, from a table that never named it.
385
403
  const record = table && Object.hasOwn(table, id) ? table[id] : undefined;
386
404
  for (const axis of ["context", "output"]) {
387
- const declaredValue = declared?.[axis];
405
+ // Own property, like the table lookup two lines up: `declared` is handed in
406
+ // by a caller, and a `context` inherited from a custom prototype — or from a
407
+ // polluted `Object.prototype` — would otherwise be read as a declaration the
408
+ // catalogue never made, feeding the staleness compare and the raise-only
409
+ // check with a number nobody declared.
410
+ const declaredValue = declared && Object.hasOwn(declared, axis) ? declared[axis] : undefined;
388
411
  const entry = record?.[axis];
389
412
  const live = (axis === "context" ? observed : observedOutput)?.get(id);
390
413
  // A route statement made while this process was running is the same tier as
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.12",
4
4
  "description": "OpenCode Zen's free models, for hosts that are not OpenCode: the public credential, automatic discovery and native streaming protocols",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/provider.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { PROFILES, agentFor, generationOf } from "./core-tools.mjs";
2
+ import { agentFor, generationOf, profileFor } from "./core-tools.mjs";
3
3
  import {
4
4
  PROVIDER,
5
5
  SDK,
@@ -114,6 +114,66 @@ 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
+
161
+ // The response this path hands on, from what the limits reader made of the one
162
+ // that arrived: the same response when it never read it — the reader touches no
163
+ // 2xx body, so that is the common branch — the bytes it read under headers
164
+ // corrected for them when it read the whole body, and a refusal that says so
165
+ // when it stopped short. One decision, written once, so the three outcomes
166
+ // cannot be read as a chain of ternaries at the call site.
167
+ function relayed(response, observed) {
168
+ if (observed === undefined) return response;
169
+ if (!observed.ended) return relayedRefusal(response, observed);
170
+ return new Response(observed.text, {
171
+ status: response.status,
172
+ statusText: response.statusText,
173
+ headers: relayedHeaders(response),
174
+ });
175
+ }
176
+
117
177
  // How long a request for a model the last list didn't have waits before it
118
178
  // asks Zen's list again: a model Zen made free since is served at once,
119
179
  // and a model typed in by hand doesn't ask the list on every request.
@@ -166,20 +226,23 @@ function normalizeTools(body, protocol) {
166
226
  const declared = new Set([...present].filter((value) => typeof value === "string"));
167
227
  // The legacy top-level `functions` array is the older way of declaring the
168
228
  // 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.
229
+ // so they count as declared, and the fill loop below finds them by name: a
230
+ // name that only arrived through `functions` has no index in `body.tools`, so
231
+ // it is looked up in the array itself and reworded where it sits rather than
232
+ // being stood in for. Without that the same tool would go out twice, which is
233
+ // what the note on `nameOf` says must not happen.
174
234
  const legacy = Array.isArray(body.functions) && protocol === "chat" ? body.functions : [];
175
235
  for (const tool of legacy) if (typeof tool?.name === "string") declared.add(tool.name);
176
- const declaredElsewhere = new Set(legacy.map((tool) => tool?.name));
177
236
  // Zen takes a request for one generation of OpenCode or the other, and the tools
178
237
  // it carries say which: `shell` is 2.x, `bash` is 1.x. Names move far less
179
238
  // often than descriptions do, so this survives a rewording that a pinned
180
239
  // User-Agent would not.
181
240
  const generation = generationOf(body.tools) ?? DEFAULT_GENERATION;
182
- const profile = PROFILES[generation];
241
+ // Through `profileFor`, not `PROFILES[generation]`: a capture that holds one
242
+ // generation leaves the other key absent, and the direct read fails below on
243
+ // `profile.tools` with a TypeError that names nothing — the capture is what
244
+ // has to change, and it is the one thing that error does not say.
245
+ const profile = profileFor(generation);
183
246
  const filled = [];
184
247
  const at = new Map();
185
248
  body.tools.forEach((tool, index) => {
@@ -208,8 +271,17 @@ function normalizeTools(body, protocol) {
208
271
  // tightened check would otherwise find missing.
209
272
  if (!profile.required.includes(name)) continue;
210
273
  // 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;
274
+ // tool, so it is not one to stand in for. Its wording is still the host's,
275
+ // though, and Zen reads the wording of a tool it knows by name whichever
276
+ // array carried it — leaving that one entry as the host wrote it is the
277
+ // refusal this loop exists to prevent, and it is the entry `explain()`
278
+ // would then describe wrongly. The legacy shape carries `description` at
279
+ // the top level, which `setDescription` already handles.
280
+ const throughFunctions = legacy.find((tool) => tool?.name === name);
281
+ if (throughFunctions) {
282
+ setDescription(throughFunctions, spoken.description);
283
+ continue;
284
+ }
213
285
  filled.push(name);
214
286
  // A tool added here has nothing behind it: the agent has no such tool and
215
287
  // a call to it is dropped. Describing it as OpenCode describes it is what
@@ -264,7 +336,10 @@ function explain(text, source, toolPolicy) {
264
336
  // A 403 that is not Zen's free tier is somebody else's; it is left as sent —
265
337
  // its bytes are what arrived, and so are the headers describing them.
266
338
  if (source.status === 403 && !text.includes("FreeTierError")) return source;
267
- const profile = PROFILES[toolPolicy.generation];
339
+ // The same lookup `normalizeTools` made, through the same owner: the refusal
340
+ // this message explains is about the generation that request was sent as, and
341
+ // a capture that lost that profile has to say so rather than fail on `.tools`.
342
+ const profile = profileFor(toolPolicy.generation);
268
343
  const names = Object.keys(profile.tools).join(", ");
269
344
  const { filled } = toolPolicy;
270
345
  // 426 is not a refusal of the wording: it is this plugin presenting itself as
@@ -544,23 +619,16 @@ async function server(_input, options = {}) {
544
619
  // quietly never used.
545
620
  //
546
621
  // 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.
622
+ // took is handed on as the response the rest of the path works with
623
+ // (see `relayed`): never the upstream's own headers over bytes this
624
+ // package wrote again, and never a prefix presented as the whole
625
+ // reply. A response the reader did not consume is used as it arrived.
551
626
  const observed = await observeRouteStatement(body.model, response, undefined, {
552
627
  // The caller's own cancellation reaches this read as well: a host
553
628
  // that has given up is not waiting for a number out of a refusal.
554
629
  signal: request.signal,
555
630
  });
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
- });
631
+ const answered = relayed(response, observed);
564
632
  const guarded = await guardToolResponse(
565
633
  explain(observed?.text ?? null, answered, toolPolicy),
566
634
  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
  }
@@ -173,14 +187,19 @@ function responsesAccumulator() {
173
187
  // naming an item the stream never opened has no slot at all — slot 0 is
174
188
  // only for a frame that names nothing, which a stream that never indexed
175
189
  // 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) {
190
+ // was measured handing a function_call there the text of a message. An
191
+ // `added` frame opens an item, so the id it names is new there by
192
+ // definition and takes the next free slot; a frame that has to find an
193
+ // item that is already open still throws.
194
+ function slot(data, id, fresh = false) {
178
195
  if (data.output_index !== undefined) return index(data.output_index);
179
196
  if (id === undefined || id === null || id === "") return index(undefined);
180
197
  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);
198
+ if (position !== undefined) return index(position);
199
+ if (!fresh) throw new Error("Upstream frame names an unknown item without an output index");
200
+ let next = 0;
201
+ for (const held of output.keys()) next = Math.max(next, held + 1);
202
+ return index(next);
184
203
  }
185
204
  // A snapshot replaces the item in its slot only when it is a whole view of
186
205
  // it; a partial one leaves what the deltas delivered standing.
@@ -211,11 +230,19 @@ function responsesAccumulator() {
211
230
  keep(position, value);
212
231
  }
213
232
  } else if (type === "response.output_item.added" || type === "response.output_item.done") {
214
- if (data.item) keep(slot(data, data.item.id), data.item);
233
+ if (data.item)
234
+ keep(slot(data, data.item.id, type === "response.output_item.added"), data.item);
215
235
  } else if (type === "response.content_part.added" || type === "response.content_part.done") {
216
236
  const value = item(data);
217
237
  value.content ??= [];
218
- value.content[index(data.content_index)] = data.part;
238
+ // A re-emitted opening part carries an empty text where the deltas
239
+ // have already filled the slot — the partial-snapshot hazard the item
240
+ // guard above and content_block_start exist for, so the same test.
241
+ // `refusal` is weighed too: a part whose only content is a refusal
242
+ // weighs nothing on text alone and would be taken for an empty one.
243
+ const at = index(data.content_index);
244
+ if (carries(data.part, value.content[at], ["text", "refusal"]))
245
+ value.content[at] = data.part;
219
246
  } else if (
220
247
  type === "response.output_text.delta" ||
221
248
  type === "response.output_text.done" ||
@@ -244,7 +271,9 @@ function responsesAccumulator() {
244
271
  ) {
245
272
  const value = item(data, "reasoning");
246
273
  value.summary ??= [];
247
- value.summary[index(data.summary_index)] = data.part;
274
+ // The same re-emitted-opening-frame hazard as content_part above.
275
+ const at = index(data.summary_index);
276
+ if (carries(data.part, value.summary[at], ["text"])) value.summary[at] = data.part;
248
277
  } else if (/^response\.reasoning_(summary_)?text\.(delta|done)$/.test(type)) {
249
278
  const value = item(data, "reasoning");
250
279
  const summary = type.includes("summary");
@@ -287,8 +316,18 @@ function responsesAccumulator() {
287
316
  const whole = Array.isArray(final.output)
288
317
  ? final.output.length > 0
289
318
  : final.output !== null && final.output !== undefined;
290
- if (whole || !kept.length || Array.isArray(final)) return final;
291
- return { ...final, output: kept };
319
+ // An array is not a reply object at all, and is returned as it came.
320
+ if (Array.isArray(final)) return final;
321
+ // The terminal is not the whole reply either. What apply merged onto
322
+ // the body from the events' top level — usage, status,
323
+ // incomplete_details, finish_reason, and the object this reply is — is
324
+ // the upstream's too, and answering with the terminal alone dropped it:
325
+ // a terminal carrying its whole response beside a top-level usage, or
326
+ // one that omits object, answered without them. The body is the base,
327
+ // the terminal overrides what it carries, and the items it lost come
328
+ // back from the accumulation.
329
+ if (whole || !kept.length) return { ...body, ...final };
330
+ return { ...body, ...final, output: kept };
292
331
  },
293
332
  };
294
333
  }
@@ -317,7 +356,15 @@ function anthropicAccumulator() {
317
356
  for (const [position, block] of (data.message?.content ?? []).entries())
318
357
  if (carries(block, blocks.get(position), fields)) blocks.set(position, block);
319
358
  } else if (type === "content_block_start") {
320
- blocks.set(index(data.index), data.content_block);
359
+ // The same test message_start's blocks get, for the same reason: a
360
+ // gateway re-emitting a block's opening frame carries a block that
361
+ // was never filled in (a text one opens with text: ""), and taking
362
+ // it would discard the text, thinking or signature the deltas
363
+ // already delivered. A start with nothing accumulated applies, as
364
+ // does one that carries content of its own.
365
+ const position = index(data.index);
366
+ if (carries(data.content_block, blocks.get(position), fields))
367
+ blocks.set(position, data.content_block);
321
368
  } else if (type === "content_block_delta") {
322
369
  const position = index(data.index);
323
370
  const block = blocks.get(position);
package/tool-response.mjs CHANGED
@@ -158,13 +158,36 @@ function isCall(protocol, value) {
158
158
  );
159
159
  }
160
160
 
161
+ // Counts the ids invented for calls the reply gives nothing to place: a
162
+ // counter cannot be stable, but it is the only thing that keeps two of them
163
+ // apart (see inPlaceOf).
164
+ let synthesized = 0;
165
+
161
166
  // What a call left out of a Responses or Messages reply becomes: text
162
- // saying so, in the call's place, so no later index moves.
163
- function inPlaceOf(protocol, call, note) {
167
+ // saying so, in the call's place, so no later index moves. `at` is where the
168
+ // call sits in the reply as far as the caller can tell — a frame's
169
+ // output_index, or the index the walk found it at.
170
+ function inPlaceOf(protocol, call, note, at) {
164
171
  if (protocol === "anthropic") return { type: "text", text: note };
172
+ // An item id is what a Responses client keys the item by, and what the
173
+ // streaming path suppresses arguments by. One call is replaced more than
174
+ // once — its output_item.added, its output_item.done, and the copy inside
175
+ // the terminal response's output — so the invented id has to be the same
176
+ // every time: a counter made one call into several items to the client, and
177
+ // left leftIDs unable to match its arguments frames. A call's place is the
178
+ // same in all three, and two calls cannot share one, so the id names it.
179
+ //
180
+ // A call the reply places nowhere keeps the counter — and names it under a
181
+ // different word, because both arms are inventing ids in one reply: a counter
182
+ // that happened to reach another call's index would hand two unrelated calls
183
+ // the same item, which is the failure the counter was introduced to prevent.
184
+ const id =
185
+ typeof call.id === "string" && call.id
186
+ ? call.id
187
+ : `msg_${call.call_id ?? (at === undefined ? `anon_${++synthesized}` : `dropped_${at}`)}`;
165
188
  return {
166
189
  type: "message",
167
- id: typeof call.id === "string" && call.id ? call.id : `msg_${call.call_id ?? "dropped"}`,
190
+ id,
168
191
  role: "assistant",
169
192
  status: call.status ?? "completed",
170
193
  content: [{ type: "output_text", text: note, annotations: [] }],
@@ -194,10 +217,15 @@ function checkSnapshots(data, protocol, checkName) {
194
217
  let kept = 0;
195
218
  let left = 0;
196
219
  if (!object(data)) throw new GuardError("Invalid upstream tool snapshot");
197
- // Each entry is a value and where it sits, so a call can be replaced.
198
- const stack = [[data, null, null]];
220
+ // Each entry is a value, where it sits, and the place it holds in the reply
221
+ // as far as the walk knows: an item of an output array is at its index, and
222
+ // anything reached through an object — a frame's own `item` — keeps the
223
+ // output_index the frame carries, which is where that item sits. A call
224
+ // left out is replaced by an item whose invented id names that place, so
225
+ // the added, done and terminal copies of one call are one item (inPlaceOf).
226
+ const stack = [[data, null, null, object(data) ? data.output_index : undefined]];
199
227
  while (stack.length) {
200
- const [value, holder, key] = stack.pop();
228
+ const [value, holder, key, at] = stack.pop();
201
229
  if (!object(value)) throw new GuardError("Invalid upstream tool snapshot");
202
230
  if (
203
231
  protocol === "responses" &&
@@ -211,7 +239,7 @@ function checkSnapshots(data, protocol, checkName) {
211
239
  const name = checkName(value.name);
212
240
  if (name == null) {
213
241
  if (!holder) throw new GuardError(droppedNote(value.name), "undeclared_tool");
214
- holder[key] = inPlaceOf(protocol, value, droppedNote(value.name));
242
+ holder[key] = inPlaceOf(protocol, value, droppedNote(value.name), at);
215
243
  changed = true;
216
244
  left++;
217
245
  continue;
@@ -229,10 +257,10 @@ function checkSnapshots(data, protocol, checkName) {
229
257
  // Text content is not a snapshot container.
230
258
  if (typeof value[name] === "string") continue;
231
259
  const items = list(value[name]);
232
- for (let i = 0; i < items.length; i++) stack.push([items[i], items, i]);
260
+ for (let i = 0; i < items.length; i++) stack.push([items[i], items, i, i]);
233
261
  }
234
262
  for (const name of objects) {
235
- if (value[name] != null) stack.push([value[name], value, name]);
263
+ if (value[name] != null) stack.push([value[name], value, name, at]);
236
264
  }
237
265
  }
238
266
  if (
@@ -451,7 +479,10 @@ function snapshotStream(protocol, checkName) {
451
479
  checkName(item.name) == null
452
480
  ) {
453
481
  const note = droppedNote(item.name);
454
- const message = inPlaceOf(protocol, item, note);
482
+ // The frame's output_index is this call's place in the reply, which
483
+ // is the id its later frames (done, the terminal's output) invent
484
+ // for it again (inPlaceOf).
485
+ const message = inPlaceOf(protocol, item, note, data.output_index);
455
486
  left.set(data.output_index, true);
456
487
  leftIDs.add(message.id);
457
488
  if (typeof item.id === "string") leftIDs.add(item.id);
@@ -532,6 +563,7 @@ async function readBounded(response) {
532
563
  if (!reader) return "";
533
564
  const parts = [];
534
565
  let bytes = 0;
566
+ let failure;
535
567
  try {
536
568
  while (true) {
537
569
  const { done, value } = await reader.read();
@@ -541,20 +573,28 @@ async function readBounded(response) {
541
573
  throw new GuardError(`Upstream response exceeds the ${LIMIT} buffer limit`);
542
574
  parts.push(value);
543
575
  }
576
+ } catch (error) {
577
+ failure = error;
578
+ }
579
+ // Whatever ended the read — the limit, a malformed body, or the end of it —
580
+ // this body is never read again, so the stream is cancelled and the reader
581
+ // loosed. This was the one place in the package that abandoned a body
582
+ // without cancelling it (measured: no cancellation past the limit, and the
583
+ // source still pulled after the 502), which leaves an upstream writing into
584
+ // a socket nobody reads. A cancel that fails must not stand in for what
585
+ // ended the read: an oversized body is the 502 this function promises, not
586
+ // an opaque transport error, so the cancel's failure is kept as that
587
+ // error's cause — and is thrown itself only when nothing else went wrong.
588
+ // The lock is released either way.
589
+ try {
590
+ await reader.cancel();
591
+ } catch (error) {
592
+ if (!failure) throw error;
593
+ if (failure instanceof Error && !("cause" in failure)) failure.cause = error;
544
594
  } finally {
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
- }
595
+ reader.releaseLock();
557
596
  }
597
+ if (failure) throw failure;
558
598
  const all = new Uint8Array(bytes);
559
599
  let offset = 0;
560
600
  for (const part of parts) {
@@ -743,7 +783,14 @@ function chatTrack() {
743
783
  if (choice.finish_reason != null) {
744
784
  finished.add(index);
745
785
  open.delete(index);
746
- } else if (!finished.has(index)) open.add(index);
786
+ } else {
787
+ // A frame for this choice after it finished opens a new round of it
788
+ // (chatBuffer.apply judges a later round the same way), so the
789
+ // choice is open again: latching it shut for good left a round cut
790
+ // short with no note and no finish_reason, only a bare [DONE].
791
+ finished.delete(index);
792
+ open.add(index);
793
+ }
747
794
  }
748
795
  },
749
796
  // ending answers the frames that end the reply as cut short, or null
@@ -919,7 +966,17 @@ function guardedStream(response, protocol, checkName, endCutShort) {
919
966
  );
920
967
  controller.close();
921
968
  } else {
922
- release();
969
+ // A failure that is not the guard's own is a transport one: the
970
+ // stream is over either way, and what is left of the body is not
971
+ // something to keep writing into a socket nobody reads — the
972
+ // same rule readBounded follows. Cancel's own failure is not
973
+ // allowed to replace the error the client has to see, which is
974
+ // the one that got here.
975
+ try {
976
+ await cancel(error);
977
+ } catch {
978
+ /* the original error is the one the client must see */
979
+ }
923
980
  controller.error(error);
924
981
  }
925
982
  }
@@ -938,9 +995,12 @@ function guardedStream(response, protocol, checkName, endCutShort) {
938
995
  // collapsed for a client that doesn't stream fails instead, so it can be
939
996
  // asked again before anything reached the client.
940
997
  export async function guardToolResponse(response, protocol, policy, options = {}) {
941
- if (!response.ok) return response;
998
+ // A protocol this guard has no rules for is the caller's mistake, and it
999
+ // is one whichever the upstream answered: checked before the status is
1000
+ // read, so an error response cannot return it unexamined.
942
1001
  if (!["chat", "responses", "anthropic"].includes(protocol))
943
1002
  throw new TypeError(`Unsupported protocol: ${protocol}`);
1003
+ if (!response.ok) return response;
944
1004
  const checkName = nameGuard(policy);
945
1005
  const headers = headersFor(response);
946
1006
  const contentType = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();