opencode-zen-freex 0.2.13 → 0.2.15

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
@@ -28,13 +28,19 @@ model whose own vendor publishes 1048576/131072. The ids the tier added on
28
28
  against a declared 65536, and `glm-5.3-flash-free` is declared 200000/32000
29
29
  while its paid twin `glm-5.3-flash` is declared 1000000/131072 in the same
30
30
  catalogue — and a 232685-token prompt was served through the free id, which the
31
- declared window does not allow.
32
-
33
- Both numbers are acted on. The window decides when a conversation is compacted
34
- and how much of it is ever sent; the output cap is sent as `max_tokens` on every
35
- request, so a reply is cut at it with a finish reason that reads like the
36
- model's own decision. A window that is too small is never refused, it is just
37
- quietly never used, which is why this stayed invisible for so long.
31
+ declared window does not allow. `space-bunny-free` under-reports on a third
32
+ axis, its `input`: the catalogue derives that number from its own pair
33
+ (`context − output`, 524288) while prompts of 535,110, 935,110 and 979,550
34
+ tokens were served through the free id, and the axis is what a host that shows
35
+ a window shows — magpie's `context_window` and `max_input_tokens` both follow
36
+ it, so the derivation was on screen as half a window.
37
+
38
+ All of these numbers are acted on. The window decides when a conversation is
39
+ compacted and how much of it is ever sent; the output cap is sent as
40
+ `max_tokens` on every request, so a reply is cut at it with a finish reason that
41
+ reads like the model's own decision; the `input` axis is the size a
42
+ window-showing host displays. A window that is too small is never refused, it is
43
+ just quietly never used, which is why this stayed invisible for so long.
38
44
 
39
45
  `limits.mjs` holds what was measured, per axis, with the catalogue value it was
40
46
  measured against. Each axis carries `samples` and `how`; the short of it:
@@ -51,6 +57,7 @@ measured against. Each axis carries `samples` and `how`; the short of it:
51
57
  | `ll-alpha-free` | output | 262144 | the ceiling the route's own refusal names, against a declared 65536 |
52
58
  | `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
59
  | `glm-5.3-flash-free` | output | 131072 | what this route declares for the same upstream model |
60
+ | `space-bunny-free` | input | 1048576 | the window this route declares for the same id, with prompts of up to nearly twice the declared input served |
54
61
 
55
62
  A prompt sized just over the window is refused with
56
63
 
@@ -74,6 +81,14 @@ figure is a rough one (characters, not tokens) and must not be read as a count.
74
81
  A second upstream words the same fact as "This endpoint's maximum context length
75
82
  is …" and breaks the request into text input, tool input and output.
76
83
 
84
+ **The input**, the third axis: nothing cheap names it — the refusals on this
85
+ route name the window, and this id's own refusal names nothing at all — so it is
86
+ measured by serving. A prompt sized past the declared input is the measurement
87
+ (535,110, 935,110 and 979,550-token prompts each came back with the needle at
88
+ their end echoed, and `usage.prompt_tokens` is the count that says how much was
89
+ served), and the window the catalogue declares for the same id bounds the value
90
+ from above: a request placed over it is refused.
91
+
77
92
  The ceiling, which is cheaper than the window: ask for `max_tokens: 1000000` on a
78
93
  short prompt and stop reading as soon as the answer starts. An upstream that
79
94
  validates the ceiling refuses and names it — `max_tokens is too large: 1000000.
@@ -104,7 +119,8 @@ own, which is the intended end state.
104
119
  that model's window for the rest of the process. The list is rebuilt at every
105
120
  start and hourly after, so a route that changes the window it enforces is
106
121
  followed without anyone editing this file. Nothing is persisted: a restart falls
107
- back to what is measured here.
122
+ back to what is measured here. The `input` axis has no live form: the refusals
123
+ this route sends name a window and a completion ceiling, and never an input cap.
108
124
 
109
125
  **The same shape on a second upstream.** `mimo-v2.6-flash-free` is declared
110
126
  200000/32000 here too, and its window is a route figure like the one above — an
@@ -280,6 +296,17 @@ it. Rewriting the parameters as well looks tidier and breaks the agent.
280
296
  **Replacing the wording with something of your own.** That is what 0.1.5 and
281
297
  earlier did, and it is a guaranteed refusal.
282
298
 
299
+ **Merging a tool call's frames over its id.** The route puts `"id": null` on
300
+ the continuation frames of a call whose first frame named it (measured
301
+ 2026-10-17 on `deepseek-v4.1-flash-free`), and a host that keys a tool result
302
+ by the id — dsh writes `tool/result` records keyed by it — fails its turn on
303
+ an empty one and cannot reload the session afterwards ("tool call id requires
304
+ a nonempty string"). Merging frames must read the id as a name that only
305
+ replaces itself, and a call the route never names has to leave with an
306
+ invented one. Both live in `tool-response.mjs` — the merge's keep, and
307
+ `ensureCallIDs` at the two places a call is published — and `stream.mjs`'s
308
+ collapse reads the same rule.
309
+
283
310
  **Trusting one observation.** The gate is not steady: it moved more than once in
284
311
  a day while this was being worked out, and identical requests were answered both
285
312
  ways minutes apart. Anything measured once is not a fact. Measure it three times,
package/README.md CHANGED
@@ -45,7 +45,7 @@ magpie plugin login opencode-zen-free
45
45
 
46
46
  插件会去发现 Zen 当前的免费模型,包括它们的上下文窗口和思考(reasoning)等级。用 `opencode-zen-free/<模型名>`,例如 `opencode-zen-free/big-pickle`。
47
47
 
48
- 免费模型的目录上限有时被 Zen 少报,而少报的那一边没有谁会报错:`deepseek-v4.1-flash-free` 在目录里是 20 万上下文 / 3.2 万输出,而同一上游模型的付费 id 是 100 万 / 38.4 万。上下文窗口太小只会让宿主安静地早点压缩,输出上限太小会把回复截断成看起来像模型自己停下的样子,所以插件对这类模型按实测与路由自述对齐(依据与复测方法见 `MAINTAINING.md` 的 “Limits Zen under-reports”),并在运行中采用上游自报的窗口。
48
+ 免费模型的目录上限有时被 Zen 少报,而少报的那一边没有谁会报错:`deepseek-v4.1-flash-free` 在目录里是 20 万上下文 / 3.2 万输出,而同一上游模型的付费 id 是 100 万 / 38.4 万。上下文窗口太小只会让宿主安静地早点压缩,输出上限太小会把回复截断成看起来像模型自己停下的样子;`input` 这一轴同样会被少报——有些宿主显示的「上下文窗口」取的就是它(magpie 如此),而 `space-bunny-free` 的 `input` 是目录自己按 `context − output` 推出的 52.4 万,实测 97.9 万 token 的 prompt 都被完整服务、结尾的暗号原样复述。所以插件对这类模型按实测与路由自述对齐(依据与复测方法见 `MAINTAINING.md` 的 “Limits Zen under-reports”),并在运行中采用上游自报的窗口。
49
49
 
50
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
 
@@ -84,7 +84,7 @@ bun run check:gate
84
84
 
85
85
  请求发往 `https://opencode.ai/inference/v1`,按每个模型原生的 Chat Completions、Responses 或 Anthropic Messages 接口来走。流式和普通 JSON 两种回复都支持。
86
86
 
87
- 宿主自己的工具会被完整保留。Zen 的模型经常去调 OpenCode 的工具名;这种调用会被对上宿主那个只差大小写的工具(`bash` 对 `Bash`),或者只差大小写和 `_`/`-` 的那个(`todowrite` 对 `todo_write`、`webfetch` 对 `web_fetch`),前提是恰好只有一个对得上。找不到对应工具(或者调用根本没有名字)的,不放进回复里,回复的正文会说明哪个名字被略去了;如果这一轮所有调用都被略去,这一轮就以一段普通回答结束。工具仍然在宿主里执行。
87
+ 宿主自己的工具会被完整保留。Zen 的模型经常去调 OpenCode 的工具名;这种调用会被对上宿主那个只差大小写的工具(`bash` 对 `Bash`),或者只差大小写和 `_`/`-` 的那个(`todowrite` 对 `todo_write`、`webfetch` 对 `web_fetch`),前提是恰好只有一个对得上。找不到对应工具(或者调用根本没有名字)的,不放进回复里,回复的正文会说明哪个名字被略去了;如果这一轮所有调用都被略去,这一轮就以一段普通回答结束。上游偶尔还会在工具调用的续帧里发 `"id": null`(实测 2026-10-17),或者整条回复都不给调用 id——插件保证交到宿主手里的调用一律带非空 id(没给的补一个,已有的不会被后来的帧抹掉),因为宿主是靠这个 id 把工具结果对回调用的。工具仍然在宿主里执行。
88
88
 
89
89
  Zen 的免费层读请求体,也读请求头,只有当整个请求都属于 OpenCode 的同一代时才作答:User-Agent、请求头和工具三者必须一致。当前一共有两代,请求带的工具说明是哪一代:`shell` 和 `read` 属于 2.x,`bash`、`glob`、`grep` 和 `read` 属于 1.x。如果宿主本身就是 OpenCode,它自己的 User-Agent 已经说明是哪一代,这个值会被原样传出去,而不是被钉死,所以永远不会落后。没有任何一方说明时,以工具为准,User-Agent 跟着工具走。
90
90
 
@@ -142,6 +142,16 @@ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时
142
142
 
143
143
  模型的可用性和限流归 Zen 管,插件拿不到剩余额度可显示。同一个地址反复被拒,也可能被放进一个冷却期,表现得和被拒很像。
144
144
 
145
+ ## 鸣谢
146
+
147
+ | 类别 | 项目 |
148
+ | --- | --- |
149
+ | 本插件前身 | [zen-free](https://github.com/magpie-community/plugins/tree/main/packages/zen-free) |
150
+ | 插件载体 | [magpie](https://github.com/yetone/magpie) |
151
+ | 模型提供者 | [anomalyco/opencode](https://github.com/anomalyco/opencode) |
152
+ | 本仓库常用 Agent | [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) · [hermes-agent](https://github.com/NousResearch/hermes-agent) |
153
+ | 灵感来源 | [9router](https://github.com/decolua/9router) · [opencode2api](https://github.com/jasonxu114514/opencode2api) · [zen-gate](https://github.com/LAGcomcom/zen-gate) |
154
+
145
155
  ## 许可
146
156
 
147
157
  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/limits.mjs CHANGED
@@ -12,9 +12,17 @@
12
12
  // output cap that is too small cuts replies off with a finish reason that looks
13
13
  // like the model's own decision.
14
14
  //
15
+ // The input axis is the third, and it fails the same way one layer out. A host
16
+ // that plans what to send reads it as the room a prompt has; a host that shows a
17
+ // model's window reads it as the window itself — measured 2026-10-16, magpie's
18
+ // `context_window` and `max_input_tokens` both follow `limit.input` when one is
19
+ // declared. So an input axis that under-reports is not only never used, it is
20
+ // what a user is shown as the model's size.
21
+ //
15
22
  // Only evidence moves a value here, and evidence is sorted:
16
23
  //
17
- // route the route's own refusal, in its own words
24
+ // route the route's own answer about its own limit: the words of a
25
+ // refusal, or what its own service demonstrates
18
26
  // route-twin what this route declares for the same upstream model
19
27
  // vendor the model vendor's published page
20
28
  // witnessed a generation somebody watched finish
@@ -128,6 +136,19 @@ export const LIMITS = {
128
136
  how: "the same paid twin declares 131072 on this axis",
129
137
  },
130
138
  },
139
+ // Measured the day a host's window for this id was reported half its size.
140
+ // Only the input axis moves: the window and the ceiling are what this route
141
+ // declares, and what was served agrees with both.
142
+ "space-bunny-free": {
143
+ input: {
144
+ value: 1048576,
145
+ source: "route",
146
+ declaredAt: 524288,
147
+ measured: "2026-10-16",
148
+ samples: "prompts of 535,110, 935,110 and 979,550 tokens served whole, each with the needle at the end of it echoed back and each counted by `usage.prompt_tokens`, so the declared 524288 is already known to be small; a prompt placed over the window was refused 400, in words that name nothing",
149
+ how: "the catalogue's 524288 is its own `context − output` (1048576 − 524288) read as a cap, and this route does not enforce it: the samples above run from just past the declared cap to nearly twice it, served whole, and the wall is the 1048576-token window this same catalogue declares for this id. A host that shows the input axis shows the window — magpie's `context_window` and `max_input_tokens` are both this field when one is declared — so a derivation left here is a wrong window on screen.",
150
+ },
151
+ },
131
152
  };
132
153
 
133
154
  // "This model's maximum context length is 1048576 tokens. However, you
@@ -443,7 +464,7 @@ export function limitOverrides(
443
464
  // an object handed in as `table` carries — evidence for a model nobody
444
465
  // declared, from a table that never named it.
445
466
  const record = table && Object.hasOwn(table, id) ? table[id] : undefined;
446
- for (const axis of ["context", "output"]) {
467
+ for (const axis of ["context", "input", "output"]) {
447
468
  // Own property, like the table lookup two lines up: `declared` is handed in
448
469
  // by a caller, and a `context` inherited from a custom prototype — or from a
449
470
  // polluted `Object.prototype` — would otherwise be read as a declaration the
@@ -451,7 +472,12 @@ export function limitOverrides(
451
472
  // check with a number nobody declared.
452
473
  const declaredValue = declared && Object.hasOwn(declared, axis) ? declared[axis] : undefined;
453
474
  const entry = record?.[axis];
454
- const live = (axis === "context" ? observed : observedOutput)?.get(id);
475
+ // Only the axes a refusal names have a live form — a window and a
476
+ // completion ceiling. An input axis is answered from the table alone: the
477
+ // refusals this route sends name the window that bounds it, never an input
478
+ // cap of its own, so there is nothing to observe into it.
479
+ const live =
480
+ (axis === "context" ? observed : axis === "output" ? observedOutput : undefined)?.get(id);
455
481
  // A route statement made while this process was running is the same tier as
456
482
  // the table and newer than it, so it is taken as it stands. Everything else
457
483
  // has to have been measured against the catalogue in front of us now: the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-zen-freex",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
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/stream.mjs CHANGED
@@ -132,7 +132,13 @@ function chatAccumulator() {
132
132
  calls.set(toolIndex, { type: "function", function: { name: "", arguments: "" } });
133
133
  const call = calls.get(toolIndex);
134
134
  for (const key of ["id", "type"]) {
135
- if (fragment[key] !== undefined) call[key] = fragment[key];
135
+ // `"id": null` arrives on the continuation frames of a call this
136
+ // route named on the first one (see ensureCallIDs in
137
+ // tool-response.mjs): a fragment may leave the id as it stands,
138
+ // but a null or empty one does not take it away.
139
+ if (fragment[key] === undefined) continue;
140
+ if (key === "id" && (fragment[key] === null || fragment[key] === "")) continue;
141
+ call[key] = fragment[key];
136
142
  }
137
143
  if (fragment.function) appendFunction(call.function, fragment.function);
138
144
  }
package/tool-response.mjs CHANGED
@@ -194,6 +194,25 @@ function inPlaceOf(protocol, call, note, at) {
194
194
  };
195
195
  }
196
196
 
197
+ // A tool call needs an id its host can key the result by, and this route has
198
+ // been measured leaving calls without one: 2026-10-17, through this plugin, a
199
+ // reply's flushed frame carried `{"index":0,"id":null,"name":"bash"}` — the
200
+ // route puts `"id": null` on a continuation frame of a call it named on the
201
+ // first one, and a reply can also leave the call unnamed to the end. OpenAI's
202
+ // shape requires the field, and a host it reaches has nothing to match its
203
+ // result against without it: dsh keys its tool/result records by this id and
204
+ // fails the whole turn — and then the whole session, on reload — when the id
205
+ // is empty ("toolCallId matching its tool source"). So a call that reaches
206
+ // the wire leaves with an id: the last-resort kind, process-unique under the
207
+ // same counter the invented item ids use, so two invented calls cannot share
208
+ // one.
209
+ function ensureCallIDs(calls) {
210
+ for (const call of calls) {
211
+ if (typeof call.id === "string" && call.id !== "") continue;
212
+ call.id = `call_${++synthesized}`;
213
+ }
214
+ }
215
+
197
216
  // checkSnapshots checks every call in a reply or event: renamed to the
198
217
  // agent's tool, or (Responses, Messages) put in place of by a note. It
199
218
  // answers whether data changed, how many calls it kept and how many it
@@ -342,6 +361,7 @@ function chatBuffer(checkName) {
342
361
  }
343
362
  if (state.legacy) delta.function_call = state.legacy;
344
363
  const { notes } = checkChatMessage(delta, checkName);
364
+ if (Array.isArray(delta.tool_calls)) ensureCallIDs(delta.tool_calls);
345
365
  if (notes.length) {
346
366
  delta.tool_calls?.forEach((call, i) => {
347
367
  call.index = i;
@@ -399,6 +419,16 @@ function chatBuffer(checkName) {
399
419
  const index = position(fragment.index);
400
420
  const previous = state.calls.get(index) ?? { index, function: {} };
401
421
  const value = { ...previous, ...fragment, index, function: previous.function };
422
+ // A continuation frame may carry `"id": null` — measured on this
423
+ // route — and a spread would let it clobber the id that named the
424
+ // call. A fragment that says nothing about the id leaves the one
425
+ // already there; one that carries a real id still wins, as it did.
426
+ if (
427
+ (typeof value.id !== "string" || value.id === "") &&
428
+ typeof previous.id === "string" &&
429
+ previous.id !== ""
430
+ )
431
+ value.id = previous.id;
402
432
  if (fragment.function != null) appendFunction(value.function, fragment.function);
403
433
  state.calls.set(index, value);
404
434
  }
@@ -1016,6 +1046,12 @@ export async function guardToolResponse(response, protocol, policy, options = {}
1016
1046
  try {
1017
1047
  value = parseJSON(await readBounded(response));
1018
1048
  checkSnapshots(value, protocol, checkName);
1049
+ if (protocol === "chat") {
1050
+ for (const choice of list(value.choices)) {
1051
+ const message = object(choice) ? choice.message : undefined;
1052
+ if (object(message)) ensureCallIDs(list(message.tool_calls));
1053
+ }
1054
+ }
1019
1055
  } catch (error) {
1020
1056
  if (!(error instanceof GuardError)) throw error;
1021
1057
  status = 502;