opencode-zen-freex 0.2.14 → 0.2.16
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 +19 -0
- package/NOTICE +4 -3
- package/README.md +46 -96
- package/limits.mjs +1 -1
- package/package.json +1 -1
- package/provider.mjs +2 -2
- package/stream.mjs +39 -8
- package/tool-response.mjs +227 -33
package/MAINTAINING.md
CHANGED
|
@@ -108,6 +108,14 @@ under a short prompt answers 200 and writes nothing like it, and `mimo`'s refusa
|
|
|
108
108
|
came back with the 200000 it was asked for rather than with a ceiling — and
|
|
109
109
|
neither is a generation nobody watched finish.
|
|
110
110
|
|
|
111
|
+
`witnessed`, the fourth evidence tier, is used by nothing that ships here. The
|
|
112
|
+
table draws on `route`, `route-twin` and `vendor` only; `witnessed` — a
|
|
113
|
+
generation somebody watched finish — is accepted because `limitOverrides` is
|
|
114
|
+
exported and a caller may hand it a table that says so, and no shipped entry
|
|
115
|
+
says it. A live refusal is not an entry either: `observeRouteStatement` applies
|
|
116
|
+
one to the running process, for the rest of that process, and needs no record in
|
|
117
|
+
this file.
|
|
118
|
+
|
|
111
119
|
**What voids it.** Each axis records the catalogue value it was measured
|
|
112
120
|
against, and an axis whose declaration moved is dropped rather than kept: a
|
|
113
121
|
measurement of one question is not an answer to another. A catalogue that raises
|
|
@@ -296,6 +304,17 @@ it. Rewriting the parameters as well looks tidier and breaks the agent.
|
|
|
296
304
|
**Replacing the wording with something of your own.** That is what 0.1.5 and
|
|
297
305
|
earlier did, and it is a guaranteed refusal.
|
|
298
306
|
|
|
307
|
+
**Merging a tool call's frames over its id.** The route puts `"id": null` on
|
|
308
|
+
the continuation frames of a call whose first frame named it (measured
|
|
309
|
+
2026-10-17 on `deepseek-v4.1-flash-free`), and a host that keys a tool result
|
|
310
|
+
by the id — dsh writes `tool/result` records keyed by it — fails its turn on
|
|
311
|
+
an empty one and cannot reload the session afterwards ("tool call id requires
|
|
312
|
+
a nonempty string"). Merging frames must read the id as a name that only
|
|
313
|
+
replaces itself, and a call the route never names has to leave with an
|
|
314
|
+
invented one. Both live in `tool-response.mjs` — the merge's keep, and
|
|
315
|
+
`ensureCallIDs` at the two places a call is published — and `stream.mjs`'s
|
|
316
|
+
collapse reads the same rule.
|
|
317
|
+
|
|
299
318
|
**Trusting one observation.** The gate is not steady: it moved more than once in
|
|
300
319
|
a day while this was being worked out, and identical requests were answered both
|
|
301
320
|
ways minutes apart. Anything measured once is not a fact. Measure it three times,
|
package/NOTICE
CHANGED
|
@@ -10,9 +10,10 @@ itself is unmodified MIT.
|
|
|
10
10
|
The fork is maintained separately from upstream under the name
|
|
11
11
|
`opencode-zen-freex` (https://github.com/tkhs101/OpenCode-Zen-FreeX) because the
|
|
12
12
|
free tier's request fingerprint moves faster than that repository follows it:
|
|
13
|
-
the tool wording and User-Agent Zen accepts can be recaptured
|
|
14
|
-
`bun run capture
|
|
15
|
-
|
|
13
|
+
the tool wording and User-Agent Zen accepts can be recaptured with
|
|
14
|
+
`bun run capture` from a clone of this repository — `capture.mjs` is a
|
|
15
|
+
maintenance tool and is not part of the published tarball — without waiting for a
|
|
16
|
+
release upstream. `MAINTAINING.md` describes that loop.
|
|
16
17
|
|
|
17
18
|
The provider id this package registers, `opencode-zen-free`, is deliberately the
|
|
18
19
|
same one upstream's plugin registers — a model is written
|
package/README.md
CHANGED
|
@@ -1,32 +1,30 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# OpenCode Zen FreeX
|
|
2
4
|
|
|
3
|
-
[magpie](https://github.com/yetone/magpie)
|
|
5
|
+
为 [magpie](https://github.com/yetone/magpie) 提供 **OpenCode Zen 的免费模型**——共享凭据 `public` 即用,自动发现,原生流式协议
|
|
6
|
+
|
|
7
|
+
[](https://github.com/tkhs101/OpenCode-Zen-FreeX/actions/workflows/ci.yml)
|
|
8
|
+
[](https://www.npmjs.com/package/opencode-zen-freex)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
[](https://bun.sh)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
4
13
|
|
|
5
14
|
## 为什么有这个分支
|
|
6
15
|
|
|
7
|
-
|
|
16
|
+
[zen-free](https://github.com/magpie-community/plugins/tree/main/packages/zen-free) 是这个插件的前身。先说清楚:上游一直在维护,修 Bug 也很快。问题在于它对**免费模型支持**这件事,始终缺少一个**稳定、明确的态度**
|
|
8
17
|
|
|
9
|
-
|
|
18
|
+
免费模型失效时,用户反馈issue,得到的答复却是**将不再支持**;过一段时间,相关问题又被悄悄修复,免费模型重新恢复可用。支持、放弃、再支持——用户只能跟着上游的态度**反复折腾**,却始终无法确定这份支持究竟能持续多久
|
|
19
|
+
|
|
20
|
+
既然有人依赖它,就值得一个明确的答案,而不是**反复摇摆**,这就是本项目诞生的原因
|
|
21
|
+
| UTC | 提交 | 上游说了什么 |
|
|
10
22
|
| --- | --- | --- |
|
|
11
23
|
| 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
24
|
| 2026-10-14 10:57 | [`a810ee7`](https://github.com/magpie-community/plugins/commit/a810ee7e882012ab106e2deb742176cd43db781a) | 0.1.6 把那段说明**删掉**,改为发 OpenCode 1.19.0 原样的请求头——距加上去 11 小时 |
|
|
13
25
|
| 2026-10-14 17:59 | [`5fc0a75`](https://github.com/magpie-community/plugins/commit/5fc0a75292f0e5c0c10cf65a188bff5545d6b8e9) | 0.1.7:`free models answer again` |
|
|
14
26
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
上游的修复是跟着报告走的——0.1.1 到 0.1.8 的每一个版本提交都挂着一位具名的报告者。没人遇到、没人报告,就没有那一次修复。这里的 `bun run check:gate` 把「去问 Zen 现在还认哪一代」变成一条随时能跑的命令。
|
|
18
|
-
|
|
19
|
-
上游修好的东西会照常跟进。**版本号与上游不可比**:0.2.x 长在上游 0.1.6 上,之后按需合并上游的修复,同时保留自己的取舍(比如工具措辞按代从真实 OpenCode 采集,而不是钉死某一个版本)。升级看这个仓库的提交,别拿版本号大小跟上游排。
|
|
20
|
-
|
|
21
|
-
## 目录
|
|
22
|
-
|
|
23
|
-
- [为什么有这个分支](#为什么有这个分支)
|
|
24
|
-
- [安装](#安装)
|
|
25
|
-
- [登录](#登录)
|
|
26
|
-
- [模型](#模型)
|
|
27
|
-
- [免费层不再应答时](#免费层不再应答时)
|
|
28
|
-
- [请求](#请求)
|
|
29
|
-
- [排查](#排查)
|
|
27
|
+
上游的后续更新会按需合并,同时保留自己的取舍(比如工具措辞按代从真实 OpenCode 采集,而不是钉死某一个版本)
|
|
30
28
|
|
|
31
29
|
## 安装
|
|
32
30
|
|
|
@@ -37,111 +35,63 @@ magpie plugin login opencode-zen-free
|
|
|
37
35
|
|
|
38
36
|
## 登录
|
|
39
37
|
|
|
40
|
-
|
|
38
|
+
密钥一栏填 `public`,不需要个人 API key——填什么都会被丢掉,插件用的就是这把共享凭据。
|
|
41
39
|
|
|
42
|
-
|
|
40
|
+
个人 key 请加到 magpie 内置的 OpenCode Zen provider:那里收多把 key,能轮换、故障转移。凭据保存在 OpenCode 的 `auth.json` 或 magpie 的 `plugin-auth.json` 里。
|
|
43
41
|
|
|
44
42
|
## 模型
|
|
45
43
|
|
|
46
|
-
|
|
47
|
-
|
|
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”),并在运行中采用上游自报的窗口。
|
|
44
|
+
模型名 = `opencode-zen-free/<模型名>`,例如 `opencode-zen-free/big-pickle`。
|
|
49
45
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
- **自动发现**:宿主每次列举模型时重新获取(magpie:启动一次,其后每小时)。id 带 `-free`、Zen 的列表里就有就提供,**不等**目录;下线(`retired`/`disabled`/退役日期已生效)与 SystemOne 排除,标 `deprecated` 但 Zen 仍在服务的照常提供。
|
|
47
|
+
- **上限按实测对齐**:目录会少报上下文 / 输出 / `input`,插件按实测与路由自述对齐——例如 `deepseek-v4.1-flash-free` 目录 20 万上下文 / 3.2 万输出,付费同源是 100 万 / 38.4 万。依据与复测方法见 [`MAINTAINING.md` 的 “Limits Zen under-reports”](MAINTAINING.md#limits-zen-under-reports)。
|
|
48
|
+
- **目录读不到 ≠ fallback**:Zen 的列表读到了它就是当前该提供的;只有**列表本身**读不到才沿用上一次结果。
|
|
49
|
+
- 请求一个不再免费的模型 → 404,并列出当时免费的模型;刚转免费的模型会在重新获取列表后照常服务。可用性与限流归 Zen 管,插件看不到剩余额度。
|
|
53
50
|
|
|
54
51
|
## 免费层不再应答时
|
|
55
52
|
|
|
56
|
-
|
|
53
|
+
插件给出的 403 会写明措辞来自哪个 OpenCode 版本,先从它查起(下面三条都是**从仓库副本里**跑的检查——`check:gate` 与 `capture` 不在发布包里,装好的用户那里没有这两个脚本):
|
|
57
54
|
|
|
58
55
|
```sh
|
|
59
|
-
bun run check:gate
|
|
56
|
+
bun run check:gate # 问 Zen 还认哪代指纹;只要有一代 floor 被应答,退出码为 0
|
|
57
|
+
bun run capture -- --dry # floor 全被拒时:取新采集并报告差异,不写入
|
|
58
|
+
bun run capture # 确认差异后才写入
|
|
60
59
|
```
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
| 形状 | 发出去的工具 | 是什么 |
|
|
65
|
-
| --- | --- | --- |
|
|
66
|
-
| `floor` | `required` 里的名字 | 宿主一个都没有时,插件替它补上的那几个。**这才是插件真实发出的形状,退出码由它决定** |
|
|
67
|
-
| `full` | 这一代携带的全部工具 | 宿主本来就是 OpenCode 时发出来的形状。用来看措辞有没有过期,不决定退出码 |
|
|
68
|
-
|
|
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 说明的是那次请求,不足以把「没有一代被服务」说成「去查网络」。
|
|
72
|
-
|
|
73
|
-
闸门问的时候用的就是插件自己那份请求:请求头的名字来自 `provider.mjs` 里的 `outboundHeaders`,工具来自插件所顶替的同一份 `required`。所以它绿了是在说「插件真发的那个请求」被应答了,而不是在说「照抄的一份」被应答了——照抄的那一份曾经少发一个 `accept`,而且没有任何东西因此报错。
|
|
74
|
-
|
|
75
|
-
发版之前、以及有人报告 403 的时候,都该跑一次。`ZEN_CHECK_MODEL` 可以指定探测用的模型,`ZEN_BASE_URL` 可以指定端点,默认值不合适时就改这两个。
|
|
76
|
-
|
|
77
|
-
如果每一代的 `floor` 都被拒,就从当前版本的 OpenCode 重新采集措辞:把一个 release 指向本地服务器,从它发出的请求里把工具读出来。`bun run capture -- --dry` 取一份新采集并报告差异,`bun run capture` 才真正写入。插件在拒绝时会直接说出发出去的措辞是哪个版本、去哪里找,而不是让人自己猜。
|
|
78
|
-
|
|
79
|
-
要注意这个闸门并不稳定:摸索它的过程中它一天之内挪动过不止一次,而且同一个地址反复被拒之后,接下来一段时间反而会被应答。这里的结论只能读成"此时此刻",信之前再跑一遍。
|
|
80
|
-
|
|
81
|
-
`bun test` 是这个循环的另一半。它从不联网,所以它回答不了指纹是否还有效——它只能说明这个包的行为和以前是否一致。
|
|
61
|
+
闸门并不稳定:结论只代表此刻,信之前再跑一遍;同一地址反复被拒后还可能进冷却期。逐代结论、失败判读与排查细节见 [`MAINTAINING.md` 的 “The loop”](MAINTAINING.md#the-loop) 与 [“Reading a failure”](MAINTAINING.md#reading-a-failure)。
|
|
82
62
|
|
|
83
63
|
## 请求
|
|
84
64
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
宿主自己的工具会被完整保留。Zen 的模型经常去调 OpenCode 的工具名;这种调用会被对上宿主那个只差大小写的工具(`bash` 对 `Bash`),或者只差大小写和 `_`/`-` 的那个(`todowrite` 对 `todo_write`、`webfetch` 对 `web_fetch`),前提是恰好只有一个对得上。找不到对应工具(或者调用根本没有名字)的,不放进回复里,回复的正文会说明哪个名字被略去了;如果这一轮所有调用都被略去,这一轮就以一段普通回答结束。工具仍然在宿主里执行。
|
|
88
|
-
|
|
89
|
-
Zen 的免费层读请求体,也读请求头,只有当整个请求都属于 OpenCode 的同一代时才作答:User-Agent、请求头和工具三者必须一致。当前一共有两代,请求带的工具说明是哪一代:`shell` 和 `read` 属于 2.x,`bash`、`glob`、`grep` 和 `read` 属于 1.x。如果宿主本身就是 OpenCode,它自己的 User-Agent 已经说明是哪一代,这个值会被原样传出去,而不是被钉死,所以永远不会落后。没有任何一方说明时,以工具为准,User-Agent 跟着工具走。
|
|
90
|
-
|
|
91
|
-
在同一代之内,插件把这一代的工具名都带上——`bash`、`glob`、`grep`、`read`、`write`、`edit`——并且要按 OpenCode 的说法描述。前四个(`required`)在宿主缺了的时候会补上;后两个只在宿主本来就有的时候才去读。实测只有 `read` 是 Zen 咬住不放的:只发 `read` 会被应答,发齐其余五个却不带 `read` 会被拒。那为什么四个都补?因为超集是被接受的,而多出来的名字只在宿主本来没有它时才有代价——一段不会被调用的工具的描述——却能在 Zen 哪天收紧时先挡住一次。这份清单因此刻意留得比实测更宽。
|
|
92
|
-
|
|
93
|
-
工具永远不会被丢弃:宿主自己的工具原封不动地保留,只有发往上游的措辞被替换掉,所以模型发出的调用仍然适配真正执行它的那个工具。别的名字的工具一概不看。
|
|
94
|
-
|
|
95
|
-
被*补上*的工具背后什么都没有:宿主没有这个工具,调它也会被丢弃。但 Zen 要求按 OpenCode 的样子描述它,而这么描述又会让模型想去调它,所以在 Zen 会读的正文开头之后、它不读的那段收尾之前,插一句说明。那句话是只针对这一个工具写的:实测过一句笼统的"这些工具不可用",会连宿主自己的工具也一起不用。
|
|
96
|
-
|
|
97
|
-
发出去的请求头就是下面这些,一个不多,而这份清单是刻意从 Zen 曾经接受过的全部头里挑出来的子集:
|
|
98
|
-
|
|
99
|
-
| 请求头 | 值 |
|
|
100
|
-
| --- | --- |
|
|
101
|
-
| `content-type` | `application/json` |
|
|
102
|
-
| `accept` | `*/*` |
|
|
103
|
-
| `user-agent` | 上面说的那一代的值 |
|
|
104
|
-
| `x-opencode-project` | 宿主的 project id,或 `global` |
|
|
105
|
-
| `x-opencode-session` | 整个会话共用的一个 id |
|
|
106
|
-
| `x-opencode-session-id` | 同一个 id |
|
|
107
|
-
| `x-opencode-client` | `cli` |
|
|
108
|
-
| `authorization` | `Bearer public`;走 Messages 时是 `x-api-key: public` |
|
|
65
|
+
发往 `https://opencode.ai/inference/v1`,按每个模型原生协议走(Chat Completions / Responses / Anthropic Messages),流式与普通 JSON 都支持。
|
|
109
66
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
| 任意一个,单独 | 应答 |
|
|
117
|
-
| `x-opencode-request` + `x-session-id` | 应答 |
|
|
118
|
-
| `x-session-affinity` + `x-session-id` | 应答 |
|
|
119
|
-
| **`x-opencode-request` + `x-session-affinity`** | **被拒**(复现三次;同一次运行里被拒与通过交替出现,所以不是冷却期) |
|
|
120
|
-
| 三个一起 | 被拒(复现四次) |
|
|
121
|
-
|
|
122
|
-
两个各自无害的头放在一起会被拒,所以「某个头能不能加」不能靠单个试出来。测试按名字钉住了发出的这组请求头,因为多加一个请求头是弄坏这个插件最容易的方式。
|
|
123
|
-
|
|
124
|
-
Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时间戳加每毫秒计数器截成十二位十六进制,后面接十四个随机的 base62 字符;session 的那一种还会把低位取反。宿主给的 id 如果本来就是这个形状,就原样传出去;否则按会话的首条消息生成一个并记住,这样同一会话的各轮请求会共用一个 session id。宿主既不声明会话、又没有可作依据的用户消息时,这一轮就拿一个自己的 id——没有东西可以据以记住它,所以它不会被并进另一个会话里去。
|
|
125
|
-
|
|
126
|
-
偶尔 Zen 会在发出 `[DONE]` 之前就结束一个流式的 Chat Completions 回复。如果回复已经说了内容、且其中没有未完成的工具调用,就以普通回答结束,正文里会说明它被截断了;再问一次就会接着往下走。如果是带着未完成的工具调用被截断、或者什么都没说就断了、或者被折叠给一个不支持流式的客户端,请求就会失败,错误信息里写明这个回复的 id 和收到了多少。走 Anthropic Messages 或 Responses 的回复被截断同样会失败:magpie 能把它和完整的回复区分开,并且在该模型支持的情况下继续往下走。
|
|
127
|
-
|
|
128
|
-
失败回复的正文会被读一小段,用来找 Zen 自己说出的上下文窗口或输出上限(见下)。因此透传给你的错误正文有两个规矩:读完的按原样给,只去掉描述旧字节的长度、编码与校验头;**没读完的不会冒充完整正文**——状态码仍是上游的,正文换成一段说明(写明上游的状态、读到了多少),并把读到的开头放在 `upstream` 字段里。不这么做的话,超长的错误正文会被截断成一段解析不了的 JSON,看起来像是解析出错而不是 Zen 说了什么。
|
|
67
|
+
- **宿主的工具不会被丢**:Zen 调 OpenCode 工具名时,按大小写与 `_`/`-` 对上唯一匹配的宿主工具(`bash`↔`Bash`、`todowrite`↔`todo_write`);对不上就略去并在正文说明。工具仍在宿主执行,调用一律带非空 id。
|
|
68
|
+
- **只借 OpenCode 的样子**:请求头、User-Agent、工具必须同属一代(`bash`/`glob`/`grep`/`read` 是 1.x,`shell`/`read` 是 2.x);宿主缺的 `required` 工具由插件补发描述(背后没有实现,正文注明),宿主自带的 User-Agent 原样透传。
|
|
69
|
+
- **请求头是刻意挑出的最小集合**:`content-type`、`accept`、`user-agent`、`x-opencode-project`、`x-opencode-session(-id)`(同一 id)、`x-opencode-client: cli`、`authorization: Bearer public`(走 Messages 时 `x-api-key: public` + `anthropic-version`;`anthropic-beta` 这个头插件自己从不设置:只在宿主已经发了的时候原样透传)。**不要加别的头**:两个各自无害的组合起来也会被拒——单试一个得不出结论。
|
|
70
|
+
- **会话 id** 按会话首条消息生成并记住,同一会话各轮共用;宿主的 id 形状合法就原样传。
|
|
71
|
+
- **提前掐断的流**:有内容且无未完成调用 → 按普通回答收尾并注明截断,再问会续;否则请求失败,错误写明回复 id 与已收字节。Messages / Responses 被截断同样失败。
|
|
72
|
+
- **错误正文不会冒充完整**:只读一小段找 Zen 自陈的上限;没读完的部分换成说明(保留上游状态码,已读开头放 `upstream` 字段)。
|
|
129
73
|
|
|
130
74
|
## 排查
|
|
131
75
|
|
|
132
76
|
| 看到的现象 | 这是什么 | 怎么办 |
|
|
133
77
|
| --- | --- | --- |
|
|
134
|
-
| 每个请求都是 `403 FreeTierError` | `fingerprint.json` 里的措辞过期了 |
|
|
78
|
+
| 每个请求都是 `403 FreeTierError` | `fingerprint.json` 里的措辞过期了 | 在仓库副本里跑 `bun run check:gate`;全被拒时跑 `bun run capture`(这两个脚本不在发布包里,见上一节) |
|
|
135
79
|
| `426 Upgrade Required` | 这个包自称的 OpenCode 版本比 Zen 要的旧 | 升级插件,或者在更新的 OpenCode 里运行 |
|
|
136
80
|
| `404 … not available as an OpenCode Zen free model now` | Zen 不再免费开放这个模型 | 从错误信息列出的模型里换一个 |
|
|
137
81
|
| `Model is not an available free model for this protocol` | 请求发来时列表变了 | 让宿主刷新模型列表再试 |
|
|
138
82
|
| `uses the public credential` | 存的密钥不是 `public` | 用 `public` 重新登录 |
|
|
139
|
-
| 不带 `FreeTierError` 的 403 | 是别的 403
|
|
83
|
+
| 不带 `FreeTierError` 的 403 | 是别的 403:正文原样透传,只去掉描述旧字节的长度、编码与校验头 | 按 Zen 自己的错误来读,别当成这个插件的 |
|
|
140
84
|
|
|
141
|
-
|
|
85
|
+
## 鸣谢
|
|
142
86
|
|
|
143
|
-
|
|
87
|
+
| 类别 | 项目 |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| 本插件前身 | [zen-free](https://github.com/magpie-community/plugins/tree/main/packages/zen-free) |
|
|
90
|
+
| 插件载体 | [magpie](https://github.com/yetone/magpie) |
|
|
91
|
+
| 模型提供者 | [anomalyco/opencode](https://github.com/anomalyco/opencode) |
|
|
92
|
+
| 本仓库常用 Agent | [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) · [hermes-agent](https://github.com/NousResearch/hermes-agent) |
|
|
93
|
+
| 灵感来源 | [9router](https://github.com/decolua/9router) · [opencode2api](https://github.com/jasonxu114514/opencode2api) · [zen-gate](https://github.com/LAGcomcom/zen-gate) |
|
|
144
94
|
|
|
145
95
|
## 许可
|
|
146
96
|
|
|
147
|
-
MIT,分叉自 [zen-free](https://github.com/magpie-community/plugins/tree/main/packages/zen-free)
|
|
97
|
+
MIT,分叉自 [zen-free](https://github.com/magpie-community/plugins/tree/main/packages/zen-free),`LICENSE` 正文原样未改(版权行:上游原作者 `xiaozhou26` 与本分支 `OpenCode-Zen-FreeX contributors` 各一条)。来源与维护缘由见 [NOTICE](NOTICE)。
|
package/limits.mjs
CHANGED
|
@@ -434,7 +434,7 @@ function digitsEnd(found) {
|
|
|
434
434
|
// digits touching the boundary are ambiguous. Each axis is read from the match
|
|
435
435
|
// its number came from, so the match the guard judges and the number that is
|
|
436
436
|
// returned cannot be two answers.
|
|
437
|
-
function namedAxes(text, limit, cut) {
|
|
437
|
+
export function namedAxes(text, limit, cut) {
|
|
438
438
|
const head = text.slice(0, limit);
|
|
439
439
|
const context = namedFrom(NAMED_CONTEXT, head);
|
|
440
440
|
const output = namedFrom(NAMED_OUTPUT, head);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-zen-freex",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.16",
|
|
4
4
|
"description": "OpenCode Zen's free models, for hosts that are not OpenCode: the public credential, automatic discovery and native streaming protocols",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
package/provider.mjs
CHANGED
|
@@ -353,7 +353,7 @@ function explain(text, source, toolPolicy) {
|
|
|
353
353
|
filled.length
|
|
354
354
|
? `The host declares none of ${filled.join(", ")}, so those were added from it.`
|
|
355
355
|
: `The host declared every one of them; only the wording was replaced, and its own parameters were kept so its tools still run.`,
|
|
356
|
-
"OpenCode rewords those tools between releases. Run `bun run check:gate` to see whether that copy is still current; if it is not, capture the wording again from a current OpenCode and update this package.",
|
|
356
|
+
"OpenCode rewords those tools between releases. Run `bun run check:gate` from a clone of this repository to see whether that copy is still current; if it is not, capture the wording again from a current OpenCode and update this package.",
|
|
357
357
|
].join(" ");
|
|
358
358
|
return new Response(
|
|
359
359
|
JSON.stringify({
|
|
@@ -530,7 +530,7 @@ async function server(_input, options = {}) {
|
|
|
530
530
|
methods: [
|
|
531
531
|
{
|
|
532
532
|
type: "api",
|
|
533
|
-
label: "Zen
|
|
533
|
+
label: "Zen 免费访问(密钥填 public;个人 key 请加到内置的 OpenCode Zen)",
|
|
534
534
|
placeholder: "public",
|
|
535
535
|
async authorize() {
|
|
536
536
|
return { type: "success", key: "public" };
|
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
|
-
|
|
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
|
}
|
|
@@ -190,24 +196,46 @@ function responsesAccumulator() {
|
|
|
190
196
|
// was measured handing a function_call there the text of a message. An
|
|
191
197
|
// `added` frame opens an item, so the id it names is new there by
|
|
192
198
|
// definition and takes the next free slot; a frame that has to find an
|
|
193
|
-
// item that is already open still throws
|
|
194
|
-
|
|
199
|
+
// item that is already open still throws — unless the reply holds exactly
|
|
200
|
+
// one item of the frame's own kind with no id of its own, which is the item
|
|
201
|
+
// the frame is naming: measured on this route, a call's output_item.added
|
|
202
|
+
// can arrive carrying no id at all and every frame after it names the item
|
|
203
|
+
// by an id of its own, so the id is adopted onto the item the stream opened
|
|
204
|
+
// for that call, and one call stays one item. Two candidates, or none of
|
|
205
|
+
// the frame's kind, is still not something to guess at, and still throws.
|
|
206
|
+
function slot(data, id, fresh = false, kind) {
|
|
195
207
|
if (data.output_index !== undefined) return index(data.output_index);
|
|
196
208
|
if (id === undefined || id === null || id === "") return index(undefined);
|
|
197
209
|
const position = [...output].find(([, value]) => value.id === id)?.[0];
|
|
198
210
|
if (position !== undefined) return index(position);
|
|
199
|
-
if (!fresh)
|
|
211
|
+
if (!fresh) {
|
|
212
|
+
const candidates = [...output].filter(([, value]) => !value.id && value.type === kind);
|
|
213
|
+
if (candidates.length === 1) {
|
|
214
|
+
const [position, value] = candidates[0];
|
|
215
|
+
value.id = id;
|
|
216
|
+
return index(position);
|
|
217
|
+
}
|
|
218
|
+
throw new Error("Upstream frame names an unknown item without an output index");
|
|
219
|
+
}
|
|
200
220
|
let next = 0;
|
|
201
221
|
for (const held of output.keys()) next = Math.max(next, held + 1);
|
|
202
222
|
return index(next);
|
|
203
223
|
}
|
|
204
224
|
// A snapshot replaces the item in its slot only when it is a whole view of
|
|
205
|
-
// it; a partial one leaves what the deltas delivered standing.
|
|
225
|
+
// it; a partial one leaves what the deltas delivered standing. A snapshot
|
|
226
|
+
// that carries no id does not take away the one the stream named the item by
|
|
227
|
+
// (slot, above): the item's own snapshot arrives without it in the shape
|
|
228
|
+
// that names an item from the frames after it, and taking the snapshot as it
|
|
229
|
+
// stands lost the id again. One that carries an id still wins, as it did.
|
|
206
230
|
function keep(position, value) {
|
|
207
|
-
if (carries(value, output.get(position), fields))
|
|
231
|
+
if (carries(value, output.get(position), fields)) {
|
|
232
|
+
const held = output.get(position);
|
|
233
|
+
if (held?.id && !value.id) value.id = held.id;
|
|
234
|
+
output.set(position, value);
|
|
235
|
+
}
|
|
208
236
|
}
|
|
209
237
|
function item(data, type = "message") {
|
|
210
|
-
const position = slot(data, data.item_id);
|
|
238
|
+
const position = slot(data, data.item_id, false, type);
|
|
211
239
|
if (!output.has(position)) {
|
|
212
240
|
const value = { type };
|
|
213
241
|
if (data.item_id) value.id = data.item_id;
|
|
@@ -231,7 +259,10 @@ function responsesAccumulator() {
|
|
|
231
259
|
}
|
|
232
260
|
} else if (type === "response.output_item.added" || type === "response.output_item.done") {
|
|
233
261
|
if (data.item)
|
|
234
|
-
keep(
|
|
262
|
+
keep(
|
|
263
|
+
slot(data, data.item.id, type === "response.output_item.added", data.item?.type),
|
|
264
|
+
data.item,
|
|
265
|
+
);
|
|
235
266
|
} else if (type === "response.content_part.added" || type === "response.content_part.done") {
|
|
236
267
|
const value = item(data);
|
|
237
268
|
value.content ??= [];
|
package/tool-response.mjs
CHANGED
|
@@ -60,10 +60,34 @@ function foldsTo(declared, key) {
|
|
|
60
60
|
return folded;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// The names the policy says the agent declared. A field that is missing or
|
|
64
|
+
// renamed used to read as an empty set — `new Set(undefined)` does not throw —
|
|
65
|
+
// so every call of every reply became "a tool this agent doesn't have", with
|
|
66
|
+
// nothing thrown and nothing logged to say why. A policy this guard cannot
|
|
67
|
+
// read is a caller's mistake and is refused by name instead: the Set this
|
|
68
|
+
// package's own producer builds (provider.mjs's normalizeTools), or an array
|
|
69
|
+
// of names. It is a TypeError, not a GuardError: the reply is not what is
|
|
70
|
+
// wrong, the caller is.
|
|
71
|
+
function declaredNames(policy) {
|
|
72
|
+
const held = policy?.declared;
|
|
73
|
+
const names = held instanceof Set ? [...held] : held;
|
|
74
|
+
if (!Array.isArray(names))
|
|
75
|
+
throw new TypeError(
|
|
76
|
+
`policy.declared must be a Set or an array of tool names, not ${
|
|
77
|
+
held === null ? "null" : Array.isArray(held) ? "an array of those" : typeof held
|
|
78
|
+
}`,
|
|
79
|
+
);
|
|
80
|
+
for (const name of names) {
|
|
81
|
+
if (typeof name !== "string")
|
|
82
|
+
throw new TypeError(`policy.declared must hold tool names, and holds ${typeof name}`);
|
|
83
|
+
}
|
|
84
|
+
return new Set(names);
|
|
85
|
+
}
|
|
86
|
+
|
|
63
87
|
// nameGuard answers the agent's name for a call, or null for a call of no
|
|
64
88
|
// tool the agent has (a missing name included).
|
|
65
89
|
function nameGuard(policy) {
|
|
66
|
-
const declared =
|
|
90
|
+
const declared = declaredNames(policy);
|
|
67
91
|
const lower = (name) => name.toLowerCase();
|
|
68
92
|
const bare = (name) => name.toLowerCase().replace(separators, "");
|
|
69
93
|
const byCase = foldsTo(declared, lower);
|
|
@@ -166,8 +190,11 @@ let synthesized = 0;
|
|
|
166
190
|
// What a call left out of a Responses or Messages reply becomes: text
|
|
167
191
|
// saying so, in the call's place, so no later index moves. `at` is where the
|
|
168
192
|
// call sits in the reply as far as the caller can tell — a frame's
|
|
169
|
-
// output_index, or the index the walk found it at.
|
|
170
|
-
|
|
193
|
+
// output_index, or the index the walk found it at. `placed` is the token a
|
|
194
|
+
// caller that follows the whole stream already settled on for a call whose
|
|
195
|
+
// frames place it nowhere at all (see snapshotStream): without it every
|
|
196
|
+
// rewrite of that call invented a new id.
|
|
197
|
+
function inPlaceOf(protocol, call, note, at, placed) {
|
|
171
198
|
if (protocol === "anthropic") return { type: "text", text: note };
|
|
172
199
|
// An item id is what a Responses client keys the item by, and what the
|
|
173
200
|
// streaming path suppresses arguments by. One call is replaced more than
|
|
@@ -175,16 +202,20 @@ function inPlaceOf(protocol, call, note, at) {
|
|
|
175
202
|
// the terminal response's output — so the invented id has to be the same
|
|
176
203
|
// every time: a counter made one call into several items to the client, and
|
|
177
204
|
// left leftIDs unable to match its arguments frames. A call's place is the
|
|
178
|
-
// same in all three, and two calls cannot share one, so the id names it
|
|
205
|
+
// same in all three, and two calls cannot share one, so the id names it —
|
|
206
|
+
// and a call the reply places nowhere takes the id its own stream settled
|
|
207
|
+
// on for it.
|
|
179
208
|
//
|
|
180
|
-
//
|
|
181
|
-
// different
|
|
182
|
-
//
|
|
183
|
-
//
|
|
209
|
+
// Both arms still invent ids in one reply, and they still name them under
|
|
210
|
+
// different words: a counter that happened to reach another call's index
|
|
211
|
+
// would hand two unrelated calls the same item, which is the failure the
|
|
212
|
+
// counter was introduced to prevent.
|
|
184
213
|
const id =
|
|
185
214
|
typeof call.id === "string" && call.id
|
|
186
215
|
? call.id
|
|
187
|
-
: `msg_${
|
|
216
|
+
: `msg_${
|
|
217
|
+
call.call_id ?? placed ?? (at === undefined ? `anon_${++synthesized}` : `dropped_${at}`)
|
|
218
|
+
}`;
|
|
188
219
|
return {
|
|
189
220
|
type: "message",
|
|
190
221
|
id,
|
|
@@ -194,11 +225,33 @@ function inPlaceOf(protocol, call, note, at) {
|
|
|
194
225
|
};
|
|
195
226
|
}
|
|
196
227
|
|
|
228
|
+
// A tool call needs an id its host can key the result by, and this route has
|
|
229
|
+
// been measured leaving calls without one: 2026-10-17, through this plugin, a
|
|
230
|
+
// reply's flushed frame carried `{"index":0,"id":null,"name":"bash"}` — the
|
|
231
|
+
// route puts `"id": null` on a continuation frame of a call it named on the
|
|
232
|
+
// first one, and a reply can also leave the call unnamed to the end. OpenAI's
|
|
233
|
+
// shape requires the field, and a host it reaches has nothing to match its
|
|
234
|
+
// result against without it: dsh keys its tool/result records by this id and
|
|
235
|
+
// fails the whole turn — and then the whole session, on reload — when the id
|
|
236
|
+
// is empty ("toolCallId matching its tool source"). So a call that reaches
|
|
237
|
+
// the wire leaves with an id: the last-resort kind, process-unique under the
|
|
238
|
+
// same counter the invented item ids use, so two invented calls cannot share
|
|
239
|
+
// one.
|
|
240
|
+
function ensureCallIDs(calls) {
|
|
241
|
+
for (const call of calls) {
|
|
242
|
+
if (typeof call.id === "string" && call.id !== "") continue;
|
|
243
|
+
call.id = `call_${++synthesized}`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
197
247
|
// checkSnapshots checks every call in a reply or event: renamed to the
|
|
198
248
|
// agent's tool, or (Responses, Messages) put in place of by a note. It
|
|
199
249
|
// answers whether data changed, how many calls it kept and how many it
|
|
200
|
-
// left out.
|
|
201
|
-
|
|
250
|
+
// left out. `placeOf` is the stream's own answer for a call whose frames
|
|
251
|
+
// carry no output_index and no item id — a token already invented for it, so
|
|
252
|
+
// all of its rewrites answer one item id (snapshotStream); a caller reading a
|
|
253
|
+
// whole reply has no such state and leaves it out.
|
|
254
|
+
function checkSnapshots(data, protocol, checkName, placeOf) {
|
|
202
255
|
let changed = false;
|
|
203
256
|
if (protocol === "chat") {
|
|
204
257
|
for (const choice of list(data.choices)) {
|
|
@@ -217,15 +270,15 @@ function checkSnapshots(data, protocol, checkName) {
|
|
|
217
270
|
let kept = 0;
|
|
218
271
|
let left = 0;
|
|
219
272
|
if (!object(data)) throw new GuardError("Invalid upstream tool snapshot");
|
|
220
|
-
// Each entry is a value, where it sits,
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
// output_index the frame carries
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
const stack = [[data, null, null, object(data) ? data.output_index : undefined]];
|
|
273
|
+
// Each entry is a value, where it sits, the place it holds in the reply as
|
|
274
|
+
// far as the walk knows, and whether the walk reached it as an item of a
|
|
275
|
+
// list (an item of an output array, as opposed to a frame's own `item`,
|
|
276
|
+
// which keeps the output_index the frame carries). A call left out is
|
|
277
|
+
// replaced by an item whose invented id names that place, so the added,
|
|
278
|
+
// done and terminal copies of one call are one item (inPlaceOf).
|
|
279
|
+
const stack = [[data, null, null, object(data) ? data.output_index : undefined, false]];
|
|
227
280
|
while (stack.length) {
|
|
228
|
-
const [value, holder, key, at] = stack.pop();
|
|
281
|
+
const [value, holder, key, at, listed] = stack.pop();
|
|
229
282
|
if (!object(value)) throw new GuardError("Invalid upstream tool snapshot");
|
|
230
283
|
if (
|
|
231
284
|
protocol === "responses" &&
|
|
@@ -239,7 +292,13 @@ function checkSnapshots(data, protocol, checkName) {
|
|
|
239
292
|
const name = checkName(value.name);
|
|
240
293
|
if (name == null) {
|
|
241
294
|
if (!holder) throw new GuardError(droppedNote(value.name), "undeclared_tool");
|
|
242
|
-
holder[key] = inPlaceOf(
|
|
295
|
+
holder[key] = inPlaceOf(
|
|
296
|
+
protocol,
|
|
297
|
+
value,
|
|
298
|
+
droppedNote(value.name),
|
|
299
|
+
at,
|
|
300
|
+
placeOf?.(value, at, listed),
|
|
301
|
+
);
|
|
243
302
|
changed = true;
|
|
244
303
|
left++;
|
|
245
304
|
continue;
|
|
@@ -257,10 +316,10 @@ function checkSnapshots(data, protocol, checkName) {
|
|
|
257
316
|
// Text content is not a snapshot container.
|
|
258
317
|
if (typeof value[name] === "string") continue;
|
|
259
318
|
const items = list(value[name]);
|
|
260
|
-
for (let i = 0; i < items.length; i++) stack.push([items[i], items, i, i]);
|
|
319
|
+
for (let i = 0; i < items.length; i++) stack.push([items[i], items, i, i, true]);
|
|
261
320
|
}
|
|
262
321
|
for (const name of objects) {
|
|
263
|
-
if (value[name] != null) stack.push([value[name], value, name, at]);
|
|
322
|
+
if (value[name] != null) stack.push([value[name], value, name, at, false]);
|
|
264
323
|
}
|
|
265
324
|
}
|
|
266
325
|
if (
|
|
@@ -342,6 +401,7 @@ function chatBuffer(checkName) {
|
|
|
342
401
|
}
|
|
343
402
|
if (state.legacy) delta.function_call = state.legacy;
|
|
344
403
|
const { notes } = checkChatMessage(delta, checkName);
|
|
404
|
+
if (Array.isArray(delta.tool_calls)) ensureCallIDs(delta.tool_calls);
|
|
345
405
|
if (notes.length) {
|
|
346
406
|
delta.tool_calls?.forEach((call, i) => {
|
|
347
407
|
call.index = i;
|
|
@@ -399,6 +459,16 @@ function chatBuffer(checkName) {
|
|
|
399
459
|
const index = position(fragment.index);
|
|
400
460
|
const previous = state.calls.get(index) ?? { index, function: {} };
|
|
401
461
|
const value = { ...previous, ...fragment, index, function: previous.function };
|
|
462
|
+
// A continuation frame may carry `"id": null` — measured on this
|
|
463
|
+
// route — and a spread would let it clobber the id that named the
|
|
464
|
+
// call. A fragment that says nothing about the id leaves the one
|
|
465
|
+
// already there; one that carries a real id still wins, as it did.
|
|
466
|
+
if (
|
|
467
|
+
(typeof value.id !== "string" || value.id === "") &&
|
|
468
|
+
typeof previous.id === "string" &&
|
|
469
|
+
previous.id !== ""
|
|
470
|
+
)
|
|
471
|
+
value.id = previous.id;
|
|
402
472
|
if (fragment.function != null) appendFunction(value.function, fragment.function);
|
|
403
473
|
state.calls.set(index, value);
|
|
404
474
|
}
|
|
@@ -466,11 +536,90 @@ function chatBuffer(checkName) {
|
|
|
466
536
|
function snapshotStream(protocol, checkName) {
|
|
467
537
|
const left = new Map(); // output_index or content block index → true
|
|
468
538
|
const leftIDs = new Set(); // Responses item ids left out
|
|
539
|
+
// The calls this stream placed nowhere — its frames carried no output_index
|
|
540
|
+
// and no item id — in the order it opened them. Nothing in those frames says
|
|
541
|
+
// which item is which, so the order is all the guard has: the k-th item a
|
|
542
|
+
// stream opens without a place of its own is the k-th item of a reply that
|
|
543
|
+
// places nothing, which is where the terminal response's copy of it sits.
|
|
544
|
+
// One call is replaced three times — the frame that opens it, the frame that
|
|
545
|
+
// closes it, and that copy — and all three answer the same invented id
|
|
546
|
+
// (inPlaceOf), which is also what its arguments frames are matched by. An
|
|
547
|
+
// entry is settled once the stream has left the call.
|
|
548
|
+
const nowhere = []; // { token, name, settled }
|
|
549
|
+
// The item ids the reply has named on an item of its own: the arguments
|
|
550
|
+
// frames of a call the reply opened carry one of these, and are not the
|
|
551
|
+
// placeless call's to swallow.
|
|
552
|
+
const seen = new Set();
|
|
553
|
+
let open = -1; // the placeless call the stream is inside, or -1
|
|
469
554
|
let kept = 0;
|
|
470
555
|
let dropped = 0;
|
|
556
|
+
// What a frame says the reply's items are called. A frame that names none of
|
|
557
|
+
// them — the shape this tracking exists for — records nothing.
|
|
558
|
+
function see(data) {
|
|
559
|
+
const values = [
|
|
560
|
+
data.item,
|
|
561
|
+
...(Array.isArray(data.output) ? data.output : []),
|
|
562
|
+
...(Array.isArray(data.response?.output) ? data.response.output : []),
|
|
563
|
+
];
|
|
564
|
+
for (const value of values) {
|
|
565
|
+
if (object(value) && typeof value.id === "string" && value.id) seen.add(value.id);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// Opens a placeless call of its own and answers the token naming it.
|
|
569
|
+
function entering(name, settled = false) {
|
|
570
|
+
const token = `anon_${++synthesized}`;
|
|
571
|
+
nowhere.push({ token, name, settled });
|
|
572
|
+
open = nowhere.length - 1;
|
|
573
|
+
return token;
|
|
574
|
+
}
|
|
575
|
+
function identified(call) {
|
|
576
|
+
return (typeof call.id === "string" && call.id) || call.call_id != null;
|
|
577
|
+
}
|
|
578
|
+
// The stream's answer for a call whose frames carry no output_index and no
|
|
579
|
+
// item id, or undefined when the call is not one the stream has to place:
|
|
580
|
+
// the walk hands every call it leaves out through here.
|
|
581
|
+
function placeOf(call, at, listed) {
|
|
582
|
+
if (protocol !== "responses" || identified(call)) return undefined;
|
|
583
|
+
if (listed) {
|
|
584
|
+
// The copy inside a snapshot or the terminal response's output: the call
|
|
585
|
+
// sits where the walk found it, and an entry invented when the stream
|
|
586
|
+
// opened that call already names that place. A reply that places nothing
|
|
587
|
+
// is what this can be read from, so the entry is matched by the place it
|
|
588
|
+
// holds when the names agree, and by its name when they do not — the
|
|
589
|
+
// same call is copied into several snapshots, and every one of them has
|
|
590
|
+
// to answer the same id. Two calls of one name cannot be told apart
|
|
591
|
+
// here, and the last one the stream opened answers for both.
|
|
592
|
+
const entry =
|
|
593
|
+
(nowhere[at]?.name === call.name ? nowhere[at] : undefined) ??
|
|
594
|
+
nowhere.findLast((value) => value.name === call.name);
|
|
595
|
+
return entry?.token;
|
|
596
|
+
}
|
|
597
|
+
// A frame's own item. One that gave an index is already placed by it (the
|
|
598
|
+
// dropped_<at> token inPlaceOf mints), so only the index-less frames need
|
|
599
|
+
// the stream's answer: the call of that name it is inside, or else the
|
|
600
|
+
// call it opened last — which is what a frame that repeats no name is.
|
|
601
|
+
if (at !== undefined) return undefined;
|
|
602
|
+
const entry =
|
|
603
|
+
nowhere.findLast((value) => value.name === call.name) ??
|
|
604
|
+
(nowhere[open] && !nowhere[open].settled ? nowhere[open] : undefined);
|
|
605
|
+
if (entry) {
|
|
606
|
+
entry.settled = true;
|
|
607
|
+
return entry.token;
|
|
608
|
+
}
|
|
609
|
+
// A done frame whose added frame was never seen. It is a call of its own,
|
|
610
|
+
// and the terminal's copy of it still answers this id by name.
|
|
611
|
+
return entering(call.name, true);
|
|
612
|
+
}
|
|
471
613
|
return {
|
|
472
614
|
apply(data, type) {
|
|
473
615
|
if (protocol === "responses") {
|
|
616
|
+
see(data);
|
|
617
|
+
// A frame that opens an item ends the window the last one had: the
|
|
618
|
+
// stream is not inside that call any more, so an arguments frame
|
|
619
|
+
// naming an item the reply never opened is not that call's to swallow
|
|
620
|
+
// (below). The item this frame opens is entered just after this, in
|
|
621
|
+
// the frame's own branch.
|
|
622
|
+
if (type === "response.output_item.added" && nowhere[open]) nowhere[open].settled = true;
|
|
474
623
|
const item = data.item;
|
|
475
624
|
if (
|
|
476
625
|
type === "response.output_item.added" &&
|
|
@@ -481,8 +630,14 @@ function snapshotStream(protocol, checkName) {
|
|
|
481
630
|
const note = droppedNote(item.name);
|
|
482
631
|
// The frame's output_index is this call's place in the reply, which
|
|
483
632
|
// is the id its later frames (done, the terminal's output) invent
|
|
484
|
-
// for it again (inPlaceOf).
|
|
485
|
-
|
|
633
|
+
// for it again (inPlaceOf). A frame that gives no index leaves the
|
|
634
|
+
// place to the stream: the entry opened here is what its later
|
|
635
|
+
// frames — and its arguments frames — are matched by.
|
|
636
|
+
const placed =
|
|
637
|
+
data.output_index === undefined && !identified(item)
|
|
638
|
+
? entering(item.name)
|
|
639
|
+
: undefined;
|
|
640
|
+
const message = inPlaceOf(protocol, item, note, data.output_index, placed);
|
|
486
641
|
left.set(data.output_index, true);
|
|
487
642
|
leftIDs.add(message.id);
|
|
488
643
|
if (typeof item.id === "string") leftIDs.add(item.id);
|
|
@@ -500,12 +655,30 @@ function snapshotStream(protocol, checkName) {
|
|
|
500
655
|
],
|
|
501
656
|
};
|
|
502
657
|
}
|
|
503
|
-
if (
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
((data.
|
|
507
|
-
|
|
508
|
-
|
|
658
|
+
if (typeof type === "string" && type.startsWith("response.function_call_arguments.")) {
|
|
659
|
+
if (data.output_index != null && left.has(data.output_index))
|
|
660
|
+
return { changed: true, data: null };
|
|
661
|
+
if (leftIDs.has(data.item_id)) return { changed: true, data: null };
|
|
662
|
+
// An item id the reply never named on an item of its own: the
|
|
663
|
+
// arguments frames of a call it placed nowhere name the item from
|
|
664
|
+
// the first of them on (measured: the added frame carries no id at
|
|
665
|
+
// all, and the frames after it carry only an item_id), so while the
|
|
666
|
+
// stream is inside that call they are its frames. They are left out
|
|
667
|
+
// with the call — reaching the client they would describe an item
|
|
668
|
+
// that was replaced by a note, and the collapse path refuses them —
|
|
669
|
+
// and the id is recorded, so the frames after the first are matched
|
|
670
|
+
// too. A frame naming an item the reply did name belongs to that
|
|
671
|
+
// item and is left alone.
|
|
672
|
+
const entry = nowhere[open];
|
|
673
|
+
if (
|
|
674
|
+
data.output_index == null &&
|
|
675
|
+
entry &&
|
|
676
|
+
!entry.settled &&
|
|
677
|
+
!seen.has(data.item_id)
|
|
678
|
+
) {
|
|
679
|
+
if (typeof data.item_id === "string" && data.item_id) leftIDs.add(data.item_id);
|
|
680
|
+
return { changed: true, data: null };
|
|
681
|
+
}
|
|
509
682
|
}
|
|
510
683
|
} else {
|
|
511
684
|
if (type === "message_start") {
|
|
@@ -547,7 +720,7 @@ function snapshotStream(protocol, checkName) {
|
|
|
547
720
|
return { changed: true, data };
|
|
548
721
|
}
|
|
549
722
|
}
|
|
550
|
-
const checked = checkSnapshots(data, protocol, checkName);
|
|
723
|
+
const checked = checkSnapshots(data, protocol, checkName, placeOf);
|
|
551
724
|
kept += checked.kept;
|
|
552
725
|
dropped += checked.left;
|
|
553
726
|
return { changed: checked.changed, data };
|
|
@@ -930,6 +1103,18 @@ function guardedStream(response, protocol, checkName, endCutShort) {
|
|
|
930
1103
|
controller.close();
|
|
931
1104
|
return;
|
|
932
1105
|
}
|
|
1106
|
+
// An event after the terminal marker is not part of the reply: the
|
|
1107
|
+
// reply ended where the marker said. Reading it as more of the
|
|
1108
|
+
// same was this path's behaviour alone — the collapse path read
|
|
1109
|
+
// the same bytes and dropped it (stream.mjs's `if (completed)
|
|
1110
|
+
// return`) — so the same upstream answered two different replies.
|
|
1111
|
+
// It is ignored: not applied to the buffer, not forwarded. This
|
|
1112
|
+
// sits below the error branch above on purpose: an upstream that
|
|
1113
|
+
// reports an error after it ended the reply still failed the turn,
|
|
1114
|
+
// and that is a failure, not something to ignore. A frame with no
|
|
1115
|
+
// data at all is the SSE's own framing rather than an event, and
|
|
1116
|
+
// it keeps flowing from above this point.
|
|
1117
|
+
if (completed) continue;
|
|
933
1118
|
const checked = chat ? chat.apply(data) : snapshots.apply(data, type);
|
|
934
1119
|
const { changed, data: result, extra = [] } = checked;
|
|
935
1120
|
if (result) track?.see(result);
|
|
@@ -997,11 +1182,14 @@ function guardedStream(response, protocol, checkName, endCutShort) {
|
|
|
997
1182
|
export async function guardToolResponse(response, protocol, policy, options = {}) {
|
|
998
1183
|
// A protocol this guard has no rules for is the caller's mistake, and it
|
|
999
1184
|
// is one whichever the upstream answered: checked before the status is
|
|
1000
|
-
// read, so an error response cannot return it unexamined.
|
|
1185
|
+
// read, so an error response cannot return it unexamined. The policy is
|
|
1186
|
+
// read here for the same reason: a policy this guard cannot read refuses
|
|
1187
|
+
// every call of every reply, and that must be said rather than done
|
|
1188
|
+
// quietly (declaredNames), whichever the upstream answered.
|
|
1001
1189
|
if (!["chat", "responses", "anthropic"].includes(protocol))
|
|
1002
1190
|
throw new TypeError(`Unsupported protocol: ${protocol}`);
|
|
1003
|
-
if (!response.ok) return response;
|
|
1004
1191
|
const checkName = nameGuard(policy);
|
|
1192
|
+
if (!response.ok) return response;
|
|
1005
1193
|
const headers = headersFor(response);
|
|
1006
1194
|
const contentType = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
|
|
1007
1195
|
if (contentType === "text/event-stream") {
|
|
@@ -1016,6 +1204,12 @@ export async function guardToolResponse(response, protocol, policy, options = {}
|
|
|
1016
1204
|
try {
|
|
1017
1205
|
value = parseJSON(await readBounded(response));
|
|
1018
1206
|
checkSnapshots(value, protocol, checkName);
|
|
1207
|
+
if (protocol === "chat") {
|
|
1208
|
+
for (const choice of list(value.choices)) {
|
|
1209
|
+
const message = object(choice) ? choice.message : undefined;
|
|
1210
|
+
if (object(message)) ensureCallIDs(list(message.tool_calls));
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1019
1213
|
} catch (error) {
|
|
1020
1214
|
if (!(error instanceof GuardError)) throw error;
|
|
1021
1215
|
status = 502;
|