opencode-zen-freex 0.2.5 → 0.2.7

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/MAINTAINING.md CHANGED
@@ -39,6 +39,9 @@ measured against. Each axis carries `samples` and `how`; the short of it:
39
39
  | `deepseek-v4.1-flash-free` | output | 384000 | what this route declares for the same upstream model |
40
40
  | `mimo-v2.6-flash-free` | context | 1048576 | the route's own refusal, in its own words |
41
41
  | `mimo-v2.6-flash-free` | output | 131072 | the vendor's own catalogue entry |
42
+ | `big-pickle` | output | 131072 | the ceiling the route names when it is asked for too much |
43
+ | `nemotron-3.5-lightning-free` | context | 1000000 | the route's own refusal, in its own words |
44
+ | `longcat-2.5-preview-free` | output | 262144 | the ceiling the route names when it is asked for too much |
42
45
 
43
46
  A prompt sized just over the window is refused with
44
47
 
@@ -62,6 +65,16 @@ figure is a rough one (characters, not tokens) and must not be read as a count.
62
65
  A second upstream words the same fact as "This endpoint's maximum context length
63
66
  is …" and breaks the request into text input, tool input and output.
64
67
 
68
+ The ceiling, which is cheaper than the window: ask for `max_tokens: 1000000` on a
69
+ short prompt and stop reading as soon as the answer starts. An upstream that
70
+ validates the ceiling refuses and names it — `max_tokens is too large: 1000000.
71
+ This model supports at most 131072 completion tokens`, or LongCat's
72
+ `/max_tokens: 1000000 is not less or equal to 262144` — and one that does not
73
+ just begins to answer, so the cost of asking every model is a few tokens each.
74
+ Both wordings are in `limits.mjs` (`namedOutputCap`) and both are read from
75
+ production refusals as well, which is how a ceiling that moves gets noticed
76
+ without anyone running anything.
77
+
65
78
  The output: a paid twin's `limit` in `https://models.opencode.ai/api.json` is
66
79
  the stronger source, and re-reading it is the whole job. With no twin — `mimo`
67
80
  has none — the figure rests on the vendor's own entry, which is weaker evidence
@@ -103,10 +116,24 @@ asked for, unclamped, so this route states no completion ceiling of its own, and
103
116
  China token plan). Nothing above 32000 has been watched on this route, and
104
117
  `samples` says that rather than implying a witness.
105
118
 
119
+ **Looking for the next one, instead of waiting to notice it.** `bun run
120
+ limits:audit` asks the route which free models it still offers — the listing's
121
+ ten, not the forty the catalogue still describes — and asks each one the cheap
122
+ question above. It groups them by the `x-opencode-upstream-model-id` the route
123
+ answers with, because two ids naming one upstream model are one capability, so a
124
+ declaration that differs inside a group is the question worth asking. It prints
125
+ those, the ceilings it was told, the windows a refusal named, and the ids that
126
+ are gone (410) or unavailable (400); it writes nothing and measures no window.
127
+ Its first run named
128
+ `big-pickle`'s ceiling and two ids declared below what the route says
129
+ (`nemotron-3.5-lightning-free` at a quarter of its window, `longcat-2.5-preview-free`
130
+ at half its ceiling), which is the whole argument for having it.
131
+
106
132
  ## The loop
107
133
 
108
134
  ```sh
109
135
  bun test # the unit tests; these never touch the network
136
+ bun run limits:audit # what the route offers, what limits it states, what to measure
110
137
  bun run check:gate # asks Zen which generations are still accepted
111
138
  bun run capture -- --dry # takes a fresh capture and reports the difference
112
139
  bun run capture # writes it
@@ -168,6 +195,26 @@ the cooldown answers like a refusal.
168
195
 
169
196
  ## Things that are easy to break
170
197
 
198
+ **Marking a list as fallen back when Zen's own list was read.** `discover()`
199
+ returns every id Zen's `/models` lists; the catalogue and the console docs only
200
+ add what each model *is* — name, protocol, limits. When either of those two
201
+ cannot be read, the list is not a fallback: it is Zen's, current, and complete to
202
+ the last id. Saying so costs a user something real, because the host reacts to
203
+ that marker by keeping the last list it read in full, and a machine that cannot
204
+ reach `raw.githubusercontent.com` or `models.opencode.ai` — mainland China, for
205
+ one — then never sees a model Zen made free since. The marker belongs to the one
206
+ case that describes it: the listing itself could not be read, so what is returned
207
+ really is the last list there was (`yetone/magpie#2385`).
208
+
209
+ **Waiting for the catalogue before offering a model Zen already lists.** An id
210
+ ending in `-free` that neither the catalogue nor the docs describe is offered
211
+ anyway, asked through the provider's own protocol (openai-compatible, and so
212
+ chat) — the way OpenCode asks a model with no package of its own. Zen lists a
213
+ model the hour it turns free and the catalogue catches up later; a host should
214
+ see it in between. An id that does not call itself free still needs the
215
+ catalogue's word, and one the snapshot or the docs name keeps that say:
216
+ `jev-1.13-free` is SystemOne and stays out.
217
+
171
218
  **Adding a header.** The set sent has one owner: `outboundHeaders` in
172
219
  `provider.mjs`, which the plugin builds its request with and which the gate asks
173
220
  Zen with, so neither can drift from the other on its own. `index.test.mjs` pins
@@ -279,4 +326,26 @@ you do not have permission to access it.
279
326
 
280
327
  which reads exactly like the package not existing. It is the same status npm
281
328
  gives a genuine permission failure, so a 404 from a publish says nothing at
282
- all until the token has been checked.
329
+ all until the token has been checked.
330
+
331
+ **A publish that no longer offers the browser at all.** A token can be dead
332
+ while npm says nothing about it until a write is attempted. Measured: `npm
333
+ whoami` answers
334
+
335
+ ```
336
+ npm error 401 Unauthorized - GET https://registry.npmjs.org/-/whoami
337
+ ```
338
+
339
+ and the publish answers that same 404 without ever printing an auth address,
340
+ because with no usable credential the request goes out unauthenticated and npm
341
+ reports the result as not-found. `npm login --auth-type=web` is the fix — the
342
+ browser exchange again, which replaces the stored token — and the publish after
343
+ it asks for the second factor in the browser once more. **Two authorizations in
344
+ a row is the normal shape of this**, not a sign that something is wrong. So on a
345
+ 404 from a publish, ask `npm whoami` before anything else: 401 is a session that
346
+ has to be renewed, and everything else is the permission problem above.
347
+
348
+ npm says on login that "tokens that bypass 2FA are being restricted for account
349
+ changes and direct publishing", so the granular route below is on its way out as
350
+ a way to publish; the browser is the road, and the token is the exception it is
351
+ already written to be.
package/README.md CHANGED
@@ -47,7 +47,9 @@ magpie plugin login opencode-zen-free
47
47
 
48
48
  免费模型的目录上限有时被 Zen 少报,而少报的那一边没有谁会报错:`deepseek-v4.1-flash-free` 在目录里是 20 万上下文 / 3.2 万输出,而同一上游模型的付费 id 是 100 万 / 38.4 万。上下文窗口太小只会让宿主安静地早点压缩,输出上限太小会把回复截断成看起来像模型自己停下的样子,所以插件对这类模型按实测与路由自述对齐(依据与复测方法见 `MAINTAINING.md` 的 “Limits Zen under-reports”),并在运行中采用上游自报的窗口。
49
49
 
50
- 已下线的模型和 SystemOne 模型会被排除。这份列表在宿主每次列举模型时重新获取(magpie 在启动时做一次,之后每小时一次):Zen 不再免费开放的模型会退出列表,新转为免费的模型会加入列表。如果读不到 Zen 的列表,就沿用上一次的结果;第一次还没读到之前,用的是打包在插件里的那份,所以这个 provider 不会空着。请求一个当前并不免费的模型,会得到一个 404 并列出当时免费的模型;若某个模型是在上次列举之后才转免费的,请求会在重新获取列表后照常服务(最多每分钟一次)。可用性和限流由 Zen 决定,插件这边拿不到剩余额度。
50
+ 已下线的模型和 SystemOne 模型会被排除。这份列表在宿主每次列举模型时重新获取(magpie 在启动时做一次,之后每小时一次):Zen 不再免费开放的模型会退出列表,新转为免费的模型会加入列表——**不等** OpenCode 的目录或文档写到它:只要 Zen 的列表里有、id 里带 `-free`,就按 provider 自己的协议(OpenAI 兼容 / chat)提供,目录晚一步也不影响。
51
+
52
+ 目录或文档读不到时**不算 fallback**:Zen 的列表已经读到了,它就是当前该提供的东西;报成 fallback 会让宿主保守地沿用上一次完整读到的列表,于是读不到 `raw.githubusercontent.com` 或 `models.opencode.ai` 的机器永远看不到之后新转免费的模型。只有**列表本身**读不到才沿用上一次结果;第一次还没读到之前,用的是打包在插件里的那份,所以这个 provider 不会空着。请求一个当前并不免费的模型,会得到一个 404 并列出当时免费的模型;若某个模型是在上次列举之后才转免费的,请求会在重新获取列表后照常服务(最多每分钟一次)。可用性和限流由 Zen 决定,插件这边拿不到剩余额度。
51
53
 
52
54
  ## 免费层不再应答时
53
55
 
@@ -0,0 +1,262 @@
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/limits.mjs CHANGED
@@ -66,6 +66,36 @@ export const LIMITS = {
66
66
  how: "Xiaomi's own entry for mimo-v2.6-flash, api.xiaomimimo.com and its China token plan, declares 1048576/131072, as do DeepInfra and nan; this route declares 32000 for the free id and states no completion ceiling of its own",
67
67
  },
68
68
  },
69
+ "big-pickle": {
70
+ output: {
71
+ value: 131072,
72
+ source: "route",
73
+ declaredAt: 32000,
74
+ measured: "2026-10-15",
75
+ samples: "the endpoint refused the request asking for 200000 and named its own ceiling; nothing watched above 32000",
76
+ how: "\"max_tokens is too large: 200000. This model supports at most 131072 completion tokens, whereas you provided 200000.\" — an upstream that validates the requested ceiling and states its own. No context entry: this id is the same upstream model as mimo-v2.6-flash-free (x-opencode-upstream-model-id answers mimo-v2.6-flash) on a different endpoint, and two endpoints of one model are not one window — the attempt to measure this one answered 200 with an empty body, which is not an answer, so that axis stays with the catalogue.",
77
+ },
78
+ },
79
+ "nemotron-3.5-lightning-free": {
80
+ context: {
81
+ value: 1000000,
82
+ source: "route",
83
+ declaredAt: 262144,
84
+ measured: "2026-10-15",
85
+ samples: "a refusal that named the window, which is what `bun run limits:audit` asks for; nothing near the wall has been served yet",
86
+ how: "\"This endpoint's maximum context length is 1000000 tokens. However, you requested about …\" — read from the refusal the audit provoked by asking for more completion tokens than the window leaves, on the same upstream that words it as the mimo entry above. The catalogue declares 262144 for this id.",
87
+ },
88
+ },
89
+ "longcat-2.5-preview-free": {
90
+ output: {
91
+ value: 262144,
92
+ source: "route",
93
+ declaredAt: 131072,
94
+ measured: "2026-10-15",
95
+ samples: "the ceiling named by a refusal, from the same audit run; nothing above 131072 watched on this route",
96
+ how: "\"Upstream request failed: [invalid_parameter] 参数校验失败: /max_tokens: 1000000 is not less or equal to 262144\" — the second wording namedOutputCap reads, where the ceiling is the number max_tokens has to stay under. Its context needs nothing: the catalogue's 1000000 is what that same refusal calls the endpoint's window.",
97
+ },
98
+ },
69
99
  };
70
100
 
71
101
  // "This model's maximum context length is 1048576 tokens. However, you
@@ -81,24 +111,55 @@ export const LIMITS = {
81
111
  // on another Zen model.
82
112
  const NAMED_CONTEXT = /maximum context length is (\d+) tokens/i;
83
113
 
84
- // Windows named by a refusal while this process is running, by model id. Not
85
- // persisted: it is the same kind of fact as LIMITS, only newer, and a restart
86
- // falls back to what was measured last.
114
+ // "max_tokens is too large: 200000. This model supports at most 131072 completion
115
+ // tokens, whereas you provided 200000." — an upstream that validates the ceiling
116
+ // it was asked for and names its own, which is the only sentence seen on this
117
+ // route that states a completion ceiling at all. It is worth knowing that the
118
+ // DeepSeek upstream does not validate: `max_tokens: 2000000` under a short prompt
119
+ // answers 200 there, so silence from this one says nothing about a model, and a
120
+ // request that is served is not evidence of a ceiling either way.
121
+ const NAMED_OUTPUT = [
122
+ /supports at most (\d+) completion tokens/i,
123
+ // "/max_tokens: 1000000 is not less or equal to 262144" — LongCat's wording for
124
+ // the same fact, where the ceiling is the number it has to stay under.
125
+ /max_tokens:?\s*\d+\s+is not less or equal to\s+(\d+)/i,
126
+ ];
127
+
128
+ // Names a refusal made while this process is running, by model id. Not
129
+ // persisted: the same kind of fact as LIMITS, only newer, so a restart falls
130
+ // back to what was measured last.
87
131
  const OBSERVED = new Map();
132
+ const OBSERVED_OUTPUT = new Map();
88
133
 
89
134
  // The window a refusal names, or undefined when the body says nothing
90
135
  // attributable. Exported for the tests and for anyone reading a capture.
91
136
  export function namedContextWindow(text) {
92
- const match = NAMED_CONTEXT.exec(typeof text === "string" ? text : "");
137
+ return namedNumber(NAMED_CONTEXT, text);
138
+ }
139
+
140
+ // The completion ceiling a refusal names, or undefined. Same rule as the window:
141
+ // the body has to name the parameter and a number, or it says nothing.
142
+ export function namedOutputCap(text) {
143
+ for (const pattern of NAMED_OUTPUT) {
144
+ const value = namedNumber(pattern, text);
145
+ if (value !== undefined) return value;
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ function namedNumber(pattern, text) {
151
+ const match = pattern.exec(typeof text === "string" ? text : "");
93
152
  if (!match) return undefined;
94
153
  const tokens = Number.parseInt(match[1], 10);
95
154
  return Number.isSafeInteger(tokens) && tokens > 0 ? tokens : undefined;
96
155
  }
97
156
 
98
- // Read a refusal for a window it names. Only non-2xx responses are read, and
99
- // only the first `limit` characters of one: this is an error path on the
100
- // request's way to a caller that is waiting, so it must never be able to fail a
101
- // request, change a response, or buffer a body that is not small.
157
+ // Read a refusal for the names it makes about this model's limits. Only non-2xx
158
+ // responses are read, and only the first `limit` characters of one: this is an
159
+ // error path on the request's way to a caller that is waiting, so it must never
160
+ // be able to fail a request, change a response, or buffer a body that is not
161
+ // small. Returns undefined when the body names nothing attributable, otherwise
162
+ // whichever axes it named.
102
163
  export async function observeRouteStatement(id, response, limit = 4096) {
103
164
  try {
104
165
  if (typeof id !== "string" || !id || response?.ok !== false) return undefined;
@@ -106,9 +167,12 @@ export async function observeRouteStatement(id, response, limit = 4096) {
106
167
  .clone()
107
168
  .text()
108
169
  .catch(() => "");
109
- const tokens = namedContextWindow(text.slice(0, limit));
110
- if (tokens !== undefined) OBSERVED.set(id, tokens);
111
- return tokens;
170
+ const context = namedContextWindow(text.slice(0, limit));
171
+ const output = namedOutputCap(text.slice(0, limit));
172
+ if (context === undefined && output === undefined) return undefined;
173
+ if (context !== undefined) OBSERVED.set(id, context);
174
+ if (output !== undefined) OBSERVED_OUTPUT.set(id, output);
175
+ return { context, output };
112
176
  } catch {
113
177
  return undefined;
114
178
  }
@@ -120,22 +184,24 @@ const ROUTE_TIERS = new Set(["route", "route-twin"]);
120
184
  // a plain object to spread over the catalogue's own limit. Empty when there is
121
185
  // no evidence, when the evidence was measured against a different catalogue, or
122
186
  // when it would change nothing.
123
- export function limitOverrides(id, declared = {}, table = LIMITS, observed = OBSERVED) {
187
+ export function limitOverrides(
188
+ id,
189
+ declared = {},
190
+ table = LIMITS,
191
+ observed = OBSERVED,
192
+ observedOutput = OBSERVED_OUTPUT,
193
+ ) {
124
194
  const overrides = {};
125
195
  const record = table?.[id];
126
- const live = observed?.get(id);
127
196
  for (const axis of ["context", "output"]) {
128
197
  const declaredValue = declared?.[axis];
129
198
  const entry = record?.[axis];
199
+ const live = (axis === "context" ? observed : observedOutput)?.get(id);
130
200
  // A route statement made while this process was running is the same tier as
131
201
  // the table and newer than it, so it is taken as it stands. Everything else
132
202
  // has to have been measured against the catalogue in front of us now.
133
203
  const value =
134
- axis === "context" && live !== undefined
135
- ? live
136
- : entry && entry.declaredAt === declaredValue
137
- ? entry.value
138
- : undefined;
204
+ live !== undefined ? live : entry && entry.declaredAt === declaredValue ? entry.value : undefined;
139
205
  if (value === undefined || !Number.isSafeInteger(value) || value <= 0) continue;
140
206
  if (value === declaredValue) continue;
141
207
  if (entry && !ROUTE_TIERS.has(entry.source) && Number.isSafeInteger(declaredValue) && value < declaredValue)
package/models.mjs CHANGED
@@ -43,13 +43,31 @@ export function isFree(id, model = {}) {
43
43
  );
44
44
  }
45
45
 
46
+ // Whether an id names itself free — Zen names them "-free" — which is all this
47
+ // can know about one that neither the catalogue nor the docs describe yet: Zen
48
+ // made it free after they were last written, or they could not be read. Such a
49
+ // model is asked the way OpenCode asks a model with no package of its own,
50
+ // through the provider's own (openai-compatible, and so chat), so a new free
51
+ // model is offered the hour Zen lists it rather than the hour the catalogue
52
+ // catches up. An id that does not call itself free still needs the catalogue's
53
+ // word (a paid one, or a zero-cost one Zen never named).
54
+ function listedFree(id) {
55
+ return /-free$/.test(id);
56
+ }
57
+
46
58
  export function buildModels(ids, provider, protocols, baseURL) {
47
59
  const result = {};
48
60
  for (const id of ids) {
49
61
  if (typeof id !== "string" || !id || id === "__proto__") continue;
50
62
  const raw = provider.models?.[id];
51
63
  const model = raw ?? {};
52
- const protocol = protocols[id] ?? (raw && sdkProtocol(model.provider?.npm ?? provider.npm));
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);
53
71
  if (!isFree(id, model) || !Object.hasOwn(SDK, protocol)) continue;
54
72
  const input = model.modalities?.input ?? ["text"];
55
73
  const output = model.modalities?.output ?? ["text"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-zen-freex",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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",
@@ -10,6 +10,7 @@
10
10
  "core-tools.mjs",
11
11
  "fingerprint.json",
12
12
  "limits.mjs",
13
+ "limits-audit.mjs",
13
14
  "models.mjs",
14
15
  "stream.mjs",
15
16
  "tool-response.mjs",
@@ -27,6 +28,7 @@
27
28
  "scripts": {
28
29
  "test": "bun test",
29
30
  "check:gate": "bun tools-smoke.mjs",
31
+ "limits:audit": "bun limits-audit.mjs",
30
32
  "capture": "bun capture.mjs"
31
33
  },
32
34
  "magpie": { "icon": "https://opencode.ai/favicon-v3.ico", "maxConcurrency": 2 }
package/provider.mjs CHANGED
@@ -15,8 +15,8 @@ import { guardToolResponse } from "./tool-response.mjs";
15
15
 
16
16
  // Zen moved behind opencode.ai/inference; everything under /zen/ answers 403
17
17
  // FreeTierError now, however the request is made.
18
- const DEFAULT_BASE = "https://opencode.ai/inference/v1";
19
- const CATALOG = "https://models.opencode.ai/api.json";
18
+ export const DEFAULT_BASE = "https://opencode.ai/inference/v1";
19
+ export const CATALOG = "https://models.opencode.ai/api.json";
20
20
  const DOCS =
21
21
  "https://raw.githubusercontent.com/anomalyco/opencode/dev/services/www/src/docs/content/console/models.mdx";
22
22
  const PATHS = { chat: "/chat/completions", responses: "/responses", anthropic: "/messages" };
@@ -359,7 +359,13 @@ async function server(_input, options = {}) {
359
359
  discovered[id] = known[id];
360
360
  }
361
361
  }
362
- discovered[Symbol.for("magpie.fellBack")] = true;
362
+ // Deliberately not said as fallen back. Zen's own list was read, and what
363
+ // it lists is what gets offered; said as a fallback, magpie keeps the last
364
+ // list it read in full instead, and a host that cannot reach the catalogue
365
+ // or the docs — GitHub's raw host, from mainland China — would never see a
366
+ // model Zen made free since (yetone/magpie#2385). A listing that could not
367
+ // be read is still said so, above, because then this really is the last
368
+ // list there was.
363
369
  }
364
370
  known = discovered;
365
371
  listed = true;
package/tools-smoke.mjs CHANGED
@@ -72,8 +72,14 @@ function says(text) {
72
72
  }
73
73
 
74
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.
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`).
76
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 }) {
77
83
  return {
78
84
  headers: outboundHeaders({
79
85
  session: sessionID(),
@@ -82,12 +88,12 @@ export function requestFor(generation, profile, names) {
82
88
  protocol: "chat",
83
89
  }),
84
90
  body: {
85
- model: MODEL,
86
- messages: [{ role: "user", content: "hi" }],
91
+ model,
92
+ messages: messages ?? [{ role: "user", content: "hi" }],
87
93
  tools: names.map((name) => ({ type: "function", function: profile.tools[name] })),
88
94
  stream: true,
89
95
  stream_options: { include_usage: true },
90
- max_tokens: 40,
96
+ max_tokens: maxTokens,
91
97
  },
92
98
  };
93
99
  }