@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 +37 -6
- package/models.mjs +64 -1
- package/package.json +1 -1
- package/provider.mjs +147 -57
- package/tool-response.mjs +399 -60
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.
|
|
26
|
-
|
|
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/
|
|
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.
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
//
|
|
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
package/provider.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { createHash,
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
168
|
+
if (!first) return opencodeID("ses", true);
|
|
169
|
+
signal = JSON.stringify(first);
|
|
103
170
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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(
|
|
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) =>
|
|
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 (
|
|
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
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
"
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
39
|
-
|
|
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)
|
|
60
|
+
if (typeof name !== "string" || !name) return null;
|
|
43
61
|
if (declared.has(name)) return name;
|
|
44
|
-
const match =
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
if (message.function_call != null)
|
|
62
|
-
|
|
63
|
-
if (!object(
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
(
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
216
|
+
for (const name of arrays) {
|
|
102
217
|
// Text content is not a snapshot container.
|
|
103
|
-
if (typeof value[
|
|
104
|
-
|
|
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
|
|
107
|
-
if (value[
|
|
222
|
+
for (const name of objects) {
|
|
223
|
+
if (value[name] != null) stack.push([value[name], value, name]);
|
|
108
224
|
}
|
|
109
225
|
}
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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,
|