opencode-zen-freex 0.2.4 → 0.2.6

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
@@ -21,7 +21,8 @@ ids the catalogue is wrong in the one direction nothing complains about: too
21
21
  small. `deepseek-v4.1-flash-free` is declared 200000/32000 while
22
22
  `deepseek-v4.1-flash` — the paid id serving the same upstream model, which
23
23
  `x-opencode-upstream-model-id` confirms — is declared 1000000/384000 in that
24
- same catalogue.
24
+ same catalogue, and `mimo-v2.6-flash-free` is declared 200000/32000 against a
25
+ model whose own vendor publishes 1048576/131072.
25
26
 
26
27
  Both numbers are acted on. The window decides when a conversation is compacted
27
28
  and how much of it is ever sent; the output cap is sent as `max_tokens` on every
@@ -32,10 +33,15 @@ quietly never used, which is why this stayed invisible for so long.
32
33
  `limits.mjs` holds what was measured, per axis, with the catalogue value it was
33
34
  measured against. Each axis carries `samples` and `how`; the short of it:
34
35
 
35
- | axis | value | from |
36
- | --- | --- | --- |
37
- | context | 1048576 | the route's own refusal, in its own words |
38
- | output | 384000 | what this route declares for the same upstream model |
36
+ | id | axis | value | from |
37
+ | --- | --- | --- | --- |
38
+ | `deepseek-v4.1-flash-free` | context | 1048576 | the route's own refusal, in its own words |
39
+ | `deepseek-v4.1-flash-free` | output | 384000 | what this route declares for the same upstream model |
40
+ | `mimo-v2.6-flash-free` | context | 1048576 | the route's own refusal, in its own words |
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 |
39
45
 
40
46
  A prompt sized just over the window is refused with
41
47
 
@@ -53,10 +59,30 @@ published what that model does; a single reply of 306,880 completion tokens
53
59
  number out of the refusal, three times, interleaved, per the rule below. The
54
60
  filler measured 4.887 characters per token with about 1700 tokens of fixed
55
61
  request overhead, and a request that is *served* says nothing about where the
56
- wall is. The output: re-read the paid id's `limit` in
57
- `https://models.opencode.ai/api.json`. A value Zen accepts is not evidence —
58
- `max_tokens: 2000000` under a short prompt answers 200 and writes nothing like
59
- it — and neither is a generation nobody watched finish.
62
+ wall is — it is what the calibration is checked against, since
63
+ `usage.prompt_tokens` is the real count while the refusal's "requested about"
64
+ figure is a rough one (characters, not tokens) and must not be read as a count.
65
+ A second upstream words the same fact as "This endpoint's maximum context length
66
+ is …" and breaks the request into text input, tool input and output.
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
+
78
+ The output: a paid twin's `limit` in `https://models.opencode.ai/api.json` is
79
+ the stronger source, and re-reading it is the whole job. With no twin — `mimo`
80
+ has none — the figure rests on the vendor's own entry, which is weaker evidence
81
+ and has to say so in `samples`: what is declared is what a vendor publishes, not
82
+ something watched. A value Zen accepts is not evidence — `max_tokens: 2000000`
83
+ under a short prompt answers 200 and writes nothing like it, and `mimo`'s refusal
84
+ came back with the 200000 it was asked for rather than with a ceiling — and
85
+ neither is a generation nobody watched finish.
60
86
 
61
87
  **What voids it.** Each axis records the catalogue value it was measured
62
88
  against, and an axis whose declaration moved is dropped rather than kept: a
@@ -71,15 +97,43 @@ start and hourly after, so a route that changes the window it enforces is
71
97
  followed without anyone editing this file. Nothing is persisted: a restart falls
72
98
  back to what is measured here.
73
99
 
74
- **A lead, not evidence.** `mimo-v2.6-flash-free` is declared 200000/32000 here
75
- too, and another plugin on this route has reported Zen answering that id with
76
- `maximum context length is 1048576 tokens`. That is somebody else's measurement
77
- of a different id, so it is not in `limits.mjs`: measure it before adding it.
100
+ **The same shape on a second upstream.** `mimo-v2.6-flash-free` is declared
101
+ 200000/32000 here too, and its window is a route figure like the one above — an
102
+ 850,249 token prompt was served first, its needle echoed back, and then a prompt
103
+ just over the wall was refused with
104
+
105
+ ```
106
+ This endpoint's maximum context length is 1048576 tokens. However, you requested
107
+ about 1496523 tokens (1295079 of text input, 1444 of tool input, 200000 in the
108
+ output).
109
+ ```
110
+
111
+ which is the route naming its own window, on an upstream that words it
112
+ differently from the DeepSeek ids. Its output is the first figure in this file
113
+ resting on a vendor page: that same refusal came back with the 200000 it was
114
+ asked for, unclamped, so this route states no completion ceiling of its own, and
115
+ 131072 is Xiaomi's entry for `mimo-v2.6-flash` (`api.xiaomimimo.com`, and its
116
+ China token plan). Nothing above 32000 has been watched on this route, and
117
+ `samples` says that rather than implying a witness.
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.
78
131
 
79
132
  ## The loop
80
133
 
81
134
  ```sh
82
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
83
137
  bun run check:gate # asks Zen which generations are still accepted
84
138
  bun run capture -- --dry # takes a fresh capture and reports the difference
85
139
  bun run capture # writes it
@@ -252,4 +306,26 @@ you do not have permission to access it.
252
306
 
253
307
  which reads exactly like the package not existing. It is the same status npm
254
308
  gives a genuine permission failure, so a 404 from a publish says nothing at
255
- all until the token has been checked.
309
+ all until the token has been checked.
310
+
311
+ **A publish that no longer offers the browser at all.** A token can be dead
312
+ while npm says nothing about it until a write is attempted. Measured: `npm
313
+ whoami` answers
314
+
315
+ ```
316
+ npm error 401 Unauthorized - GET https://registry.npmjs.org/-/whoami
317
+ ```
318
+
319
+ and the publish answers that same 404 without ever printing an auth address,
320
+ because with no usable credential the request goes out unauthenticated and npm
321
+ reports the result as not-found. `npm login --auth-type=web` is the fix — the
322
+ browser exchange again, which replaces the stored token — and the publish after
323
+ it asks for the second factor in the browser once more. **Two authorizations in
324
+ a row is the normal shape of this**, not a sign that something is wrong. So on a
325
+ 404 from a publish, ask `npm whoami` before anything else: 401 is a session that
326
+ has to be renewed, and everything else is the permission problem above.
327
+
328
+ npm says on login that "tokens that bypass 2FA are being restricted for account
329
+ changes and direct publishing", so the granular route below is on its way out as
330
+ a way to publish; the browser is the road, and the token is the exception it is
331
+ already written to be.
@@ -0,0 +1,238 @@
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, stopped as soon as it
20
+ // starts writing. An upstream that validates the ceiling refuses and names its
21
+ // own; one that does not just begins to answer and is cut off after the first
22
+ // chunk. Either way the cost is a handful of tokens, and a 410 or a 400 for a
23
+ // model that is gone is visible in the same request.
24
+ //
25
+ // The offered set is the listing's, not the catalogue's: Zen's /v1/models
26
+ // carries 10 of the 40 free ids the catalogue still describes, and a plugin
27
+ // only ever offers what the listing has. Auditing the other 30 reports a page
28
+ // of `Model is unavailable` about models nobody can pick.
29
+ //
30
+ // What is reported, per offered id: the upstream model and the ids sharing it,
31
+ // the ceiling a refusal named, the window a refusal named, and the status. The
32
+ // candidates at the end are the reason to run it.
33
+ //
34
+ // Exit code is 0 when at least one offered id answered, 1 when none did: being
35
+ // unable to ask is not a finding about limits.
36
+
37
+ import { PROFILES } from "./core-tools.mjs";
38
+ import { LIMITS, limitOverrides, namedContextWindow, namedOutputCap } from "./limits.mjs";
39
+ import { CATALOG, DEFAULT_BASE } from "./provider.mjs";
40
+ import { chatRequest } from "./tools-smoke.mjs";
41
+
42
+ const BASE = process.env.ZEN_BASE_URL ?? DEFAULT_BASE;
43
+ const CATALOG_URL = process.env.ZEN_CATALOG_URL ?? CATALOG;
44
+ const GENERATION = process.env.ZEN_GENERATION ?? "1.x";
45
+ const READ_BUDGET = 4096;
46
+ const ASKED_CEILING = 1_000_000;
47
+
48
+ // The ids the plugin would offer: the listing's, kept where the catalogue calls
49
+ // them free. Exported for the tests.
50
+ export function offeredIds(listing, catalog) {
51
+ return listing.filter((id) => {
52
+ const model = catalog?.[id];
53
+ if (!model) return false;
54
+ return /free/i.test(id) || (model.cost?.input === 0 && model.cost?.output === 0);
55
+ });
56
+ }
57
+
58
+ // Offered ids by the upstream model serving them, which is the route's own
59
+ // answer and not a guess from the id's shape.
60
+ export function groupByUpstream(rows) {
61
+ const groups = new Map();
62
+ for (const row of rows) {
63
+ if (!row.upstream) continue;
64
+ groups.set(row.upstream, [...(groups.get(row.upstream) ?? []), row]);
65
+ }
66
+ return groups;
67
+ }
68
+
69
+ // What is worth a measurement, in the order it should be read. `covered` are the
70
+ // ids limits.mjs already carries evidence for, which are said once and not
71
+ // raised again. Exported for the tests.
72
+ export function findings(rows, covered = new Set()) {
73
+ const out = [];
74
+ for (const row of rows) {
75
+ const declared = row.declared ?? {};
76
+ // An id limits.mjs already carries evidence for is not asked for again: its
77
+ // numbers are in the table and repeating them here would bury what is new.
78
+ // Its status is still printed in the table above, so a model that dies is
79
+ // not hidden by this — only its numbers stop being a question.
80
+ if (covered.has(row.id)) continue;
81
+ if (row.ceiling !== undefined && row.ceiling !== declared.output)
82
+ out.push(
83
+ `${row.id}: the route names a ceiling of ${row.ceiling} completion tokens, the catalogue declares ${declared.output ?? "none"}`,
84
+ );
85
+ if (row.window !== undefined && row.window !== declared.context)
86
+ out.push(
87
+ `${row.id}: a refusal named a window of ${row.window}, the catalogue declares ${declared.context ?? "none"}`,
88
+ );
89
+ const named = row.ceiling !== undefined || row.window !== undefined;
90
+ if (row.status !== 200 && !named) {
91
+ // A 400 this audit provoked by asking for too much is the audit working,
92
+ // not a finding, and neither is a refusal a number was just read out of.
93
+ // What is left is worth reading: a 410, a 400 that named nothing, and a
94
+ // model that does not speak the shape asked with.
95
+ const protocol = /protocol|format/i.test(row.says ?? "");
96
+ out.push(
97
+ `${row.id}: HTTP ${row.status}${protocol ? " (does not speak the chat shape this audit asks with)" : ""}${row.says ? ` — ${row.says}` : ""}`,
98
+ );
99
+ }
100
+ }
101
+ for (const [upstream, list] of groupByUpstream(rows)) {
102
+ const largest = (axis) => Math.max(...list.map((row) => row.declared?.[axis] ?? 0));
103
+ if (list.length < 2) continue;
104
+ if (new Set(list.map((row) => `${row.declared?.context}/${row.declared?.output}`)).size < 2) continue;
105
+ for (const row of list) {
106
+ if (covered.has(row.id)) continue;
107
+ if ((row.declared?.context ?? 0) >= largest("context") && (row.declared?.output ?? 0) >= largest("output"))
108
+ continue;
109
+ const siblings = list
110
+ .filter((other) => other.id !== row.id)
111
+ .map((other) => `${other.id} (${other.declared?.context}/${other.declared?.output})`)
112
+ .join(", ");
113
+ out.push(
114
+ `${row.id}: declared ${row.declared?.context}/${row.declared?.output}, and ${upstream} is also served as ${siblings}`,
115
+ );
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+
121
+ function says(text) {
122
+ const match = text.match(/"(?:message|param)"\s*:\s*"([^"]{0,160})"/);
123
+ if (match) return match[1];
124
+ // Some upstreams refuse in words that are not under a `message` key at all,
125
+ // and a refusal nobody can read is not a finding.
126
+ const flat = text.replace(/\s+/g, " ").trim();
127
+ return flat ? flat.slice(0, 160) : "";
128
+ }
129
+
130
+ async function getJSON(url) {
131
+ const response = await fetch(url, { signal: AbortSignal.timeout(30000) });
132
+ if (!response.ok) throw new Error(`${url} answered HTTP ${response.status}`);
133
+ return response.json();
134
+ }
135
+
136
+ // One cheap question per id. The ceiling asked for is past anything this route
137
+ // serves, so an upstream that validates it says what it allows, and the stream
138
+ // is dropped as soon as it starts rather than paying for an answer nobody reads.
139
+ async function ask(id, names) {
140
+ const { headers, body } = chatRequest({
141
+ model: id,
142
+ generation: GENERATION,
143
+ profile: PROFILES[GENERATION],
144
+ names,
145
+ maxTokens: ASKED_CEILING,
146
+ });
147
+ const controller = new AbortController();
148
+ const timer = setTimeout(() => controller.abort(), 45000);
149
+ try {
150
+ const response = await fetch(`${BASE}/chat/completions`, {
151
+ method: "POST",
152
+ headers,
153
+ body: JSON.stringify(body),
154
+ signal: controller.signal,
155
+ });
156
+ let text = "";
157
+ if (response.ok) {
158
+ const reader = response.body?.getReader();
159
+ if (reader) {
160
+ const decoder = new TextDecoder();
161
+ while (text.length < READ_BUDGET) {
162
+ const { value, done } = await reader.read();
163
+ if (done) break;
164
+ text += decoder.decode(value, { stream: true });
165
+ }
166
+ }
167
+ } else {
168
+ text = (await response.text()).slice(0, READ_BUDGET);
169
+ }
170
+ const upstream = response.headers.get("x-opencode-upstream-model-id") ?? undefined;
171
+ const endpoint = response.headers.get("x-opencode-endpoint-id") ?? undefined;
172
+ return {
173
+ status: response.status,
174
+ upstream,
175
+ endpoint,
176
+ window: namedContextWindow(text),
177
+ ceiling: namedOutputCap(text),
178
+ says: response.ok ? "" : says(text),
179
+ };
180
+ } catch (error) {
181
+ return { status: 0, says: error.message, upstream: undefined, endpoint: undefined };
182
+ } finally {
183
+ clearTimeout(timer);
184
+ controller.abort();
185
+ }
186
+ }
187
+
188
+ // Only a run asks the route. The tests import the helpers above and must not
189
+ // reach the network.
190
+ if (import.meta.main) {
191
+ const names = [...PROFILES[GENERATION].required];
192
+ const [listing, catalog] = await Promise.all([getJSON(`${BASE}/models`), getJSON(CATALOG_URL)]);
193
+ const models = catalog?.opencode?.models ?? {};
194
+ const ids = offeredIds(
195
+ (Array.isArray(listing?.data) ? listing.data : []).map((entry) => entry?.id).filter(Boolean),
196
+ models,
197
+ );
198
+ const free = Object.keys(models).filter(
199
+ (id) => /free/i.test(id) || (models[id].cost?.input === 0 && models[id].cost?.output === 0),
200
+ );
201
+ console.log(`limits audit — ${BASE}`);
202
+ console.log(`${ids.length} free ids are offered; the catalogue still describes ${free.length}\n`);
203
+
204
+ const rows = [];
205
+ for (const id of ids) {
206
+ const answer = await ask(id, names);
207
+ const declared = models[id]?.limit ?? {};
208
+ rows.push({ id, declared, ...answer });
209
+ // What the plugin declares is the catalogue's limit with limits.mjs over it,
210
+ // so that is what is printed; the catalogue's own pair is shown beside it
211
+ // when the two differ, since that is the under-report being answered.
212
+ const effective = { ...declared, ...limitOverrides(id, declared) };
213
+ const moved =
214
+ effective.context !== declared.context || effective.output !== declared.output;
215
+ const mark = answer.status === 200 ? " " : "! ";
216
+ console.log(
217
+ `${mark}${id.padEnd(34)} ${`${effective.context}/${effective.output}`.padStart(17)}` +
218
+ `${moved ? ` (catalogue ${declared.context}/${declared.output})` : ""} ${answer.status}` +
219
+ `${answer.upstream ? ` ${answer.upstream}${answer.endpoint ? ` via ${answer.endpoint}` : ""}` : ""}` +
220
+ `${answer.ceiling !== undefined ? ` ceiling=${answer.ceiling}` : ""}${answer.window !== undefined ? ` window=${answer.window}` : ""}` +
221
+ `${answer.says ? ` — ${answer.says}` : ""}`,
222
+ );
223
+ }
224
+
225
+ const answered = rows.filter((row) => row.status === 200);
226
+ const candidates = findings(rows, new Set(Object.keys(LIMITS)));
227
+ console.log(`\nlimits.mjs already carries evidence for: ${Object.keys(LIMITS).join(", ")}`);
228
+ console.log(`\n${candidates.length} candidate${candidates.length === 1 ? "" : "s"}:`);
229
+ for (const line of candidates) console.log(` ${line}`);
230
+ if (!candidates.length) console.log(" none — every offered id is either measured or declared at what the route states");
231
+
232
+ if (!answered.length) {
233
+ console.log(
234
+ "\nNothing answered, so this says nothing about limits. Check the network or ZEN_BASE_URL and ask again.",
235
+ );
236
+ process.exitCode = 1;
237
+ }
238
+ }
package/limits.mjs CHANGED
@@ -48,34 +48,118 @@ export const LIMITS = {
48
48
  how: "deepseek-v4.1-flash, the paid id serving the same upstream model, is declared 384000 in this same catalogue; DeepSeek's own page says 384K, which is 393216 read as binary K",
49
49
  },
50
50
  },
51
+ "mimo-v2.6-flash-free": {
52
+ context: {
53
+ value: 1048576,
54
+ source: "route",
55
+ declaredAt: 200000,
56
+ measured: "2026-10-15",
57
+ samples: "an 850,249 token prompt served, its needle echoed back; a prompt just over the window refused 400",
58
+ how: "a different upstream from the DeepSeek ids, and it says so in its own words: \"This endpoint's maximum context length is 1048576 tokens. However, you requested about 1496523 tokens (1295079 of text input, 1444 of tool input, 200000 in the output).\"",
59
+ },
60
+ output: {
61
+ value: 131072,
62
+ source: "vendor",
63
+ declaredAt: 32000,
64
+ measured: "2026-10-15",
65
+ samples: "nothing above 32000 watched on this route; that same refusal came back with the requested 200000 unclamped",
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
+ },
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
+ },
51
99
  };
52
100
 
53
101
  // "This model's maximum context length is 1048576 tokens. However, you
54
102
  // requested 1422278 tokens (1038278 in the messages, 384000 in the
55
103
  // completion)." — the route's own words, and the only sentence seen on this
56
- // route that states a window. A generic 400 says nothing attributable and is
57
- // left alone; §7 of the measured notes has a context overflow and an invalid
58
- // parameter answering with byte-identical 400s on another Zen model.
104
+ // route that states a window. A second upstream phrases the same fact as "This
105
+ // endpoint's maximum context length is ...", with the request broken into text
106
+ // input, tool input and output; both are the route naming its own window, and
107
+ // its "requested about" figure is a rough one (chars, not tokens) rather than
108
+ // the count a served request reports in `usage.prompt_tokens`. A generic 400
109
+ // says nothing attributable and is left alone; §7 of the measured notes has a
110
+ // context overflow and an invalid parameter answering with byte-identical 400s
111
+ // on another Zen model.
59
112
  const NAMED_CONTEXT = /maximum context length is (\d+) tokens/i;
60
113
 
61
- // Windows named by a refusal while this process is running, by model id. Not
62
- // persisted: it is the same kind of fact as LIMITS, only newer, and a restart
63
- // 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.
64
131
  const OBSERVED = new Map();
132
+ const OBSERVED_OUTPUT = new Map();
65
133
 
66
134
  // The window a refusal names, or undefined when the body says nothing
67
135
  // attributable. Exported for the tests and for anyone reading a capture.
68
136
  export function namedContextWindow(text) {
69
- 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 : "");
70
152
  if (!match) return undefined;
71
153
  const tokens = Number.parseInt(match[1], 10);
72
154
  return Number.isSafeInteger(tokens) && tokens > 0 ? tokens : undefined;
73
155
  }
74
156
 
75
- // Read a refusal for a window it names. Only non-2xx responses are read, and
76
- // only the first `limit` characters of one: this is an error path on the
77
- // request's way to a caller that is waiting, so it must never be able to fail a
78
- // 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.
79
163
  export async function observeRouteStatement(id, response, limit = 4096) {
80
164
  try {
81
165
  if (typeof id !== "string" || !id || response?.ok !== false) return undefined;
@@ -83,9 +167,12 @@ export async function observeRouteStatement(id, response, limit = 4096) {
83
167
  .clone()
84
168
  .text()
85
169
  .catch(() => "");
86
- const tokens = namedContextWindow(text.slice(0, limit));
87
- if (tokens !== undefined) OBSERVED.set(id, tokens);
88
- 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 };
89
176
  } catch {
90
177
  return undefined;
91
178
  }
@@ -97,22 +184,24 @@ const ROUTE_TIERS = new Set(["route", "route-twin"]);
97
184
  // a plain object to spread over the catalogue's own limit. Empty when there is
98
185
  // no evidence, when the evidence was measured against a different catalogue, or
99
186
  // when it would change nothing.
100
- 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
+ ) {
101
194
  const overrides = {};
102
195
  const record = table?.[id];
103
- const live = observed?.get(id);
104
196
  for (const axis of ["context", "output"]) {
105
197
  const declaredValue = declared?.[axis];
106
198
  const entry = record?.[axis];
199
+ const live = (axis === "context" ? observed : observedOutput)?.get(id);
107
200
  // A route statement made while this process was running is the same tier as
108
201
  // the table and newer than it, so it is taken as it stands. Everything else
109
202
  // has to have been measured against the catalogue in front of us now.
110
203
  const value =
111
- axis === "context" && live !== undefined
112
- ? live
113
- : entry && entry.declaredAt === declaredValue
114
- ? entry.value
115
- : undefined;
204
+ live !== undefined ? live : entry && entry.declaredAt === declaredValue ? entry.value : undefined;
116
205
  if (value === undefined || !Number.isSafeInteger(value) || value <= 0) continue;
117
206
  if (value === declaredValue) continue;
118
207
  if (entry && !ROUTE_TIERS.has(entry.source) && Number.isSafeInteger(declaredValue) && value < declaredValue)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-zen-freex",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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" };
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
  }