opencode-zen-freex 0.2.2 → 0.2.4
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 +99 -15
- package/README.md +3 -1
- package/limits.mjs +123 -0
- package/models.mjs +3 -1
- package/package.json +2 -1
- package/provider.mjs +6 -0
package/MAINTAINING.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Maintaining this plugin
|
|
2
2
|
|
|
3
|
-
The plugin is one file of logic and
|
|
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
|
|
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`) |
|
|
@@ -124,22 +187,43 @@ either way, so one stays in place whichever is installed. It does mean the two
|
|
|
124
187
|
are mutually exclusive — installing both leaves two plugins claiming one
|
|
125
188
|
provider id, and which one wins is down to load order. Pick one.
|
|
126
189
|
|
|
127
|
-
To publish: bump `version`, `npm publish
|
|
128
|
-
|
|
190
|
+
To publish: bump `version`, commit, `npm publish` in a terminal, and tag — in
|
|
191
|
+
that order, so the tarball, the commit the tag names, and the tree both were
|
|
192
|
+
built from are one thing. `magpie plugin add opencode-zen-freex` installs it by
|
|
193
|
+
that name.
|
|
129
194
|
|
|
130
|
-
|
|
131
|
-
the
|
|
195
|
+
**The second factor is a browser, not a password.** Run `npm publish` in a
|
|
196
|
+
terminal. When the account has 2FA on, the command stops and offers the whole
|
|
197
|
+
exchange at once:
|
|
132
198
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
199
|
+
```
|
|
200
|
+
Authenticate your account at:
|
|
201
|
+
https://www.npmjs.com/auth/cli/...
|
|
202
|
+
Press ENTER to open in the browser...
|
|
203
|
+
```
|
|
138
204
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
205
|
+
Press ENTER, authorize in the browser, and the same command carries on by
|
|
206
|
+
itself. Nothing is typed back into the terminal and nothing is passed to a
|
|
207
|
+
script: the address is tied to the browser session that printed it and differs
|
|
208
|
+
every time, so it is read out of the running command rather than composed in
|
|
209
|
+
advance. Whoever is at the browser authorizes, so the command can be started for
|
|
210
|
+
them and left waiting — the one thing it needs from them is the ENTER that opens
|
|
211
|
+
it.
|
|
212
|
+
|
|
213
|
+
Both halves of that depend on the command having a terminal. Run as a background
|
|
214
|
+
job or piped into a file, npm has no browser to offer, redacts the address to
|
|
215
|
+
`https://www.npmjs.com/auth/cli/***` and exits `EOTP` rather than waiting, so a
|
|
216
|
+
log of the failed attempt cannot be used to finish it — start it again in a
|
|
217
|
+
terminal.
|
|
218
|
+
|
|
219
|
+
An account with no second factor to give publishes in one step. So does
|
|
220
|
+
`npm login --auth-type=web`, which is the same browser exchange kept as a
|
|
221
|
+
session, and so does a granular token below.
|
|
222
|
+
|
|
223
|
+
**With a granular token** (unattended, CI). A token with publish permission
|
|
224
|
+
authorizes the publish on its own and never asks for the second factor. Write it
|
|
225
|
+
to an npmrc outside the repository and point npm at it for that one command, so
|
|
226
|
+
it does not land in a tracked file:
|
|
143
227
|
|
|
144
228
|
```sh
|
|
145
229
|
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.
|
|
3
|
+
"version": "0.2.4",
|
|
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
|
});
|