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/LICENSE +2 -7
- package/MAINTAINING.md +81 -1
- package/NOTICE +18 -0
- package/README.md +6 -4
- package/core-tools.mjs +85 -7
- package/index.mjs +18 -0
- package/limits.mjs +247 -26
- package/models.mjs +67 -22
- package/package.json +14 -5
- package/provider.mjs +128 -42
- package/stream.mjs +151 -28
- package/tool-response.mjs +109 -24
- package/capture.mjs +0 -203
- package/limits-audit.mjs +0 -262
- package/tools-smoke.mjs +0 -175
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
|
-
}
|