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 +70 -1
- package/README.md +3 -1
- package/limits-audit.mjs +262 -0
- package/limits.mjs +84 -18
- package/models.mjs +19 -1
- package/package.json +3 -1
- package/provider.mjs +9 -3
- package/tools-smoke.mjs +10 -4
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
|
|
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
|
|
package/limits-audit.mjs
ADDED
|
@@ -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
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
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
|
-
|
|
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
|
|
99
|
-
// only the first `limit` characters of one: this is an
|
|
100
|
-
// request's way to a caller that is waiting, so it must never
|
|
101
|
-
// 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.
|
|
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
|
|
110
|
-
|
|
111
|
-
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 };
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
}
|