opencode-zen-freex 0.2.2 → 0.2.3

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
@@ -1,6 +1,6 @@
1
1
  # Maintaining this plugin
2
2
 
3
- The plugin is one file of logic and one piece of data, and the data is the part
3
+ The plugin is one file of logic and two pieces of data, and the data is the part
4
4
  that goes out of date. This is how to look after it.
5
5
 
6
6
  ## What is actually fragile
@@ -14,6 +14,68 @@ Nothing about a stale copy looks different from a working one. The symptom is a
14
14
  `403 FreeTierError` on every request, which reads as "you are not OpenCode" and
15
15
  sends people off to change headers.
16
16
 
17
+ ## Limits Zen under-reports
18
+
19
+ `models.mjs` declares each model's limits from Zen's catalogue, and for some free
20
+ ids the catalogue is wrong in the one direction nothing complains about: too
21
+ small. `deepseek-v4.1-flash-free` is declared 200000/32000 while
22
+ `deepseek-v4.1-flash` — the paid id serving the same upstream model, which
23
+ `x-opencode-upstream-model-id` confirms — is declared 1000000/384000 in that
24
+ same catalogue.
25
+
26
+ Both numbers are acted on. The window decides when a conversation is compacted
27
+ and how much of it is ever sent; the output cap is sent as `max_tokens` on every
28
+ request, so a reply is cut at it with a finish reason that reads like the
29
+ model's own decision. A window that is too small is never refused, it is just
30
+ quietly never used, which is why this stayed invisible for so long.
31
+
32
+ `limits.mjs` holds what was measured, per axis, with the catalogue value it was
33
+ measured against. Each axis carries `samples` and `how`; the short of it:
34
+
35
+ | axis | value | from |
36
+ | --- | --- | --- |
37
+ | context | 1048576 | the route's own refusal, in its own words |
38
+ | output | 384000 | what this route declares for the same upstream model |
39
+
40
+ A prompt sized just over the window is refused with
41
+
42
+ ```
43
+ This model's maximum context length is 1048576 tokens. However, you requested
44
+ 1422278 tokens (1038278 in the messages, 384000 in the completion).
45
+ ```
46
+
47
+ which is the route stating its own window, and better evidence than any vendor
48
+ page. The output figure is the paid twin's declaration, so the route has already
49
+ published what that model does; a single reply of 306,880 completion tokens
50
+ (`finish_reason: stop`) was watched and is recorded as the witness.
51
+
52
+ **Re-measuring.** The window: send a prompt of about 1.05M tokens and read the
53
+ number out of the refusal, three times, interleaved, per the rule below. The
54
+ filler measured 4.887 characters per token with about 1700 tokens of fixed
55
+ request overhead, and a request that is *served* says nothing about where the
56
+ wall is. The output: re-read the paid id's `limit` in
57
+ `https://models.opencode.ai/api.json`. A value Zen accepts is not evidence —
58
+ `max_tokens: 2000000` under a short prompt answers 200 and writes nothing like
59
+ it — and neither is a generation nobody watched finish.
60
+
61
+ **What voids it.** Each axis records the catalogue value it was measured
62
+ against, and an axis whose declaration moved is dropped rather than kept: a
63
+ measurement of one question is not an answer to another. A catalogue that raises
64
+ the free id to the paid twin's figures therefore makes this file inert on its
65
+ own, which is the intended end state.
66
+
67
+ **While the plugin runs.** A refusal that names a window is read on its way past
68
+ (`observeRouteStatement`, called from the provider's `fetch`), and it corrects
69
+ that model's window for the rest of the process. The list is rebuilt at every
70
+ start and hourly after, so a route that changes the window it enforces is
71
+ followed without anyone editing this file. Nothing is persisted: a restart falls
72
+ back to what is measured here.
73
+
74
+ **A lead, not evidence.** `mimo-v2.6-flash-free` is declared 200000/32000 here
75
+ too, and another plugin on this route has reported Zen answering that id with
76
+ `maximum context length is 1048576 tokens`. That is somebody else's measurement
77
+ of a different id, so it is not in `limits.mjs`: measure it before adding it.
78
+
17
79
  ## The loop
18
80
 
19
81
  ```sh
@@ -107,10 +169,11 @@ that passes under the stricter of them, since the looser one will not last.
107
169
 
108
170
  | file | what it is |
109
171
  | --- | --- |
110
- | `fingerprint.json` | the capture. The only thing that goes out of date |
172
+ | `fingerprint.json` | the capture: the wording and User-Agent Zen accepts |
111
173
  | `core-tools.mjs` | reads the capture; says which generation a request is |
112
174
  | `provider.mjs` | the plugin: discovery, headers, tools, the diagnostic |
113
175
  | `models.mjs` | which models are free, and what protocol each speaks |
176
+ | `limits.mjs` | what a model's limits are, where the catalogue under-reports them |
114
177
  | `stream.mjs`, `tool-response.mjs` | replies Zen cuts short, and tool calls |
115
178
  | `capture.mjs` | takes a new capture |
116
179
  | `tools-smoke.mjs` | asks Zen what it accepts (`check:gate`) |
@@ -127,19 +190,30 @@ provider id, and which one wins is down to load order. Pick one.
127
190
  To publish: bump `version`, `npm publish`, and tag. `magpie plugin add
128
191
  opencode-zen-freex` installs it by that name.
129
192
 
130
- Publishing is the one step here that cannot be run unattended, because it is
131
- the one step npm asks a person about. Run it as yourself.
193
+ **The second factor is a browser, not a password.** Run `npm publish`; when the
194
+ account has 2FA on, the command stops with `EOTP` and prints
132
195
 
133
- **With 2FA on the account.** `npm publish` fails with `EOTP` and prints a
134
- `https://www.npmjs.com/auth/cli/...` address, redacted in the error and
135
- different every time. Open it, finish, and the same command carries on. There
136
- is nothing to pass to a script: the address is tied to the browser session that
137
- prints it.
196
+ ```
197
+ https://www.npmjs.com/auth/cli/...
198
+ ```
138
199
 
139
- **With a granular token.** A token with publish permission authorizes the
140
- publish on its own and never asks for the second factor. Write it to an npmrc
141
- outside the repository and point npm at it for that one command, so it does
142
- not land in a tracked file:
200
+ Open that address, authorize there, and the same command carries on by itself.
201
+ Nothing is typed back into the terminal and nothing is passed to a script: the
202
+ address is tied to the browser session that printed it and is different every
203
+ time, so it is read out of the running command's own output rather than composed
204
+ in advance. That address belongs to whoever is at the browser, so they are the
205
+ one who authorizes — the command can sit in the background while they do, and it
206
+ needs no more than the one click. Opening it is a convenience, not a step: a
207
+ `start` (Windows) or `open` (macOS) on the URL is the whole of it.
208
+
209
+ An account with no second factor to give publishes in one step. So does
210
+ `npm login --auth-type=web`, which is the same browser exchange kept as a
211
+ session, and so does a granular token below.
212
+
213
+ **With a granular token** (unattended, CI). A token with publish permission
214
+ authorizes the publish on its own and never asks for the second factor. Write it
215
+ to an npmrc outside the repository and point npm at it for that one command, so
216
+ it does not land in a tracked file:
143
217
 
144
218
  ```sh
145
219
  printf '//registry.npmjs.org/:_authToken=npm_...\n' > "$TMP/npmrc"
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 为什么有这个分支
6
6
 
7
- 上游 [zen-free](https://github.com/magpie-community/plugins/tree/main/packages/zen-free) 是这个插件的前身。它一直在修,修得也不慢,但「免费模型还能不能用」这件事在上游那里翻过一次,而往哪边翻不掌握在使用它的人手里:
7
+ 上游 [zen-free](https://github.com/magpie-community/plugins/tree/main/packages/zen-free) 是这个插件的前身。它一直在维护,修bug也不慢,但对「给免费模型适配」这件事的态度摇摆不定,时而维护而放弃维护故催生本分支:
8
8
 
9
9
  | UTC | 提交 | 那一下说了什么 |
10
10
  | --- | --- | --- |
@@ -45,6 +45,8 @@ magpie plugin login opencode-zen-free
45
45
 
46
46
  插件会去发现 Zen 当前的免费模型,包括它们的上下文窗口和思考(reasoning)等级。用 `opencode-zen-free/<模型名>`,例如 `opencode-zen-free/big-pickle`。
47
47
 
48
+ 免费模型的目录上限有时被 Zen 少报,而少报的那一边没有谁会报错:`deepseek-v4.1-flash-free` 在目录里是 20 万上下文 / 3.2 万输出,而同一上游模型的付费 id 是 100 万 / 38.4 万。上下文窗口太小只会让宿主安静地早点压缩,输出上限太小会把回复截断成看起来像模型自己停下的样子,所以插件对这类模型按实测与路由自述对齐(依据与复测方法见 `MAINTAINING.md` 的 “Limits Zen under-reports”),并在运行中采用上游自报的窗口。
49
+
48
50
  已下线的模型和 SystemOne 模型会被排除。这份列表在宿主每次列举模型时重新获取(magpie 在启动时做一次,之后每小时一次):Zen 不再免费开放的模型会退出列表,新转为免费的模型会加入列表。如果读不到 Zen 的列表,就沿用上一次的结果;第一次还没读到之前,用的是打包在插件里的那份,所以这个 provider 不会空着。请求一个当前并不免费的模型,会得到一个 404 并列出当时免费的模型;若某个模型是在上次列举之后才转免费的,请求会在重新获取列表后照常服务(最多每分钟一次)。可用性和限流由 Zen 决定,插件这边拿不到剩余额度。
49
51
 
50
52
  ## 免费层不再应答时
package/limits.mjs ADDED
@@ -0,0 +1,123 @@
1
+ // Zen's catalogue is where a model's limits are declared from, and for some
2
+ // free ids it under-reports them. A free id and its paid twin are the same
3
+ // upstream model — x-opencode-upstream-model-id answers
4
+ // "deepseek/deepseek-v4.1-flash" whichever of the two is asked for — and only
5
+ // the paid one is declared at what that model can actually do.
6
+ //
7
+ // A limit the host believes is acted on. The window decides when a
8
+ // conversation is compacted and whether a request is worth sending at all; the
9
+ // output cap is sent as max_tokens on every request. So an under-reported limit
10
+ // is not the conservative choice, it is a wrong one nobody can see: a window
11
+ // that is too small is never refused, it is just quietly never used, and an
12
+ // output cap that is too small cuts replies off with a finish reason that looks
13
+ // like the model's own decision.
14
+ //
15
+ // Only evidence moves a value here, and evidence is sorted:
16
+ //
17
+ // route the route's own refusal, in its own words
18
+ // route-twin what this route declares for the same upstream model
19
+ // vendor the model vendor's published page
20
+ // witnessed a generation somebody watched finish
21
+ //
22
+ // Being accepted is not evidence. Zen answers 200 to max_tokens 2000000 under a
23
+ // short prompt, so a value that is accepted says something about the request
24
+ // and nothing about what the model writes. A route or route-twin value may set
25
+ // any number; a vendor or witnessed one may only raise, because lowering a
26
+ // limit on weaker evidence invents a restriction nobody asked for.
27
+ //
28
+ // Every axis carries the catalogue value it was measured against. A catalogue
29
+ // that moves voids that axis rather than leaving a stale number behind, and a
30
+ // refusal that names a different window while the plugin is running corrects it
31
+ // for the rest of the process (see observeRouteStatement).
32
+ export const LIMITS = {
33
+ "deepseek-v4.1-flash-free": {
34
+ context: {
35
+ value: 1048576,
36
+ source: "route",
37
+ declaredAt: 200000,
38
+ measured: "2026-10-15",
39
+ samples: "1,048,507 tokens served; 1,048,707 and 1,060,000 refused 4/4",
40
+ how: "a prompt sized just over the window; Zen's own 400 answers \"This model's maximum context length is 1048576 tokens. However, you requested 1422278 tokens (1038278 in the messages, 384000 in the completion).\"",
41
+ },
42
+ output: {
43
+ value: 384000,
44
+ source: "route-twin",
45
+ declaredAt: 32000,
46
+ measured: "2026-10-15",
47
+ samples: "one reply of 306,880 completion tokens, finish=stop; nothing above it observed",
48
+ how: "deepseek-v4.1-flash, the paid id serving the same upstream model, is declared 384000 in this same catalogue; DeepSeek's own page says 384K, which is 393216 read as binary K",
49
+ },
50
+ },
51
+ };
52
+
53
+ // "This model's maximum context length is 1048576 tokens. However, you
54
+ // requested 1422278 tokens (1038278 in the messages, 384000 in the
55
+ // completion)." — the route's own words, and the only sentence seen on this
56
+ // route that states a window. A generic 400 says nothing attributable and is
57
+ // left alone; §7 of the measured notes has a context overflow and an invalid
58
+ // parameter answering with byte-identical 400s on another Zen model.
59
+ const NAMED_CONTEXT = /maximum context length is (\d+) tokens/i;
60
+
61
+ // Windows named by a refusal while this process is running, by model id. Not
62
+ // persisted: it is the same kind of fact as LIMITS, only newer, and a restart
63
+ // falls back to what was measured last.
64
+ const OBSERVED = new Map();
65
+
66
+ // The window a refusal names, or undefined when the body says nothing
67
+ // attributable. Exported for the tests and for anyone reading a capture.
68
+ export function namedContextWindow(text) {
69
+ const match = NAMED_CONTEXT.exec(typeof text === "string" ? text : "");
70
+ if (!match) return undefined;
71
+ const tokens = Number.parseInt(match[1], 10);
72
+ return Number.isSafeInteger(tokens) && tokens > 0 ? tokens : undefined;
73
+ }
74
+
75
+ // Read a refusal for a window it names. Only non-2xx responses are read, and
76
+ // only the first `limit` characters of one: this is an error path on the
77
+ // request's way to a caller that is waiting, so it must never be able to fail a
78
+ // request, change a response, or buffer a body that is not small.
79
+ export async function observeRouteStatement(id, response, limit = 4096) {
80
+ try {
81
+ if (typeof id !== "string" || !id || response?.ok !== false) return undefined;
82
+ const text = await response
83
+ .clone()
84
+ .text()
85
+ .catch(() => "");
86
+ const tokens = namedContextWindow(text.slice(0, limit));
87
+ if (tokens !== undefined) OBSERVED.set(id, tokens);
88
+ return tokens;
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ const ROUTE_TIERS = new Set(["route", "route-twin"]);
95
+
96
+ // What this plugin declares for a model instead of what the catalogue says, as
97
+ // a plain object to spread over the catalogue's own limit. Empty when there is
98
+ // no evidence, when the evidence was measured against a different catalogue, or
99
+ // when it would change nothing.
100
+ export function limitOverrides(id, declared = {}, table = LIMITS, observed = OBSERVED) {
101
+ const overrides = {};
102
+ const record = table?.[id];
103
+ const live = observed?.get(id);
104
+ for (const axis of ["context", "output"]) {
105
+ const declaredValue = declared?.[axis];
106
+ const entry = record?.[axis];
107
+ // A route statement made while this process was running is the same tier as
108
+ // the table and newer than it, so it is taken as it stands. Everything else
109
+ // has to have been measured against the catalogue in front of us now.
110
+ const value =
111
+ axis === "context" && live !== undefined
112
+ ? live
113
+ : entry && entry.declaredAt === declaredValue
114
+ ? entry.value
115
+ : undefined;
116
+ if (value === undefined || !Number.isSafeInteger(value) || value <= 0) continue;
117
+ if (value === declaredValue) continue;
118
+ if (entry && !ROUTE_TIERS.has(entry.source) && Number.isSafeInteger(declaredValue) && value < declaredValue)
119
+ continue;
120
+ overrides[axis] = value;
121
+ }
122
+ return overrides;
123
+ }
package/models.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { limitOverrides } from "./limits.mjs";
2
+
1
3
  export const PROVIDER = "opencode-zen-free";
2
4
  export const SDK = {
3
5
  chat: "@ai-sdk/openai-compatible",
@@ -63,7 +65,7 @@ export function buildModels(ids, provider, protocols, baseURL) {
63
65
  providerID: PROVIDER,
64
66
  name: model.name ?? id,
65
67
  api: { id, url: baseURL, npm: SDK[protocol] },
66
- limit: { context: 32768, output: 4096, ...model.limit },
68
+ limit: { context: 32768, output: 4096, ...model.limit, ...limitOverrides(id, model.limit) },
67
69
  capabilities: {
68
70
  temperature: model.temperature ?? true,
69
71
  reasoning: model.reasoning ?? false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-zen-freex",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
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",
@@ -9,6 +9,7 @@
9
9
  "provider.mjs",
10
10
  "core-tools.mjs",
11
11
  "fingerprint.json",
12
+ "limits.mjs",
12
13
  "models.mjs",
13
14
  "stream.mjs",
14
15
  "tool-response.mjs",
package/provider.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  configModels,
10
10
  parseProtocolDocs,
11
11
  } from "./models.mjs";
12
+ import { observeRouteStatement } from "./limits.mjs";
12
13
  import { collapseResponse } from "./stream.mjs";
13
14
  import { guardToolResponse } from "./tool-response.mjs";
14
15
 
@@ -466,6 +467,11 @@ async function server(_input, options = {}) {
466
467
  signal: request.signal,
467
468
  redirect: "error",
468
469
  });
470
+ // A refusal is the one place this route states its own window, and
471
+ // it has to be read before explain() replaces the body. See
472
+ // limits.mjs: an under-declared window is never refused, it is just
473
+ // quietly never used.
474
+ await observeRouteStatement(body.model, response);
469
475
  const guarded = await guardToolResponse(await explain(response, toolPolicy), protocol, toolPolicy, {
470
476
  endCutShort: streaming,
471
477
  });