@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 +36 -5
- package/models.mjs +83 -2
- package/package.json +1 -1
- package/provider.mjs +152 -58
- package/tool-response.mjs +381 -57
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.
|
|
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. 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/
|
|
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
|
|
39
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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
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
|
}
|
|
@@ -156,10 +228,15 @@ async function server(_input, options = {}) {
|
|
|
156
228
|
discovered[id] = known[id];
|
|
157
229
|
}
|
|
158
230
|
}
|
|
159
|
-
|
|
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) =>
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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(
|
|
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)
|
|
60
|
+
if (typeof name !== "string" || !name) return null;
|
|
56
61
|
if (declared.has(name)) return name;
|
|
57
|
-
const match =
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
if (message.function_call != null)
|
|
77
|
-
|
|
78
|
-
if (!object(
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
(
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
216
|
+
for (const name of arrays) {
|
|
117
217
|
// Text content is not a snapshot container.
|
|
118
|
-
if (typeof value[
|
|
119
|
-
|
|
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
|
|
122
|
-
if (value[
|
|
222
|
+
for (const name of objects) {
|
|
223
|
+
if (value[name] != null) stack.push([value[name], value, name]);
|
|
123
224
|
}
|
|
124
225
|
}
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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,
|