opencode-zen-freex 0.2.10 → 0.2.12
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 +3 -2
- package/NOTICE +3 -1
- package/README.md +4 -2
- package/core-tools.mjs +78 -13
- package/index.mjs +7 -3
- package/limits.mjs +44 -21
- package/models.mjs +31 -5
- package/package.json +1 -1
- package/provider.mjs +91 -23
- package/stream.mjs +61 -14
- package/tool-response.mjs +85 -25
package/MAINTAINING.md
CHANGED
|
@@ -354,8 +354,9 @@ What ships is `package.json`'s `files` list, and it is deliberately narrower tha
|
|
|
354
354
|
the repository: the three maintenance scripts (`capture.mjs`, `tools-smoke.mjs`,
|
|
355
355
|
`limits-audit.mjs`) stay here, because a host never loads them and one of them
|
|
356
356
|
rewrites the shipped `fingerprint.json` if it is ever imported. `LICENSE` is
|
|
357
|
-
|
|
358
|
-
|
|
357
|
+
MIT with its text unmodified and both copyright lines in place — upstream's and
|
|
358
|
+
this fork's — so what a licence scanner reads is a standard MIT file; the fork's
|
|
359
|
+
provenance is in `NOTICE`, which is where a reader finds why this fork exists.
|
|
359
360
|
|
|
360
361
|
**The second factor is a browser, not a password.** Run `npm publish` in a
|
|
361
362
|
terminal. When the account has 2FA on, the command stops and offers the whole
|
package/NOTICE
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
This package is a maintained fork of the OpenCode Zen Free plugin from
|
|
4
4
|
[magpie-community/plugins](https://github.com/magpie-community/plugins), which
|
|
5
5
|
is MIT licensed. Upstream's notice, `Copyright (c) 2026 xiaozhou26`, is kept in
|
|
6
|
-
[LICENSE](./LICENSE) alongside this fork's own
|
|
6
|
+
[LICENSE](./LICENSE) alongside this fork's own — the second copyright line
|
|
7
|
+
there, `Copyright (c) 2026 OpenCode-Zen-FreeX contributors`. The license text
|
|
8
|
+
itself is unmodified MIT.
|
|
7
9
|
|
|
8
10
|
The fork is maintained separately from upstream under the name
|
|
9
11
|
`opencode-zen-freex` (https://github.com/tkhs101/OpenCode-Zen-FreeX) because the
|
package/README.md
CHANGED
|
@@ -125,6 +125,8 @@ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时
|
|
|
125
125
|
|
|
126
126
|
偶尔 Zen 会在发出 `[DONE]` 之前就结束一个流式的 Chat Completions 回复。如果回复已经说了内容、且其中没有未完成的工具调用,就以普通回答结束,正文里会说明它被截断了;再问一次就会接着往下走。如果是带着未完成的工具调用被截断、或者什么都没说就断了、或者被折叠给一个不支持流式的客户端,请求就会失败,错误信息里写明这个回复的 id 和收到了多少。走 Anthropic Messages 或 Responses 的回复被截断同样会失败:magpie 能把它和完整的回复区分开,并且在该模型支持的情况下继续往下走。
|
|
127
127
|
|
|
128
|
+
失败回复的正文会被读一小段,用来找 Zen 自己说出的上下文窗口或输出上限(见下)。因此透传给你的错误正文有两个规矩:读完的按原样给,只去掉描述旧字节的长度、编码与校验头;**没读完的不会冒充完整正文**——状态码仍是上游的,正文换成一段说明(写明上游的状态、读到了多少),并把读到的开头放在 `upstream` 字段里。不这么做的话,超长的错误正文会被截断成一段解析不了的 JSON,看起来像是解析出错而不是 Zen 说了什么。
|
|
129
|
+
|
|
128
130
|
## 排查
|
|
129
131
|
|
|
130
132
|
| 看到的现象 | 这是什么 | 怎么办 |
|
|
@@ -134,7 +136,7 @@ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时
|
|
|
134
136
|
| `404 … not available as an OpenCode Zen free model now` | Zen 不再免费开放这个模型 | 从错误信息列出的模型里换一个 |
|
|
135
137
|
| `Model is not an available free model for this protocol` | 请求发来时列表变了 | 让宿主刷新模型列表再试 |
|
|
136
138
|
| `uses the public credential` | 存的密钥不是 `public` | 用 `public` 重新登录 |
|
|
137
|
-
| 不带 `FreeTierError` 的 403 | 是别的 403
|
|
139
|
+
| 不带 `FreeTierError` 的 403 | 是别的 403:正文原样透传,只去掉描述旧字节的头(`content-length`、`content-encoding`、`transfer-encoding`、`content-md5`、`etag`、`last-modified`) | 按 Zen 自己的错误来读,别当成这个插件的 |
|
|
138
140
|
|
|
139
141
|
这个插件给出的 403 会写明发出去的措辞来自哪个 OpenCode 版本。那正是 `check:gate` 在问的那份指纹,所以从它开始查,而不是从请求头开始查。
|
|
140
142
|
|
|
@@ -142,4 +144,4 @@ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时
|
|
|
142
144
|
|
|
143
145
|
## 许可
|
|
144
146
|
|
|
145
|
-
MIT,分叉自 [zen-free](https://github.com/magpie-community/plugins/tree/main/packages/zen-free)
|
|
147
|
+
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/core-tools.mjs
CHANGED
|
@@ -22,19 +22,38 @@ import { fileURLToPath } from "node:url";
|
|
|
22
22
|
|
|
23
23
|
const CAPTURE = new URL("./fingerprint.json", import.meta.url);
|
|
24
24
|
|
|
25
|
+
// The one remedy every failure below points at, written once: only a capture
|
|
26
|
+
// writes this file, so a message that names a command instead of the file to fix
|
|
27
|
+
// would have to be edited in three places the day that command is renamed.
|
|
28
|
+
const REMEDY = "run `bun run capture` to write a new one";
|
|
29
|
+
|
|
30
|
+
// Every failure in this module is the same shape — what could not be read or
|
|
31
|
+
// used, then the remedy — so the wording lives in one place and each throw names
|
|
32
|
+
// only its own half.
|
|
33
|
+
function captureError(what) {
|
|
34
|
+
return `core-tools: ${what} — ${REMEDY}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
// Read once, here, because every answer in this module comes out of it. Guarded
|
|
26
38
|
// because the failure this replaces is a bare ENOENT or SyntaxError thrown out of
|
|
27
39
|
// whichever module imported this one, which reads as *that* module being broken
|
|
28
|
-
// and never names the file to fix.
|
|
29
|
-
// the
|
|
40
|
+
// and never names the file to fix. The remedy is the same whatever went wrong,
|
|
41
|
+
// but the diagnosis is not: an unreadable file (EACCES, EISDIR, a directory
|
|
42
|
+
// where a file should be) is neither missing nor invalid JSON, and saying it is
|
|
43
|
+
// sends the reader to a command that cannot fix a permission problem.
|
|
30
44
|
function readProfiles() {
|
|
31
45
|
let captured;
|
|
32
46
|
try {
|
|
33
47
|
captured = JSON.parse(readFileSync(CAPTURE, "utf8"));
|
|
34
48
|
} catch (error) {
|
|
49
|
+
const reason =
|
|
50
|
+
error.code === "ENOENT"
|
|
51
|
+
? "missing"
|
|
52
|
+
: error instanceof SyntaxError
|
|
53
|
+
? "invalid JSON"
|
|
54
|
+
: `could not be read (${error.code ?? error.message})`;
|
|
35
55
|
throw new Error(
|
|
36
|
-
`
|
|
37
|
-
"missing or invalid JSON — run `bun run capture` to write a new one",
|
|
56
|
+
captureError(`the capture at ${fileURLToPath(CAPTURE)} is ${reason}`),
|
|
38
57
|
{ cause: error },
|
|
39
58
|
);
|
|
40
59
|
}
|
|
@@ -51,8 +70,8 @@ function readProfiles() {
|
|
|
51
70
|
!Object.keys(profiles).length
|
|
52
71
|
) {
|
|
53
72
|
throw new Error(
|
|
54
|
-
`
|
|
55
|
-
|
|
73
|
+
captureError(`the capture at ${fileURLToPath(CAPTURE)} has no profiles`),
|
|
74
|
+
{ cause: captured },
|
|
56
75
|
);
|
|
57
76
|
}
|
|
58
77
|
return profiles;
|
|
@@ -84,17 +103,40 @@ export function generationOf(tools) {
|
|
|
84
103
|
// asked for and what the capture holds. Ownership rather than truthiness: a
|
|
85
104
|
// plain object answers `constructor` and `toString` out of Object.prototype, so
|
|
86
105
|
// `PROFILES[generation]` alone would hand back a function and then fail on its
|
|
87
|
-
// `.agent` with a TypeError that names nothing.
|
|
88
|
-
|
|
106
|
+
// `.agent` with a TypeError that names nothing. Ownership of the key is half of
|
|
107
|
+
// it — a capture holding `{"1.x": null}` owns the key and still has no profile,
|
|
108
|
+
// and the `.agent` of that is the same unnamed TypeError.
|
|
109
|
+
//
|
|
110
|
+
// Exported, because the same two mistakes are reachable from outside this file
|
|
111
|
+
// and are made there: `provider.mjs` indexes `PROFILES[...]` directly to read a
|
|
112
|
+
// profile's `tools`, and a capture missing that generation then fails with
|
|
113
|
+
// "Cannot read properties of undefined" instead of the sentence below.
|
|
114
|
+
export function profileFor(generation) {
|
|
89
115
|
if (!Object.hasOwn(PROFILES, generation)) {
|
|
90
116
|
throw new Error(
|
|
91
117
|
`core-tools: no profile for generation ${JSON.stringify(generation)}; ` +
|
|
92
118
|
`the capture holds ${Object.keys(PROFILES).join(", ")}`,
|
|
93
119
|
);
|
|
94
120
|
}
|
|
95
|
-
|
|
121
|
+
const profile = PROFILES[generation];
|
|
122
|
+
if (typeof profile !== "object" || profile === null || Array.isArray(profile)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
captureError(
|
|
125
|
+
`the ${JSON.stringify(generation)} profile in the capture is not an object`,
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return profile;
|
|
96
130
|
}
|
|
97
131
|
|
|
132
|
+
// A field interpolated into the User-Agent has to be a string with something in
|
|
133
|
+
// it: each one is read by Zen, and a missing or mistyped one used to compose
|
|
134
|
+
// `opencode/undefined/1.19.0/undefined` — a header that gets a refusal saying
|
|
135
|
+
// nothing about the capture it came out of. `Object.hasOwn` alone would only
|
|
136
|
+
// prove the key is there: `{"chat": null}` and `{"chat": ""}` both compose a
|
|
137
|
+
// version of `null`.
|
|
138
|
+
const filledIn = (value) => typeof value === "string" && value !== "";
|
|
139
|
+
|
|
98
140
|
/**
|
|
99
141
|
* The User-Agent that generation of OpenCode sends, for a protocol. Each
|
|
100
142
|
* protocol's ai-sdk package bundles its own provider-utils, so 1.x differs by
|
|
@@ -102,15 +144,30 @@ function profileFor(generation) {
|
|
|
102
144
|
*/
|
|
103
145
|
export function agentFor(generation, protocol = "chat") {
|
|
104
146
|
const agent = profileFor(generation).agent;
|
|
105
|
-
if (typeof agent !== "object" || agent === null) {
|
|
147
|
+
if (typeof agent !== "object" || agent === null || Array.isArray(agent)) {
|
|
106
148
|
throw new Error(
|
|
107
|
-
`
|
|
108
|
-
"run `bun run capture` to write a new one",
|
|
149
|
+
captureError(`the ${JSON.stringify(generation)} profile in the capture has no agent`),
|
|
109
150
|
);
|
|
110
151
|
}
|
|
111
152
|
// The release shape carries no provider-utils at all, and is composed the same
|
|
112
153
|
// way whatever protocol was asked for.
|
|
113
|
-
if (!agent.providerUtils)
|
|
154
|
+
if (!agent.providerUtils) {
|
|
155
|
+
if (![agent.channel, agent.opencode, agent.client].every(filledIn)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
captureError(
|
|
158
|
+
`the ${JSON.stringify(generation)} profile in the capture is not a release agent`,
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return `opencode/${agent.channel}/${agent.opencode}/${agent.client}`;
|
|
163
|
+
}
|
|
164
|
+
if (typeof agent.providerUtils !== "object" || agent.providerUtils === null) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
captureError(
|
|
167
|
+
`the ${JSON.stringify(generation)} profile in the capture has no per-protocol provider-utils`,
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
114
171
|
// `?? "undefined"` used to be interpolated here for a protocol the capture has
|
|
115
172
|
// no provider-utils for, and a prototype member (`toString`) as the version.
|
|
116
173
|
// Zen reads this header, so a malformed one is rejected upstream with an error
|
|
@@ -122,5 +179,13 @@ export function agentFor(generation, protocol = "chat") {
|
|
|
122
179
|
`it holds ${Object.keys(agent.providerUtils).join(", ")}`,
|
|
123
180
|
);
|
|
124
181
|
}
|
|
182
|
+
if (!filledIn(agent.providerUtils[protocol]) || !filledIn(agent.opencode) || !filledIn(agent.runtime)) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
captureError(
|
|
185
|
+
`the ${JSON.stringify(generation)} profile in the capture does not fill in every field of ` +
|
|
186
|
+
`its ${JSON.stringify(protocol)} User-Agent`,
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
125
190
|
return `opencode/${agent.opencode} ai-sdk/provider-utils/${agent.providerUtils[protocol]} ${agent.runtime}`;
|
|
126
191
|
}
|
package/index.mjs
CHANGED
|
@@ -11,9 +11,13 @@ import plugin from "./provider.mjs"
|
|
|
11
11
|
//
|
|
12
12
|
// The named export stays for anything that already imported it by name, and
|
|
13
13
|
// `server` is offered as a name too, so `mod.default.server` and `mod.server`
|
|
14
|
-
// both resolve.
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// both resolve. Both halves of what the object carries are checked here, the
|
|
15
|
+
// `id` as well as the factory: a provider.mjs that lost either one would
|
|
16
|
+
// otherwise load cleanly and hand the host a plugin it cannot key or start.
|
|
17
|
+
if (typeof plugin?.server !== "function" || typeof plugin?.id !== "string" || !plugin.id) {
|
|
18
|
+
throw new TypeError(
|
|
19
|
+
"provider.mjs must default-export a plugin with a `server` factory and an `id`",
|
|
20
|
+
)
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export const ZenFreePlugin = plugin.server
|
package/limits.mjs
CHANGED
|
@@ -109,7 +109,7 @@ export const LIMITS = {
|
|
|
109
109
|
// says nothing attributable and is left alone; §7 of the measured notes has a
|
|
110
110
|
// context overflow and an invalid parameter answering with byte-identical 400s
|
|
111
111
|
// on another Zen model.
|
|
112
|
-
const NAMED_CONTEXT = /maximum context length is (\d+) tokens/
|
|
112
|
+
const NAMED_CONTEXT = /maximum context length is (\d+) tokens/id;
|
|
113
113
|
|
|
114
114
|
// "max_tokens is too large: 200000. This model supports at most 131072 completion
|
|
115
115
|
// tokens, whereas you provided 200000." — an upstream that validates the ceiling
|
|
@@ -119,10 +119,10 @@ const NAMED_CONTEXT = /maximum context length is (\d+) tokens/i;
|
|
|
119
119
|
// answers 200 there, so silence from this one says nothing about a model, and a
|
|
120
120
|
// request that is served is not evidence of a ceiling either way.
|
|
121
121
|
const NAMED_OUTPUT = [
|
|
122
|
-
/supports at most (\d+) completion tokens/
|
|
122
|
+
/supports at most (\d+) completion tokens/id,
|
|
123
123
|
// "/max_tokens: 1000000 is not less or equal to 262144" — LongCat's wording for
|
|
124
124
|
// the same fact, where the ceiling is the number it has to stay under.
|
|
125
|
-
/max_tokens:?\s*\d+\s+is not less or equal to\s+(\d+)/
|
|
125
|
+
/max_tokens:?\s*\d+\s+is not less or equal to\s+(\d+)/id,
|
|
126
126
|
];
|
|
127
127
|
|
|
128
128
|
// Names a refusal made while this process is running, by model id. Not
|
|
@@ -318,6 +318,14 @@ export async function readBoundedText(response, limit = 4096, { signal } = {}) {
|
|
|
318
318
|
// the stream is still going, and a number sliced there (`262144` read as
|
|
319
319
|
// `2621`) must not be taken for a limit the route stated.
|
|
320
320
|
//
|
|
321
|
+
// `ended` is the read's own answer to "is this the whole body?": true when the
|
|
322
|
+
// body was read to its end, false when the budget, the deadline or a failed
|
|
323
|
+
// read stopped it short — a read that got nothing at all included, which is the
|
|
324
|
+
// same answer for a different reason. A caller that hands these bytes on has to
|
|
325
|
+
// know which of the two it holds: a prefix presented as the whole reply is a
|
|
326
|
+
// lie the caller cannot see, and the bytes cannot be gone back for, since this
|
|
327
|
+
// read is the body's one reader.
|
|
328
|
+
//
|
|
321
329
|
// Returns undefined only when nothing was read — a response that is not a
|
|
322
330
|
// refusal, or one with no body to read — and otherwise whichever axes the text
|
|
323
331
|
// named, with that text alongside them. An axis the body did not name is
|
|
@@ -327,37 +335,47 @@ export async function observeRouteStatement(id, response, limit = 4096, { signal
|
|
|
327
335
|
try {
|
|
328
336
|
if (typeof id !== "string" || !id || response?.ok !== false) return undefined;
|
|
329
337
|
// A response with no body was not read and is not consumed: the caller
|
|
330
|
-
// still holds the one it had, and undefined says so.
|
|
331
|
-
|
|
332
|
-
|
|
338
|
+
// still holds the one it had, and undefined says so. Written out rather
|
|
339
|
+
// than left to a loose comparison, which the project's rules do not allow
|
|
340
|
+
// and which would also answer for a body that is not one of these two.
|
|
341
|
+
if (response.body === null || response.body === undefined) return undefined;
|
|
342
|
+
const { text, over, ended } = await readBoundedText(response, limit, { signal });
|
|
333
343
|
const { context, output } = namedAxes(text, limit, over);
|
|
334
344
|
if (context !== undefined) OBSERVED.set(id, context);
|
|
335
345
|
if (output !== undefined) OBSERVED_OUTPUT.set(id, output);
|
|
336
|
-
return { context, output, text };
|
|
346
|
+
return { context, output, text, ended };
|
|
337
347
|
} catch {
|
|
338
348
|
return undefined;
|
|
339
349
|
}
|
|
340
350
|
}
|
|
341
351
|
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
352
|
+
// Where the number a match named ends, in the text it was matched against. The
|
|
353
|
+
// `d` flag is what makes this exact: `NAMED_CONTEXT` and the first
|
|
354
|
+
// `NAMED_OUTPUT` run on past the digits (" completion tokens"), so judging the
|
|
355
|
+
// cut by the match's own end threw away numbers that were provably whole — the
|
|
356
|
+
// digits ended well before the read did, with text after them that the greedy
|
|
357
|
+
// `(\d+)` had refused to consume.
|
|
358
|
+
function digitsEnd(found) {
|
|
359
|
+
const at = found?.match?.indices?.[1]?.[1];
|
|
360
|
+
// The match's own end is the conservative answer when the pattern cannot say
|
|
361
|
+
// where the digits are: it drops a whole number rather than reading a cut one,
|
|
362
|
+
// which is the direction this guard has to fail in.
|
|
363
|
+
return Number.isSafeInteger(at) ? at : found.match.index + found.match[0].length;
|
|
364
|
+
}
|
|
349
365
|
|
|
350
366
|
// The axes the first `limit` characters of `text` name. `cut` says the read
|
|
351
|
-
// stopped
|
|
352
|
-
// may have
|
|
353
|
-
// digit short.
|
|
354
|
-
//
|
|
367
|
+
// stopped before the body did, so a number whose own digits reach the last
|
|
368
|
+
// character read may have had digits past it: that axis is left unread rather
|
|
369
|
+
// than reported a digit short. Any character after the digits inside the head
|
|
370
|
+
// proves the number ended, since a greedy `\d+` would have taken it — only
|
|
371
|
+
// digits touching the boundary are ambiguous. Each axis is read from the match
|
|
372
|
+
// its number came from, so the match the guard judges and the number that is
|
|
373
|
+
// returned cannot be two answers.
|
|
355
374
|
function namedAxes(text, limit, cut) {
|
|
356
375
|
const head = text.slice(0, limit);
|
|
357
376
|
const context = namedFrom([NAMED_CONTEXT], head);
|
|
358
377
|
const output = namedFrom(NAMED_OUTPUT, head);
|
|
359
|
-
const onCut = (found) =>
|
|
360
|
-
cut && Boolean(found) && found.match.index + found.match[0].length >= head.length - CUT_MARGIN;
|
|
378
|
+
const onCut = (found) => cut && found !== undefined && digitsEnd(found) >= head.length;
|
|
361
379
|
return {
|
|
362
380
|
context: onCut(context) ? undefined : context?.value,
|
|
363
381
|
output: onCut(output) ? undefined : output?.value,
|
|
@@ -384,7 +402,12 @@ export function limitOverrides(
|
|
|
384
402
|
// declared, from a table that never named it.
|
|
385
403
|
const record = table && Object.hasOwn(table, id) ? table[id] : undefined;
|
|
386
404
|
for (const axis of ["context", "output"]) {
|
|
387
|
-
|
|
405
|
+
// Own property, like the table lookup two lines up: `declared` is handed in
|
|
406
|
+
// by a caller, and a `context` inherited from a custom prototype — or from a
|
|
407
|
+
// polluted `Object.prototype` — would otherwise be read as a declaration the
|
|
408
|
+
// catalogue never made, feeding the staleness compare and the raise-only
|
|
409
|
+
// check with a number nobody declared.
|
|
410
|
+
const declaredValue = declared && Object.hasOwn(declared, axis) ? declared[axis] : undefined;
|
|
388
411
|
const entry = record?.[axis];
|
|
389
412
|
const live = (axis === "context" ? observed : observedOutput)?.get(id);
|
|
390
413
|
// A route statement made while this process was running is the same tier as
|
package/models.mjs
CHANGED
|
@@ -33,7 +33,9 @@ function sdkProtocol(npm) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// Set, and not merely present: the catalogue writes both a missing date and an
|
|
36
|
-
// explicit null, and neither retires a model.
|
|
36
|
+
// explicit null, and neither retires a model. A blank string is not a date
|
|
37
|
+
// either — see `isSet` below, which is the same rule stated for the two axes
|
|
38
|
+
// the retirement checks read.
|
|
37
39
|
const hasValue = (value) => value !== null && value !== undefined;
|
|
38
40
|
|
|
39
41
|
// A status has to say something to be one. The catalogue writes an empty string
|
|
@@ -48,8 +50,11 @@ function isRetired(model) {
|
|
|
48
50
|
["deprecated", "retired", "disabled"].includes(
|
|
49
51
|
isSet(model.status) ? model.status : model.lifecycle,
|
|
50
52
|
) ||
|
|
51
|
-
hasValue
|
|
52
|
-
|
|
53
|
+
// Read through `isSet`, not `hasValue`: a blank `deprecated_at` is the empty
|
|
54
|
+
// string the catalogue writes for a date it does not have, and reading it as
|
|
55
|
+
// a date retired a model that was still on offer.
|
|
56
|
+
isSet(model.deprecated_at) ||
|
|
57
|
+
isSet(model.retirement_date)
|
|
53
58
|
);
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -67,8 +72,13 @@ export function isFree(id, model = {}) {
|
|
|
67
72
|
// model is offered the hour Zen lists it rather than the hour the catalogue
|
|
68
73
|
// catches up. An id that does not call itself free still needs the catalogue's
|
|
69
74
|
// word (a paid one, or a zero-cost one Zen never named).
|
|
75
|
+
//
|
|
76
|
+
// The same question `isFree` asks, and answered the same way: case-insensitive.
|
|
77
|
+
// Asked case-sensitively, an id like `new-model-FREE` passed the free gate above
|
|
78
|
+
// and then found no protocol here, so it was dropped at the `SDK` lookup below —
|
|
79
|
+
// silently, which is the one outcome the fallback exists to prevent.
|
|
70
80
|
function listedFree(id) {
|
|
71
|
-
return /-free
|
|
81
|
+
return /-free$/i.test(id);
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
// Which protocol a model is asked at, in the order the answer is trusted: the
|
|
@@ -99,6 +109,22 @@ const modalities = (values) =>
|
|
|
99
109
|
]),
|
|
100
110
|
);
|
|
101
111
|
|
|
112
|
+
// A window is a positive whole number of tokens. The catalogue writes an
|
|
113
|
+
// explicit `null` — and an empty string, and a zero — for an axis it does not
|
|
114
|
+
// have, and every one of those used to be copied over the 32768/4096 defaults by
|
|
115
|
+
// the spread below, so the host was handed `context: null` as the model's window.
|
|
116
|
+
const usable = (value) => Number.isSafeInteger(value) && value > 0;
|
|
117
|
+
|
|
118
|
+
// The limit the model is offered with, from the axes that are usable: the
|
|
119
|
+
// defaults first, then the catalogue's own numbers, then what the plugin has
|
|
120
|
+
// evidence for — each level only contributing axes it can actually state.
|
|
121
|
+
function limitFor(id, declared) {
|
|
122
|
+
const limit = { context: 32768, output: 4096 };
|
|
123
|
+
for (const [axis, value] of Object.entries(declared ?? {})) if (usable(value)) limit[axis] = value;
|
|
124
|
+
for (const [axis, value] of Object.entries(limitOverrides(id, declared))) limit[axis] = value;
|
|
125
|
+
return limit;
|
|
126
|
+
}
|
|
127
|
+
|
|
102
128
|
export function buildModels(ids, provider, protocols, baseURL) {
|
|
103
129
|
const result = {};
|
|
104
130
|
for (const id of ids) {
|
|
@@ -128,7 +154,7 @@ export function buildModels(ids, provider, protocols, baseURL) {
|
|
|
128
154
|
providerID: PROVIDER,
|
|
129
155
|
name: model.name ?? id,
|
|
130
156
|
api: { id, url: baseURL, npm: SDK[protocol] },
|
|
131
|
-
limit:
|
|
157
|
+
limit: limitFor(id, model.limit),
|
|
132
158
|
capabilities: {
|
|
133
159
|
temperature: model.temperature ?? true,
|
|
134
160
|
reasoning: model.reasoning ?? false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-zen-freex",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { agentFor, generationOf, profileFor } from "./core-tools.mjs";
|
|
3
3
|
import {
|
|
4
4
|
PROVIDER,
|
|
5
5
|
SDK,
|
|
@@ -114,6 +114,66 @@ function rewrittenHeaders(response) {
|
|
|
114
114
|
return headers;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// The headers a body this package read once and writes again unchanged is
|
|
118
|
+
// described by. The staleness rule is the same one `rewrittenHeaders` applies —
|
|
119
|
+
// a length, an encoding and the validators all describe the bytes that arrived,
|
|
120
|
+
// not the ones going out — but the content type is left as the upstream sent
|
|
121
|
+
// it: a refusal relayed from a gateway may be HTML, and calling it JSON here
|
|
122
|
+
// would be this package's word rather than the upstream's.
|
|
123
|
+
function relayedHeaders(response) {
|
|
124
|
+
const headers = new Headers(response.headers);
|
|
125
|
+
for (const name of STALE_BODY_HEADERS) headers.delete(name);
|
|
126
|
+
return headers;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// What was read of a refusal is a prefix, not necessarily the refusal. Handing
|
|
130
|
+
// that prefix on as the whole body is a lie the caller cannot see: a host that
|
|
131
|
+
// parses it reports a JSON error of its own instead of Zen's, which is the one
|
|
132
|
+
// message this path exists to keep readable. So a read that did not reach the
|
|
133
|
+
// end of the body — one that got nothing at all included — is answered with a
|
|
134
|
+
// refusal that says what happened, carries the opening that was read under
|
|
135
|
+
// `upstream`, and keeps the status the upstream sent.
|
|
136
|
+
const KEPT_PREFIX = 512;
|
|
137
|
+
|
|
138
|
+
function relayedRefusal(response, observed) {
|
|
139
|
+
const read = typeof observed.text === "string" ? observed.text : "";
|
|
140
|
+
const why = read
|
|
141
|
+
? `its body ran past the ${read.length} characters this plugin reads of a refusal, so only the opening of it came back`
|
|
142
|
+
: "its body could not be read at all";
|
|
143
|
+
return new Response(
|
|
144
|
+
JSON.stringify({
|
|
145
|
+
type: "error",
|
|
146
|
+
error: {
|
|
147
|
+
type: "upstream_error",
|
|
148
|
+
message:
|
|
149
|
+
`Upstream answered HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}, and ${why}.`,
|
|
150
|
+
upstream: read.slice(0, KEPT_PREFIX) + (read.length > KEPT_PREFIX ? "…" : ""),
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
{
|
|
154
|
+
status: response.status,
|
|
155
|
+
statusText: response.statusText,
|
|
156
|
+
headers: rewrittenHeaders(response),
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The response this path hands on, from what the limits reader made of the one
|
|
162
|
+
// that arrived: the same response when it never read it — the reader touches no
|
|
163
|
+
// 2xx body, so that is the common branch — the bytes it read under headers
|
|
164
|
+
// corrected for them when it read the whole body, and a refusal that says so
|
|
165
|
+
// when it stopped short. One decision, written once, so the three outcomes
|
|
166
|
+
// cannot be read as a chain of ternaries at the call site.
|
|
167
|
+
function relayed(response, observed) {
|
|
168
|
+
if (observed === undefined) return response;
|
|
169
|
+
if (!observed.ended) return relayedRefusal(response, observed);
|
|
170
|
+
return new Response(observed.text, {
|
|
171
|
+
status: response.status,
|
|
172
|
+
statusText: response.statusText,
|
|
173
|
+
headers: relayedHeaders(response),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
117
177
|
// How long a request for a model the last list didn't have waits before it
|
|
118
178
|
// asks Zen's list again: a model Zen made free since is served at once,
|
|
119
179
|
// and a model typed in by hand doesn't ask the list on every request.
|
|
@@ -166,20 +226,23 @@ function normalizeTools(body, protocol) {
|
|
|
166
226
|
const declared = new Set([...present].filter((value) => typeof value === "string"));
|
|
167
227
|
// The legacy top-level `functions` array is the older way of declaring the
|
|
168
228
|
// same tools, and one a chat host may still use. Its names are declarations,
|
|
169
|
-
// so they count as declared
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
229
|
+
// so they count as declared, and the fill loop below finds them by name: a
|
|
230
|
+
// name that only arrived through `functions` has no index in `body.tools`, so
|
|
231
|
+
// it is looked up in the array itself and reworded where it sits rather than
|
|
232
|
+
// being stood in for. Without that the same tool would go out twice, which is
|
|
233
|
+
// what the note on `nameOf` says must not happen.
|
|
174
234
|
const legacy = Array.isArray(body.functions) && protocol === "chat" ? body.functions : [];
|
|
175
235
|
for (const tool of legacy) if (typeof tool?.name === "string") declared.add(tool.name);
|
|
176
|
-
const declaredElsewhere = new Set(legacy.map((tool) => tool?.name));
|
|
177
236
|
// Zen takes a request for one generation of OpenCode or the other, and the tools
|
|
178
237
|
// it carries say which: `shell` is 2.x, `bash` is 1.x. Names move far less
|
|
179
238
|
// often than descriptions do, so this survives a rewording that a pinned
|
|
180
239
|
// User-Agent would not.
|
|
181
240
|
const generation = generationOf(body.tools) ?? DEFAULT_GENERATION;
|
|
182
|
-
|
|
241
|
+
// Through `profileFor`, not `PROFILES[generation]`: a capture that holds one
|
|
242
|
+
// generation leaves the other key absent, and the direct read fails below on
|
|
243
|
+
// `profile.tools` with a TypeError that names nothing — the capture is what
|
|
244
|
+
// has to change, and it is the one thing that error does not say.
|
|
245
|
+
const profile = profileFor(generation);
|
|
183
246
|
const filled = [];
|
|
184
247
|
const at = new Map();
|
|
185
248
|
body.tools.forEach((tool, index) => {
|
|
@@ -208,8 +271,17 @@ function normalizeTools(body, protocol) {
|
|
|
208
271
|
// tightened check would otherwise find missing.
|
|
209
272
|
if (!profile.required.includes(name)) continue;
|
|
210
273
|
// Declared through `functions` rather than `tools`: this host does have the
|
|
211
|
-
// tool, so it is not one to stand in for.
|
|
212
|
-
|
|
274
|
+
// tool, so it is not one to stand in for. Its wording is still the host's,
|
|
275
|
+
// though, and Zen reads the wording of a tool it knows by name whichever
|
|
276
|
+
// array carried it — leaving that one entry as the host wrote it is the
|
|
277
|
+
// refusal this loop exists to prevent, and it is the entry `explain()`
|
|
278
|
+
// would then describe wrongly. The legacy shape carries `description` at
|
|
279
|
+
// the top level, which `setDescription` already handles.
|
|
280
|
+
const throughFunctions = legacy.find((tool) => tool?.name === name);
|
|
281
|
+
if (throughFunctions) {
|
|
282
|
+
setDescription(throughFunctions, spoken.description);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
213
285
|
filled.push(name);
|
|
214
286
|
// A tool added here has nothing behind it: the agent has no such tool and
|
|
215
287
|
// a call to it is dropped. Describing it as OpenCode describes it is what
|
|
@@ -264,7 +336,10 @@ function explain(text, source, toolPolicy) {
|
|
|
264
336
|
// A 403 that is not Zen's free tier is somebody else's; it is left as sent —
|
|
265
337
|
// its bytes are what arrived, and so are the headers describing them.
|
|
266
338
|
if (source.status === 403 && !text.includes("FreeTierError")) return source;
|
|
267
|
-
|
|
339
|
+
// The same lookup `normalizeTools` made, through the same owner: the refusal
|
|
340
|
+
// this message explains is about the generation that request was sent as, and
|
|
341
|
+
// a capture that lost that profile has to say so rather than fail on `.tools`.
|
|
342
|
+
const profile = profileFor(toolPolicy.generation);
|
|
268
343
|
const names = Object.keys(profile.tools).join(", ");
|
|
269
344
|
const { filled } = toolPolicy;
|
|
270
345
|
// 426 is not a refusal of the wording: it is this plugin presenting itself as
|
|
@@ -544,23 +619,16 @@ async function server(_input, options = {}) {
|
|
|
544
619
|
// quietly never used.
|
|
545
620
|
//
|
|
546
621
|
// A response body can only be read once, so what the limits reader
|
|
547
|
-
// took is handed on as the response the rest of the path works with
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
// A response the reader did not consume is used as it arrived.
|
|
622
|
+
// took is handed on as the response the rest of the path works with
|
|
623
|
+
// (see `relayed`): never the upstream's own headers over bytes this
|
|
624
|
+
// package wrote again, and never a prefix presented as the whole
|
|
625
|
+
// reply. A response the reader did not consume is used as it arrived.
|
|
551
626
|
const observed = await observeRouteStatement(body.model, response, undefined, {
|
|
552
627
|
// The caller's own cancellation reaches this read as well: a host
|
|
553
628
|
// that has given up is not waiting for a number out of a refusal.
|
|
554
629
|
signal: request.signal,
|
|
555
630
|
});
|
|
556
|
-
const answered =
|
|
557
|
-
observed === undefined
|
|
558
|
-
? response
|
|
559
|
-
: new Response(observed.text, {
|
|
560
|
-
status: response.status,
|
|
561
|
-
statusText: response.statusText,
|
|
562
|
-
headers: response.headers,
|
|
563
|
-
});
|
|
631
|
+
const answered = relayed(response, observed);
|
|
564
632
|
const guarded = await guardToolResponse(
|
|
565
633
|
explain(observed?.text ?? null, answered, toolPolicy),
|
|
566
634
|
protocol,
|
package/stream.mjs
CHANGED
|
@@ -143,10 +143,24 @@ function chatAccumulator() {
|
|
|
143
143
|
choice.finish_reason = part.finish_reason;
|
|
144
144
|
if (part.logprobs) {
|
|
145
145
|
choice.logprobs ??= {};
|
|
146
|
+
// Written as a defined own property, and read back as one. A
|
|
147
|
+
// "__proto__" key arrives as an own property of the parsed object,
|
|
148
|
+
// and `choice.logprobs[key] = value` would take the inherited
|
|
149
|
+
// accessor instead: an object value silently re-parented the
|
|
150
|
+
// accumulated object and the key never reached the reply, while an
|
|
151
|
+
// array value read Object.prototype back and spread it, throwing
|
|
152
|
+
// "not iterable" — a 502 on an otherwise valid stream. The delta
|
|
153
|
+
// written by tool-response.mjs follows the same rule.
|
|
146
154
|
for (const [key, value] of Object.entries(part.logprobs)) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
155
|
+
const held = Object.hasOwn(choice.logprobs, key) ? choice.logprobs[key] : undefined;
|
|
156
|
+
Object.defineProperty(choice.logprobs, key, {
|
|
157
|
+
value: Array.isArray(value)
|
|
158
|
+
? [...(Array.isArray(held) ? held : []), ...value]
|
|
159
|
+
: value,
|
|
160
|
+
writable: true,
|
|
161
|
+
enumerable: true,
|
|
162
|
+
configurable: true,
|
|
163
|
+
});
|
|
150
164
|
}
|
|
151
165
|
}
|
|
152
166
|
}
|
|
@@ -173,14 +187,19 @@ function responsesAccumulator() {
|
|
|
173
187
|
// naming an item the stream never opened has no slot at all — slot 0 is
|
|
174
188
|
// only for a frame that names nothing, which a stream that never indexed
|
|
175
189
|
// its items means as the first one. Merging an unknown item into slot 0
|
|
176
|
-
// was measured handing a function_call there the text of a message.
|
|
177
|
-
|
|
190
|
+
// was measured handing a function_call there the text of a message. An
|
|
191
|
+
// `added` frame opens an item, so the id it names is new there by
|
|
192
|
+
// definition and takes the next free slot; a frame that has to find an
|
|
193
|
+
// item that is already open still throws.
|
|
194
|
+
function slot(data, id, fresh = false) {
|
|
178
195
|
if (data.output_index !== undefined) return index(data.output_index);
|
|
179
196
|
if (id === undefined || id === null || id === "") return index(undefined);
|
|
180
197
|
const position = [...output].find(([, value]) => value.id === id)?.[0];
|
|
181
|
-
if (position
|
|
182
|
-
|
|
183
|
-
|
|
198
|
+
if (position !== undefined) return index(position);
|
|
199
|
+
if (!fresh) throw new Error("Upstream frame names an unknown item without an output index");
|
|
200
|
+
let next = 0;
|
|
201
|
+
for (const held of output.keys()) next = Math.max(next, held + 1);
|
|
202
|
+
return index(next);
|
|
184
203
|
}
|
|
185
204
|
// A snapshot replaces the item in its slot only when it is a whole view of
|
|
186
205
|
// it; a partial one leaves what the deltas delivered standing.
|
|
@@ -211,11 +230,19 @@ function responsesAccumulator() {
|
|
|
211
230
|
keep(position, value);
|
|
212
231
|
}
|
|
213
232
|
} else if (type === "response.output_item.added" || type === "response.output_item.done") {
|
|
214
|
-
if (data.item)
|
|
233
|
+
if (data.item)
|
|
234
|
+
keep(slot(data, data.item.id, type === "response.output_item.added"), data.item);
|
|
215
235
|
} else if (type === "response.content_part.added" || type === "response.content_part.done") {
|
|
216
236
|
const value = item(data);
|
|
217
237
|
value.content ??= [];
|
|
218
|
-
|
|
238
|
+
// A re-emitted opening part carries an empty text where the deltas
|
|
239
|
+
// have already filled the slot — the partial-snapshot hazard the item
|
|
240
|
+
// guard above and content_block_start exist for, so the same test.
|
|
241
|
+
// `refusal` is weighed too: a part whose only content is a refusal
|
|
242
|
+
// weighs nothing on text alone and would be taken for an empty one.
|
|
243
|
+
const at = index(data.content_index);
|
|
244
|
+
if (carries(data.part, value.content[at], ["text", "refusal"]))
|
|
245
|
+
value.content[at] = data.part;
|
|
219
246
|
} else if (
|
|
220
247
|
type === "response.output_text.delta" ||
|
|
221
248
|
type === "response.output_text.done" ||
|
|
@@ -244,7 +271,9 @@ function responsesAccumulator() {
|
|
|
244
271
|
) {
|
|
245
272
|
const value = item(data, "reasoning");
|
|
246
273
|
value.summary ??= [];
|
|
247
|
-
|
|
274
|
+
// The same re-emitted-opening-frame hazard as content_part above.
|
|
275
|
+
const at = index(data.summary_index);
|
|
276
|
+
if (carries(data.part, value.summary[at], ["text"])) value.summary[at] = data.part;
|
|
248
277
|
} else if (/^response\.reasoning_(summary_)?text\.(delta|done)$/.test(type)) {
|
|
249
278
|
const value = item(data, "reasoning");
|
|
250
279
|
const summary = type.includes("summary");
|
|
@@ -287,8 +316,18 @@ function responsesAccumulator() {
|
|
|
287
316
|
const whole = Array.isArray(final.output)
|
|
288
317
|
? final.output.length > 0
|
|
289
318
|
: final.output !== null && final.output !== undefined;
|
|
290
|
-
|
|
291
|
-
|
|
319
|
+
// An array is not a reply object at all, and is returned as it came.
|
|
320
|
+
if (Array.isArray(final)) return final;
|
|
321
|
+
// The terminal is not the whole reply either. What apply merged onto
|
|
322
|
+
// the body from the events' top level — usage, status,
|
|
323
|
+
// incomplete_details, finish_reason, and the object this reply is — is
|
|
324
|
+
// the upstream's too, and answering with the terminal alone dropped it:
|
|
325
|
+
// a terminal carrying its whole response beside a top-level usage, or
|
|
326
|
+
// one that omits object, answered without them. The body is the base,
|
|
327
|
+
// the terminal overrides what it carries, and the items it lost come
|
|
328
|
+
// back from the accumulation.
|
|
329
|
+
if (whole || !kept.length) return { ...body, ...final };
|
|
330
|
+
return { ...body, ...final, output: kept };
|
|
292
331
|
},
|
|
293
332
|
};
|
|
294
333
|
}
|
|
@@ -317,7 +356,15 @@ function anthropicAccumulator() {
|
|
|
317
356
|
for (const [position, block] of (data.message?.content ?? []).entries())
|
|
318
357
|
if (carries(block, blocks.get(position), fields)) blocks.set(position, block);
|
|
319
358
|
} else if (type === "content_block_start") {
|
|
320
|
-
blocks
|
|
359
|
+
// The same test message_start's blocks get, for the same reason: a
|
|
360
|
+
// gateway re-emitting a block's opening frame carries a block that
|
|
361
|
+
// was never filled in (a text one opens with text: ""), and taking
|
|
362
|
+
// it would discard the text, thinking or signature the deltas
|
|
363
|
+
// already delivered. A start with nothing accumulated applies, as
|
|
364
|
+
// does one that carries content of its own.
|
|
365
|
+
const position = index(data.index);
|
|
366
|
+
if (carries(data.content_block, blocks.get(position), fields))
|
|
367
|
+
blocks.set(position, data.content_block);
|
|
321
368
|
} else if (type === "content_block_delta") {
|
|
322
369
|
const position = index(data.index);
|
|
323
370
|
const block = blocks.get(position);
|
package/tool-response.mjs
CHANGED
|
@@ -158,13 +158,36 @@ function isCall(protocol, value) {
|
|
|
158
158
|
);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// Counts the ids invented for calls the reply gives nothing to place: a
|
|
162
|
+
// counter cannot be stable, but it is the only thing that keeps two of them
|
|
163
|
+
// apart (see inPlaceOf).
|
|
164
|
+
let synthesized = 0;
|
|
165
|
+
|
|
161
166
|
// What a call left out of a Responses or Messages reply becomes: text
|
|
162
|
-
// saying so, in the call's place, so no later index moves.
|
|
163
|
-
|
|
167
|
+
// saying so, in the call's place, so no later index moves. `at` is where the
|
|
168
|
+
// 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
|
+
function inPlaceOf(protocol, call, note, at) {
|
|
164
171
|
if (protocol === "anthropic") return { type: "text", text: note };
|
|
172
|
+
// An item id is what a Responses client keys the item by, and what the
|
|
173
|
+
// streaming path suppresses arguments by. One call is replaced more than
|
|
174
|
+
// once — its output_item.added, its output_item.done, and the copy inside
|
|
175
|
+
// the terminal response's output — so the invented id has to be the same
|
|
176
|
+
// every time: a counter made one call into several items to the client, and
|
|
177
|
+
// 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.
|
|
179
|
+
//
|
|
180
|
+
// A call the reply places nowhere keeps the counter — and names it under a
|
|
181
|
+
// different word, because both arms are inventing ids in one reply: a counter
|
|
182
|
+
// that happened to reach another call's index would hand two unrelated calls
|
|
183
|
+
// the same item, which is the failure the counter was introduced to prevent.
|
|
184
|
+
const id =
|
|
185
|
+
typeof call.id === "string" && call.id
|
|
186
|
+
? call.id
|
|
187
|
+
: `msg_${call.call_id ?? (at === undefined ? `anon_${++synthesized}` : `dropped_${at}`)}`;
|
|
165
188
|
return {
|
|
166
189
|
type: "message",
|
|
167
|
-
id
|
|
190
|
+
id,
|
|
168
191
|
role: "assistant",
|
|
169
192
|
status: call.status ?? "completed",
|
|
170
193
|
content: [{ type: "output_text", text: note, annotations: [] }],
|
|
@@ -194,10 +217,15 @@ function checkSnapshots(data, protocol, checkName) {
|
|
|
194
217
|
let kept = 0;
|
|
195
218
|
let left = 0;
|
|
196
219
|
if (!object(data)) throw new GuardError("Invalid upstream tool snapshot");
|
|
197
|
-
// Each entry is a value
|
|
198
|
-
|
|
220
|
+
// Each entry is a value, where it sits, and the place it holds in the reply
|
|
221
|
+
// as far as the walk knows: an item of an output array is at its index, and
|
|
222
|
+
// anything reached through an object — a frame's own `item` — keeps the
|
|
223
|
+
// output_index the frame carries, which is where that item sits. A call
|
|
224
|
+
// left out is replaced by an item whose invented id names that place, so
|
|
225
|
+
// the added, done and terminal copies of one call are one item (inPlaceOf).
|
|
226
|
+
const stack = [[data, null, null, object(data) ? data.output_index : undefined]];
|
|
199
227
|
while (stack.length) {
|
|
200
|
-
const [value, holder, key] = stack.pop();
|
|
228
|
+
const [value, holder, key, at] = stack.pop();
|
|
201
229
|
if (!object(value)) throw new GuardError("Invalid upstream tool snapshot");
|
|
202
230
|
if (
|
|
203
231
|
protocol === "responses" &&
|
|
@@ -211,7 +239,7 @@ function checkSnapshots(data, protocol, checkName) {
|
|
|
211
239
|
const name = checkName(value.name);
|
|
212
240
|
if (name == null) {
|
|
213
241
|
if (!holder) throw new GuardError(droppedNote(value.name), "undeclared_tool");
|
|
214
|
-
holder[key] = inPlaceOf(protocol, value, droppedNote(value.name));
|
|
242
|
+
holder[key] = inPlaceOf(protocol, value, droppedNote(value.name), at);
|
|
215
243
|
changed = true;
|
|
216
244
|
left++;
|
|
217
245
|
continue;
|
|
@@ -229,10 +257,10 @@ function checkSnapshots(data, protocol, checkName) {
|
|
|
229
257
|
// Text content is not a snapshot container.
|
|
230
258
|
if (typeof value[name] === "string") continue;
|
|
231
259
|
const items = list(value[name]);
|
|
232
|
-
for (let i = 0; i < items.length; i++) stack.push([items[i], items, i]);
|
|
260
|
+
for (let i = 0; i < items.length; i++) stack.push([items[i], items, i, i]);
|
|
233
261
|
}
|
|
234
262
|
for (const name of objects) {
|
|
235
|
-
if (value[name] != null) stack.push([value[name], value, name]);
|
|
263
|
+
if (value[name] != null) stack.push([value[name], value, name, at]);
|
|
236
264
|
}
|
|
237
265
|
}
|
|
238
266
|
if (
|
|
@@ -451,7 +479,10 @@ function snapshotStream(protocol, checkName) {
|
|
|
451
479
|
checkName(item.name) == null
|
|
452
480
|
) {
|
|
453
481
|
const note = droppedNote(item.name);
|
|
454
|
-
|
|
482
|
+
// The frame's output_index is this call's place in the reply, which
|
|
483
|
+
// is the id its later frames (done, the terminal's output) invent
|
|
484
|
+
// for it again (inPlaceOf).
|
|
485
|
+
const message = inPlaceOf(protocol, item, note, data.output_index);
|
|
455
486
|
left.set(data.output_index, true);
|
|
456
487
|
leftIDs.add(message.id);
|
|
457
488
|
if (typeof item.id === "string") leftIDs.add(item.id);
|
|
@@ -532,6 +563,7 @@ async function readBounded(response) {
|
|
|
532
563
|
if (!reader) return "";
|
|
533
564
|
const parts = [];
|
|
534
565
|
let bytes = 0;
|
|
566
|
+
let failure;
|
|
535
567
|
try {
|
|
536
568
|
while (true) {
|
|
537
569
|
const { done, value } = await reader.read();
|
|
@@ -541,20 +573,28 @@ async function readBounded(response) {
|
|
|
541
573
|
throw new GuardError(`Upstream response exceeds the ${LIMIT} buffer limit`);
|
|
542
574
|
parts.push(value);
|
|
543
575
|
}
|
|
576
|
+
} catch (error) {
|
|
577
|
+
failure = error;
|
|
578
|
+
}
|
|
579
|
+
// Whatever ended the read — the limit, a malformed body, or the end of it —
|
|
580
|
+
// this body is never read again, so the stream is cancelled and the reader
|
|
581
|
+
// loosed. This was the one place in the package that abandoned a body
|
|
582
|
+
// without cancelling it (measured: no cancellation past the limit, and the
|
|
583
|
+
// source still pulled after the 502), which leaves an upstream writing into
|
|
584
|
+
// a socket nobody reads. A cancel that fails must not stand in for what
|
|
585
|
+
// ended the read: an oversized body is the 502 this function promises, not
|
|
586
|
+
// an opaque transport error, so the cancel's failure is kept as that
|
|
587
|
+
// error's cause — and is thrown itself only when nothing else went wrong.
|
|
588
|
+
// The lock is released either way.
|
|
589
|
+
try {
|
|
590
|
+
await reader.cancel();
|
|
591
|
+
} catch (error) {
|
|
592
|
+
if (!failure) throw error;
|
|
593
|
+
if (failure instanceof Error && !("cause" in failure)) failure.cause = error;
|
|
544
594
|
} finally {
|
|
545
|
-
|
|
546
|
-
// it — this body is never read again, so the reader is loosed and the
|
|
547
|
-
// stream cancelled. This was the one place in the package that abandoned
|
|
548
|
-
// a body without cancelling it (measured: no cancellation past the limit,
|
|
549
|
-
// and the source still pulled after the 502), which leaves an upstream
|
|
550
|
-
// writing into a socket nobody reads. The cancel's own failure is not
|
|
551
|
-
// swallowed, and the lock is released whether it threw or not.
|
|
552
|
-
try {
|
|
553
|
-
await reader.cancel();
|
|
554
|
-
} finally {
|
|
555
|
-
reader.releaseLock();
|
|
556
|
-
}
|
|
595
|
+
reader.releaseLock();
|
|
557
596
|
}
|
|
597
|
+
if (failure) throw failure;
|
|
558
598
|
const all = new Uint8Array(bytes);
|
|
559
599
|
let offset = 0;
|
|
560
600
|
for (const part of parts) {
|
|
@@ -743,7 +783,14 @@ function chatTrack() {
|
|
|
743
783
|
if (choice.finish_reason != null) {
|
|
744
784
|
finished.add(index);
|
|
745
785
|
open.delete(index);
|
|
746
|
-
} else
|
|
786
|
+
} else {
|
|
787
|
+
// A frame for this choice after it finished opens a new round of it
|
|
788
|
+
// (chatBuffer.apply judges a later round the same way), so the
|
|
789
|
+
// choice is open again: latching it shut for good left a round cut
|
|
790
|
+
// short with no note and no finish_reason, only a bare [DONE].
|
|
791
|
+
finished.delete(index);
|
|
792
|
+
open.add(index);
|
|
793
|
+
}
|
|
747
794
|
}
|
|
748
795
|
},
|
|
749
796
|
// ending answers the frames that end the reply as cut short, or null
|
|
@@ -919,7 +966,17 @@ function guardedStream(response, protocol, checkName, endCutShort) {
|
|
|
919
966
|
);
|
|
920
967
|
controller.close();
|
|
921
968
|
} else {
|
|
922
|
-
|
|
969
|
+
// A failure that is not the guard's own is a transport one: the
|
|
970
|
+
// stream is over either way, and what is left of the body is not
|
|
971
|
+
// something to keep writing into a socket nobody reads — the
|
|
972
|
+
// same rule readBounded follows. Cancel's own failure is not
|
|
973
|
+
// allowed to replace the error the client has to see, which is
|
|
974
|
+
// the one that got here.
|
|
975
|
+
try {
|
|
976
|
+
await cancel(error);
|
|
977
|
+
} catch {
|
|
978
|
+
/* the original error is the one the client must see */
|
|
979
|
+
}
|
|
923
980
|
controller.error(error);
|
|
924
981
|
}
|
|
925
982
|
}
|
|
@@ -938,9 +995,12 @@ function guardedStream(response, protocol, checkName, endCutShort) {
|
|
|
938
995
|
// collapsed for a client that doesn't stream fails instead, so it can be
|
|
939
996
|
// asked again before anything reached the client.
|
|
940
997
|
export async function guardToolResponse(response, protocol, policy, options = {}) {
|
|
941
|
-
|
|
998
|
+
// A protocol this guard has no rules for is the caller's mistake, and it
|
|
999
|
+
// is one whichever the upstream answered: checked before the status is
|
|
1000
|
+
// read, so an error response cannot return it unexamined.
|
|
942
1001
|
if (!["chat", "responses", "anthropic"].includes(protocol))
|
|
943
1002
|
throw new TypeError(`Unsupported protocol: ${protocol}`);
|
|
1003
|
+
if (!response.ok) return response;
|
|
944
1004
|
const checkName = nameGuard(policy);
|
|
945
1005
|
const headers = headersFor(response);
|
|
946
1006
|
const contentType = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
|