opencode-zen-freex 0.2.11 → 0.2.13

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
@@ -22,7 +22,13 @@ small. `deepseek-v4.1-flash-free` is declared 200000/32000 while
22
22
  `deepseek-v4.1-flash` — the paid id serving the same upstream model, which
23
23
  `x-opencode-upstream-model-id` confirms — is declared 1000000/384000 in that
24
24
  same catalogue, and `mimo-v2.6-flash-free` is declared 200000/32000 against a
25
- model whose own vendor publishes 1048576/131072.
25
+ model whose own vendor publishes 1048576/131072. The ids the tier added on
26
+ 2026-10-16 were under-reported the same way on the day they appeared:
27
+ `ll-alpha-free` names a 262144-token completion ceiling in its own refusal
28
+ against a declared 65536, and `glm-5.3-flash-free` is declared 200000/32000
29
+ while its paid twin `glm-5.3-flash` is declared 1000000/131072 in the same
30
+ catalogue — and a 232685-token prompt was served through the free id, which the
31
+ declared window does not allow.
26
32
 
27
33
  Both numbers are acted on. The window decides when a conversation is compacted
28
34
  and how much of it is ever sent; the output cap is sent as `max_tokens` on every
@@ -42,6 +48,9 @@ measured against. Each axis carries `samples` and `how`; the short of it:
42
48
  | `big-pickle` | output | 131072 | the ceiling the route names when it is asked for too much |
43
49
  | `nemotron-3.5-lightning-free` | context | 1000000 | the route's own refusal, in its own words |
44
50
  | `longcat-2.5-preview-free` | output | 262144 | the ceiling the route names when it is asked for too much |
51
+ | `ll-alpha-free` | output | 262144 | the ceiling the route's own refusal names, against a declared 65536 |
52
+ | `glm-5.3-flash-free` | context | 1000000 | what this route declares for the same upstream model, with a 232685-token prompt served as witness |
53
+ | `glm-5.3-flash-free` | output | 131072 | what this route declares for the same upstream model |
45
54
 
46
55
  A prompt sized just over the window is refused with
47
56
 
package/README.md CHANGED
@@ -47,7 +47,7 @@ magpie plugin login opencode-zen-free
47
47
 
48
48
  免费模型的目录上限有时被 Zen 少报,而少报的那一边没有谁会报错:`deepseek-v4.1-flash-free` 在目录里是 20 万上下文 / 3.2 万输出,而同一上游模型的付费 id 是 100 万 / 38.4 万。上下文窗口太小只会让宿主安静地早点压缩,输出上限太小会把回复截断成看起来像模型自己停下的样子,所以插件对这类模型按实测与路由自述对齐(依据与复测方法见 `MAINTAINING.md` 的 “Limits Zen under-reports”),并在运行中采用上游自报的窗口。
49
49
 
50
- 已下线的模型和 SystemOne 模型会被排除。这份列表在宿主每次列举模型时重新获取(magpie 在启动时做一次,之后每小时一次):Zen 不再免费开放的模型会退出列表,新转为免费的模型会加入列表——**不等** OpenCode 的目录或文档写到它:只要 Zen 的列表里有、id 里带 `-free`,就按 provider 自己的协议(OpenAI 兼容 / chat)提供,目录晚一步也不影响。
50
+ 已下线的模型和 SystemOne 模型会被排除:「下线」指的是状态写着 `retired`/`disabled`,或带着已生效的退役日期。目录里标了 `deprecated` 但 Zen 的列表里还在的模型**照常提供**——`deprecated` 是预告,而「还在不在」由列表说了算(实测 2026-10-16:`muse-spark-1.2-contributor-free` 在目录里是 `deprecated`,Zen 仍在服务它,把它当退役就把一个能用的免费模型藏掉了)。这份列表在宿主每次列举模型时重新获取(magpie 在启动时做一次,之后每小时一次):Zen 不再免费开放的模型会退出列表,新转为免费的模型会加入列表——**不等** OpenCode 的目录或文档写到它:只要 Zen 的列表里有、id 里带 `-free`,就按 provider 自己的协议(OpenAI 兼容 / chat)提供,目录晚一步也不影响。
51
51
 
52
52
  目录或文档读不到时**不算 fallback**:Zen 的列表已经读到了,它就是当前该提供的东西;报成 fallback 会让宿主保守地沿用上一次完整读到的列表,于是读不到 `raw.githubusercontent.com` 或 `models.opencode.ai` 的机器永远看不到之后新转免费的模型。只有**列表本身**读不到才沿用上一次结果;第一次还没读到之前,用的是打包在插件里的那份,所以这个 provider 不会空着。请求一个当前并不免费的模型,会得到一个 404 并列出当时免费的模型;若某个模型是在上次列举之后才转免费的,请求会在重新获取列表后照常服务(最多每分钟一次)。可用性和限流由 Zen 决定,插件这边拿不到剩余额度。
53
53
 
package/core-tools.mjs CHANGED
@@ -106,7 +106,12 @@ export function generationOf(tools) {
106
106
  // `.agent` with a TypeError that names nothing. Ownership of the key is half of
107
107
  // it — a capture holding `{"1.x": null}` owns the key and still has no profile,
108
108
  // and the `.agent` of that is the same unnamed TypeError.
109
- function profileFor(generation) {
109
+ //
110
+ // Exported, because the same two mistakes are reachable from outside this file
111
+ // and are made there: `provider.mjs` indexes `PROFILES[...]` directly to read a
112
+ // profile's `tools`, and a capture missing that generation then fails with
113
+ // "Cannot read properties of undefined" instead of the sentence below.
114
+ export function profileFor(generation) {
110
115
  if (!Object.hasOwn(PROFILES, generation)) {
111
116
  throw new Error(
112
117
  `core-tools: no profile for generation ${JSON.stringify(generation)}; ` +
package/limits.mjs CHANGED
@@ -96,20 +96,62 @@ export const LIMITS = {
96
96
  how: "\"Upstream request failed: [invalid_parameter] 参数校验失败: /max_tokens: 1000000 is not less or equal to 262144\" — the second wording namedOutputCap reads, where the ceiling is the number max_tokens has to stay under. Its context needs nothing: the catalogue's 1000000 is what that same refusal calls the endpoint's window.",
97
97
  },
98
98
  },
99
+ // The two the free tier added on 2026-10-16, each measured the day it was
100
+ // noticed. Both under-report in the catalogue the same way the older entries
101
+ // do, and both were checked on every axis: nothing below is what a refusal
102
+ // happened to say, it is all that was asked.
103
+ "ll-alpha-free": {
104
+ output: {
105
+ value: 262144,
106
+ source: "route",
107
+ declaredAt: 65536,
108
+ measured: "2026-10-16",
109
+ samples: "max_completion_tokens 1000000 and 300000 both refused, each naming 262144; nothing above 65536 watched — the ceiling is the route's own number, four times what the catalogue declares",
110
+ how: "\"Upstream request failed: [400] max_completion_tokens is too large: 300000.This model supports at most 262144 completion tokens.\" — the ceiling is the number the sentence names. Its context needs nothing: the same day's refusal, \"The input (341480 tokens) is longer than the model's context length (262144 tokens).\", is the window the catalogue already declares.",
111
+ },
112
+ },
113
+ "glm-5.3-flash-free": {
114
+ context: {
115
+ value: 1000000,
116
+ source: "route-twin",
117
+ declaredAt: 200000,
118
+ measured: "2026-10-16",
119
+ samples: "a 232685-token prompt was served and answered from (usage.prompt_tokens 232685), so the declared 200000 is already known to be small; nothing above it was refused, because this upstream refuses no max_tokens at all",
120
+ how: "glm-5.3-flash, the paid id serving the same upstream model — the catalogue's own canonical_model_id for this free id is zhipuai/glm-5.3-flash — is declared 1000000 in this same catalogue",
121
+ },
122
+ output: {
123
+ value: 131072,
124
+ source: "route-twin",
125
+ declaredAt: 32000,
126
+ measured: "2026-10-16",
127
+ samples: "max_tokens 4000000 was accepted with no refusal at all, which is the DeepSeek upstream's shape: silence here is not evidence of a ceiling either way, and nothing above 32000 was watched",
128
+ how: "the same paid twin declares 131072 on this axis",
129
+ },
130
+ },
99
131
  };
100
132
 
101
133
  // "This model's maximum context length is 1048576 tokens. However, you
102
134
  // requested 1422278 tokens (1038278 in the messages, 384000 in the
103
- // completion)." — the route's own words, and the only sentence seen on this
104
- // route that states a window. A second upstream phrases the same fact as "This
105
- // endpoint's maximum context length is ...", with the request broken into text
106
- // input, tool input and output; both are the route naming its own window, and
107
- // its "requested about" figure is a rough one (chars, not tokens) rather than
108
- // the count a served request reports in `usage.prompt_tokens`. A generic 400
109
- // says nothing attributable and is left alone; §7 of the measured notes has a
110
- // context overflow and an invalid parameter answering with byte-identical 400s
135
+ // completion)." — the route's own words, and the sentence seen most often on
136
+ // this route that states a window. A second upstream phrases the same fact as
137
+ // "This endpoint's maximum context length is ...", with the request broken into
138
+ // text input, tool input and output; both are the route naming its own window,
139
+ // and its "requested about" figure is a rough one (chars, not tokens) rather
140
+ // than the count a served request reports in `usage.prompt_tokens`. A generic
141
+ // 400 says nothing attributable and is left alone; §7 of the measured notes has
142
+ // a context overflow and an invalid parameter answering with byte-identical 400s
111
143
  // on another Zen model.
112
- const NAMED_CONTEXT = /maximum context length is (\d+) tokens/i;
144
+ //
145
+ // A third wording states the same fact as a parenthetical, and was missed until
146
+ // it was measured: "The input (341480 tokens) is longer than the model's context
147
+ // length (262144 tokens)." (`ll-alpha-free`, 2026-10-16). It names the same
148
+ // number in the same sentence, so it is read the same way — a window this file
149
+ // does not read is a window the plugin keeps under-declaring, which is the one
150
+ // failure it exists to remove.
151
+ const NAMED_CONTEXT = [
152
+ /maximum context length is (\d+) tokens/id,
153
+ /context length \((\d+) tokens?\)/id,
154
+ ];
113
155
 
114
156
  // "max_tokens is too large: 200000. This model supports at most 131072 completion
115
157
  // tokens, whereas you provided 200000." — an upstream that validates the ceiling
@@ -119,10 +161,10 @@ const NAMED_CONTEXT = /maximum context length is (\d+) tokens/i;
119
161
  // answers 200 there, so silence from this one says nothing about a model, and a
120
162
  // request that is served is not evidence of a ceiling either way.
121
163
  const NAMED_OUTPUT = [
122
- /supports at most (\d+) completion tokens/i,
164
+ /supports at most (\d+) completion tokens/id,
123
165
  // "/max_tokens: 1000000 is not less or equal to 262144" — LongCat's wording for
124
166
  // 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,
167
+ /max_tokens:?\s*\d+\s+is not less or equal to\s+(\d+)/id,
126
168
  ];
127
169
 
128
170
  // Names a refusal made while this process is running, by model id. Not
@@ -169,7 +211,7 @@ function describe({ kind, id, axis, value, measuredAgainst, declared }) {
169
211
  // The window a refusal names, or undefined when the body says nothing
170
212
  // attributable. Exported for the tests and for anyone reading a capture.
171
213
  export function namedContextWindow(text) {
172
- return namedFrom([NAMED_CONTEXT], text)?.value;
214
+ return namedFrom(NAMED_CONTEXT, text)?.value;
173
215
  }
174
216
 
175
217
  // The completion ceiling a refusal names, or undefined. Same rule as the window:
@@ -349,25 +391,33 @@ export async function observeRouteStatement(id, response, limit = 4096, { signal
349
391
  }
350
392
  }
351
393
 
352
- // How close to the end of what was read a match has to end before a truncated
353
- // body makes it unusable. A number ending inside this margin of the last
354
- // character read may have had digits past it — `2621` where the body said
355
- // `262144` — and a smaller limit that looks like a real one is the one failure
356
- // this guard exists to prevent. Numbers further back are inside the buffer
357
- // entirely and are read as they stand.
358
- const CUT_MARGIN = 8;
394
+ // Where the number a match named ends, in the text it was matched against. The
395
+ // `d` flag is what makes this exact: `NAMED_CONTEXT` and the first
396
+ // `NAMED_OUTPUT` run on past the digits (" completion tokens"), so judging the
397
+ // cut by the match's own end threw away numbers that were provably whole — the
398
+ // digits ended well before the read did, with text after them that the greedy
399
+ // `(\d+)` had refused to consume.
400
+ function digitsEnd(found) {
401
+ const at = found?.match?.indices?.[1]?.[1];
402
+ // The match's own end is the conservative answer when the pattern cannot say
403
+ // where the digits are: it drops a whole number rather than reading a cut one,
404
+ // which is the direction this guard has to fail in.
405
+ return Number.isSafeInteger(at) ? at : found.match.index + found.match[0].length;
406
+ }
359
407
 
360
408
  // The axes the first `limit` characters of `text` name. `cut` says the read
361
- // stopped at the budget with the body going on, so a match ending at the margin
362
- // may have been a longer number: that axis is left unread rather than reported a
363
- // digit short. Each axis is read from the match its number came from, so the
364
- // match the guard judges and the number that is returned cannot be two answers.
409
+ // stopped before the body did, so a number whose own digits reach the last
410
+ // character read may have had digits past it: that axis is left unread rather
411
+ // than reported a digit short. Any character after the digits inside the head
412
+ // proves the number ended, since a greedy `\d+` would have taken it — only
413
+ // digits touching the boundary are ambiguous. Each axis is read from the match
414
+ // its number came from, so the match the guard judges and the number that is
415
+ // returned cannot be two answers.
365
416
  function namedAxes(text, limit, cut) {
366
417
  const head = text.slice(0, limit);
367
- const context = namedFrom([NAMED_CONTEXT], head);
418
+ const context = namedFrom(NAMED_CONTEXT, head);
368
419
  const output = namedFrom(NAMED_OUTPUT, head);
369
- const onCut = (found) =>
370
- cut && Boolean(found) && found.match.index + found.match[0].length >= head.length - CUT_MARGIN;
420
+ const onCut = (found) => cut && found !== undefined && digitsEnd(found) >= head.length;
371
421
  return {
372
422
  context: onCut(context) ? undefined : context?.value,
373
423
  output: onCut(output) ? undefined : output?.value,
@@ -394,7 +444,12 @@ export function limitOverrides(
394
444
  // declared, from a table that never named it.
395
445
  const record = table && Object.hasOwn(table, id) ? table[id] : undefined;
396
446
  for (const axis of ["context", "output"]) {
397
- const declaredValue = declared?.[axis];
447
+ // Own property, like the table lookup two lines up: `declared` is handed in
448
+ // by a caller, and a `context` inherited from a custom prototype — or from a
449
+ // polluted `Object.prototype` — would otherwise be read as a declaration the
450
+ // catalogue never made, feeding the staleness compare and the raise-only
451
+ // check with a number nobody declared.
452
+ const declaredValue = declared && Object.hasOwn(declared, axis) ? declared[axis] : undefined;
398
453
  const entry = record?.[axis];
399
454
  const live = (axis === "context" ? observed : observedOutput)?.get(id);
400
455
  // A route statement made while this process was running is the same tier as
package/models.mjs CHANGED
@@ -44,15 +44,23 @@ const hasValue = (value) => value !== null && value !== undefined;
44
44
  // offered. A status that is set — "active" included — is still read as itself.
45
45
  const isSet = (value) => hasValue(value) && String(value).trim() !== "";
46
46
 
47
+ // A status ends a model when it says the model is gone: "retired" and
48
+ // "disabled" are endings. "deprecated" is not one — it says the catalogue
49
+ // expects the model to go, and what decides whether it is still there is the
50
+ // list Zen serves, which discovery already reads: measured 2026-10-16,
51
+ // `muse-spark-1.2-contributor-free` is marked deprecated in the catalogue while
52
+ // Zen lists it and the free tier answers a request for it through this plugin,
53
+ // so reading that status as an ending hid a model that was being served.
54
+ const ENDED = ["retired", "disabled"];
55
+
47
56
  function isRetired(model) {
48
57
  return (
49
58
  model.deprecated === true ||
50
- ["deprecated", "retired", "disabled"].includes(
51
- isSet(model.status) ? model.status : model.lifecycle,
52
- ) ||
59
+ ENDED.includes(isSet(model.status) ? model.status : model.lifecycle) ||
53
60
  // Read through `isSet`, not `hasValue`: a blank `deprecated_at` is the empty
54
61
  // 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.
62
+ // a date retired a model that was still on offer. An effective date is an
63
+ // ending whatever the status beside it says.
56
64
  isSet(model.deprecated_at) ||
57
65
  isSet(model.retirement_date)
58
66
  );
@@ -191,10 +199,19 @@ export const bootstrapProtocols = {
191
199
  "nemotron-3.5-lightning-free": "chat",
192
200
  "muse-spark-1.3-contributor-free": "responses",
193
201
  "jev-1.13-free": "systemone",
202
+ // The free tier's list on 2026-10-16, when these five appeared: the four chat
203
+ // ones and the responses one below, whose paid twin the docs table names.
204
+ "deepseek-v4.1-flash-free": "chat",
205
+ "glm-5.3-flash-free": "chat",
206
+ "ll-alpha-free": "chat",
207
+ "nemotron-3.5-super-vl-free": "chat",
208
+ "muse-spark-1.2-contributor-free": "responses",
194
209
  };
195
210
 
196
- // Offline bootstrap metadata from the OpenCode catalog, 2026-10-06; the
197
- // free models listed since added on 2026-10-15.
211
+ // Offline bootstrap metadata from the OpenCode catalog, 2026-10-06; the free
212
+ // models listed since added on 2026-10-15, and the ids the tier was serving on
213
+ // 2026-10-16 added then. A host that reaches nothing on its first run offers
214
+ // these; the first discovery replaces the list.
198
215
  export const bootstrapProvider = {
199
216
  npm: SDK.chat,
200
217
  models: {
@@ -270,6 +287,66 @@ export const bootstrapProvider = {
270
287
  { type: "effort", values: ["minimal", "low", "medium", "high", "xhigh"] },
271
288
  ],
272
289
  },
290
+ // The free ids the tier was serving on 2026-10-16 and this snapshot did not
291
+ // carry. Their numbers are the catalogue's, as every other entry here is —
292
+ // `limits.mjs` is what corrects the ones the catalogue under-reports, and it
293
+ // carries entries for two of these.
294
+ "deepseek-v4.1-flash-free": {
295
+ name: "DeepSeek V4.1 Flash Free",
296
+ cost: { input: 0, output: 0 },
297
+ limit: { context: 200000, output: 32000 },
298
+ reasoning: true,
299
+ tool_call: true,
300
+ interleaved: { field: "reasoning_content" },
301
+ modalities: { input: ["text", "image"], output: ["text"] },
302
+ reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }],
303
+ },
304
+ "glm-5.3-flash-free": {
305
+ name: "GLM-5.3-Flash Free",
306
+ cost: { input: 0, output: 0 },
307
+ limit: { context: 200000, output: 32000 },
308
+ reasoning: true,
309
+ tool_call: true,
310
+ interleaved: { field: "reasoning_content" },
311
+ modalities: { input: ["text", "image", "video", "pdf"], output: ["text"] },
312
+ reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }],
313
+ },
314
+ "ll-alpha-free": {
315
+ name: "LL Alpha Free",
316
+ cost: { input: 0, output: 0 },
317
+ limit: { context: 262144, output: 65536 },
318
+ reasoning: true,
319
+ tool_call: true,
320
+ interleaved: { field: "reasoning_content" },
321
+ modalities: { input: ["text"], output: ["text"] },
322
+ reasoning_options: [
323
+ { type: "effort", values: ["low", "medium", "high", "xhigh", "max"] },
324
+ ],
325
+ },
326
+ "nemotron-3.5-super-vl-free": {
327
+ name: "Nemotron 3.5 Super VL Free",
328
+ cost: { input: 0, output: 0 },
329
+ limit: { context: 1048576, output: 131072 },
330
+ reasoning: true,
331
+ tool_call: true,
332
+ interleaved: { field: "reasoning_content" },
333
+ modalities: { input: ["text", "image"], output: ["text"] },
334
+ reasoning_options: [
335
+ { type: "effort", values: ["none", "minimal", "low", "medium", "high", "xhigh"] },
336
+ ],
337
+ },
338
+ "muse-spark-1.2-contributor-free": {
339
+ name: "Muse Spark 1.2 Free",
340
+ cost: { input: 0, output: 0 },
341
+ provider: { npm: SDK.responses },
342
+ limit: { context: 1048576, output: 131072 },
343
+ reasoning: true,
344
+ tool_call: true,
345
+ modalities: { input: ["text", "image", "video", "pdf", "audio"], output: ["text"] },
346
+ reasoning_options: [
347
+ { type: "effort", values: ["minimal", "low", "medium", "high", "xhigh"] },
348
+ ],
349
+ },
273
350
  },
274
351
  };
275
352
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-zen-freex",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
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,
@@ -158,6 +158,22 @@ function relayedRefusal(response, observed) {
158
158
  );
159
159
  }
160
160
 
161
+ // The response this path hands on, from what the limits reader made of the one
162
+ // that arrived: the same response when it never read it — the reader touches no
163
+ // 2xx body, so that is the common branch — the bytes it read under headers
164
+ // corrected for them when it read the whole body, and a refusal that says so
165
+ // when it stopped short. One decision, written once, so the three outcomes
166
+ // cannot be read as a chain of ternaries at the call site.
167
+ function relayed(response, observed) {
168
+ if (observed === undefined) return response;
169
+ if (!observed.ended) return relayedRefusal(response, observed);
170
+ return new Response(observed.text, {
171
+ status: response.status,
172
+ statusText: response.statusText,
173
+ headers: relayedHeaders(response),
174
+ });
175
+ }
176
+
161
177
  // How long a request for a model the last list didn't have waits before it
162
178
  // asks Zen's list again: a model Zen made free since is served at once,
163
179
  // and a model typed in by hand doesn't ask the list on every request.
@@ -210,20 +226,23 @@ function normalizeTools(body, protocol) {
210
226
  const declared = new Set([...present].filter((value) => typeof value === "string"));
211
227
  // The legacy top-level `functions` array is the older way of declaring the
212
228
  // same tools, and one a chat host may still use. Its names are declarations,
213
- // so they count as declared — and, since they are not in `body.tools`, they
214
- // are also remembered as "do not stand in for this name": the fill loop
215
- // below looks at where a name already sits, and a name that only arrived
216
- // through `functions` has no index there. Without this the same tool would go
217
- // 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.
218
234
  const legacy = Array.isArray(body.functions) && protocol === "chat" ? body.functions : [];
219
235
  for (const tool of legacy) if (typeof tool?.name === "string") declared.add(tool.name);
220
- const declaredElsewhere = new Set(legacy.map((tool) => tool?.name));
221
236
  // Zen takes a request for one generation of OpenCode or the other, and the tools
222
237
  // it carries say which: `shell` is 2.x, `bash` is 1.x. Names move far less
223
238
  // often than descriptions do, so this survives a rewording that a pinned
224
239
  // User-Agent would not.
225
240
  const generation = generationOf(body.tools) ?? DEFAULT_GENERATION;
226
- 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);
227
246
  const filled = [];
228
247
  const at = new Map();
229
248
  body.tools.forEach((tool, index) => {
@@ -252,8 +271,17 @@ function normalizeTools(body, protocol) {
252
271
  // tightened check would otherwise find missing.
253
272
  if (!profile.required.includes(name)) continue;
254
273
  // Declared through `functions` rather than `tools`: this host does have the
255
- // tool, so it is not one to stand in for.
256
- 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
+ }
257
285
  filled.push(name);
258
286
  // A tool added here has nothing behind it: the agent has no such tool and
259
287
  // a call to it is dropped. Describing it as OpenCode describes it is what
@@ -308,7 +336,10 @@ function explain(text, source, toolPolicy) {
308
336
  // A 403 that is not Zen's free tier is somebody else's; it is left as sent —
309
337
  // its bytes are what arrived, and so are the headers describing them.
310
338
  if (source.status === 403 && !text.includes("FreeTierError")) return source;
311
- 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);
312
343
  const names = Object.keys(profile.tools).join(", ");
313
344
  const { filled } = toolPolicy;
314
345
  // 426 is not a refusal of the wording: it is this plugin presenting itself as
@@ -588,28 +619,16 @@ async function server(_input, options = {}) {
588
619
  // quietly never used.
589
620
  //
590
621
  // A response body can only be read once, so what the limits reader
591
- // took is handed on as the response the rest of the path works with:
592
- // never the upstream's own headers over bytes this 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.
622
+ // took is handed on as the response the rest of the path works with
623
+ // (see `relayed`): never the upstream's own headers over bytes this
624
+ // package wrote again, and never a prefix presented as the whole
625
+ // reply. A response the reader did not consume is used as it arrived.
598
626
  const observed = await observeRouteStatement(body.model, response, undefined, {
599
627
  // The caller's own cancellation reaches this read as well: a host
600
628
  // that has given up is not waiting for a number out of a refusal.
601
629
  signal: request.signal,
602
630
  });
603
- const answered =
604
- observed === undefined
605
- ? response
606
- : observed.ended
607
- ? new Response(observed.text, {
608
- status: response.status,
609
- statusText: response.statusText,
610
- headers: relayedHeaders(response),
611
- })
612
- : relayedRefusal(response, observed);
631
+ const answered = relayed(response, observed);
613
632
  const guarded = await guardToolResponse(
614
633
  explain(observed?.text ?? null, answered, toolPolicy),
615
634
  protocol,
package/stream.mjs CHANGED
@@ -187,14 +187,19 @@ function responsesAccumulator() {
187
187
  // naming an item the stream never opened has no slot at all — slot 0 is
188
188
  // only for a frame that names nothing, which a stream that never indexed
189
189
  // its items means as the first one. Merging an unknown item into slot 0
190
- // was measured handing a function_call there the text of a message.
191
- 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) {
192
195
  if (data.output_index !== undefined) return index(data.output_index);
193
196
  if (id === undefined || id === null || id === "") return index(undefined);
194
197
  const position = [...output].find(([, value]) => value.id === id)?.[0];
195
- if (position === undefined)
196
- throw new Error("Upstream frame names an unknown item without an output index");
197
- 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);
198
203
  }
199
204
  // A snapshot replaces the item in its slot only when it is a whole view of
200
205
  // it; a partial one leaves what the deltas delivered standing.
@@ -225,11 +230,19 @@ function responsesAccumulator() {
225
230
  keep(position, value);
226
231
  }
227
232
  } else if (type === "response.output_item.added" || type === "response.output_item.done") {
228
- if (data.item) 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);
229
235
  } else if (type === "response.content_part.added" || type === "response.content_part.done") {
230
236
  const value = item(data);
231
237
  value.content ??= [];
232
- 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;
233
246
  } else if (
234
247
  type === "response.output_text.delta" ||
235
248
  type === "response.output_text.done" ||
@@ -258,7 +271,9 @@ function responsesAccumulator() {
258
271
  ) {
259
272
  const value = item(data, "reasoning");
260
273
  value.summary ??= [];
261
- 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;
262
277
  } else if (/^response\.reasoning_(summary_)?text\.(delta|done)$/.test(type)) {
263
278
  const value = item(data, "reasoning");
264
279
  const summary = type.includes("summary");
@@ -301,8 +316,18 @@ function responsesAccumulator() {
301
316
  const whole = Array.isArray(final.output)
302
317
  ? final.output.length > 0
303
318
  : final.output !== null && final.output !== undefined;
304
- if (whole || !kept.length || Array.isArray(final)) return final;
305
- 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 };
306
331
  },
307
332
  };
308
333
  }
package/tool-response.mjs CHANGED
@@ -158,22 +158,33 @@ function isCall(protocol, value) {
158
158
  );
159
159
  }
160
160
 
161
- // Counts the ids invented for calls that came without one, so that no two of
162
- // them can be handed the same id (see inPlaceOf).
161
+ // Counts the ids invented for calls the reply gives nothing to place: a
162
+ // counter cannot be stable, but it is the only thing that keeps two of them
163
+ // apart (see inPlaceOf).
163
164
  let synthesized = 0;
164
165
 
165
166
  // What a call left out of a Responses or Messages reply becomes: text
166
- // saying so, in the call's place, so no later index moves.
167
- 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) {
168
171
  if (protocol === "anthropic") return { type: "text", text: note };
169
172
  // An item id is what a Responses client keys the item by, and what the
170
- // streaming path suppresses arguments by: 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
+ // streaming path suppresses arguments by. One call is replaced more than
174
+ // once — its output_item.added, its output_item.done, and the copy inside
175
+ // the terminal response's output — so the invented id has to be the same
176
+ // every time: a counter made one call into several items to the client, and
177
+ // left leftIDs unable to match its arguments frames. A call's place is the
178
+ // same in all three, and two calls cannot share one, so the id names it.
179
+ //
180
+ // A call the reply places nowhere keeps the counter — and names it under a
181
+ // different word, because both arms are inventing ids in one reply: a counter
182
+ // that happened to reach another call's index would hand two unrelated calls
183
+ // the same item, which is the failure the counter was introduced to prevent.
173
184
  const id =
174
185
  typeof call.id === "string" && call.id
175
186
  ? call.id
176
- : `msg_${call.call_id ?? `dropped_${++synthesized}`}`;
187
+ : `msg_${call.call_id ?? (at === undefined ? `anon_${++synthesized}` : `dropped_${at}`)}`;
177
188
  return {
178
189
  type: "message",
179
190
  id,
@@ -206,10 +217,15 @@ function checkSnapshots(data, protocol, checkName) {
206
217
  let kept = 0;
207
218
  let left = 0;
208
219
  if (!object(data)) throw new GuardError("Invalid upstream tool snapshot");
209
- // Each entry is a value and where it sits, so a call can be replaced.
210
- 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]];
211
227
  while (stack.length) {
212
- const [value, holder, key] = stack.pop();
228
+ const [value, holder, key, at] = stack.pop();
213
229
  if (!object(value)) throw new GuardError("Invalid upstream tool snapshot");
214
230
  if (
215
231
  protocol === "responses" &&
@@ -223,7 +239,7 @@ function checkSnapshots(data, protocol, checkName) {
223
239
  const name = checkName(value.name);
224
240
  if (name == null) {
225
241
  if (!holder) throw new GuardError(droppedNote(value.name), "undeclared_tool");
226
- holder[key] = inPlaceOf(protocol, value, droppedNote(value.name));
242
+ holder[key] = inPlaceOf(protocol, value, droppedNote(value.name), at);
227
243
  changed = true;
228
244
  left++;
229
245
  continue;
@@ -241,10 +257,10 @@ function checkSnapshots(data, protocol, checkName) {
241
257
  // Text content is not a snapshot container.
242
258
  if (typeof value[name] === "string") continue;
243
259
  const items = list(value[name]);
244
- for (let i = 0; i < items.length; i++) stack.push([items[i], items, i]);
260
+ for (let i = 0; i < items.length; i++) stack.push([items[i], items, i, i]);
245
261
  }
246
262
  for (const name of objects) {
247
- if (value[name] != null) stack.push([value[name], value, name]);
263
+ if (value[name] != null) stack.push([value[name], value, name, at]);
248
264
  }
249
265
  }
250
266
  if (
@@ -463,7 +479,10 @@ function snapshotStream(protocol, checkName) {
463
479
  checkName(item.name) == null
464
480
  ) {
465
481
  const note = droppedNote(item.name);
466
- 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);
467
486
  left.set(data.output_index, true);
468
487
  leftIDs.add(message.id);
469
488
  if (typeof item.id === "string") leftIDs.add(item.id);
@@ -544,6 +563,7 @@ async function readBounded(response) {
544
563
  if (!reader) return "";
545
564
  const parts = [];
546
565
  let bytes = 0;
566
+ let failure;
547
567
  try {
548
568
  while (true) {
549
569
  const { done, value } = await reader.read();
@@ -553,20 +573,28 @@ async function readBounded(response) {
553
573
  throw new GuardError(`Upstream response exceeds the ${LIMIT} buffer limit`);
554
574
  parts.push(value);
555
575
  }
576
+ } catch (error) {
577
+ failure = error;
578
+ }
579
+ // Whatever ended the read — the limit, a malformed body, or the end of it —
580
+ // this body is never read again, so the stream is cancelled and the reader
581
+ // loosed. This was the one place in the package that abandoned a body
582
+ // without cancelling it (measured: no cancellation past the limit, and the
583
+ // source still pulled after the 502), which leaves an upstream writing into
584
+ // a socket nobody reads. A cancel that fails must not stand in for what
585
+ // ended the read: an oversized body is the 502 this function promises, not
586
+ // an opaque transport error, so the cancel's failure is kept as that
587
+ // error's cause — and is thrown itself only when nothing else went wrong.
588
+ // The lock is released either way.
589
+ try {
590
+ await reader.cancel();
591
+ } catch (error) {
592
+ if (!failure) throw error;
593
+ if (failure instanceof Error && !("cause" in failure)) failure.cause = error;
556
594
  } finally {
557
- // Whatever ended the read — the limit, a malformed body, or the end of
558
- // it — this body is never read again, so the reader is loosed and the
559
- // stream cancelled. This was the one place in the package that abandoned
560
- // a body without cancelling it (measured: no cancellation past the limit,
561
- // and the source still pulled after the 502), which leaves an upstream
562
- // writing into a socket nobody reads. The cancel's own failure is not
563
- // swallowed, and the lock is released whether it threw or not.
564
- try {
565
- await reader.cancel();
566
- } finally {
567
- reader.releaseLock();
568
- }
595
+ reader.releaseLock();
569
596
  }
597
+ if (failure) throw failure;
570
598
  const all = new Uint8Array(bytes);
571
599
  let offset = 0;
572
600
  for (const part of parts) {
@@ -755,7 +783,14 @@ function chatTrack() {
755
783
  if (choice.finish_reason != null) {
756
784
  finished.add(index);
757
785
  open.delete(index);
758
- } else 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
+ }
759
794
  }
760
795
  },
761
796
  // ending answers the frames that end the reply as cut short, or null