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 +90 -14
- package/limits-audit.mjs +238 -0
- package/limits.mjs +110 -21
- package/package.json +3 -1
- package/provider.mjs +2 -2
- package/tools-smoke.mjs +10 -4
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
|
|
57
|
-
`
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
**
|
|
75
|
-
too, and
|
|
76
|
-
|
|
77
|
-
|
|
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.
|
package/limits-audit.mjs
ADDED
|
@@ -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
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
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
|
-
|
|
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
|
|
76
|
-
// only the first `limit` characters of one: this is an
|
|
77
|
-
// request's way to a caller that is waiting, so it must never
|
|
78
|
-
// request, change a response, or buffer a body that is not
|
|
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
|
|
87
|
-
|
|
88
|
-
return
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:
|
|
96
|
+
max_tokens: maxTokens,
|
|
91
97
|
},
|
|
92
98
|
};
|
|
93
99
|
}
|