opencode-zen-freex 0.2.6 → 0.2.8
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/LICENSE +3 -1
- package/MAINTAINING.md +59 -0
- package/README.md +3 -1
- package/capture.mjs +44 -16
- package/index.mjs +8 -0
- package/limits-audit.mjs +135 -33
- package/limits.mjs +69 -12
- package/models.mjs +53 -14
- package/package.json +2 -1
- package/provider.mjs +24 -21
- package/stream.mjs +35 -10
- package/tool-response.mjs +56 -16
- package/tools-smoke.mjs +70 -11
package/LICENSE
CHANGED
|
@@ -4,7 +4,9 @@ Copyright (c) 2026 xiaozhou26
|
|
|
4
4
|
Copyright (c) 2026 opencode-zen-free contributors
|
|
5
5
|
|
|
6
6
|
This is a maintained fork of the OpenCode Zen Free plugin from
|
|
7
|
-
magpie-community/plugins, which is MIT licensed and carries the notice above
|
|
7
|
+
magpie-community/plugins, which is MIT licensed and carries the notice above:
|
|
8
|
+
the `Copyright (c) 2026 xiaozhou26` line is upstream's and has to be kept with
|
|
9
|
+
it, and the `opencode-zen-free contributors` line covers this fork.
|
|
8
10
|
The fork is maintained separately because the free tier's request fingerprint
|
|
9
11
|
moves faster than that repository follows it.
|
|
10
12
|
|
package/MAINTAINING.md
CHANGED
|
@@ -155,6 +155,7 @@ It reports each request as:
|
|
|
155
155
|
| `answered` | Zen served it; this shape is current |
|
|
156
156
|
| `refused` | Zen did not recognise it (403) |
|
|
157
157
|
| `superseded` | Zen wants a newer OpenCode than the profile claims (426) |
|
|
158
|
+
| `failed` | Zen answered with something other than a 403, so it never judged the fingerprint |
|
|
158
159
|
| `unreachable` | Zen could not be asked at all |
|
|
159
160
|
|
|
160
161
|
The `floor` decides the exit code, because it is the shape this plugin exists to
|
|
@@ -163,6 +164,11 @@ stale. Asking only the full shape left a real gap: `read` alone is `answered` an
|
|
|
163
164
|
everything but `read` is `refused`, so a Zen that began insisting on one more
|
|
164
165
|
name would keep `full` passing while the shape actually sent was refused.
|
|
165
166
|
|
|
167
|
+
Only a 403 and a 426 are Zen answering about the fingerprint. A 5xx, a 429 or a
|
|
168
|
+
401 says nothing about the wording, and the gate says so rather than sending the
|
|
169
|
+
reader to `capture` over an outage — as it does when nothing was reached, or when
|
|
170
|
+
part of a run answered and part of it did not.
|
|
171
|
+
|
|
166
172
|
`capture` needs `opencode` on PATH. It points a real OpenCode at a server running
|
|
167
173
|
locally and reads the request it makes, so nothing is sent to Zen and no key is
|
|
168
174
|
involved.
|
|
@@ -193,8 +199,34 @@ by changing tools.
|
|
|
193
199
|
repeated refusals from this address have put it in a cooldown — they can, and
|
|
194
200
|
the cooldown answers like a refusal.
|
|
195
201
|
|
|
202
|
+
**`failed`.** Zen answered, but not with 403: a 5xx or a 429, or a 401 if the
|
|
203
|
+
request was not authorized. Not a fingerprint problem either, and the gate says
|
|
204
|
+
so rather than naming the wording. A 429 is the one to read twice: repeated
|
|
205
|
+
refusals from this address can put it in a cooldown, and a cooldown answers
|
|
206
|
+
quickly and often.
|
|
207
|
+
|
|
196
208
|
## Things that are easy to break
|
|
197
209
|
|
|
210
|
+
**Marking a list as fallen back when Zen's own list was read.** `discover()`
|
|
211
|
+
returns every id Zen's `/models` lists; the catalogue and the console docs only
|
|
212
|
+
add what each model *is* — name, protocol, limits. When either of those two
|
|
213
|
+
cannot be read, the list is not a fallback: it is Zen's, current, and complete to
|
|
214
|
+
the last id. Saying so costs a user something real, because the host reacts to
|
|
215
|
+
that marker by keeping the last list it read in full, and a machine that cannot
|
|
216
|
+
reach `raw.githubusercontent.com` or `models.opencode.ai` — mainland China, for
|
|
217
|
+
one — then never sees a model Zen made free since. The marker belongs to the one
|
|
218
|
+
case that describes it: the listing itself could not be read, so what is returned
|
|
219
|
+
really is the last list there was (`yetone/magpie#2385`).
|
|
220
|
+
|
|
221
|
+
**Waiting for the catalogue before offering a model Zen already lists.** An id
|
|
222
|
+
ending in `-free` that neither the catalogue nor the docs describe is offered
|
|
223
|
+
anyway, asked through the provider's own protocol (openai-compatible, and so
|
|
224
|
+
chat) — the way OpenCode asks a model with no package of its own. Zen lists a
|
|
225
|
+
model the hour it turns free and the catalogue catches up later; a host should
|
|
226
|
+
see it in between. An id that does not call itself free still needs the
|
|
227
|
+
catalogue's word, and one the snapshot or the docs name keeps that say:
|
|
228
|
+
`jev-1.13-free` is SystemOne and stays out.
|
|
229
|
+
|
|
198
230
|
**Adding a header.** The set sent has one owner: `outboundHeaders` in
|
|
199
231
|
`provider.mjs`, which the plugin builds its request with and which the gate asks
|
|
200
232
|
Zen with, so neither can drift from the other on its own. `index.test.mjs` pins
|
|
@@ -232,6 +264,33 @@ that passes under the stricter of them, since the looser one will not last.
|
|
|
232
264
|
| `capture.mjs` | takes a new capture |
|
|
233
265
|
| `tools-smoke.mjs` | asks Zen what it accepts (`check:gate`) |
|
|
234
266
|
|
|
267
|
+
## Which copy of the icon and the concurrency is authoritative
|
|
268
|
+
|
|
269
|
+
`icon` and `maxConcurrency` are declared twice, and neither copy can be read by
|
|
270
|
+
the other: `package.json`'s `magpie` block is host-facing JSON, and the `auth`
|
|
271
|
+
object in `provider.mjs` is the runtime's. Nothing builds one from the other, so
|
|
272
|
+
a change to either alone drifts.
|
|
273
|
+
|
|
274
|
+
The runtime values are authoritative for what the plugin asks for. The manifest
|
|
275
|
+
block is what a host reads before it loads the plugin, so a host that reads it
|
|
276
|
+
will act on that copy, and one that does not will act on the runtime's — which
|
|
277
|
+
means editing one and not the other silently changes how many requests the host
|
|
278
|
+
makes at once (too high and Zen rate-limits them, too low and they queue behind
|
|
279
|
+
each other). Change both, and keep them saying the same thing.
|
|
280
|
+
|
|
281
|
+
## What runs this
|
|
282
|
+
|
|
283
|
+
Every script in `package.json` names `bun`: `bun test` is the suite, and
|
|
284
|
+
`check:gate`, `limits:audit` and `capture` are the tools. `bun test` is what CI
|
|
285
|
+
runs, at the version pinned in `.github/workflows/ci.yml` so a run says something
|
|
286
|
+
about the commit rather than about whatever Bun was newest that morning.
|
|
287
|
+
|
|
288
|
+
Nothing here is installed with Bun — there are no dependencies — so Bun is the
|
|
289
|
+
tools only. `import.meta.main` is what decides whether `tools-smoke.mjs` runs its
|
|
290
|
+
gate or is being imported by a test, and a Node older than 22.18 does not define
|
|
291
|
+
it: the gate falls back to comparing the entry path, and exits non-zero saying
|
|
292
|
+
nothing was asked if it cannot tell the two apart.
|
|
293
|
+
|
|
235
294
|
## Publishing
|
|
236
295
|
|
|
237
296
|
The package is `opencode-zen-freex`; the provider it registers is
|
package/README.md
CHANGED
|
@@ -47,7 +47,9 @@ magpie plugin login opencode-zen-free
|
|
|
47
47
|
|
|
48
48
|
免费模型的目录上限有时被 Zen 少报,而少报的那一边没有谁会报错:`deepseek-v4.1-flash-free` 在目录里是 20 万上下文 / 3.2 万输出,而同一上游模型的付费 id 是 100 万 / 38.4 万。上下文窗口太小只会让宿主安静地早点压缩,输出上限太小会把回复截断成看起来像模型自己停下的样子,所以插件对这类模型按实测与路由自述对齐(依据与复测方法见 `MAINTAINING.md` 的 “Limits Zen under-reports”),并在运行中采用上游自报的窗口。
|
|
49
49
|
|
|
50
|
-
已下线的模型和 SystemOne 模型会被排除。这份列表在宿主每次列举模型时重新获取(magpie 在启动时做一次,之后每小时一次):Zen
|
|
50
|
+
已下线的模型和 SystemOne 模型会被排除。这份列表在宿主每次列举模型时重新获取(magpie 在启动时做一次,之后每小时一次):Zen 不再免费开放的模型会退出列表,新转为免费的模型会加入列表——**不等** OpenCode 的目录或文档写到它:只要 Zen 的列表里有、id 里带 `-free`,就按 provider 自己的协议(OpenAI 兼容 / chat)提供,目录晚一步也不影响。
|
|
51
|
+
|
|
52
|
+
目录或文档读不到时**不算 fallback**:Zen 的列表已经读到了,它就是当前该提供的东西;报成 fallback 会让宿主保守地沿用上一次完整读到的列表,于是读不到 `raw.githubusercontent.com` 或 `models.opencode.ai` 的机器永远看不到之后新转免费的模型。只有**列表本身**读不到才沿用上一次结果;第一次还没读到之前,用的是打包在插件里的那份,所以这个 provider 不会空着。请求一个当前并不免费的模型,会得到一个 404 并列出当时免费的模型;若某个模型是在上次列举之后才转免费的,请求会在重新获取列表后照常服务(最多每分钟一次)。可用性和限流由 Zen 决定,插件这边拿不到剩余额度。
|
|
51
53
|
|
|
52
54
|
## 免费层不再应答时
|
|
53
55
|
|
package/capture.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
19
|
import { mkdtempSync } from "node:fs";
|
|
20
|
+
import { rmSync } from "node:fs";
|
|
20
21
|
import { tmpdir } from "node:os";
|
|
21
22
|
import { join } from "node:path";
|
|
22
23
|
import { PROFILES } from "./core-tools.mjs";
|
|
@@ -26,8 +27,12 @@ const DRY = process.argv.includes("--dry");
|
|
|
26
27
|
const HERE = import.meta.dir;
|
|
27
28
|
const FINGERPRINT = join(HERE, "fingerprint.json");
|
|
28
29
|
|
|
29
|
-
// The names Zen reads
|
|
30
|
-
|
|
30
|
+
// The names Zen reads, by generation: 2.x sends `shell` where 1.x sends `bash`
|
|
31
|
+
// and carries none of the others. Everything else a host declares is ignored.
|
|
32
|
+
const READ = {
|
|
33
|
+
"1.x": ["bash", "glob", "grep", "read", "write", "edit"],
|
|
34
|
+
"2.x": ["shell", "read"],
|
|
35
|
+
};
|
|
31
36
|
|
|
32
37
|
// The capture host's own scratch directory and OS banner belong to the machine
|
|
33
38
|
// the capture ran on, not to the tool. These are put back to something neutral
|
|
@@ -42,6 +47,15 @@ const NEUTRALISE = [
|
|
|
42
47
|
const KEEP = 1200;
|
|
43
48
|
|
|
44
49
|
const capturedPath = join(tmpdir(), `zen-capture-${process.pid}.json`);
|
|
50
|
+
// A run that was killed leaves its file behind and a PID comes round again, so
|
|
51
|
+
// this one is removed rather than read back as if it were this run's capture.
|
|
52
|
+
rmSync(capturedPath, { force: true });
|
|
53
|
+
// OpenCode makes more than one request (a short one for the title, then the
|
|
54
|
+
// real one). The one worth reading is the longest, since it is the one that
|
|
55
|
+
// carries the full toolset. It is kept here rather than written from the handler
|
|
56
|
+
// as it arrives: two requests in flight at once would both be writing this one
|
|
57
|
+
// file, and the writes interleave into it.
|
|
58
|
+
let longest = { agent: null, body: "" };
|
|
45
59
|
const server = Bun.serve({
|
|
46
60
|
port: PORT,
|
|
47
61
|
hostname: "127.0.0.1",
|
|
@@ -49,16 +63,8 @@ const server = Bun.serve({
|
|
|
49
63
|
const url = new URL(request.url);
|
|
50
64
|
if (url.pathname.endsWith("/models"))
|
|
51
65
|
return Response.json({ object: "list", data: [{ id: "big-pickle", object: "model", owned_by: "opencode" }] });
|
|
52
|
-
// OpenCode makes more than one request (a short one for the title, then the
|
|
53
|
-
// real one). The one worth reading is the longest, since it is the one that
|
|
54
|
-
// carries the full toolset.
|
|
55
66
|
const body = await request.text();
|
|
56
|
-
|
|
57
|
-
try {
|
|
58
|
-
previous = JSON.parse(readFileSync(capturedPath, "utf8"));
|
|
59
|
-
} catch {}
|
|
60
|
-
if (body.length > previous.body.length)
|
|
61
|
-
await Bun.write(capturedPath, JSON.stringify({ agent: request.headers.get("user-agent"), body }));
|
|
67
|
+
if (body.length > longest.body.length) longest = { agent: request.headers.get("user-agent"), body };
|
|
62
68
|
return new Response(
|
|
63
69
|
[
|
|
64
70
|
'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"big-pickle","choices":[{"index":0,"delta":{"role":"assistant","content":"OK"},"finish_reason":null}]}',
|
|
@@ -101,6 +107,8 @@ const code = await new Promise((done) => {
|
|
|
101
107
|
});
|
|
102
108
|
server.stop(true);
|
|
103
109
|
if (!ran) process.exit(1);
|
|
110
|
+
// Written here, once nothing else can be writing it.
|
|
111
|
+
if (longest.body.length) await Bun.write(capturedPath, JSON.stringify(longest));
|
|
104
112
|
|
|
105
113
|
let request;
|
|
106
114
|
try {
|
|
@@ -110,11 +118,25 @@ try {
|
|
|
110
118
|
process.exit(1);
|
|
111
119
|
}
|
|
112
120
|
|
|
113
|
-
|
|
121
|
+
let body;
|
|
122
|
+
try {
|
|
123
|
+
body = JSON.parse(request.body);
|
|
124
|
+
} catch {
|
|
125
|
+
console.error("the captured request body is not JSON; nothing to read");
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Which generation this client is: the names it sends say so. It is read
|
|
130
|
+
// before the tools are, since the two generations do not send the same names
|
|
131
|
+
// and 2.x has to be recognised by `shell` rather than judged against 1.x's.
|
|
132
|
+
const names = new Set((body.tools ?? []).map((raw) => (raw.function ?? raw)?.name));
|
|
133
|
+
const generation = names.has("shell") ? "2.x" : "1.x";
|
|
134
|
+
const read = READ[generation];
|
|
135
|
+
|
|
114
136
|
const tools = {};
|
|
115
137
|
for (const raw of body.tools ?? []) {
|
|
116
138
|
const tool = raw.function ?? raw;
|
|
117
|
-
if (!tool?.name || !
|
|
139
|
+
if (!tool?.name || !read.includes(tool.name) || !tool.description) continue;
|
|
118
140
|
tools[tool.name] = {
|
|
119
141
|
name: tool.name,
|
|
120
142
|
description: NEUTRALISE.reduce((text, [find, to]) => text.replace(find, to), tool.description).slice(0, KEEP),
|
|
@@ -122,7 +144,7 @@ for (const raw of body.tools ?? []) {
|
|
|
122
144
|
};
|
|
123
145
|
}
|
|
124
146
|
|
|
125
|
-
const missing =
|
|
147
|
+
const missing = read.filter((name) => !tools[name]);
|
|
126
148
|
if (missing.length) {
|
|
127
149
|
console.error(
|
|
128
150
|
`the captured request has no ${missing.join(", ")}; this OpenCode no longer sends them, so it cannot be captured this way`,
|
|
@@ -135,8 +157,6 @@ for (const [name, tool] of Object.entries(tools))
|
|
|
135
157
|
process.exit(1);
|
|
136
158
|
}
|
|
137
159
|
|
|
138
|
-
// Which generation this client is: the names it sends say so.
|
|
139
|
-
const generation = body.tools.some((t) => (t.function ?? t).name === "shell") ? "2.x" : "1.x";
|
|
140
160
|
const required = generation === "2.x" ? ["shell", "read"] : ["bash", "glob", "grep", "read"];
|
|
141
161
|
const absent = required.filter((name) => !tools[name]);
|
|
142
162
|
if (absent.length) {
|
|
@@ -146,6 +166,14 @@ if (absent.length) {
|
|
|
146
166
|
|
|
147
167
|
// Which protocol was captured, since each of 1.x's protocols bundles its own
|
|
148
168
|
// provider-utils and a capture must not overwrite the ones it did not observe.
|
|
169
|
+
// Anthropic is not one this can read: its tools carry `input_schema` where the
|
|
170
|
+
// other two carry `parameters`, so read as the chat one it would write every
|
|
171
|
+
// tool without its parameters and take the chat slot's provider-utils from an
|
|
172
|
+
// anthropic client. Refused rather than written.
|
|
173
|
+
if ((body.tools ?? []).some((raw) => (raw.function ?? raw)?.input_schema !== undefined)) {
|
|
174
|
+
console.error("the captured request is in the anthropic shape, which this script cannot read; nothing written");
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
149
177
|
const protocol = body.input !== undefined && body.messages === undefined ? "responses" : "chat";
|
|
150
178
|
|
|
151
179
|
console.log(`\nagent: ${request.agent}`);
|
package/index.mjs
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
import plugin from "./provider.mjs"
|
|
2
2
|
|
|
3
|
+
// Only the `server` factory is forwarded: that is the hook a magpie host builds
|
|
4
|
+
// a plugin from, so the provider's `id` is deliberately not repeated here. Say
|
|
5
|
+
// so where the mismatch is, rather than handing the host an undefined plugin to
|
|
6
|
+
// fail with something opaque later.
|
|
7
|
+
if (typeof plugin?.server !== "function") {
|
|
8
|
+
throw new TypeError("provider.mjs must default-export a plugin with a `server` factory")
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
export const ZenFreePlugin = plugin.server
|
package/limits-audit.mjs
CHANGED
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
// prompt of about a million tokens — minutes and megabytes — and belongs to the
|
|
17
17
|
// one id a signal has already pointed at; the numbers live in limits.mjs and are
|
|
18
18
|
// put there by a measurement, never by a script. What this does ask is cheap:
|
|
19
|
-
// one request of `max_tokens: 1000000` per offered id
|
|
20
|
-
// starts writing. An upstream that validates the
|
|
21
|
-
// own; one that does not just begins to answer and
|
|
22
|
-
// chunk. Either way the cost is a handful of tokens,
|
|
23
|
-
// model that is gone is visible in the same request.
|
|
19
|
+
// one request of `max_tokens: 1000000` per offered id that speaks the chat
|
|
20
|
+
// shape, stopped as soon as it starts writing. An upstream that validates the
|
|
21
|
+
// ceiling refuses and names its own; one that does not just begins to answer and
|
|
22
|
+
// is cut off after the first chunk. Either way the cost is a handful of tokens,
|
|
23
|
+
// and a 410 or a 400 for a model that is gone is visible in the same request.
|
|
24
|
+
// An id the snapshot places on another protocol is listed and not asked.
|
|
24
25
|
//
|
|
25
26
|
// The offered set is the listing's, not the catalogue's: Zen's /v1/models
|
|
26
27
|
// carries 10 of the 40 free ids the catalogue still describes, and a plugin
|
|
@@ -34,8 +35,10 @@
|
|
|
34
35
|
// Exit code is 0 when at least one offered id answered, 1 when none did: being
|
|
35
36
|
// unable to ask is not a finding about limits.
|
|
36
37
|
|
|
38
|
+
import { fileURLToPath } from "node:url";
|
|
37
39
|
import { PROFILES } from "./core-tools.mjs";
|
|
38
40
|
import { LIMITS, limitOverrides, namedContextWindow, namedOutputCap } from "./limits.mjs";
|
|
41
|
+
import { SDK, bootstrapProtocols, isFree } from "./models.mjs";
|
|
39
42
|
import { CATALOG, DEFAULT_BASE } from "./provider.mjs";
|
|
40
43
|
import { chatRequest } from "./tools-smoke.mjs";
|
|
41
44
|
|
|
@@ -44,14 +47,26 @@ const CATALOG_URL = process.env.ZEN_CATALOG_URL ?? CATALOG;
|
|
|
44
47
|
const GENERATION = process.env.ZEN_GENERATION ?? "1.x";
|
|
45
48
|
const READ_BUDGET = 4096;
|
|
46
49
|
const ASKED_CEILING = 1_000_000;
|
|
50
|
+
// How many ids are asked at once. Each ask stops as soon as the answer starts,
|
|
51
|
+
// so the connection and the upstream load stay modest while the 45 s abort of
|
|
52
|
+
// one id no longer holds up every id behind it.
|
|
53
|
+
const POOL = 4;
|
|
47
54
|
|
|
48
55
|
// The ids the plugin would offer: the listing's, kept where the catalogue calls
|
|
49
|
-
// them free
|
|
50
|
-
|
|
56
|
+
// them free — or, for an id neither the catalogue nor the docs describe, where
|
|
57
|
+
// the id names itself free and nothing else names its protocol, which is what
|
|
58
|
+
// models.mjs offers on its own say-so. An id the snapshot already calls
|
|
59
|
+
// something this plugin has no SDK for is not offered by anyone: `jev-1.13-free`
|
|
60
|
+
// is SystemOne, and stays out here for the same reason buildModels leaves it out.
|
|
61
|
+
// "Free" is models.mjs's own answer, retirement included, so the audited set and
|
|
62
|
+
// the offered set cannot drift apart. Exported for the tests.
|
|
63
|
+
export function offeredIds(listing, catalog, snapshot = bootstrapProtocols) {
|
|
51
64
|
return listing.filter((id) => {
|
|
65
|
+
const protocol = snapshot?.[id];
|
|
66
|
+
if (protocol !== undefined && !Object.hasOwn(SDK, protocol)) return false;
|
|
52
67
|
const model = catalog?.[id];
|
|
53
|
-
if (!model) return
|
|
54
|
-
return
|
|
68
|
+
if (!model) return /-free$/.test(id);
|
|
69
|
+
return isFree(id, model);
|
|
55
70
|
});
|
|
56
71
|
}
|
|
57
72
|
|
|
@@ -87,15 +102,16 @@ export function findings(rows, covered = new Set()) {
|
|
|
87
102
|
`${row.id}: a refusal named a window of ${row.window}, the catalogue declares ${declared.context ?? "none"}`,
|
|
88
103
|
);
|
|
89
104
|
const named = row.ceiling !== undefined || row.window !== undefined;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
// A model that does not speak the shape this audit asks with is not a limits
|
|
106
|
+
// finding and must not sit in this list for ever: it is said once, beside the
|
|
107
|
+
// ids that were not asked at all.
|
|
108
|
+
if (protocolScoped(row)) continue;
|
|
109
|
+
if (row.status !== 200 && row.status !== 0 && !named) {
|
|
110
|
+
// What is left is worth reading: a 410, and a 400 that named nothing. A 0
|
|
111
|
+
// is a question that was never answered — timeout, DNS, a closed socket —
|
|
112
|
+
// and the audit's own rule is that being unable to ask is not a finding
|
|
113
|
+
// about limits, so the id, its status and its message stay in the table.
|
|
114
|
+
out.push(`${row.id}: HTTP ${row.status}${row.says ? ` — ${row.says}` : ""}`);
|
|
99
115
|
}
|
|
100
116
|
}
|
|
101
117
|
for (const [upstream, list] of groupByUpstream(rows)) {
|
|
@@ -127,6 +143,19 @@ function says(text) {
|
|
|
127
143
|
return flat ? flat.slice(0, 160) : "";
|
|
128
144
|
}
|
|
129
145
|
|
|
146
|
+
// A refusal that names the protocol rather than a limit. This audit asks the
|
|
147
|
+
// chat shape, so a model that speaks Responses or Messages answers by saying so;
|
|
148
|
+
// that is the host's business, not a limit to measure. The wording has to name
|
|
149
|
+
// another shape or say it cannot be spoken at all: `format` alone is how an
|
|
150
|
+
// ordinary `invalid request format` is worded, which is a question that was
|
|
151
|
+
// asked and refused and belongs in the candidate list, not beside the ids that
|
|
152
|
+
// were never asked.
|
|
153
|
+
const OTHER_SHAPE = /protocol|\b(?:responses|messages)\b|anthropic/i;
|
|
154
|
+
|
|
155
|
+
export function protocolScoped(row) {
|
|
156
|
+
return OTHER_SHAPE.test(row?.says ?? "");
|
|
157
|
+
}
|
|
158
|
+
|
|
130
159
|
async function getJSON(url) {
|
|
131
160
|
const response = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
|
132
161
|
if (!response.ok) throw new Error(`${url} answered HTTP ${response.status}`);
|
|
@@ -169,12 +198,17 @@ async function ask(id, names) {
|
|
|
169
198
|
}
|
|
170
199
|
const upstream = response.headers.get("x-opencode-upstream-model-id") ?? undefined;
|
|
171
200
|
const endpoint = response.headers.get("x-opencode-endpoint-id") ?? undefined;
|
|
201
|
+
// limits.mjs reads refusals, and a refusal is the only place this route
|
|
202
|
+
// states a limit. The body of a 200 is the model's own prose, which an
|
|
203
|
+
// answer about token limits — or one that merely mentions them — would turn
|
|
204
|
+
// into a ceiling nobody stated, and a `named` row then skips its status line
|
|
205
|
+
// in findings() as well.
|
|
172
206
|
return {
|
|
173
207
|
status: response.status,
|
|
174
208
|
upstream,
|
|
175
209
|
endpoint,
|
|
176
|
-
window: namedContextWindow(text),
|
|
177
|
-
ceiling: namedOutputCap(text),
|
|
210
|
+
window: response.ok ? undefined : namedContextWindow(text),
|
|
211
|
+
ceiling: response.ok ? undefined : namedOutputCap(text),
|
|
178
212
|
says: response.ok ? "" : says(text),
|
|
179
213
|
};
|
|
180
214
|
} catch (error) {
|
|
@@ -185,9 +219,54 @@ async function ask(id, names) {
|
|
|
185
219
|
}
|
|
186
220
|
}
|
|
187
221
|
|
|
222
|
+
// Whether this module is the file that was run rather than one the tests
|
|
223
|
+
// import. `import.meta.main` is the direct answer and the one Bun gives, but it
|
|
224
|
+
// arrived in Node 22.18 and nothing before that has it — and an audit that cannot
|
|
225
|
+
// tell whether it was run is an audit that reports success without asking.
|
|
226
|
+
function invokedDirectly() {
|
|
227
|
+
// `typeof` rather than `??`: a runtime without the property throws on the
|
|
228
|
+
// member access itself, where there is nothing to fall back from.
|
|
229
|
+
if (typeof import.meta.main === "boolean") return import.meta.main;
|
|
230
|
+
try {
|
|
231
|
+
return process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1];
|
|
232
|
+
} catch {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
188
237
|
// Only a run asks the route. The tests import the helpers above and must not
|
|
189
238
|
// reach the network.
|
|
190
|
-
|
|
239
|
+
const entry = invokedDirectly();
|
|
240
|
+
if (entry === undefined) {
|
|
241
|
+
console.error(
|
|
242
|
+
"Could not tell whether this file was run or imported, so nothing was asked: neither import.meta.main nor process.argv[1] named an entry point. Run it with `bun run limits:audit`.",
|
|
243
|
+
);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (entry) {
|
|
248
|
+
// A generation nobody captured is a typo or a Zen that has moved on, and is
|
|
249
|
+
// worth saying in one line rather than throwing a TypeError out of the first
|
|
250
|
+
// line of the run.
|
|
251
|
+
if (!Object.hasOwn(PROFILES, GENERATION)) {
|
|
252
|
+
console.error(
|
|
253
|
+
`limits audit — ZEN_GENERATION "${GENERATION}" is not a captured generation (${Object.keys(PROFILES).join(", ")})`,
|
|
254
|
+
);
|
|
255
|
+
process.exitCode = 1;
|
|
256
|
+
} else {
|
|
257
|
+
try {
|
|
258
|
+
await run();
|
|
259
|
+
} catch (error) {
|
|
260
|
+
// Being unable to ask is not a finding about limits, so it is a sentence
|
|
261
|
+
// and an exit code rather than a stack trace about a fetch that failed.
|
|
262
|
+
console.error(`\nlimits audit — unable to ask: ${error?.message ?? error}`);
|
|
263
|
+
console.error("The route or the catalogue could not be read. Check the network and ZEN_BASE_URL, then ask again.");
|
|
264
|
+
process.exitCode = 1;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function run() {
|
|
191
270
|
const names = [...PROFILES[GENERATION].required];
|
|
192
271
|
const [listing, catalog] = await Promise.all([getJSON(`${BASE}/models`), getJSON(CATALOG_URL)]);
|
|
193
272
|
const models = catalog?.opencode?.models ?? {};
|
|
@@ -202,20 +281,36 @@ if (import.meta.main) {
|
|
|
202
281
|
console.log(`${ids.length} free ids are offered; the catalogue still describes ${free.length}\n`);
|
|
203
282
|
|
|
204
283
|
const rows = [];
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
284
|
+
// This asks the chat shape only. An id the snapshot places on another protocol
|
|
285
|
+
// is left to the host that speaks it, and said once rather than reported as a
|
|
286
|
+
// finding it cannot be.
|
|
287
|
+
const asked = ids.filter((id) => (bootstrapProtocols[id] ?? "chat") === "chat");
|
|
288
|
+
const elsewhere = ids.filter((id) => !asked.includes(id));
|
|
289
|
+
// The asks are independent, and each one can sit until its 45 s abort, so a
|
|
290
|
+
// few run together: 4 at a time overlaps the waiting without opening a
|
|
291
|
+
// connection per id. Answers are gathered first and printed in `asked` order,
|
|
292
|
+
// which is the order the listing gave.
|
|
293
|
+
for (let at = 0; at < asked.length; at += POOL) {
|
|
294
|
+
const batch = await Promise.all(
|
|
295
|
+
asked.slice(at, at + POOL).map(async (id) => {
|
|
296
|
+
const answer = await ask(id, names);
|
|
297
|
+
return { id, declared: models[id]?.limit ?? {}, ...answer };
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
rows.push(...batch);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// What the plugin declares is the catalogue's limit with limits.mjs over it,
|
|
304
|
+
// so that is what is printed; the catalogue's own pair is shown beside it when
|
|
305
|
+
// the two differ, since that is the under-report being answered.
|
|
306
|
+
for (const { id, declared, ...answer } of rows) {
|
|
212
307
|
const effective = { ...declared, ...limitOverrides(id, declared) };
|
|
213
|
-
const moved =
|
|
214
|
-
|
|
308
|
+
const moved = effective.context !== declared.context || effective.output !== declared.output;
|
|
309
|
+
const pair = (limit) => `${limit.context ?? "—"}/${limit.output ?? "—"}`;
|
|
215
310
|
const mark = answer.status === 200 ? " " : "! ";
|
|
216
311
|
console.log(
|
|
217
|
-
`${mark}${id.padEnd(34)} ${
|
|
218
|
-
`${moved ? ` (catalogue ${declared
|
|
312
|
+
`${mark}${id.padEnd(34)} ${pair(effective).padStart(17)}` +
|
|
313
|
+
`${moved ? ` (catalogue ${pair(declared)})` : ""} ${answer.status}` +
|
|
219
314
|
`${answer.upstream ? ` ${answer.upstream}${answer.endpoint ? ` via ${answer.endpoint}` : ""}` : ""}` +
|
|
220
315
|
`${answer.ceiling !== undefined ? ` ceiling=${answer.ceiling}` : ""}${answer.window !== undefined ? ` window=${answer.window}` : ""}` +
|
|
221
316
|
`${answer.says ? ` — ${answer.says}` : ""}`,
|
|
@@ -224,6 +319,11 @@ if (import.meta.main) {
|
|
|
224
319
|
|
|
225
320
|
const answered = rows.filter((row) => row.status === 200);
|
|
226
321
|
const candidates = findings(rows, new Set(Object.keys(LIMITS)));
|
|
322
|
+
const skipped = [...elsewhere, ...rows.filter(protocolScoped).map((row) => row.id)];
|
|
323
|
+
if (skipped.length)
|
|
324
|
+
console.log(
|
|
325
|
+
`\nnot asked, this audit asks the chat shape only: ${skipped.map((id) => `${id}${bootstrapProtocols[id] ? ` (${bootstrapProtocols[id]})` : ""}`).join(", ")}`,
|
|
326
|
+
);
|
|
227
327
|
console.log(`\nlimits.mjs already carries evidence for: ${Object.keys(LIMITS).join(", ")}`);
|
|
228
328
|
console.log(`\n${candidates.length} candidate${candidates.length === 1 ? "" : "s"}:`);
|
|
229
329
|
for (const line of candidates) console.log(` ${line}`);
|
|
@@ -231,7 +331,9 @@ if (import.meta.main) {
|
|
|
231
331
|
|
|
232
332
|
if (!answered.length) {
|
|
233
333
|
console.log(
|
|
234
|
-
|
|
334
|
+
asked.length
|
|
335
|
+
? "\nNothing answered, so this says nothing about limits. Check the network or ZEN_BASE_URL and ask again."
|
|
336
|
+
: "\nNo offered id speaks the chat shape, so nothing was asked and this says nothing about limits.",
|
|
235
337
|
);
|
|
236
338
|
process.exitCode = 1;
|
|
237
339
|
}
|
package/limits.mjs
CHANGED
|
@@ -158,17 +158,24 @@ function namedNumber(pattern, text) {
|
|
|
158
158
|
// responses are read, and only the first `limit` characters of one: this is an
|
|
159
159
|
// error path on the request's way to a caller that is waiting, so it must never
|
|
160
160
|
// be able to fail a request, change a response, or buffer a body that is not
|
|
161
|
-
// small.
|
|
162
|
-
//
|
|
161
|
+
// small. A number the read cut in half names nothing either, since half of one
|
|
162
|
+
// is a smaller limit that looks like a whole one. Returns undefined when the
|
|
163
|
+
// body names nothing attributable, otherwise whichever axes it named.
|
|
163
164
|
export async function observeRouteStatement(id, response, limit = 4096) {
|
|
164
165
|
try {
|
|
165
166
|
if (typeof id !== "string" || !id || response?.ok !== false) return undefined;
|
|
166
|
-
const text = await response
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
167
|
+
const text = await readPrefix(response, limit);
|
|
168
|
+
// `readPrefix` stops at `limit`, so the last character read may be the first
|
|
169
|
+
// half of a longer number. Both words this reads are numbers inside prose —
|
|
170
|
+
// the window's ends in the literal "tokens", but the second ceiling wording
|
|
171
|
+
// ends with the digits it captured, so `/max_tokens: 1000000 is not less or
|
|
172
|
+
// equal to 2621` carved out of 262144 reads as a ceiling Zen stated. Live
|
|
173
|
+
// values are taken as they stand, past the staleness check and the guard
|
|
174
|
+
// below, so that half a number would become the applied max_tokens. A match
|
|
175
|
+
// that runs up to the cut is therefore not evidence, on either axis: asking
|
|
176
|
+
// again over one character less only finds the same number one digit
|
|
177
|
+
// shorter, and reading the rest of the body is what this path must not do.
|
|
178
|
+
const { context, output } = namedAxes(text, limit, text.length > limit);
|
|
172
179
|
if (context === undefined && output === undefined) return undefined;
|
|
173
180
|
if (context !== undefined) OBSERVED.set(id, context);
|
|
174
181
|
if (output !== undefined) OBSERVED_OUTPUT.set(id, output);
|
|
@@ -178,6 +185,42 @@ export async function observeRouteStatement(id, response, limit = 4096) {
|
|
|
178
185
|
}
|
|
179
186
|
}
|
|
180
187
|
|
|
188
|
+
// Read at most `limit` characters of a refusal without buffering the rest: this
|
|
189
|
+
// is an error path on a request whose caller is waiting, so a body that is not
|
|
190
|
+
// small is dropped as soon as the part that is read is in hand.
|
|
191
|
+
async function readPrefix(response, limit) {
|
|
192
|
+
const body = response.clone().body;
|
|
193
|
+
if (typeof body?.getReader !== "function") return response.clone().text().catch(() => "");
|
|
194
|
+
const reader = body.getReader();
|
|
195
|
+
const decoder = new TextDecoder();
|
|
196
|
+
let text = "";
|
|
197
|
+
try {
|
|
198
|
+
while (text.length < limit) {
|
|
199
|
+
const { value, done } = await reader.read();
|
|
200
|
+
if (done) break;
|
|
201
|
+
text += decoder.decode(value, { stream: true });
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// A body that fails halfway is still worth what was read of it.
|
|
205
|
+
}
|
|
206
|
+
await reader.cancel().catch(() => {});
|
|
207
|
+
return text;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The axes the first `limit` characters of `text` name. `cut` says the body went
|
|
211
|
+
// on past that point, so a match ending on the last character read may have been
|
|
212
|
+
// a longer number: that axis is left unread rather than reported a digit short.
|
|
213
|
+
function namedAxes(text, limit, cut) {
|
|
214
|
+
const head = text.slice(0, limit);
|
|
215
|
+
const contextMatch = NAMED_CONTEXT.exec(head);
|
|
216
|
+
const outputMatch = NAMED_OUTPUT.map((pattern) => pattern.exec(head)).find(Boolean);
|
|
217
|
+
const onCut = (match) => cut && Boolean(match) && match.index + match[0].length >= limit;
|
|
218
|
+
return {
|
|
219
|
+
context: onCut(contextMatch) ? undefined : namedContextWindow(head),
|
|
220
|
+
output: onCut(outputMatch) ? undefined : namedOutputCap(head),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
181
224
|
const ROUTE_TIERS = new Set(["route", "route-twin"]);
|
|
182
225
|
|
|
183
226
|
// What this plugin declares for a model instead of what the catalogue says, as
|
|
@@ -199,12 +242,26 @@ export function limitOverrides(
|
|
|
199
242
|
const live = (axis === "context" ? observed : observedOutput)?.get(id);
|
|
200
243
|
// A route statement made while this process was running is the same tier as
|
|
201
244
|
// the table and newer than it, so it is taken as it stands. Everything else
|
|
202
|
-
// has to have been measured against the catalogue in front of us now
|
|
203
|
-
|
|
204
|
-
|
|
245
|
+
// has to have been measured against the catalogue in front of us now: the
|
|
246
|
+
// value it was measured against has to exist and still be the declaration,
|
|
247
|
+
// and one that names no such value was measured against nothing.
|
|
248
|
+
let value;
|
|
249
|
+
if (live !== undefined) {
|
|
250
|
+
value = live;
|
|
251
|
+
} else if (entry && declaredValue !== undefined && entry.declaredAt === declaredValue) {
|
|
252
|
+
value = entry.value;
|
|
253
|
+
}
|
|
205
254
|
if (value === undefined || !Number.isSafeInteger(value) || value <= 0) continue;
|
|
206
255
|
if (value === declaredValue) continue;
|
|
207
|
-
|
|
256
|
+
// Weaker evidence may only raise, and that holds for the table alone: a live
|
|
257
|
+
// refusal is the route itself and is taken as it stands, entry or no entry.
|
|
258
|
+
if (
|
|
259
|
+
live === undefined &&
|
|
260
|
+
entry &&
|
|
261
|
+
!ROUTE_TIERS.has(entry.source) &&
|
|
262
|
+
Number.isSafeInteger(declaredValue) &&
|
|
263
|
+
value < declaredValue
|
|
264
|
+
)
|
|
208
265
|
continue;
|
|
209
266
|
overrides[axis] = value;
|
|
210
267
|
}
|
package/models.mjs
CHANGED
|
@@ -7,17 +7,21 @@ export const SDK = {
|
|
|
7
7
|
anthropic: "@ai-sdk/anthropic",
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
// The endpoint each table row names, as this file's names for them. A row that
|
|
11
|
+
// names one outside this set is not a protocol this plugin can ask.
|
|
12
|
+
const PROTOCOLS = {
|
|
13
|
+
"chat/completions": "chat",
|
|
14
|
+
responses: "responses",
|
|
15
|
+
messages: "anthropic",
|
|
16
|
+
systemone: "systemone",
|
|
17
|
+
};
|
|
18
|
+
|
|
10
19
|
export function parseProtocolDocs(text) {
|
|
11
20
|
const protocols = {};
|
|
12
21
|
const pattern =
|
|
13
22
|
/\|[^|]+\|\s*`?([^|`\s]+)`?\s*\|\s*`[^`]+\/v1\/(chat\/completions|responses|messages|systemone)`/g;
|
|
14
23
|
for (const match of text.matchAll(pattern)) {
|
|
15
|
-
protocols[match[1]] =
|
|
16
|
-
"chat/completions": "chat",
|
|
17
|
-
responses: "responses",
|
|
18
|
-
messages: "anthropic",
|
|
19
|
-
systemone: "systemone",
|
|
20
|
-
}[match[2]];
|
|
24
|
+
protocols[match[1]] = PROTOCOLS[match[2]];
|
|
21
25
|
}
|
|
22
26
|
return protocols;
|
|
23
27
|
}
|
|
@@ -28,35 +32,70 @@ function sdkProtocol(npm) {
|
|
|
28
32
|
if (npm?.includes("openai-compatible")) return "chat";
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
// Set, and not merely present: the catalogue writes both a missing date and an
|
|
36
|
+
// explicit null, and neither retires a model.
|
|
37
|
+
const hasValue = (value) => value !== null && value !== undefined;
|
|
38
|
+
|
|
31
39
|
function isRetired(model) {
|
|
32
40
|
return (
|
|
33
41
|
model.deprecated === true ||
|
|
34
42
|
["deprecated", "retired", "disabled"].includes(model.status ?? model.lifecycle) ||
|
|
35
|
-
model.deprecated_at
|
|
36
|
-
model.retirement_date
|
|
43
|
+
hasValue(model.deprecated_at) ||
|
|
44
|
+
hasValue(model.retirement_date)
|
|
37
45
|
);
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
export function isFree(id, model = {}) {
|
|
41
49
|
return (
|
|
42
|
-
!isRetired(model) && (
|
|
50
|
+
!isRetired(model) && (/-free$/i.test(id) || (model.cost?.input === 0 && model.cost?.output === 0))
|
|
43
51
|
);
|
|
44
52
|
}
|
|
45
53
|
|
|
54
|
+
// Whether an id names itself free — Zen names them "-free" — which is all this
|
|
55
|
+
// can know about one that neither the catalogue nor the docs describe yet: Zen
|
|
56
|
+
// made it free after they were last written, or they could not be read. Such a
|
|
57
|
+
// model is asked the way OpenCode asks a model with no package of its own,
|
|
58
|
+
// through the provider's own (openai-compatible, and so chat), so a new free
|
|
59
|
+
// model is offered the hour Zen lists it rather than the hour the catalogue
|
|
60
|
+
// catches up. An id that does not call itself free still needs the catalogue's
|
|
61
|
+
// word (a paid one, or a zero-cost one Zen never named).
|
|
62
|
+
function listedFree(id) {
|
|
63
|
+
return /-free$/.test(id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Which protocol a model is asked at, in the order the answer is trusted: the
|
|
67
|
+
// console docs' own endpoint first, then the package the catalogue gives the
|
|
68
|
+
// model (or its provider), and last the provider's own protocol for an id that
|
|
69
|
+
// names itself free and neither of those describes. Nothing here is a guess at
|
|
70
|
+
// an endpoint — the fallback is openai-compatible, which is what the provider
|
|
71
|
+
// speaks. A protocol outside `SDK` leaves the model out below rather than
|
|
72
|
+
// sending it somewhere it is not served: `systemone` and a model the catalogue
|
|
73
|
+
// drops do not appear.
|
|
74
|
+
function protocolFor(id, model, raw, provider, protocols) {
|
|
75
|
+
if (protocols[id]) return protocols[id];
|
|
76
|
+
if (raw) return sdkProtocol(model.provider?.npm ?? provider.npm);
|
|
77
|
+
if (listedFree(id)) return sdkProtocol(provider.npm) ?? "chat";
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const modalities = (values) =>
|
|
82
|
+
Object.fromEntries(
|
|
83
|
+
["text", "image", "audio", "video", "pdf"].map((name) => [
|
|
84
|
+
name,
|
|
85
|
+
Array.isArray(values) && values.includes(name),
|
|
86
|
+
]),
|
|
87
|
+
);
|
|
88
|
+
|
|
46
89
|
export function buildModels(ids, provider, protocols, baseURL) {
|
|
47
90
|
const result = {};
|
|
48
91
|
for (const id of ids) {
|
|
49
92
|
if (typeof id !== "string" || !id || id === "__proto__") continue;
|
|
50
93
|
const raw = provider.models?.[id];
|
|
51
94
|
const model = raw ?? {};
|
|
52
|
-
const protocol =
|
|
95
|
+
const protocol = protocolFor(id, model, raw, provider, protocols);
|
|
53
96
|
if (!isFree(id, model) || !Object.hasOwn(SDK, protocol)) continue;
|
|
54
97
|
const input = model.modalities?.input ?? ["text"];
|
|
55
98
|
const output = model.modalities?.output ?? ["text"];
|
|
56
|
-
const modalities = (values) =>
|
|
57
|
-
Object.fromEntries(
|
|
58
|
-
["text", "image", "audio", "video", "pdf"].map((name) => [name, values.includes(name)]),
|
|
59
|
-
);
|
|
60
99
|
const efforts = (model.reasoning_options ?? [])
|
|
61
100
|
.filter((option) => option.type === "effort")
|
|
62
101
|
.flatMap((option) => option.values ?? []);
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-zen-freex",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
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",
|
|
7
|
+
"exports": { ".": "./index.mjs", "./*": "./*" },
|
|
7
8
|
"files": [
|
|
8
9
|
"index.mjs",
|
|
9
10
|
"provider.mjs",
|
package/provider.mjs
CHANGED
|
@@ -140,10 +140,6 @@ function normalizeTools(body, protocol) {
|
|
|
140
140
|
// it carries say which: `shell` is 2.x, `bash` is 1.x. Names move far less
|
|
141
141
|
// often than descriptions do, so this survives a rewording that a pinned
|
|
142
142
|
// User-Agent would not.
|
|
143
|
-
// Zen takes a request for one generation of OpenCode or the other, and the tools
|
|
144
|
-
// it carries say which: `shell` is 2.x, `bash` is 1.x. Names move far less
|
|
145
|
-
// often than descriptions do, so this survives a rewording that a pinned
|
|
146
|
-
// User-Agent would not.
|
|
147
143
|
const generation = generationOf(body.tools) ?? DEFAULT_GENERATION;
|
|
148
144
|
const profile = PROFILES[generation];
|
|
149
145
|
const filled = [];
|
|
@@ -187,21 +183,22 @@ function normalizeTools(body, protocol) {
|
|
|
187
183
|
// on the other two, and which one a host used is its own business: it is
|
|
188
184
|
// matched by where the name was found, so a tool sent in the other shape still
|
|
189
185
|
// has its wording read and replaced.
|
|
190
|
-
function descriptionAt(tool) {
|
|
191
|
-
return tool?.function ? tool.function.description : tool?.description;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
186
|
function setDescription(tool, description) {
|
|
195
187
|
if (tool?.function) tool.function.description = description;
|
|
196
188
|
else if (tool) tool.description = description;
|
|
197
189
|
}
|
|
198
190
|
|
|
199
|
-
|
|
200
|
-
protocol === "chat"
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
:
|
|
191
|
+
function shapeOf(definition, protocol) {
|
|
192
|
+
if (protocol === "chat") return { type: "function", function: definition };
|
|
193
|
+
if (protocol === "anthropic") {
|
|
194
|
+
return {
|
|
195
|
+
name: definition.name,
|
|
196
|
+
description: definition.description,
|
|
197
|
+
input_schema: definition.parameters,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return { type: "function", ...definition };
|
|
201
|
+
}
|
|
205
202
|
|
|
206
203
|
// Zen's own wording for a refusal says only that the request did not look like
|
|
207
204
|
// OpenCode's, which sends people off to change headers. Everything the plugin
|
|
@@ -321,7 +318,10 @@ async function server(_input, options = {}) {
|
|
|
321
318
|
);
|
|
322
319
|
let metadata = bootstrapProvider;
|
|
323
320
|
let protocols = { ...bootstrapProtocols };
|
|
324
|
-
|
|
321
|
+
// When the list was last read, or last asked for: 0 until the first ask. A
|
|
322
|
+
// failed ask sets it too, so a host whose listing can't be read waits out the
|
|
323
|
+
// same interval as one whose listing can, instead of asking three times with
|
|
324
|
+
// a 15 s timeout each on every request that names a model it doesn't know.
|
|
325
325
|
let listedAt = 0;
|
|
326
326
|
|
|
327
327
|
async function discover(signal) {
|
|
@@ -346,6 +346,7 @@ async function server(_input, options = {}) {
|
|
|
346
346
|
partial = true;
|
|
347
347
|
}
|
|
348
348
|
if (listing.status !== "fulfilled" || !Array.isArray(listing.value?.data)) {
|
|
349
|
+
listedAt = Date.now();
|
|
349
350
|
const fallback = { ...known };
|
|
350
351
|
fallback[Symbol.for("magpie.fellBack")] = true;
|
|
351
352
|
return fallback;
|
|
@@ -359,10 +360,15 @@ async function server(_input, options = {}) {
|
|
|
359
360
|
discovered[id] = known[id];
|
|
360
361
|
}
|
|
361
362
|
}
|
|
362
|
-
|
|
363
|
+
// Deliberately not said as fallen back. Zen's own list was read, and what
|
|
364
|
+
// it lists is what gets offered; said as a fallback, magpie keeps the last
|
|
365
|
+
// list it read in full instead, and a host that cannot reach the catalogue
|
|
366
|
+
// or the docs — GitHub's raw host, from mainland China — would never see a
|
|
367
|
+
// model Zen made free since (yetone/magpie#2385). A listing that could not
|
|
368
|
+
// be read is still said so, above, because then this really is the last
|
|
369
|
+
// list there was.
|
|
363
370
|
}
|
|
364
371
|
known = discovered;
|
|
365
|
-
listed = true;
|
|
366
372
|
listedAt = Date.now();
|
|
367
373
|
return discovered;
|
|
368
374
|
}
|
|
@@ -410,10 +416,7 @@ async function server(_input, options = {}) {
|
|
|
410
416
|
const body = await request.json();
|
|
411
417
|
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
412
418
|
throw new Error("Expected a JSON request object");
|
|
413
|
-
if (
|
|
414
|
-
!Object.hasOwn(known, body.model) &&
|
|
415
|
-
(!listed || Date.now() - listedAt >= RELIST_AFTER)
|
|
416
|
-
) {
|
|
419
|
+
if (!Object.hasOwn(known, body.model) && Date.now() - listedAt >= RELIST_AFTER) {
|
|
417
420
|
await discover(request.signal);
|
|
418
421
|
}
|
|
419
422
|
const model = Object.hasOwn(known, body.model) ? known[body.model] : undefined;
|
package/stream.mjs
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
const MAX_BYTES = 32 * 1024 * 1024;
|
|
2
2
|
|
|
3
|
+
// The headers that describe the bytes a reply arrived as. A reply whose body
|
|
4
|
+
// is written again — a stream collapsed into one JSON document, or a guarded
|
|
5
|
+
// stream whose frames may be rewritten or added to — no longer describes
|
|
6
|
+
// those bytes: a length, an encoding, hop-by-hop framing, and the validators
|
|
7
|
+
// a cache or a conditional request would compare against all describe the
|
|
8
|
+
// old ones only. What says something about the reply itself (a retry-after,
|
|
9
|
+
// a request id) is left alone. One list, so the two places that write a body
|
|
10
|
+
// again cannot drift apart.
|
|
11
|
+
export const STALE_BODY_HEADERS = [
|
|
12
|
+
"content-length",
|
|
13
|
+
"content-encoding",
|
|
14
|
+
"transfer-encoding",
|
|
15
|
+
"content-md5",
|
|
16
|
+
"etag",
|
|
17
|
+
"last-modified",
|
|
18
|
+
];
|
|
19
|
+
|
|
3
20
|
function jsonResponse(source, protocol, value, error) {
|
|
4
21
|
const headers = new Headers(source.headers);
|
|
5
|
-
headers.delete(
|
|
6
|
-
headers.delete("content-encoding");
|
|
22
|
+
for (const name of STALE_BODY_HEADERS) headers.delete(name);
|
|
7
23
|
headers.set("content-type", "application/json");
|
|
8
24
|
if (error) {
|
|
9
25
|
const detail = { type: "upstream_error", message: error };
|
|
@@ -108,12 +124,15 @@ function responsesAccumulator() {
|
|
|
108
124
|
let body = { object: "response", output: [] };
|
|
109
125
|
let final;
|
|
110
126
|
const output = new Map();
|
|
127
|
+
// The slot a frame belongs to: the output_index it carries, or the slot
|
|
128
|
+
// of the item it names when the upstream left output_index out.
|
|
129
|
+
function slot(data, id) {
|
|
130
|
+
if (data.output_index !== undefined) return index(data.output_index);
|
|
131
|
+
const position = id ? [...output].find(([, value]) => value.id === id)?.[0] : undefined;
|
|
132
|
+
return index(position);
|
|
133
|
+
}
|
|
111
134
|
function item(data, type = "message") {
|
|
112
|
-
|
|
113
|
-
if (position === undefined && data.item_id) {
|
|
114
|
-
position = [...output].find(([, value]) => value.id === data.item_id)?.[0];
|
|
115
|
-
}
|
|
116
|
-
position = index(position);
|
|
135
|
+
const position = slot(data, data.item_id);
|
|
117
136
|
if (!output.has(position)) {
|
|
118
137
|
const value = { type };
|
|
119
138
|
if (data.item_id) value.id = data.item_id;
|
|
@@ -133,7 +152,7 @@ function responsesAccumulator() {
|
|
|
133
152
|
output.set(position, value);
|
|
134
153
|
}
|
|
135
154
|
} else if (type === "response.output_item.added" || type === "response.output_item.done") {
|
|
136
|
-
if (data.item) output.set(
|
|
155
|
+
if (data.item) output.set(slot(data, data.item.id), data.item);
|
|
137
156
|
} else if (type === "response.content_part.added" || type === "response.content_part.done") {
|
|
138
157
|
const value = item(data);
|
|
139
158
|
value.content ??= [];
|
|
@@ -239,8 +258,14 @@ function anthropicAccumulator() {
|
|
|
239
258
|
},
|
|
240
259
|
finish() {
|
|
241
260
|
for (const [position, value] of argumentsJSON) {
|
|
261
|
+
// An upstream may send an empty delta set for a call that takes no
|
|
262
|
+
// arguments: there are no bytes to parse, and the block's own input
|
|
263
|
+
// stands. JSON.parse("") would fail a valid call.
|
|
264
|
+
if (!value.trim()) continue;
|
|
265
|
+
const block = blocks.get(position);
|
|
266
|
+
if (!block) throw new Error("Tool input for no content block in upstream stream");
|
|
242
267
|
try {
|
|
243
|
-
|
|
268
|
+
block.input = JSON.parse(value);
|
|
244
269
|
} catch {
|
|
245
270
|
throw new Error("Invalid tool input JSON in upstream stream");
|
|
246
271
|
}
|
|
@@ -370,7 +395,7 @@ export async function collapseResponse(response, protocol) {
|
|
|
370
395
|
if (done) break;
|
|
371
396
|
bytes += value.byteLength;
|
|
372
397
|
if (bytes > MAX_BYTES) {
|
|
373
|
-
failure =
|
|
398
|
+
failure = `Upstream SSE exceeds the ${MAX_BYTES / (1024 * 1024)} MiB limit`;
|
|
374
399
|
await reader.cancel();
|
|
375
400
|
break;
|
|
376
401
|
}
|
package/tool-response.mjs
CHANGED
|
@@ -1,21 +1,33 @@
|
|
|
1
|
+
import { STALE_BODY_HEADERS } from "./stream.mjs";
|
|
2
|
+
|
|
1
3
|
const MAX_BYTES = 32 * 1024 * 1024;
|
|
4
|
+
// The limit as a failure's message names it, from the one place it is set.
|
|
5
|
+
const LIMIT = `${MAX_BYTES / (1024 * 1024)} MiB`;
|
|
2
6
|
const encoder = new TextEncoder();
|
|
3
7
|
|
|
4
|
-
class GuardError extends Error {
|
|
8
|
+
class GuardError extends Error {
|
|
9
|
+
constructor(message, code = "upstream_error") {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
5
14
|
|
|
6
15
|
function headersFor(response) {
|
|
7
16
|
const headers = new Headers(response.headers);
|
|
8
|
-
headers.delete(
|
|
9
|
-
headers.delete("content-encoding");
|
|
17
|
+
for (const name of STALE_BODY_HEADERS) headers.delete(name);
|
|
10
18
|
return headers;
|
|
11
19
|
}
|
|
12
20
|
|
|
13
|
-
|
|
21
|
+
// failure answers the error a client reads. `code` says what went wrong
|
|
22
|
+
// where the shape has room for it: only a call of no tool the agent has is
|
|
23
|
+
// an undeclared one, and a limit, a truncation or a malformed upstream is
|
|
24
|
+
// not.
|
|
25
|
+
function failure(protocol, message, stream = false, code = "upstream_error") {
|
|
14
26
|
if (protocol === "anthropic") return { type: "error", error: { type: "api_error", message } };
|
|
15
27
|
if (protocol === "responses") {
|
|
16
28
|
return stream
|
|
17
|
-
? { type: "error", code
|
|
18
|
-
: { error: { type: "upstream_error", code
|
|
29
|
+
? { type: "error", code, message }
|
|
30
|
+
: { error: { type: "upstream_error", code, message } };
|
|
19
31
|
}
|
|
20
32
|
return { error: { message, type: "upstream_error" } };
|
|
21
33
|
}
|
|
@@ -198,7 +210,7 @@ function checkSnapshots(data, protocol, checkName) {
|
|
|
198
210
|
if (isCall(protocol, value)) {
|
|
199
211
|
const name = checkName(value.name);
|
|
200
212
|
if (name == null) {
|
|
201
|
-
if (!holder) throw new GuardError(droppedNote(value.name));
|
|
213
|
+
if (!holder) throw new GuardError(droppedNote(value.name), "undeclared_tool");
|
|
202
214
|
holder[key] = inPlaceOf(protocol, value, droppedNote(value.name));
|
|
203
215
|
changed = true;
|
|
204
216
|
left++;
|
|
@@ -267,7 +279,7 @@ function chatBuffer(checkName) {
|
|
|
267
279
|
bytes += size;
|
|
268
280
|
state.bytes += size;
|
|
269
281
|
if (bytes > MAX_BYTES)
|
|
270
|
-
throw new GuardError(
|
|
282
|
+
throw new GuardError(`Pending upstream tools exceed the ${LIMIT} buffer limit`);
|
|
271
283
|
}
|
|
272
284
|
function stateFor(index, data) {
|
|
273
285
|
if (!pending.has(index)) {
|
|
@@ -485,6 +497,35 @@ function snapshotStream(protocol, checkName) {
|
|
|
485
497
|
};
|
|
486
498
|
}
|
|
487
499
|
|
|
500
|
+
// A reply that is not a stream is read whole before it is checked, so it is
|
|
501
|
+
// read through the same limit a streamed reply's events are: a body past it
|
|
502
|
+
// is a 502, rather than the whole of it sitting in memory first.
|
|
503
|
+
async function readBounded(response) {
|
|
504
|
+
const reader = response.body?.getReader();
|
|
505
|
+
if (!reader) return "";
|
|
506
|
+
const parts = [];
|
|
507
|
+
let bytes = 0;
|
|
508
|
+
try {
|
|
509
|
+
while (true) {
|
|
510
|
+
const { done, value } = await reader.read();
|
|
511
|
+
if (done) break;
|
|
512
|
+
bytes += value.byteLength;
|
|
513
|
+
if (bytes > MAX_BYTES)
|
|
514
|
+
throw new GuardError(`Upstream response exceeds the ${LIMIT} buffer limit`);
|
|
515
|
+
parts.push(value);
|
|
516
|
+
}
|
|
517
|
+
} finally {
|
|
518
|
+
reader.releaseLock();
|
|
519
|
+
}
|
|
520
|
+
const all = new Uint8Array(bytes);
|
|
521
|
+
let offset = 0;
|
|
522
|
+
for (const part of parts) {
|
|
523
|
+
all.set(part, offset);
|
|
524
|
+
offset += part.length;
|
|
525
|
+
}
|
|
526
|
+
return new TextDecoder().decode(all);
|
|
527
|
+
}
|
|
528
|
+
|
|
488
529
|
function parseJSON(text) {
|
|
489
530
|
let value;
|
|
490
531
|
try {
|
|
@@ -519,7 +560,7 @@ async function* framesFrom(reader) {
|
|
|
519
560
|
function append(value) {
|
|
520
561
|
eventBytes += value.byteLength;
|
|
521
562
|
if (eventBytes > MAX_BYTES)
|
|
522
|
-
throw new GuardError(
|
|
563
|
+
throw new GuardError(`Upstream SSE event exceeds the ${LIMIT} buffer limit`);
|
|
523
564
|
if (value.byteLength) {
|
|
524
565
|
parts.push(value);
|
|
525
566
|
lineBytes += value.byteLength;
|
|
@@ -546,7 +587,7 @@ async function* framesFrom(reader) {
|
|
|
546
587
|
start = 1;
|
|
547
588
|
}
|
|
548
589
|
if (eventBytes > MAX_BYTES)
|
|
549
|
-
throw new GuardError(
|
|
590
|
+
throw new GuardError(`Upstream SSE event exceeds the ${LIMIT} buffer limit`);
|
|
550
591
|
skipLF = false;
|
|
551
592
|
if (pendingFrame) {
|
|
552
593
|
const frame = pendingFrame;
|
|
@@ -559,7 +600,7 @@ async function* framesFrom(reader) {
|
|
|
559
600
|
if (value[offset] !== 10 && value[offset] !== 13) continue;
|
|
560
601
|
append(value.subarray(start, offset));
|
|
561
602
|
if (++eventBytes > MAX_BYTES)
|
|
562
|
-
throw new GuardError(
|
|
603
|
+
throw new GuardError(`Upstream SSE event exceeds the ${LIMIT} buffer limit`);
|
|
563
604
|
const bytes = new Uint8Array(lineBytes);
|
|
564
605
|
let cursor = 0;
|
|
565
606
|
for (const part of parts) {
|
|
@@ -584,7 +625,7 @@ async function* framesFrom(reader) {
|
|
|
584
625
|
rawLine += "\n";
|
|
585
626
|
offset++;
|
|
586
627
|
if (++eventBytes > MAX_BYTES)
|
|
587
|
-
throw new GuardError(
|
|
628
|
+
throw new GuardError(`Upstream SSE event exceeds the ${LIMIT} buffer limit`);
|
|
588
629
|
} else if (offset + 1 === value.length) skipLF = true;
|
|
589
630
|
}
|
|
590
631
|
start = offset + 1;
|
|
@@ -826,7 +867,7 @@ function guardedStream(response, protocol, checkName, endCutShort) {
|
|
|
826
867
|
controller.enqueue(
|
|
827
868
|
encoder.encode(
|
|
828
869
|
encodeEvent(
|
|
829
|
-
failure(protocol, error.message, true),
|
|
870
|
+
failure(protocol, error.message, true, error.code),
|
|
830
871
|
protocol === "chat" ? "" : "error",
|
|
831
872
|
),
|
|
832
873
|
),
|
|
@@ -865,16 +906,15 @@ export async function guardToolResponse(response, protocol, policy, options = {}
|
|
|
865
906
|
headers,
|
|
866
907
|
});
|
|
867
908
|
}
|
|
868
|
-
const text = await response.text();
|
|
869
909
|
let value;
|
|
870
910
|
let status = response.status;
|
|
871
911
|
try {
|
|
872
|
-
value = parseJSON(
|
|
912
|
+
value = parseJSON(await readBounded(response));
|
|
873
913
|
checkSnapshots(value, protocol, checkName);
|
|
874
914
|
} catch (error) {
|
|
875
915
|
if (!(error instanceof GuardError)) throw error;
|
|
876
916
|
status = 502;
|
|
877
|
-
value = failure(protocol, error.message);
|
|
917
|
+
value = failure(protocol, error.message, false, error.code);
|
|
878
918
|
}
|
|
879
919
|
headers.set("content-type", "application/json");
|
|
880
920
|
return new Response(JSON.stringify(value), { status, headers });
|
package/tools-smoke.mjs
CHANGED
|
@@ -34,12 +34,15 @@
|
|
|
34
34
|
// answered Zen served the request; this shape is current
|
|
35
35
|
// refused Zen did not recognise this generation (403)
|
|
36
36
|
// superseded Zen wants a newer OpenCode than the one this profile claims (426)
|
|
37
|
+
// failed Zen answered something other than 403: the request was made, and
|
|
38
|
+
// what came back is not a verdict on the fingerprint
|
|
37
39
|
// unreachable the free tier could not be asked at all
|
|
38
40
|
//
|
|
39
41
|
// Exit code is 0 when at least one generation's floor is answered. A generation
|
|
40
42
|
// being refused is not a failure while another still works.
|
|
41
43
|
|
|
42
44
|
import { randomBytes } from "node:crypto";
|
|
45
|
+
import { fileURLToPath } from "node:url";
|
|
43
46
|
import { PROFILES, agentFor } from "./core-tools.mjs";
|
|
44
47
|
import { outboundHeaders } from "./provider.mjs";
|
|
45
48
|
|
|
@@ -90,7 +93,15 @@ export function chatRequest({ model, generation, profile, names, maxTokens = 40,
|
|
|
90
93
|
body: {
|
|
91
94
|
model,
|
|
92
95
|
messages: messages ?? [{ role: "user", content: "hi" }],
|
|
93
|
-
|
|
96
|
+
// A name in `required` that the profile does not carry is a broken
|
|
97
|
+
// fingerprint, not a request to ask Zen with: without this it is sent as
|
|
98
|
+
// `{"type":"function"}`, Zen refuses it as malformed, and the gate blames
|
|
99
|
+
// the wording instead of the file it came from.
|
|
100
|
+
tools: names.map((name) => {
|
|
101
|
+
const tool = profile.tools[name];
|
|
102
|
+
if (!tool) throw new Error(`${generation} has no tool named "${name}" in fingerprint.json`);
|
|
103
|
+
return { type: "function", function: tool };
|
|
104
|
+
}),
|
|
94
105
|
stream: true,
|
|
95
106
|
stream_options: { include_usage: true },
|
|
96
107
|
max_tokens: maxTokens,
|
|
@@ -117,20 +128,55 @@ async function check(generation, profile, shape, names) {
|
|
|
117
128
|
verdict: "superseded",
|
|
118
129
|
detail: "Zen wants a newer OpenCode than this profile claims",
|
|
119
130
|
};
|
|
120
|
-
|
|
131
|
+
// Only a 403 is the answer this gate reads: Zen quoting the fingerprint
|
|
132
|
+
// back. A 5xx, a 429 or a 401 says something about the service or the way
|
|
133
|
+
// it was asked, and reading any of them as "the fingerprint is stale" sends
|
|
134
|
+
// the reader to `capture` over a problem capturing cannot fix.
|
|
135
|
+
if (response.status === 403)
|
|
136
|
+
return { generation, shape, verdict: "refused", detail: `HTTP 403${says(text)}` };
|
|
137
|
+
return { generation, shape, verdict: "failed", detail: `HTTP ${response.status}${says(text)}` };
|
|
121
138
|
} catch (error) {
|
|
122
139
|
return { generation, shape, verdict: "unreachable", detail: error.message };
|
|
123
140
|
}
|
|
124
141
|
}
|
|
125
142
|
|
|
143
|
+
// Whether this module is the file that was run rather than one the tests
|
|
144
|
+
// import. `import.meta.main` is the direct answer and the one Bun gives, but it
|
|
145
|
+
// arrived in Node 22.18 and nothing before that has it — and a gate that cannot
|
|
146
|
+
// tell whether it was run is a gate that reports success without asking.
|
|
147
|
+
function invokedDirectly() {
|
|
148
|
+
// `typeof` rather than `??`: a runtime without the property throws on the
|
|
149
|
+
// member access itself, where there is nothing to fall back from.
|
|
150
|
+
if (typeof import.meta.main === "boolean") return import.meta.main;
|
|
151
|
+
try {
|
|
152
|
+
return process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1];
|
|
153
|
+
} catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
126
158
|
// Only a run asks Zen. index.test.mjs imports this module to hold its request
|
|
127
159
|
// against the plugin's, and an import must not reach the network.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
160
|
+
const entry = invokedDirectly();
|
|
161
|
+
if (entry === undefined) {
|
|
162
|
+
console.error(
|
|
163
|
+
"Could not tell whether this file was run or imported, so nothing was asked: neither import.meta.main nor process.argv[1] named an entry point. Run it with `bun run check:gate`.",
|
|
164
|
+
);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (entry) {
|
|
169
|
+
// Each request is independent and every result is printed in order, so the
|
|
170
|
+
// two shapes of every profile are asked at once rather than one abort's
|
|
171
|
+
// worth of waiting after another.
|
|
172
|
+
const results = (
|
|
173
|
+
await Promise.all(
|
|
174
|
+
Object.entries(PROFILES).map(async ([generation, profile]) => [
|
|
175
|
+
await check(generation, profile, "floor", [...profile.required]),
|
|
176
|
+
await check(generation, profile, "full", Object.keys(profile.tools)),
|
|
177
|
+
]),
|
|
178
|
+
)
|
|
179
|
+
).flat();
|
|
134
180
|
|
|
135
181
|
const width = Math.max(...results.map((result) => result.generation.length));
|
|
136
182
|
for (const result of results)
|
|
@@ -159,13 +205,26 @@ if (import.meta.main) {
|
|
|
159
205
|
if (!served.length) {
|
|
160
206
|
// Being unable to ask is not an answer. Saying the fingerprint has gone stale
|
|
161
207
|
// when nothing was reached sends the reader to `capture` over a dead network.
|
|
162
|
-
|
|
208
|
+
// Only 403 and 426 are Zen answering about the fingerprint at all: a 5xx or a
|
|
209
|
+
// 429 was answered by something that never judged it, and counting those as a
|
|
210
|
+
// verdict would blame the wording for an outage.
|
|
211
|
+
const answered = (result) => ["refused", "superseded"].includes(result.verdict);
|
|
212
|
+
const unreached = results.filter((result) => result.verdict === "unreachable");
|
|
213
|
+
if (results.every(answered)) {
|
|
163
214
|
console.log(
|
|
164
|
-
"
|
|
215
|
+
"None of them. Zen has moved past every generation this plugin knows; the tool wording in core-tools.mjs needs capturing again from a current OpenCode.",
|
|
216
|
+
);
|
|
217
|
+
} else if (results.some(answered)) {
|
|
218
|
+
console.log(
|
|
219
|
+
"Some of this run was answered about the fingerprint and some of it was not, which is not a fact about the fingerprint either way. Check the network or ZEN_BASE_URL and ask again.",
|
|
220
|
+
);
|
|
221
|
+
} else if (!unreached.length) {
|
|
222
|
+
console.log(
|
|
223
|
+
"Zen answered every request, but none of them with a verdict on the fingerprint, so this says nothing about it. Check the service or ZEN_BASE_URL and ask again.",
|
|
165
224
|
);
|
|
166
225
|
} else {
|
|
167
226
|
console.log(
|
|
168
|
-
"
|
|
227
|
+
"The free tier could not be reached at all, so this says nothing about the fingerprint. Check the network or ZEN_BASE_URL and ask again.",
|
|
169
228
|
);
|
|
170
229
|
}
|
|
171
230
|
process.exitCode = 1;
|