@magpie-community/opencode-zen-free-auth 0.1.1 → 0.1.9

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,12 +28,21 @@ 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. A model Zen lists as `-free` that OpenCode's catalog and docs don't
35
+ describe yet (or can't be read) is offered too, on Chat Completions, as
36
+ OpenCode asks a model with no package of its own. When Zen's list can't be
37
+ read, the last list is kept. A request for a
38
+ model that isn't free now gets a 404 naming the free ones; one made free
39
+ since the last list is served after the list is asked again (at most once a
40
+ minute). Availability and rate limits are set by Zen; the plugin has no
41
+ remaining-quota data.
27
42
 
28
43
  ## Requests
29
44
 
30
- Requests go to `https://opencode.ai/zen/v1` using each model's native
45
+ Requests go to `https://opencode.ai/inference/v1` using each model's native
31
46
  Chat Completions, Responses or Anthropic Messages API. Streaming and
32
47
  ordinary JSON replies are supported.
33
48
 
@@ -35,5 +50,21 @@ The agent's tools are preserved. Zen's models often call OpenCode's own
35
50
  tool names; such a call is given the agent's tool whose name differs only
36
51
  in case (`bash` to `Bash`), or else only in case and `_`/`-` (`todowrite`
37
52
  to `todo_write`, `webfetch` to `web_fetch`), when exactly one does. A
38
- call with no such tool returns an error that names it. Tools run in the
39
- agent.
53
+ call with no such tool (or with no name) is left out of the reply, and
54
+ the reply's text says which name was left out; a turn whose every call
55
+ was left out ends as an ordinary answer. Tools run in the agent.
56
+
57
+ Zen's free tier only answers a request that carries tools, so a request
58
+ is sent with the five core OpenCode tools the agent doesn't declare
59
+ itself (`bash`, `edit`, `glob`, `grep`, `read`), described as not
60
+ available. A call of one of them the agent doesn't have is left out like
61
+ any other.
62
+
63
+ Now and then Zen closes a streamed Chat Completions reply before its
64
+ `[DONE]`. When the reply has said something and no tool call of it is
65
+ unfinished, it ends there as an ordinary answer, and its text says it was
66
+ cut short; asking again goes on. A reply cut short with a tool call
67
+ unfinished, with nothing said, or collapsed for a client that doesn't
68
+ stream fails with an error naming the reply's id and how much of it came.
69
+ An Anthropic Messages or Responses reply cut short fails too: magpie
70
+ tells it from a whole one and, where the model can, goes on with it.
package/models.mjs CHANGED
@@ -41,13 +41,31 @@ export function isFree(id, model = {}) {
41
41
  );
42
42
  }
43
43
 
44
+ // A model Zen lists whose id says it is free (Zen names them "-free"), and
45
+ // that neither the catalog nor the docs describe yet: Zen made it free
46
+ // after they were last written (deepseek-v4.1-flash-free, yetone/magpie#2385),
47
+ // or they couldn't be read. It is asked as OpenCode asks a model with no
48
+ // package of its own, through the provider's (openai-compatible, chat), so
49
+ // a new free model is offered the hour Zen lists it, not when the catalog
50
+ // or this plugin catch up. A paid or zero-cost id the id doesn't call free
51
+ // still needs the catalog's word.
52
+ function listedFree(id) {
53
+ return /-free$/.test(id);
54
+ }
55
+
44
56
  export function buildModels(ids, provider, protocols, baseURL) {
45
57
  const result = {};
46
58
  for (const id of ids) {
47
59
  if (typeof id !== "string" || !id || id === "__proto__") continue;
48
60
  const raw = provider.models?.[id];
49
61
  const model = raw ?? {};
50
- const protocol = protocols[id] ?? (raw && sdkProtocol(model.provider?.npm ?? provider.npm));
62
+ const protocol =
63
+ protocols[id] ??
64
+ (raw
65
+ ? sdkProtocol(model.provider?.npm ?? provider.npm)
66
+ : listedFree(id)
67
+ ? (sdkProtocol(provider.npm) ?? "chat")
68
+ : undefined);
51
69
  if (!isFree(id, model) || !Object.hasOwn(SDK, protocol)) continue;
52
70
  const input = model.modalities?.input ?? ["text"];
53
71
  const output = model.modalities?.output ?? ["text"];
@@ -85,10 +103,73 @@ export function buildModels(ids, provider, protocols, baseURL) {
85
103
  return result;
86
104
  }
87
105
 
88
- // Offline bootstrap metadata from the OpenCode catalog, 2026-10-06.
106
+ // What each free model is asked at, from OpenCode's console docs
107
+ // (services/www/src/docs/content/console/models.mdx) on 2026-10-15: used for
108
+ // a listed model when the docs can't be read, so the catalog or the docs
109
+ // being out of reach never leaves a listed free model out. Jev is a
110
+ // SystemOne decision model, not one to chat with.
111
+ export const bootstrapProtocols = {
112
+ "big-pickle": "chat",
113
+ "space-bunny-free": "chat",
114
+ "cat-jam-free": "chat",
115
+ "longcat-2.5-preview-free": "chat",
116
+ "step-5-preview-free": "chat",
117
+ "mimo-v2.6-flash-free": "chat",
118
+ "nemotron-3.5-lightning-free": "chat",
119
+ "muse-spark-1.3-contributor-free": "responses",
120
+ "jev-1.13-free": "systemone",
121
+ };
122
+
123
+ // Offline bootstrap metadata from the OpenCode catalog, 2026-10-06; the
124
+ // free models listed since added on 2026-10-15.
89
125
  export const bootstrapProvider = {
90
126
  npm: SDK.chat,
91
127
  models: {
128
+ "cat-jam-free": {
129
+ name: "Cat Jam Free",
130
+ cost: { input: 0, output: 0 },
131
+ limit: { context: 262144, output: 131072 },
132
+ reasoning: true,
133
+ tool_call: true,
134
+ interleaved: { field: "reasoning_content" },
135
+ reasoning_options: [{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }],
136
+ },
137
+ "step-5-preview-free": {
138
+ name: "Step 5 Preview Free",
139
+ cost: { input: 0, output: 0 },
140
+ limit: { context: 1000000, input: 1000000, output: 65536 },
141
+ reasoning: true,
142
+ tool_call: true,
143
+ interleaved: { field: "reasoning_content" },
144
+ modalities: { input: ["text", "image", "video"], output: ["text"] },
145
+ reasoning_options: [{ type: "effort", values: ["low", "medium", "high"] }],
146
+ },
147
+ "longcat-2.5-preview-free": {
148
+ name: "LongCat 2.5 Preview Free",
149
+ cost: { input: 0, output: 0 },
150
+ limit: { context: 1000000, output: 131072 },
151
+ reasoning: true,
152
+ tool_call: true,
153
+ interleaved: { field: "reasoning_content" },
154
+ modalities: { input: ["text", "image"], output: ["text"] },
155
+ },
156
+ "mimo-v2.6-flash-free": {
157
+ name: "MiMo-V2.6-Flash Free",
158
+ cost: { input: 0, output: 0 },
159
+ limit: { context: 200000, output: 32000 },
160
+ reasoning: true,
161
+ tool_call: true,
162
+ interleaved: { field: "reasoning_content" },
163
+ modalities: { input: ["text", "image", "audio", "video"], output: ["text"] },
164
+ },
165
+ "nemotron-3.5-lightning-free": {
166
+ name: "Nemotron 3.5 Lightning Free",
167
+ cost: { input: 0, output: 0 },
168
+ limit: { context: 262144, output: 262144 },
169
+ reasoning: true,
170
+ tool_call: true,
171
+ interleaved: { field: "reasoning_content" },
172
+ },
92
173
  "big-pickle": {
93
174
  name: "Big Pickle",
94
175
  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.1",
3
+ "version": "0.1.9",
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
  }
@@ -156,10 +228,15 @@ async function server(_input, options = {}) {
156
228
  discovered[id] = known[id];
157
229
  }
158
230
  }
159
- discovered[Symbol.for("magpie.fellBack")] = true;
231
+ // Not said as fallen back: Zen's own list was read, and what it
232
+ // lists is what is offered. Said so, magpie kept the last list read
233
+ // in full instead, and a user who can't reach the catalog or the
234
+ // docs (GitHub's raw host, from mainland China) never saw a model
235
+ // Zen made free since (yetone/magpie#2385).
160
236
  }
161
237
  known = discovered;
162
238
  listed = true;
239
+ listedAt = Date.now();
163
240
  return discovered;
164
241
  }
165
242
 
@@ -198,7 +275,9 @@ async function server(_input, options = {}) {
198
275
  requirePublic(await getAuth());
199
276
  const request = new Request(input, init);
200
277
  const protocol = Object.keys(PATHS).find(
201
- (key) => request.url === `${baseURL}${PATHS[key]}`,
278
+ (key) =>
279
+ request.url === `${baseURL}${PATHS[key]}` ||
280
+ (baseURL === DEFAULT_BASE && request.url === `${LEGACY_BASE}${PATHS[key]}`),
202
281
  );
203
282
  if (!protocol || request.method !== "POST")
204
283
  throw new Error("Unsupported Zen endpoint or method");
@@ -206,11 +285,31 @@ async function server(_input, options = {}) {
206
285
  const body = await request.json();
207
286
  if (!body || typeof body !== "object" || Array.isArray(body))
208
287
  throw new Error("Expected a JSON request object");
209
- if (!listed && !Object.hasOwn(known, body.model)) {
288
+ if (
289
+ !Object.hasOwn(known, body.model) &&
290
+ (!listed || Date.now() - listedAt >= RELIST_AFTER)
291
+ ) {
210
292
  await discover(request.signal);
211
293
  }
212
- const model = known[body.model];
213
- if (!model || model.api.npm !== SDK[protocol]) {
294
+ const model = Object.hasOwn(known, body.model) ? known[body.model] : undefined;
295
+ if (!model) {
296
+ // Zen no longer gives it free, or never did: said as a model
297
+ // not served, so the caller sets this model aside and not the
298
+ // account, whose other free models are still served.
299
+ const free = Object.keys(known).sort().join(", ");
300
+ const name = typeof body.model === "string" ? body.model : "";
301
+ return new Response(
302
+ JSON.stringify({
303
+ type: "error",
304
+ error: {
305
+ type: "not_found_error",
306
+ message: `Model ${name} is not available as an OpenCode Zen free model now. Free models: ${free || "none"}.`,
307
+ },
308
+ }),
309
+ { status: 404, headers: { "content-type": "application/json" } },
310
+ );
311
+ }
312
+ if (model.api.npm !== SDK[protocol]) {
214
313
  throw new Error(
215
314
  "Model is not an available free model for this protocol. Refresh the model list.",
216
315
  );
@@ -220,47 +319,42 @@ async function server(_input, options = {}) {
220
319
  const toolPolicy = normalizeTools(body, protocol);
221
320
  if (protocol === "chat")
222
321
  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);
322
+ // Only what OpenCode 1.19.0 itself sends to its gateway, so a
323
+ // caller's own headers (an agent's User-Agent, x-stainless-*,
324
+ // x-session-affinity, ...) never mark the request as not
325
+ // OpenCode's (packages/opencode/src/session/llm/request.ts).
326
+ const given = request.headers;
327
+ const session = sessionID(given, body);
328
+ const project = _input?.project;
329
+ const headers = new Headers({
330
+ "content-type": "application/json",
331
+ accept: "*/*",
332
+ "user-agent": userAgent(protocol),
333
+ "x-opencode-project":
334
+ given.get("x-opencode-project") ||
335
+ (project?.vcs === "git" && project.id ? project.id : "global"),
336
+ "x-opencode-session": session,
337
+ "x-opencode-request": opencodeID("msg", false),
338
+ "x-opencode-client": "cli",
339
+ "x-opencode-session-id": session,
340
+ });
250
341
  if (protocol === "anthropic") {
251
342
  headers.set("x-api-key", "public");
252
- if (!headers.has("anthropic-version")) headers.set("anthropic-version", "2023-06-01");
343
+ headers.set("anthropic-version", given.get("anthropic-version") || "2023-06-01");
344
+ if (given.get("anthropic-beta")) headers.set("anthropic-beta", given.get("anthropic-beta"));
253
345
  } else {
254
346
  headers.set("authorization", "Bearer public");
255
347
  }
256
- const response = await fetch(request.url, {
348
+ const response = await fetch(`${baseURL}${PATHS[protocol]}`, {
257
349
  method: "POST",
258
350
  headers,
259
351
  body: JSON.stringify(body),
260
352
  signal: request.signal,
261
353
  redirect: "error",
262
354
  });
263
- const guarded = await guardToolResponse(response, protocol, toolPolicy);
355
+ const guarded = await guardToolResponse(response, protocol, toolPolicy, {
356
+ endCutShort: streaming,
357
+ });
264
358
  return streaming ? guarded : collapseResponse(guarded, protocol);
265
359
  },
266
360
  };
package/tool-response.mjs CHANGED
@@ -40,11 +40,16 @@ function foldsTo(declared, key) {
40
40
  const folded = new Map();
41
41
  for (const name of declared) {
42
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;
43
46
  folded.set(k, folded.has(k) ? null : name);
44
47
  }
45
48
  return folded;
46
49
  }
47
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).
48
53
  function nameGuard(policy) {
49
54
  const declared = new Set(policy.declared);
50
55
  const lower = (name) => name.toLowerCase();
@@ -52,49 +57,135 @@ function nameGuard(policy) {
52
57
  const byCase = foldsTo(declared, lower);
53
58
  const byShape = foldsTo(declared, bare);
54
59
  return (name) => {
55
- if (typeof name !== "string" || !name) throw new GuardError("Missing upstream tool name");
60
+ if (typeof name !== "string" || !name) return null;
56
61
  if (declared.has(name)) return name;
57
- const match = byCase.get(lower(name)) ?? (byCase.has(lower(name)) ? null : byShape.get(bare(name)));
58
- if (match) return match;
59
- throw new GuardError(
60
- `Upstream called a tool not declared by the client: ${JSON.stringify(name.slice(0, 128))}`,
61
- );
62
+ const match =
63
+ byCase.get(lower(name)) ?? (byCase.has(lower(name)) ? null : byShape.get(bare(name)));
64
+ return match || null;
62
65
  };
63
66
  }
64
67
 
65
- function checkFunction(value, checkName) {
66
- if (!object(value)) throw new GuardError("Invalid upstream function call");
67
- const name = checkName(value.name);
68
- const changed = name !== value.name;
69
- value.name = name;
70
- 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.)";
71
78
  }
72
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
+ }
89
+ }
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.
73
93
  function checkChatMessage(message, checkName) {
74
- if (!object(message)) return false;
75
- let changed = false;
76
- if (message.function_call != null) changed = checkFunction(message.function_call, checkName);
77
- for (const call of list(message.tool_calls)) {
78
- if (!object(call) || (call.type != null && call.type !== "function")) {
79
- 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;
80
132
  }
81
- changed = checkFunction(call.function, checkName) || changed;
82
133
  }
83
- return changed;
134
+ return result;
135
+ }
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
+ );
84
147
  }
85
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.
86
166
  function checkSnapshots(data, protocol, checkName) {
87
167
  let changed = false;
88
168
  if (protocol === "chat") {
89
169
  for (const choice of list(data.choices)) {
90
170
  if (!object(choice)) throw new GuardError("Invalid upstream choice");
91
- 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
+ }
92
179
  }
93
- return changed;
180
+ return { changed, kept: 0, left: 0 };
94
181
  }
95
- 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]];
96
187
  while (stack.length) {
97
- const value = stack.pop();
188
+ const [value, holder, key] = stack.pop();
98
189
  if (!object(value)) throw new GuardError("Invalid upstream tool snapshot");
99
190
  if (
100
191
  protocol === "responses" &&
@@ -104,25 +195,44 @@ function checkSnapshots(data, protocol, checkName) {
104
195
  ) {
105
196
  throw new GuardError("Unsupported upstream tool call type");
106
197
  }
107
- if (
108
- (protocol === "responses" && value.type === "function_call") ||
109
- (protocol === "anthropic" && ["tool_use", "server_tool_use"].includes(value.type))
110
- ) {
111
- 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++;
112
212
  }
113
213
  const arrays = protocol === "responses" ? ["output", "content"] : ["content"];
114
214
  const objects =
115
215
  protocol === "responses" ? ["response", "item", "part"] : ["message", "content_block"];
116
- for (const key of arrays) {
216
+ for (const name of arrays) {
117
217
  // Text content is not a snapshot container.
118
- if (typeof value[key] === "string") continue;
119
- 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]);
120
221
  }
121
- for (const key of objects) {
122
- 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]);
123
224
  }
124
225
  }
125
- 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 };
126
236
  }
127
237
 
128
238
  function position(value) {
@@ -178,13 +288,24 @@ function chatBuffer(checkName) {
178
288
  }
179
289
  return pending.get(index);
180
290
  }
181
- 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) {
182
296
  const delta = {};
183
297
  if (state.calls.size) {
184
298
  delta.tool_calls = [...state.calls].sort(([a], [b]) => a - b).map(([, value]) => value);
185
299
  }
186
300
  if (state.legacy) delta.function_call = state.legacy;
187
- 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
+ }
188
309
  return delta;
189
310
  }
190
311
  function remove(index) {
@@ -201,11 +322,12 @@ function chatBuffer(checkName) {
201
322
  bytes = 0;
202
323
  },
203
324
  apply(data) {
204
- let changed = checkSnapshots(data, "chat", checkName);
325
+ let { changed } = checkSnapshots(data, "chat", checkName);
205
326
  const choices = list(data.choices);
206
327
  for (const choice of choices) {
207
328
  const delta = choice.delta;
208
329
  if (!object(delta)) continue;
330
+ if (typeof delta.content === "string" && delta.content) wrote.add(position(choice.index));
209
331
  if (delta.tool_calls == null && delta.function_call == null) continue;
210
332
  changed = true;
211
333
  const state = stateFor(position(choice.index), data);
@@ -234,10 +356,20 @@ function chatBuffer(checkName) {
234
356
  const index = position(choice.index);
235
357
  const state = pending.get(index);
236
358
  if (!state) continue;
237
- 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 };
238
364
  remove(index);
239
365
  changed = true;
240
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
+ }
241
373
  if (!changed) return { data, changed: false };
242
374
  data.choices = choices.filter(
243
375
  (choice) =>
@@ -251,7 +383,7 @@ function chatBuffer(checkName) {
251
383
  if (!pending.size) return null;
252
384
  const choices = [...pending]
253
385
  .sort(([a], [b]) => a - b)
254
- .map(([index, state]) => ({ index, delta: complete(state), finish_reason: null }));
386
+ .map(([index, state]) => ({ index, delta: complete(state, index), finish_reason: null }));
255
387
  const metadata = pending.values().next().value.metadata;
256
388
  pending.clear();
257
389
  bytes = 0;
@@ -260,6 +392,99 @@ function chatBuffer(checkName) {
260
392
  };
261
393
  }
262
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
+
263
488
  function parseJSON(text) {
264
489
  let value;
265
490
  try {
@@ -399,9 +624,89 @@ async function* framesFrom(reader) {
399
624
  }
400
625
  }
401
626
 
402
- function guardedStream(response, protocol, checkName) {
403
- 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
+ };
404
707
  const chat = protocol === "chat" ? chatBuffer(checkName) : null;
708
+ const track = chat && endCutShort ? chatTrack() : null;
709
+ const snapshots = chat ? null : snapshotStream(protocol, checkName);
405
710
  let frames = reader ? framesFrom(reader) : null;
406
711
  let completed = false;
407
712
  let stopped = false;
@@ -433,8 +738,25 @@ function guardedStream(response, protocol, checkName) {
433
738
  const next = frames ? await frames.next() : { done: true };
434
739
  if (stopped) return;
435
740
  if (next.done) {
436
- if (!completed || chat?.size)
437
- 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
+ }
438
760
  stopped = true;
439
761
  release();
440
762
  controller.close();
@@ -453,8 +775,10 @@ function guardedStream(response, protocol, checkName) {
453
775
  controller.enqueue(encoder.encode((final ? encodeEvent(final) : "") + frame.raw));
454
776
  return;
455
777
  }
778
+ events++;
456
779
  const data = frame.name === "error" ? null : parseJSON(frame.text);
457
780
  const type = data?.type ?? frame.name;
781
+ replyID = data?.id ?? data?.message?.id ?? data?.response?.id ?? replyID;
458
782
  if (
459
783
  frame.name === "error" ||
460
784
  type === "error" ||
@@ -473,23 +797,19 @@ function guardedStream(response, protocol, checkName) {
473
797
  controller.close();
474
798
  return;
475
799
  }
476
- let changed;
477
- let result = data;
478
- if (chat) {
479
- const checked = chat.apply(data);
480
- changed = checked.changed;
481
- result = checked.data;
482
- } else {
483
- changed = checkSnapshots(data, protocol, checkName);
484
- }
800
+ const checked = chat ? chat.apply(data) : snapshots.apply(data, type);
801
+ const { changed, data: result, extra = [] } = checked;
802
+ if (result) track?.see(result);
485
803
  if (
486
804
  (protocol === "responses" &&
487
805
  ["response.completed", "response.incomplete"].includes(type)) ||
488
806
  (protocol === "anthropic" && type === "message_stop")
489
807
  )
490
808
  completed = true;
491
- if (result) {
492
- 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));
493
813
  return;
494
814
  }
495
815
  }
@@ -527,7 +847,11 @@ function guardedStream(response, protocol, checkName) {
527
847
  );
528
848
  }
529
849
 
530
- 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 = {}) {
531
855
  if (!response.ok) return response;
532
856
  if (!["chat", "responses", "anthropic"].includes(protocol))
533
857
  throw new TypeError(`Unsupported protocol: ${protocol}`);
@@ -535,7 +859,7 @@ export async function guardToolResponse(response, protocol, policy) {
535
859
  const headers = headersFor(response);
536
860
  const contentType = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
537
861
  if (contentType === "text/event-stream") {
538
- return new Response(guardedStream(response, protocol, checkName), {
862
+ return new Response(guardedStream(response, protocol, checkName, options.endCutShort === true), {
539
863
  status: response.status,
540
864
  statusText: response.statusText,
541
865
  headers,