opencode-zen-freex 0.2.6 → 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 +20 -0
- package/README.md +3 -1
- package/limits-audit.mjs +45 -21
- package/models.mjs +19 -1
- package/package.json +1 -1
- package/provider.mjs +7 -1
package/MAINTAINING.md
CHANGED
|
@@ -195,6 +195,26 @@ the cooldown answers like a refusal.
|
|
|
195
195
|
|
|
196
196
|
## Things that are easy to break
|
|
197
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
|
+
|
|
198
218
|
**Adding a header.** The set sent has one owner: `outboundHeaders` in
|
|
199
219
|
`provider.mjs`, which the plugin builds its request with and which the gate asks
|
|
200
220
|
Zen with, so neither can drift from the other on its own. `index.test.mjs` pins
|
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
CHANGED
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
// prompt of about a million tokens — minutes and megabytes — and belongs to the
|
|
17
17
|
// one id a signal has already pointed at; the numbers live in limits.mjs and are
|
|
18
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
|
|
20
|
-
// starts writing. An upstream that validates the
|
|
21
|
-
// own; one that does not just begins to answer and
|
|
22
|
-
// chunk. Either way the cost is a handful of tokens,
|
|
23
|
-
// model that is gone is visible in the same request.
|
|
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.
|
|
24
25
|
//
|
|
25
26
|
// The offered set is the listing's, not the catalogue's: Zen's /v1/models
|
|
26
27
|
// carries 10 of the 40 free ids the catalogue still describes, and a plugin
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
|
|
37
38
|
import { PROFILES } from "./core-tools.mjs";
|
|
38
39
|
import { LIMITS, limitOverrides, namedContextWindow, namedOutputCap } from "./limits.mjs";
|
|
40
|
+
import { SDK, bootstrapProtocols } from "./models.mjs";
|
|
39
41
|
import { CATALOG, DEFAULT_BASE } from "./provider.mjs";
|
|
40
42
|
import { chatRequest } from "./tools-smoke.mjs";
|
|
41
43
|
|
|
@@ -46,11 +48,18 @@ const READ_BUDGET = 4096;
|
|
|
46
48
|
const ASKED_CEILING = 1_000_000;
|
|
47
49
|
|
|
48
50
|
// The ids the plugin would offer: the listing's, kept where the catalogue calls
|
|
49
|
-
// them free
|
|
50
|
-
|
|
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) {
|
|
51
58
|
return listing.filter((id) => {
|
|
59
|
+
const protocol = snapshot?.[id];
|
|
60
|
+
if (protocol !== undefined && !Object.hasOwn(SDK, protocol)) return false;
|
|
52
61
|
const model = catalog?.[id];
|
|
53
|
-
if (!model) return
|
|
62
|
+
if (!model) return /-free$/.test(id);
|
|
54
63
|
return /free/i.test(id) || (model.cost?.input === 0 && model.cost?.output === 0);
|
|
55
64
|
});
|
|
56
65
|
}
|
|
@@ -87,15 +96,13 @@ export function findings(rows, covered = new Set()) {
|
|
|
87
96
|
`${row.id}: a refusal named a window of ${row.window}, the catalogue declares ${declared.context ?? "none"}`,
|
|
88
97
|
);
|
|
89
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;
|
|
90
103
|
if (row.status !== 200 && !named) {
|
|
91
|
-
//
|
|
92
|
-
|
|
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
|
-
);
|
|
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}` : ""}`);
|
|
99
106
|
}
|
|
100
107
|
}
|
|
101
108
|
for (const [upstream, list] of groupByUpstream(rows)) {
|
|
@@ -127,6 +134,13 @@ function says(text) {
|
|
|
127
134
|
return flat ? flat.slice(0, 160) : "";
|
|
128
135
|
}
|
|
129
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
|
+
|
|
130
144
|
async function getJSON(url) {
|
|
131
145
|
const response = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
|
132
146
|
if (!response.ok) throw new Error(`${url} answered HTTP ${response.status}`);
|
|
@@ -202,7 +216,12 @@ if (import.meta.main) {
|
|
|
202
216
|
console.log(`${ids.length} free ids are offered; the catalogue still describes ${free.length}\n`);
|
|
203
217
|
|
|
204
218
|
const rows = [];
|
|
205
|
-
|
|
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) {
|
|
206
225
|
const answer = await ask(id, names);
|
|
207
226
|
const declared = models[id]?.limit ?? {};
|
|
208
227
|
rows.push({ id, declared, ...answer });
|
|
@@ -210,12 +229,12 @@ if (import.meta.main) {
|
|
|
210
229
|
// so that is what is printed; the catalogue's own pair is shown beside it
|
|
211
230
|
// when the two differ, since that is the under-report being answered.
|
|
212
231
|
const effective = { ...declared, ...limitOverrides(id, declared) };
|
|
213
|
-
const moved =
|
|
214
|
-
|
|
232
|
+
const moved = effective.context !== declared.context || effective.output !== declared.output;
|
|
233
|
+
const pair = (limit) => `${limit.context ?? "—"}/${limit.output ?? "—"}`;
|
|
215
234
|
const mark = answer.status === 200 ? " " : "! ";
|
|
216
235
|
console.log(
|
|
217
|
-
`${mark}${id.padEnd(34)} ${
|
|
218
|
-
`${moved ? ` (catalogue ${declared
|
|
236
|
+
`${mark}${id.padEnd(34)} ${pair(effective).padStart(17)}` +
|
|
237
|
+
`${moved ? ` (catalogue ${pair(declared)})` : ""} ${answer.status}` +
|
|
219
238
|
`${answer.upstream ? ` ${answer.upstream}${answer.endpoint ? ` via ${answer.endpoint}` : ""}` : ""}` +
|
|
220
239
|
`${answer.ceiling !== undefined ? ` ceiling=${answer.ceiling}` : ""}${answer.window !== undefined ? ` window=${answer.window}` : ""}` +
|
|
221
240
|
`${answer.says ? ` — ${answer.says}` : ""}`,
|
|
@@ -224,6 +243,11 @@ if (import.meta.main) {
|
|
|
224
243
|
|
|
225
244
|
const answered = rows.filter((row) => row.status === 200);
|
|
226
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
|
+
);
|
|
227
251
|
console.log(`\nlimits.mjs already carries evidence for: ${Object.keys(LIMITS).join(", ")}`);
|
|
228
252
|
console.log(`\n${candidates.length} candidate${candidates.length === 1 ? "" : "s"}:`);
|
|
229
253
|
for (const line of candidates) console.log(` ${line}`);
|
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",
|
package/provider.mjs
CHANGED
|
@@ -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;
|