opencode-zen-freex 0.2.7 → 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 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,6 +199,12 @@ 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
 
198
210
  **Marking a list as fallen back when Zen's own list was read.** `discover()`
@@ -252,6 +264,33 @@ that passes under the stricter of them, since the looser one will not last.
252
264
  | `capture.mjs` | takes a new capture |
253
265
  | `tools-smoke.mjs` | asks Zen what it accepts (`check:gate`) |
254
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
+
255
294
  ## Publishing
256
295
 
257
296
  The package is `opencode-zen-freex`; the provider it registers is
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. Everything else a host declares is ignored by it.
30
- const READ = ["bash", "glob", "grep", "read", "write", "edit"];
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
- let previous = { agent: null, body: "" };
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
- const body = JSON.parse(request.body);
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 || !READ.includes(tool.name) || !tool.description) continue;
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 = READ.filter((name) => !tools[name]);
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
@@ -35,9 +35,10 @@
35
35
  // Exit code is 0 when at least one offered id answered, 1 when none did: being
36
36
  // unable to ask is not a finding about limits.
37
37
 
38
+ import { fileURLToPath } from "node:url";
38
39
  import { PROFILES } from "./core-tools.mjs";
39
40
  import { LIMITS, limitOverrides, namedContextWindow, namedOutputCap } from "./limits.mjs";
40
- import { SDK, bootstrapProtocols } from "./models.mjs";
41
+ import { SDK, bootstrapProtocols, isFree } from "./models.mjs";
41
42
  import { CATALOG, DEFAULT_BASE } from "./provider.mjs";
42
43
  import { chatRequest } from "./tools-smoke.mjs";
43
44
 
@@ -46,6 +47,10 @@ const CATALOG_URL = process.env.ZEN_CATALOG_URL ?? CATALOG;
46
47
  const GENERATION = process.env.ZEN_GENERATION ?? "1.x";
47
48
  const READ_BUDGET = 4096;
48
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;
49
54
 
50
55
  // The ids the plugin would offer: the listing's, kept where the catalogue calls
51
56
  // them free — or, for an id neither the catalogue nor the docs describe, where
@@ -53,14 +58,15 @@ const ASKED_CEILING = 1_000_000;
53
58
  // models.mjs offers on its own say-so. An id the snapshot already calls
54
59
  // something this plugin has no SDK for is not offered by anyone: `jev-1.13-free`
55
60
  // is SystemOne, and stays out here for the same reason buildModels leaves it out.
56
- // Exported for the tests.
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.
57
63
  export function offeredIds(listing, catalog, snapshot = bootstrapProtocols) {
58
64
  return listing.filter((id) => {
59
65
  const protocol = snapshot?.[id];
60
66
  if (protocol !== undefined && !Object.hasOwn(SDK, protocol)) return false;
61
67
  const model = catalog?.[id];
62
68
  if (!model) return /-free$/.test(id);
63
- return /free/i.test(id) || (model.cost?.input === 0 && model.cost?.output === 0);
69
+ return isFree(id, model);
64
70
  });
65
71
  }
66
72
 
@@ -100,8 +106,11 @@ export function findings(rows, covered = new Set()) {
100
106
  // finding and must not sit in this list for ever: it is said once, beside the
101
107
  // ids that were not asked at all.
102
108
  if (protocolScoped(row)) continue;
103
- if (row.status !== 200 && !named) {
104
- // What is left is worth reading: a 410, and a 400 that named nothing.
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.
105
114
  out.push(`${row.id}: HTTP ${row.status}${row.says ? ` — ${row.says}` : ""}`);
106
115
  }
107
116
  }
@@ -136,9 +145,15 @@ function says(text) {
136
145
 
137
146
  // A refusal that names the protocol rather than a limit. This audit asks the
138
147
  // chat shape, so a model that speaks Responses or Messages answers by saying so;
139
- // that is the host's business, not a limit to measure.
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
+
140
155
  export function protocolScoped(row) {
141
- return /protocol|format/i.test(row?.says ?? "");
156
+ return OTHER_SHAPE.test(row?.says ?? "");
142
157
  }
143
158
 
144
159
  async function getJSON(url) {
@@ -183,12 +198,17 @@ async function ask(id, names) {
183
198
  }
184
199
  const upstream = response.headers.get("x-opencode-upstream-model-id") ?? undefined;
185
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.
186
206
  return {
187
207
  status: response.status,
188
208
  upstream,
189
209
  endpoint,
190
- window: namedContextWindow(text),
191
- ceiling: namedOutputCap(text),
210
+ window: response.ok ? undefined : namedContextWindow(text),
211
+ ceiling: response.ok ? undefined : namedOutputCap(text),
192
212
  says: response.ok ? "" : says(text),
193
213
  };
194
214
  } catch (error) {
@@ -199,9 +219,54 @@ async function ask(id, names) {
199
219
  }
200
220
  }
201
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
+
202
237
  // Only a run asks the route. The tests import the helpers above and must not
203
238
  // reach the network.
204
- if (import.meta.main) {
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() {
205
270
  const names = [...PROFILES[GENERATION].required];
206
271
  const [listing, catalog] = await Promise.all([getJSON(`${BASE}/models`), getJSON(CATALOG_URL)]);
207
272
  const models = catalog?.opencode?.models ?? {};
@@ -221,13 +286,24 @@ if (import.meta.main) {
221
286
  // finding it cannot be.
222
287
  const asked = ids.filter((id) => (bootstrapProtocols[id] ?? "chat") === "chat");
223
288
  const elsewhere = ids.filter((id) => !asked.includes(id));
224
- for (const id of asked) {
225
- const answer = await ask(id, names);
226
- const declared = models[id]?.limit ?? {};
227
- rows.push({ id, declared, ...answer });
228
- // What the plugin declares is the catalogue's limit with limits.mjs over it,
229
- // so that is what is printed; the catalogue's own pair is shown beside it
230
- // when the two differ, since that is the under-report being answered.
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) {
231
307
  const effective = { ...declared, ...limitOverrides(id, declared) };
232
308
  const moved = effective.context !== declared.context || effective.output !== declared.output;
233
309
  const pair = (limit) => `${limit.context ?? "—"}/${limit.output ?? "—"}`;
@@ -255,7 +331,9 @@ if (import.meta.main) {
255
331
 
256
332
  if (!answered.length) {
257
333
  console.log(
258
- "\nNothing answered, so this says nothing about limits. Check the network or ZEN_BASE_URL and ask again.",
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.",
259
337
  );
260
338
  process.exitCode = 1;
261
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. Returns undefined when the body names nothing attributable, otherwise
162
- // whichever axes it named.
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
- .clone()
168
- .text()
169
- .catch(() => "");
170
- const context = namedContextWindow(text.slice(0, limit));
171
- const output = namedOutputCap(text.slice(0, limit));
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
- const value =
204
- live !== undefined ? live : entry && entry.declaredAt === declaredValue ? entry.value : undefined;
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
- if (entry && !ROUTE_TIERS.has(entry.source) && Number.isSafeInteger(declaredValue) && value < declaredValue)
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,18 +32,22 @@ 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 != null ||
36
- model.retirement_date != null
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) && (/free/i.test(id) || (model.cost?.input === 0 && model.cost?.output === 0))
50
+ !isRetired(model) && (/-free$/i.test(id) || (model.cost?.input === 0 && model.cost?.output === 0))
43
51
  );
44
52
  }
45
53
 
@@ -55,26 +63,39 @@ function listedFree(id) {
55
63
  return /-free$/.test(id);
56
64
  }
57
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
+
58
89
  export function buildModels(ids, provider, protocols, baseURL) {
59
90
  const result = {};
60
91
  for (const id of ids) {
61
92
  if (typeof id !== "string" || !id || id === "__proto__") continue;
62
93
  const raw = provider.models?.[id];
63
94
  const model = raw ?? {};
64
- const protocol =
65
- protocols[id] ??
66
- (raw
67
- ? sdkProtocol(model.provider?.npm ?? provider.npm)
68
- : listedFree(id)
69
- ? (sdkProtocol(provider.npm) ?? "chat")
70
- : undefined);
95
+ const protocol = protocolFor(id, model, raw, provider, protocols);
71
96
  if (!isFree(id, model) || !Object.hasOwn(SDK, protocol)) continue;
72
97
  const input = model.modalities?.input ?? ["text"];
73
98
  const output = model.modalities?.output ?? ["text"];
74
- const modalities = (values) =>
75
- Object.fromEntries(
76
- ["text", "image", "audio", "video", "pdf"].map((name) => [name, values.includes(name)]),
77
- );
78
99
  const efforts = (model.reasoning_options ?? [])
79
100
  .filter((option) => option.type === "effort")
80
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.7",
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
- const shapeOf = (definition, protocol) =>
200
- protocol === "chat"
201
- ? { type: "function", function: definition }
202
- : protocol === "anthropic"
203
- ? { name: definition.name, description: definition.description, input_schema: definition.parameters }
204
- : { type: "function", ...definition };
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
- let listed = false;
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;
@@ -368,7 +369,6 @@ async function server(_input, options = {}) {
368
369
  // list there was.
369
370
  }
370
371
  known = discovered;
371
- listed = true;
372
372
  listedAt = Date.now();
373
373
  return discovered;
374
374
  }
@@ -416,10 +416,7 @@ async function server(_input, options = {}) {
416
416
  const body = await request.json();
417
417
  if (!body || typeof body !== "object" || Array.isArray(body))
418
418
  throw new Error("Expected a JSON request object");
419
- if (
420
- !Object.hasOwn(known, body.model) &&
421
- (!listed || Date.now() - listedAt >= RELIST_AFTER)
422
- ) {
419
+ if (!Object.hasOwn(known, body.model) && Date.now() - listedAt >= RELIST_AFTER) {
423
420
  await discover(request.signal);
424
421
  }
425
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("content-length");
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
- let position = data.output_index;
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(index(data.output_index), data.item);
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
- blocks.get(position).input = JSON.parse(value);
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 = "Upstream SSE exceeds the 32 MiB buffer limit";
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("content-length");
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
- function failure(protocol, message, stream = false) {
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: "undeclared_tool", message }
18
- : { error: { type: "upstream_error", code: "undeclared_tool", message } };
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("Pending upstream tools exceed the 32 MiB buffer limit");
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("Upstream SSE event exceeds the 32 MiB buffer limit");
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("Upstream SSE event exceeds the 32 MiB buffer limit");
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("Upstream SSE event exceeds the 32 MiB buffer limit");
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("Upstream SSE event exceeds the 32 MiB buffer limit");
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(text);
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
- tools: names.map((name) => ({ type: "function", function: profile.tools[name] })),
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
- return { generation, shape, verdict: "refused", detail: `HTTP ${response.status}${says(text)}` };
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
- if (import.meta.main) {
129
- const results = [];
130
- for (const [generation, profile] of Object.entries(PROFILES)) {
131
- results.push(await check(generation, profile, "floor", [...profile.required]));
132
- results.push(await check(generation, profile, "full", Object.keys(profile.tools)));
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
- if (results.every((result) => result.verdict === "unreachable")) {
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
- "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.",
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
- "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.",
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;