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 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 不再免费开放的模型会退出列表,新转为免费的模型会加入列表。如果读不到 Zen 的列表,就沿用上一次的结果;第一次还没读到之前,用的是打包在插件里的那份,所以这个 provider 不会空着。请求一个当前并不免费的模型,会得到一个 404 并列出当时免费的模型;若某个模型是在上次列举之后才转免费的,请求会在重新获取列表后照常服务(最多每分钟一次)。可用性和限流由 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, 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.
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. Exported for the tests.
50
- export function offeredIds(listing, catalog) {
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 false;
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
- // 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
- );
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
- for (const id of ids) {
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
- effective.context !== declared.context || effective.output !== declared.output;
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)} ${`${effective.context}/${effective.output}`.padStart(17)}` +
218
- `${moved ? ` (catalogue ${declared.context}/${declared.output})` : ""} ${answer.status}` +
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 = protocols[id] ?? (raw && sdkProtocol(model.provider?.npm ?? provider.npm));
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.6",
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
- discovered[Symbol.for("magpie.fellBack")] = true;
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;