opencode-zen-freex 0.2.11 → 0.2.13
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 +10 -1
- package/README.md +1 -1
- package/core-tools.mjs +6 -1
- package/limits.mjs +82 -27
- package/models.mjs +83 -6
- package/package.json +1 -1
- package/provider.mjs +47 -28
- package/stream.mjs +35 -10
- package/tool-response.mjs +63 -28
package/MAINTAINING.md
CHANGED
|
@@ -22,7 +22,13 @@ small. `deepseek-v4.1-flash-free` is declared 200000/32000 while
|
|
|
22
22
|
`deepseek-v4.1-flash` — the paid id serving the same upstream model, which
|
|
23
23
|
`x-opencode-upstream-model-id` confirms — is declared 1000000/384000 in that
|
|
24
24
|
same catalogue, and `mimo-v2.6-flash-free` is declared 200000/32000 against a
|
|
25
|
-
model whose own vendor publishes 1048576/131072.
|
|
25
|
+
model whose own vendor publishes 1048576/131072. The ids the tier added on
|
|
26
|
+
2026-10-16 were under-reported the same way on the day they appeared:
|
|
27
|
+
`ll-alpha-free` names a 262144-token completion ceiling in its own refusal
|
|
28
|
+
against a declared 65536, and `glm-5.3-flash-free` is declared 200000/32000
|
|
29
|
+
while its paid twin `glm-5.3-flash` is declared 1000000/131072 in the same
|
|
30
|
+
catalogue — and a 232685-token prompt was served through the free id, which the
|
|
31
|
+
declared window does not allow.
|
|
26
32
|
|
|
27
33
|
Both numbers are acted on. The window decides when a conversation is compacted
|
|
28
34
|
and how much of it is ever sent; the output cap is sent as `max_tokens` on every
|
|
@@ -42,6 +48,9 @@ measured against. Each axis carries `samples` and `how`; the short of it:
|
|
|
42
48
|
| `big-pickle` | output | 131072 | the ceiling the route names when it is asked for too much |
|
|
43
49
|
| `nemotron-3.5-lightning-free` | context | 1000000 | the route's own refusal, in its own words |
|
|
44
50
|
| `longcat-2.5-preview-free` | output | 262144 | the ceiling the route names when it is asked for too much |
|
|
51
|
+
| `ll-alpha-free` | output | 262144 | the ceiling the route's own refusal names, against a declared 65536 |
|
|
52
|
+
| `glm-5.3-flash-free` | context | 1000000 | what this route declares for the same upstream model, with a 232685-token prompt served as witness |
|
|
53
|
+
| `glm-5.3-flash-free` | output | 131072 | what this route declares for the same upstream model |
|
|
45
54
|
|
|
46
55
|
A prompt sized just over the window is refused with
|
|
47
56
|
|
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ 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
|
|
50
|
+
已下线的模型和 SystemOne 模型会被排除:「下线」指的是状态写着 `retired`/`disabled`,或带着已生效的退役日期。目录里标了 `deprecated` 但 Zen 的列表里还在的模型**照常提供**——`deprecated` 是预告,而「还在不在」由列表说了算(实测 2026-10-16:`muse-spark-1.2-contributor-free` 在目录里是 `deprecated`,Zen 仍在服务它,把它当退役就把一个能用的免费模型藏掉了)。这份列表在宿主每次列举模型时重新获取(magpie 在启动时做一次,之后每小时一次):Zen 不再免费开放的模型会退出列表,新转为免费的模型会加入列表——**不等** OpenCode 的目录或文档写到它:只要 Zen 的列表里有、id 里带 `-free`,就按 provider 自己的协议(OpenAI 兼容 / chat)提供,目录晚一步也不影响。
|
|
51
51
|
|
|
52
52
|
目录或文档读不到时**不算 fallback**:Zen 的列表已经读到了,它就是当前该提供的东西;报成 fallback 会让宿主保守地沿用上一次完整读到的列表,于是读不到 `raw.githubusercontent.com` 或 `models.opencode.ai` 的机器永远看不到之后新转免费的模型。只有**列表本身**读不到才沿用上一次结果;第一次还没读到之前,用的是打包在插件里的那份,所以这个 provider 不会空着。请求一个当前并不免费的模型,会得到一个 404 并列出当时免费的模型;若某个模型是在上次列举之后才转免费的,请求会在重新获取列表后照常服务(最多每分钟一次)。可用性和限流由 Zen 决定,插件这边拿不到剩余额度。
|
|
53
53
|
|
package/core-tools.mjs
CHANGED
|
@@ -106,7 +106,12 @@ export function generationOf(tools) {
|
|
|
106
106
|
// `.agent` with a TypeError that names nothing. Ownership of the key is half of
|
|
107
107
|
// it — a capture holding `{"1.x": null}` owns the key and still has no profile,
|
|
108
108
|
// and the `.agent` of that is the same unnamed TypeError.
|
|
109
|
-
|
|
109
|
+
//
|
|
110
|
+
// Exported, because the same two mistakes are reachable from outside this file
|
|
111
|
+
// and are made there: `provider.mjs` indexes `PROFILES[...]` directly to read a
|
|
112
|
+
// profile's `tools`, and a capture missing that generation then fails with
|
|
113
|
+
// "Cannot read properties of undefined" instead of the sentence below.
|
|
114
|
+
export function profileFor(generation) {
|
|
110
115
|
if (!Object.hasOwn(PROFILES, generation)) {
|
|
111
116
|
throw new Error(
|
|
112
117
|
`core-tools: no profile for generation ${JSON.stringify(generation)}; ` +
|
package/limits.mjs
CHANGED
|
@@ -96,20 +96,62 @@ export const LIMITS = {
|
|
|
96
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
97
|
},
|
|
98
98
|
},
|
|
99
|
+
// The two the free tier added on 2026-10-16, each measured the day it was
|
|
100
|
+
// noticed. Both under-report in the catalogue the same way the older entries
|
|
101
|
+
// do, and both were checked on every axis: nothing below is what a refusal
|
|
102
|
+
// happened to say, it is all that was asked.
|
|
103
|
+
"ll-alpha-free": {
|
|
104
|
+
output: {
|
|
105
|
+
value: 262144,
|
|
106
|
+
source: "route",
|
|
107
|
+
declaredAt: 65536,
|
|
108
|
+
measured: "2026-10-16",
|
|
109
|
+
samples: "max_completion_tokens 1000000 and 300000 both refused, each naming 262144; nothing above 65536 watched — the ceiling is the route's own number, four times what the catalogue declares",
|
|
110
|
+
how: "\"Upstream request failed: [400] max_completion_tokens is too large: 300000.This model supports at most 262144 completion tokens.\" — the ceiling is the number the sentence names. Its context needs nothing: the same day's refusal, \"The input (341480 tokens) is longer than the model's context length (262144 tokens).\", is the window the catalogue already declares.",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
"glm-5.3-flash-free": {
|
|
114
|
+
context: {
|
|
115
|
+
value: 1000000,
|
|
116
|
+
source: "route-twin",
|
|
117
|
+
declaredAt: 200000,
|
|
118
|
+
measured: "2026-10-16",
|
|
119
|
+
samples: "a 232685-token prompt was served and answered from (usage.prompt_tokens 232685), so the declared 200000 is already known to be small; nothing above it was refused, because this upstream refuses no max_tokens at all",
|
|
120
|
+
how: "glm-5.3-flash, the paid id serving the same upstream model — the catalogue's own canonical_model_id for this free id is zhipuai/glm-5.3-flash — is declared 1000000 in this same catalogue",
|
|
121
|
+
},
|
|
122
|
+
output: {
|
|
123
|
+
value: 131072,
|
|
124
|
+
source: "route-twin",
|
|
125
|
+
declaredAt: 32000,
|
|
126
|
+
measured: "2026-10-16",
|
|
127
|
+
samples: "max_tokens 4000000 was accepted with no refusal at all, which is the DeepSeek upstream's shape: silence here is not evidence of a ceiling either way, and nothing above 32000 was watched",
|
|
128
|
+
how: "the same paid twin declares 131072 on this axis",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
99
131
|
};
|
|
100
132
|
|
|
101
133
|
// "This model's maximum context length is 1048576 tokens. However, you
|
|
102
134
|
// requested 1422278 tokens (1038278 in the messages, 384000 in the
|
|
103
|
-
// completion)." — the route's own words, and the
|
|
104
|
-
// route that states a window. A second upstream phrases the same fact as
|
|
105
|
-
// endpoint's maximum context length is ...", with the request broken into
|
|
106
|
-
// input, tool input and output; both are the route naming its own window,
|
|
107
|
-
// its "requested about" figure is a rough one (chars, not tokens) rather
|
|
108
|
-
// the count a served request reports in `usage.prompt_tokens`. A generic
|
|
109
|
-
// says nothing attributable and is left alone; §7 of the measured notes has
|
|
110
|
-
// context overflow and an invalid parameter answering with byte-identical 400s
|
|
135
|
+
// completion)." — the route's own words, and the sentence seen most often on
|
|
136
|
+
// this route that states a window. A second upstream phrases the same fact as
|
|
137
|
+
// "This endpoint's maximum context length is ...", with the request broken into
|
|
138
|
+
// text input, tool input and output; both are the route naming its own window,
|
|
139
|
+
// and its "requested about" figure is a rough one (chars, not tokens) rather
|
|
140
|
+
// than the count a served request reports in `usage.prompt_tokens`. A generic
|
|
141
|
+
// 400 says nothing attributable and is left alone; §7 of the measured notes has
|
|
142
|
+
// a context overflow and an invalid parameter answering with byte-identical 400s
|
|
111
143
|
// on another Zen model.
|
|
112
|
-
|
|
144
|
+
//
|
|
145
|
+
// A third wording states the same fact as a parenthetical, and was missed until
|
|
146
|
+
// it was measured: "The input (341480 tokens) is longer than the model's context
|
|
147
|
+
// length (262144 tokens)." (`ll-alpha-free`, 2026-10-16). It names the same
|
|
148
|
+
// number in the same sentence, so it is read the same way — a window this file
|
|
149
|
+
// does not read is a window the plugin keeps under-declaring, which is the one
|
|
150
|
+
// failure it exists to remove.
|
|
151
|
+
const NAMED_CONTEXT = [
|
|
152
|
+
/maximum context length is (\d+) tokens/id,
|
|
153
|
+
/context length \((\d+) tokens?\)/id,
|
|
154
|
+
];
|
|
113
155
|
|
|
114
156
|
// "max_tokens is too large: 200000. This model supports at most 131072 completion
|
|
115
157
|
// tokens, whereas you provided 200000." — an upstream that validates the ceiling
|
|
@@ -119,10 +161,10 @@ const NAMED_CONTEXT = /maximum context length is (\d+) tokens/i;
|
|
|
119
161
|
// answers 200 there, so silence from this one says nothing about a model, and a
|
|
120
162
|
// request that is served is not evidence of a ceiling either way.
|
|
121
163
|
const NAMED_OUTPUT = [
|
|
122
|
-
/supports at most (\d+) completion tokens/
|
|
164
|
+
/supports at most (\d+) completion tokens/id,
|
|
123
165
|
// "/max_tokens: 1000000 is not less or equal to 262144" — LongCat's wording for
|
|
124
166
|
// 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+)/
|
|
167
|
+
/max_tokens:?\s*\d+\s+is not less or equal to\s+(\d+)/id,
|
|
126
168
|
];
|
|
127
169
|
|
|
128
170
|
// Names a refusal made while this process is running, by model id. Not
|
|
@@ -169,7 +211,7 @@ function describe({ kind, id, axis, value, measuredAgainst, declared }) {
|
|
|
169
211
|
// The window a refusal names, or undefined when the body says nothing
|
|
170
212
|
// attributable. Exported for the tests and for anyone reading a capture.
|
|
171
213
|
export function namedContextWindow(text) {
|
|
172
|
-
return namedFrom(
|
|
214
|
+
return namedFrom(NAMED_CONTEXT, text)?.value;
|
|
173
215
|
}
|
|
174
216
|
|
|
175
217
|
// The completion ceiling a refusal names, or undefined. Same rule as the window:
|
|
@@ -349,25 +391,33 @@ export async function observeRouteStatement(id, response, limit = 4096, { signal
|
|
|
349
391
|
}
|
|
350
392
|
}
|
|
351
393
|
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
394
|
+
// Where the number a match named ends, in the text it was matched against. The
|
|
395
|
+
// `d` flag is what makes this exact: `NAMED_CONTEXT` and the first
|
|
396
|
+
// `NAMED_OUTPUT` run on past the digits (" completion tokens"), so judging the
|
|
397
|
+
// cut by the match's own end threw away numbers that were provably whole — the
|
|
398
|
+
// digits ended well before the read did, with text after them that the greedy
|
|
399
|
+
// `(\d+)` had refused to consume.
|
|
400
|
+
function digitsEnd(found) {
|
|
401
|
+
const at = found?.match?.indices?.[1]?.[1];
|
|
402
|
+
// The match's own end is the conservative answer when the pattern cannot say
|
|
403
|
+
// where the digits are: it drops a whole number rather than reading a cut one,
|
|
404
|
+
// which is the direction this guard has to fail in.
|
|
405
|
+
return Number.isSafeInteger(at) ? at : found.match.index + found.match[0].length;
|
|
406
|
+
}
|
|
359
407
|
|
|
360
408
|
// The axes the first `limit` characters of `text` name. `cut` says the read
|
|
361
|
-
// stopped
|
|
362
|
-
// may have
|
|
363
|
-
// digit short.
|
|
364
|
-
//
|
|
409
|
+
// stopped before the body did, so a number whose own digits reach the last
|
|
410
|
+
// character read may have had digits past it: that axis is left unread rather
|
|
411
|
+
// than reported a digit short. Any character after the digits inside the head
|
|
412
|
+
// proves the number ended, since a greedy `\d+` would have taken it — only
|
|
413
|
+
// digits touching the boundary are ambiguous. Each axis is read from the match
|
|
414
|
+
// its number came from, so the match the guard judges and the number that is
|
|
415
|
+
// returned cannot be two answers.
|
|
365
416
|
function namedAxes(text, limit, cut) {
|
|
366
417
|
const head = text.slice(0, limit);
|
|
367
|
-
const context = namedFrom(
|
|
418
|
+
const context = namedFrom(NAMED_CONTEXT, head);
|
|
368
419
|
const output = namedFrom(NAMED_OUTPUT, head);
|
|
369
|
-
const onCut = (found) =>
|
|
370
|
-
cut && Boolean(found) && found.match.index + found.match[0].length >= head.length - CUT_MARGIN;
|
|
420
|
+
const onCut = (found) => cut && found !== undefined && digitsEnd(found) >= head.length;
|
|
371
421
|
return {
|
|
372
422
|
context: onCut(context) ? undefined : context?.value,
|
|
373
423
|
output: onCut(output) ? undefined : output?.value,
|
|
@@ -394,7 +444,12 @@ export function limitOverrides(
|
|
|
394
444
|
// declared, from a table that never named it.
|
|
395
445
|
const record = table && Object.hasOwn(table, id) ? table[id] : undefined;
|
|
396
446
|
for (const axis of ["context", "output"]) {
|
|
397
|
-
|
|
447
|
+
// Own property, like the table lookup two lines up: `declared` is handed in
|
|
448
|
+
// by a caller, and a `context` inherited from a custom prototype — or from a
|
|
449
|
+
// polluted `Object.prototype` — would otherwise be read as a declaration the
|
|
450
|
+
// catalogue never made, feeding the staleness compare and the raise-only
|
|
451
|
+
// check with a number nobody declared.
|
|
452
|
+
const declaredValue = declared && Object.hasOwn(declared, axis) ? declared[axis] : undefined;
|
|
398
453
|
const entry = record?.[axis];
|
|
399
454
|
const live = (axis === "context" ? observed : observedOutput)?.get(id);
|
|
400
455
|
// A route statement made while this process was running is the same tier as
|
package/models.mjs
CHANGED
|
@@ -44,15 +44,23 @@ const hasValue = (value) => value !== null && value !== undefined;
|
|
|
44
44
|
// offered. A status that is set — "active" included — is still read as itself.
|
|
45
45
|
const isSet = (value) => hasValue(value) && String(value).trim() !== "";
|
|
46
46
|
|
|
47
|
+
// A status ends a model when it says the model is gone: "retired" and
|
|
48
|
+
// "disabled" are endings. "deprecated" is not one — it says the catalogue
|
|
49
|
+
// expects the model to go, and what decides whether it is still there is the
|
|
50
|
+
// list Zen serves, which discovery already reads: measured 2026-10-16,
|
|
51
|
+
// `muse-spark-1.2-contributor-free` is marked deprecated in the catalogue while
|
|
52
|
+
// Zen lists it and the free tier answers a request for it through this plugin,
|
|
53
|
+
// so reading that status as an ending hid a model that was being served.
|
|
54
|
+
const ENDED = ["retired", "disabled"];
|
|
55
|
+
|
|
47
56
|
function isRetired(model) {
|
|
48
57
|
return (
|
|
49
58
|
model.deprecated === true ||
|
|
50
|
-
|
|
51
|
-
isSet(model.status) ? model.status : model.lifecycle,
|
|
52
|
-
) ||
|
|
59
|
+
ENDED.includes(isSet(model.status) ? model.status : model.lifecycle) ||
|
|
53
60
|
// Read through `isSet`, not `hasValue`: a blank `deprecated_at` is the empty
|
|
54
61
|
// string the catalogue writes for a date it does not have, and reading it as
|
|
55
|
-
// a date retired a model that was still on offer.
|
|
62
|
+
// a date retired a model that was still on offer. An effective date is an
|
|
63
|
+
// ending whatever the status beside it says.
|
|
56
64
|
isSet(model.deprecated_at) ||
|
|
57
65
|
isSet(model.retirement_date)
|
|
58
66
|
);
|
|
@@ -191,10 +199,19 @@ export const bootstrapProtocols = {
|
|
|
191
199
|
"nemotron-3.5-lightning-free": "chat",
|
|
192
200
|
"muse-spark-1.3-contributor-free": "responses",
|
|
193
201
|
"jev-1.13-free": "systemone",
|
|
202
|
+
// The free tier's list on 2026-10-16, when these five appeared: the four chat
|
|
203
|
+
// ones and the responses one below, whose paid twin the docs table names.
|
|
204
|
+
"deepseek-v4.1-flash-free": "chat",
|
|
205
|
+
"glm-5.3-flash-free": "chat",
|
|
206
|
+
"ll-alpha-free": "chat",
|
|
207
|
+
"nemotron-3.5-super-vl-free": "chat",
|
|
208
|
+
"muse-spark-1.2-contributor-free": "responses",
|
|
194
209
|
};
|
|
195
210
|
|
|
196
|
-
// Offline bootstrap metadata from the OpenCode catalog, 2026-10-06; the
|
|
197
|
-
//
|
|
211
|
+
// Offline bootstrap metadata from the OpenCode catalog, 2026-10-06; the free
|
|
212
|
+
// models listed since added on 2026-10-15, and the ids the tier was serving on
|
|
213
|
+
// 2026-10-16 added then. A host that reaches nothing on its first run offers
|
|
214
|
+
// these; the first discovery replaces the list.
|
|
198
215
|
export const bootstrapProvider = {
|
|
199
216
|
npm: SDK.chat,
|
|
200
217
|
models: {
|
|
@@ -270,6 +287,66 @@ export const bootstrapProvider = {
|
|
|
270
287
|
{ type: "effort", values: ["minimal", "low", "medium", "high", "xhigh"] },
|
|
271
288
|
],
|
|
272
289
|
},
|
|
290
|
+
// The free ids the tier was serving on 2026-10-16 and this snapshot did not
|
|
291
|
+
// carry. Their numbers are the catalogue's, as every other entry here is —
|
|
292
|
+
// `limits.mjs` is what corrects the ones the catalogue under-reports, and it
|
|
293
|
+
// carries entries for two of these.
|
|
294
|
+
"deepseek-v4.1-flash-free": {
|
|
295
|
+
name: "DeepSeek V4.1 Flash Free",
|
|
296
|
+
cost: { input: 0, output: 0 },
|
|
297
|
+
limit: { context: 200000, output: 32000 },
|
|
298
|
+
reasoning: true,
|
|
299
|
+
tool_call: true,
|
|
300
|
+
interleaved: { field: "reasoning_content" },
|
|
301
|
+
modalities: { input: ["text", "image"], output: ["text"] },
|
|
302
|
+
reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }],
|
|
303
|
+
},
|
|
304
|
+
"glm-5.3-flash-free": {
|
|
305
|
+
name: "GLM-5.3-Flash Free",
|
|
306
|
+
cost: { input: 0, output: 0 },
|
|
307
|
+
limit: { context: 200000, output: 32000 },
|
|
308
|
+
reasoning: true,
|
|
309
|
+
tool_call: true,
|
|
310
|
+
interleaved: { field: "reasoning_content" },
|
|
311
|
+
modalities: { input: ["text", "image", "video", "pdf"], output: ["text"] },
|
|
312
|
+
reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }],
|
|
313
|
+
},
|
|
314
|
+
"ll-alpha-free": {
|
|
315
|
+
name: "LL Alpha Free",
|
|
316
|
+
cost: { input: 0, output: 0 },
|
|
317
|
+
limit: { context: 262144, output: 65536 },
|
|
318
|
+
reasoning: true,
|
|
319
|
+
tool_call: true,
|
|
320
|
+
interleaved: { field: "reasoning_content" },
|
|
321
|
+
modalities: { input: ["text"], output: ["text"] },
|
|
322
|
+
reasoning_options: [
|
|
323
|
+
{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] },
|
|
324
|
+
],
|
|
325
|
+
},
|
|
326
|
+
"nemotron-3.5-super-vl-free": {
|
|
327
|
+
name: "Nemotron 3.5 Super VL Free",
|
|
328
|
+
cost: { input: 0, output: 0 },
|
|
329
|
+
limit: { context: 1048576, output: 131072 },
|
|
330
|
+
reasoning: true,
|
|
331
|
+
tool_call: true,
|
|
332
|
+
interleaved: { field: "reasoning_content" },
|
|
333
|
+
modalities: { input: ["text", "image"], output: ["text"] },
|
|
334
|
+
reasoning_options: [
|
|
335
|
+
{ type: "effort", values: ["none", "minimal", "low", "medium", "high", "xhigh"] },
|
|
336
|
+
],
|
|
337
|
+
},
|
|
338
|
+
"muse-spark-1.2-contributor-free": {
|
|
339
|
+
name: "Muse Spark 1.2 Free",
|
|
340
|
+
cost: { input: 0, output: 0 },
|
|
341
|
+
provider: { npm: SDK.responses },
|
|
342
|
+
limit: { context: 1048576, output: 131072 },
|
|
343
|
+
reasoning: true,
|
|
344
|
+
tool_call: true,
|
|
345
|
+
modalities: { input: ["text", "image", "video", "pdf", "audio"], output: ["text"] },
|
|
346
|
+
reasoning_options: [
|
|
347
|
+
{ type: "effort", values: ["minimal", "low", "medium", "high", "xhigh"] },
|
|
348
|
+
],
|
|
349
|
+
},
|
|
273
350
|
},
|
|
274
351
|
};
|
|
275
352
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-zen-freex",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { agentFor, generationOf, profileFor } from "./core-tools.mjs";
|
|
3
3
|
import {
|
|
4
4
|
PROVIDER,
|
|
5
5
|
SDK,
|
|
@@ -158,6 +158,22 @@ function relayedRefusal(response, observed) {
|
|
|
158
158
|
);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// The response this path hands on, from what the limits reader made of the one
|
|
162
|
+
// that arrived: the same response when it never read it — the reader touches no
|
|
163
|
+
// 2xx body, so that is the common branch — the bytes it read under headers
|
|
164
|
+
// corrected for them when it read the whole body, and a refusal that says so
|
|
165
|
+
// when it stopped short. One decision, written once, so the three outcomes
|
|
166
|
+
// cannot be read as a chain of ternaries at the call site.
|
|
167
|
+
function relayed(response, observed) {
|
|
168
|
+
if (observed === undefined) return response;
|
|
169
|
+
if (!observed.ended) return relayedRefusal(response, observed);
|
|
170
|
+
return new Response(observed.text, {
|
|
171
|
+
status: response.status,
|
|
172
|
+
statusText: response.statusText,
|
|
173
|
+
headers: relayedHeaders(response),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
161
177
|
// How long a request for a model the last list didn't have waits before it
|
|
162
178
|
// asks Zen's list again: a model Zen made free since is served at once,
|
|
163
179
|
// and a model typed in by hand doesn't ask the list on every request.
|
|
@@ -210,20 +226,23 @@ function normalizeTools(body, protocol) {
|
|
|
210
226
|
const declared = new Set([...present].filter((value) => typeof value === "string"));
|
|
211
227
|
// The legacy top-level `functions` array is the older way of declaring the
|
|
212
228
|
// same tools, and one a chat host may still use. Its names are declarations,
|
|
213
|
-
// so they count as declared
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
229
|
+
// so they count as declared, and the fill loop below finds them by name: a
|
|
230
|
+
// name that only arrived through `functions` has no index in `body.tools`, so
|
|
231
|
+
// it is looked up in the array itself and reworded where it sits rather than
|
|
232
|
+
// being stood in for. Without that the same tool would go out twice, which is
|
|
233
|
+
// what the note on `nameOf` says must not happen.
|
|
218
234
|
const legacy = Array.isArray(body.functions) && protocol === "chat" ? body.functions : [];
|
|
219
235
|
for (const tool of legacy) if (typeof tool?.name === "string") declared.add(tool.name);
|
|
220
|
-
const declaredElsewhere = new Set(legacy.map((tool) => tool?.name));
|
|
221
236
|
// Zen takes a request for one generation of OpenCode or the other, and the tools
|
|
222
237
|
// it carries say which: `shell` is 2.x, `bash` is 1.x. Names move far less
|
|
223
238
|
// often than descriptions do, so this survives a rewording that a pinned
|
|
224
239
|
// User-Agent would not.
|
|
225
240
|
const generation = generationOf(body.tools) ?? DEFAULT_GENERATION;
|
|
226
|
-
|
|
241
|
+
// Through `profileFor`, not `PROFILES[generation]`: a capture that holds one
|
|
242
|
+
// generation leaves the other key absent, and the direct read fails below on
|
|
243
|
+
// `profile.tools` with a TypeError that names nothing — the capture is what
|
|
244
|
+
// has to change, and it is the one thing that error does not say.
|
|
245
|
+
const profile = profileFor(generation);
|
|
227
246
|
const filled = [];
|
|
228
247
|
const at = new Map();
|
|
229
248
|
body.tools.forEach((tool, index) => {
|
|
@@ -252,8 +271,17 @@ function normalizeTools(body, protocol) {
|
|
|
252
271
|
// tightened check would otherwise find missing.
|
|
253
272
|
if (!profile.required.includes(name)) continue;
|
|
254
273
|
// Declared through `functions` rather than `tools`: this host does have the
|
|
255
|
-
// tool, so it is not one to stand in for.
|
|
256
|
-
|
|
274
|
+
// tool, so it is not one to stand in for. Its wording is still the host's,
|
|
275
|
+
// though, and Zen reads the wording of a tool it knows by name whichever
|
|
276
|
+
// array carried it — leaving that one entry as the host wrote it is the
|
|
277
|
+
// refusal this loop exists to prevent, and it is the entry `explain()`
|
|
278
|
+
// would then describe wrongly. The legacy shape carries `description` at
|
|
279
|
+
// the top level, which `setDescription` already handles.
|
|
280
|
+
const throughFunctions = legacy.find((tool) => tool?.name === name);
|
|
281
|
+
if (throughFunctions) {
|
|
282
|
+
setDescription(throughFunctions, spoken.description);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
257
285
|
filled.push(name);
|
|
258
286
|
// A tool added here has nothing behind it: the agent has no such tool and
|
|
259
287
|
// a call to it is dropped. Describing it as OpenCode describes it is what
|
|
@@ -308,7 +336,10 @@ function explain(text, source, toolPolicy) {
|
|
|
308
336
|
// A 403 that is not Zen's free tier is somebody else's; it is left as sent —
|
|
309
337
|
// its bytes are what arrived, and so are the headers describing them.
|
|
310
338
|
if (source.status === 403 && !text.includes("FreeTierError")) return source;
|
|
311
|
-
|
|
339
|
+
// The same lookup `normalizeTools` made, through the same owner: the refusal
|
|
340
|
+
// this message explains is about the generation that request was sent as, and
|
|
341
|
+
// a capture that lost that profile has to say so rather than fail on `.tools`.
|
|
342
|
+
const profile = profileFor(toolPolicy.generation);
|
|
312
343
|
const names = Object.keys(profile.tools).join(", ");
|
|
313
344
|
const { filled } = toolPolicy;
|
|
314
345
|
// 426 is not a refusal of the wording: it is this plugin presenting itself as
|
|
@@ -588,28 +619,16 @@ async function server(_input, options = {}) {
|
|
|
588
619
|
// quietly never used.
|
|
589
620
|
//
|
|
590
621
|
// A response body can only be read once, so what the limits reader
|
|
591
|
-
// took is handed on as the response the rest of the path works with
|
|
592
|
-
// never the upstream's own headers over bytes this
|
|
593
|
-
// again, and never a prefix presented as the whole
|
|
594
|
-
//
|
|
595
|
-
// headers corrected for it; one that stopped short is answered with
|
|
596
|
-
// a refusal that says so (see `relayedRefusal`). A response the
|
|
597
|
-
// reader did not consume is used as it arrived.
|
|
622
|
+
// took is handed on as the response the rest of the path works with
|
|
623
|
+
// (see `relayed`): never the upstream's own headers over bytes this
|
|
624
|
+
// package wrote again, and never a prefix presented as the whole
|
|
625
|
+
// reply. A response the reader did not consume is used as it arrived.
|
|
598
626
|
const observed = await observeRouteStatement(body.model, response, undefined, {
|
|
599
627
|
// The caller's own cancellation reaches this read as well: a host
|
|
600
628
|
// that has given up is not waiting for a number out of a refusal.
|
|
601
629
|
signal: request.signal,
|
|
602
630
|
});
|
|
603
|
-
const answered =
|
|
604
|
-
observed === undefined
|
|
605
|
-
? response
|
|
606
|
-
: observed.ended
|
|
607
|
-
? new Response(observed.text, {
|
|
608
|
-
status: response.status,
|
|
609
|
-
statusText: response.statusText,
|
|
610
|
-
headers: relayedHeaders(response),
|
|
611
|
-
})
|
|
612
|
-
: relayedRefusal(response, observed);
|
|
631
|
+
const answered = relayed(response, observed);
|
|
613
632
|
const guarded = await guardToolResponse(
|
|
614
633
|
explain(observed?.text ?? null, answered, toolPolicy),
|
|
615
634
|
protocol,
|
package/stream.mjs
CHANGED
|
@@ -187,14 +187,19 @@ function responsesAccumulator() {
|
|
|
187
187
|
// naming an item the stream never opened has no slot at all — slot 0 is
|
|
188
188
|
// only for a frame that names nothing, which a stream that never indexed
|
|
189
189
|
// its items means as the first one. Merging an unknown item into slot 0
|
|
190
|
-
// was measured handing a function_call there the text of a message.
|
|
191
|
-
|
|
190
|
+
// was measured handing a function_call there the text of a message. An
|
|
191
|
+
// `added` frame opens an item, so the id it names is new there by
|
|
192
|
+
// definition and takes the next free slot; a frame that has to find an
|
|
193
|
+
// item that is already open still throws.
|
|
194
|
+
function slot(data, id, fresh = false) {
|
|
192
195
|
if (data.output_index !== undefined) return index(data.output_index);
|
|
193
196
|
if (id === undefined || id === null || id === "") return index(undefined);
|
|
194
197
|
const position = [...output].find(([, value]) => value.id === id)?.[0];
|
|
195
|
-
if (position
|
|
196
|
-
|
|
197
|
-
|
|
198
|
+
if (position !== undefined) return index(position);
|
|
199
|
+
if (!fresh) throw new Error("Upstream frame names an unknown item without an output index");
|
|
200
|
+
let next = 0;
|
|
201
|
+
for (const held of output.keys()) next = Math.max(next, held + 1);
|
|
202
|
+
return index(next);
|
|
198
203
|
}
|
|
199
204
|
// A snapshot replaces the item in its slot only when it is a whole view of
|
|
200
205
|
// it; a partial one leaves what the deltas delivered standing.
|
|
@@ -225,11 +230,19 @@ function responsesAccumulator() {
|
|
|
225
230
|
keep(position, value);
|
|
226
231
|
}
|
|
227
232
|
} else if (type === "response.output_item.added" || type === "response.output_item.done") {
|
|
228
|
-
if (data.item)
|
|
233
|
+
if (data.item)
|
|
234
|
+
keep(slot(data, data.item.id, type === "response.output_item.added"), data.item);
|
|
229
235
|
} else if (type === "response.content_part.added" || type === "response.content_part.done") {
|
|
230
236
|
const value = item(data);
|
|
231
237
|
value.content ??= [];
|
|
232
|
-
|
|
238
|
+
// A re-emitted opening part carries an empty text where the deltas
|
|
239
|
+
// have already filled the slot — the partial-snapshot hazard the item
|
|
240
|
+
// guard above and content_block_start exist for, so the same test.
|
|
241
|
+
// `refusal` is weighed too: a part whose only content is a refusal
|
|
242
|
+
// weighs nothing on text alone and would be taken for an empty one.
|
|
243
|
+
const at = index(data.content_index);
|
|
244
|
+
if (carries(data.part, value.content[at], ["text", "refusal"]))
|
|
245
|
+
value.content[at] = data.part;
|
|
233
246
|
} else if (
|
|
234
247
|
type === "response.output_text.delta" ||
|
|
235
248
|
type === "response.output_text.done" ||
|
|
@@ -258,7 +271,9 @@ function responsesAccumulator() {
|
|
|
258
271
|
) {
|
|
259
272
|
const value = item(data, "reasoning");
|
|
260
273
|
value.summary ??= [];
|
|
261
|
-
|
|
274
|
+
// The same re-emitted-opening-frame hazard as content_part above.
|
|
275
|
+
const at = index(data.summary_index);
|
|
276
|
+
if (carries(data.part, value.summary[at], ["text"])) value.summary[at] = data.part;
|
|
262
277
|
} else if (/^response\.reasoning_(summary_)?text\.(delta|done)$/.test(type)) {
|
|
263
278
|
const value = item(data, "reasoning");
|
|
264
279
|
const summary = type.includes("summary");
|
|
@@ -301,8 +316,18 @@ function responsesAccumulator() {
|
|
|
301
316
|
const whole = Array.isArray(final.output)
|
|
302
317
|
? final.output.length > 0
|
|
303
318
|
: final.output !== null && final.output !== undefined;
|
|
304
|
-
|
|
305
|
-
|
|
319
|
+
// An array is not a reply object at all, and is returned as it came.
|
|
320
|
+
if (Array.isArray(final)) return final;
|
|
321
|
+
// The terminal is not the whole reply either. What apply merged onto
|
|
322
|
+
// the body from the events' top level — usage, status,
|
|
323
|
+
// incomplete_details, finish_reason, and the object this reply is — is
|
|
324
|
+
// the upstream's too, and answering with the terminal alone dropped it:
|
|
325
|
+
// a terminal carrying its whole response beside a top-level usage, or
|
|
326
|
+
// one that omits object, answered without them. The body is the base,
|
|
327
|
+
// the terminal overrides what it carries, and the items it lost come
|
|
328
|
+
// back from the accumulation.
|
|
329
|
+
if (whole || !kept.length) return { ...body, ...final };
|
|
330
|
+
return { ...body, ...final, output: kept };
|
|
306
331
|
},
|
|
307
332
|
};
|
|
308
333
|
}
|
package/tool-response.mjs
CHANGED
|
@@ -158,22 +158,33 @@ function isCall(protocol, value) {
|
|
|
158
158
|
);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
// Counts the ids invented for calls
|
|
162
|
-
//
|
|
161
|
+
// Counts the ids invented for calls the reply gives nothing to place: a
|
|
162
|
+
// counter cannot be stable, but it is the only thing that keeps two of them
|
|
163
|
+
// apart (see inPlaceOf).
|
|
163
164
|
let synthesized = 0;
|
|
164
165
|
|
|
165
166
|
// What a call left out of a Responses or Messages reply becomes: text
|
|
166
|
-
// saying so, in the call's place, so no later index moves.
|
|
167
|
-
|
|
167
|
+
// saying so, in the call's place, so no later index moves. `at` is where the
|
|
168
|
+
// call sits in the reply as far as the caller can tell — a frame's
|
|
169
|
+
// output_index, or the index the walk found it at.
|
|
170
|
+
function inPlaceOf(protocol, call, note, at) {
|
|
168
171
|
if (protocol === "anthropic") return { type: "text", text: note };
|
|
169
172
|
// An item id is what a Responses client keys the item by, and what the
|
|
170
|
-
// streaming path suppresses arguments by
|
|
171
|
-
//
|
|
172
|
-
// the
|
|
173
|
+
// streaming path suppresses arguments by. One call is replaced more than
|
|
174
|
+
// once — its output_item.added, its output_item.done, and the copy inside
|
|
175
|
+
// the terminal response's output — so the invented id has to be the same
|
|
176
|
+
// every time: a counter made one call into several items to the client, and
|
|
177
|
+
// left leftIDs unable to match its arguments frames. A call's place is the
|
|
178
|
+
// same in all three, and two calls cannot share one, so the id names it.
|
|
179
|
+
//
|
|
180
|
+
// A call the reply places nowhere keeps the counter — and names it under a
|
|
181
|
+
// different word, because both arms are inventing ids in one reply: a counter
|
|
182
|
+
// that happened to reach another call's index would hand two unrelated calls
|
|
183
|
+
// the same item, which is the failure the counter was introduced to prevent.
|
|
173
184
|
const id =
|
|
174
185
|
typeof call.id === "string" && call.id
|
|
175
186
|
? call.id
|
|
176
|
-
: `msg_${call.call_id ?? `
|
|
187
|
+
: `msg_${call.call_id ?? (at === undefined ? `anon_${++synthesized}` : `dropped_${at}`)}`;
|
|
177
188
|
return {
|
|
178
189
|
type: "message",
|
|
179
190
|
id,
|
|
@@ -206,10 +217,15 @@ function checkSnapshots(data, protocol, checkName) {
|
|
|
206
217
|
let kept = 0;
|
|
207
218
|
let left = 0;
|
|
208
219
|
if (!object(data)) throw new GuardError("Invalid upstream tool snapshot");
|
|
209
|
-
// Each entry is a value
|
|
210
|
-
|
|
220
|
+
// Each entry is a value, where it sits, and the place it holds in the reply
|
|
221
|
+
// as far as the walk knows: an item of an output array is at its index, and
|
|
222
|
+
// anything reached through an object — a frame's own `item` — keeps the
|
|
223
|
+
// output_index the frame carries, which is where that item sits. A call
|
|
224
|
+
// left out is replaced by an item whose invented id names that place, so
|
|
225
|
+
// the added, done and terminal copies of one call are one item (inPlaceOf).
|
|
226
|
+
const stack = [[data, null, null, object(data) ? data.output_index : undefined]];
|
|
211
227
|
while (stack.length) {
|
|
212
|
-
const [value, holder, key] = stack.pop();
|
|
228
|
+
const [value, holder, key, at] = stack.pop();
|
|
213
229
|
if (!object(value)) throw new GuardError("Invalid upstream tool snapshot");
|
|
214
230
|
if (
|
|
215
231
|
protocol === "responses" &&
|
|
@@ -223,7 +239,7 @@ function checkSnapshots(data, protocol, checkName) {
|
|
|
223
239
|
const name = checkName(value.name);
|
|
224
240
|
if (name == null) {
|
|
225
241
|
if (!holder) throw new GuardError(droppedNote(value.name), "undeclared_tool");
|
|
226
|
-
holder[key] = inPlaceOf(protocol, value, droppedNote(value.name));
|
|
242
|
+
holder[key] = inPlaceOf(protocol, value, droppedNote(value.name), at);
|
|
227
243
|
changed = true;
|
|
228
244
|
left++;
|
|
229
245
|
continue;
|
|
@@ -241,10 +257,10 @@ function checkSnapshots(data, protocol, checkName) {
|
|
|
241
257
|
// Text content is not a snapshot container.
|
|
242
258
|
if (typeof value[name] === "string") continue;
|
|
243
259
|
const items = list(value[name]);
|
|
244
|
-
for (let i = 0; i < items.length; i++) stack.push([items[i], items, i]);
|
|
260
|
+
for (let i = 0; i < items.length; i++) stack.push([items[i], items, i, i]);
|
|
245
261
|
}
|
|
246
262
|
for (const name of objects) {
|
|
247
|
-
if (value[name] != null) stack.push([value[name], value, name]);
|
|
263
|
+
if (value[name] != null) stack.push([value[name], value, name, at]);
|
|
248
264
|
}
|
|
249
265
|
}
|
|
250
266
|
if (
|
|
@@ -463,7 +479,10 @@ function snapshotStream(protocol, checkName) {
|
|
|
463
479
|
checkName(item.name) == null
|
|
464
480
|
) {
|
|
465
481
|
const note = droppedNote(item.name);
|
|
466
|
-
|
|
482
|
+
// The frame's output_index is this call's place in the reply, which
|
|
483
|
+
// is the id its later frames (done, the terminal's output) invent
|
|
484
|
+
// for it again (inPlaceOf).
|
|
485
|
+
const message = inPlaceOf(protocol, item, note, data.output_index);
|
|
467
486
|
left.set(data.output_index, true);
|
|
468
487
|
leftIDs.add(message.id);
|
|
469
488
|
if (typeof item.id === "string") leftIDs.add(item.id);
|
|
@@ -544,6 +563,7 @@ async function readBounded(response) {
|
|
|
544
563
|
if (!reader) return "";
|
|
545
564
|
const parts = [];
|
|
546
565
|
let bytes = 0;
|
|
566
|
+
let failure;
|
|
547
567
|
try {
|
|
548
568
|
while (true) {
|
|
549
569
|
const { done, value } = await reader.read();
|
|
@@ -553,20 +573,28 @@ async function readBounded(response) {
|
|
|
553
573
|
throw new GuardError(`Upstream response exceeds the ${LIMIT} buffer limit`);
|
|
554
574
|
parts.push(value);
|
|
555
575
|
}
|
|
576
|
+
} catch (error) {
|
|
577
|
+
failure = error;
|
|
578
|
+
}
|
|
579
|
+
// Whatever ended the read — the limit, a malformed body, or the end of it —
|
|
580
|
+
// this body is never read again, so the stream is cancelled and the reader
|
|
581
|
+
// loosed. This was the one place in the package that abandoned a body
|
|
582
|
+
// without cancelling it (measured: no cancellation past the limit, and the
|
|
583
|
+
// source still pulled after the 502), which leaves an upstream writing into
|
|
584
|
+
// a socket nobody reads. A cancel that fails must not stand in for what
|
|
585
|
+
// ended the read: an oversized body is the 502 this function promises, not
|
|
586
|
+
// an opaque transport error, so the cancel's failure is kept as that
|
|
587
|
+
// error's cause — and is thrown itself only when nothing else went wrong.
|
|
588
|
+
// The lock is released either way.
|
|
589
|
+
try {
|
|
590
|
+
await reader.cancel();
|
|
591
|
+
} catch (error) {
|
|
592
|
+
if (!failure) throw error;
|
|
593
|
+
if (failure instanceof Error && !("cause" in failure)) failure.cause = error;
|
|
556
594
|
} finally {
|
|
557
|
-
|
|
558
|
-
// it — this body is never read again, so the reader is loosed and the
|
|
559
|
-
// stream cancelled. This was the one place in the package that abandoned
|
|
560
|
-
// a body without cancelling it (measured: no cancellation past the limit,
|
|
561
|
-
// and the source still pulled after the 502), which leaves an upstream
|
|
562
|
-
// writing into a socket nobody reads. The cancel's own failure is not
|
|
563
|
-
// swallowed, and the lock is released whether it threw or not.
|
|
564
|
-
try {
|
|
565
|
-
await reader.cancel();
|
|
566
|
-
} finally {
|
|
567
|
-
reader.releaseLock();
|
|
568
|
-
}
|
|
595
|
+
reader.releaseLock();
|
|
569
596
|
}
|
|
597
|
+
if (failure) throw failure;
|
|
570
598
|
const all = new Uint8Array(bytes);
|
|
571
599
|
let offset = 0;
|
|
572
600
|
for (const part of parts) {
|
|
@@ -755,7 +783,14 @@ function chatTrack() {
|
|
|
755
783
|
if (choice.finish_reason != null) {
|
|
756
784
|
finished.add(index);
|
|
757
785
|
open.delete(index);
|
|
758
|
-
} else
|
|
786
|
+
} else {
|
|
787
|
+
// A frame for this choice after it finished opens a new round of it
|
|
788
|
+
// (chatBuffer.apply judges a later round the same way), so the
|
|
789
|
+
// choice is open again: latching it shut for good left a round cut
|
|
790
|
+
// short with no note and no finish_reason, only a bare [DONE].
|
|
791
|
+
finished.delete(index);
|
|
792
|
+
open.add(index);
|
|
793
|
+
}
|
|
759
794
|
}
|
|
760
795
|
},
|
|
761
796
|
// ending answers the frames that end the reply as cut short, or null
|