@magpie-community/opencode-zen-free-auth 0.1.0 → 0.1.8

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/README.md CHANGED
@@ -3,6 +3,12 @@
3
3
  OpenCode Zen's free models in magpie and OpenCode. Provider id:
4
4
  `opencode-zen-free`.
5
5
 
6
+ Since 2026-10-13 Zen's free tier answers 403 FreeTierError ("can only be
7
+ used from within OpenCode") to a request that isn't OpenCode's. From
8
+ 0.1.6 the plugin sends a free-model request with exactly the headers
9
+ OpenCode 1.19.0 sends to Zen, and a session id of OpenCode's own form.
10
+ Older versions (0.1.1 on npm before it) get the 403: update the plugin.
11
+
6
12
  ## Install
7
13
 
8
14
  ```sh
@@ -22,15 +28,40 @@ The plugin discovers Zen's current free models, including their context
22
28
  windows and reasoning levels. Use `opencode-zen-free/<model>`, for example
23
29
  `opencode-zen-free/big-pickle`.
24
30
 
25
- Retired models and SystemOne models are excluded. Availability and rate
26
- limits are set by Zen; the plugin has no remaining-quota data.
31
+ Retired models and SystemOne models are excluded. The list is asked
32
+ again each time the host lists models (magpie does this on start and every
33
+ hour): a model Zen no longer gives free leaves it, and a new free one joins
34
+ it. When Zen's list can't be read, the last list is kept. A request for a
35
+ model that isn't free now gets a 404 naming the free ones; one made free
36
+ since the last list is served after the list is asked again (at most once a
37
+ minute). Availability and rate limits are set by Zen; the plugin has no
38
+ remaining-quota data.
27
39
 
28
40
  ## Requests
29
41
 
30
- Requests go to `https://opencode.ai/zen/v1` using each model's native
42
+ Requests go to `https://opencode.ai/inference/v1` using each model's native
31
43
  Chat Completions, Responses or Anthropic Messages API. Streaming and
32
44
  ordinary JSON replies are supported.
33
45
 
34
- The agent's tools are preserved. If Zen calls an added tool, its name is
35
- mapped to the agent's unique case-insensitive match (`bash` to `Bash`).
36
- A call with no matching tool returns an error. Tools run in the agent.
46
+ The agent's tools are preserved. Zen's models often call OpenCode's own
47
+ tool names; such a call is given the agent's tool whose name differs only
48
+ in case (`bash` to `Bash`), or else only in case and `_`/`-` (`todowrite`
49
+ to `todo_write`, `webfetch` to `web_fetch`), when exactly one does. A
50
+ call with no such tool (or with no name) is left out of the reply, and
51
+ the reply's text says which name was left out; a turn whose every call
52
+ was left out ends as an ordinary answer. Tools run in the agent.
53
+
54
+ Zen's free tier only answers a request that carries tools, so a request
55
+ is sent with the five core OpenCode tools the agent doesn't declare
56
+ itself (`bash`, `edit`, `glob`, `grep`, `read`), described as not
57
+ available. A call of one of them the agent doesn't have is left out like
58
+ any other.
59
+
60
+ Now and then Zen closes a streamed Chat Completions reply before its
61
+ `[DONE]`. When the reply has said something and no tool call of it is
62
+ unfinished, it ends there as an ordinary answer, and its text says it was
63
+ cut short; asking again goes on. A reply cut short with a tool call
64
+ unfinished, with nothing said, or collapsed for a client that doesn't
65
+ stream fails with an error naming the reply's id and how much of it came.
66
+ An Anthropic Messages or Responses reply cut short fails too: magpie
67
+ tells it from a whole one and, where the model can, goes on with it.
package/models.mjs CHANGED
@@ -85,10 +85,73 @@ export function buildModels(ids, provider, protocols, baseURL) {
85
85
  return result;
86
86
  }
87
87
 
88
- // Offline bootstrap metadata from the OpenCode catalog, 2026-10-06.
88
+ // What each free model is asked at, from OpenCode's console docs
89
+ // (services/www/src/docs/content/console/models.mdx) on 2026-10-15: used for
90
+ // a listed model when the docs can't be read, so the catalog or the docs
91
+ // being out of reach never leaves a listed free model out. Jev is a
92
+ // SystemOne decision model, not one to chat with.
93
+ export const bootstrapProtocols = {
94
+ "big-pickle": "chat",
95
+ "space-bunny-free": "chat",
96
+ "cat-jam-free": "chat",
97
+ "longcat-2.5-preview-free": "chat",
98
+ "step-5-preview-free": "chat",
99
+ "mimo-v2.6-flash-free": "chat",
100
+ "nemotron-3.5-lightning-free": "chat",
101
+ "muse-spark-1.3-contributor-free": "responses",
102
+ "jev-1.13-free": "systemone",
103
+ };
104
+
105
+ // Offline bootstrap metadata from the OpenCode catalog, 2026-10-06; the
106
+ // free models listed since added on 2026-10-15.
89
107
  export const bootstrapProvider = {
90
108
  npm: SDK.chat,
91
109
  models: {
110
+ "cat-jam-free": {
111
+ name: "Cat Jam Free",
112
+ cost: { input: 0, output: 0 },
113
+ limit: { context: 262144, output: 131072 },
114
+ reasoning: true,
115
+ tool_call: true,
116
+ interleaved: { field: "reasoning_content" },
117
+ reasoning_options: [{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }],
118
+ },
119
+ "step-5-preview-free": {
120
+ name: "Step 5 Preview Free",
121
+ cost: { input: 0, output: 0 },
122
+ limit: { context: 1000000, input: 1000000, output: 65536 },
123
+ reasoning: true,
124
+ tool_call: true,
125
+ interleaved: { field: "reasoning_content" },
126
+ modalities: { input: ["text", "image", "video"], output: ["text"] },
127
+ reasoning_options: [{ type: "effort", values: ["low", "medium", "high"] }],
128
+ },
129
+ "longcat-2.5-preview-free": {
130
+ name: "LongCat 2.5 Preview Free",
131
+ cost: { input: 0, output: 0 },
132
+ limit: { context: 1000000, output: 131072 },
133
+ reasoning: true,
134
+ tool_call: true,
135
+ interleaved: { field: "reasoning_content" },
136
+ modalities: { input: ["text", "image"], output: ["text"] },
137
+ },
138
+ "mimo-v2.6-flash-free": {
139
+ name: "MiMo-V2.6-Flash Free",
140
+ cost: { input: 0, output: 0 },
141
+ limit: { context: 200000, output: 32000 },
142
+ reasoning: true,
143
+ tool_call: true,
144
+ interleaved: { field: "reasoning_content" },
145
+ modalities: { input: ["text", "image", "audio", "video"], output: ["text"] },
146
+ },
147
+ "nemotron-3.5-lightning-free": {
148
+ name: "Nemotron 3.5 Lightning Free",
149
+ cost: { input: 0, output: 0 },
150
+ limit: { context: 262144, output: 262144 },
151
+ reasoning: true,
152
+ tool_call: true,
153
+ interleaved: { field: "reasoning_content" },
154
+ },
92
155
  "big-pickle": {
93
156
  name: "Big Pickle",
94
157
  cost: { input: 0, output: 0 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-zen-free-auth",
3
- "version": "0.1.0",
3
+ "version": "0.1.8",
4
4
  "description": "OpenCode Zen free models through the public credential, with native streaming protocols",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/provider.mjs CHANGED
@@ -1,7 +1,8 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import {
3
3
  PROVIDER,
4
4
  SDK,
5
+ bootstrapProtocols,
5
6
  bootstrapProvider,
6
7
  buildModels,
7
8
  configModels,
@@ -10,13 +11,40 @@ import {
10
11
  import { collapseResponse } from "./stream.mjs";
11
12
  import { guardToolResponse } from "./tool-response.mjs";
12
13
 
13
- const DEFAULT_BASE = "https://opencode.ai/zen/v1";
14
+ // Zen's free models answer only at the inference API since mid-October
15
+ // 2026: the same request OpenCode 1.19.0 sends answers 403 FreeTierError
16
+ // at /zen/v1 and 200 here, and models.dev gives OpenCode this URL. A model
17
+ // listed before then still names /zen/v1 (LEGACY_BASE), which is asked here.
18
+ const DEFAULT_BASE = "https://opencode.ai/inference/v1";
19
+ const LEGACY_BASE = "https://opencode.ai/zen/v1";
14
20
  const CATALOG = "https://models.opencode.ai/api.json";
21
+ // OpenCode's table of what each model is asked at; packages/web's zen.mdx,
22
+ // read before, was removed on 2026-10-12 (anomalyco/opencode#54761)
15
23
  const DOCS =
16
- "https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/web/src/content/docs/zen.mdx";
24
+ "https://raw.githubusercontent.com/anomalyco/opencode/dev/services/www/src/docs/content/console/models.mdx";
17
25
  const PATHS = { chat: "/chat/completions", responses: "/responses", anthropic: "/messages" };
18
26
  const CORE_TOOLS = ["bash", "edit", "glob", "grep", "read"];
19
- const OPENCODE_VERSION = "1.18.34";
27
+ // The parameter each stand-in takes, OpenCode's own first one: Zen's free
28
+ // tier answers 403 FreeTierError to bash or read offered with none.
29
+ const CORE_PARAMETER = {
30
+ bash: "command",
31
+ edit: "filePath",
32
+ glob: "pattern",
33
+ grep: "pattern",
34
+ read: "filePath",
35
+ };
36
+ const OPENCODE_VERSION = "1.19.0";
37
+ // What OpenCode 1.19.0 sends as its User-Agent, ai-sdk and Bun included.
38
+ // Each protocol's ai-sdk package bundles its own provider-utils, so the
39
+ // version differs by protocol (captured from the release binary's requests).
40
+ const PROVIDER_UTILS = { chat: "4.0.23", responses: "4.0.40", anthropic: "4.0.46" };
41
+ export function userAgent(protocol) {
42
+ return `opencode/${OPENCODE_VERSION} ai-sdk/provider-utils/${PROVIDER_UTILS[protocol]} runtime/bun/1.3.14`;
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;
20
48
 
21
49
  function httpURL(value) {
22
50
  const url = new URL(value);
@@ -63,11 +91,13 @@ function normalizeTools(body, protocol) {
63
91
  if (protocol === "chat" && Array.isArray(body.functions)) {
64
92
  for (const tool of body.functions) if (typeof tool?.name === "string") declared.add(tool.name);
65
93
  }
66
- const injected = new Set();
67
94
  for (const name of CORE_TOOLS) {
68
95
  if (present.has(name)) continue;
69
- injected.add(name);
70
- const parameters = { type: "object", properties: {}, additionalProperties: false };
96
+ const parameters = {
97
+ type: "object",
98
+ properties: { [CORE_PARAMETER[name]]: { type: "string" } },
99
+ additionalProperties: false,
100
+ };
71
101
  const definition = { name, description: "Not available. Never call this tool." };
72
102
  body.tools.push(
73
103
  protocol === "chat"
@@ -77,13 +107,48 @@ function normalizeTools(body, protocol) {
77
107
  : { type: "function", ...definition, parameters },
78
108
  );
79
109
  }
80
- return { declared, injected };
110
+ return { declared };
111
+ }
112
+
113
+ const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
114
+ let lastStamp = 0;
115
+ let counter = 0;
116
+
117
+ // An id the way OpenCode makes one (packages/schema/src/identifier.ts):
118
+ // 12 hex of Date.now() * 0x1000 + a per-millisecond counter (bit-inverted
119
+ // for a session, so newer sorts first), then 14 random base62 characters.
120
+ // Zen's free tier checks a session id has this shape, a small counter
121
+ // included, and answers 403 FreeTierError to one that doesn't (#100).
122
+ export function opencodeID(prefix, descending, now = Date.now()) {
123
+ if (now !== lastStamp) {
124
+ lastStamp = now;
125
+ counter = 0;
126
+ }
127
+ counter++;
128
+ const current = BigInt(now) * 0x1000n + BigInt(counter);
129
+ const value = (descending ? ~current : current) & 0xffffffffffffn;
130
+ const random = randomBytes(14);
131
+ let tail = "";
132
+ for (const byte of random) tail += ALPHABET[byte % 62];
133
+ return `${prefix}_${value.toString(16).padStart(12, "0")}${tail}`;
134
+ }
135
+
136
+ // A session id OpenCode itself could have made: its counter (the low 12
137
+ // bits, inverted) is small. A hash cut to the same shape isn't one.
138
+ export function realSessionID(value) {
139
+ const match = /^ses_[0-9a-f]{9}([0-9a-f]{3})[0-9A-Za-z]{14}$/.exec(value ?? "");
140
+ return !!match && 0xfff - Number.parseInt(match[1], 16) < 16;
81
141
  }
82
142
 
83
- function sessionID(headers, body) {
143
+ // One made-up session per conversation, so its turns keep one session.
144
+ const SESSIONS = new Map();
145
+ const KEEP_SESSIONS = 1000;
146
+
147
+ export function sessionID(headers, body) {
84
148
  let signal;
85
149
  for (const name of [
86
150
  "x-opencode-session",
151
+ "x-opencode-session-id",
87
152
  "x-session-affinity",
88
153
  "x-session-id",
89
154
  "conversation-id",
@@ -94,34 +159,41 @@ function sessionID(headers, body) {
94
159
  }
95
160
  }
96
161
  if (!signal && typeof body.metadata?.session_id === "string") signal = body.metadata.session_id;
162
+ if (realSessionID(signal)) return signal;
97
163
  if (!signal) {
98
164
  const messages = body.messages ?? body.input;
99
165
  const first = Array.isArray(messages)
100
166
  ? messages.find((message) => message.role === "user")?.content
101
167
  : messages;
102
- signal = first ? JSON.stringify(first) : randomUUID();
168
+ if (!first) return opencodeID("ses", true);
169
+ signal = JSON.stringify(first);
103
170
  }
104
- if (/^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/.test(signal)) return signal;
105
- // Keep conversation affinity using OpenCode's canonical session format.
106
- const hash = createHash("sha256").update(`ses\0${signal}`).digest("hex");
107
- let value = BigInt(`0x${hash.slice(12, 32)}`);
108
- let suffix = "";
109
- const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
110
- for (let i = 0; i < 14; i++) {
111
- suffix = alphabet[Number(value % 62n)] + suffix;
112
- value /= 62n;
171
+ const key = createHash("sha256").update(`ses\0${signal}`).digest("hex");
172
+ let session = SESSIONS.get(key);
173
+ if (session) {
174
+ SESSIONS.delete(key);
175
+ } else {
176
+ session = opencodeID("ses", true);
177
+ if (SESSIONS.size >= KEEP_SESSIONS) SESSIONS.delete(SESSIONS.keys().next().value);
113
178
  }
114
- return `ses_${hash.slice(0, 12)}${suffix}`;
179
+ SESSIONS.set(key, session);
180
+ return session;
115
181
  }
116
182
 
117
183
  async function server(_input, options = {}) {
118
184
  const baseURL = httpURL(options.baseURL ?? DEFAULT_BASE);
119
185
  const catalogURL = httpURL(options.catalogURL ?? CATALOG);
120
186
  const docsURL = httpURL(options.docsURL ?? DOCS);
121
- let known = buildModels(Object.keys(bootstrapProvider.models), bootstrapProvider, {}, baseURL);
187
+ let known = buildModels(
188
+ Object.keys(bootstrapProvider.models),
189
+ bootstrapProvider,
190
+ bootstrapProtocols,
191
+ baseURL,
192
+ );
122
193
  let metadata = bootstrapProvider;
123
- let protocols = {};
194
+ let protocols = { ...bootstrapProtocols };
124
195
  let listed = false;
196
+ let listedAt = 0;
125
197
 
126
198
  async function discover(signal) {
127
199
  const headers = { authorization: "Bearer public", "x-opencode-client": "cli" };
@@ -138,7 +210,7 @@ async function server(_input, options = {}) {
138
210
  partial = true;
139
211
  }
140
212
  if (docs.status === "fulfilled" && Object.keys(parseProtocolDocs(docs.value)).length) {
141
- protocols = parseProtocolDocs(docs.value);
213
+ protocols = { ...bootstrapProtocols, ...parseProtocolDocs(docs.value) };
142
214
  } else {
143
215
  partial = true;
144
216
  }
@@ -160,6 +232,7 @@ async function server(_input, options = {}) {
160
232
  }
161
233
  known = discovered;
162
234
  listed = true;
235
+ listedAt = Date.now();
163
236
  return discovered;
164
237
  }
165
238
 
@@ -198,7 +271,9 @@ async function server(_input, options = {}) {
198
271
  requirePublic(await getAuth());
199
272
  const request = new Request(input, init);
200
273
  const protocol = Object.keys(PATHS).find(
201
- (key) => request.url === `${baseURL}${PATHS[key]}`,
274
+ (key) =>
275
+ request.url === `${baseURL}${PATHS[key]}` ||
276
+ (baseURL === DEFAULT_BASE && request.url === `${LEGACY_BASE}${PATHS[key]}`),
202
277
  );
203
278
  if (!protocol || request.method !== "POST")
204
279
  throw new Error("Unsupported Zen endpoint or method");
@@ -206,11 +281,31 @@ async function server(_input, options = {}) {
206
281
  const body = await request.json();
207
282
  if (!body || typeof body !== "object" || Array.isArray(body))
208
283
  throw new Error("Expected a JSON request object");
209
- if (!listed && !Object.hasOwn(known, body.model)) {
284
+ if (
285
+ !Object.hasOwn(known, body.model) &&
286
+ (!listed || Date.now() - listedAt >= RELIST_AFTER)
287
+ ) {
210
288
  await discover(request.signal);
211
289
  }
212
- const model = known[body.model];
213
- if (!model || model.api.npm !== SDK[protocol]) {
290
+ const model = Object.hasOwn(known, body.model) ? known[body.model] : undefined;
291
+ if (!model) {
292
+ // Zen no longer gives it free, or never did: said as a model
293
+ // not served, so the caller sets this model aside and not the
294
+ // account, whose other free models are still served.
295
+ const free = Object.keys(known).sort().join(", ");
296
+ const name = typeof body.model === "string" ? body.model : "";
297
+ return new Response(
298
+ JSON.stringify({
299
+ type: "error",
300
+ error: {
301
+ type: "not_found_error",
302
+ message: `Model ${name} is not available as an OpenCode Zen free model now. Free models: ${free || "none"}.`,
303
+ },
304
+ }),
305
+ { status: 404, headers: { "content-type": "application/json" } },
306
+ );
307
+ }
308
+ if (model.api.npm !== SDK[protocol]) {
214
309
  throw new Error(
215
310
  "Model is not an available free model for this protocol. Refresh the model list.",
216
311
  );
@@ -220,47 +315,42 @@ async function server(_input, options = {}) {
220
315
  const toolPolicy = normalizeTools(body, protocol);
221
316
  if (protocol === "chat")
222
317
  body.stream_options = { ...body.stream_options, include_usage: true };
223
- const headers = new Headers(request.headers);
224
- for (const name of [
225
- "authorization",
226
- "x-api-key",
227
- "x-goog-api-key",
228
- "content-length",
229
- "content-encoding",
230
- "host",
231
- "cookie",
232
- ]) {
233
- headers.delete(name);
234
- }
235
- headers.set("content-type", "application/json");
236
- headers.set("accept", "text/event-stream");
237
- headers.set("user-agent", `opencode/${OPENCODE_VERSION}`);
238
- headers.set("x-opencode-client", "cli");
239
- headers.set("x-opencode-request", `req_${randomUUID().replaceAll("-", "")}`);
240
- if (!headers.has("x-opencode-project")) {
241
- const project = _input?.project;
242
- headers.set(
243
- "x-opencode-project",
244
- project?.vcs === "git" && project.id ? project.id : "global",
245
- );
246
- }
247
- const session = sessionID(headers, body);
248
- for (const name of ["x-session-id", "x-opencode-session", "x-session-affinity"])
249
- headers.set(name, session);
318
+ // Only what OpenCode 1.19.0 itself sends to its gateway, so a
319
+ // caller's own headers (an agent's User-Agent, x-stainless-*,
320
+ // x-session-affinity, ...) never mark the request as not
321
+ // OpenCode's (packages/opencode/src/session/llm/request.ts).
322
+ const given = request.headers;
323
+ const session = sessionID(given, body);
324
+ const project = _input?.project;
325
+ const headers = new Headers({
326
+ "content-type": "application/json",
327
+ accept: "*/*",
328
+ "user-agent": userAgent(protocol),
329
+ "x-opencode-project":
330
+ given.get("x-opencode-project") ||
331
+ (project?.vcs === "git" && project.id ? project.id : "global"),
332
+ "x-opencode-session": session,
333
+ "x-opencode-request": opencodeID("msg", false),
334
+ "x-opencode-client": "cli",
335
+ "x-opencode-session-id": session,
336
+ });
250
337
  if (protocol === "anthropic") {
251
338
  headers.set("x-api-key", "public");
252
- if (!headers.has("anthropic-version")) headers.set("anthropic-version", "2023-06-01");
339
+ headers.set("anthropic-version", given.get("anthropic-version") || "2023-06-01");
340
+ if (given.get("anthropic-beta")) headers.set("anthropic-beta", given.get("anthropic-beta"));
253
341
  } else {
254
342
  headers.set("authorization", "Bearer public");
255
343
  }
256
- const response = await fetch(request.url, {
344
+ const response = await fetch(`${baseURL}${PATHS[protocol]}`, {
257
345
  method: "POST",
258
346
  headers,
259
347
  body: JSON.stringify(body),
260
348
  signal: request.signal,
261
349
  redirect: "error",
262
350
  });
263
- const guarded = await guardToolResponse(response, protocol, toolPolicy);
351
+ const guarded = await guardToolResponse(response, protocol, toolPolicy, {
352
+ endCutShort: streaming,
353
+ });
264
354
  return streaming ? guarded : collapseResponse(guarded, protocol);
265
355
  },
266
356
  };
package/tool-response.mjs CHANGED
@@ -30,56 +30,162 @@ function list(value) {
30
30
  return value;
31
31
  }
32
32
 
33
- function nameGuard(policy) {
34
- const declared = new Set(policy.declared);
35
- const injected = new Set(policy.injected);
33
+ // OpenCode's own models call OpenCode's tool names (bash, todowrite,
34
+ // webfetch) whatever the agent declared. A call is given the agent's tool
35
+ // whose name differs only in case, or else only in case and separators
36
+ // (todowrite → todo_write, webfetch → web_fetch), when exactly one does; a
37
+ // name two of the agent's tools fold to is never guessed between.
38
+ const separators = /[\s_.-]/g;
39
+ function foldsTo(declared, key) {
36
40
  const folded = new Map();
37
41
  for (const name of declared) {
38
- const key = name.toLowerCase();
39
- folded.set(key, folded.has(key) ? null : name);
42
+ const k = key(name);
43
+ // A name of separators only folds to nothing, and would be handed any
44
+ // other such call ("_" for "--"): it matches only itself.
45
+ if (!k) continue;
46
+ folded.set(k, folded.has(k) ? null : name);
40
47
  }
48
+ return folded;
49
+ }
50
+
51
+ // nameGuard answers the agent's name for a call, or null for a call of no
52
+ // tool the agent has (a missing name included).
53
+ function nameGuard(policy) {
54
+ const declared = new Set(policy.declared);
55
+ const lower = (name) => name.toLowerCase();
56
+ const bare = (name) => name.toLowerCase().replace(separators, "");
57
+ const byCase = foldsTo(declared, lower);
58
+ const byShape = foldsTo(declared, bare);
41
59
  return (name) => {
42
- if (typeof name !== "string" || !name) throw new GuardError("Missing upstream tool name");
60
+ if (typeof name !== "string" || !name) return null;
43
61
  if (declared.has(name)) return name;
44
- const match = injected.has(name) && folded.get(name.toLowerCase());
45
- if (match) return match;
46
- throw new GuardError("Upstream called a tool not declared by the client");
62
+ const match =
63
+ byCase.get(lower(name)) ?? (byCase.has(lower(name)) ? null : byShape.get(bare(name)));
64
+ return match || null;
47
65
  };
48
66
  }
49
67
 
50
- function checkFunction(value, checkName) {
51
- if (!object(value)) throw new GuardError("Invalid upstream function call");
52
- const name = checkName(value.name);
53
- const changed = name !== value.name;
54
- value.name = name;
55
- return changed;
68
+ // The models also call tools no agent has: bedit, bcp, ash, a mangled
69
+ // mcp__netcatcatty__unused (#76). Such a call can't be run or named onto
70
+ // one the agent has, and failing the reply for it threw away the whole
71
+ // turn. It is left out instead, and the reply says so in its text, where
72
+ // the agent's user and the model's next turn both read it. A reply left
73
+ // with no call ends as a plain stop.
74
+ export function droppedNote(name) {
75
+ return typeof name === "string" && name
76
+ ? `(The model called a tool this agent doesn't have: ${JSON.stringify(name.slice(0, 128))}. The call was left out.)`
77
+ : "(The model called a tool without a name. The call was left out.)";
78
+ }
79
+
80
+ function appendNotes(message, notes) {
81
+ const text = notes.join("\n\n");
82
+ if (typeof message.content === "string" && message.content) {
83
+ message.content = `${message.content}\n\n${text}`;
84
+ } else if (Array.isArray(message.content)) {
85
+ message.content.push({ type: "text", text });
86
+ } else {
87
+ message.content = text;
88
+ }
56
89
  }
57
90
 
91
+ // checkChatMessage renames message's calls to the agent's tools and leaves
92
+ // out the rest, answering the notes for those left out.
58
93
  function checkChatMessage(message, checkName) {
59
- if (!object(message)) return false;
60
- let changed = false;
61
- if (message.function_call != null) changed = checkFunction(message.function_call, checkName);
62
- for (const call of list(message.tool_calls)) {
63
- if (!object(call) || (call.type != null && call.type !== "function")) {
64
- throw new GuardError("Unsupported upstream tool call");
94
+ const result = { changed: false, notes: [] };
95
+ if (!object(message)) return result;
96
+ if (message.function_call != null) {
97
+ const value = message.function_call;
98
+ if (!object(value)) throw new GuardError("Invalid upstream function call");
99
+ const name = checkName(value.name);
100
+ if (name == null) {
101
+ result.notes.push(droppedNote(value.name));
102
+ delete message.function_call;
103
+ result.changed = true;
104
+ } else if (name !== value.name) {
105
+ value.name = name;
106
+ result.changed = true;
107
+ }
108
+ }
109
+ if (message.tool_calls != null) {
110
+ const calls = list(message.tool_calls);
111
+ const kept = [];
112
+ for (const call of calls) {
113
+ if (!object(call) || (call.type != null && call.type !== "function")) {
114
+ throw new GuardError("Unsupported upstream tool call");
115
+ }
116
+ if (!object(call.function)) throw new GuardError("Invalid upstream function call");
117
+ const name = checkName(call.function.name);
118
+ if (name == null) {
119
+ result.notes.push(droppedNote(call.function.name));
120
+ result.changed = true;
121
+ continue;
122
+ }
123
+ if (name !== call.function.name) {
124
+ call.function.name = name;
125
+ result.changed = true;
126
+ }
127
+ kept.push(call);
128
+ }
129
+ if (kept.length !== calls.length) {
130
+ if (kept.length) message.tool_calls = kept;
131
+ else delete message.tool_calls;
65
132
  }
66
- changed = checkFunction(call.function, checkName) || changed;
67
133
  }
68
- return changed;
134
+ return result;
69
135
  }
70
136
 
137
+ const callFinish = new Set(["tool_calls", "function_call"]);
138
+ function hasCalls(message) {
139
+ return message.function_call != null || (message.tool_calls?.length ?? 0) > 0;
140
+ }
141
+
142
+ function isCall(protocol, value) {
143
+ return (
144
+ (protocol === "responses" && value.type === "function_call") ||
145
+ (protocol === "anthropic" && ["tool_use", "server_tool_use"].includes(value.type))
146
+ );
147
+ }
148
+
149
+ // What a call left out of a Responses or Messages reply becomes: text
150
+ // saying so, in the call's place, so no later index moves.
151
+ function inPlaceOf(protocol, call, note) {
152
+ if (protocol === "anthropic") return { type: "text", text: note };
153
+ return {
154
+ type: "message",
155
+ id: typeof call.id === "string" && call.id ? call.id : `msg_${call.call_id ?? "dropped"}`,
156
+ role: "assistant",
157
+ status: call.status ?? "completed",
158
+ content: [{ type: "output_text", text: note, annotations: [] }],
159
+ };
160
+ }
161
+
162
+ // checkSnapshots checks every call in a reply or event: renamed to the
163
+ // agent's tool, or (Responses, Messages) put in place of by a note. It
164
+ // answers whether data changed, how many calls it kept and how many it
165
+ // left out.
71
166
  function checkSnapshots(data, protocol, checkName) {
72
167
  let changed = false;
73
168
  if (protocol === "chat") {
74
169
  for (const choice of list(data.choices)) {
75
170
  if (!object(choice)) throw new GuardError("Invalid upstream choice");
76
- changed = checkChatMessage(choice.message, checkName) || changed;
171
+ const message = choice.message;
172
+ const checked = checkChatMessage(message, checkName);
173
+ changed = checked.changed || changed;
174
+ if (checked.notes.length) {
175
+ appendNotes(message, checked.notes);
176
+ if (!hasCalls(message) && callFinish.has(choice.finish_reason))
177
+ choice.finish_reason = "stop";
178
+ }
77
179
  }
78
- return changed;
180
+ return { changed, kept: 0, left: 0 };
79
181
  }
80
- const stack = [data];
182
+ let kept = 0;
183
+ let left = 0;
184
+ if (!object(data)) throw new GuardError("Invalid upstream tool snapshot");
185
+ // Each entry is a value and where it sits, so a call can be replaced.
186
+ const stack = [[data, null, null]];
81
187
  while (stack.length) {
82
- const value = stack.pop();
188
+ const [value, holder, key] = stack.pop();
83
189
  if (!object(value)) throw new GuardError("Invalid upstream tool snapshot");
84
190
  if (
85
191
  protocol === "responses" &&
@@ -89,25 +195,44 @@ function checkSnapshots(data, protocol, checkName) {
89
195
  ) {
90
196
  throw new GuardError("Unsupported upstream tool call type");
91
197
  }
92
- if (
93
- (protocol === "responses" && value.type === "function_call") ||
94
- (protocol === "anthropic" && ["tool_use", "server_tool_use"].includes(value.type))
95
- ) {
96
- changed = checkFunction(value, checkName) || changed;
198
+ if (isCall(protocol, value)) {
199
+ const name = checkName(value.name);
200
+ if (name == null) {
201
+ if (!holder) throw new GuardError(droppedNote(value.name));
202
+ holder[key] = inPlaceOf(protocol, value, droppedNote(value.name));
203
+ changed = true;
204
+ left++;
205
+ continue;
206
+ }
207
+ if (name !== value.name) {
208
+ value.name = name;
209
+ changed = true;
210
+ }
211
+ kept++;
97
212
  }
98
213
  const arrays = protocol === "responses" ? ["output", "content"] : ["content"];
99
214
  const objects =
100
215
  protocol === "responses" ? ["response", "item", "part"] : ["message", "content_block"];
101
- for (const key of arrays) {
216
+ for (const name of arrays) {
102
217
  // Text content is not a snapshot container.
103
- if (typeof value[key] === "string") continue;
104
- for (const item of list(value[key])) stack.push(item);
218
+ if (typeof value[name] === "string") continue;
219
+ const items = list(value[name]);
220
+ for (let i = 0; i < items.length; i++) stack.push([items[i], items, i]);
105
221
  }
106
- for (const key of objects) {
107
- if (value[key] != null) stack.push(value[key]);
222
+ for (const name of objects) {
223
+ if (value[name] != null) stack.push([value[name], value, name]);
108
224
  }
109
225
  }
110
- return changed;
226
+ if (
227
+ protocol === "anthropic" &&
228
+ left &&
229
+ !kept &&
230
+ data.type === "message" &&
231
+ data.stop_reason === "tool_use"
232
+ ) {
233
+ data.stop_reason = "end_turn";
234
+ }
235
+ return { changed, kept, left };
111
236
  }
112
237
 
113
238
  function position(value) {
@@ -163,13 +288,24 @@ function chatBuffer(checkName) {
163
288
  }
164
289
  return pending.get(index);
165
290
  }
166
- function complete(state) {
291
+ // Choices that streamed text, and those whose every call was left out
292
+ // (whose later "tool_calls" finish is a stop).
293
+ const wrote = new Set();
294
+ const ended = new Set();
295
+ function complete(state, index) {
167
296
  const delta = {};
168
297
  if (state.calls.size) {
169
298
  delta.tool_calls = [...state.calls].sort(([a], [b]) => a - b).map(([, value]) => value);
170
299
  }
171
300
  if (state.legacy) delta.function_call = state.legacy;
172
- checkChatMessage(delta, checkName);
301
+ const { notes } = checkChatMessage(delta, checkName);
302
+ if (notes.length) {
303
+ delta.tool_calls?.forEach((call, i) => {
304
+ call.index = i;
305
+ });
306
+ delta.content = (wrote.has(index) ? "\n\n" : "") + notes.join("\n\n");
307
+ if (!hasCalls(delta)) ended.add(index);
308
+ }
173
309
  return delta;
174
310
  }
175
311
  function remove(index) {
@@ -186,11 +322,12 @@ function chatBuffer(checkName) {
186
322
  bytes = 0;
187
323
  },
188
324
  apply(data) {
189
- let changed = checkSnapshots(data, "chat", checkName);
325
+ let { changed } = checkSnapshots(data, "chat", checkName);
190
326
  const choices = list(data.choices);
191
327
  for (const choice of choices) {
192
328
  const delta = choice.delta;
193
329
  if (!object(delta)) continue;
330
+ if (typeof delta.content === "string" && delta.content) wrote.add(position(choice.index));
194
331
  if (delta.tool_calls == null && delta.function_call == null) continue;
195
332
  changed = true;
196
333
  const state = stateFor(position(choice.index), data);
@@ -219,10 +356,20 @@ function chatBuffer(checkName) {
219
356
  const index = position(choice.index);
220
357
  const state = pending.get(index);
221
358
  if (!state) continue;
222
- choice.delta = { ...choice.delta, ...complete(state) };
359
+ const own = object(choice.delta) ? choice.delta : {};
360
+ const delta = complete(state, index);
361
+ if (typeof own.content === "string" && delta.content)
362
+ delta.content = own.content + delta.content;
363
+ choice.delta = { ...own, ...delta };
223
364
  remove(index);
224
365
  changed = true;
225
366
  }
367
+ for (const choice of choices) {
368
+ if (callFinish.has(choice.finish_reason) && ended.has(position(choice.index))) {
369
+ choice.finish_reason = "stop";
370
+ changed = true;
371
+ }
372
+ }
226
373
  if (!changed) return { data, changed: false };
227
374
  data.choices = choices.filter(
228
375
  (choice) =>
@@ -236,7 +383,7 @@ function chatBuffer(checkName) {
236
383
  if (!pending.size) return null;
237
384
  const choices = [...pending]
238
385
  .sort(([a], [b]) => a - b)
239
- .map(([index, state]) => ({ index, delta: complete(state), finish_reason: null }));
386
+ .map(([index, state]) => ({ index, delta: complete(state, index), finish_reason: null }));
240
387
  const metadata = pending.values().next().value.metadata;
241
388
  pending.clear();
242
389
  bytes = 0;
@@ -245,6 +392,99 @@ function chatBuffer(checkName) {
245
392
  };
246
393
  }
247
394
 
395
+ // snapshotStream checks a Responses or Messages stream event by event. A
396
+ // call left out is streamed as text in its place: its item or block starts
397
+ // as text carrying the note, and its arguments are never sent. A message
398
+ // whose every call was left out stops as a turn's end, not as tool use.
399
+ function snapshotStream(protocol, checkName) {
400
+ const left = new Map(); // output_index or content block index → true
401
+ const leftIDs = new Set(); // Responses item ids left out
402
+ let kept = 0;
403
+ let dropped = 0;
404
+ return {
405
+ apply(data, type) {
406
+ if (protocol === "responses") {
407
+ const item = data.item;
408
+ if (
409
+ type === "response.output_item.added" &&
410
+ object(item) &&
411
+ item.type === "function_call" &&
412
+ checkName(item.name) == null
413
+ ) {
414
+ const note = droppedNote(item.name);
415
+ const message = inPlaceOf(protocol, item, note);
416
+ left.set(data.output_index, true);
417
+ leftIDs.add(message.id);
418
+ if (typeof item.id === "string") leftIDs.add(item.id);
419
+ dropped++;
420
+ const where = { item_id: message.id, output_index: data.output_index, content_index: 0 };
421
+ const part = { type: "output_text", text: "", annotations: [] };
422
+ return {
423
+ changed: true,
424
+ data: { ...data, item: { ...message, status: "in_progress", content: [] } },
425
+ extra: [
426
+ { type: "response.content_part.added", ...where, part },
427
+ { type: "response.output_text.delta", ...where, delta: note },
428
+ { type: "response.output_text.done", ...where, text: note },
429
+ { type: "response.content_part.done", ...where, part: { ...part, text: note } },
430
+ ],
431
+ };
432
+ }
433
+ if (
434
+ typeof type === "string" &&
435
+ type.startsWith("response.function_call_arguments.") &&
436
+ ((data.output_index != null && left.has(data.output_index)) || leftIDs.has(data.item_id))
437
+ ) {
438
+ return { changed: true, data: null };
439
+ }
440
+ } else {
441
+ if (type === "message_start") {
442
+ left.clear();
443
+ kept = 0;
444
+ dropped = 0;
445
+ }
446
+ const block = data.content_block;
447
+ if (
448
+ type === "content_block_start" &&
449
+ object(block) &&
450
+ isCall(protocol, block) &&
451
+ checkName(block.name) == null
452
+ ) {
453
+ left.set(data.index, true);
454
+ dropped++;
455
+ return {
456
+ changed: true,
457
+ data: { ...data, content_block: { type: "text", text: "" } },
458
+ extra: [
459
+ {
460
+ type: "content_block_delta",
461
+ index: data.index,
462
+ delta: { type: "text_delta", text: droppedNote(block.name) },
463
+ },
464
+ ],
465
+ };
466
+ }
467
+ if (type === "content_block_delta" && left.has(data.index))
468
+ return { changed: true, data: null };
469
+ if (
470
+ type === "message_delta" &&
471
+ object(data.delta) &&
472
+ data.delta.stop_reason === "tool_use" &&
473
+ dropped &&
474
+ !kept
475
+ ) {
476
+ data.delta.stop_reason = "end_turn";
477
+ return { changed: true, data };
478
+ }
479
+ }
480
+ const checked = checkSnapshots(data, protocol, checkName);
481
+ kept += checked.kept;
482
+ dropped += checked.left;
483
+ return { changed: checked.changed, data };
484
+ },
485
+ };
486
+ }
487
+
248
488
  function parseJSON(text) {
249
489
  let value;
250
490
  try {
@@ -384,9 +624,89 @@ async function* framesFrom(reader) {
384
624
  }
385
625
  }
386
626
 
387
- function guardedStream(response, protocol, checkName) {
388
- const reader = response.body?.getReader();
627
+ // A Chat stream Zen closed before its [DONE] (#91: space-bunny-free, about
628
+ // 0.1% of turns) is ended as a turn cut short when it has said something
629
+ // and no tool call of it is pending: each choice still open gets a note
630
+ // saying so and a stop, then [DONE]. Failing it threw away the reasoning
631
+ // and text the agent already had, and a Chat reply through Zen can't be
632
+ // asked to go on from there. A pending call's arguments may be half
633
+ // written, and a reply that said nothing is an empty one, so both still
634
+ // fail, as does one collapsed for a client that doesn't stream, which can
635
+ // still be asked again. Anthropic and Responses streams cut short fail
636
+ // too: magpie's gateway tells such a reply from a whole one itself
637
+ // (#370), and goes on with it where the upstream can (an Anthropic one),
638
+ // which a made-up end here would stop.
639
+ export const cutShortNote =
640
+ "(The reply was cut short: Zen closed the stream before it finished. Ask again to go on.)";
641
+
642
+ function chatTrack() {
643
+ const open = new Set();
644
+ const finished = new Set();
645
+ const wrote = new Set();
646
+ let said = false;
647
+ let meta = null;
648
+ return {
649
+ see(data) {
650
+ if (!object(data)) return;
651
+ meta = {
652
+ id: data.id ?? meta?.id,
653
+ created: data.created ?? meta?.created,
654
+ model: data.model ?? meta?.model,
655
+ };
656
+ for (const choice of Array.isArray(data.choices) ? data.choices : []) {
657
+ if (!object(choice)) continue;
658
+ const index = position(choice.index);
659
+ const delta = object(choice.delta) ? choice.delta : {};
660
+ for (const key of ["content", "reasoning_content", "reasoning"]) {
661
+ if (typeof delta[key] === "string" && delta[key]) said = true;
662
+ }
663
+ if (typeof delta.content === "string" && delta.content) wrote.add(index);
664
+ if (choice.finish_reason != null) {
665
+ finished.add(index);
666
+ open.delete(index);
667
+ } else if (!finished.has(index)) open.add(index);
668
+ }
669
+ },
670
+ // ending answers the frames that end the reply as cut short, or null
671
+ // when it can't be.
672
+ ending() {
673
+ if (!said) return null;
674
+ const choices = [...open]
675
+ .sort((a, b) => a - b)
676
+ .map((index) => ({
677
+ index,
678
+ delta: { content: (wrote.has(index) ? "\n\n" : "") + cutShortNote },
679
+ finish_reason: "stop",
680
+ }));
681
+ const head = {
682
+ id: meta?.id ?? "chatcmpl-cut-short",
683
+ object: "chat.completion.chunk",
684
+ created: meta?.created ?? Math.floor(Date.now() / 1000),
685
+ model: meta?.model ?? "",
686
+ };
687
+ return (choices.length ? encodeEvent({ ...head, choices }) : "") + "data: [DONE]\n\n";
688
+ },
689
+ };
690
+ }
691
+
692
+ function guardedStream(response, protocol, checkName, endCutShort) {
693
+ const source = response.body?.getReader();
694
+ // What came before a stream cut short, for its error to say (#91).
695
+ let bytes = 0;
696
+ let events = 0;
697
+ let replyID;
698
+ const reader = source && {
699
+ async read() {
700
+ const result = await source.read();
701
+ if (result.value) bytes += result.value.byteLength;
702
+ return result;
703
+ },
704
+ releaseLock: () => source.releaseLock(),
705
+ cancel: (reason) => source.cancel(reason),
706
+ };
389
707
  const chat = protocol === "chat" ? chatBuffer(checkName) : null;
708
+ const track = chat && endCutShort ? chatTrack() : null;
709
+ const snapshots = chat ? null : snapshotStream(protocol, checkName);
390
710
  let frames = reader ? framesFrom(reader) : null;
391
711
  let completed = false;
392
712
  let stopped = false;
@@ -418,8 +738,25 @@ function guardedStream(response, protocol, checkName) {
418
738
  const next = frames ? await frames.next() : { done: true };
419
739
  if (stopped) return;
420
740
  if (next.done) {
421
- if (!completed || chat?.size)
422
- throw new GuardError("Upstream SSE ended without a completion marker");
741
+ const ending = !completed && !chat?.size ? track?.ending() : null;
742
+ if (ending) {
743
+ stopped = true;
744
+ release();
745
+ controller.enqueue(encoder.encode(ending));
746
+ controller.close();
747
+ return;
748
+ }
749
+ if (!completed || chat?.size) {
750
+ const came = [
751
+ replyID ? `reply ${String(replyID).slice(0, 128)}` : "",
752
+ `${events} event${events === 1 ? "" : "s"}`,
753
+ `${bytes} bytes received`,
754
+ chat?.size ? "a tool call unfinished" : "",
755
+ ].filter(Boolean);
756
+ throw new GuardError(
757
+ `Upstream SSE ended without a completion marker (${came.join(", ")})`,
758
+ );
759
+ }
423
760
  stopped = true;
424
761
  release();
425
762
  controller.close();
@@ -438,8 +775,10 @@ function guardedStream(response, protocol, checkName) {
438
775
  controller.enqueue(encoder.encode((final ? encodeEvent(final) : "") + frame.raw));
439
776
  return;
440
777
  }
778
+ events++;
441
779
  const data = frame.name === "error" ? null : parseJSON(frame.text);
442
780
  const type = data?.type ?? frame.name;
781
+ replyID = data?.id ?? data?.message?.id ?? data?.response?.id ?? replyID;
443
782
  if (
444
783
  frame.name === "error" ||
445
784
  type === "error" ||
@@ -458,23 +797,19 @@ function guardedStream(response, protocol, checkName) {
458
797
  controller.close();
459
798
  return;
460
799
  }
461
- let changed;
462
- let result = data;
463
- if (chat) {
464
- const checked = chat.apply(data);
465
- changed = checked.changed;
466
- result = checked.data;
467
- } else {
468
- changed = checkSnapshots(data, protocol, checkName);
469
- }
800
+ const checked = chat ? chat.apply(data) : snapshots.apply(data, type);
801
+ const { changed, data: result, extra = [] } = checked;
802
+ if (result) track?.see(result);
470
803
  if (
471
804
  (protocol === "responses" &&
472
805
  ["response.completed", "response.incomplete"].includes(type)) ||
473
806
  (protocol === "anthropic" && type === "message_stop")
474
807
  )
475
808
  completed = true;
476
- if (result) {
477
- controller.enqueue(encoder.encode(changed ? rewrite(frame, result) : frame.raw));
809
+ if (result || extra.length) {
810
+ const own = !result ? "" : changed ? rewrite(frame, result) : frame.raw;
811
+ const added = extra.map((value) => encodeEvent(value, value.type)).join("");
812
+ controller.enqueue(encoder.encode(own + added));
478
813
  return;
479
814
  }
480
815
  }
@@ -512,7 +847,11 @@ function guardedStream(response, protocol, checkName) {
512
847
  );
513
848
  }
514
849
 
515
- export async function guardToolResponse(response, protocol, policy) {
850
+ // options.endCutShort lets a Chat stream cut short end as one (#91): for a
851
+ // client that streams, which has the reply's start already. A reply
852
+ // collapsed for a client that doesn't stream fails instead, so it can be
853
+ // asked again before anything reached the client.
854
+ export async function guardToolResponse(response, protocol, policy, options = {}) {
516
855
  if (!response.ok) return response;
517
856
  if (!["chat", "responses", "anthropic"].includes(protocol))
518
857
  throw new TypeError(`Unsupported protocol: ${protocol}`);
@@ -520,7 +859,7 @@ export async function guardToolResponse(response, protocol, policy) {
520
859
  const headers = headersFor(response);
521
860
  const contentType = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
522
861
  if (contentType === "text/event-stream") {
523
- return new Response(guardedStream(response, protocol, checkName), {
862
+ return new Response(guardedStream(response, protocol, checkName, options.endCutShort === true), {
524
863
  status: response.status,
525
864
  statusText: response.statusText,
526
865
  headers,