opencode-zen-freex 0.2.7 → 0.2.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/tool-response.mjs CHANGED
@@ -1,21 +1,33 @@
1
- const MAX_BYTES = 32 * 1024 * 1024;
1
+ import { MAX_BYTES, STALE_BODY_HEADERS } from "./stream.mjs";
2
+
3
+ // The limit as a failure's message names it, from the one place it is set:
4
+ // stream.mjs owns the number, and this guard reads the same bytes through it.
5
+ const LIMIT = `${MAX_BYTES / (1024 * 1024)} MiB`;
2
6
  const encoder = new TextEncoder();
3
7
 
4
- class GuardError extends Error {}
8
+ class GuardError extends Error {
9
+ constructor(message, code = "upstream_error") {
10
+ super(message);
11
+ this.code = code;
12
+ }
13
+ }
5
14
 
6
15
  function headersFor(response) {
7
16
  const headers = new Headers(response.headers);
8
- headers.delete("content-length");
9
- headers.delete("content-encoding");
17
+ for (const name of STALE_BODY_HEADERS) headers.delete(name);
10
18
  return headers;
11
19
  }
12
20
 
13
- function failure(protocol, message, stream = false) {
21
+ // failure answers the error a client reads. `code` says what went wrong
22
+ // where the shape has room for it: only a call of no tool the agent has is
23
+ // an undeclared one, and a limit, a truncation or a malformed upstream is
24
+ // not.
25
+ function failure(protocol, message, stream = false, code = "upstream_error") {
14
26
  if (protocol === "anthropic") return { type: "error", error: { type: "api_error", message } };
15
27
  if (protocol === "responses") {
16
28
  return stream
17
- ? { type: "error", code: "undeclared_tool", message }
18
- : { error: { type: "upstream_error", code: "undeclared_tool", message } };
29
+ ? { type: "error", code, message }
30
+ : { error: { type: "upstream_error", code, message } };
19
31
  }
20
32
  return { error: { message, type: "upstream_error" } };
21
33
  }
@@ -198,7 +210,7 @@ function checkSnapshots(data, protocol, checkName) {
198
210
  if (isCall(protocol, value)) {
199
211
  const name = checkName(value.name);
200
212
  if (name == null) {
201
- if (!holder) throw new GuardError(droppedNote(value.name));
213
+ if (!holder) throw new GuardError(droppedNote(value.name), "undeclared_tool");
202
214
  holder[key] = inPlaceOf(protocol, value, droppedNote(value.name));
203
215
  changed = true;
204
216
  left++;
@@ -267,7 +279,7 @@ function chatBuffer(checkName) {
267
279
  bytes += size;
268
280
  state.bytes += size;
269
281
  if (bytes > MAX_BYTES)
270
- throw new GuardError("Pending upstream tools exceed the 32 MiB buffer limit");
282
+ throw new GuardError(`Pending upstream tools exceed the ${LIMIT} buffer limit`);
271
283
  }
272
284
  function stateFor(index, data) {
273
285
  if (!pending.has(index)) {
@@ -289,7 +301,10 @@ function chatBuffer(checkName) {
289
301
  return pending.get(index);
290
302
  }
291
303
  // Choices that streamed text, and those whose every call was left out
292
- // (whose later "tool_calls" finish is a stop).
304
+ // (whose later "tool_calls" finish is a stop). The second is per round: a
305
+ // later round that buffers a call for the same choice is judged on what it
306
+ // holds, not on the round before it, so the mark is dropped as soon as that
307
+ // call arrives.
293
308
  const wrote = new Set();
294
309
  const ended = new Set();
295
310
  function complete(state, index) {
@@ -308,6 +323,14 @@ function chatBuffer(checkName) {
308
323
  }
309
324
  return delta;
310
325
  }
326
+ // What a choice the buffer flushes at [DONE] ended as: tool use when the
327
+ // delta it publishes carries calls, the legacy field when it carries that,
328
+ // and a plain stop otherwise.
329
+ function ending(delta) {
330
+ if (delta.tool_calls?.length) return "tool_calls";
331
+ if (delta.function_call) return "function_call";
332
+ return "stop";
333
+ }
311
334
  function remove(index) {
312
335
  const state = pending.get(index);
313
336
  if (state) bytes -= state.bytes;
@@ -319,6 +342,10 @@ function chatBuffer(checkName) {
319
342
  },
320
343
  clear() {
321
344
  pending.clear();
345
+ // Everything this buffer holds, not only the bytes: a cleared buffer
346
+ // has no round to judge.
347
+ wrote.clear();
348
+ ended.clear();
322
349
  bytes = 0;
323
350
  },
324
351
  apply(data) {
@@ -330,7 +357,12 @@ function chatBuffer(checkName) {
330
357
  if (typeof delta.content === "string" && delta.content) wrote.add(position(choice.index));
331
358
  if (delta.tool_calls == null && delta.function_call == null) continue;
332
359
  changed = true;
333
- const state = stateFor(position(choice.index), data);
360
+ // A call buffered now opens a round of its own for this choice: a
361
+ // choice that finished as a plain stop because every call of an
362
+ // earlier round was left out is judged again on what this one holds.
363
+ const at = position(choice.index);
364
+ ended.delete(at);
365
+ const state = stateFor(at, data);
334
366
  for (const fragment of list(delta.tool_calls)) {
335
367
  if (!object(fragment) || (fragment.type != null && fragment.type !== "function")) {
336
368
  throw new GuardError("Unsupported upstream tool delta");
@@ -381,9 +413,16 @@ function chatBuffer(checkName) {
381
413
  },
382
414
  finish() {
383
415
  if (!pending.size) return null;
384
- const choices = [...pending]
385
- .sort(([a], [b]) => a - b)
386
- .map(([index, state]) => ({ index, delta: complete(state, index), finish_reason: null }));
416
+ const choices = [...pending].sort(([a], [b]) => a - b).map(([index, state]) => {
417
+ const delta = complete(state, index);
418
+ // The case this buffer exists for is an upstream that closes with
419
+ // [DONE] before sending a finish chunk: the calls it buffered are
420
+ // published here, and the choice has to say the turn ended in tool
421
+ // use. A null reason reads as no ending at all, and "stop" as a plain
422
+ // one — either way a client keyed on finish_reason never runs a call
423
+ // it was handed.
424
+ return { index, delta, finish_reason: ending(delta) };
425
+ });
387
426
  const metadata = pending.values().next().value.metadata;
388
427
  pending.clear();
389
428
  bytes = 0;
@@ -485,6 +524,46 @@ function snapshotStream(protocol, checkName) {
485
524
  };
486
525
  }
487
526
 
527
+ // A reply that is not a stream is read whole before it is checked, so it is
528
+ // read through the same limit a streamed reply's events are: a body past it
529
+ // is a 502, rather than the whole of it sitting in memory first.
530
+ async function readBounded(response) {
531
+ const reader = response.body?.getReader();
532
+ if (!reader) return "";
533
+ const parts = [];
534
+ let bytes = 0;
535
+ try {
536
+ while (true) {
537
+ const { done, value } = await reader.read();
538
+ if (done) break;
539
+ bytes += value.byteLength;
540
+ if (bytes > MAX_BYTES)
541
+ throw new GuardError(`Upstream response exceeds the ${LIMIT} buffer limit`);
542
+ parts.push(value);
543
+ }
544
+ } finally {
545
+ // Whatever ended the read — the limit, a malformed body, or the end of
546
+ // it — this body is never read again, so the reader is loosed and the
547
+ // stream cancelled. This was the one place in the package that abandoned
548
+ // a body without cancelling it (measured: no cancellation past the limit,
549
+ // and the source still pulled after the 502), which leaves an upstream
550
+ // writing into a socket nobody reads. The cancel's own failure is not
551
+ // swallowed, and the lock is released whether it threw or not.
552
+ try {
553
+ await reader.cancel();
554
+ } finally {
555
+ reader.releaseLock();
556
+ }
557
+ }
558
+ const all = new Uint8Array(bytes);
559
+ let offset = 0;
560
+ for (const part of parts) {
561
+ all.set(part, offset);
562
+ offset += part.length;
563
+ }
564
+ return new TextDecoder().decode(all);
565
+ }
566
+
488
567
  function parseJSON(text) {
489
568
  let value;
490
569
  try {
@@ -519,7 +598,7 @@ async function* framesFrom(reader) {
519
598
  function append(value) {
520
599
  eventBytes += value.byteLength;
521
600
  if (eventBytes > MAX_BYTES)
522
- throw new GuardError("Upstream SSE event exceeds the 32 MiB buffer limit");
601
+ throw new GuardError(`Upstream SSE event exceeds the ${LIMIT} buffer limit`);
523
602
  if (value.byteLength) {
524
603
  parts.push(value);
525
604
  lineBytes += value.byteLength;
@@ -546,7 +625,7 @@ async function* framesFrom(reader) {
546
625
  start = 1;
547
626
  }
548
627
  if (eventBytes > MAX_BYTES)
549
- throw new GuardError("Upstream SSE event exceeds the 32 MiB buffer limit");
628
+ throw new GuardError(`Upstream SSE event exceeds the ${LIMIT} buffer limit`);
550
629
  skipLF = false;
551
630
  if (pendingFrame) {
552
631
  const frame = pendingFrame;
@@ -559,7 +638,7 @@ async function* framesFrom(reader) {
559
638
  if (value[offset] !== 10 && value[offset] !== 13) continue;
560
639
  append(value.subarray(start, offset));
561
640
  if (++eventBytes > MAX_BYTES)
562
- throw new GuardError("Upstream SSE event exceeds the 32 MiB buffer limit");
641
+ throw new GuardError(`Upstream SSE event exceeds the ${LIMIT} buffer limit`);
563
642
  const bytes = new Uint8Array(lineBytes);
564
643
  let cursor = 0;
565
644
  for (const part of parts) {
@@ -584,7 +663,7 @@ async function* framesFrom(reader) {
584
663
  rawLine += "\n";
585
664
  offset++;
586
665
  if (++eventBytes > MAX_BYTES)
587
- throw new GuardError("Upstream SSE event exceeds the 32 MiB buffer limit");
666
+ throw new GuardError(`Upstream SSE event exceeds the ${LIMIT} buffer limit`);
588
667
  } else if (offset + 1 === value.length) skipLF = true;
589
668
  }
590
669
  start = offset + 1;
@@ -768,9 +847,16 @@ function guardedStream(response, protocol, checkName, endCutShort) {
768
847
  return;
769
848
  }
770
849
  if (frame.name !== "error" && frame.text.trim() === "[DONE]") {
771
- if (protocol !== "chat")
850
+ // [DONE] is what ends a Chat stream, and a valid end-of-stream
851
+ // sentinel for a Responses or Messages one that has already
852
+ // reported its own terminal event (response.completed,
853
+ // response.incomplete, message_stop) — a gateway in front of the
854
+ // upstream may append it. Before that event it claims an ending
855
+ // the protocol never reported, and the frame is refused; the
856
+ // collapse path reads the same bytes the same way.
857
+ if (protocol !== "chat" && !completed)
772
858
  throw new GuardError("Unexpected upstream completion marker");
773
- const final = chat.finish();
859
+ const final = chat ? chat.finish() : null;
774
860
  completed = true;
775
861
  controller.enqueue(encoder.encode((final ? encodeEvent(final) : "") + frame.raw));
776
862
  return;
@@ -826,7 +912,7 @@ function guardedStream(response, protocol, checkName, endCutShort) {
826
912
  controller.enqueue(
827
913
  encoder.encode(
828
914
  encodeEvent(
829
- failure(protocol, error.message, true),
915
+ failure(protocol, error.message, true, error.code),
830
916
  protocol === "chat" ? "" : "error",
831
917
  ),
832
918
  ),
@@ -865,16 +951,15 @@ export async function guardToolResponse(response, protocol, policy, options = {}
865
951
  headers,
866
952
  });
867
953
  }
868
- const text = await response.text();
869
954
  let value;
870
955
  let status = response.status;
871
956
  try {
872
- value = parseJSON(text);
957
+ value = parseJSON(await readBounded(response));
873
958
  checkSnapshots(value, protocol, checkName);
874
959
  } catch (error) {
875
960
  if (!(error instanceof GuardError)) throw error;
876
961
  status = 502;
877
- value = failure(protocol, error.message);
962
+ value = failure(protocol, error.message, false, error.code);
878
963
  }
879
964
  headers.set("content-type", "application/json");
880
965
  return new Response(JSON.stringify(value), { status, headers });
package/capture.mjs DELETED
@@ -1,203 +0,0 @@
1
- // Recaptures fingerprint.json from a real OpenCode.
2
- //
3
- // The free tier reads the wording of the tools it knows by name out of the
4
- // request body and refuses the request when it differs from its own copy. That
5
- // copy moves with OpenCode, so the one held here goes stale — and a stale copy
6
- // looks exactly like a working one until somebody is refused. This is how a new
7
- // one is taken, so that refreshing it is a command and not an archaeology
8
- // exercise.
9
- //
10
- // bun run capture # writes fingerprint.json
11
- // bun run capture -- --dry # reports what changed, writes nothing
12
- //
13
- // Needs opencode on PATH (https://opencode.ai) and bun. Nothing goes to Zen: the
14
- // OpenCode under test is pointed at a server running here, and the request it
15
- // makes is the thing being read.
16
-
17
- import { spawn } from "node:child_process";
18
- import { readFileSync } from "node:fs";
19
- import { mkdtempSync } from "node:fs";
20
- import { tmpdir } from "node:os";
21
- import { join } from "node:path";
22
- import { PROFILES } from "./core-tools.mjs";
23
-
24
- const PORT = Number(process.env.CAPTURE_PORT ?? 8791);
25
- const DRY = process.argv.includes("--dry");
26
- const HERE = import.meta.dir;
27
- const FINGERPRINT = join(HERE, "fingerprint.json");
28
-
29
- // The names Zen reads. Everything else a host declares is ignored by it.
30
- const READ = ["bash", "glob", "grep", "read", "write", "edit"];
31
-
32
- // The capture host's own scratch directory and OS banner belong to the machine
33
- // the capture ran on, not to the tool. These are put back to something neutral
34
- // so the capture is not tied to one machine.
35
- const NEUTRALISE = [
36
- [/Be aware: OS: [^\n]*/g, "Be aware: OS: linux, Shell: bash"],
37
- [/Use `[^`]*` for temporary work outside the workspace\./g, "Use `/tmp` for temporary work outside the workspace."],
38
- [/Use `[A-Za-z]:\\[^`]*`/g, "Use `/tmp`"],
39
- ];
40
- // Zen compares roughly the first 700 characters and ignores the rest; 1200
41
- // leaves margin, and the tail is where a note to the model goes.
42
- const KEEP = 1200;
43
-
44
- const capturedPath = join(tmpdir(), `zen-capture-${process.pid}.json`);
45
- const server = Bun.serve({
46
- port: PORT,
47
- hostname: "127.0.0.1",
48
- async fetch(request) {
49
- const url = new URL(request.url);
50
- if (url.pathname.endsWith("/models"))
51
- return Response.json({ object: "list", data: [{ id: "big-pickle", object: "model", owned_by: "opencode" }] });
52
- // OpenCode makes more than one request (a short one for the title, then the
53
- // real one). The one worth reading is the longest, since it is the one that
54
- // carries the full toolset.
55
- const body = await request.text();
56
- let previous = { agent: null, body: "" };
57
- try {
58
- previous = JSON.parse(readFileSync(capturedPath, "utf8"));
59
- } catch {}
60
- if (body.length > previous.body.length)
61
- await Bun.write(capturedPath, JSON.stringify({ agent: request.headers.get("user-agent"), body }));
62
- return new Response(
63
- [
64
- 'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"big-pickle","choices":[{"index":0,"delta":{"role":"assistant","content":"OK"},"finish_reason":null}]}',
65
- 'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"big-pickle","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
66
- "data: [DONE]",
67
- "",
68
- ].join("\n\n"),
69
- { headers: { "content-type": "text/event-stream" } },
70
- );
71
- },
72
- });
73
-
74
- const home = mkdtempSync(join(tmpdir(), "zen-capture-home-"));
75
- // Where OpenCode looks for its config is XDG_CONFIG_HOME/opencode, and the
76
- // three XDG directories are pointed at `home` below.
77
- const config = join(home, "config", "opencode", "opencode.json");
78
- await Bun.write(
79
- config,
80
- JSON.stringify({ provider: { opencode: { options: { baseURL: `http://127.0.0.1:${PORT}/v1` } } } }),
81
- );
82
-
83
- console.log(`listening on 127.0.0.1:${PORT}, running opencode against it`);
84
- let ran = true;
85
- const code = await new Promise((done) => {
86
- const child = spawn("opencode", ["run", "-m", "opencode/big-pickle", "Say OK."], {
87
- env: {
88
- ...process.env,
89
- XDG_DATA_HOME: join(home, "data"),
90
- XDG_CONFIG_HOME: join(home, "config"),
91
- XDG_CACHE_HOME: join(home, "cache"),
92
- },
93
- stdio: ["ignore", "ignore", "inherit"],
94
- });
95
- child.on("error", () => {
96
- console.error("could not run opencode; install it from https://opencode.ai and put it on PATH");
97
- ran = false;
98
- done(1);
99
- });
100
- child.on("exit", (value) => done(value ?? 0));
101
- });
102
- server.stop(true);
103
- if (!ran) process.exit(1);
104
-
105
- let request;
106
- try {
107
- request = JSON.parse(readFileSync(capturedPath, "utf8"));
108
- } catch {
109
- console.error("no request was captured; opencode did not reach the local server");
110
- process.exit(1);
111
- }
112
-
113
- const body = JSON.parse(request.body);
114
- const tools = {};
115
- for (const raw of body.tools ?? []) {
116
- const tool = raw.function ?? raw;
117
- if (!tool?.name || !READ.includes(tool.name) || !tool.description) continue;
118
- tools[tool.name] = {
119
- name: tool.name,
120
- description: NEUTRALISE.reduce((text, [find, to]) => text.replace(find, to), tool.description).slice(0, KEEP),
121
- parameters: tool.parameters,
122
- };
123
- }
124
-
125
- const missing = READ.filter((name) => !tools[name]);
126
- if (missing.length) {
127
- console.error(
128
- `the captured request has no ${missing.join(", ")}; this OpenCode no longer sends them, so it cannot be captured this way`,
129
- );
130
- process.exit(1);
131
- }
132
- for (const [name, tool] of Object.entries(tools))
133
- if (tool.description.length < 100) {
134
- console.error(`${name}'s description looks truncated (${tool.description.length} chars); not writing`);
135
- process.exit(1);
136
- }
137
-
138
- // Which generation this client is: the names it sends say so.
139
- const generation = body.tools.some((t) => (t.function ?? t).name === "shell") ? "2.x" : "1.x";
140
- const required = generation === "2.x" ? ["shell", "read"] : ["bash", "glob", "grep", "read"];
141
- const absent = required.filter((name) => !tools[name]);
142
- if (absent.length) {
143
- console.error(`a ${generation} request must carry ${absent.join(", ")}, and the capture does not`);
144
- process.exit(1);
145
- }
146
-
147
- // Which protocol was captured, since each of 1.x's protocols bundles its own
148
- // provider-utils and a capture must not overwrite the ones it did not observe.
149
- const protocol = body.input !== undefined && body.messages === undefined ? "responses" : "chat";
150
-
151
- console.log(`\nagent: ${request.agent}`);
152
- console.log(`tools: ${Object.keys(tools).join(", ")} (${generation}, ${protocol})`);
153
- if (code !== 0) console.log(`(opencode exited ${code}; its request was still captured)`);
154
-
155
- const agent = parseAgent(request.agent);
156
- if (agent.providerUtils) agent.providerUtils = { ...PROFILES[generation]?.agent?.providerUtils, [protocol]: agent.providerUtils[protocol] };
157
-
158
- const before = PROFILES[generation];
159
- const agentChanged = JSON.stringify(before?.agent) !== JSON.stringify(agent);
160
- let changed = 0;
161
- for (const name of Object.keys(tools)) {
162
- if (JSON.stringify(before?.tools?.[name]) !== JSON.stringify(tools[name])) changed++;
163
- console.log(` ${name.padEnd(6)} ${JSON.stringify(before?.tools?.[name]) === JSON.stringify(tools[name]) ? "unchanged" : "CHANGED"} ${tools[name].description.length} chars`);
164
- }
165
- if (agent.providerUtils)
166
- console.log(` agent ${agentChanged ? "CHANGED" : "unchanged"} ${protocol} provider-utils kept separate from the others`);
167
-
168
- if (DRY) {
169
- const what = [changed ? `${changed} tool(s)` : null, agentChanged ? "the agent" : null].filter(Boolean);
170
- console.log(`\n${what.length ? `${what.join(" and ")} would change.` : "Nothing would change."} Nothing written (--dry).`);
171
- process.exit(what.length ? 1 : 0);
172
- }
173
-
174
- if (!changed && !agentChanged) {
175
- console.log("\nnothing changed.");
176
- process.exit(0);
177
- }
178
-
179
- const next = JSON.parse(readFileSync(FINGERPRINT, "utf8"));
180
- next.profiles[generation] = { agent, required, tools };
181
- await Bun.write(FINGERPRINT, JSON.stringify(next, null, 2) + "\n");
182
- console.log(`\nwrote ${FINGERPRINT} for ${generation}. Now run \`bun run check:gate\` and commit if it passes.`);
183
-
184
- // The User-Agent is stored the way agentFor() reads it back, which is not
185
- // necessarily the same split the raw header used.
186
- function parseAgent(agent) {
187
- const twoPart = /^opencode\/(\d[^ ]*) ai-sdk\/provider-utils\/(\S+) (\S+)$/.exec(agent ?? "");
188
- if (twoPart) {
189
- const [, opencode, providerUtils, runtime] = twoPart;
190
- return {
191
- opencode,
192
- providerUtils: { chat: providerUtils, responses: providerUtils, anthropic: providerUtils },
193
- runtime,
194
- };
195
- }
196
- const threePart = /^opencode\/([a-z]+)\/(\d[^/]*)\/([a-z-]+)$/.exec(agent ?? "");
197
- if (threePart) {
198
- const [, channel, opencode, client] = threePart;
199
- return { channel, opencode, client };
200
- }
201
- console.error(`\ncould not read \`${agent}\` as an OpenCode User-Agent; write PROFILES[${generation}].agent by hand`);
202
- process.exit(1);
203
- }