opencode-zen-freex 0.2.8 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,14 +1,7 @@
1
1
  MIT License
2
2
 
3
3
  Copyright (c) 2026 xiaozhou26
4
- Copyright (c) 2026 opencode-zen-free contributors
5
-
6
- This is a maintained fork of the OpenCode Zen Free plugin from
7
- magpie-community/plugins, which is MIT licensed and carries the notice above:
8
- the `Copyright (c) 2026 xiaozhou26` line is upstream's and has to be kept with
9
- it, and the `opencode-zen-free contributors` line covers this fork.
10
- The fork is maintained separately because the free tier's request fingerprint
11
- moves faster than that repository follows it.
4
+ Copyright (c) 2026 OpenCode-Zen-FreeX contributors
12
5
 
13
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
14
7
  of this software and associated documentation files (the "Software"), to deal
@@ -26,4 +19,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
19
  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
20
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
21
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
- SOFTWARE.
22
+ SOFTWARE.
package/MAINTAINING.md CHANGED
@@ -156,13 +156,21 @@ It reports each request as:
156
156
  | `refused` | Zen did not recognise it (403) |
157
157
  | `superseded` | Zen wants a newer OpenCode than the profile claims (426) |
158
158
  | `failed` | Zen answered with something other than a 403, so it never judged the fingerprint |
159
+ | `malformed` | the request could not be built: a name in `required` has no tool behind it in `fingerprint.json`. Zen was never asked, and the line names the generation, the shape and the name |
159
160
  | `unreachable` | Zen could not be asked at all |
160
161
 
162
+ A `malformed` row is a `fingerprint.json` problem, not a network one, and the
163
+ summary says so rather than sending the reader to `capture`. It is deliberately
164
+ not counted as `answered`: a run whose floors are all `malformed` exits non-zero.
165
+
161
166
  The `floor` decides the exit code, because it is the shape this plugin exists to
162
167
  send. The `full` is reported for what it says about a wording that has gone
163
168
  stale. Asking only the full shape left a real gap: `read` alone is `answered` and
164
169
  everything but `read` is `refused`, so a Zen that began insisting on one more
165
- name would keep `full` passing while the shape actually sent was refused.
170
+ name would keep `full` passing while the shape actually sent was refused. The
171
+ diagnosis at the end reads the floor rows only: a stray 5xx on a `full` request
172
+ says something about that request and nothing about whether the wording is
173
+ current, so it must not turn "nothing was served" into "check the network".
166
174
 
167
175
  Only a 403 and a 426 are Zen answering about the fingerprint. A 5xx, a 429 or a
168
176
  401 says nothing about the wording, and the gate says so rather than sending the
@@ -195,6 +203,13 @@ name is served either way, which is why only the `floor` shows this.
195
203
  "your client is too old", which is a different problem from 403 and is not fixed
196
204
  by changing tools.
197
205
 
206
+ **`malformed`.** `fingerprint.json` names a tool in `required` that the same
207
+ profile does not carry under `tools`, so the gate could not build the request it
208
+ wants to ask with. This is not a network problem and not something Zen said: the
209
+ line names the generation, the shape and the missing name. Fix the file — a
210
+ capture from a current OpenCode is the usual way — and ask again. The run exits
211
+ non-zero, and the summary says the same thing in one sentence.
212
+
198
213
  **`unreachable`.** Not a fingerprint problem. Check the network, and whether
199
214
  repeated refusals from this address have put it in a cooldown — they can, and
200
215
  the cooldown answers like a refusal.
@@ -261,9 +276,18 @@ that passes under the stricter of them, since the looser one will not last.
261
276
  | `models.mjs` | which models are free, and what protocol each speaks |
262
277
  | `limits.mjs` | what a model's limits are, where the catalogue under-reports them |
263
278
  | `stream.mjs`, `tool-response.mjs` | replies Zen cuts short, and tool calls |
279
+ | `limits-audit.mjs` | asks the route which ids it still offers and which look under-declared (`limits:audit`) |
264
280
  | `capture.mjs` | takes a new capture |
265
281
  | `tools-smoke.mjs` | asks Zen what it accepts (`check:gate`) |
266
282
 
283
+ `capture.mjs`, `limits-audit.mjs` and `tools-smoke.mjs` are maintenance tools for
284
+ this repository, not part of what a host loads: they are not in `package.json`'s
285
+ `files`, so a published tarball carries only the modules a host imports plus
286
+ `fingerprint.json`. `package.json`'s `exports` names those public modules one by
287
+ one rather than mapping `./*`, so a file that was never meant to be imported
288
+ cannot be reached as a subpath — `capture.mjs` in particular starts a server and
289
+ rewrites `fingerprint.json` the moment it is imported.
290
+
267
291
  ## Which copy of the icon and the concurrency is authoritative
268
292
 
269
293
  `icon` and `maxConcurrency` are declared twice, and neither copy can be read by
@@ -288,8 +312,18 @@ about the commit rather than about whatever Bun was newest that morning.
288
312
  Nothing here is installed with Bun — there are no dependencies — so Bun is the
289
313
  tools only. `import.meta.main` is what decides whether `tools-smoke.mjs` runs its
290
314
  gate or is being imported by a test, and a Node older than 22.18 does not define
291
- it: the gate falls back to comparing the entry path, and exits non-zero saying
292
- nothing was asked if it cannot tell the two apart.
315
+ it: the gate falls back to comparing the entry path. That comparison resolves
316
+ both paths and then their real paths, so a symlink or a junction naming this file
317
+ is still recognised as this file, and a hard link is compared by device and inode
318
+ (only where the filesystem reports an inode at all). A question it cannot answer
319
+ exits non-zero saying nothing was asked — never a silent success, which is the
320
+ one outcome a release gate must not have.
321
+
322
+ Both scripts read their endpoint and model from the environment when the
323
+ defaults are wrong: `ZEN_BASE_URL`, `ZEN_CHECK_MODEL` (gate), `ZEN_CATALOG_URL`,
324
+ `ZEN_GENERATION` (audit) and `CAPTURE_PORT` (capture). `.env.example` lists them
325
+ with their defaults. Nothing loads a file by itself, so export them or copy that
326
+ file to `.env` and export from it.
293
327
 
294
328
  ## Publishing
295
329
 
@@ -305,6 +339,13 @@ that order, so the tarball, the commit the tag names, and the tree both were
305
339
  built from are one thing. `magpie plugin add opencode-zen-freex` installs it by
306
340
  that name.
307
341
 
342
+ What ships is `package.json`'s `files` list, and it is deliberately narrower than
343
+ the repository: the three maintenance scripts (`capture.mjs`, `tools-smoke.mjs`,
344
+ `limits-audit.mjs`) stay here, because a host never loads them and one of them
345
+ rewrites the shipped `fingerprint.json` if it is ever imported. `LICENSE` is
346
+ verbatim MIT; the fork's provenance is in `NOTICE`, so a licence scanner reads a
347
+ standard MIT file and a reader still finds why this fork exists.
348
+
308
349
  **The second factor is a browser, not a password.** Run `npm publish` in a
309
350
  terminal. When the account has 2FA on, the command stops and offers the whole
310
351
  exchange at once:
package/NOTICE ADDED
@@ -0,0 +1,18 @@
1
+ # NOTICE
2
+
3
+ This package is a maintained fork of the OpenCode Zen Free plugin from
4
+ [magpie-community/plugins](https://github.com/magpie-community/plugins), which
5
+ is MIT licensed. Upstream's notice, `Copyright (c) 2026 xiaozhou26`, is kept in
6
+ [LICENSE](./LICENSE) alongside this fork's own.
7
+
8
+ The fork is maintained separately from upstream under the name
9
+ `opencode-zen-freex` (https://github.com/tkhs101/OpenCode-Zen-FreeX) because the
10
+ free tier's request fingerprint moves faster than that repository follows it:
11
+ the tool wording and User-Agent Zen accepts can be recaptured here with
12
+ `bun run capture`, without waiting for a release upstream. `MAINTAINING.md`
13
+ describes that loop.
14
+
15
+ The provider id this package registers, `opencode-zen-free`, is deliberately the
16
+ same one upstream's plugin registers — a model is written
17
+ `opencode-zen-free/<model>` either way — so the two are mutually exclusive;
18
+ install one.
package/README.md CHANGED
@@ -66,7 +66,9 @@ bun run check:gate
66
66
  | `floor` | `required` 里的名字 | 宿主一个都没有时,插件替它补上的那几个。**这才是插件真实发出的形状,退出码由它决定** |
67
67
  | `full` | 这一代携带的全部工具 | 宿主本来就是 OpenCode 时发出来的形状。用来看措辞有没有过期,不决定退出码 |
68
68
 
69
- 每一代的结论是 `answered`(正常应答)、`refused`(被拒,403)、`superseded`(426——Zen 要求的 OpenCode 比这份指纹声称的更新)或 `unreachable`(根本联系不上)。只要还有一代的 `floor` 被应答,退出码就是 0。某个 `floor` 被拒、而它自己的 `full` 被应答时会单独点出来:那意味着带着自己那套 OpenCode 工具来的宿主能用,而插件真正要服务的那种宿主会被拒。全部联系不上时它不会说指纹过期,只会说问不到——这两件事得分开。
69
+ 每一代的结论是 `answered`(正常应答)、`refused`(被拒,403)、`superseded`(426——Zen 要求的 OpenCode 比这份指纹声称的更新)、`failed`(Zen 答了,但不是 403,所以它没有对指纹表态)、`malformed`(请求根本没法构造:`required` 里的某个名字在 `fingerprint.json` 里没有对应的工具。Zen 没被问到,那一行会点名是哪一代、哪种形状、缺的是哪个名字)或 `unreachable`(根本联系不上)。只要还有一代的 `floor` 被应答,退出码就是 0;`malformed` 不算应答,所以整轮都是它的时候退出码非 0,并且会说明这是指纹文件的问题、不是网络的问题。
70
+
71
+ 某个 `floor` 被拒、而它自己的 `full` 被应答时会单独点出来:那意味着带着自己那套 OpenCode 工具来的宿主能用,而插件真正要服务的那种宿主会被拒。全部联系不上时它不会说指纹过期,只会说问不到——这两件事得分开,而且最后的判断只读 `floor` 那几行:某个 `full` 请求拿到 5xx 说明的是那次请求,不足以把「没有一代被服务」说成「去查网络」。
70
72
 
71
73
  闸门问的时候用的就是插件自己那份请求:请求头的名字来自 `provider.mjs` 里的 `outboundHeaders`,工具来自插件所顶替的同一份 `required`。所以它绿了是在说「插件真发的那个请求」被应答了,而不是在说「照抄的一份」被应答了——照抄的那一份曾经少发一个 `accept`,而且没有任何东西因此报错。
72
74
 
@@ -105,7 +107,7 @@ Zen 的免费层读请求体,也读请求头,只有当整个请求都属于
105
107
  | `x-opencode-client` | `cli` |
106
108
  | `authorization` | `Bearer public`;走 Messages 时是 `x-api-key: public` |
107
109
 
108
- `anthropic-version` 和 `anthropic-beta` 也会发出去,但只在走 Messages 时,而且只在宿主自己设了它们的时候。
110
+ `anthropic-version` 在走 Messages 时总是发出去(宿主设了就用它的,没设就是 `2023-06-01`);`anthropic-beta` 只在宿主自己设了的时候才发。
109
111
 
110
112
  `x-opencode-request`、`x-session-affinity` 和 `x-session-id` 都在 OpenCode 自己的请求头集合里,但没有一个是必需的,所以都不发——不过进来的同名请求头仍然会被读,用来判断这次请求属于哪个会话。这不是「一个个试过不行」得出的结论,2026-10-15 实测(1.x,其余不动):
111
113
 
@@ -119,7 +121,7 @@ Zen 的免费层读请求体,也读请求头,只有当整个请求都属于
119
121
 
120
122
  两个各自无害的头放在一起会被拒,所以「某个头能不能加」不能靠单个试出来。测试按名字钉住了发出的这组请求头,因为多加一个请求头是弄坏这个插件最容易的方式。
121
123
 
122
- Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时间戳加每毫秒计数器截成十二位十六进制,后面接十四个随机的 base62 字符;session 的那一种还会把低位取反。宿主给的 id 如果本来就是这个形状,就原样传出去;否则按会话的首条消息生成一个并记住,这样同一会话的各轮请求会共用一个 session id。
124
+ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时间戳加每毫秒计数器截成十二位十六进制,后面接十四个随机的 base62 字符;session 的那一种还会把低位取反。宿主给的 id 如果本来就是这个形状,就原样传出去;否则按会话的首条消息生成一个并记住,这样同一会话的各轮请求会共用一个 session id。宿主既不声明会话、又没有可作依据的用户消息时,这一轮就拿一个自己的 id——没有东西可以据以记住它,所以它不会被并进另一个会话里去。
123
125
 
124
126
  偶尔 Zen 会在发出 `[DONE]` 之前就结束一个流式的 Chat Completions 回复。如果回复已经说了内容、且其中没有未完成的工具调用,就以普通回答结束,正文里会说明它被截断了;再问一次就会接着往下走。如果是带着未完成的工具调用被截断、或者什么都没说就断了、或者被折叠给一个不支持流式的客户端,请求就会失败,错误信息里写明这个回复的 id 和收到了多少。走 Anthropic Messages 或 Responses 的回复被截断同样会失败:magpie 能把它和完整的回复区分开,并且在该模型支持的情况下继续往下走。
125
127
 
@@ -140,4 +142,4 @@ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时
140
142
 
141
143
  ## 许可
142
144
 
143
- MIT,分叉自 [zen-free](https://github.com/magpie-community/plugins/tree/main/packages/zen-free)。版权署名见 [LICENSE](https://github.com/tkhs101/OpenCode-Zen-FreeX/blob/main/LICENSE):`Copyright (c) 2026 xiaozhou26` 是上游原作者,本分支的贡献者另有署名。
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 文件,而想知道这个分支为什么存在的人仍然找得到答案。
package/core-tools.mjs CHANGED
@@ -18,31 +18,109 @@
18
18
  // because those belong to the machine the capture ran on.
19
19
 
20
20
  import { readFileSync } from "node:fs";
21
+ import { fileURLToPath } from "node:url";
21
22
 
22
- const captured = JSON.parse(readFileSync(new URL("./fingerprint.json", import.meta.url), "utf8"));
23
+ const CAPTURE = new URL("./fingerprint.json", import.meta.url);
23
24
 
24
- export const PROFILES = captured.profiles;
25
+ // Read once, here, because every answer in this module comes out of it. Guarded
26
+ // because the failure this replaces is a bare ENOENT or SyntaxError thrown out of
27
+ // 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.
30
+ function readProfiles() {
31
+ let captured;
32
+ try {
33
+ captured = JSON.parse(readFileSync(CAPTURE, "utf8"));
34
+ } catch (error) {
35
+ 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",
38
+ { cause: error },
39
+ );
40
+ }
41
+ // An object carrying a non-empty map of profiles, and nothing more specific:
42
+ // which generations those are is the capture's to say — capture.mjs writes
43
+ // next.profiles[generation] — so the keys are never named here. A capture that
44
+ // lost every profile cannot tell one generation from another and fails now
45
+ // rather than at the first request.
46
+ const profiles = captured?.profiles;
47
+ if (
48
+ typeof profiles !== "object" ||
49
+ profiles === null ||
50
+ Array.isArray(profiles) ||
51
+ !Object.keys(profiles).length
52
+ ) {
53
+ throw new Error(
54
+ `core-tools: the capture at ${fileURLToPath(CAPTURE)} has no profiles; ` +
55
+ "run `bun run capture` to write a new one",
56
+ );
57
+ }
58
+ return profiles;
59
+ }
60
+
61
+ export const PROFILES = readProfiles();
25
62
 
26
63
  /**
27
64
  * The generation a request belongs to, told by the tools it carries. Tool names
28
65
  * move far less often than descriptions do, so this survives a rewording that a
29
66
  * pinned User-Agent would not.
67
+ *
68
+ * `undefined` is a normal return: it says neither generation's names were there,
69
+ * and the callers default it themselves (`provider.mjs` uses `?? DEFAULT_GENERATION`).
70
+ * A body whose `tools` is not an array is the same answer as an empty one — the
71
+ * body is client-supplied, so it can carry any shape at all, and "no generation
72
+ * recognised" is the only honest reading of one that is not a list of tools.
30
73
  */
31
74
  export function generationOf(tools) {
32
- const names = new Set((tools ?? []).map((tool) => tool?.function?.name ?? tool?.name));
75
+ const names = new Set(
76
+ (Array.isArray(tools) ? tools : []).map((tool) => tool?.function?.name ?? tool?.name),
77
+ );
33
78
  if (names.has("shell")) return "2.x";
34
79
  if (names.has("bash")) return "1.x";
35
80
  return undefined;
36
81
  }
37
82
 
83
+ // The profile a generation was captured into, or an Error naming both what was
84
+ // asked for and what the capture holds. Ownership rather than truthiness: a
85
+ // plain object answers `constructor` and `toString` out of Object.prototype, so
86
+ // `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) {
89
+ if (!Object.hasOwn(PROFILES, generation)) {
90
+ throw new Error(
91
+ `core-tools: no profile for generation ${JSON.stringify(generation)}; ` +
92
+ `the capture holds ${Object.keys(PROFILES).join(", ")}`,
93
+ );
94
+ }
95
+ return PROFILES[generation];
96
+ }
97
+
38
98
  /**
39
99
  * The User-Agent that generation of OpenCode sends, for a protocol. Each
40
100
  * protocol's ai-sdk package bundles its own provider-utils, so 1.x differs by
41
101
  * protocol and 2.x does not.
42
102
  */
43
103
  export function agentFor(generation, protocol = "chat") {
44
- const agent = PROFILES[generation].agent;
45
- return agent.providerUtils
46
- ? `opencode/${agent.opencode} ai-sdk/provider-utils/${agent.providerUtils[protocol]} ${agent.runtime}`
47
- : `opencode/${agent.channel}/${agent.opencode}/${agent.client}`;
104
+ const agent = profileFor(generation).agent;
105
+ if (typeof agent !== "object" || agent === null) {
106
+ 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",
109
+ );
110
+ }
111
+ // The release shape carries no provider-utils at all, and is composed the same
112
+ // way whatever protocol was asked for.
113
+ if (!agent.providerUtils) return `opencode/${agent.channel}/${agent.opencode}/${agent.client}`;
114
+ // `?? "undefined"` used to be interpolated here for a protocol the capture has
115
+ // no provider-utils for, and a prototype member (`toString`) as the version.
116
+ // Zen reads this header, so a malformed one is rejected upstream with an error
117
+ // that says nothing about where it came from; name the miss instead.
118
+ if (!Object.hasOwn(agent.providerUtils, protocol)) {
119
+ throw new Error(
120
+ `core-tools: generation ${JSON.stringify(generation)} composes its User-Agent ` +
121
+ `per protocol, and the capture has no provider-utils for ${JSON.stringify(protocol)}; ` +
122
+ `it holds ${Object.keys(agent.providerUtils).join(", ")}`,
123
+ );
124
+ }
125
+ return `opencode/${agent.opencode} ai-sdk/provider-utils/${agent.providerUtils[protocol]} ${agent.runtime}`;
48
126
  }
package/index.mjs CHANGED
@@ -1,11 +1,21 @@
1
1
  import plugin from "./provider.mjs"
2
2
 
3
- // Only the `server` factory is forwarded: that is the hook a magpie host builds
4
- // a plugin from, so the provider's `id` is deliberately not repeated here. Say
5
- // so where the mismatch is, rather than handing the host an undefined plugin to
6
- // fail with something opaque later.
3
+ // A host resolves a plugin module and reads the factory off it. Which shape it
4
+ // reads is the host's business and not this package's to narrow: magpie builds
5
+ // one from `default.server`, which is the shape provider.mjs already exports, so
6
+ // that object is forwarded whole rather than the factory being renamed into a
7
+ // shape nothing else in the package uses. The `id` comes with it, because a
8
+ // loader that keys plugins by id needs the same one the provider registers — and
9
+ // it is the provider's own id, not a second copy of it: it is read from the
10
+ // module, so the two cannot drift.
11
+ //
12
+ // The named export stays for anything that already imported it by name, and
13
+ // `server` is offered as a name too, so `mod.default.server` and `mod.server`
14
+ // both resolve.
7
15
  if (typeof plugin?.server !== "function") {
8
16
  throw new TypeError("provider.mjs must default-export a plugin with a `server` factory")
9
17
  }
10
18
 
11
19
  export const ZenFreePlugin = plugin.server
20
+ export const server = plugin.server
21
+ export default plugin