opencode-zen-freex 0.2.7 → 0.2.9

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/limits-audit.mjs DELETED
@@ -1,262 +0,0 @@
1
- // Asks the route which free models it still offers, groups them by the upstream
2
- // model serving them, and says which look under-declared.
3
- //
4
- // bun run limits:audit
5
- //
6
- // Why this exists. Zen's catalogue is where this plugin declares limits from,
7
- // and two of the ids it publishes were declared far below what the model does:
8
- // `deepseek-v4.1-flash-free` at 200000/32000 against 1048576/384000, and
9
- // `mimo-v2.6-flash-free` at 200000/32000 against 1048576/131072. Both were found
10
- // by hand, one id at a time. What makes them findable without reading every id
11
- // by eye is that the route answers `x-opencode-upstream-model-id` on every
12
- // request: two ids naming one upstream model are one capability, so a
13
- // declaration that differs inside such a group is the question worth asking.
14
- //
15
- // What this does not do: measure a window, or write anything. A window costs a
16
- // prompt of about a million tokens — minutes and megabytes — and belongs to the
17
- // one id a signal has already pointed at; the numbers live in limits.mjs and are
18
- // put there by a measurement, never by a script. What this does ask is cheap:
19
- // one request of `max_tokens: 1000000` per offered id that speaks the chat
20
- // shape, stopped as soon as it starts writing. An upstream that validates the
21
- // ceiling refuses and names its own; one that does not just begins to answer and
22
- // is cut off after the first chunk. Either way the cost is a handful of tokens,
23
- // and a 410 or a 400 for a model that is gone is visible in the same request.
24
- // An id the snapshot places on another protocol is listed and not asked.
25
- //
26
- // The offered set is the listing's, not the catalogue's: Zen's /v1/models
27
- // carries 10 of the 40 free ids the catalogue still describes, and a plugin
28
- // only ever offers what the listing has. Auditing the other 30 reports a page
29
- // of `Model is unavailable` about models nobody can pick.
30
- //
31
- // What is reported, per offered id: the upstream model and the ids sharing it,
32
- // the ceiling a refusal named, the window a refusal named, and the status. The
33
- // candidates at the end are the reason to run it.
34
- //
35
- // Exit code is 0 when at least one offered id answered, 1 when none did: being
36
- // unable to ask is not a finding about limits.
37
-
38
- import { PROFILES } from "./core-tools.mjs";
39
- import { LIMITS, limitOverrides, namedContextWindow, namedOutputCap } from "./limits.mjs";
40
- import { SDK, bootstrapProtocols } from "./models.mjs";
41
- import { CATALOG, DEFAULT_BASE } from "./provider.mjs";
42
- import { chatRequest } from "./tools-smoke.mjs";
43
-
44
- const BASE = process.env.ZEN_BASE_URL ?? DEFAULT_BASE;
45
- const CATALOG_URL = process.env.ZEN_CATALOG_URL ?? CATALOG;
46
- const GENERATION = process.env.ZEN_GENERATION ?? "1.x";
47
- const READ_BUDGET = 4096;
48
- const ASKED_CEILING = 1_000_000;
49
-
50
- // The ids the plugin would offer: the listing's, kept where the catalogue calls
51
- // them free — or, for an id neither the catalogue nor the docs describe, where
52
- // the id names itself free and nothing else names its protocol, which is what
53
- // models.mjs offers on its own say-so. An id the snapshot already calls
54
- // something this plugin has no SDK for is not offered by anyone: `jev-1.13-free`
55
- // is SystemOne, and stays out here for the same reason buildModels leaves it out.
56
- // Exported for the tests.
57
- export function offeredIds(listing, catalog, snapshot = bootstrapProtocols) {
58
- return listing.filter((id) => {
59
- const protocol = snapshot?.[id];
60
- if (protocol !== undefined && !Object.hasOwn(SDK, protocol)) return false;
61
- const model = catalog?.[id];
62
- if (!model) return /-free$/.test(id);
63
- return /free/i.test(id) || (model.cost?.input === 0 && model.cost?.output === 0);
64
- });
65
- }
66
-
67
- // Offered ids by the upstream model serving them, which is the route's own
68
- // answer and not a guess from the id's shape.
69
- export function groupByUpstream(rows) {
70
- const groups = new Map();
71
- for (const row of rows) {
72
- if (!row.upstream) continue;
73
- groups.set(row.upstream, [...(groups.get(row.upstream) ?? []), row]);
74
- }
75
- return groups;
76
- }
77
-
78
- // What is worth a measurement, in the order it should be read. `covered` are the
79
- // ids limits.mjs already carries evidence for, which are said once and not
80
- // raised again. Exported for the tests.
81
- export function findings(rows, covered = new Set()) {
82
- const out = [];
83
- for (const row of rows) {
84
- const declared = row.declared ?? {};
85
- // An id limits.mjs already carries evidence for is not asked for again: its
86
- // numbers are in the table and repeating them here would bury what is new.
87
- // Its status is still printed in the table above, so a model that dies is
88
- // not hidden by this — only its numbers stop being a question.
89
- if (covered.has(row.id)) continue;
90
- if (row.ceiling !== undefined && row.ceiling !== declared.output)
91
- out.push(
92
- `${row.id}: the route names a ceiling of ${row.ceiling} completion tokens, the catalogue declares ${declared.output ?? "none"}`,
93
- );
94
- if (row.window !== undefined && row.window !== declared.context)
95
- out.push(
96
- `${row.id}: a refusal named a window of ${row.window}, the catalogue declares ${declared.context ?? "none"}`,
97
- );
98
- const named = row.ceiling !== undefined || row.window !== undefined;
99
- // A model that does not speak the shape this audit asks with is not a limits
100
- // finding and must not sit in this list for ever: it is said once, beside the
101
- // ids that were not asked at all.
102
- 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.
105
- out.push(`${row.id}: HTTP ${row.status}${row.says ? ` — ${row.says}` : ""}`);
106
- }
107
- }
108
- for (const [upstream, list] of groupByUpstream(rows)) {
109
- const largest = (axis) => Math.max(...list.map((row) => row.declared?.[axis] ?? 0));
110
- if (list.length < 2) continue;
111
- if (new Set(list.map((row) => `${row.declared?.context}/${row.declared?.output}`)).size < 2) continue;
112
- for (const row of list) {
113
- if (covered.has(row.id)) continue;
114
- if ((row.declared?.context ?? 0) >= largest("context") && (row.declared?.output ?? 0) >= largest("output"))
115
- continue;
116
- const siblings = list
117
- .filter((other) => other.id !== row.id)
118
- .map((other) => `${other.id} (${other.declared?.context}/${other.declared?.output})`)
119
- .join(", ");
120
- out.push(
121
- `${row.id}: declared ${row.declared?.context}/${row.declared?.output}, and ${upstream} is also served as ${siblings}`,
122
- );
123
- }
124
- }
125
- return out;
126
- }
127
-
128
- function says(text) {
129
- const match = text.match(/"(?:message|param)"\s*:\s*"([^"]{0,160})"/);
130
- if (match) return match[1];
131
- // Some upstreams refuse in words that are not under a `message` key at all,
132
- // and a refusal nobody can read is not a finding.
133
- const flat = text.replace(/\s+/g, " ").trim();
134
- return flat ? flat.slice(0, 160) : "";
135
- }
136
-
137
- // A refusal that names the protocol rather than a limit. This audit asks the
138
- // 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.
140
- export function protocolScoped(row) {
141
- return /protocol|format/i.test(row?.says ?? "");
142
- }
143
-
144
- async function getJSON(url) {
145
- const response = await fetch(url, { signal: AbortSignal.timeout(30000) });
146
- if (!response.ok) throw new Error(`${url} answered HTTP ${response.status}`);
147
- return response.json();
148
- }
149
-
150
- // One cheap question per id. The ceiling asked for is past anything this route
151
- // serves, so an upstream that validates it says what it allows, and the stream
152
- // is dropped as soon as it starts rather than paying for an answer nobody reads.
153
- async function ask(id, names) {
154
- const { headers, body } = chatRequest({
155
- model: id,
156
- generation: GENERATION,
157
- profile: PROFILES[GENERATION],
158
- names,
159
- maxTokens: ASKED_CEILING,
160
- });
161
- const controller = new AbortController();
162
- const timer = setTimeout(() => controller.abort(), 45000);
163
- try {
164
- const response = await fetch(`${BASE}/chat/completions`, {
165
- method: "POST",
166
- headers,
167
- body: JSON.stringify(body),
168
- signal: controller.signal,
169
- });
170
- let text = "";
171
- if (response.ok) {
172
- const reader = response.body?.getReader();
173
- if (reader) {
174
- const decoder = new TextDecoder();
175
- while (text.length < READ_BUDGET) {
176
- const { value, done } = await reader.read();
177
- if (done) break;
178
- text += decoder.decode(value, { stream: true });
179
- }
180
- }
181
- } else {
182
- text = (await response.text()).slice(0, READ_BUDGET);
183
- }
184
- const upstream = response.headers.get("x-opencode-upstream-model-id") ?? undefined;
185
- const endpoint = response.headers.get("x-opencode-endpoint-id") ?? undefined;
186
- return {
187
- status: response.status,
188
- upstream,
189
- endpoint,
190
- window: namedContextWindow(text),
191
- ceiling: namedOutputCap(text),
192
- says: response.ok ? "" : says(text),
193
- };
194
- } catch (error) {
195
- return { status: 0, says: error.message, upstream: undefined, endpoint: undefined };
196
- } finally {
197
- clearTimeout(timer);
198
- controller.abort();
199
- }
200
- }
201
-
202
- // Only a run asks the route. The tests import the helpers above and must not
203
- // reach the network.
204
- if (import.meta.main) {
205
- const names = [...PROFILES[GENERATION].required];
206
- const [listing, catalog] = await Promise.all([getJSON(`${BASE}/models`), getJSON(CATALOG_URL)]);
207
- const models = catalog?.opencode?.models ?? {};
208
- const ids = offeredIds(
209
- (Array.isArray(listing?.data) ? listing.data : []).map((entry) => entry?.id).filter(Boolean),
210
- models,
211
- );
212
- const free = Object.keys(models).filter(
213
- (id) => /free/i.test(id) || (models[id].cost?.input === 0 && models[id].cost?.output === 0),
214
- );
215
- console.log(`limits audit — ${BASE}`);
216
- console.log(`${ids.length} free ids are offered; the catalogue still describes ${free.length}\n`);
217
-
218
- const rows = [];
219
- // This asks the chat shape only. An id the snapshot places on another protocol
220
- // is left to the host that speaks it, and said once rather than reported as a
221
- // finding it cannot be.
222
- const asked = ids.filter((id) => (bootstrapProtocols[id] ?? "chat") === "chat");
223
- 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.
231
- const effective = { ...declared, ...limitOverrides(id, declared) };
232
- const moved = effective.context !== declared.context || effective.output !== declared.output;
233
- const pair = (limit) => `${limit.context ?? "—"}/${limit.output ?? "—"}`;
234
- const mark = answer.status === 200 ? " " : "! ";
235
- console.log(
236
- `${mark}${id.padEnd(34)} ${pair(effective).padStart(17)}` +
237
- `${moved ? ` (catalogue ${pair(declared)})` : ""} ${answer.status}` +
238
- `${answer.upstream ? ` ${answer.upstream}${answer.endpoint ? ` via ${answer.endpoint}` : ""}` : ""}` +
239
- `${answer.ceiling !== undefined ? ` ceiling=${answer.ceiling}` : ""}${answer.window !== undefined ? ` window=${answer.window}` : ""}` +
240
- `${answer.says ? ` — ${answer.says}` : ""}`,
241
- );
242
- }
243
-
244
- const answered = rows.filter((row) => row.status === 200);
245
- const candidates = findings(rows, new Set(Object.keys(LIMITS)));
246
- const skipped = [...elsewhere, ...rows.filter(protocolScoped).map((row) => row.id)];
247
- if (skipped.length)
248
- console.log(
249
- `\nnot asked, this audit asks the chat shape only: ${skipped.map((id) => `${id}${bootstrapProtocols[id] ? ` (${bootstrapProtocols[id]})` : ""}`).join(", ")}`,
250
- );
251
- console.log(`\nlimits.mjs already carries evidence for: ${Object.keys(LIMITS).join(", ")}`);
252
- console.log(`\n${candidates.length} candidate${candidates.length === 1 ? "" : "s"}:`);
253
- for (const line of candidates) console.log(` ${line}`);
254
- if (!candidates.length) console.log(" none — every offered id is either measured or declared at what the route states");
255
-
256
- if (!answered.length) {
257
- console.log(
258
- "\nNothing answered, so this says nothing about limits. Check the network or ZEN_BASE_URL and ask again.",
259
- );
260
- process.exitCode = 1;
261
- }
262
- }
package/tools-smoke.mjs DELETED
@@ -1,175 +0,0 @@
1
- // Asks Zen which of the client generations this package still speaks.
2
- //
3
- // The gate moved more than once in a day, and nothing short of asking it says
4
- // so: a stale fingerprint does not look different from a working one until a
5
- // user is refused. This is the check to run before a release, and whenever a
6
- // report comes in that the free tier answers 403.
7
- //
8
- // bun run check:gate
9
- //
10
- // Each profile is asked twice, because the plugin sends two shapes and Zen
11
- // does not answer them alike:
12
- //
13
- // floor the names in `required`, which is what the plugin stands in for a
14
- // host that is not OpenCode and has none of these tools by name. This
15
- // is the shape the plugin exists to send, so it is the one that
16
- // decides the answer.
17
- // full every tool the profile carries, which is what a host that is
18
- // OpenCode itself sends. It is reported for what it says about a
19
- // wording that has gone stale, and does not decide the exit code.
20
- //
21
- // Asking only the full shape hid a real gap: `read` alone is served and
22
- // everything but `read` is refused, so a Zen that started insisting on another
23
- // name would leave the full shape passing while the shape the plugin actually
24
- // sends was refused.
25
- //
26
- // Every request here is built by the plugin itself: the header names come from
27
- // provider.mjs's outboundHeaders and the tools from the same `required` the
28
- // plugin stands in, so this cannot report on a shape the plugin does not send.
29
- // A copy of the headers is what it used to ask with, and that copy had drifted -
30
- // it was missing `accept` - with nothing failing on it.
31
- //
32
- // Each request is reported as:
33
- //
34
- // answered Zen served the request; this shape is current
35
- // refused Zen did not recognise this generation (403)
36
- // superseded Zen wants a newer OpenCode than the one this profile claims (426)
37
- // unreachable the free tier could not be asked at all
38
- //
39
- // Exit code is 0 when at least one generation's floor is answered. A generation
40
- // being refused is not a failure while another still works.
41
-
42
- import { randomBytes } from "node:crypto";
43
- import { PROFILES, agentFor } from "./core-tools.mjs";
44
- import { outboundHeaders } from "./provider.mjs";
45
-
46
- const BASE = process.env.ZEN_BASE_URL ?? "https://opencode.ai/inference/v1";
47
- const MODEL = process.env.ZEN_CHECK_MODEL ?? "space-bunny-free";
48
- const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
49
-
50
- // A session id of the shape OpenCode makes, since that is part of what is asked.
51
- let stamp = 0;
52
- let counter = 0;
53
- function sessionID() {
54
- const now = Date.now();
55
- if (now !== stamp) {
56
- stamp = now;
57
- counter = 0;
58
- }
59
- counter++;
60
- const value = (~(BigInt(now) * 0x1000n + BigInt(counter))) & 0xffffffffffffn;
61
- let tail = "";
62
- for (const byte of randomBytes(14)) tail += ALPHABET[byte % 62];
63
- return `ses_${value.toString(16).padStart(12, "0")}${tail}`;
64
- }
65
-
66
- // Zen gives the same sentence whichever part of a request failed to look like
67
- // OpenCode, so it is worth printing: a refusal that arrives with no message at
68
- // all reads as something other than a stale fingerprint.
69
- function says(text) {
70
- const match = text.match(/"message"\s*:\s*"([^"]{0,120})"/);
71
- return match ? `: ${match[1]}` : "";
72
- }
73
-
74
- // The request a shape is asked with. It is its own function so index.test.mjs
75
- // can hold it against the request the plugin was observed to send, offline, and
76
- // so limits-audit.mjs asks with the same shape this gate does instead of a copy
77
- // of it — a copy is what drifted before (it was missing `accept`).
78
- export function requestFor(generation, profile, names) {
79
- return chatRequest({ model: MODEL, generation, profile, names, maxTokens: 40 });
80
- }
81
-
82
- export function chatRequest({ model, generation, profile, names, maxTokens = 40, messages }) {
83
- return {
84
- headers: outboundHeaders({
85
- session: sessionID(),
86
- agent: agentFor(generation),
87
- project: "global",
88
- protocol: "chat",
89
- }),
90
- body: {
91
- model,
92
- messages: messages ?? [{ role: "user", content: "hi" }],
93
- tools: names.map((name) => ({ type: "function", function: profile.tools[name] })),
94
- stream: true,
95
- stream_options: { include_usage: true },
96
- max_tokens: maxTokens,
97
- },
98
- };
99
- }
100
-
101
- async function check(generation, profile, shape, names) {
102
- const { headers, body } = requestFor(generation, profile, names);
103
- try {
104
- const response = await fetch(`${BASE}/chat/completions`, {
105
- method: "POST",
106
- headers,
107
- body: JSON.stringify(body),
108
- signal: AbortSignal.timeout(30000),
109
- });
110
- const text = await response.text();
111
- if (response.ok)
112
- return { generation, shape, verdict: "answered", detail: `as ${headers.get("user-agent")}` };
113
- if (response.status === 426)
114
- return {
115
- generation,
116
- shape,
117
- verdict: "superseded",
118
- detail: "Zen wants a newer OpenCode than this profile claims",
119
- };
120
- return { generation, shape, verdict: "refused", detail: `HTTP ${response.status}${says(text)}` };
121
- } catch (error) {
122
- return { generation, shape, verdict: "unreachable", detail: error.message };
123
- }
124
- }
125
-
126
- // Only a run asks Zen. index.test.mjs imports this module to hold its request
127
- // 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
- }
134
-
135
- const width = Math.max(...results.map((result) => result.generation.length));
136
- for (const result of results)
137
- console.log(
138
- `${result.shape.padEnd(6)}${result.verdict.padEnd(12)} ${result.generation.padEnd(width)} ${result.detail}`,
139
- );
140
-
141
- const floor = results.filter((result) => result.shape === "floor");
142
- const served = floor.filter((result) => result.verdict === "answered");
143
- console.log(`\n${served.length}/${floor.length} generations serve a host that is not OpenCode at ${BASE}`);
144
-
145
- // A generation whose full shape is served while its floor is not is the case
146
- // this gate was widened to see: the plugin would work for a host bringing its
147
- // own OpenCode tools and be refused for the host it was written for.
148
- for (const result of floor) {
149
- if (result.verdict === "answered") continue;
150
- const full = results.find(
151
- (other) => other.generation === result.generation && other.shape === "full",
152
- );
153
- if (full?.verdict === "answered")
154
- console.log(
155
- `${result.generation} answers the full OpenCode shape but not the tools the plugin stands in: a host that has none of them by name will be refused.`,
156
- );
157
- }
158
-
159
- if (!served.length) {
160
- // Being unable to ask is not an answer. Saying the fingerprint has gone stale
161
- // when nothing was reached sends the reader to `capture` over a dead network.
162
- if (results.every((result) => result.verdict === "unreachable")) {
163
- 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.",
165
- );
166
- } else {
167
- 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.",
169
- );
170
- }
171
- process.exitCode = 1;
172
- } else if (served.length < floor.length) {
173
- console.log("Some are refused but at least one works, so the plugin still has a generation Zen will serve.");
174
- }
175
- }