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 +3 -1
- package/MAINTAINING.md +39 -0
- package/capture.mjs +44 -16
- package/index.mjs +8 -0
- package/limits-audit.mjs +96 -18
- package/limits.mjs +69 -12
- package/models.mjs +41 -20
- package/package.json +2 -1
- package/provider.mjs +17 -20
- 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,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
|
|
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
|
@@ -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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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.
|
|
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,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
|
|
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
|
|
|
@@ -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.
|
|
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;
|
|
@@ -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(
|
|
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;
|