opencode-zen-freex 0.2.14 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MAINTAINING.md +11 -0
- package/README.md +11 -1
- package/package.json +1 -1
- package/stream.mjs +7 -1
- package/tool-response.mjs +36 -0
package/MAINTAINING.md
CHANGED
|
@@ -296,6 +296,17 @@ it. Rewriting the parameters as well looks tidier and breaks the agent.
|
|
|
296
296
|
**Replacing the wording with something of your own.** That is what 0.1.5 and
|
|
297
297
|
earlier did, and it is a guaranteed refusal.
|
|
298
298
|
|
|
299
|
+
**Merging a tool call's frames over its id.** The route puts `"id": null` on
|
|
300
|
+
the continuation frames of a call whose first frame named it (measured
|
|
301
|
+
2026-10-17 on `deepseek-v4.1-flash-free`), and a host that keys a tool result
|
|
302
|
+
by the id — dsh writes `tool/result` records keyed by it — fails its turn on
|
|
303
|
+
an empty one and cannot reload the session afterwards ("tool call id requires
|
|
304
|
+
a nonempty string"). Merging frames must read the id as a name that only
|
|
305
|
+
replaces itself, and a call the route never names has to leave with an
|
|
306
|
+
invented one. Both live in `tool-response.mjs` — the merge's keep, and
|
|
307
|
+
`ensureCallIDs` at the two places a call is published — and `stream.mjs`'s
|
|
308
|
+
collapse reads the same rule.
|
|
309
|
+
|
|
299
310
|
**Trusting one observation.** The gate is not steady: it moved more than once in
|
|
300
311
|
a day while this was being worked out, and identical requests were answered both
|
|
301
312
|
ways minutes apart. Anything measured once is not a fact. Measure it three times,
|
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ bun run check:gate
|
|
|
84
84
|
|
|
85
85
|
请求发往 `https://opencode.ai/inference/v1`,按每个模型原生的 Chat Completions、Responses 或 Anthropic Messages 接口来走。流式和普通 JSON 两种回复都支持。
|
|
86
86
|
|
|
87
|
-
宿主自己的工具会被完整保留。Zen 的模型经常去调 OpenCode 的工具名;这种调用会被对上宿主那个只差大小写的工具(`bash` 对 `Bash`),或者只差大小写和 `_`/`-` 的那个(`todowrite` 对 `todo_write`、`webfetch` 对 `web_fetch
|
|
87
|
+
宿主自己的工具会被完整保留。Zen 的模型经常去调 OpenCode 的工具名;这种调用会被对上宿主那个只差大小写的工具(`bash` 对 `Bash`),或者只差大小写和 `_`/`-` 的那个(`todowrite` 对 `todo_write`、`webfetch` 对 `web_fetch`),前提是恰好只有一个对得上。找不到对应工具(或者调用根本没有名字)的,不放进回复里,回复的正文会说明哪个名字被略去了;如果这一轮所有调用都被略去,这一轮就以一段普通回答结束。上游偶尔还会在工具调用的续帧里发 `"id": null`(实测 2026-10-17),或者整条回复都不给调用 id——插件保证交到宿主手里的调用一律带非空 id(没给的补一个,已有的不会被后来的帧抹掉),因为宿主是靠这个 id 把工具结果对回调用的。工具仍然在宿主里执行。
|
|
88
88
|
|
|
89
89
|
Zen 的免费层读请求体,也读请求头,只有当整个请求都属于 OpenCode 的同一代时才作答:User-Agent、请求头和工具三者必须一致。当前一共有两代,请求带的工具说明是哪一代:`shell` 和 `read` 属于 2.x,`bash`、`glob`、`grep` 和 `read` 属于 1.x。如果宿主本身就是 OpenCode,它自己的 User-Agent 已经说明是哪一代,这个值会被原样传出去,而不是被钉死,所以永远不会落后。没有任何一方说明时,以工具为准,User-Agent 跟着工具走。
|
|
90
90
|
|
|
@@ -142,6 +142,16 @@ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时
|
|
|
142
142
|
|
|
143
143
|
模型的可用性和限流归 Zen 管,插件拿不到剩余额度可显示。同一个地址反复被拒,也可能被放进一个冷却期,表现得和被拒很像。
|
|
144
144
|
|
|
145
|
+
## 鸣谢
|
|
146
|
+
|
|
147
|
+
| 类别 | 项目 |
|
|
148
|
+
| --- | --- |
|
|
149
|
+
| 本插件前身 | [zen-free](https://github.com/magpie-community/plugins/tree/main/packages/zen-free) |
|
|
150
|
+
| 插件载体 | [magpie](https://github.com/yetone/magpie) |
|
|
151
|
+
| 模型提供者 | [anomalyco/opencode](https://github.com/anomalyco/opencode) |
|
|
152
|
+
| 本仓库常用 Agent | [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) · [hermes-agent](https://github.com/NousResearch/hermes-agent) |
|
|
153
|
+
| 灵感来源 | [9router](https://github.com/decolua/9router) · [opencode2api](https://github.com/jasonxu114514/opencode2api) · [zen-gate](https://github.com/LAGcomcom/zen-gate) |
|
|
154
|
+
|
|
145
155
|
## 许可
|
|
146
156
|
|
|
147
157
|
MIT,分叉自 [zen-free](https://github.com/magpie-community/plugins/tree/main/packages/zen-free)。`LICENSE` 的 MIT 正文原样未改,版权行有两条:上游原作者的 `Copyright (c) 2026 xiaozhou26`,和本分支贡献者的 `Copyright (c) 2026 OpenCode-Zen-FreeX contributors`。本分支的来源与维护缘由写在 [NOTICE](https://github.com/tkhs101/OpenCode-Zen-FreeX/blob/main/NOTICE),这样许可证扫描器读到的是一份标准的 MIT 文件,而想知道这个分支为什么存在的人仍然找得到答案。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-zen-freex",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
4
4
|
"description": "OpenCode Zen's free models, for hosts that are not OpenCode: the public credential, automatic discovery and native streaming protocols",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
package/stream.mjs
CHANGED
|
@@ -132,7 +132,13 @@ function chatAccumulator() {
|
|
|
132
132
|
calls.set(toolIndex, { type: "function", function: { name: "", arguments: "" } });
|
|
133
133
|
const call = calls.get(toolIndex);
|
|
134
134
|
for (const key of ["id", "type"]) {
|
|
135
|
-
|
|
135
|
+
// `"id": null` arrives on the continuation frames of a call this
|
|
136
|
+
// route named on the first one (see ensureCallIDs in
|
|
137
|
+
// tool-response.mjs): a fragment may leave the id as it stands,
|
|
138
|
+
// but a null or empty one does not take it away.
|
|
139
|
+
if (fragment[key] === undefined) continue;
|
|
140
|
+
if (key === "id" && (fragment[key] === null || fragment[key] === "")) continue;
|
|
141
|
+
call[key] = fragment[key];
|
|
136
142
|
}
|
|
137
143
|
if (fragment.function) appendFunction(call.function, fragment.function);
|
|
138
144
|
}
|
package/tool-response.mjs
CHANGED
|
@@ -194,6 +194,25 @@ function inPlaceOf(protocol, call, note, at) {
|
|
|
194
194
|
};
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
// A tool call needs an id its host can key the result by, and this route has
|
|
198
|
+
// been measured leaving calls without one: 2026-10-17, through this plugin, a
|
|
199
|
+
// reply's flushed frame carried `{"index":0,"id":null,"name":"bash"}` — the
|
|
200
|
+
// route puts `"id": null` on a continuation frame of a call it named on the
|
|
201
|
+
// first one, and a reply can also leave the call unnamed to the end. OpenAI's
|
|
202
|
+
// shape requires the field, and a host it reaches has nothing to match its
|
|
203
|
+
// result against without it: dsh keys its tool/result records by this id and
|
|
204
|
+
// fails the whole turn — and then the whole session, on reload — when the id
|
|
205
|
+
// is empty ("toolCallId matching its tool source"). So a call that reaches
|
|
206
|
+
// the wire leaves with an id: the last-resort kind, process-unique under the
|
|
207
|
+
// same counter the invented item ids use, so two invented calls cannot share
|
|
208
|
+
// one.
|
|
209
|
+
function ensureCallIDs(calls) {
|
|
210
|
+
for (const call of calls) {
|
|
211
|
+
if (typeof call.id === "string" && call.id !== "") continue;
|
|
212
|
+
call.id = `call_${++synthesized}`;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
197
216
|
// checkSnapshots checks every call in a reply or event: renamed to the
|
|
198
217
|
// agent's tool, or (Responses, Messages) put in place of by a note. It
|
|
199
218
|
// answers whether data changed, how many calls it kept and how many it
|
|
@@ -342,6 +361,7 @@ function chatBuffer(checkName) {
|
|
|
342
361
|
}
|
|
343
362
|
if (state.legacy) delta.function_call = state.legacy;
|
|
344
363
|
const { notes } = checkChatMessage(delta, checkName);
|
|
364
|
+
if (Array.isArray(delta.tool_calls)) ensureCallIDs(delta.tool_calls);
|
|
345
365
|
if (notes.length) {
|
|
346
366
|
delta.tool_calls?.forEach((call, i) => {
|
|
347
367
|
call.index = i;
|
|
@@ -399,6 +419,16 @@ function chatBuffer(checkName) {
|
|
|
399
419
|
const index = position(fragment.index);
|
|
400
420
|
const previous = state.calls.get(index) ?? { index, function: {} };
|
|
401
421
|
const value = { ...previous, ...fragment, index, function: previous.function };
|
|
422
|
+
// A continuation frame may carry `"id": null` — measured on this
|
|
423
|
+
// route — and a spread would let it clobber the id that named the
|
|
424
|
+
// call. A fragment that says nothing about the id leaves the one
|
|
425
|
+
// already there; one that carries a real id still wins, as it did.
|
|
426
|
+
if (
|
|
427
|
+
(typeof value.id !== "string" || value.id === "") &&
|
|
428
|
+
typeof previous.id === "string" &&
|
|
429
|
+
previous.id !== ""
|
|
430
|
+
)
|
|
431
|
+
value.id = previous.id;
|
|
402
432
|
if (fragment.function != null) appendFunction(value.function, fragment.function);
|
|
403
433
|
state.calls.set(index, value);
|
|
404
434
|
}
|
|
@@ -1016,6 +1046,12 @@ export async function guardToolResponse(response, protocol, policy, options = {}
|
|
|
1016
1046
|
try {
|
|
1017
1047
|
value = parseJSON(await readBounded(response));
|
|
1018
1048
|
checkSnapshots(value, protocol, checkName);
|
|
1049
|
+
if (protocol === "chat") {
|
|
1050
|
+
for (const choice of list(value.choices)) {
|
|
1051
|
+
const message = object(choice) ? choice.message : undefined;
|
|
1052
|
+
if (object(message)) ensureCallIDs(list(message.tool_calls));
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1019
1055
|
} catch (error) {
|
|
1020
1056
|
if (!(error instanceof GuardError)) throw error;
|
|
1021
1057
|
status = 502;
|