opencode-zen-freex 0.2.1 → 0.2.2
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 +171 -0
- package/README.md +45 -11
- package/capture.mjs +203 -0
- package/index.mjs +3 -3
- package/models.mjs +211 -148
- package/package.json +4 -1
- package/provider.mjs +503 -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,171 @@
|
|
|
1
|
+
# Maintaining this plugin
|
|
2
|
+
|
|
3
|
+
The plugin is one file of logic and one piece 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
|
+
## The loop
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
bun test # the unit tests; these never touch the network
|
|
21
|
+
bun run check:gate # asks Zen which generations are still accepted
|
|
22
|
+
bun run capture -- --dry # takes a fresh capture and reports the difference
|
|
23
|
+
bun run capture # writes it
|
|
24
|
+
bun run check:gate # did that fix it
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`check:gate` is the one that matters. It asks each generation twice, because the
|
|
28
|
+
plugin sends two shapes and Zen does not answer them alike:
|
|
29
|
+
|
|
30
|
+
| shape | tools sent | what it is |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `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 |
|
|
33
|
+
| `full` | every tool in the profile | what a host that is OpenCode itself sends |
|
|
34
|
+
|
|
35
|
+
It reports each request as:
|
|
36
|
+
|
|
37
|
+
| verdict | meaning |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `answered` | Zen served it; this shape is current |
|
|
40
|
+
| `refused` | Zen did not recognise it (403) |
|
|
41
|
+
| `superseded` | Zen wants a newer OpenCode than the profile claims (426) |
|
|
42
|
+
| `unreachable` | Zen could not be asked at all |
|
|
43
|
+
|
|
44
|
+
The `floor` decides the exit code, because it is the shape this plugin exists to
|
|
45
|
+
send. The `full` is reported for what it says about a wording that has gone
|
|
46
|
+
stale. Asking only the full shape left a real gap: `read` alone is `answered` and
|
|
47
|
+
everything but `read` is `refused`, so a Zen that began insisting on one more
|
|
48
|
+
name would keep `full` passing while the shape actually sent was refused.
|
|
49
|
+
|
|
50
|
+
`capture` needs `opencode` on PATH. It points a real OpenCode at a server running
|
|
51
|
+
locally and reads the request it makes, so nothing is sent to Zen and no key is
|
|
52
|
+
involved.
|
|
53
|
+
|
|
54
|
+
## Reading a failure
|
|
55
|
+
|
|
56
|
+
**Every `floor` `refused`.** OpenCode reworded its tools, or moved the endpoint.
|
|
57
|
+
Run `bun run capture`, then `check:gate` again. If the endpoint moved,
|
|
58
|
+
`DEFAULT_BASE` in `provider.mjs` is what has to change.
|
|
59
|
+
|
|
60
|
+
**One `floor` `refused`, the other `answered`.** OpenCode has released and this
|
|
61
|
+
generation is retired. Capture from a current OpenCode: it will be detected as
|
|
62
|
+
the other generation, so what is captured goes into that profile.
|
|
63
|
+
|
|
64
|
+
**A `floor` `refused` while its `full` is `answered`.** Zen has begun insisting
|
|
65
|
+
on a tool this plugin does not stand in, which the gate names in a line of its
|
|
66
|
+
own. What gets stood in is `required` in `fingerprint.json`, and the wording it
|
|
67
|
+
is stood in with is that profile's `tools`, so the name has to reach both, from a
|
|
68
|
+
capture taken of an OpenCode that has it. A host that brings its own tool by that
|
|
69
|
+
name is served either way, which is why only the `floor` shows this.
|
|
70
|
+
|
|
71
|
+
**`superseded` (426).** Zen raised the minimum version this profile claims. Raise
|
|
72
|
+
`agent.opencode` in `fingerprint.json` above what it asks for. 426 is Zen saying
|
|
73
|
+
"your client is too old", which is a different problem from 403 and is not fixed
|
|
74
|
+
by changing tools.
|
|
75
|
+
|
|
76
|
+
**`unreachable`.** Not a fingerprint problem. Check the network, and whether
|
|
77
|
+
repeated refusals from this address have put it in a cooldown — they can, and
|
|
78
|
+
the cooldown answers like a refusal.
|
|
79
|
+
|
|
80
|
+
## Things that are easy to break
|
|
81
|
+
|
|
82
|
+
**Adding a header.** The set sent has one owner: `outboundHeaders` in
|
|
83
|
+
`provider.mjs`, which the plugin builds its request with and which the gate asks
|
|
84
|
+
Zen with, so neither can drift from the other on its own. `index.test.mjs` pins
|
|
85
|
+
the names on the wire. `x-opencode-request`, `x-session-affinity` and
|
|
86
|
+
`x-session-id` are all in OpenCode's own header set upstream and none is sent;
|
|
87
|
+
measured 2026-10-15, each of the three answers on its own while
|
|
88
|
+
x-opencode-request with x-session-affinity is refused. A header cannot be judged
|
|
89
|
+
by adding it on its own, so add it in `outboundHeaders` and let the pinned test
|
|
90
|
+
and a live `check:gate` say what it did.
|
|
91
|
+
|
|
92
|
+
**Rewriting the tool wording.** The wording sent upstream is OpenCode's, because
|
|
93
|
+
Zen reads it. What replaces it is only the wording: the host's tool and its
|
|
94
|
+
parameters are kept, so the call the model makes still fits the tool that runs
|
|
95
|
+
it. Rewriting the parameters as well looks tidier and breaks the agent.
|
|
96
|
+
|
|
97
|
+
**Replacing the wording with something of your own.** That is what 0.1.5 and
|
|
98
|
+
earlier did, and it is a guaranteed refusal.
|
|
99
|
+
|
|
100
|
+
**Trusting one observation.** The gate is not steady: it moved more than once in
|
|
101
|
+
a day while this was being worked out, and identical requests were answered both
|
|
102
|
+
ways minutes apart. Anything measured once is not a fact. Measure it three times,
|
|
103
|
+
interleaved, before believing it. When two sources disagree, prefer the shape
|
|
104
|
+
that passes under the stricter of them, since the looser one will not last.
|
|
105
|
+
|
|
106
|
+
## Layout
|
|
107
|
+
|
|
108
|
+
| file | what it is |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| `fingerprint.json` | the capture. The only thing that goes out of date |
|
|
111
|
+
| `core-tools.mjs` | reads the capture; says which generation a request is |
|
|
112
|
+
| `provider.mjs` | the plugin: discovery, headers, tools, the diagnostic |
|
|
113
|
+
| `models.mjs` | which models are free, and what protocol each speaks |
|
|
114
|
+
| `stream.mjs`, `tool-response.mjs` | replies Zen cuts short, and tool calls |
|
|
115
|
+
| `capture.mjs` | takes a new capture |
|
|
116
|
+
| `tools-smoke.mjs` | asks Zen what it accepts (`check:gate`) |
|
|
117
|
+
|
|
118
|
+
## Publishing
|
|
119
|
+
|
|
120
|
+
The package is `opencode-zen-freex`; the provider it registers is
|
|
121
|
+
`opencode-zen-free`, which is the same one magpie-community/plugins' version
|
|
122
|
+
registers. That is deliberate: a model is written `opencode-zen-free/<model>`
|
|
123
|
+
either way, so one stays in place whichever is installed. It does mean the two
|
|
124
|
+
are mutually exclusive — installing both leaves two plugins claiming one
|
|
125
|
+
provider id, and which one wins is down to load order. Pick one.
|
|
126
|
+
|
|
127
|
+
To publish: bump `version`, `npm publish`, and tag. `magpie plugin add
|
|
128
|
+
opencode-zen-freex` installs it by that name.
|
|
129
|
+
|
|
130
|
+
Publishing is the one step here that cannot be run unattended, because it is
|
|
131
|
+
the one step npm asks a person about. Run it as yourself.
|
|
132
|
+
|
|
133
|
+
**With 2FA on the account.** `npm publish` fails with `EOTP` and prints a
|
|
134
|
+
`https://www.npmjs.com/auth/cli/...` address, redacted in the error and
|
|
135
|
+
different every time. Open it, finish, and the same command carries on. There
|
|
136
|
+
is nothing to pass to a script: the address is tied to the browser session that
|
|
137
|
+
prints it.
|
|
138
|
+
|
|
139
|
+
**With a granular token.** A token with publish permission authorizes the
|
|
140
|
+
publish on its own and never asks for the second factor. Write it to an npmrc
|
|
141
|
+
outside the repository and point npm at it for that one command, so it does
|
|
142
|
+
not land in a tracked file:
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
printf '//registry.npmjs.org/:_authToken=npm_...\n' > "$TMP/npmrc"
|
|
146
|
+
npm publish --userconfig "$TMP/npmrc"; rm -f "$TMP/npmrc"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Whatever carries a token — a shell, a chat, a CI log under
|
|
150
|
+
`%LOCALAPPDATA%\npm-cache\_logs` — is now a place the token has been, and
|
|
151
|
+
npm's debug log records the whole command line. Rotate it once the publish is
|
|
152
|
+
done. It is not needed to install the package afterwards.
|
|
153
|
+
|
|
154
|
+
Two things that read as failures and are not. A version already published
|
|
155
|
+
cannot be published again, so a republished README means a new `version`, not
|
|
156
|
+
a retry — `README.md` is in `files`, so it ships and npmjs.com renders it.
|
|
157
|
+
And for a minute or two after a successful publish, `npm view` still 404s
|
|
158
|
+
while the registry replicates; wait rather than publishing under a second
|
|
159
|
+
number.
|
|
160
|
+
|
|
161
|
+
One trap worth knowing: a token that is missing, truncated or malformed is
|
|
162
|
+
reported by npm as
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
404 The requested resource 'opencode-zen-freex@0.2.1' could not be found or
|
|
166
|
+
you do not have permission to access it.
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
which reads exactly like the package not existing. It is the same status npm
|
|
170
|
+
gives a genuine permission failure, so a 404 from a publish says nothing at
|
|
171
|
+
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) 是这个插件的前身。它一直在修,修得也不慢,但「免费模型还能不能用」这件事在上游那里翻过一次,而往哪边翻不掌握在使用它的人手里:
|
|
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
|
- [模型](#模型)
|
|
@@ -44,9 +55,20 @@ magpie plugin login opencode-zen-free
|
|
|
44
55
|
bun run check:gate
|
|
45
56
|
```
|
|
46
57
|
|
|
47
|
-
|
|
58
|
+
每一代问两次,因为插件发出的形状和 OpenCode 本体发出的形状并不是同一个:
|
|
59
|
+
|
|
60
|
+
| 形状 | 发出去的工具 | 是什么 |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| `floor` | `required` 里的名字 | 宿主一个都没有时,插件替它补上的那几个。**这才是插件真实发出的形状,退出码由它决定** |
|
|
63
|
+
| `full` | 这一代携带的全部工具 | 宿主本来就是 OpenCode 时发出来的形状。用来看措辞有没有过期,不决定退出码 |
|
|
64
|
+
|
|
65
|
+
每一代的结论是 `answered`(正常应答)、`refused`(被拒,403)、`superseded`(426——Zen 要求的 OpenCode 比这份指纹声称的更新)或 `unreachable`(根本联系不上)。只要还有一代的 `floor` 被应答,退出码就是 0。某个 `floor` 被拒、而它自己的 `full` 被应答时会单独点出来:那意味着带着自己那套 OpenCode 工具来的宿主能用,而插件真正要服务的那种宿主会被拒。全部联系不上时它不会说指纹过期,只会说问不到——这两件事得分开。
|
|
48
66
|
|
|
49
|
-
|
|
67
|
+
闸门问的时候用的就是插件自己那份请求:请求头的名字来自 `provider.mjs` 里的 `outboundHeaders`,工具来自插件所顶替的同一份 `required`。所以它绿了是在说「插件真发的那个请求」被应答了,而不是在说「照抄的一份」被应答了——照抄的那一份曾经少发一个 `accept`,而且没有任何东西因此报错。
|
|
68
|
+
|
|
69
|
+
发版之前、以及有人报告 403 的时候,都该跑一次。`ZEN_CHECK_MODEL` 可以指定探测用的模型,`ZEN_BASE_URL` 可以指定端点,默认值不合适时就改这两个。
|
|
70
|
+
|
|
71
|
+
如果每一代的 `floor` 都被拒,就从当前版本的 OpenCode 重新采集措辞:把一个 release 指向本地服务器,从它发出的请求里把工具读出来。`bun run capture -- --dry` 取一份新采集并报告差异,`bun run capture` 才真正写入。插件在拒绝时会直接说出发出去的措辞是哪个版本、去哪里找,而不是让人自己猜。
|
|
50
72
|
|
|
51
73
|
要注意这个闸门并不稳定:摸索它的过程中它一天之内挪动过不止一次,而且同一个地址反复被拒之后,接下来一段时间反而会被应答。这里的结论只能读成"此时此刻",信之前再跑一遍。
|
|
52
74
|
|
|
@@ -60,7 +82,9 @@ bun run check:gate
|
|
|
60
82
|
|
|
61
83
|
Zen 的免费层读请求体,也读请求头,只有当整个请求都属于 OpenCode 的同一代时才作答:User-Agent、请求头和工具三者必须一致。当前一共有两代,请求带的工具说明是哪一代:`shell` 和 `read` 属于 2.x,`bash`、`glob`、`grep` 和 `read` 属于 1.x。如果宿主本身就是 OpenCode,它自己的 User-Agent 已经说明是哪一代,这个值会被原样传出去,而不是被钉死,所以永远不会落后。没有任何一方说明时,以工具为准,User-Agent 跟着工具走。
|
|
62
84
|
|
|
63
|
-
|
|
85
|
+
在同一代之内,插件把这一代的工具名都带上——`bash`、`glob`、`grep`、`read`、`write`、`edit`——并且要按 OpenCode 的说法描述。前四个(`required`)在宿主缺了的时候会补上;后两个只在宿主本来就有的时候才去读。实测只有 `read` 是 Zen 咬住不放的:只发 `read` 会被应答,发齐其余五个却不带 `read` 会被拒。那为什么四个都补?因为超集是被接受的,而多出来的名字只在宿主本来没有它时才有代价——一段不会被调用的工具的描述——却能在 Zen 哪天收紧时先挡住一次。这份清单因此刻意留得比实测更宽。
|
|
86
|
+
|
|
87
|
+
工具永远不会被丢弃:宿主自己的工具原封不动地保留,只有发往上游的措辞被替换掉,所以模型发出的调用仍然适配真正执行它的那个工具。别的名字的工具一概不看。
|
|
64
88
|
|
|
65
89
|
被*补上*的工具背后什么都没有:宿主没有这个工具,调它也会被丢弃。但 Zen 要求按 OpenCode 的样子描述它,而这么描述又会让模型想去调它,所以在 Zen 会读的正文开头之后、它不读的那段收尾之前,插一句说明。那句话是只针对这一个工具写的:实测过一句笼统的"这些工具不可用",会连宿主自己的工具也一起不用。
|
|
66
90
|
|
|
@@ -79,7 +103,17 @@ Zen 的免费层读请求体,也读请求头,只有当整个请求都属于
|
|
|
79
103
|
|
|
80
104
|
`anthropic-version` 和 `anthropic-beta` 也会发出去,但只在走 Messages 时,而且只在宿主自己设了它们的时候。
|
|
81
105
|
|
|
82
|
-
`x-opencode-request
|
|
106
|
+
`x-opencode-request`、`x-session-affinity` 和 `x-session-id` 都在 OpenCode 自己的请求头集合里,但没有一个是必需的,所以都不发——不过进来的同名请求头仍然会被读,用来判断这次请求属于哪个会话。这不是「一个个试过不行」得出的结论,2026-10-15 实测(1.x,其余不动):
|
|
107
|
+
|
|
108
|
+
| 加进基准请求的 | 结果 |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| 任意一个,单独 | 应答 |
|
|
111
|
+
| `x-opencode-request` + `x-session-id` | 应答 |
|
|
112
|
+
| `x-session-affinity` + `x-session-id` | 应答 |
|
|
113
|
+
| **`x-opencode-request` + `x-session-affinity`** | **被拒**(复现三次;同一次运行里被拒与通过交替出现,所以不是冷却期) |
|
|
114
|
+
| 三个一起 | 被拒(复现四次) |
|
|
115
|
+
|
|
116
|
+
两个各自无害的头放在一起会被拒,所以「某个头能不能加」不能靠单个试出来。测试按名字钉住了发出的这组请求头,因为多加一个请求头是弄坏这个插件最容易的方式。
|
|
83
117
|
|
|
84
118
|
Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时间戳加每毫秒计数器截成十二位十六进制,后面接十四个随机的 base62 字符;session 的那一种还会把低位取反。宿主给的 id 如果本来就是这个形状,就原样传出去;否则按会话的首条消息生成一个并记住,这样同一会话的各轮请求会共用一个 session id。
|
|
85
119
|
|
|
@@ -89,7 +123,7 @@ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时
|
|
|
89
123
|
|
|
90
124
|
| 看到的现象 | 这是什么 | 怎么办 |
|
|
91
125
|
| --- | --- | --- |
|
|
92
|
-
| 每个请求都是 `403 FreeTierError` | `fingerprint.json` 里的措辞过期了 | 跑 `bun run check:gate
|
|
126
|
+
| 每个请求都是 `403 FreeTierError` | `fingerprint.json` 里的措辞过期了 | 跑 `bun run check:gate`;如果每一代的 `floor` 都被拒,跑 `bun run capture` |
|
|
93
127
|
| `426 Upgrade Required` | 这个包自称的 OpenCode 版本比 Zen 要的旧 | 升级插件,或者在更新的 OpenCode 里运行 |
|
|
94
128
|
| `404 … not available as an OpenCode Zen free model now` | Zen 不再免费开放这个模型 | 从错误信息列出的模型里换一个 |
|
|
95
129
|
| `Model is not an available free model for this protocol` | 请求发来时列表变了 | 让宿主刷新模型列表再试 |
|
|
@@ -102,4 +136,4 @@ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时
|
|
|
102
136
|
|
|
103
137
|
## 许可
|
|
104
138
|
|
|
105
|
-
MIT
|
|
139
|
+
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
|