opencode-zen-freex 0.2.0 → 0.2.2

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