opencode-anthropic-fix 0.1.31 → 0.2.0

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.
Files changed (40) hide show
  1. package/README.md +2 -0
  2. package/cli.mjs +42 -35
  3. package/index.mjs +399 -3591
  4. package/lib/config.mjs +9 -10
  5. package/lib/config.test.mjs +298 -1
  6. package/lib/diagnose.mjs +184 -0
  7. package/lib/diagnose.test.mjs +122 -0
  8. package/lib/env.mjs +19 -0
  9. package/lib/env.test.mjs +25 -0
  10. package/lib/mimicry/cache.mjs +62 -0
  11. package/lib/mimicry/cache.test.mjs +112 -0
  12. package/lib/mimicry/headers.mjs +530 -0
  13. package/lib/mimicry/headers.test.mjs +50 -0
  14. package/lib/mimicry/models.mjs +146 -0
  15. package/lib/mimicry/models.test.mjs +132 -0
  16. package/lib/mimicry/request-body.mjs +684 -0
  17. package/lib/mimicry/request-body.test.mjs +34 -0
  18. package/lib/mimicry/request-helpers.mjs +243 -0
  19. package/lib/mimicry/request-helpers.test.mjs +155 -0
  20. package/lib/mimicry/response-stream.mjs +429 -0
  21. package/lib/mimicry/response-stream.test.mjs +127 -0
  22. package/lib/mimicry/system-prompt.mjs +638 -0
  23. package/lib/mimicry/system-prompt.test.mjs +73 -0
  24. package/lib/oauth.mjs +1 -1
  25. package/lib/oauth.test.mjs +8 -4
  26. package/lib/redact.mjs +147 -0
  27. package/lib/redact.test.mjs +154 -0
  28. package/lib/refresh-lock.test.mjs +52 -0
  29. package/lib/retry/overload-loop.mjs +83 -0
  30. package/lib/retry/overload-loop.test.mjs +107 -0
  31. package/lib/session-metrics.mjs +75 -0
  32. package/lib/session-metrics.test.mjs +67 -0
  33. package/lib/storage.test.mjs +82 -0
  34. package/lib/token-economy/microcompact.mjs +35 -0
  35. package/lib/token-economy/microcompact.test.mjs +28 -0
  36. package/lib/token-economy/transforms.mjs +637 -0
  37. package/lib/token-economy/transforms.test.mjs +192 -0
  38. package/lib/tuning.mjs +70 -0
  39. package/lib/tuning.test.mjs +89 -0
  40. package/package.json +4 -1
@@ -0,0 +1,429 @@
1
+ import { isAccountSpecificError, parseRateLimitReason } from "../backoff.mjs";
2
+
3
+ /**
4
+ * @typedef {object} UsageStats
5
+ * @property {number} inputTokens
6
+ * @property {number} outputTokens
7
+ * @property {number} cacheReadTokens
8
+ * @property {number} cacheWriteTokens
9
+ * @property {number} [webSearchRequests]
10
+ * @property {string} [lastStopReason]
11
+ */
12
+
13
+ /**
14
+ * Update running usage stats from a parsed SSE event.
15
+ * @param {any} parsed
16
+ * @param {UsageStats} stats
17
+ */
18
+ export function extractUsageFromSSEEvent(parsed, stats) {
19
+ // message_delta: cumulative usage (preferred, overwrites)
20
+ if (parsed?.type === "message_delta" && parsed.usage) {
21
+ const u = parsed.usage;
22
+ if (typeof u.input_tokens === "number") stats.inputTokens = u.input_tokens;
23
+ if (typeof u.output_tokens === "number") stats.outputTokens = u.output_tokens;
24
+ if (typeof u.cache_read_input_tokens === "number") stats.cacheReadTokens = u.cache_read_input_tokens;
25
+ if (typeof u.cache_creation_input_tokens === "number") stats.cacheWriteTokens = u.cache_creation_input_tokens;
26
+ // Web search requests (server tool usage)
27
+ if (typeof u.server_tool_use?.web_search_requests === "number") {
28
+ stats.webSearchRequests = u.server_tool_use.web_search_requests;
29
+ }
30
+ // Capture stop_reason from message_delta for output cap escalation
31
+ if (parsed.delta?.stop_reason) {
32
+ stats.lastStopReason = parsed.delta.stop_reason;
33
+ }
34
+ return;
35
+ }
36
+
37
+ // message_start: initial usage (only set if we haven't seen message_delta yet)
38
+ if (parsed?.type === "message_start" && parsed.message?.usage) {
39
+ const u = parsed.message.usage;
40
+ if (stats.inputTokens === 0 && typeof u.input_tokens === "number") {
41
+ stats.inputTokens = u.input_tokens;
42
+ }
43
+ if (stats.cacheReadTokens === 0 && typeof u.cache_read_input_tokens === "number") {
44
+ stats.cacheReadTokens = u.cache_read_input_tokens;
45
+ }
46
+ if (stats.cacheWriteTokens === 0 && typeof u.cache_creation_input_tokens === "number") {
47
+ stats.cacheWriteTokens = u.cache_creation_input_tokens;
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Extract the combined SSE data payload from one event block.
54
+ * @param {string} eventBlock
55
+ * @returns {string | null}
56
+ */
57
+ export function getSSEDataPayload(eventBlock) {
58
+ if (!eventBlock) return null;
59
+
60
+ const dataLines = [];
61
+ for (const line of eventBlock.split("\n")) {
62
+ if (!line.startsWith("data:")) continue;
63
+ // QA fix: SSE spec says strip only a single leading space after "data:", not all whitespace
64
+ const raw = line.slice(5);
65
+ dataLines.push(raw.startsWith(" ") ? raw.slice(1) : raw);
66
+ }
67
+
68
+ if (dataLines.length === 0) return null;
69
+ const payload = dataLines.join("\n");
70
+ if (!payload || payload === "[DONE]") return null;
71
+ return payload;
72
+ }
73
+
74
+ /**
75
+ * Parse one SSE event payload and return account-error details if present.
76
+ * @param {any} parsed
77
+ * @returns {{reason: import('../backoff.mjs').RateLimitReason, invalidateToken: boolean} | null}
78
+ */
79
+ export function getMidStreamAccountError(parsed) {
80
+ if (!parsed || parsed.type !== "error" || !parsed.error) {
81
+ return null;
82
+ }
83
+
84
+ const errorBody = {
85
+ error: {
86
+ type: String(parsed.error.type || ""),
87
+ message: String(parsed.error.message || ""),
88
+ },
89
+ };
90
+
91
+ // Mid-stream errors do not include a reliable HTTP status. Use 400-style
92
+ // body parsing to identify account-specific errors.
93
+ if (!isAccountSpecificError(400, errorBody)) {
94
+ return null;
95
+ }
96
+
97
+ const reason = parseRateLimitReason(400, errorBody);
98
+
99
+ return {
100
+ reason,
101
+ invalidateToken: reason === "AUTH_FAILED",
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Strip `mcp_` prefix from tool_use `name` fields in SSE data lines.
107
+ * Only modifies `name` values inside content blocks with `"type": "tool_use"`.
108
+ * Non-JSON lines and text blocks are left untouched.
109
+ *
110
+ * @param {string} text - Raw SSE chunk text (may contain multiple lines)
111
+ * @returns {string}
112
+ */
113
+ export function stripMcpPrefixFromSSE(text) {
114
+ return text.replace(/^data:\s*(.+)$/gm, (_match, jsonStr) => {
115
+ try {
116
+ const parsed = JSON.parse(jsonStr);
117
+ if (stripMcpPrefixFromParsedEvent(parsed)) {
118
+ return `data: ${JSON.stringify(parsed)}`;
119
+ }
120
+ } catch {
121
+ // Not valid JSON — pass through unchanged.
122
+ }
123
+ return _match;
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Reverse map: CC PascalCase → opencode lowercase for response stream.
129
+ * Built from the outgoing OC_TO_CC_TOOL_NAMES map (defined in transformRequestBody).
130
+ * Must stay in sync with that map.
131
+ */
132
+ export const CC_TO_OC_TOOL_NAMES = {
133
+ Bash: "bash",
134
+ Read: "read",
135
+ Glob: "glob",
136
+ Grep: "grep",
137
+ Edit: "edit",
138
+ Write: "write",
139
+ WebFetch: "webfetch",
140
+ TodoWrite: "todowrite",
141
+ Skill: "skill",
142
+ Task: "task",
143
+ Compress: "compress",
144
+ };
145
+
146
+ /**
147
+ * Reverse-map a tool name from CC PascalCase back to opencode lowercase,
148
+ * and strip `mcp_` prefix if present. Returns the original name if no mapping exists.
149
+ * @param {string} name
150
+ * @returns {string}
151
+ */
152
+ export function reverseMapToolName(name) {
153
+ if (CC_TO_OC_TOOL_NAMES[name]) return CC_TO_OC_TOOL_NAMES[name];
154
+ if (name.startsWith("mcp_")) return name.slice(4);
155
+ return name;
156
+ }
157
+
158
+ /**
159
+ * Mutate a parsed SSE event object, reversing tool name renames (CC PascalCase
160
+ * → opencode lowercase) and removing `mcp_` prefix from tool_use name fields.
161
+ * Returns true if any modification was made.
162
+ *
163
+ * @param {any} parsed
164
+ * @returns {boolean}
165
+ */
166
+ export function stripMcpPrefixFromParsedEvent(parsed) {
167
+ if (!parsed || typeof parsed !== "object") return false;
168
+
169
+ let modified = false;
170
+
171
+ // content_block_start: { content_block: { type: "tool_use"|"tool_reference", name: "..." } }
172
+ if (
173
+ parsed.content_block &&
174
+ (parsed.content_block.type === "tool_use" || parsed.content_block.type === "tool_reference") &&
175
+ typeof parsed.content_block.name === "string"
176
+ ) {
177
+ const mapped = reverseMapToolName(parsed.content_block.name);
178
+ if (mapped !== parsed.content_block.name) {
179
+ parsed.content_block.name = mapped;
180
+ modified = true;
181
+ }
182
+ }
183
+
184
+ // message_start: { message: { content: [{ type: "tool_use"|"tool_reference", name: "..." }] } }
185
+ if (parsed.message && Array.isArray(parsed.message.content)) {
186
+ for (const block of parsed.message.content) {
187
+ if ((block.type === "tool_use" || block.type === "tool_reference") && typeof block.name === "string") {
188
+ const mapped = reverseMapToolName(block.name);
189
+ if (mapped !== block.name) {
190
+ block.name = mapped;
191
+ modified = true;
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ // Top-level content array (non-streaming responses forwarded through SSE)
198
+ if (Array.isArray(parsed.content)) {
199
+ for (const block of parsed.content) {
200
+ if ((block.type === "tool_use" || block.type === "tool_reference") && typeof block.name === "string") {
201
+ const mapped = reverseMapToolName(block.name);
202
+ if (mapped !== block.name) {
203
+ block.name = mapped;
204
+ modified = true;
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ return modified;
211
+ }
212
+
213
+ /**
214
+ * Resolve the byte-stream idle timeout in milliseconds.
215
+ *
216
+ * Parity with Claude Code's `tengu_byte_stream_idle_timeout_ms`: a watchdog that
217
+ * fires when the upstream SSE stream produces no bytes for N ms, so a stalled /
218
+ * half-dead connection surfaces as a fast error instead of an indefinite hang.
219
+ *
220
+ * Resolution order: env `OPENCODE_ANTHROPIC_STREAM_IDLE_TIMEOUT_MS` →
221
+ * `config.streaming.idle_timeout_ms` → 0 (disabled). Disabled by default because
222
+ * a too-aggressive value could abort a legitimate long generation; Anthropic SSE
223
+ * emits periodic `ping` events, so a multi-minute silence is the safe signal.
224
+ *
225
+ * @param {any} cfg
226
+ * @returns {number} timeout in ms, or 0 to disable
227
+ */
228
+ export function resolveStreamIdleTimeoutMs(cfg) {
229
+ const envRaw = process.env.OPENCODE_ANTHROPIC_STREAM_IDLE_TIMEOUT_MS;
230
+ if (envRaw != null && envRaw !== "") {
231
+ const n = Number(envRaw);
232
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
233
+ }
234
+ const c = cfg?.streaming?.idle_timeout_ms;
235
+ if (typeof c === "number" && Number.isFinite(c) && c >= 0) return Math.floor(c);
236
+ return 0;
237
+ }
238
+
239
+ /**
240
+ * Create a response body stream that rewrites tool names, extracts usage, and
241
+ * detects account-specific errors while preserving all other SSE bytes.
242
+ *
243
+ * @param {Response} response
244
+ * @param {object} options
245
+ * @param {((stats: UsageStats) => void) | null} [options.onUsage]
246
+ * @param {((details: {reason: import('../backoff.mjs').RateLimitReason, invalidateToken: boolean}) => void) | null} [options.onAccountError]
247
+ * @param {string} [options.correlationId]
248
+ * @param {number} options.idleTimeoutMs
249
+ * @param {boolean} options.captureEnabled
250
+ * @param {(correlationId: string | undefined, body: string, truncated: boolean) => Promise<void>} options.writeSseCapture
251
+ * @returns {ReadableStream}
252
+ */
253
+ export function createTransformedSSEStream(
254
+ response,
255
+ { onUsage, onAccountError, correlationId, idleTimeoutMs, captureEnabled, writeSseCapture },
256
+ ) {
257
+ const reader = response.body.getReader();
258
+ const decoder = new TextDecoder();
259
+ const encoder = new TextEncoder();
260
+ const EMPTY_CHUNK = new Uint8Array();
261
+ const MAX_SSE_CAPTURE_BYTES = 256 * 1024;
262
+ let sseCaptureBuf = "";
263
+ let sseCaptureTruncated = false;
264
+
265
+ /** @type {UsageStats} */
266
+ const stats = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
267
+ let sseBuffer = "";
268
+ let sseRewriteBuffer = "";
269
+ let accountErrorHandled = false;
270
+
271
+ /**
272
+ * Process buffered SSE event blocks.
273
+ * @param {boolean} flush
274
+ */
275
+ function processSSEBuffer(flush = false) {
276
+ while (true) {
277
+ const boundary = sseBuffer.indexOf("\n\n");
278
+
279
+ if (boundary === -1) {
280
+ if (!flush) return;
281
+ if (!sseBuffer.trim()) {
282
+ sseBuffer = "";
283
+ return;
284
+ }
285
+ }
286
+
287
+ const eventBlock = boundary === -1 ? sseBuffer : sseBuffer.slice(0, boundary);
288
+ sseBuffer = boundary === -1 ? "" : sseBuffer.slice(boundary + 2);
289
+
290
+ const payload = getSSEDataPayload(eventBlock);
291
+ if (!payload) {
292
+ if (boundary === -1) return;
293
+ continue;
294
+ }
295
+
296
+ try {
297
+ const parsed = JSON.parse(payload);
298
+
299
+ if (onUsage) {
300
+ extractUsageFromSSEEvent(parsed, stats);
301
+ }
302
+
303
+ if (onAccountError && !accountErrorHandled) {
304
+ const details = getMidStreamAccountError(parsed);
305
+ if (details) {
306
+ accountErrorHandled = true;
307
+ onAccountError(details);
308
+ }
309
+ }
310
+ } catch {
311
+ // Ignore malformed event payloads.
312
+ }
313
+
314
+ if (boundary === -1) return;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Rewrite complete SSE lines while preserving chunk boundaries for streaming.
320
+ * Buffers trailing partial lines to avoid parsing split JSON payloads.
321
+ * @param {string} chunk
322
+ * @param {boolean} [flush]
323
+ * @returns {string}
324
+ */
325
+ function rewriteSSEChunk(chunk, flush = false) {
326
+ sseRewriteBuffer += chunk;
327
+
328
+ if (!flush) {
329
+ const boundary = sseRewriteBuffer.lastIndexOf("\n");
330
+ if (boundary === -1) return "";
331
+ const complete = sseRewriteBuffer.slice(0, boundary + 1);
332
+ sseRewriteBuffer = sseRewriteBuffer.slice(boundary + 1);
333
+ return stripMcpPrefixFromSSE(complete);
334
+ }
335
+
336
+ if (!sseRewriteBuffer) return "";
337
+ const finalText = stripMcpPrefixFromSSE(sseRewriteBuffer);
338
+ sseRewriteBuffer = "";
339
+ return finalText;
340
+ }
341
+
342
+ return new ReadableStream({
343
+ async pull(controller) {
344
+ let readResult;
345
+ if (idleTimeoutMs > 0) {
346
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
347
+ let idleTimer;
348
+ try {
349
+ readResult = await Promise.race([
350
+ reader.read(),
351
+ new Promise((_resolve, reject) => {
352
+ idleTimer = setTimeout(
353
+ () => reject(new Error(`stream idle timeout: no bytes for ${idleTimeoutMs}ms`)),
354
+ idleTimeoutMs,
355
+ );
356
+ }),
357
+ ]);
358
+ } catch (err) {
359
+ // Idle timeout (or read error): cancel the upstream reader and surface a
360
+ // clear error so the consumer can retry instead of waiting on a dead
361
+ // connection. Mirrors CC's stream-idle watchdog (no silent hang).
362
+ try {
363
+ await reader.cancel();
364
+ } catch {
365
+ // Reader may already be released; nothing to do.
366
+ }
367
+ controller.error(err instanceof Error ? err : new Error(String(err)));
368
+ return;
369
+ } finally {
370
+ clearTimeout(idleTimer);
371
+ }
372
+ } else {
373
+ readResult = await reader.read();
374
+ }
375
+ const { done, value } = readResult;
376
+ if (done) {
377
+ processSSEBuffer(true);
378
+
379
+ const rewrittenTail = rewriteSSEChunk("", true);
380
+ if (rewrittenTail) {
381
+ controller.enqueue(encoder.encode(rewrittenTail));
382
+ }
383
+
384
+ if (
385
+ onUsage &&
386
+ (stats.inputTokens > 0 || stats.outputTokens > 0 || stats.cacheReadTokens > 0 || stats.cacheWriteTokens > 0)
387
+ ) {
388
+ onUsage(stats);
389
+ }
390
+ if (captureEnabled) {
391
+ try {
392
+ await writeSseCapture(correlationId, sseCaptureBuf, sseCaptureTruncated);
393
+ } catch {
394
+ // Never let a debug write break the stream.
395
+ }
396
+ }
397
+ controller.close();
398
+ return;
399
+ }
400
+
401
+ const text = decoder.decode(value, { stream: true });
402
+
403
+ if (captureEnabled && !sseCaptureTruncated && text) {
404
+ const remaining = MAX_SSE_CAPTURE_BYTES - sseCaptureBuf.length;
405
+ if (remaining <= 0) {
406
+ sseCaptureTruncated = true;
407
+ } else {
408
+ sseCaptureBuf += text.length > remaining ? text.slice(0, remaining) : text;
409
+ if (sseCaptureBuf.length >= MAX_SSE_CAPTURE_BYTES) sseCaptureTruncated = true;
410
+ }
411
+ }
412
+
413
+ if (onUsage || onAccountError) {
414
+ // Normalize CRLF for parser only; preserve original bytes for passthrough.
415
+ sseBuffer += text.replace(/\r\n/g, "\n");
416
+ processSSEBuffer(false);
417
+ }
418
+
419
+ const rewrittenText = rewriteSSEChunk(text, false);
420
+ if (rewrittenText) {
421
+ controller.enqueue(encoder.encode(rewrittenText));
422
+ } else {
423
+ // Keep the pull/read loop progressing when this chunk only extends a
424
+ // partial line buffered for later rewrite.
425
+ controller.enqueue(EMPTY_CHUNK);
426
+ }
427
+ },
428
+ });
429
+ }
@@ -0,0 +1,127 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ createTransformedSSEStream,
4
+ extractUsageFromSSEEvent,
5
+ getMidStreamAccountError,
6
+ getSSEDataPayload,
7
+ resolveStreamIdleTimeoutMs,
8
+ stripMcpPrefixFromParsedEvent,
9
+ } from "./response-stream.mjs";
10
+
11
+ describe("response stream mimicry", () => {
12
+ afterEach(() => {
13
+ delete process.env.OPENCODE_ANTHROPIC_STREAM_IDLE_TIMEOUT_MS;
14
+ });
15
+
16
+ it("reverse-maps tool names in every supported tool_use location", () => {
17
+ const parsed = {
18
+ type: "message_start",
19
+ content_block: { type: "tool_use", name: "mcp_write_file" },
20
+ message: {
21
+ content: [
22
+ { type: "tool_use", name: "Bash" },
23
+ { type: "text", text: "call mcp_write_file", name: "mcp_keep_me" },
24
+ ],
25
+ },
26
+ content: [{ type: "tool_use", name: "WebFetch" }],
27
+ };
28
+
29
+ expect(stripMcpPrefixFromParsedEvent(parsed)).toBe(true);
30
+ expect(parsed.content_block.name).toBe("write_file");
31
+ expect(parsed.message.content[0].name).toBe("bash");
32
+ expect(parsed.message.content[1]).toEqual({ type: "text", text: "call mcp_write_file", name: "mcp_keep_me" });
33
+ expect(parsed.content[0].name).toBe("webfetch");
34
+ });
35
+
36
+ it("combines multiline data payloads and ignores the done sentinel", () => {
37
+ expect(getSSEDataPayload("event: message\ndata: first\ndata: second")).toBe("first\n second");
38
+ expect(getSSEDataPayload("data: [DONE]")).toBeNull();
39
+ });
40
+
41
+ it("extracts final usage and stop reason from message_delta", () => {
42
+ const stats = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
43
+
44
+ extractUsageFromSSEEvent(
45
+ {
46
+ type: "message_delta",
47
+ delta: { stop_reason: "max_tokens" },
48
+ usage: {
49
+ input_tokens: 11,
50
+ output_tokens: 7,
51
+ cache_read_input_tokens: 5,
52
+ cache_creation_input_tokens: 3,
53
+ server_tool_use: { web_search_requests: 2 },
54
+ },
55
+ },
56
+ stats,
57
+ );
58
+
59
+ expect(stats).toEqual({
60
+ inputTokens: 11,
61
+ outputTokens: 7,
62
+ cacheReadTokens: 5,
63
+ cacheWriteTokens: 3,
64
+ webSearchRequests: 2,
65
+ lastStopReason: "max_tokens",
66
+ });
67
+ });
68
+
69
+ it("identifies an account-specific mid-stream authentication error", () => {
70
+ expect(
71
+ getMidStreamAccountError({
72
+ type: "error",
73
+ error: { type: "authentication_error", message: "invalid bearer token" },
74
+ }),
75
+ ).toEqual({ reason: "AUTH_FAILED", invalidateToken: true });
76
+ });
77
+
78
+ it("resolves idle timeout from env, config, then the disabled default", () => {
79
+ expect(resolveStreamIdleTimeoutMs({ streaming: { idle_timeout_ms: 1234.9 } })).toBe(1234);
80
+ expect(resolveStreamIdleTimeoutMs({})).toBe(0);
81
+
82
+ process.env.OPENCODE_ANTHROPIC_STREAM_IDLE_TIMEOUT_MS = "456.8";
83
+ expect(resolveStreamIdleTimeoutMs({ streaming: { idle_timeout_ms: 1234 } })).toBe(456);
84
+ });
85
+
86
+ it("round-trips thinking bytes, rewrites tool names, and reports final usage", async () => {
87
+ const thinkingFrame =
88
+ 'data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"consider mcp_write_file carefully"}}\n\n';
89
+ const toolFrame =
90
+ 'data: {"type":"content_block_start","content_block":{"type":"tool_use","name":"mcp_write_file","id":"t1"}}\n\n';
91
+ const usageFrame =
92
+ 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":4,"output_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0}}\n\n';
93
+ const encoder = new TextEncoder();
94
+ const response = new Response(
95
+ new ReadableStream({
96
+ start(controller) {
97
+ controller.enqueue(encoder.encode(thinkingFrame.slice(0, 47)));
98
+ controller.enqueue(encoder.encode(thinkingFrame.slice(47) + toolFrame + usageFrame));
99
+ controller.close();
100
+ },
101
+ }),
102
+ );
103
+ const onUsage = vi.fn();
104
+ const stream = createTransformedSSEStream(response, {
105
+ onUsage,
106
+ onAccountError: null,
107
+ correlationId: undefined,
108
+ idleTimeoutMs: 0,
109
+ captureEnabled: false,
110
+ writeSseCapture: vi.fn(),
111
+ });
112
+
113
+ const output = await new Response(stream).text();
114
+
115
+ expect(output.startsWith(thinkingFrame)).toBe(true);
116
+ expect(output).toContain('"name":"write_file"');
117
+ expect(output).not.toContain('"name":"mcp_write_file"');
118
+ expect(onUsage).toHaveBeenCalledOnce();
119
+ expect(onUsage).toHaveBeenCalledWith({
120
+ inputTokens: 4,
121
+ outputTokens: 2,
122
+ cacheReadTokens: 1,
123
+ cacheWriteTokens: 0,
124
+ lastStopReason: "end_turn",
125
+ });
126
+ });
127
+ });