opencode-zen-freex 0.2.5 → 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 +50 -1
- package/limits-audit.mjs +238 -0
- package/limits.mjs +84 -18
- package/package.json +3 -1
- package/provider.mjs +2 -2
- 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
|
|
@@ -279,4 +306,26 @@ you do not have permission to access it.
|
|
|
279
306
|
|
|
280
307
|
which reads exactly like the package not existing. It is the same status npm
|
|
281
308
|
gives a genuine permission failure, so a 404 from a publish says nothing at
|
|
282
|
-
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
|
@@ -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/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
|
}
|