opencode-zen-freex 0.2.1 → 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 +245 -0
- package/README.md +47 -11
- package/capture.mjs +203 -0
- package/index.mjs +3 -3
- package/limits.mjs +123 -0
- package/models.mjs +213 -148
- package/package.json +5 -1
- package/provider.mjs +509 -461
- package/stream.mjs +398 -398
- package/tool-response.mjs +881 -881
- package/tools-smoke.mjs +169 -0
package/provider.mjs
CHANGED
|
@@ -1,461 +1,509 @@
|
|
|
1
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { PROFILES, agentFor, generationOf } from "./core-tools.mjs";
|
|
3
|
-
import {
|
|
4
|
-
PROVIDER,
|
|
5
|
-
SDK,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
function
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
"
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { PROFILES, agentFor, generationOf } from "./core-tools.mjs";
|
|
3
|
+
import {
|
|
4
|
+
PROVIDER,
|
|
5
|
+
SDK,
|
|
6
|
+
bootstrapProtocols,
|
|
7
|
+
bootstrapProvider,
|
|
8
|
+
buildModels,
|
|
9
|
+
configModels,
|
|
10
|
+
parseProtocolDocs,
|
|
11
|
+
} from "./models.mjs";
|
|
12
|
+
import { observeRouteStatement } from "./limits.mjs";
|
|
13
|
+
import { collapseResponse } from "./stream.mjs";
|
|
14
|
+
import { guardToolResponse } from "./tool-response.mjs";
|
|
15
|
+
|
|
16
|
+
// Zen moved behind opencode.ai/inference; everything under /zen/ answers 403
|
|
17
|
+
// FreeTierError now, however the request is made.
|
|
18
|
+
const DEFAULT_BASE = "https://opencode.ai/inference/v1";
|
|
19
|
+
const CATALOG = "https://models.opencode.ai/api.json";
|
|
20
|
+
const DOCS =
|
|
21
|
+
"https://raw.githubusercontent.com/anomalyco/opencode/dev/services/www/src/docs/content/console/models.mdx";
|
|
22
|
+
const PATHS = { chat: "/chat/completions", responses: "/responses", anthropic: "/messages" };
|
|
23
|
+
// Which generation to present when the host says nothing that tells us: the
|
|
24
|
+
// one opencode-ai publishes as latest.
|
|
25
|
+
const DEFAULT_GENERATION = "1.x";
|
|
26
|
+
// Said only of a tool this plugin added, which the agent has no such tool for.
|
|
27
|
+
// It has to be phrased as being about that one tool: a blanket "these tools are
|
|
28
|
+
// not available" was measured to stop the model using the host's own tools too.
|
|
29
|
+
const PLACEHOLDER =
|
|
30
|
+
"This one is a placeholder that satisfies OpenCode Zen's request and is not " +
|
|
31
|
+
"connected to anything: do not call it, and use the other tools you have.";
|
|
32
|
+
|
|
33
|
+
// Zen answers a request only when the whole of it belongs to one generation of
|
|
34
|
+
// OpenCode: its User-Agent, its headers and its tools have to agree. So the
|
|
35
|
+
// User-Agent is not a constant here. A host that is OpenCode itself already
|
|
36
|
+
// says which one it is, and that answer is above whatever floor Zen is holding,
|
|
37
|
+
// so it is passed through; otherwise the generation is taken from the tools
|
|
38
|
+
// the request actually carries, which is what the gate compares anyway.
|
|
39
|
+
const RELEASE = /^opencode\/(?:latest|stable|beta)\/\d[^/]*\/[^/]+$/;
|
|
40
|
+
|
|
41
|
+
function agentForRequest(protocol, tools, given) {
|
|
42
|
+
const said = given?.get("user-agent");
|
|
43
|
+
if (said && RELEASE.test(said)) return said;
|
|
44
|
+
return agentFor(generationOf(tools) ?? DEFAULT_GENERATION, protocol);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The complete set of headers that leaves here, and nothing else: one owner for
|
|
48
|
+
// the names. tools-smoke.mjs builds the request it asks Zen with from this
|
|
49
|
+
// function, so what the gate reports is about the request this plugin makes and
|
|
50
|
+
// not about a copy of it. A copy drifts, and one did: the gate was missing
|
|
51
|
+
// `accept`, and nothing failed on it. index.test.mjs asserts the names on the
|
|
52
|
+
// wire.
|
|
53
|
+
//
|
|
54
|
+
// x-session-affinity, x-session-id and x-opencode-request are in OpenCode's own
|
|
55
|
+
// header set upstream (packages/core/src/session/model-request.ts) and none of
|
|
56
|
+
// them is needed, so none is sent. Measured on this set, 2026-10-15: each of the
|
|
57
|
+
// three answers on its own, x-opencode-request with x-session-id answers, and
|
|
58
|
+
// x-opencode-request with x-session-affinity is refused. It is the pair and not
|
|
59
|
+
// either header, so a header cannot be judged by adding it on its own.
|
|
60
|
+
export function outboundHeaders({
|
|
61
|
+
session,
|
|
62
|
+
agent,
|
|
63
|
+
protocol = "chat",
|
|
64
|
+
project = "global",
|
|
65
|
+
given,
|
|
66
|
+
}) {
|
|
67
|
+
const headers = new Headers({
|
|
68
|
+
"content-type": "application/json",
|
|
69
|
+
accept: "*/*",
|
|
70
|
+
"user-agent": agent,
|
|
71
|
+
"x-opencode-project": project,
|
|
72
|
+
"x-opencode-session": session,
|
|
73
|
+
"x-opencode-client": "cli",
|
|
74
|
+
"x-opencode-session-id": session,
|
|
75
|
+
});
|
|
76
|
+
if (protocol === "anthropic") {
|
|
77
|
+
headers.set("x-api-key", "public");
|
|
78
|
+
headers.set("anthropic-version", given?.get("anthropic-version") || "2023-06-01");
|
|
79
|
+
if (given?.get("anthropic-beta")) headers.set("anthropic-beta", given.get("anthropic-beta"));
|
|
80
|
+
} else {
|
|
81
|
+
headers.set("authorization", "Bearer public");
|
|
82
|
+
}
|
|
83
|
+
return headers;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// How long a request for a model the last list didn't have waits before it
|
|
87
|
+
// asks Zen's list again: a model Zen made free since is served at once,
|
|
88
|
+
// and a model typed in by hand doesn't ask the list on every request.
|
|
89
|
+
const RELIST_AFTER = 60_000;
|
|
90
|
+
|
|
91
|
+
function httpURL(value) {
|
|
92
|
+
const url = new URL(value);
|
|
93
|
+
if (
|
|
94
|
+
!["http:", "https:"].includes(url.protocol) ||
|
|
95
|
+
url.username ||
|
|
96
|
+
url.password ||
|
|
97
|
+
url.search ||
|
|
98
|
+
url.hash
|
|
99
|
+
) {
|
|
100
|
+
throw new Error("Expected an HTTP(S) URL without credentials, query or fragment");
|
|
101
|
+
}
|
|
102
|
+
return url.href.replace(/\/+$/, "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function requirePublic(auth) {
|
|
106
|
+
if (auth?.type !== "api" || auth.key !== "public") {
|
|
107
|
+
throw new Error(
|
|
108
|
+
"OpenCode Zen Free uses the public credential. Enable its free access login method.",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readRemote(url, json, headers, callerSignal) {
|
|
114
|
+
const timeout = AbortSignal.timeout(15000);
|
|
115
|
+
const signal = callerSignal ? AbortSignal.any([callerSignal, timeout]) : timeout;
|
|
116
|
+
const response = await fetch(url, { headers, signal });
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
await response.body?.cancel();
|
|
119
|
+
throw new Error(`Model discovery returned HTTP ${response.status}`);
|
|
120
|
+
}
|
|
121
|
+
return json ? response.json() : response.text();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeTools(body, protocol) {
|
|
125
|
+
if (body.tools !== undefined && !Array.isArray(body.tools)) {
|
|
126
|
+
throw new Error("tools must be an array");
|
|
127
|
+
}
|
|
128
|
+
body.tools ??= [];
|
|
129
|
+
// A name is read out of whichever shape it arrived in. The protocol decides
|
|
130
|
+
// what is sent, but a host that sent the other shape's tools still declared
|
|
131
|
+
// them, and taking the name from one shape only would fill a gap that is not
|
|
132
|
+
// one and send the same tool twice.
|
|
133
|
+
const nameOf = (tool) => tool?.function?.name ?? tool?.name;
|
|
134
|
+
const present = new Set(body.tools.map(nameOf));
|
|
135
|
+
const declared = new Set([...present].filter((value) => typeof value === "string"));
|
|
136
|
+
if (protocol === "chat" && Array.isArray(body.functions)) {
|
|
137
|
+
for (const tool of body.functions) if (typeof tool?.name === "string") declared.add(tool.name);
|
|
138
|
+
}
|
|
139
|
+
// Zen takes a request for one generation of OpenCode or the other, and the tools
|
|
140
|
+
// it carries say which: `shell` is 2.x, `bash` is 1.x. Names move far less
|
|
141
|
+
// often than descriptions do, so this survives a rewording that a pinned
|
|
142
|
+
// User-Agent would not.
|
|
143
|
+
// Zen takes a request for one generation of OpenCode or the other, and the tools
|
|
144
|
+
// it carries say which: `shell` is 2.x, `bash` is 1.x. Names move far less
|
|
145
|
+
// often than descriptions do, so this survives a rewording that a pinned
|
|
146
|
+
// User-Agent would not.
|
|
147
|
+
const generation = generationOf(body.tools) ?? DEFAULT_GENERATION;
|
|
148
|
+
const profile = PROFILES[generation];
|
|
149
|
+
const filled = [];
|
|
150
|
+
const at = new Map();
|
|
151
|
+
body.tools.forEach((tool, index) => {
|
|
152
|
+
const name = nameOf(tool);
|
|
153
|
+
if (name && !at.has(name)) at.set(name, index);
|
|
154
|
+
});
|
|
155
|
+
for (const [name, spoken] of Object.entries(profile.tools)) {
|
|
156
|
+
const index = at.get(name);
|
|
157
|
+
if (index !== undefined) {
|
|
158
|
+
// The host's own tool, kept whole so that it still runs and the reply's
|
|
159
|
+
// call still finds it. Only the wording is replaced: Zen reads the
|
|
160
|
+
// wording of a tool it knows by name, and a host that is not OpenCode
|
|
161
|
+
// describes its tools in its own words. The host's parameters are left
|
|
162
|
+
// as they are, since Zen does not read those and the call the model
|
|
163
|
+
// makes has to fit the tool that will run it.
|
|
164
|
+
setDescription(body.tools[index], spoken.description);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
// The names in `required` are stood in for: a host that is not OpenCode has
|
|
168
|
+
// no tool by these names, and Zen reads a request as belonging to a
|
|
169
|
+
// generation partly by the tools it carries. `required` is deliberately
|
|
170
|
+
// wider than what Zen was measured to insist on — `read` alone was answered,
|
|
171
|
+
// and everything but `read` was refused, on 2026-10-15 — because a wider
|
|
172
|
+
// set is served, so the extra names cost a host that lacks them only the
|
|
173
|
+
// tokens of a tool it will not call, and they are the first thing a
|
|
174
|
+
// tightened check would otherwise find missing.
|
|
175
|
+
if (!profile.required.includes(name)) continue;
|
|
176
|
+
filled.push(name);
|
|
177
|
+
// A tool added here has nothing behind it: the agent has no such tool and
|
|
178
|
+
// a call to it is dropped. Describing it as OpenCode describes it is what
|
|
179
|
+
// Zen asks for, and it also makes the model want to call it, so the note
|
|
180
|
+
// goes after the opening Zen reads and before the closing it does not.
|
|
181
|
+
body.tools.push(shapeOf({ ...spoken, description: `${spoken.description}\n\n${PLACEHOLDER}` }, protocol));
|
|
182
|
+
}
|
|
183
|
+
return { declared, filled, generation };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The description sits inside `function` on the chat shape and beside the name
|
|
187
|
+
// on the other two, and which one a host used is its own business: it is
|
|
188
|
+
// matched by where the name was found, so a tool sent in the other shape still
|
|
189
|
+
// has its wording read and replaced.
|
|
190
|
+
function descriptionAt(tool) {
|
|
191
|
+
return tool?.function ? tool.function.description : tool?.description;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function setDescription(tool, description) {
|
|
195
|
+
if (tool?.function) tool.function.description = description;
|
|
196
|
+
else if (tool) tool.description = description;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const shapeOf = (definition, protocol) =>
|
|
200
|
+
protocol === "chat"
|
|
201
|
+
? { type: "function", function: definition }
|
|
202
|
+
: protocol === "anthropic"
|
|
203
|
+
? { name: definition.name, description: definition.description, input_schema: definition.parameters }
|
|
204
|
+
: { type: "function", ...definition };
|
|
205
|
+
|
|
206
|
+
// Zen's own wording for a refusal says only that the request did not look like
|
|
207
|
+
// OpenCode's, which sends people off to change headers. Everything the plugin
|
|
208
|
+
// sends apart from the tool wording is what Zen expects — the endpoint, the
|
|
209
|
+
// shape of the User-Agent, both session headers — so a refusal this far in is
|
|
210
|
+
// about the wording, and this says so, and says which wording went out.
|
|
211
|
+
async function explain(response, toolPolicy) {
|
|
212
|
+
if (response.status !== 403 && response.status !== 426) return response;
|
|
213
|
+
const text = await response.text().catch(() => "");
|
|
214
|
+
// A 403 that is not Zen's free tier is somebody else's; it is left as sent.
|
|
215
|
+
if (response.status === 403 && !text.includes("FreeTierError"))
|
|
216
|
+
return new Response(text, response);
|
|
217
|
+
const profile = PROFILES[toolPolicy.generation];
|
|
218
|
+
const names = Object.keys(profile.tools).join(", ");
|
|
219
|
+
const { filled } = toolPolicy;
|
|
220
|
+
// 426 is not a refusal of the wording: it is this plugin presenting itself as
|
|
221
|
+
// an OpenCode older than Zen accepts, which only a newer copy of it answers.
|
|
222
|
+
const message =
|
|
223
|
+
response.status === 426
|
|
224
|
+
? `OpenCode Zen answered 426 Upgrade Required: this plugin presents itself as OpenCode ${profile.agent.opencode} and Zen now wants a newer one. Update the plugin, or run inside a newer OpenCode.`
|
|
225
|
+
: [
|
|
226
|
+
"OpenCode Zen's free tier refused this request as not coming from OpenCode.",
|
|
227
|
+
`Zen reads the wording of ${names} from the request body, not only its headers, and wants OpenCode's own. The wording sent was this package's, for OpenCode ${profile.agent.opencode}.`,
|
|
228
|
+
filled.length
|
|
229
|
+
? `The host declares none of ${filled.join(", ")}, so those were added from it.`
|
|
230
|
+
: `The host declared every one of them; only the wording was replaced, and its own parameters were kept so its tools still run.`,
|
|
231
|
+
"OpenCode rewords those tools between releases. Run `bun run check:gate` to see whether that copy is still current; if it is not, capture the wording again from a current OpenCode and update this package.",
|
|
232
|
+
].join(" ");
|
|
233
|
+
return new Response(
|
|
234
|
+
JSON.stringify({
|
|
235
|
+
type: "error",
|
|
236
|
+
error: { type: response.status === 426 ? "upgrade_required" : "FreeTierError", message },
|
|
237
|
+
}),
|
|
238
|
+
{ status: response.status, statusText: response.statusText, headers: response.headers },
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
243
|
+
let lastStamp = 0;
|
|
244
|
+
let counter = 0;
|
|
245
|
+
|
|
246
|
+
// An id the way OpenCode makes one (packages/schema/src/identifier.ts):
|
|
247
|
+
// 12 hex of Date.now() * 0x1000 + a per-millisecond counter (bit-inverted
|
|
248
|
+
// for a session, so newer sorts first), then 14 random base62 characters.
|
|
249
|
+
// Zen's free tier checks a session id has this shape, a small counter
|
|
250
|
+
// included, and answers 403 FreeTierError to one that doesn't (#100).
|
|
251
|
+
export function opencodeID(prefix, descending, now = Date.now()) {
|
|
252
|
+
if (now !== lastStamp) {
|
|
253
|
+
lastStamp = now;
|
|
254
|
+
counter = 0;
|
|
255
|
+
}
|
|
256
|
+
counter++;
|
|
257
|
+
const current = BigInt(now) * 0x1000n + BigInt(counter);
|
|
258
|
+
const value = (descending ? ~current : current) & 0xffffffffffffn;
|
|
259
|
+
const random = randomBytes(14);
|
|
260
|
+
let tail = "";
|
|
261
|
+
for (const byte of random) tail += ALPHABET[byte % 62];
|
|
262
|
+
return `${prefix}_${value.toString(16).padStart(12, "0")}${tail}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// A session id OpenCode itself could have made: its counter (the low 12
|
|
266
|
+
// bits, inverted) is small. A hash cut to the same shape isn't one.
|
|
267
|
+
export function realSessionID(value) {
|
|
268
|
+
const match = /^ses_[0-9a-f]{9}([0-9a-f]{3})[0-9A-Za-z]{14}$/.exec(value ?? "");
|
|
269
|
+
return !!match && 0xfff - Number.parseInt(match[1], 16) < 16;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// One made-up session per conversation, so its turns keep one session.
|
|
273
|
+
const SESSIONS = new Map();
|
|
274
|
+
const KEEP_SESSIONS = 1000;
|
|
275
|
+
|
|
276
|
+
export function sessionID(headers, body) {
|
|
277
|
+
let signal;
|
|
278
|
+
for (const name of [
|
|
279
|
+
"x-opencode-session",
|
|
280
|
+
"x-opencode-session-id",
|
|
281
|
+
"x-session-affinity",
|
|
282
|
+
"x-session-id",
|
|
283
|
+
"conversation-id",
|
|
284
|
+
]) {
|
|
285
|
+
if (headers.get(name)) {
|
|
286
|
+
signal = headers.get(name);
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (!signal && typeof body.metadata?.session_id === "string") signal = body.metadata.session_id;
|
|
291
|
+
if (realSessionID(signal)) return signal;
|
|
292
|
+
if (!signal) {
|
|
293
|
+
const messages = body.messages ?? body.input;
|
|
294
|
+
const first = Array.isArray(messages)
|
|
295
|
+
? messages.find((message) => message.role === "user")?.content
|
|
296
|
+
: messages;
|
|
297
|
+
if (!first) return opencodeID("ses", true);
|
|
298
|
+
signal = JSON.stringify(first);
|
|
299
|
+
}
|
|
300
|
+
const key = createHash("sha256").update(`ses\0${signal}`).digest("hex");
|
|
301
|
+
let session = SESSIONS.get(key);
|
|
302
|
+
if (session) {
|
|
303
|
+
SESSIONS.delete(key);
|
|
304
|
+
} else {
|
|
305
|
+
session = opencodeID("ses", true);
|
|
306
|
+
if (SESSIONS.size >= KEEP_SESSIONS) SESSIONS.delete(SESSIONS.keys().next().value);
|
|
307
|
+
}
|
|
308
|
+
SESSIONS.set(key, session);
|
|
309
|
+
return session;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function server(_input, options = {}) {
|
|
313
|
+
const baseURL = httpURL(options.baseURL ?? DEFAULT_BASE);
|
|
314
|
+
const catalogURL = httpURL(options.catalogURL ?? CATALOG);
|
|
315
|
+
const docsURL = httpURL(options.docsURL ?? DOCS);
|
|
316
|
+
let known = buildModels(
|
|
317
|
+
Object.keys(bootstrapProvider.models),
|
|
318
|
+
bootstrapProvider,
|
|
319
|
+
bootstrapProtocols,
|
|
320
|
+
baseURL,
|
|
321
|
+
);
|
|
322
|
+
let metadata = bootstrapProvider;
|
|
323
|
+
let protocols = { ...bootstrapProtocols };
|
|
324
|
+
let listed = false;
|
|
325
|
+
let listedAt = 0;
|
|
326
|
+
|
|
327
|
+
async function discover(signal) {
|
|
328
|
+
const headers = { authorization: "Bearer public", "x-opencode-client": "cli" };
|
|
329
|
+
const [listing, catalog, docs] = await Promise.allSettled([
|
|
330
|
+
readRemote(`${baseURL}/models`, true, headers, signal),
|
|
331
|
+
readRemote(catalogURL, true, undefined, signal),
|
|
332
|
+
readRemote(docsURL, false, undefined, signal),
|
|
333
|
+
]);
|
|
334
|
+
signal?.throwIfAborted();
|
|
335
|
+
let partial = false;
|
|
336
|
+
if (catalog.status === "fulfilled" && catalog.value?.opencode?.models) {
|
|
337
|
+
metadata = catalog.value.opencode;
|
|
338
|
+
} else {
|
|
339
|
+
partial = true;
|
|
340
|
+
}
|
|
341
|
+
if (docs.status === "fulfilled" && Object.keys(parseProtocolDocs(docs.value)).length) {
|
|
342
|
+
// The snapshot is the floor, not a rival: the docs say what they say and
|
|
343
|
+
// the snapshot only fills what they no longer name.
|
|
344
|
+
protocols = { ...bootstrapProtocols, ...parseProtocolDocs(docs.value) };
|
|
345
|
+
} else {
|
|
346
|
+
partial = true;
|
|
347
|
+
}
|
|
348
|
+
if (listing.status !== "fulfilled" || !Array.isArray(listing.value?.data)) {
|
|
349
|
+
const fallback = { ...known };
|
|
350
|
+
fallback[Symbol.for("magpie.fellBack")] = true;
|
|
351
|
+
return fallback;
|
|
352
|
+
}
|
|
353
|
+
const ids = listing.value.data.map((item) => item.id);
|
|
354
|
+
const discovered = buildModels(ids, metadata, protocols, baseURL);
|
|
355
|
+
if (partial) {
|
|
356
|
+
// Preserve previously verified models only while they remain in the live catalog.
|
|
357
|
+
for (const id of ids) {
|
|
358
|
+
if (!discovered[id] && !metadata.models?.[id] && !protocols[id] && known[id]) {
|
|
359
|
+
discovered[id] = known[id];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
discovered[Symbol.for("magpie.fellBack")] = true;
|
|
363
|
+
}
|
|
364
|
+
known = discovered;
|
|
365
|
+
listed = true;
|
|
366
|
+
listedAt = Date.now();
|
|
367
|
+
return discovered;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
async config(cfg) {
|
|
372
|
+
cfg.provider ??= {};
|
|
373
|
+
cfg.provider[PROVIDER] ??= {
|
|
374
|
+
name: "OpenCode Zen Free",
|
|
375
|
+
npm: SDK.chat,
|
|
376
|
+
api: baseURL,
|
|
377
|
+
models: configModels(known),
|
|
378
|
+
};
|
|
379
|
+
},
|
|
380
|
+
auth: {
|
|
381
|
+
provider: PROVIDER,
|
|
382
|
+
icon: "https://opencode.ai/favicon-v3.ico",
|
|
383
|
+
maxConcurrency: 2,
|
|
384
|
+
methods: [
|
|
385
|
+
{
|
|
386
|
+
type: "api",
|
|
387
|
+
label: "Zen 免费访问(密钥填写 public)",
|
|
388
|
+
placeholder: "public",
|
|
389
|
+
async authorize() {
|
|
390
|
+
return { type: "success", key: "public" };
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
async loader(getAuth) {
|
|
395
|
+
const auth = await getAuth();
|
|
396
|
+
if (!auth) return {};
|
|
397
|
+
requirePublic(auth);
|
|
398
|
+
return {
|
|
399
|
+
baseURL,
|
|
400
|
+
apiKey: "public",
|
|
401
|
+
async fetch(input, init) {
|
|
402
|
+
requirePublic(await getAuth());
|
|
403
|
+
const request = new Request(input, init);
|
|
404
|
+
const protocol = Object.keys(PATHS).find(
|
|
405
|
+
(key) => request.url === `${baseURL}${PATHS[key]}`,
|
|
406
|
+
);
|
|
407
|
+
if (!protocol || request.method !== "POST")
|
|
408
|
+
throw new Error("Unsupported Zen endpoint or method");
|
|
409
|
+
request.signal.throwIfAborted();
|
|
410
|
+
const body = await request.json();
|
|
411
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
412
|
+
throw new Error("Expected a JSON request object");
|
|
413
|
+
if (
|
|
414
|
+
!Object.hasOwn(known, body.model) &&
|
|
415
|
+
(!listed || Date.now() - listedAt >= RELIST_AFTER)
|
|
416
|
+
) {
|
|
417
|
+
await discover(request.signal);
|
|
418
|
+
}
|
|
419
|
+
const model = Object.hasOwn(known, body.model) ? known[body.model] : undefined;
|
|
420
|
+
if (!model) {
|
|
421
|
+
// Zen no longer gives it free, or never did: said as a model
|
|
422
|
+
// not served, so the caller sets this model aside and not the
|
|
423
|
+
// account, whose other free models are still served.
|
|
424
|
+
const free = Object.keys(known).sort().join(", ");
|
|
425
|
+
const name = typeof body.model === "string" ? body.model : "";
|
|
426
|
+
return new Response(
|
|
427
|
+
JSON.stringify({
|
|
428
|
+
type: "error",
|
|
429
|
+
error: {
|
|
430
|
+
type: "not_found_error",
|
|
431
|
+
message: `Model ${name} is not available as an OpenCode Zen free model now. Free models: ${free || "none"}.`,
|
|
432
|
+
},
|
|
433
|
+
}),
|
|
434
|
+
{ status: 404, headers: { "content-type": "application/json" } },
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
if (model.api.npm !== SDK[protocol]) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
"Model is not an available free model for this protocol. Refresh the model list.",
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
const streaming = body.stream === true;
|
|
443
|
+
body.stream = true;
|
|
444
|
+
const toolPolicy = normalizeTools(body, protocol);
|
|
445
|
+
if (protocol === "chat")
|
|
446
|
+
body.stream_options = { ...body.stream_options, include_usage: true };
|
|
447
|
+
// Adding a header here is the easiest way to break this plugin: Zen
|
|
448
|
+
// takes a request for one generation of OpenCode or the other, and a
|
|
449
|
+
// header belonging to neither is as good as saying so. The names are
|
|
450
|
+
// outboundHeaders', which is also what the gate asks Zen with.
|
|
451
|
+
const given = request.headers;
|
|
452
|
+
const session = sessionID(given, body);
|
|
453
|
+
const project = _input?.project;
|
|
454
|
+
const headers = outboundHeaders({
|
|
455
|
+
session,
|
|
456
|
+
agent: agentForRequest(protocol, body.tools, given),
|
|
457
|
+
protocol,
|
|
458
|
+
given,
|
|
459
|
+
project:
|
|
460
|
+
given.get("x-opencode-project") ||
|
|
461
|
+
(project?.vcs === "git" && project.id ? project.id : "global"),
|
|
462
|
+
});
|
|
463
|
+
const response = await fetch(request.url, {
|
|
464
|
+
method: "POST",
|
|
465
|
+
headers,
|
|
466
|
+
body: JSON.stringify(body),
|
|
467
|
+
signal: request.signal,
|
|
468
|
+
redirect: "error",
|
|
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);
|
|
475
|
+
const guarded = await guardToolResponse(await explain(response, toolPolicy), protocol, toolPolicy, {
|
|
476
|
+
endCutShort: streaming,
|
|
477
|
+
});
|
|
478
|
+
return streaming ? guarded : collapseResponse(guarded, protocol);
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
},
|
|
482
|
+
async usage() {
|
|
483
|
+
return { plan: "OpenCode Zen Free", windows: [] };
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
provider: {
|
|
487
|
+
id: PROVIDER,
|
|
488
|
+
async models(provider, { auth } = {}) {
|
|
489
|
+
if (!auth) return provider.models;
|
|
490
|
+
requirePublic(auth);
|
|
491
|
+
return discover();
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
async "chat.headers"(input, output) {
|
|
495
|
+
if (![PROVIDER, `${PROVIDER}-plugin`].includes(input.model?.providerID) || !input.sessionID)
|
|
496
|
+
return;
|
|
497
|
+
// The pair Zen wants, both present and equal (see outboundHeaders). This
|
|
498
|
+
// hook is the one place where the host is asked to carry them, and it asks
|
|
499
|
+
// for these two only: x-session-affinity and x-session-id are in OpenCode's
|
|
500
|
+
// own header set upstream but are not needed, and measured 2026-10-15 two
|
|
501
|
+
// headers of that set together are refused where either one alone answers
|
|
502
|
+
// (tkhs101#100 follow-up).
|
|
503
|
+
output.headers["x-opencode-session"] = input.sessionID;
|
|
504
|
+
output.headers["x-opencode-session-id"] = input.sessionID;
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export default { id: PROVIDER, server };
|