opencode-zen-freex 0.2.1 → 0.2.3
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 +245 -0
- package/README.md +47 -11
- package/capture.mjs +203 -0
- package/index.mjs +3 -3
- package/limits.mjs +123 -0
- package/models.mjs +213 -148
- package/package.json +5 -1
- package/provider.mjs +509 -461
- package/stream.mjs +398 -398
- package/tool-response.mjs +881 -881
- package/tools-smoke.mjs +169 -0
package/MAINTAINING.md
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# Maintaining this plugin
|
|
2
|
+
|
|
3
|
+
The plugin is one file of logic and two pieces of data, and the data is the part
|
|
4
|
+
that goes out of date. This is how to look after it.
|
|
5
|
+
|
|
6
|
+
## What is actually fragile
|
|
7
|
+
|
|
8
|
+
`fingerprint.json` holds a capture of a real OpenCode: the wording of the tools
|
|
9
|
+
its free tier knows by name, and the User-Agent that identifies it. Zen accepts a
|
|
10
|
+
request only when the whole of it matches one generation of OpenCode, so when
|
|
11
|
+
OpenCode changes any of that, the copy here stops being accepted.
|
|
12
|
+
|
|
13
|
+
Nothing about a stale copy looks different from a working one. The symptom is a
|
|
14
|
+
`403 FreeTierError` on every request, which reads as "you are not OpenCode" and
|
|
15
|
+
sends people off to change headers.
|
|
16
|
+
|
|
17
|
+
## Limits Zen under-reports
|
|
18
|
+
|
|
19
|
+
`models.mjs` declares each model's limits from Zen's catalogue, and for some free
|
|
20
|
+
ids the catalogue is wrong in the one direction nothing complains about: too
|
|
21
|
+
small. `deepseek-v4.1-flash-free` is declared 200000/32000 while
|
|
22
|
+
`deepseek-v4.1-flash` — the paid id serving the same upstream model, which
|
|
23
|
+
`x-opencode-upstream-model-id` confirms — is declared 1000000/384000 in that
|
|
24
|
+
same catalogue.
|
|
25
|
+
|
|
26
|
+
Both numbers are acted on. The window decides when a conversation is compacted
|
|
27
|
+
and how much of it is ever sent; the output cap is sent as `max_tokens` on every
|
|
28
|
+
request, so a reply is cut at it with a finish reason that reads like the
|
|
29
|
+
model's own decision. A window that is too small is never refused, it is just
|
|
30
|
+
quietly never used, which is why this stayed invisible for so long.
|
|
31
|
+
|
|
32
|
+
`limits.mjs` holds what was measured, per axis, with the catalogue value it was
|
|
33
|
+
measured against. Each axis carries `samples` and `how`; the short of it:
|
|
34
|
+
|
|
35
|
+
| axis | value | from |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| context | 1048576 | the route's own refusal, in its own words |
|
|
38
|
+
| output | 384000 | what this route declares for the same upstream model |
|
|
39
|
+
|
|
40
|
+
A prompt sized just over the window is refused with
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
This model's maximum context length is 1048576 tokens. However, you requested
|
|
44
|
+
1422278 tokens (1038278 in the messages, 384000 in the completion).
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
which is the route stating its own window, and better evidence than any vendor
|
|
48
|
+
page. The output figure is the paid twin's declaration, so the route has already
|
|
49
|
+
published what that model does; a single reply of 306,880 completion tokens
|
|
50
|
+
(`finish_reason: stop`) was watched and is recorded as the witness.
|
|
51
|
+
|
|
52
|
+
**Re-measuring.** The window: send a prompt of about 1.05M tokens and read the
|
|
53
|
+
number out of the refusal, three times, interleaved, per the rule below. The
|
|
54
|
+
filler measured 4.887 characters per token with about 1700 tokens of fixed
|
|
55
|
+
request overhead, and a request that is *served* says nothing about where the
|
|
56
|
+
wall is. The output: re-read the paid id's `limit` in
|
|
57
|
+
`https://models.opencode.ai/api.json`. A value Zen accepts is not evidence —
|
|
58
|
+
`max_tokens: 2000000` under a short prompt answers 200 and writes nothing like
|
|
59
|
+
it — and neither is a generation nobody watched finish.
|
|
60
|
+
|
|
61
|
+
**What voids it.** Each axis records the catalogue value it was measured
|
|
62
|
+
against, and an axis whose declaration moved is dropped rather than kept: a
|
|
63
|
+
measurement of one question is not an answer to another. A catalogue that raises
|
|
64
|
+
the free id to the paid twin's figures therefore makes this file inert on its
|
|
65
|
+
own, which is the intended end state.
|
|
66
|
+
|
|
67
|
+
**While the plugin runs.** A refusal that names a window is read on its way past
|
|
68
|
+
(`observeRouteStatement`, called from the provider's `fetch`), and it corrects
|
|
69
|
+
that model's window for the rest of the process. The list is rebuilt at every
|
|
70
|
+
start and hourly after, so a route that changes the window it enforces is
|
|
71
|
+
followed without anyone editing this file. Nothing is persisted: a restart falls
|
|
72
|
+
back to what is measured here.
|
|
73
|
+
|
|
74
|
+
**A lead, not evidence.** `mimo-v2.6-flash-free` is declared 200000/32000 here
|
|
75
|
+
too, and another plugin on this route has reported Zen answering that id with
|
|
76
|
+
`maximum context length is 1048576 tokens`. That is somebody else's measurement
|
|
77
|
+
of a different id, so it is not in `limits.mjs`: measure it before adding it.
|
|
78
|
+
|
|
79
|
+
## The loop
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
bun test # the unit tests; these never touch the network
|
|
83
|
+
bun run check:gate # asks Zen which generations are still accepted
|
|
84
|
+
bun run capture -- --dry # takes a fresh capture and reports the difference
|
|
85
|
+
bun run capture # writes it
|
|
86
|
+
bun run check:gate # did that fix it
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`check:gate` is the one that matters. It asks each generation twice, because the
|
|
90
|
+
plugin sends two shapes and Zen does not answer them alike:
|
|
91
|
+
|
|
92
|
+
| shape | tools sent | what it is |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| `floor` | the names in `required` | what the plugin stands in for a host that is not OpenCode and has none of these tools by name |
|
|
95
|
+
| `full` | every tool in the profile | what a host that is OpenCode itself sends |
|
|
96
|
+
|
|
97
|
+
It reports each request as:
|
|
98
|
+
|
|
99
|
+
| verdict | meaning |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
| `answered` | Zen served it; this shape is current |
|
|
102
|
+
| `refused` | Zen did not recognise it (403) |
|
|
103
|
+
| `superseded` | Zen wants a newer OpenCode than the profile claims (426) |
|
|
104
|
+
| `unreachable` | Zen could not be asked at all |
|
|
105
|
+
|
|
106
|
+
The `floor` decides the exit code, because it is the shape this plugin exists to
|
|
107
|
+
send. The `full` is reported for what it says about a wording that has gone
|
|
108
|
+
stale. Asking only the full shape left a real gap: `read` alone is `answered` and
|
|
109
|
+
everything but `read` is `refused`, so a Zen that began insisting on one more
|
|
110
|
+
name would keep `full` passing while the shape actually sent was refused.
|
|
111
|
+
|
|
112
|
+
`capture` needs `opencode` on PATH. It points a real OpenCode at a server running
|
|
113
|
+
locally and reads the request it makes, so nothing is sent to Zen and no key is
|
|
114
|
+
involved.
|
|
115
|
+
|
|
116
|
+
## Reading a failure
|
|
117
|
+
|
|
118
|
+
**Every `floor` `refused`.** OpenCode reworded its tools, or moved the endpoint.
|
|
119
|
+
Run `bun run capture`, then `check:gate` again. If the endpoint moved,
|
|
120
|
+
`DEFAULT_BASE` in `provider.mjs` is what has to change.
|
|
121
|
+
|
|
122
|
+
**One `floor` `refused`, the other `answered`.** OpenCode has released and this
|
|
123
|
+
generation is retired. Capture from a current OpenCode: it will be detected as
|
|
124
|
+
the other generation, so what is captured goes into that profile.
|
|
125
|
+
|
|
126
|
+
**A `floor` `refused` while its `full` is `answered`.** Zen has begun insisting
|
|
127
|
+
on a tool this plugin does not stand in, which the gate names in a line of its
|
|
128
|
+
own. What gets stood in is `required` in `fingerprint.json`, and the wording it
|
|
129
|
+
is stood in with is that profile's `tools`, so the name has to reach both, from a
|
|
130
|
+
capture taken of an OpenCode that has it. A host that brings its own tool by that
|
|
131
|
+
name is served either way, which is why only the `floor` shows this.
|
|
132
|
+
|
|
133
|
+
**`superseded` (426).** Zen raised the minimum version this profile claims. Raise
|
|
134
|
+
`agent.opencode` in `fingerprint.json` above what it asks for. 426 is Zen saying
|
|
135
|
+
"your client is too old", which is a different problem from 403 and is not fixed
|
|
136
|
+
by changing tools.
|
|
137
|
+
|
|
138
|
+
**`unreachable`.** Not a fingerprint problem. Check the network, and whether
|
|
139
|
+
repeated refusals from this address have put it in a cooldown — they can, and
|
|
140
|
+
the cooldown answers like a refusal.
|
|
141
|
+
|
|
142
|
+
## Things that are easy to break
|
|
143
|
+
|
|
144
|
+
**Adding a header.** The set sent has one owner: `outboundHeaders` in
|
|
145
|
+
`provider.mjs`, which the plugin builds its request with and which the gate asks
|
|
146
|
+
Zen with, so neither can drift from the other on its own. `index.test.mjs` pins
|
|
147
|
+
the names on the wire. `x-opencode-request`, `x-session-affinity` and
|
|
148
|
+
`x-session-id` are all in OpenCode's own header set upstream and none is sent;
|
|
149
|
+
measured 2026-10-15, each of the three answers on its own while
|
|
150
|
+
x-opencode-request with x-session-affinity is refused. A header cannot be judged
|
|
151
|
+
by adding it on its own, so add it in `outboundHeaders` and let the pinned test
|
|
152
|
+
and a live `check:gate` say what it did.
|
|
153
|
+
|
|
154
|
+
**Rewriting the tool wording.** The wording sent upstream is OpenCode's, because
|
|
155
|
+
Zen reads it. What replaces it is only the wording: the host's tool and its
|
|
156
|
+
parameters are kept, so the call the model makes still fits the tool that runs
|
|
157
|
+
it. Rewriting the parameters as well looks tidier and breaks the agent.
|
|
158
|
+
|
|
159
|
+
**Replacing the wording with something of your own.** That is what 0.1.5 and
|
|
160
|
+
earlier did, and it is a guaranteed refusal.
|
|
161
|
+
|
|
162
|
+
**Trusting one observation.** The gate is not steady: it moved more than once in
|
|
163
|
+
a day while this was being worked out, and identical requests were answered both
|
|
164
|
+
ways minutes apart. Anything measured once is not a fact. Measure it three times,
|
|
165
|
+
interleaved, before believing it. When two sources disagree, prefer the shape
|
|
166
|
+
that passes under the stricter of them, since the looser one will not last.
|
|
167
|
+
|
|
168
|
+
## Layout
|
|
169
|
+
|
|
170
|
+
| file | what it is |
|
|
171
|
+
| --- | --- |
|
|
172
|
+
| `fingerprint.json` | the capture: the wording and User-Agent Zen accepts |
|
|
173
|
+
| `core-tools.mjs` | reads the capture; says which generation a request is |
|
|
174
|
+
| `provider.mjs` | the plugin: discovery, headers, tools, the diagnostic |
|
|
175
|
+
| `models.mjs` | which models are free, and what protocol each speaks |
|
|
176
|
+
| `limits.mjs` | what a model's limits are, where the catalogue under-reports them |
|
|
177
|
+
| `stream.mjs`, `tool-response.mjs` | replies Zen cuts short, and tool calls |
|
|
178
|
+
| `capture.mjs` | takes a new capture |
|
|
179
|
+
| `tools-smoke.mjs` | asks Zen what it accepts (`check:gate`) |
|
|
180
|
+
|
|
181
|
+
## Publishing
|
|
182
|
+
|
|
183
|
+
The package is `opencode-zen-freex`; the provider it registers is
|
|
184
|
+
`opencode-zen-free`, which is the same one magpie-community/plugins' version
|
|
185
|
+
registers. That is deliberate: a model is written `opencode-zen-free/<model>`
|
|
186
|
+
either way, so one stays in place whichever is installed. It does mean the two
|
|
187
|
+
are mutually exclusive — installing both leaves two plugins claiming one
|
|
188
|
+
provider id, and which one wins is down to load order. Pick one.
|
|
189
|
+
|
|
190
|
+
To publish: bump `version`, `npm publish`, and tag. `magpie plugin add
|
|
191
|
+
opencode-zen-freex` installs it by that name.
|
|
192
|
+
|
|
193
|
+
**The second factor is a browser, not a password.** Run `npm publish`; when the
|
|
194
|
+
account has 2FA on, the command stops with `EOTP` and prints
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
https://www.npmjs.com/auth/cli/...
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Open that address, authorize there, and the same command carries on by itself.
|
|
201
|
+
Nothing is typed back into the terminal and nothing is passed to a script: the
|
|
202
|
+
address is tied to the browser session that printed it and is different every
|
|
203
|
+
time, so it is read out of the running command's own output rather than composed
|
|
204
|
+
in advance. That address belongs to whoever is at the browser, so they are the
|
|
205
|
+
one who authorizes — the command can sit in the background while they do, and it
|
|
206
|
+
needs no more than the one click. Opening it is a convenience, not a step: a
|
|
207
|
+
`start` (Windows) or `open` (macOS) on the URL is the whole of it.
|
|
208
|
+
|
|
209
|
+
An account with no second factor to give publishes in one step. So does
|
|
210
|
+
`npm login --auth-type=web`, which is the same browser exchange kept as a
|
|
211
|
+
session, and so does a granular token below.
|
|
212
|
+
|
|
213
|
+
**With a granular token** (unattended, CI). A token with publish permission
|
|
214
|
+
authorizes the publish on its own and never asks for the second factor. Write it
|
|
215
|
+
to an npmrc outside the repository and point npm at it for that one command, so
|
|
216
|
+
it does not land in a tracked file:
|
|
217
|
+
|
|
218
|
+
```sh
|
|
219
|
+
printf '//registry.npmjs.org/:_authToken=npm_...\n' > "$TMP/npmrc"
|
|
220
|
+
npm publish --userconfig "$TMP/npmrc"; rm -f "$TMP/npmrc"
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Whatever carries a token — a shell, a chat, a CI log under
|
|
224
|
+
`%LOCALAPPDATA%\npm-cache\_logs` — is now a place the token has been, and
|
|
225
|
+
npm's debug log records the whole command line. Rotate it once the publish is
|
|
226
|
+
done. It is not needed to install the package afterwards.
|
|
227
|
+
|
|
228
|
+
Two things that read as failures and are not. A version already published
|
|
229
|
+
cannot be published again, so a republished README means a new `version`, not
|
|
230
|
+
a retry — `README.md` is in `files`, so it ships and npmjs.com renders it.
|
|
231
|
+
And for a minute or two after a successful publish, `npm view` still 404s
|
|
232
|
+
while the registry replicates; wait rather than publishing under a second
|
|
233
|
+
number.
|
|
234
|
+
|
|
235
|
+
One trap worth knowing: a token that is missing, truncated or malformed is
|
|
236
|
+
reported by npm as
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
404 The requested resource 'opencode-zen-freex@0.2.1' could not be found or
|
|
240
|
+
you do not have permission to access it.
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
which reads exactly like the package not existing. It is the same status npm
|
|
244
|
+
gives a genuine permission failure, so a 404 from a publish says nothing at
|
|
245
|
+
all until the token has been checked.
|
package/README.md
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
|
-
# OpenCode Zen
|
|
1
|
+
# OpenCode Zen FreeX
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[magpie](https://github.com/yetone/magpie)的拓展插件,为其提供OpenCode Zen的免费模型
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## 为什么有这个分支
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
上游 [zen-free](https://github.com/magpie-community/plugins/tree/main/packages/zen-free) 是这个插件的前身。它一直在维护,修bug也不慢,但对「给免费模型适配」这件事的态度摇摆不定,时而维护而放弃维护故催生本分支:
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
| UTC | 提交 | 那一下说了什么 |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| 2026-10-13 23:53 | [`643ef50`](https://github.com/magpie-community/plugins/commit/643ef5018e63be78c4aa7554696831d4220ac9c9) | README 加上说明:免费层「只在 OpenCode 本体里能用」,`this plugin doesn't work around that`,请改用 Zen 付费 key 或 OpenCode Go;提交说明里写着 `No version bump … The code on main is left as it is` |
|
|
12
|
+
| 2026-10-14 10:57 | [`a810ee7`](https://github.com/magpie-community/plugins/commit/a810ee7e882012ab106e2deb742176cd43db781a) | 0.1.6 把那段说明**删掉**,改为发 OpenCode 1.19.0 原样的请求头——距加上去 11 小时 |
|
|
13
|
+
| 2026-10-14 17:59 | [`5fc0a75`](https://github.com/magpie-community/plugins/commit/5fc0a75292f0e5c0c10cf65a188bff5545d6b8e9) | 0.1.7:`free models answer again` |
|
|
14
|
+
|
|
15
|
+
本分支切于 2026-10-15 00:14 UTC,就在那段声明被删掉之后、从 0.1.6 派生出来。所以这里不去赌下一次会往哪边倒:**指纹在这个仓库里可以随时重新采集**(`bun run capture`),不必等上游发版,也不必先有人被 403 挡住。维护步骤(重新采集、读闸门结果、发版)见 [MAINTAINING.md](https://github.com/tkhs101/OpenCode-Zen-FreeX/blob/main/MAINTAINING.md)。
|
|
16
|
+
|
|
17
|
+
上游的修复是跟着报告走的——0.1.1 到 0.1.8 的每一个版本提交都挂着一位具名的报告者。没人遇到、没人报告,就没有那一次修复。这里的 `bun run check:gate` 把「去问 Zen 现在还认哪一代」变成一条随时能跑的命令。
|
|
18
|
+
|
|
19
|
+
上游修好的东西会照常跟进。**版本号与上游不可比**:0.2.x 长在上游 0.1.6 上,之后按需合并上游的修复,同时保留自己的取舍(比如工具措辞按代从真实 OpenCode 采集,而不是钉死某一个版本)。升级看这个仓库的提交,别拿版本号大小跟上游排。
|
|
10
20
|
|
|
11
21
|
## 目录
|
|
12
22
|
|
|
23
|
+
- [为什么有这个分支](#为什么有这个分支)
|
|
13
24
|
- [安装](#安装)
|
|
14
25
|
- [登录](#登录)
|
|
15
26
|
- [模型](#模型)
|
|
@@ -34,6 +45,8 @@ magpie plugin login opencode-zen-free
|
|
|
34
45
|
|
|
35
46
|
插件会去发现 Zen 当前的免费模型,包括它们的上下文窗口和思考(reasoning)等级。用 `opencode-zen-free/<模型名>`,例如 `opencode-zen-free/big-pickle`。
|
|
36
47
|
|
|
48
|
+
免费模型的目录上限有时被 Zen 少报,而少报的那一边没有谁会报错:`deepseek-v4.1-flash-free` 在目录里是 20 万上下文 / 3.2 万输出,而同一上游模型的付费 id 是 100 万 / 38.4 万。上下文窗口太小只会让宿主安静地早点压缩,输出上限太小会把回复截断成看起来像模型自己停下的样子,所以插件对这类模型按实测与路由自述对齐(依据与复测方法见 `MAINTAINING.md` 的 “Limits Zen under-reports”),并在运行中采用上游自报的窗口。
|
|
49
|
+
|
|
37
50
|
已下线的模型和 SystemOne 模型会被排除。这份列表在宿主每次列举模型时重新获取(magpie 在启动时做一次,之后每小时一次):Zen 不再免费开放的模型会退出列表,新转为免费的模型会加入列表。如果读不到 Zen 的列表,就沿用上一次的结果;第一次还没读到之前,用的是打包在插件里的那份,所以这个 provider 不会空着。请求一个当前并不免费的模型,会得到一个 404 并列出当时免费的模型;若某个模型是在上次列举之后才转免费的,请求会在重新获取列表后照常服务(最多每分钟一次)。可用性和限流由 Zen 决定,插件这边拿不到剩余额度。
|
|
38
51
|
|
|
39
52
|
## 免费层不再应答时
|
|
@@ -44,9 +57,20 @@ magpie plugin login opencode-zen-free
|
|
|
44
57
|
bun run check:gate
|
|
45
58
|
```
|
|
46
59
|
|
|
47
|
-
|
|
60
|
+
每一代问两次,因为插件发出的形状和 OpenCode 本体发出的形状并不是同一个:
|
|
61
|
+
|
|
62
|
+
| 形状 | 发出去的工具 | 是什么 |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| `floor` | `required` 里的名字 | 宿主一个都没有时,插件替它补上的那几个。**这才是插件真实发出的形状,退出码由它决定** |
|
|
65
|
+
| `full` | 这一代携带的全部工具 | 宿主本来就是 OpenCode 时发出来的形状。用来看措辞有没有过期,不决定退出码 |
|
|
66
|
+
|
|
67
|
+
每一代的结论是 `answered`(正常应答)、`refused`(被拒,403)、`superseded`(426——Zen 要求的 OpenCode 比这份指纹声称的更新)或 `unreachable`(根本联系不上)。只要还有一代的 `floor` 被应答,退出码就是 0。某个 `floor` 被拒、而它自己的 `full` 被应答时会单独点出来:那意味着带着自己那套 OpenCode 工具来的宿主能用,而插件真正要服务的那种宿主会被拒。全部联系不上时它不会说指纹过期,只会说问不到——这两件事得分开。
|
|
68
|
+
|
|
69
|
+
闸门问的时候用的就是插件自己那份请求:请求头的名字来自 `provider.mjs` 里的 `outboundHeaders`,工具来自插件所顶替的同一份 `required`。所以它绿了是在说「插件真发的那个请求」被应答了,而不是在说「照抄的一份」被应答了——照抄的那一份曾经少发一个 `accept`,而且没有任何东西因此报错。
|
|
48
70
|
|
|
49
|
-
|
|
71
|
+
发版之前、以及有人报告 403 的时候,都该跑一次。`ZEN_CHECK_MODEL` 可以指定探测用的模型,`ZEN_BASE_URL` 可以指定端点,默认值不合适时就改这两个。
|
|
72
|
+
|
|
73
|
+
如果每一代的 `floor` 都被拒,就从当前版本的 OpenCode 重新采集措辞:把一个 release 指向本地服务器,从它发出的请求里把工具读出来。`bun run capture -- --dry` 取一份新采集并报告差异,`bun run capture` 才真正写入。插件在拒绝时会直接说出发出去的措辞是哪个版本、去哪里找,而不是让人自己猜。
|
|
50
74
|
|
|
51
75
|
要注意这个闸门并不稳定:摸索它的过程中它一天之内挪动过不止一次,而且同一个地址反复被拒之后,接下来一段时间反而会被应答。这里的结论只能读成"此时此刻",信之前再跑一遍。
|
|
52
76
|
|
|
@@ -60,7 +84,9 @@ bun run check:gate
|
|
|
60
84
|
|
|
61
85
|
Zen 的免费层读请求体,也读请求头,只有当整个请求都属于 OpenCode 的同一代时才作答:User-Agent、请求头和工具三者必须一致。当前一共有两代,请求带的工具说明是哪一代:`shell` 和 `read` 属于 2.x,`bash`、`glob`、`grep` 和 `read` 属于 1.x。如果宿主本身就是 OpenCode,它自己的 User-Agent 已经说明是哪一代,这个值会被原样传出去,而不是被钉死,所以永远不会落后。没有任何一方说明时,以工具为准,User-Agent 跟着工具走。
|
|
62
86
|
|
|
63
|
-
|
|
87
|
+
在同一代之内,插件把这一代的工具名都带上——`bash`、`glob`、`grep`、`read`、`write`、`edit`——并且要按 OpenCode 的说法描述。前四个(`required`)在宿主缺了的时候会补上;后两个只在宿主本来就有的时候才去读。实测只有 `read` 是 Zen 咬住不放的:只发 `read` 会被应答,发齐其余五个却不带 `read` 会被拒。那为什么四个都补?因为超集是被接受的,而多出来的名字只在宿主本来没有它时才有代价——一段不会被调用的工具的描述——却能在 Zen 哪天收紧时先挡住一次。这份清单因此刻意留得比实测更宽。
|
|
88
|
+
|
|
89
|
+
工具永远不会被丢弃:宿主自己的工具原封不动地保留,只有发往上游的措辞被替换掉,所以模型发出的调用仍然适配真正执行它的那个工具。别的名字的工具一概不看。
|
|
64
90
|
|
|
65
91
|
被*补上*的工具背后什么都没有:宿主没有这个工具,调它也会被丢弃。但 Zen 要求按 OpenCode 的样子描述它,而这么描述又会让模型想去调它,所以在 Zen 会读的正文开头之后、它不读的那段收尾之前,插一句说明。那句话是只针对这一个工具写的:实测过一句笼统的"这些工具不可用",会连宿主自己的工具也一起不用。
|
|
66
92
|
|
|
@@ -79,7 +105,17 @@ Zen 的免费层读请求体,也读请求头,只有当整个请求都属于
|
|
|
79
105
|
|
|
80
106
|
`anthropic-version` 和 `anthropic-beta` 也会发出去,但只在走 Messages 时,而且只在宿主自己设了它们的时候。
|
|
81
107
|
|
|
82
|
-
`x-opencode-request
|
|
108
|
+
`x-opencode-request`、`x-session-affinity` 和 `x-session-id` 都在 OpenCode 自己的请求头集合里,但没有一个是必需的,所以都不发——不过进来的同名请求头仍然会被读,用来判断这次请求属于哪个会话。这不是「一个个试过不行」得出的结论,2026-10-15 实测(1.x,其余不动):
|
|
109
|
+
|
|
110
|
+
| 加进基准请求的 | 结果 |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| 任意一个,单独 | 应答 |
|
|
113
|
+
| `x-opencode-request` + `x-session-id` | 应答 |
|
|
114
|
+
| `x-session-affinity` + `x-session-id` | 应答 |
|
|
115
|
+
| **`x-opencode-request` + `x-session-affinity`** | **被拒**(复现三次;同一次运行里被拒与通过交替出现,所以不是冷却期) |
|
|
116
|
+
| 三个一起 | 被拒(复现四次) |
|
|
117
|
+
|
|
118
|
+
两个各自无害的头放在一起会被拒,所以「某个头能不能加」不能靠单个试出来。测试按名字钉住了发出的这组请求头,因为多加一个请求头是弄坏这个插件最容易的方式。
|
|
83
119
|
|
|
84
120
|
Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时间戳加每毫秒计数器截成十二位十六进制,后面接十四个随机的 base62 字符;session 的那一种还会把低位取反。宿主给的 id 如果本来就是这个形状,就原样传出去;否则按会话的首条消息生成一个并记住,这样同一会话的各轮请求会共用一个 session id。
|
|
85
121
|
|
|
@@ -89,7 +125,7 @@ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时
|
|
|
89
125
|
|
|
90
126
|
| 看到的现象 | 这是什么 | 怎么办 |
|
|
91
127
|
| --- | --- | --- |
|
|
92
|
-
| 每个请求都是 `403 FreeTierError` | `fingerprint.json` 里的措辞过期了 | 跑 `bun run check:gate
|
|
128
|
+
| 每个请求都是 `403 FreeTierError` | `fingerprint.json` 里的措辞过期了 | 跑 `bun run check:gate`;如果每一代的 `floor` 都被拒,跑 `bun run capture` |
|
|
93
129
|
| `426 Upgrade Required` | 这个包自称的 OpenCode 版本比 Zen 要的旧 | 升级插件,或者在更新的 OpenCode 里运行 |
|
|
94
130
|
| `404 … not available as an OpenCode Zen free model now` | Zen 不再免费开放这个模型 | 从错误信息列出的模型里换一个 |
|
|
95
131
|
| `Model is not an available free model for this protocol` | 请求发来时列表变了 | 让宿主刷新模型列表再试 |
|
|
@@ -102,4 +138,4 @@ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时
|
|
|
102
138
|
|
|
103
139
|
## 许可
|
|
104
140
|
|
|
105
|
-
MIT
|
|
141
|
+
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` 是上游原作者,本分支的贡献者另有署名。
|
package/capture.mjs
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Recaptures fingerprint.json from a real OpenCode.
|
|
2
|
+
//
|
|
3
|
+
// The free tier reads the wording of the tools it knows by name out of the
|
|
4
|
+
// request body and refuses the request when it differs from its own copy. That
|
|
5
|
+
// copy moves with OpenCode, so the one held here goes stale — and a stale copy
|
|
6
|
+
// looks exactly like a working one until somebody is refused. This is how a new
|
|
7
|
+
// one is taken, so that refreshing it is a command and not an archaeology
|
|
8
|
+
// exercise.
|
|
9
|
+
//
|
|
10
|
+
// bun run capture # writes fingerprint.json
|
|
11
|
+
// bun run capture -- --dry # reports what changed, writes nothing
|
|
12
|
+
//
|
|
13
|
+
// Needs opencode on PATH (https://opencode.ai) and bun. Nothing goes to Zen: the
|
|
14
|
+
// OpenCode under test is pointed at a server running here, and the request it
|
|
15
|
+
// makes is the thing being read.
|
|
16
|
+
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { mkdtempSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { PROFILES } from "./core-tools.mjs";
|
|
23
|
+
|
|
24
|
+
const PORT = Number(process.env.CAPTURE_PORT ?? 8791);
|
|
25
|
+
const DRY = process.argv.includes("--dry");
|
|
26
|
+
const HERE = import.meta.dir;
|
|
27
|
+
const FINGERPRINT = join(HERE, "fingerprint.json");
|
|
28
|
+
|
|
29
|
+
// The names Zen reads. Everything else a host declares is ignored by it.
|
|
30
|
+
const READ = ["bash", "glob", "grep", "read", "write", "edit"];
|
|
31
|
+
|
|
32
|
+
// The capture host's own scratch directory and OS banner belong to the machine
|
|
33
|
+
// the capture ran on, not to the tool. These are put back to something neutral
|
|
34
|
+
// so the capture is not tied to one machine.
|
|
35
|
+
const NEUTRALISE = [
|
|
36
|
+
[/Be aware: OS: [^\n]*/g, "Be aware: OS: linux, Shell: bash"],
|
|
37
|
+
[/Use `[^`]*` for temporary work outside the workspace\./g, "Use `/tmp` for temporary work outside the workspace."],
|
|
38
|
+
[/Use `[A-Za-z]:\\[^`]*`/g, "Use `/tmp`"],
|
|
39
|
+
];
|
|
40
|
+
// Zen compares roughly the first 700 characters and ignores the rest; 1200
|
|
41
|
+
// leaves margin, and the tail is where a note to the model goes.
|
|
42
|
+
const KEEP = 1200;
|
|
43
|
+
|
|
44
|
+
const capturedPath = join(tmpdir(), `zen-capture-${process.pid}.json`);
|
|
45
|
+
const server = Bun.serve({
|
|
46
|
+
port: PORT,
|
|
47
|
+
hostname: "127.0.0.1",
|
|
48
|
+
async fetch(request) {
|
|
49
|
+
const url = new URL(request.url);
|
|
50
|
+
if (url.pathname.endsWith("/models"))
|
|
51
|
+
return Response.json({ object: "list", data: [{ id: "big-pickle", object: "model", owned_by: "opencode" }] });
|
|
52
|
+
// OpenCode makes more than one request (a short one for the title, then the
|
|
53
|
+
// real one). The one worth reading is the longest, since it is the one that
|
|
54
|
+
// carries the full toolset.
|
|
55
|
+
const body = await request.text();
|
|
56
|
+
let previous = { agent: null, body: "" };
|
|
57
|
+
try {
|
|
58
|
+
previous = JSON.parse(readFileSync(capturedPath, "utf8"));
|
|
59
|
+
} catch {}
|
|
60
|
+
if (body.length > previous.body.length)
|
|
61
|
+
await Bun.write(capturedPath, JSON.stringify({ agent: request.headers.get("user-agent"), body }));
|
|
62
|
+
return new Response(
|
|
63
|
+
[
|
|
64
|
+
'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"big-pickle","choices":[{"index":0,"delta":{"role":"assistant","content":"OK"},"finish_reason":null}]}',
|
|
65
|
+
'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"big-pickle","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
|
66
|
+
"data: [DONE]",
|
|
67
|
+
"",
|
|
68
|
+
].join("\n\n"),
|
|
69
|
+
{ headers: { "content-type": "text/event-stream" } },
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const home = mkdtempSync(join(tmpdir(), "zen-capture-home-"));
|
|
75
|
+
// Where OpenCode looks for its config is XDG_CONFIG_HOME/opencode, and the
|
|
76
|
+
// three XDG directories are pointed at `home` below.
|
|
77
|
+
const config = join(home, "config", "opencode", "opencode.json");
|
|
78
|
+
await Bun.write(
|
|
79
|
+
config,
|
|
80
|
+
JSON.stringify({ provider: { opencode: { options: { baseURL: `http://127.0.0.1:${PORT}/v1` } } } }),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
console.log(`listening on 127.0.0.1:${PORT}, running opencode against it`);
|
|
84
|
+
let ran = true;
|
|
85
|
+
const code = await new Promise((done) => {
|
|
86
|
+
const child = spawn("opencode", ["run", "-m", "opencode/big-pickle", "Say OK."], {
|
|
87
|
+
env: {
|
|
88
|
+
...process.env,
|
|
89
|
+
XDG_DATA_HOME: join(home, "data"),
|
|
90
|
+
XDG_CONFIG_HOME: join(home, "config"),
|
|
91
|
+
XDG_CACHE_HOME: join(home, "cache"),
|
|
92
|
+
},
|
|
93
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
94
|
+
});
|
|
95
|
+
child.on("error", () => {
|
|
96
|
+
console.error("could not run opencode; install it from https://opencode.ai and put it on PATH");
|
|
97
|
+
ran = false;
|
|
98
|
+
done(1);
|
|
99
|
+
});
|
|
100
|
+
child.on("exit", (value) => done(value ?? 0));
|
|
101
|
+
});
|
|
102
|
+
server.stop(true);
|
|
103
|
+
if (!ran) process.exit(1);
|
|
104
|
+
|
|
105
|
+
let request;
|
|
106
|
+
try {
|
|
107
|
+
request = JSON.parse(readFileSync(capturedPath, "utf8"));
|
|
108
|
+
} catch {
|
|
109
|
+
console.error("no request was captured; opencode did not reach the local server");
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const body = JSON.parse(request.body);
|
|
114
|
+
const tools = {};
|
|
115
|
+
for (const raw of body.tools ?? []) {
|
|
116
|
+
const tool = raw.function ?? raw;
|
|
117
|
+
if (!tool?.name || !READ.includes(tool.name) || !tool.description) continue;
|
|
118
|
+
tools[tool.name] = {
|
|
119
|
+
name: tool.name,
|
|
120
|
+
description: NEUTRALISE.reduce((text, [find, to]) => text.replace(find, to), tool.description).slice(0, KEEP),
|
|
121
|
+
parameters: tool.parameters,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const missing = READ.filter((name) => !tools[name]);
|
|
126
|
+
if (missing.length) {
|
|
127
|
+
console.error(
|
|
128
|
+
`the captured request has no ${missing.join(", ")}; this OpenCode no longer sends them, so it cannot be captured this way`,
|
|
129
|
+
);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
for (const [name, tool] of Object.entries(tools))
|
|
133
|
+
if (tool.description.length < 100) {
|
|
134
|
+
console.error(`${name}'s description looks truncated (${tool.description.length} chars); not writing`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Which generation this client is: the names it sends say so.
|
|
139
|
+
const generation = body.tools.some((t) => (t.function ?? t).name === "shell") ? "2.x" : "1.x";
|
|
140
|
+
const required = generation === "2.x" ? ["shell", "read"] : ["bash", "glob", "grep", "read"];
|
|
141
|
+
const absent = required.filter((name) => !tools[name]);
|
|
142
|
+
if (absent.length) {
|
|
143
|
+
console.error(`a ${generation} request must carry ${absent.join(", ")}, and the capture does not`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Which protocol was captured, since each of 1.x's protocols bundles its own
|
|
148
|
+
// provider-utils and a capture must not overwrite the ones it did not observe.
|
|
149
|
+
const protocol = body.input !== undefined && body.messages === undefined ? "responses" : "chat";
|
|
150
|
+
|
|
151
|
+
console.log(`\nagent: ${request.agent}`);
|
|
152
|
+
console.log(`tools: ${Object.keys(tools).join(", ")} (${generation}, ${protocol})`);
|
|
153
|
+
if (code !== 0) console.log(`(opencode exited ${code}; its request was still captured)`);
|
|
154
|
+
|
|
155
|
+
const agent = parseAgent(request.agent);
|
|
156
|
+
if (agent.providerUtils) agent.providerUtils = { ...PROFILES[generation]?.agent?.providerUtils, [protocol]: agent.providerUtils[protocol] };
|
|
157
|
+
|
|
158
|
+
const before = PROFILES[generation];
|
|
159
|
+
const agentChanged = JSON.stringify(before?.agent) !== JSON.stringify(agent);
|
|
160
|
+
let changed = 0;
|
|
161
|
+
for (const name of Object.keys(tools)) {
|
|
162
|
+
if (JSON.stringify(before?.tools?.[name]) !== JSON.stringify(tools[name])) changed++;
|
|
163
|
+
console.log(` ${name.padEnd(6)} ${JSON.stringify(before?.tools?.[name]) === JSON.stringify(tools[name]) ? "unchanged" : "CHANGED"} ${tools[name].description.length} chars`);
|
|
164
|
+
}
|
|
165
|
+
if (agent.providerUtils)
|
|
166
|
+
console.log(` agent ${agentChanged ? "CHANGED" : "unchanged"} ${protocol} provider-utils kept separate from the others`);
|
|
167
|
+
|
|
168
|
+
if (DRY) {
|
|
169
|
+
const what = [changed ? `${changed} tool(s)` : null, agentChanged ? "the agent" : null].filter(Boolean);
|
|
170
|
+
console.log(`\n${what.length ? `${what.join(" and ")} would change.` : "Nothing would change."} Nothing written (--dry).`);
|
|
171
|
+
process.exit(what.length ? 1 : 0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!changed && !agentChanged) {
|
|
175
|
+
console.log("\nnothing changed.");
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const next = JSON.parse(readFileSync(FINGERPRINT, "utf8"));
|
|
180
|
+
next.profiles[generation] = { agent, required, tools };
|
|
181
|
+
await Bun.write(FINGERPRINT, JSON.stringify(next, null, 2) + "\n");
|
|
182
|
+
console.log(`\nwrote ${FINGERPRINT} for ${generation}. Now run \`bun run check:gate\` and commit if it passes.`);
|
|
183
|
+
|
|
184
|
+
// The User-Agent is stored the way agentFor() reads it back, which is not
|
|
185
|
+
// necessarily the same split the raw header used.
|
|
186
|
+
function parseAgent(agent) {
|
|
187
|
+
const twoPart = /^opencode\/(\d[^ ]*) ai-sdk\/provider-utils\/(\S+) (\S+)$/.exec(agent ?? "");
|
|
188
|
+
if (twoPart) {
|
|
189
|
+
const [, opencode, providerUtils, runtime] = twoPart;
|
|
190
|
+
return {
|
|
191
|
+
opencode,
|
|
192
|
+
providerUtils: { chat: providerUtils, responses: providerUtils, anthropic: providerUtils },
|
|
193
|
+
runtime,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const threePart = /^opencode\/([a-z]+)\/(\d[^/]*)\/([a-z-]+)$/.exec(agent ?? "");
|
|
197
|
+
if (threePart) {
|
|
198
|
+
const [, channel, opencode, client] = threePart;
|
|
199
|
+
return { channel, opencode, client };
|
|
200
|
+
}
|
|
201
|
+
console.error(`\ncould not read \`${agent}\` as an OpenCode User-Agent; write PROFILES[${generation}].agent by hand`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
package/index.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import plugin from "./provider.mjs"
|
|
2
|
-
|
|
3
|
-
export const ZenFreePlugin = plugin.server
|
|
1
|
+
import plugin from "./provider.mjs"
|
|
2
|
+
|
|
3
|
+
export const ZenFreePlugin = plugin.server
|