@oh-my-pi/pi-ai 15.5.12 → 15.5.15

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.5.15] - 2026-05-30
6
+
7
+ ### Added
8
+
9
+ - Added `PI_REQ_DEBUG=1` request/response recording for provider transports. Each request writes `rr-session-N.json`; each received response writes `rr-session-N.res.log` with response headers followed by raw body bytes.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed OpenCode-Go dynamic model refresh downgrading `qwen3.7-max` from Anthropic Messages to OpenAI-compatible transport, which caused `401 Model qwen3.7-max is not supported for format oa-compat` after `/v1/models` cache refreshes.
14
+
5
15
  ## [15.5.12] - 2026-05-29
6
16
  ### Removed
7
17
 
@@ -142,8 +142,8 @@ export interface OpenCodeModelManagerConfig {
142
142
  apiKey?: string;
143
143
  baseUrl?: string;
144
144
  }
145
- export declare function opencodeZenModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<"openai-completions">;
146
- export declare function opencodeGoModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<"openai-completions">;
145
+ export declare function opencodeZenModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<Api>;
146
+ export declare function opencodeGoModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<Api>;
147
147
  export interface OllamaModelManagerConfig {
148
148
  apiKey?: string;
149
149
  baseUrl?: string;
@@ -0,0 +1,29 @@
1
+ import type { FetchImpl } from "../types";
2
+ export type RequestDebugHeaders = Headers | Record<string, string | string[] | number | undefined | null> | undefined;
3
+ export interface RequestDebugPayload {
4
+ method: string;
5
+ url: string;
6
+ headers?: RequestDebugHeaders;
7
+ body?: unknown;
8
+ bodyText?: string;
9
+ bodyBase64?: string;
10
+ bodyUnavailable?: string;
11
+ protocol?: string;
12
+ }
13
+ export interface RequestDebugResponseLog {
14
+ write(chunk: Uint8Array | string): void;
15
+ close(): Promise<void>;
16
+ }
17
+ export interface RequestDebugSession {
18
+ readonly id: number;
19
+ readonly requestPath: string;
20
+ readonly responsePath: string;
21
+ openResponseLog(statusLine: string, headers?: RequestDebugHeaders): Promise<RequestDebugResponseLog>;
22
+ wrapResponse(response: Response): Promise<Response>;
23
+ }
24
+ export declare function isRequestDebugEnabled(): boolean;
25
+ export declare function wrapFetchForRequestDebug(fetchImpl: FetchImpl): FetchImpl;
26
+ export declare function withRequestDebugFetch<T extends {
27
+ fetch?: FetchImpl;
28
+ } | undefined>(options: T): T;
29
+ export declare function createRequestDebugSession(payload: RequestDebugPayload): Promise<RequestDebugSession>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "15.5.12",
4
+ "version": "15.5.15",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -40,7 +40,7 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/sdk": "^0.94.0",
42
42
  "@bufbuild/protobuf": "^2.12.0",
43
- "@oh-my-pi/pi-utils": "15.5.12",
43
+ "@oh-my-pi/pi-utils": "15.5.15",
44
44
  "openai": "^6.36.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "4.4.3"
@@ -289,20 +289,22 @@ function retainModelIds<TApi extends Api>(
289
289
  * arms calling `resolveProviderModels` with the same `staticModels` array)
290
290
  * skip the JSON+hash work after the first call.
291
291
  */
292
+ const MODEL_CACHE_FINGERPRINT_VERSION = "merge-v2";
292
293
  const kStaticFingerprint = Symbol("model-manager.staticFingerprint");
293
294
  type ModelArrayWithFingerprint = readonly Model<Api>[] & { [kStaticFingerprint]?: string };
294
295
  function fingerprintStatic<TApi extends Api>(
295
296
  models: readonly Model<TApi>[],
296
297
  dynamicModelsAuthoritative = false,
297
298
  ): string {
298
- if (models.length === 0) return "empty";
299
- if (dynamicModelsAuthoritative) return `authoritative:${fingerprintStatic(models)}`;
299
+ if (models.length === 0) return `${MODEL_CACHE_FINGERPRINT_VERSION}:empty`;
300
+ if (dynamicModelsAuthoritative)
301
+ return `${MODEL_CACHE_FINGERPRINT_VERSION}:authoritative:${fingerprintStatic(models)}`;
300
302
  const tagged = models as ModelArrayWithFingerprint;
301
303
  const cached = tagged[kStaticFingerprint];
302
304
  if (cached !== undefined) return cached;
303
305
  // `Bun.hash` returns a `bigint`; base36 keeps the string short for the
304
306
  // SQLite column without sacrificing distinguishability.
305
- const fingerprint = Bun.hash(JSON.stringify(models)).toString(36);
307
+ const fingerprint = `${MODEL_CACHE_FINGERPRINT_VERSION}:${Bun.hash(JSON.stringify(models)).toString(36)}`;
306
308
  tagged[kStaticFingerprint] = fingerprint;
307
309
  return fingerprint;
308
310
  }
package/src/models.json CHANGED
@@ -78417,9 +78417,9 @@
78417
78417
  "mimo-v2-flash": {
78418
78418
  "id": "mimo-v2-flash",
78419
78419
  "name": "MiMo-V2-Flash",
78420
- "api": "anthropic-messages",
78420
+ "api": "openai-completions",
78421
78421
  "provider": "xiaomi",
78422
- "baseUrl": "https://api.xiaomimimo.com/anthropic",
78422
+ "baseUrl": "https://api.xiaomimimo.com/v1",
78423
78423
  "reasoning": true,
78424
78424
  "input": [
78425
78425
  "text"
@@ -78432,18 +78432,25 @@
78432
78432
  },
78433
78433
  "contextWindow": 262144,
78434
78434
  "maxTokens": 65536,
78435
+ "compat": {
78436
+ "supportsStore": false,
78437
+ "thinkingFormat": "zai",
78438
+ "reasoningContentField": "reasoning_content",
78439
+ "requiresReasoningContentForToolCalls": true,
78440
+ "allowsSyntheticReasoningContentForToolCalls": false
78441
+ },
78435
78442
  "thinking": {
78436
- "mode": "budget",
78443
+ "mode": "effort",
78437
78444
  "minLevel": "minimal",
78438
- "maxLevel": "xhigh"
78445
+ "maxLevel": "high"
78439
78446
  }
78440
78447
  },
78441
78448
  "mimo-v2-omni": {
78442
78449
  "id": "mimo-v2-omni",
78443
78450
  "name": "MiMo-V2-Omni",
78444
- "api": "anthropic-messages",
78451
+ "api": "openai-completions",
78445
78452
  "provider": "xiaomi",
78446
- "baseUrl": "https://api.xiaomimimo.com/anthropic",
78453
+ "baseUrl": "https://api.xiaomimimo.com/v1",
78447
78454
  "reasoning": true,
78448
78455
  "input": [
78449
78456
  "text",
@@ -78457,18 +78464,25 @@
78457
78464
  },
78458
78465
  "contextWindow": 262144,
78459
78466
  "maxTokens": 131072,
78467
+ "compat": {
78468
+ "supportsStore": false,
78469
+ "thinkingFormat": "zai",
78470
+ "reasoningContentField": "reasoning_content",
78471
+ "requiresReasoningContentForToolCalls": true,
78472
+ "allowsSyntheticReasoningContentForToolCalls": false
78473
+ },
78460
78474
  "thinking": {
78461
- "mode": "budget",
78475
+ "mode": "effort",
78462
78476
  "minLevel": "minimal",
78463
- "maxLevel": "xhigh"
78477
+ "maxLevel": "high"
78464
78478
  }
78465
78479
  },
78466
78480
  "mimo-v2-pro": {
78467
78481
  "id": "mimo-v2-pro",
78468
78482
  "name": "MiMo-V2-Pro",
78469
- "api": "anthropic-messages",
78483
+ "api": "openai-completions",
78470
78484
  "provider": "xiaomi",
78471
- "baseUrl": "https://api.xiaomimimo.com/anthropic",
78485
+ "baseUrl": "https://api.xiaomimimo.com/v1",
78472
78486
  "reasoning": true,
78473
78487
  "input": [
78474
78488
  "text"
@@ -78481,18 +78495,25 @@
78481
78495
  },
78482
78496
  "contextWindow": 1048576,
78483
78497
  "maxTokens": 131072,
78498
+ "compat": {
78499
+ "supportsStore": false,
78500
+ "thinkingFormat": "zai",
78501
+ "reasoningContentField": "reasoning_content",
78502
+ "requiresReasoningContentForToolCalls": true,
78503
+ "allowsSyntheticReasoningContentForToolCalls": false
78504
+ },
78484
78505
  "thinking": {
78485
- "mode": "budget",
78506
+ "mode": "effort",
78486
78507
  "minLevel": "minimal",
78487
- "maxLevel": "xhigh"
78508
+ "maxLevel": "high"
78488
78509
  }
78489
78510
  },
78490
78511
  "mimo-v2.5": {
78491
78512
  "id": "mimo-v2.5",
78492
78513
  "name": "MiMo-V2.5",
78493
- "api": "anthropic-messages",
78514
+ "api": "openai-completions",
78494
78515
  "provider": "xiaomi",
78495
- "baseUrl": "https://api.xiaomimimo.com/anthropic",
78516
+ "baseUrl": "https://api.xiaomimimo.com/v1",
78496
78517
  "reasoning": true,
78497
78518
  "input": [
78498
78519
  "text",
@@ -78506,18 +78527,25 @@
78506
78527
  },
78507
78528
  "contextWindow": 1048576,
78508
78529
  "maxTokens": 131072,
78530
+ "compat": {
78531
+ "supportsStore": false,
78532
+ "thinkingFormat": "zai",
78533
+ "reasoningContentField": "reasoning_content",
78534
+ "requiresReasoningContentForToolCalls": true,
78535
+ "allowsSyntheticReasoningContentForToolCalls": false
78536
+ },
78509
78537
  "thinking": {
78510
- "mode": "budget",
78538
+ "mode": "effort",
78511
78539
  "minLevel": "minimal",
78512
- "maxLevel": "xhigh"
78540
+ "maxLevel": "high"
78513
78541
  }
78514
78542
  },
78515
78543
  "mimo-v2.5-pro": {
78516
78544
  "id": "mimo-v2.5-pro",
78517
78545
  "name": "MiMo-V2.5-Pro",
78518
- "api": "anthropic-messages",
78546
+ "api": "openai-completions",
78519
78547
  "provider": "xiaomi",
78520
- "baseUrl": "https://api.xiaomimimo.com/anthropic",
78548
+ "baseUrl": "https://api.xiaomimimo.com/v1",
78521
78549
  "reasoning": true,
78522
78550
  "input": [
78523
78551
  "text"
@@ -78530,10 +78558,17 @@
78530
78558
  },
78531
78559
  "contextWindow": 1048576,
78532
78560
  "maxTokens": 131072,
78561
+ "compat": {
78562
+ "supportsStore": false,
78563
+ "thinkingFormat": "zai",
78564
+ "reasoningContentField": "reasoning_content",
78565
+ "requiresReasoningContentForToolCalls": true,
78566
+ "allowsSyntheticReasoningContentForToolCalls": false
78567
+ },
78533
78568
  "thinking": {
78534
- "mode": "budget",
78569
+ "mode": "effort",
78535
78570
  "minLevel": "minimal",
78536
- "maxLevel": "xhigh"
78571
+ "maxLevel": "high"
78537
78572
  }
78538
78573
  }
78539
78574
  },
@@ -1218,37 +1218,62 @@ export interface OpenCodeModelManagerConfig {
1218
1218
  baseUrl?: string;
1219
1219
  }
1220
1220
 
1221
+ function normalizeOpenCodeBasePath(baseUrl: string | undefined, fallbackBasePath: string): string {
1222
+ const value = normalizeAnthropicBaseUrl(baseUrl, fallbackBasePath);
1223
+ return value.endsWith("/v1") ? value.slice(0, -3) : value;
1224
+ }
1225
+
1226
+ function openCodeBaseUrlForApi(api: Api, basePath: string): string {
1227
+ return api === "anthropic-messages" ? basePath : `${basePath}/v1`;
1228
+ }
1229
+
1221
1230
  function openCodeModelManagerOptions(
1222
1231
  providerId: "opencode-go" | "opencode-zen",
1223
- defaultBaseUrl: string,
1232
+ defaultBasePath: string,
1224
1233
  config?: OpenCodeModelManagerConfig,
1225
- ): ModelManagerOptions<"openai-completions"> {
1234
+ ): ModelManagerOptions<Api> {
1226
1235
  const apiKey = config?.apiKey;
1227
- const baseUrl = config?.baseUrl ?? defaultBaseUrl;
1236
+ const basePath = normalizeOpenCodeBasePath(config?.baseUrl, defaultBasePath);
1237
+ const discoveryBaseUrl = openCodeBaseUrlForApi("openai-completions", basePath);
1238
+ const references = createBundledReferenceMap<Api>(providerId);
1228
1239
  return {
1229
1240
  providerId,
1230
1241
  ...(apiKey && {
1231
1242
  fetchDynamicModels: () =>
1232
- fetchOpenAICompatibleModels({
1243
+ fetchOpenAICompatibleModels<Api>({
1233
1244
  api: "openai-completions",
1234
1245
  provider: providerId,
1235
- baseUrl,
1246
+ baseUrl: discoveryBaseUrl,
1236
1247
  apiKey,
1248
+ mapModel: (entry, defaults) => {
1249
+ const reference = references.get(defaults.id);
1250
+ const name = toModelName(entry.name, reference?.name ?? defaults.name);
1251
+ if (!reference) {
1252
+ return {
1253
+ ...defaults,
1254
+ name,
1255
+ };
1256
+ }
1257
+ return {
1258
+ ...reference,
1259
+ id: defaults.id,
1260
+ name,
1261
+ baseUrl: openCodeBaseUrlForApi(reference.api, basePath),
1262
+ contextWindow: toPositiveNumber(entry.context_length, reference.contextWindow),
1263
+ maxTokens: toPositiveNumber(entry.max_completion_tokens, reference.maxTokens),
1264
+ };
1265
+ },
1237
1266
  }),
1238
1267
  }),
1239
1268
  };
1240
1269
  }
1241
1270
 
1242
- export function opencodeZenModelManagerOptions(
1243
- config?: OpenCodeModelManagerConfig,
1244
- ): ModelManagerOptions<"openai-completions"> {
1245
- return openCodeModelManagerOptions("opencode-zen", "https://opencode.ai/zen/v1", config);
1271
+ export function opencodeZenModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<Api> {
1272
+ return openCodeModelManagerOptions("opencode-zen", "https://opencode.ai/zen", config);
1246
1273
  }
1247
1274
 
1248
- export function opencodeGoModelManagerOptions(
1249
- config?: OpenCodeModelManagerConfig,
1250
- ): ModelManagerOptions<"openai-completions"> {
1251
- return openCodeModelManagerOptions("opencode-go", "https://opencode.ai/zen/go/v1", config);
1275
+ export function opencodeGoModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<Api> {
1276
+ return openCodeModelManagerOptions("opencode-go", "https://opencode.ai/zen/go", config);
1252
1277
  }
1253
1278
 
1254
1279
  // ---------------------------------------------------------------------------
@@ -2643,9 +2668,16 @@ const MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS: readonly ModelsDevProviderDe
2643
2668
  // --- zAI ---
2644
2669
  anthropicMessagesDescriptor("zai-coding-plan", "zai", "https://api.z.ai/api/anthropic"),
2645
2670
  // --- Xiaomi ---
2646
- anthropicMessagesDescriptor("xiaomi", "xiaomi", "https://api.xiaomimimo.com/anthropic", {
2671
+ openAiCompletionsDescriptor("xiaomi", "xiaomi", "https://api.xiaomimimo.com/v1", {
2647
2672
  defaultContextWindow: 262144,
2648
2673
  defaultMaxTokens: 8192,
2674
+ compat: {
2675
+ supportsStore: false,
2676
+ thinkingFormat: "zai",
2677
+ reasoningContentField: "reasoning_content",
2678
+ requiresReasoningContentForToolCalls: true,
2679
+ allowsSyntheticReasoningContentForToolCalls: false,
2680
+ },
2649
2681
  }),
2650
2682
  // --- MiniMax Coding Plan ---
2651
2683
  openAiCompletionsDescriptor("minimax-coding-plan", "minimax-code", "https://api.minimax.io/v1", {
@@ -273,6 +273,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
273
273
  headers: requestHeaders,
274
274
  body,
275
275
  signal: options.signal,
276
+ fetch: options.fetch,
276
277
  });
277
278
 
278
279
  if (!response.ok) {
@@ -654,17 +654,6 @@ function convertContentBlocks(
654
654
  return blocks;
655
655
  }
656
656
 
657
- /**
658
- * Marker phrase that Claude has been observed to hallucinate inside reasoning summaries
659
- * (e.g. "I don't see any current rewritten thinking or next thinking to process. Could
660
- * you provide..."). When this substring appears in a streamed thinking block we collapse
661
- * the entire block to {@link BROKEN_THINKING_REPLACEMENT} and drop the signature so
662
- * downstream UI/transcripts don't surface the meta-prompt and replay can't re-anchor on
663
- * the garbled chain.
664
- */
665
- const BROKEN_THINKING_MARKER = "rewritten thinking";
666
- const BROKEN_THINKING_REPLACEMENT = "Thinking...";
667
-
668
657
  export type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max";
669
658
  export type AnthropicThinkingDisplay = "summarized" | "omitted";
670
659
 
@@ -1221,12 +1210,6 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1221
1210
  const requestTimeoutMs =
1222
1211
  firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
1223
1212
  const blocks = output.content as Block[];
1224
- // Recent Claude releases occasionally hallucinate meta-prompts asking the operator
1225
- // to supply "rewritten thinking" / "next thinking" as reasoning content. The summary
1226
- // is useless and confuses the UI, so we collapse any thinking block whose stream
1227
- // contains the marker phrase down to a plain "Thinking..." placeholder and drop the
1228
- // (now invalid) signature so subsequent turns don't replay the garbled chain.
1229
- const suppressedThinkingBlocks = new WeakSet<Block>();
1230
1213
  stream.push({ type: "start", partial: output });
1231
1214
  // Retry loop for transient errors from the stream.
1232
1215
  // Provider-level transport/rate-limit failures: only before any streamed content starts.
@@ -1381,14 +1364,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1381
1364
  const index = blocks.findIndex(b => b.index === event.index);
1382
1365
  const block = blocks[index];
1383
1366
  if (block && block.type === "thinking") {
1384
- if (suppressedThinkingBlocks.has(block)) continue;
1385
1367
  block.thinking += event.delta.thinking;
1386
- if (block.thinking.includes(BROKEN_THINKING_MARKER)) {
1387
- suppressedThinkingBlocks.add(block);
1388
- block.thinking = BROKEN_THINKING_REPLACEMENT;
1389
- block.thinkingSignature = "";
1390
- continue;
1391
- }
1392
1368
  stream.push({
1393
1369
  type: "thinking_delta",
1394
1370
  contentIndex: index,
@@ -1412,7 +1388,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1412
1388
  } else if (event.delta.type === "signature_delta") {
1413
1389
  const index = blocks.findIndex(b => b.index === event.index);
1414
1390
  const block = blocks[index];
1415
- if (block && block.type === "thinking" && !suppressedThinkingBlocks.has(block)) {
1391
+ if (block && block.type === "thinking") {
1416
1392
  block.thinkingSignature = block.thinkingSignature || "";
1417
1393
  block.thinkingSignature += event.delta.signature;
1418
1394
  }
@@ -1430,14 +1406,6 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1430
1406
  partial: output,
1431
1407
  });
1432
1408
  } else if (block.type === "thinking") {
1433
- if (
1434
- !suppressedThinkingBlocks.has(block) &&
1435
- block.thinking.includes(BROKEN_THINKING_MARKER)
1436
- ) {
1437
- suppressedThinkingBlocks.add(block);
1438
- block.thinking = BROKEN_THINKING_REPLACEMENT;
1439
- block.thinkingSignature = "";
1440
- }
1441
1409
  stream.push({
1442
1410
  type: "thinking_end",
1443
1411
  contentIndex: index,
@@ -28,6 +28,7 @@ import type {
28
28
  import { normalizeSystemPrompts } from "../utils";
29
29
  import { AssistantMessageEventStream } from "../utils/event-stream";
30
30
  import { parseStreamingJson } from "../utils/json-parse";
31
+ import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
31
32
  import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
32
33
  import { toolWireSchema } from "../utils/schema/wire";
33
34
  import type { McpToolDefinition } from "./cursor/gen/agent_pb";
@@ -331,6 +332,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
331
332
  let h2Client: http2.ClientHttp2Session | null = null;
332
333
  let h2Request: http2.ClientHttp2Stream | null = null;
333
334
  let heartbeatTimer: NodeJS.Timeout | null = null;
335
+ let debugResponseLogPromise: Promise<RequestDebugResponseLog | undefined> | undefined;
334
336
 
335
337
  try {
336
338
  const apiKey = options?.apiKey;
@@ -351,11 +353,10 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
351
353
  const requestContextTools = buildMcpToolDefinitions(context.tools);
352
354
 
353
355
  const baseUrl = model.baseUrl || CURSOR_API_URL;
354
- h2Client = http2.connect(baseUrl);
355
-
356
- h2Request = h2Client.request({
356
+ const requestPath = "/agent.v1.AgentService/Run";
357
+ const requestHeaders = {
357
358
  ":method": "POST",
358
- ":path": "/agent.v1.AgentService/Run",
359
+ ":path": requestPath,
359
360
  "content-type": "application/connect+proto",
360
361
  "connect-protocol-version": "1",
361
362
  te: "trailers",
@@ -364,7 +365,20 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
364
365
  "x-cursor-client-version": CURSOR_CLIENT_VERSION,
365
366
  "x-cursor-client-type": "cli",
366
367
  "x-request-id": crypto.randomUUID(),
367
- });
368
+ };
369
+ const debugSession = isRequestDebugEnabled()
370
+ ? await createRequestDebugSession({
371
+ protocol: "http2",
372
+ method: "POST",
373
+ url: new URL(requestPath, baseUrl).toString(),
374
+ headers: requestHeaders,
375
+ bodyBase64: Buffer.from(requestBytes).toString("base64"),
376
+ })
377
+ : undefined;
378
+
379
+ h2Client = http2.connect(baseUrl);
380
+
381
+ h2Request = h2Client.request(requestHeaders);
368
382
 
369
383
  stream.push({ type: "start", partial: output });
370
384
 
@@ -408,7 +422,19 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
408
422
 
409
423
  let resolveH2: (() => void) | undefined;
410
424
 
425
+ h2Request.on("response", headers => {
426
+ debugResponseLogPromise = debugSession?.openResponseLog(
427
+ `HTTP/2 ${headers[":status"] ?? ""}`.trim(),
428
+ headers,
429
+ );
430
+ });
431
+
411
432
  h2Request.on("data", (chunk: Buffer) => {
433
+ if (debugResponseLogPromise) {
434
+ void debugResponseLogPromise.then(log => {
435
+ log?.write(chunk);
436
+ });
437
+ }
412
438
  pendingBuffer = Buffer.concat([pendingBuffer, chunk]);
413
439
 
414
440
  while (pendingBuffer.length >= 5) {
@@ -480,29 +506,44 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
480
506
  await new Promise<void>((resolve, reject) => {
481
507
  resolveH2 = resolve;
482
508
 
509
+ const closeDebugLog = async (): Promise<void> => {
510
+ const log = await debugResponseLogPromise;
511
+ await log?.close();
512
+ };
513
+
483
514
  h2Request!.on("trailers", trailers => {
484
515
  const status = trailers["grpc-status"];
485
516
  const msg = trailers["grpc-message"];
486
517
  if (status && status !== "0") {
487
- reject(new Error(`gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`));
518
+ void closeDebugLog().finally(() => {
519
+ reject(new Error(`gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`));
520
+ });
488
521
  }
489
522
  });
490
523
 
491
524
  h2Request!.on("end", () => {
492
525
  resolveH2 = undefined;
493
- if (endStreamError) {
494
- reject(endStreamError);
495
- return;
496
- }
497
- resolve();
526
+ void closeDebugLog()
527
+ .then(() => {
528
+ if (endStreamError) {
529
+ reject(endStreamError);
530
+ return;
531
+ }
532
+ resolve();
533
+ })
534
+ .catch(reject);
498
535
  });
499
536
 
500
- h2Request!.on("error", reject);
537
+ h2Request!.on("error", error => {
538
+ void closeDebugLog().finally(() => reject(error));
539
+ });
501
540
 
502
541
  if (options?.signal) {
503
542
  options.signal.addEventListener("abort", () => {
504
543
  h2Request?.close();
505
- reject(new Error("Request was aborted"));
544
+ void closeDebugLog().finally(() => {
545
+ reject(new Error("Request was aborted"));
546
+ });
506
547
  });
507
548
  }
508
549
  });
@@ -557,6 +598,8 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
557
598
  stream.push({ type: "error", reason: output.stopReason, error: output });
558
599
  stream.end();
559
600
  } finally {
601
+ const log = await debugResponseLogPromise;
602
+ await log?.close();
560
603
  if (heartbeatTimer) {
561
604
  clearInterval(heartbeatTimer);
562
605
  heartbeatTimer = null;
@@ -54,6 +54,7 @@ import {
54
54
  iterateWithIdleTimeout,
55
55
  } from "../utils/idle-iterator";
56
56
  import { parseStreamingJson } from "../utils/json-parse";
57
+ import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
57
58
  import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
58
59
  import { notifyRawSseEvent } from "../utils/sse-debug";
59
60
  import { compactGrammarDefinition } from "./grammar";
@@ -2046,6 +2047,8 @@ class CodexWebSocketConnection {
2046
2047
  #streamObserver?: (event: RawSseEvent) => void;
2047
2048
  #heartbeatInterval: NodeJS.Timeout | undefined;
2048
2049
  #removePongListener?: () => void;
2050
+ #handshakeHeaders?: Headers;
2051
+ #debugResponseLog?: RequestDebugResponseLog;
2049
2052
  /**
2050
2053
  * Wall-clock of the most recent inbound activity on this socket — any
2051
2054
  * decoded message, any pong, or the moment the handshake completed. Used
@@ -2189,6 +2192,7 @@ class CodexWebSocketConnection {
2189
2192
  // the liveness clock — what matters for reuse health is that the upstream
2190
2193
  // is still talking to us, not that every frame is well-formed.
2191
2194
  this.#lastInboundAt = Date.now();
2195
+ this.#writeDebugWebSocketFrame(event.data);
2192
2196
  try {
2193
2197
  const text = typeof event.data === "string" ? event.data : Buffer.from(event.data).toString("utf-8");
2194
2198
  if (!text) return;
@@ -2256,6 +2260,19 @@ class CodexWebSocketConnection {
2256
2260
  }
2257
2261
 
2258
2262
  try {
2263
+ const debugSession = isRequestDebugEnabled()
2264
+ ? await createRequestDebugSession({
2265
+ protocol: "websocket",
2266
+ method: "POST",
2267
+ url: this.#url,
2268
+ headers: this.#headers,
2269
+ body: request,
2270
+ })
2271
+ : undefined;
2272
+ this.#debugResponseLog = debugSession
2273
+ ? await debugSession.openResponseLog("WebSocket 101 Switching Protocols", this.#handshakeHeaders)
2274
+ : undefined;
2275
+
2259
2276
  const requestPayload = JSON.stringify(request);
2260
2277
  notifyCodexWebSocketOutbound(onSseEvent, request, requestPayload);
2261
2278
  try {
@@ -2336,14 +2353,35 @@ class CodexWebSocketConnection {
2336
2353
  if (signal) {
2337
2354
  signal.removeEventListener("abort", onAbort);
2338
2355
  }
2356
+ const debugResponseLog = this.#debugResponseLog;
2357
+ this.#debugResponseLog = undefined;
2358
+ await debugResponseLog?.close();
2339
2359
  }
2340
2360
  }
2341
2361
 
2342
2362
  #captureHandshakeHeaders(socket: Bun.WebSocket, openEvent?: Event): void {
2343
- if (!this.#onHandshakeHeaders) return;
2344
2363
  const headers = extractCodexWebSocketHandshakeHeaders(socket, openEvent);
2345
2364
  if (!headers) return;
2346
- this.#onHandshakeHeaders(headers);
2365
+ this.#handshakeHeaders = headers;
2366
+ this.#onHandshakeHeaders?.(headers);
2367
+ }
2368
+
2369
+ #writeDebugWebSocketFrame(data: unknown): void {
2370
+ const log = this.#debugResponseLog;
2371
+ if (!log) return;
2372
+ if (typeof data === "string") {
2373
+ log.write(data);
2374
+ return;
2375
+ }
2376
+ if (data instanceof Uint8Array) {
2377
+ log.write(data);
2378
+ return;
2379
+ }
2380
+ if (data instanceof ArrayBuffer) {
2381
+ log.write(new Uint8Array(data));
2382
+ return;
2383
+ }
2384
+ log.write(String(data));
2347
2385
  }
2348
2386
 
2349
2387
  #startHeartbeat(socket: Bun.WebSocket): void {
@@ -208,6 +208,11 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
208
208
  requiresAssistantAfterToolResult: false,
209
209
  requiresThinkingAsText: isMistral,
210
210
  requiresMistralToolIds: isMistral,
211
+ // Only Kimi's native hosts (Moonshot / Kimi-code, matched by `isMoonshotKimi`)
212
+ // speak the z.ai binary `thinking: { type }` field. Kimi reached through
213
+ // OpenAI-compatible proxies — Fireworks' Fire Pass router, OpenCode's gateway,
214
+ // etc. — drives reasoning via OpenAI-style `reasoning_effort`
215
+ // (low|medium|high|xhigh|max|none), so those stay on the "openai" path.
211
216
  thinkingFormat:
212
217
  isZai || isZhipu || isMoonshotKimi
213
218
  ? "zai"
@@ -1341,29 +1341,56 @@ export function parseChunkUsage(
1341
1341
  const completionTokenDetails = getOptionalObjectProperty(rawUsage, "completion_tokens_details");
1342
1342
  const cachedTokens =
1343
1343
  getOptionalNumberProperty(rawUsage, "cached_tokens") ??
1344
+ getOptionalNumberProperty(rawUsage, "prompt_cache_hit_tokens") ??
1344
1345
  (promptTokenDetails ? getOptionalNumberProperty(promptTokenDetails, "cached_tokens") : undefined) ??
1345
1346
  0;
1346
1347
  // OpenRouter exposes cache writes via `prompt_tokens_details.cache_write_tokens`
1347
- // and INCLUDES them in `prompt_tokens`. Without subtracting, cache-write tokens
1348
- // leak into `input` (e.g. GLM/Anthropic via OpenRouter on a fresh cache).
1348
+ // and INCLUDES them in `prompt_tokens` they are billed on top of the input, so
1349
+ // we subtract them to get the real billed input.
1350
+ // DeepSeek exposes cache hit/miss via `prompt_cache_hit_tokens` /
1351
+ // `prompt_cache_miss_tokens` at the top level where `prompt_tokens` equals their
1352
+ // sum. The miss portion IS the billed input — we must NOT subtract it.
1349
1353
  // Ref: https://openrouter.ai/docs/guides/best-practices/prompt-caching
1350
- const cacheWriteTokens = promptTokenDetails
1351
- ? (getOptionalNumberProperty(promptTokenDetails, "cache_write_tokens") ?? 0)
1352
- : 0;
1354
+ // Ref: https://api-docs.deepseek.com/api/create-chat-completion
1355
+ //
1356
+ // Resolve cacheWrite from both possible sources separately.
1357
+ // They have different billing semantics: OpenRouter's cache_write is billed
1358
+ // on top of prompt_tokens, while DeepSeek's miss IS the billed input.
1359
+ const cacheWriteOpenRouter = promptTokenDetails
1360
+ ? getOptionalNumberProperty(promptTokenDetails, "cache_write_tokens")
1361
+ : undefined;
1362
+ const cacheWriteDeepSeek = getOptionalNumberProperty(rawUsage, "prompt_cache_miss_tokens");
1363
+ // Prefer OpenRouter's value for the input subtraction; fall back to DeepSeek.
1364
+ const cacheWriteTokens = cacheWriteOpenRouter ?? cacheWriteDeepSeek ?? 0;
1365
+
1353
1366
  const reasoningTokens =
1354
1367
  (completionTokenDetails ? getOptionalNumberProperty(completionTokenDetails, "reasoning_tokens") : undefined) ?? 0;
1355
1368
  const promptTokens = getOptionalNumberProperty(rawUsage, "prompt_tokens") ?? 0;
1356
- const input = Math.max(0, promptTokens - cachedTokens - cacheWriteTokens);
1369
+
1370
+ const isDeepSeekNative =
1371
+ getOptionalNumberProperty(rawUsage, "prompt_cache_hit_tokens") !== undefined && cacheWriteDeepSeek !== undefined;
1372
+ // Only use the DeepSeek input path when cacheWrite came from DeepSeek's
1373
+ // miss field, not from prompt_tokens_details. Avoids false positives when
1374
+ // DeepSeek models route through OpenRouter (which may pass through native
1375
+ // fields alongside its own cache_write_tokens).
1376
+ const isDeepSeekUsage = isDeepSeekNative && cacheWriteOpenRouter === undefined && cacheWriteDeepSeek > 0;
1377
+ const input = isDeepSeekUsage
1378
+ ? Math.max(0, promptTokens - cachedTokens)
1379
+ : Math.max(0, promptTokens - cachedTokens - cacheWriteTokens);
1357
1380
  // Per OpenAI's CompletionUsage spec, `reasoning_tokens` is a subset of
1358
1381
  // `completion_tokens` (which is the total billed output). Adding them would
1359
1382
  // double-count.
1360
1383
  const outputTokens = getOptionalNumberProperty(rawUsage, "completion_tokens") ?? 0;
1384
+ // DeepSeek only exposes cache hit/miss (no cache-write data).
1385
+ // Emitting miss tokens as cacheWrite would make downstream consumers
1386
+ // double-count them (input already equals miss for DeepSeek).
1387
+ const emittedCacheWrite = isDeepSeekUsage ? 0 : cacheWriteTokens;
1361
1388
  const usage: AssistantMessage["usage"] = {
1362
1389
  input,
1363
1390
  output: outputTokens,
1364
1391
  cacheRead: cachedTokens,
1365
- cacheWrite: cacheWriteTokens,
1366
- totalTokens: input + outputTokens + cachedTokens + cacheWriteTokens,
1392
+ cacheWrite: emittedCacheWrite,
1393
+ totalTokens: input + outputTokens + cachedTokens + emittedCacheWrite,
1367
1394
  ...(reasoningTokens > 0 ? { reasoningTokens } : {}),
1368
1395
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1369
1396
  ...(premiumRequests !== undefined ? { premiumRequests } : {}),
package/src/stream.ts CHANGED
@@ -61,6 +61,7 @@ import type {
61
61
  } from "./types";
62
62
  import { AssistantMessageEventStream } from "./utils/event-stream";
63
63
  import { isFoundryEnabled } from "./utils/foundry";
64
+ import { withRequestDebugFetch } from "./utils/request-debug";
64
65
 
65
66
  let cachedVertexAdcCredentialsExists: boolean | null = null;
66
67
 
@@ -295,42 +296,50 @@ export function stream<TApi extends Api>(
295
296
  context: Context,
296
297
  options?: OptionsForApi<TApi>,
297
298
  ): AssistantMessageEventStream {
299
+ const requestOptions = withRequestDebugFetch(options as StreamOptions | undefined) as
300
+ | OptionsForApi<TApi>
301
+ | undefined;
302
+
298
303
  // Check custom API registry first (extension-provided APIs like "vertex-claude-api")
299
304
  const customApiProvider = getCustomApi(model.api);
300
305
  if (customApiProvider) {
301
- return customApiProvider.stream(model, context, options as StreamOptions);
306
+ return customApiProvider.stream(model, context, requestOptions as StreamOptions);
302
307
  }
303
308
 
304
309
  if (isGitLabDuoModel(model)) {
305
- const apiKey = (options as StreamOptions | undefined)?.apiKey || getEnvApiKey(model.provider);
310
+ const apiKey = (requestOptions as StreamOptions | undefined)?.apiKey || getEnvApiKey(model.provider);
306
311
  if (!apiKey) {
307
312
  throw new Error(`No API key for provider: ${model.provider}`);
308
313
  }
309
314
  return streamGitLabDuo(model, context, {
310
- ...(options as SimpleStreamOptions | undefined),
315
+ ...(requestOptions as SimpleStreamOptions | undefined),
311
316
  apiKey,
312
317
  });
313
318
  }
314
319
 
315
320
  // Vertex AI uses Application Default Credentials, not API keys
316
321
  if (model.api === "google-vertex") {
317
- return streamGoogleVertex(model as Model<"google-vertex">, context, options as GoogleVertexOptions);
322
+ return streamGoogleVertex(model as Model<"google-vertex">, context, requestOptions as GoogleVertexOptions);
318
323
  } else if (model.api === "bedrock-converse-stream") {
319
324
  // Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile.
320
- return streamBedrock(model as Model<"bedrock-converse-stream">, context, (options || {}) as BedrockOptions);
325
+ return streamBedrock(
326
+ model as Model<"bedrock-converse-stream">,
327
+ context,
328
+ (requestOptions || {}) as BedrockOptions,
329
+ );
321
330
  }
322
331
 
323
- const apiKey = options?.apiKey || getEnvApiKey(model.provider);
332
+ const apiKey = requestOptions?.apiKey || getEnvApiKey(model.provider);
324
333
  if (!apiKey) {
325
334
  throw new Error(`No API key for provider: ${model.provider}`);
326
335
  }
327
336
  const providerOptions = isGoogleVertexAuthenticatedModel(model)
328
337
  ? {
329
- ...options,
338
+ ...requestOptions,
330
339
  apiKey: "vertex-adc",
331
- fetch: createVertexAuthenticatedFetch(options as StreamOptions | undefined),
340
+ fetch: createVertexAuthenticatedFetch(requestOptions as StreamOptions | undefined),
332
341
  }
333
- : { ...options, apiKey };
342
+ : { ...requestOptions, apiKey };
334
343
 
335
344
  const api: Api = model.api;
336
345
  switch (api) {
@@ -430,10 +439,13 @@ export function streamSimple<TApi extends Api>(
430
439
  context: Context,
431
440
  options?: SimpleStreamOptions,
432
441
  ): AssistantMessageEventStream {
433
- const retryApiKey = options?.onAuthError ? (options.apiKey ?? getEnvApiKey(model.provider)) : undefined;
442
+ const requestOptions = withRequestDebugFetch(options);
443
+ const retryApiKey = requestOptions?.onAuthError
444
+ ? (requestOptions.apiKey ?? getEnvApiKey(model.provider))
445
+ : undefined;
434
446
  if (retryApiKey) {
435
447
  const outer = new AssistantMessageEventStream();
436
- const onAuthError = options!.onAuthError!;
448
+ const onAuthError = requestOptions!.onAuthError!;
437
449
  const runAttempt = async (apiKey: string, captureAuthFailure: boolean): Promise<AuthRetryFailure | undefined> => {
438
450
  const bufferedEvents: AssistantMessageEvent[] = [];
439
451
  let emittedReplayUnsafeEvent = false;
@@ -443,7 +455,7 @@ export function streamSimple<TApi extends Api>(
443
455
  };
444
456
 
445
457
  try {
446
- const inner = streamSimple(model, context, { ...options, apiKey, onAuthError: undefined });
458
+ const inner = streamSimple(model, context, { ...requestOptions, apiKey, onAuthError: undefined });
447
459
  for await (const event of inner) {
448
460
  if (!emittedReplayUnsafeEvent && event.type === "start") {
449
461
  bufferedEvents.push(event);
@@ -519,26 +531,26 @@ export function streamSimple<TApi extends Api>(
519
531
  // extension-registered APIs can't accidentally override a configured
520
532
  // pi-native transport.
521
533
  if (model.transport === "pi-native") {
522
- return streamPiNative(model, context, options);
534
+ return streamPiNative(model, context, requestOptions);
523
535
  }
524
536
 
525
537
  // Check custom API registry (extension-provided APIs)
526
538
  const customApiProvider = getCustomApi(model.api);
527
539
  if (customApiProvider) {
528
- return customApiProvider.streamSimple(model, context, options);
540
+ return customApiProvider.streamSimple(model, context, requestOptions);
529
541
  }
530
542
 
531
543
  // Vertex AI uses Application Default Credentials, not API keys
532
544
  if (model.api === "google-vertex") {
533
- const providerOptions = mapOptionsForApi(model, options, undefined);
545
+ const providerOptions = mapOptionsForApi(model, requestOptions, undefined);
534
546
  return stream(model, context, providerOptions);
535
547
  } else if (model.api === "bedrock-converse-stream") {
536
548
  // Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile.
537
- const providerOptions = mapOptionsForApi(model, options, undefined);
549
+ const providerOptions = mapOptionsForApi(model, requestOptions, undefined);
538
550
  return stream(model, context, providerOptions);
539
551
  }
540
552
 
541
- const apiKey = options?.apiKey || getEnvApiKey(model.provider);
553
+ const apiKey = requestOptions?.apiKey || getEnvApiKey(model.provider);
542
554
  if (!apiKey) {
543
555
  throw new Error(`No API key for provider: ${model.provider}`);
544
556
  }
@@ -546,7 +558,7 @@ export function streamSimple<TApi extends Api>(
546
558
  // GitLab Duo - wraps Anthropic/OpenAI behind GitLab AI Gateway direct access tokens
547
559
  if (isGitLabDuoModel(model)) {
548
560
  return streamGitLabDuo(model, context, {
549
- ...options,
561
+ ...requestOptions,
550
562
  apiKey,
551
563
  });
552
564
  }
@@ -555,9 +567,9 @@ export function streamSimple<TApi extends Api>(
555
567
  if (isKimiModel(model)) {
556
568
  // Pass raw SimpleStreamOptions - streamKimi handles mapping internally
557
569
  return streamKimi(model as Model<"openai-completions">, context, {
558
- ...options,
570
+ ...requestOptions,
559
571
  apiKey,
560
- format: options?.kimiApiFormat ?? "anthropic",
572
+ format: requestOptions?.kimiApiFormat ?? "anthropic",
561
573
  });
562
574
  }
563
575
 
@@ -565,13 +577,12 @@ export function streamSimple<TApi extends Api>(
565
577
  if (isSyntheticModel(model)) {
566
578
  // Pass raw SimpleStreamOptions - streamSynthetic handles mapping internally
567
579
  return streamSynthetic(model as Model<"openai-completions">, context, {
568
- ...options,
580
+ ...requestOptions,
569
581
  apiKey,
570
- format: options?.syntheticApiFormat ?? "openai", // Default to OpenAI format
582
+ format: requestOptions?.syntheticApiFormat ?? "openai", // Default to OpenAI format
571
583
  });
572
584
  }
573
-
574
- const providerOptions = mapOptionsForApi(model, options, apiKey);
585
+ const providerOptions = mapOptionsForApi(model, requestOptions, apiKey);
575
586
  return stream(model, context, providerOptions);
576
587
  }
577
588
 
@@ -716,6 +727,7 @@ function mapOptionsForApi<TApi extends Api>(
716
727
  onResponse: options?.onResponse,
717
728
  onSseEvent: options?.onSseEvent,
718
729
  execHandlers: options?.execHandlers,
730
+ fetch: options?.fetch,
719
731
  };
720
732
 
721
733
  switch (model.api) {
@@ -0,0 +1,336 @@
1
+ import { Buffer } from "node:buffer";
2
+ import * as fs from "node:fs/promises";
3
+ import type { FetchImpl } from "../types";
4
+
5
+ const REQUEST_DEBUG_ENV = "PI_REQ_DEBUG";
6
+ const DEBUG_FETCH_MARKER = Symbol("omp.requestDebugFetch");
7
+ const textEncoder = new TextEncoder();
8
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
9
+
10
+ let nextSessionId = 1;
11
+
12
+ type DebugFetch = FetchImpl & { [DEBUG_FETCH_MARKER]?: true };
13
+ type RequestBodyInit = NonNullable<RequestInit["body"]>;
14
+
15
+ type RequestDebugBody = { body: unknown } | { bodyText: string } | { bodyBase64: string } | { bodyUnavailable: string };
16
+
17
+ export type RequestDebugHeaders = Headers | Record<string, string | string[] | number | undefined | null> | undefined;
18
+
19
+ export interface RequestDebugPayload {
20
+ method: string;
21
+ url: string;
22
+ headers?: RequestDebugHeaders;
23
+ body?: unknown;
24
+ bodyText?: string;
25
+ bodyBase64?: string;
26
+ bodyUnavailable?: string;
27
+ protocol?: string;
28
+ }
29
+
30
+ export interface RequestDebugResponseLog {
31
+ write(chunk: Uint8Array | string): void;
32
+ close(): Promise<void>;
33
+ }
34
+
35
+ export interface RequestDebugSession {
36
+ readonly id: number;
37
+ readonly requestPath: string;
38
+ readonly responsePath: string;
39
+ openResponseLog(statusLine: string, headers?: RequestDebugHeaders): Promise<RequestDebugResponseLog>;
40
+ wrapResponse(response: Response): Promise<Response>;
41
+ }
42
+
43
+ export function isRequestDebugEnabled(): boolean {
44
+ return Bun.env[REQUEST_DEBUG_ENV] === "1";
45
+ }
46
+
47
+ export function wrapFetchForRequestDebug(fetchImpl: FetchImpl): FetchImpl {
48
+ if (!isRequestDebugEnabled()) return fetchImpl;
49
+ const maybeWrapped = fetchImpl as DebugFetch;
50
+ if (maybeWrapped[DEBUG_FETCH_MARKER]) return fetchImpl;
51
+
52
+ const wrapped = Object.assign(
53
+ async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
54
+ const session = await createFetchRequestDebugSession(input, init);
55
+ const response = await fetchImpl(input, init);
56
+ return session.wrapResponse(response);
57
+ },
58
+ fetchImpl.preconnect ? { preconnect: fetchImpl.preconnect } : {},
59
+ { [DEBUG_FETCH_MARKER]: true as const },
60
+ );
61
+ return wrapped;
62
+ }
63
+
64
+ export function withRequestDebugFetch<T extends { fetch?: FetchImpl } | undefined>(options: T): T {
65
+ if (!isRequestDebugEnabled()) return options;
66
+ const fetchImpl = options?.fetch ?? (globalThis.fetch as FetchImpl);
67
+ const wrapped = wrapFetchForRequestDebug(fetchImpl);
68
+ return { ...(options ?? {}), fetch: wrapped } as T;
69
+ }
70
+
71
+ export async function createRequestDebugSession(payload: RequestDebugPayload): Promise<RequestDebugSession> {
72
+ const { id, requestPath, responsePath, handle } = await reserveRequestDebugFile();
73
+ const requestDump: Record<string, unknown> = {
74
+ id,
75
+ protocol: payload.protocol ?? "http",
76
+ method: payload.method,
77
+ url: payload.url,
78
+ };
79
+ const headers = headersToRecord(payload.headers);
80
+ if (headers) requestDump.headers = headers;
81
+ if (payload.body !== undefined) requestDump.body = payload.body;
82
+ if (payload.bodyText !== undefined) requestDump.bodyText = payload.bodyText;
83
+ if (payload.bodyBase64 !== undefined) requestDump.bodyBase64 = payload.bodyBase64;
84
+ if (payload.bodyUnavailable !== undefined) requestDump.bodyUnavailable = payload.bodyUnavailable;
85
+
86
+ try {
87
+ await handle.writeFile(`${JSON.stringify(requestDump, null, 2)}\n`, "utf8");
88
+ } finally {
89
+ await handle.close();
90
+ }
91
+
92
+ return new FileRequestDebugSession(id, requestPath, responsePath);
93
+ }
94
+
95
+ async function createFetchRequestDebugSession(
96
+ input: string | URL | Request,
97
+ init: RequestInit | undefined,
98
+ ): Promise<RequestDebugSession> {
99
+ const headers = resolveRequestHeaders(input, init);
100
+ const body = await snapshotRequestBody(input, init, headers.get("content-type"));
101
+ return createRequestDebugSession({
102
+ method: resolveRequestMethod(input, init),
103
+ url: resolveRequestUrl(input),
104
+ headers,
105
+ ...body,
106
+ });
107
+ }
108
+
109
+ class FileRequestDebugSession implements RequestDebugSession {
110
+ readonly id: number;
111
+ readonly requestPath: string;
112
+ readonly responsePath: string;
113
+
114
+ constructor(id: number, requestPath: string, responsePath: string) {
115
+ this.id = id;
116
+ this.requestPath = requestPath;
117
+ this.responsePath = responsePath;
118
+ }
119
+
120
+ async openResponseLog(statusLine: string, headers?: RequestDebugHeaders): Promise<RequestDebugResponseLog> {
121
+ const handle = await fs.open(this.responsePath, "wx");
122
+ const headerBlock = formatResponseHeaderBlock(statusLine, headers);
123
+ await handle.write(textEncoder.encode(headerBlock));
124
+ return new FileRequestDebugResponseLog(handle);
125
+ }
126
+
127
+ async wrapResponse(response: Response): Promise<Response> {
128
+ const log = await this.openResponseLog(`HTTP ${response.status} ${response.statusText}`.trim(), response.headers);
129
+ if (!response.body) {
130
+ await log.close();
131
+ return response;
132
+ }
133
+
134
+ const reader = response.body.getReader();
135
+ const teed = new ReadableStream<Uint8Array>({
136
+ async pull(controller) {
137
+ try {
138
+ const { done, value } = await reader.read();
139
+ if (done) {
140
+ await log.close();
141
+ controller.close();
142
+ return;
143
+ }
144
+ log.write(value);
145
+ controller.enqueue(value);
146
+ } catch (error) {
147
+ await log.close().catch(() => undefined);
148
+ controller.error(error);
149
+ }
150
+ },
151
+ async cancel(reason) {
152
+ try {
153
+ await reader.cancel(reason);
154
+ } finally {
155
+ await log.close();
156
+ }
157
+ },
158
+ });
159
+
160
+ const wrapped = new Response(teed, {
161
+ status: response.status,
162
+ statusText: response.statusText,
163
+ headers: response.headers,
164
+ });
165
+ copyResponseMetadata(wrapped, response);
166
+ return wrapped;
167
+ }
168
+ }
169
+
170
+ class FileRequestDebugResponseLog implements RequestDebugResponseLog {
171
+ #handle: fs.FileHandle | undefined;
172
+ #pending: Promise<void> = Promise.resolve();
173
+
174
+ constructor(handle: fs.FileHandle) {
175
+ this.#handle = handle;
176
+ }
177
+
178
+ write(chunk: Uint8Array | string): void {
179
+ const handle = this.#handle;
180
+ if (!handle) return;
181
+ const bytes = typeof chunk === "string" ? textEncoder.encode(chunk) : chunk.slice();
182
+ this.#pending = this.#pending.then(async () => {
183
+ await handle.write(bytes);
184
+ });
185
+ }
186
+
187
+ async close(): Promise<void> {
188
+ const handle = this.#handle;
189
+ if (!handle) return;
190
+ this.#handle = undefined;
191
+ try {
192
+ await this.#pending;
193
+ } finally {
194
+ await handle.close();
195
+ }
196
+ }
197
+ }
198
+
199
+ function copyResponseMetadata(target: Response, source: Response): void {
200
+ const sourceUrl = source.url;
201
+ if (!sourceUrl) return;
202
+ try {
203
+ Object.defineProperty(target, "url", { value: sourceUrl, configurable: true });
204
+ } catch {
205
+ // Some runtimes may expose Response.url as non-configurable. The body
206
+ // capture remains correct; callers that need url already tolerate the
207
+ // platform default on other response wrappers in this package.
208
+ }
209
+ }
210
+
211
+ async function reserveRequestDebugFile(): Promise<{
212
+ id: number;
213
+ requestPath: string;
214
+ responsePath: string;
215
+ handle: fs.FileHandle;
216
+ }> {
217
+ for (;;) {
218
+ const id = nextSessionId++;
219
+ const requestPath = `rr-session-${id}.json`;
220
+ try {
221
+ const handle = await fs.open(requestPath, "wx");
222
+ return { id, requestPath, responsePath: `rr-session-${id}.res.log`, handle };
223
+ } catch (error) {
224
+ if (isFileExistsError(error)) continue;
225
+ throw error;
226
+ }
227
+ }
228
+ }
229
+
230
+ function resolveRequestMethod(input: string | URL | Request, init: RequestInit | undefined): string {
231
+ return (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
232
+ }
233
+
234
+ function resolveRequestUrl(input: string | URL | Request): string {
235
+ return input instanceof Request ? input.url : input.toString();
236
+ }
237
+
238
+ function resolveRequestHeaders(input: string | URL | Request, init: RequestInit | undefined): Headers {
239
+ if (init?.headers) return new Headers(init.headers);
240
+ return input instanceof Request ? new Headers(input.headers) : new Headers();
241
+ }
242
+
243
+ async function snapshotRequestBody(
244
+ input: string | URL | Request,
245
+ init: RequestInit | undefined,
246
+ contentType: string | null,
247
+ ): Promise<RequestDebugBody | undefined> {
248
+ if (init?.body !== undefined && init.body !== null) return snapshotBodyInit(init.body, contentType);
249
+ if (input instanceof Request && input.body) {
250
+ return snapshotBytes(new Uint8Array(await input.clone().arrayBuffer()), contentType);
251
+ }
252
+ return undefined;
253
+ }
254
+
255
+ async function snapshotBodyInit(body: RequestBodyInit, contentType: string | null): Promise<RequestDebugBody> {
256
+ if (typeof body === "string") return snapshotText(body, contentType);
257
+ if (body instanceof URLSearchParams) return { bodyText: body.toString() };
258
+ if (body instanceof FormData) return { bodyUnavailable: "FormData" };
259
+ if (body instanceof Blob) return snapshotBytes(new Uint8Array(await body.arrayBuffer()), body.type || contentType);
260
+ if (body instanceof ArrayBuffer) return snapshotBytes(new Uint8Array(body), contentType);
261
+ if (ArrayBuffer.isView(body)) {
262
+ return snapshotBytes(new Uint8Array(body.buffer, body.byteOffset, body.byteLength), contentType);
263
+ }
264
+ if (body instanceof ReadableStream) return { bodyUnavailable: "ReadableStream" };
265
+ return { bodyText: String(body) };
266
+ }
267
+
268
+ function snapshotBytes(bytes: Uint8Array, contentType: string | null): RequestDebugBody {
269
+ try {
270
+ return snapshotText(utf8Decoder.decode(bytes), contentType);
271
+ } catch {
272
+ return { bodyBase64: Buffer.from(bytes).toString("base64") };
273
+ }
274
+ }
275
+
276
+ function snapshotText(text: string, contentType: string | null): RequestDebugBody {
277
+ if (isJsonContentType(contentType) || looksLikeJson(text)) {
278
+ try {
279
+ return { body: JSON.parse(text) };
280
+ } catch {
281
+ // Fall through to bodyText: malformed JSON is still useful as raw text.
282
+ }
283
+ }
284
+ return { bodyText: text };
285
+ }
286
+
287
+ function isJsonContentType(contentType: string | null): boolean {
288
+ if (!contentType) return false;
289
+ const lower = contentType.toLowerCase();
290
+ return lower.includes("application/json") || lower.includes("+json");
291
+ }
292
+
293
+ function looksLikeJson(text: string): boolean {
294
+ const trimmed = text.trimStart();
295
+ return trimmed.startsWith("{") || trimmed.startsWith("[");
296
+ }
297
+
298
+ function formatResponseHeaderBlock(statusLine: string, headers?: RequestDebugHeaders): string {
299
+ const lines = [statusLine];
300
+ const record = headersToRecord(headers);
301
+ if (record) {
302
+ for (const name in record) {
303
+ const value = record[name];
304
+ if (Array.isArray(value)) {
305
+ for (const item of value) lines.push(`${name}: ${item}`);
306
+ } else {
307
+ lines.push(`${name}: ${value}`);
308
+ }
309
+ }
310
+ }
311
+ return `${lines.join("\r\n")}\r\n\r\n`;
312
+ }
313
+
314
+ function headersToRecord(headers: RequestDebugHeaders): Record<string, string | string[]> | undefined {
315
+ if (!headers) return undefined;
316
+ const record: Record<string, string | string[]> = {};
317
+ let hasHeaders = false;
318
+ if (headers instanceof Headers) {
319
+ headers.forEach((value, key) => {
320
+ hasHeaders = true;
321
+ record[key] = value;
322
+ });
323
+ } else {
324
+ for (const key in headers) {
325
+ const value = headers[key];
326
+ if (value === undefined || value === null) continue;
327
+ hasHeaders = true;
328
+ record[key] = Array.isArray(value) ? value.map(String) : String(value);
329
+ }
330
+ }
331
+ return hasHeaders ? record : undefined;
332
+ }
333
+
334
+ function isFileExistsError(error: unknown): boolean {
335
+ return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "EEXIST";
336
+ }