opencode-zen-freex 0.2.10 → 0.2.11
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 +72 -12
- package/index.mjs +7 -3
- package/limits.mjs +14 -4
- package/models.mjs +31 -5
- package/package.json +1 -1
- package/provider.mjs +57 -8
- package/stream.mjs +26 -4
- package/tool-response.mjs +28 -3
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,7 +103,9 @@ 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.
|
|
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.
|
|
88
109
|
function profileFor(generation) {
|
|
89
110
|
if (!Object.hasOwn(PROFILES, generation)) {
|
|
90
111
|
throw new Error(
|
|
@@ -92,9 +113,25 @@ function profileFor(generation) {
|
|
|
92
113
|
`the capture holds ${Object.keys(PROFILES).join(", ")}`,
|
|
93
114
|
);
|
|
94
115
|
}
|
|
95
|
-
|
|
116
|
+
const profile = PROFILES[generation];
|
|
117
|
+
if (typeof profile !== "object" || profile === null || Array.isArray(profile)) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
captureError(
|
|
120
|
+
`the ${JSON.stringify(generation)} profile in the capture is not an object`,
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return profile;
|
|
96
125
|
}
|
|
97
126
|
|
|
127
|
+
// A field interpolated into the User-Agent has to be a string with something in
|
|
128
|
+
// it: each one is read by Zen, and a missing or mistyped one used to compose
|
|
129
|
+
// `opencode/undefined/1.19.0/undefined` — a header that gets a refusal saying
|
|
130
|
+
// nothing about the capture it came out of. `Object.hasOwn` alone would only
|
|
131
|
+
// prove the key is there: `{"chat": null}` and `{"chat": ""}` both compose a
|
|
132
|
+
// version of `null`.
|
|
133
|
+
const filledIn = (value) => typeof value === "string" && value !== "";
|
|
134
|
+
|
|
98
135
|
/**
|
|
99
136
|
* The User-Agent that generation of OpenCode sends, for a protocol. Each
|
|
100
137
|
* protocol's ai-sdk package bundles its own provider-utils, so 1.x differs by
|
|
@@ -102,15 +139,30 @@ function profileFor(generation) {
|
|
|
102
139
|
*/
|
|
103
140
|
export function agentFor(generation, protocol = "chat") {
|
|
104
141
|
const agent = profileFor(generation).agent;
|
|
105
|
-
if (typeof agent !== "object" || agent === null) {
|
|
142
|
+
if (typeof agent !== "object" || agent === null || Array.isArray(agent)) {
|
|
106
143
|
throw new Error(
|
|
107
|
-
`
|
|
108
|
-
"run `bun run capture` to write a new one",
|
|
144
|
+
captureError(`the ${JSON.stringify(generation)} profile in the capture has no agent`),
|
|
109
145
|
);
|
|
110
146
|
}
|
|
111
147
|
// The release shape carries no provider-utils at all, and is composed the same
|
|
112
148
|
// way whatever protocol was asked for.
|
|
113
|
-
if (!agent.providerUtils)
|
|
149
|
+
if (!agent.providerUtils) {
|
|
150
|
+
if (![agent.channel, agent.opencode, agent.client].every(filledIn)) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
captureError(
|
|
153
|
+
`the ${JSON.stringify(generation)} profile in the capture is not a release agent`,
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return `opencode/${agent.channel}/${agent.opencode}/${agent.client}`;
|
|
158
|
+
}
|
|
159
|
+
if (typeof agent.providerUtils !== "object" || agent.providerUtils === null) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
captureError(
|
|
162
|
+
`the ${JSON.stringify(generation)} profile in the capture has no per-protocol provider-utils`,
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
114
166
|
// `?? "undefined"` used to be interpolated here for a protocol the capture has
|
|
115
167
|
// no provider-utils for, and a prototype member (`toString`) as the version.
|
|
116
168
|
// Zen reads this header, so a malformed one is rejected upstream with an error
|
|
@@ -122,5 +174,13 @@ export function agentFor(generation, protocol = "chat") {
|
|
|
122
174
|
`it holds ${Object.keys(agent.providerUtils).join(", ")}`,
|
|
123
175
|
);
|
|
124
176
|
}
|
|
177
|
+
if (!filledIn(agent.providerUtils[protocol]) || !filledIn(agent.opencode) || !filledIn(agent.runtime)) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
captureError(
|
|
180
|
+
`the ${JSON.stringify(generation)} profile in the capture does not fill in every field of ` +
|
|
181
|
+
`its ${JSON.stringify(protocol)} User-Agent`,
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
125
185
|
return `opencode/${agent.opencode} ai-sdk/provider-utils/${agent.providerUtils[protocol]} ${agent.runtime}`;
|
|
126
186
|
}
|
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
|
@@ -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,13 +335,15 @@ 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
|
}
|
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.11",
|
|
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
|
@@ -114,6 +114,50 @@ 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
|
+
|
|
117
161
|
// How long a request for a model the last list didn't have waits before it
|
|
118
162
|
// asks Zen's list again: a model Zen made free since is served at once,
|
|
119
163
|
// and a model typed in by hand doesn't ask the list on every request.
|
|
@@ -545,9 +589,12 @@ async function server(_input, options = {}) {
|
|
|
545
589
|
//
|
|
546
590
|
// A response body can only be read once, so what the limits reader
|
|
547
591
|
// took is handed on as the response the rest of the path works with:
|
|
548
|
-
// the
|
|
549
|
-
//
|
|
550
|
-
//
|
|
592
|
+
// never the upstream's own headers over bytes this package wrote
|
|
593
|
+
// again, and never a prefix presented as the whole reply. A read
|
|
594
|
+
// that reached the end of the body is that body, relayed under
|
|
595
|
+
// headers corrected for it; one that stopped short is answered with
|
|
596
|
+
// a refusal that says so (see `relayedRefusal`). A response the
|
|
597
|
+
// reader did not consume is used as it arrived.
|
|
551
598
|
const observed = await observeRouteStatement(body.model, response, undefined, {
|
|
552
599
|
// The caller's own cancellation reaches this read as well: a host
|
|
553
600
|
// that has given up is not waiting for a number out of a refusal.
|
|
@@ -556,11 +603,13 @@ async function server(_input, options = {}) {
|
|
|
556
603
|
const answered =
|
|
557
604
|
observed === undefined
|
|
558
605
|
? response
|
|
559
|
-
:
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
606
|
+
: observed.ended
|
|
607
|
+
? new Response(observed.text, {
|
|
608
|
+
status: response.status,
|
|
609
|
+
statusText: response.statusText,
|
|
610
|
+
headers: relayedHeaders(response),
|
|
611
|
+
})
|
|
612
|
+
: relayedRefusal(response, observed);
|
|
564
613
|
const guarded = await guardToolResponse(
|
|
565
614
|
explain(observed?.text ?? null, answered, toolPolicy),
|
|
566
615
|
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
|
}
|
|
@@ -317,7 +331,15 @@ function anthropicAccumulator() {
|
|
|
317
331
|
for (const [position, block] of (data.message?.content ?? []).entries())
|
|
318
332
|
if (carries(block, blocks.get(position), fields)) blocks.set(position, block);
|
|
319
333
|
} else if (type === "content_block_start") {
|
|
320
|
-
blocks
|
|
334
|
+
// The same test message_start's blocks get, for the same reason: a
|
|
335
|
+
// gateway re-emitting a block's opening frame carries a block that
|
|
336
|
+
// was never filled in (a text one opens with text: ""), and taking
|
|
337
|
+
// it would discard the text, thinking or signature the deltas
|
|
338
|
+
// already delivered. A start with nothing accumulated applies, as
|
|
339
|
+
// does one that carries content of its own.
|
|
340
|
+
const position = index(data.index);
|
|
341
|
+
if (carries(data.content_block, blocks.get(position), fields))
|
|
342
|
+
blocks.set(position, data.content_block);
|
|
321
343
|
} else if (type === "content_block_delta") {
|
|
322
344
|
const position = index(data.index);
|
|
323
345
|
const block = blocks.get(position);
|
package/tool-response.mjs
CHANGED
|
@@ -158,13 +158,25 @@ function isCall(protocol, value) {
|
|
|
158
158
|
);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// Counts the ids invented for calls that came without one, so that no two of
|
|
162
|
+
// them can be handed the same id (see inPlaceOf).
|
|
163
|
+
let synthesized = 0;
|
|
164
|
+
|
|
161
165
|
// What a call left out of a Responses or Messages reply becomes: text
|
|
162
166
|
// saying so, in the call's place, so no later index moves.
|
|
163
167
|
function inPlaceOf(protocol, call, note) {
|
|
164
168
|
if (protocol === "anthropic") return { type: "text", text: note };
|
|
169
|
+
// An item id is what a Responses client keys the item by, and what the
|
|
170
|
+
// streaming path suppresses arguments by: two calls that arrived without
|
|
171
|
+
// one and shared the id invented here would be one item to the client, and
|
|
172
|
+
// the arguments of one would be read as the other's.
|
|
173
|
+
const id =
|
|
174
|
+
typeof call.id === "string" && call.id
|
|
175
|
+
? call.id
|
|
176
|
+
: `msg_${call.call_id ?? `dropped_${++synthesized}`}`;
|
|
165
177
|
return {
|
|
166
178
|
type: "message",
|
|
167
|
-
id
|
|
179
|
+
id,
|
|
168
180
|
role: "assistant",
|
|
169
181
|
status: call.status ?? "completed",
|
|
170
182
|
content: [{ type: "output_text", text: note, annotations: [] }],
|
|
@@ -919,7 +931,17 @@ function guardedStream(response, protocol, checkName, endCutShort) {
|
|
|
919
931
|
);
|
|
920
932
|
controller.close();
|
|
921
933
|
} else {
|
|
922
|
-
|
|
934
|
+
// A failure that is not the guard's own is a transport one: the
|
|
935
|
+
// stream is over either way, and what is left of the body is not
|
|
936
|
+
// something to keep writing into a socket nobody reads — the
|
|
937
|
+
// same rule readBounded follows. Cancel's own failure is not
|
|
938
|
+
// allowed to replace the error the client has to see, which is
|
|
939
|
+
// the one that got here.
|
|
940
|
+
try {
|
|
941
|
+
await cancel(error);
|
|
942
|
+
} catch {
|
|
943
|
+
/* the original error is the one the client must see */
|
|
944
|
+
}
|
|
923
945
|
controller.error(error);
|
|
924
946
|
}
|
|
925
947
|
}
|
|
@@ -938,9 +960,12 @@ function guardedStream(response, protocol, checkName, endCutShort) {
|
|
|
938
960
|
// collapsed for a client that doesn't stream fails instead, so it can be
|
|
939
961
|
// asked again before anything reached the client.
|
|
940
962
|
export async function guardToolResponse(response, protocol, policy, options = {}) {
|
|
941
|
-
|
|
963
|
+
// A protocol this guard has no rules for is the caller's mistake, and it
|
|
964
|
+
// is one whichever the upstream answered: checked before the status is
|
|
965
|
+
// read, so an error response cannot return it unexamined.
|
|
942
966
|
if (!["chat", "responses", "anthropic"].includes(protocol))
|
|
943
967
|
throw new TypeError(`Unsupported protocol: ${protocol}`);
|
|
968
|
+
if (!response.ok) return response;
|
|
944
969
|
const checkName = nameGuard(policy);
|
|
945
970
|
const headers = headersFor(response);
|
|
946
971
|
const contentType = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
|