@oh-my-pi/pi-ai 16.3.6 → 16.3.7

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 (226) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/types/api-registry.d.ts +1 -1
  3. package/dist/types/auth-broker/client.d.ts +2 -2
  4. package/dist/types/auth-broker/discover.d.ts +1 -1
  5. package/dist/types/auth-broker/index.d.ts +7 -7
  6. package/dist/types/auth-broker/refresher.d.ts +1 -1
  7. package/dist/types/auth-broker/remote-store.d.ts +6 -6
  8. package/dist/types/auth-broker/server.d.ts +1 -1
  9. package/dist/types/auth-broker/snapshot-cache.d.ts +1 -1
  10. package/dist/types/auth-broker/types.d.ts +2 -2
  11. package/dist/types/auth-gateway/index.d.ts +3 -3
  12. package/dist/types/auth-gateway/server.d.ts +3 -3
  13. package/dist/types/auth-gateway/types.d.ts +1 -1
  14. package/dist/types/auth-retry.d.ts +2 -2
  15. package/dist/types/auth-storage.d.ts +6 -6
  16. package/dist/types/dialect/anthropic.d.ts +1 -1
  17. package/dist/types/dialect/catalog.d.ts +1 -1
  18. package/dist/types/dialect/coercion.d.ts +1 -1
  19. package/dist/types/dialect/deepseek.d.ts +1 -1
  20. package/dist/types/dialect/demotion.d.ts +16 -14
  21. package/dist/types/dialect/examples.d.ts +1 -1
  22. package/dist/types/dialect/factory.d.ts +1 -1
  23. package/dist/types/dialect/gemini.d.ts +1 -1
  24. package/dist/types/dialect/gemma.d.ts +1 -1
  25. package/dist/types/dialect/glm.d.ts +1 -1
  26. package/dist/types/dialect/harmony.d.ts +1 -1
  27. package/dist/types/dialect/hermes.d.ts +1 -1
  28. package/dist/types/dialect/history.d.ts +2 -2
  29. package/dist/types/dialect/index.d.ts +11 -11
  30. package/dist/types/dialect/inventory.d.ts +1 -1
  31. package/dist/types/dialect/kimi.d.ts +1 -1
  32. package/dist/types/dialect/minimax.d.ts +1 -1
  33. package/dist/types/dialect/owned-stream.d.ts +2 -2
  34. package/dist/types/dialect/qwen3.d.ts +1 -1
  35. package/dist/types/dialect/rendering.d.ts +2 -2
  36. package/dist/types/dialect/thinking.d.ts +1 -1
  37. package/dist/types/dialect/types.d.ts +1 -1
  38. package/dist/types/dialect/xml.d.ts +1 -1
  39. package/dist/types/error/classes.d.ts +1 -1
  40. package/dist/types/error/finalize.d.ts +3 -3
  41. package/dist/types/error/flags.d.ts +1 -1
  42. package/dist/types/error/format.d.ts +1 -1
  43. package/dist/types/error/index.d.ts +13 -13
  44. package/dist/types/error/provider.d.ts +1 -1
  45. package/dist/types/error/rate-limit.d.ts +1 -1
  46. package/dist/types/index.d.ts +47 -47
  47. package/dist/types/provider-details.d.ts +1 -1
  48. package/dist/types/providers/amazon-bedrock.d.ts +1 -1
  49. package/dist/types/providers/anthropic-client.d.ts +3 -3
  50. package/dist/types/providers/anthropic-messages-server-schema.d.ts +1 -1
  51. package/dist/types/providers/anthropic-messages-server.d.ts +2 -2
  52. package/dist/types/providers/anthropic-wire.d.ts +1 -1
  53. package/dist/types/providers/anthropic.d.ts +3 -3
  54. package/dist/types/providers/aws-credentials.d.ts +2 -2
  55. package/dist/types/providers/azure-openai-responses.d.ts +2 -2
  56. package/dist/types/providers/cursor.d.ts +18 -3
  57. package/dist/types/providers/devin.d.ts +1 -1
  58. package/dist/types/providers/error-message.d.ts +1 -1
  59. package/dist/types/providers/github-copilot-headers.d.ts +1 -1
  60. package/dist/types/providers/gitlab-duo-workflow.d.ts +2 -2
  61. package/dist/types/providers/gitlab-duo.d.ts +2 -2
  62. package/dist/types/providers/google-auth.d.ts +1 -1
  63. package/dist/types/providers/google-gemini-cli.d.ts +6 -4
  64. package/dist/types/providers/google-interactions.d.ts +3 -3
  65. package/dist/types/providers/google-shared.d.ts +7 -5
  66. package/dist/types/providers/google-vertex.d.ts +2 -2
  67. package/dist/types/providers/google.d.ts +2 -2
  68. package/dist/types/providers/kimi.d.ts +3 -3
  69. package/dist/types/providers/mock.d.ts +2 -2
  70. package/dist/types/providers/ollama.d.ts +1 -1
  71. package/dist/types/providers/openai-anthropic-shim.d.ts +2 -2
  72. package/dist/types/providers/openai-chat-server-schema.d.ts +1 -1
  73. package/dist/types/providers/openai-chat-server.d.ts +2 -2
  74. package/dist/types/providers/openai-codex/request-transformer.d.ts +1 -1
  75. package/dist/types/providers/openai-codex/response-handler.d.ts +1 -1
  76. package/dist/types/providers/openai-codex-responses.d.ts +3 -3
  77. package/dist/types/providers/openai-completions.d.ts +3 -3
  78. package/dist/types/providers/openai-reasoning-fallback.d.ts +1 -1
  79. package/dist/types/providers/openai-responses-server-schema.d.ts +1 -1
  80. package/dist/types/providers/openai-responses-server.d.ts +2 -2
  81. package/dist/types/providers/openai-responses.d.ts +5 -5
  82. package/dist/types/providers/openai-shared.d.ts +10 -9
  83. package/dist/types/providers/pi-native-client.d.ts +1 -1
  84. package/dist/types/providers/pi-native-server.d.ts +2 -2
  85. package/dist/types/providers/register-builtins.d.ts +3 -3
  86. package/dist/types/providers/synthetic.d.ts +3 -3
  87. package/dist/types/providers/transform-messages.d.ts +1 -1
  88. package/dist/types/providers/vision-guard.d.ts +1 -1
  89. package/dist/types/registry/alibaba-coding-plan.d.ts +1 -1
  90. package/dist/types/registry/anthropic.d.ts +1 -1
  91. package/dist/types/registry/api-key-login.d.ts +1 -1
  92. package/dist/types/registry/api-key-validation.d.ts +1 -1
  93. package/dist/types/registry/baseten.d.ts +2 -2
  94. package/dist/types/registry/cerebras.d.ts +2 -2
  95. package/dist/types/registry/cloudflare-ai-gateway.d.ts +1 -1
  96. package/dist/types/registry/coreweave.d.ts +2 -2
  97. package/dist/types/registry/cursor.d.ts +1 -1
  98. package/dist/types/registry/deepseek.d.ts +1 -1
  99. package/dist/types/registry/devin.d.ts +1 -1
  100. package/dist/types/registry/firepass.d.ts +2 -2
  101. package/dist/types/registry/fireworks.d.ts +2 -2
  102. package/dist/types/registry/github-copilot.d.ts +1 -1
  103. package/dist/types/registry/gitlab-duo-workflow.d.ts +1 -1
  104. package/dist/types/registry/gitlab-duo.d.ts +1 -1
  105. package/dist/types/registry/google-antigravity.d.ts +1 -1
  106. package/dist/types/registry/google-gemini-cli.d.ts +1 -1
  107. package/dist/types/registry/huggingface.d.ts +2 -2
  108. package/dist/types/registry/index.d.ts +4 -4
  109. package/dist/types/registry/kagi.d.ts +1 -1
  110. package/dist/types/registry/kilo.d.ts +1 -1
  111. package/dist/types/registry/kimi-code.d.ts +1 -1
  112. package/dist/types/registry/litellm.d.ts +1 -1
  113. package/dist/types/registry/llama-cpp.d.ts +1 -1
  114. package/dist/types/registry/lm-studio.d.ts +1 -1
  115. package/dist/types/registry/minimax-code-cn.d.ts +1 -1
  116. package/dist/types/registry/minimax-code.d.ts +1 -1
  117. package/dist/types/registry/moonshot.d.ts +2 -2
  118. package/dist/types/registry/nanogpt.d.ts +2 -2
  119. package/dist/types/registry/nvidia.d.ts +1 -1
  120. package/dist/types/registry/oauth/anthropic.d.ts +3 -3
  121. package/dist/types/registry/oauth/callback-server.d.ts +1 -1
  122. package/dist/types/registry/oauth/cursor.d.ts +1 -1
  123. package/dist/types/registry/oauth/devin.d.ts +1 -1
  124. package/dist/types/registry/oauth/github-copilot.d.ts +2 -2
  125. package/dist/types/registry/oauth/gitlab-duo-workflow.d.ts +2 -2
  126. package/dist/types/registry/oauth/gitlab-duo.d.ts +1 -1
  127. package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
  128. package/dist/types/registry/oauth/google-gemini-cli.d.ts +1 -1
  129. package/dist/types/registry/oauth/google-oauth-shared.d.ts +2 -2
  130. package/dist/types/registry/oauth/index.d.ts +2 -2
  131. package/dist/types/registry/oauth/kimi.d.ts +1 -1
  132. package/dist/types/registry/oauth/minimax-code.d.ts +2 -2
  133. package/dist/types/registry/oauth/openai-codex.d.ts +1 -1
  134. package/dist/types/registry/oauth/opencode.d.ts +1 -1
  135. package/dist/types/registry/oauth/perplexity.d.ts +1 -1
  136. package/dist/types/registry/oauth/types.d.ts +2 -2
  137. package/dist/types/registry/oauth/wafer.d.ts +1 -1
  138. package/dist/types/registry/oauth/xai-oauth.d.ts +3 -3
  139. package/dist/types/registry/oauth/xiaomi.d.ts +1 -1
  140. package/dist/types/registry/ollama-cloud.d.ts +1 -1
  141. package/dist/types/registry/ollama.d.ts +1 -1
  142. package/dist/types/registry/openai-codex-device.d.ts +1 -1
  143. package/dist/types/registry/openai-codex.d.ts +1 -1
  144. package/dist/types/registry/opencode-go.d.ts +1 -1
  145. package/dist/types/registry/opencode-zen.d.ts +1 -1
  146. package/dist/types/registry/openrouter.d.ts +2 -2
  147. package/dist/types/registry/parallel.d.ts +1 -1
  148. package/dist/types/registry/perplexity.d.ts +2 -2
  149. package/dist/types/registry/qianfan.d.ts +2 -2
  150. package/dist/types/registry/qwen-portal.d.ts +1 -1
  151. package/dist/types/registry/registry.d.ts +69 -69
  152. package/dist/types/registry/sakana.d.ts +2 -2
  153. package/dist/types/registry/synthetic.d.ts +2 -2
  154. package/dist/types/registry/tavily.d.ts +1 -1
  155. package/dist/types/registry/together.d.ts +1 -1
  156. package/dist/types/registry/types.d.ts +1 -1
  157. package/dist/types/registry/umans.d.ts +2 -2
  158. package/dist/types/registry/venice.d.ts +2 -2
  159. package/dist/types/registry/vercel-ai-gateway.d.ts +1 -1
  160. package/dist/types/registry/vllm.d.ts +1 -1
  161. package/dist/types/registry/wafer-serverless.d.ts +1 -1
  162. package/dist/types/registry/xai-oauth.d.ts +1 -1
  163. package/dist/types/registry/xiaomi-token-plan-ams.d.ts +1 -1
  164. package/dist/types/registry/xiaomi-token-plan-cn.d.ts +1 -1
  165. package/dist/types/registry/xiaomi-token-plan-sgp.d.ts +1 -1
  166. package/dist/types/registry/xiaomi.d.ts +1 -1
  167. package/dist/types/registry/zai.d.ts +2 -2
  168. package/dist/types/registry/zenmux.d.ts +2 -2
  169. package/dist/types/registry/zhipu-coding-plan.d.ts +2 -2
  170. package/dist/types/stream.d.ts +6 -6
  171. package/dist/types/types.d.ts +24 -21
  172. package/dist/types/usage/claude.d.ts +1 -1
  173. package/dist/types/usage/gemini.d.ts +1 -1
  174. package/dist/types/usage/github-copilot.d.ts +1 -1
  175. package/dist/types/usage/google-antigravity.d.ts +1 -1
  176. package/dist/types/usage/kimi.d.ts +1 -1
  177. package/dist/types/usage/minimax-code.d.ts +1 -1
  178. package/dist/types/usage/ollama.d.ts +1 -1
  179. package/dist/types/usage/openai-codex-reset.d.ts +1 -1
  180. package/dist/types/usage/openai-codex.d.ts +1 -1
  181. package/dist/types/usage/opencode-go.d.ts +1 -1
  182. package/dist/types/usage/zai.d.ts +1 -1
  183. package/dist/types/usage.d.ts +1 -1
  184. package/dist/types/utils/block-symbols.d.ts +15 -0
  185. package/dist/types/utils/empty-completion-retry.d.ts +2 -2
  186. package/dist/types/utils/event-stream.d.ts +1 -1
  187. package/dist/types/utils/harmony-leak.d.ts +1 -1
  188. package/dist/types/utils/http-inspector.d.ts +19 -0
  189. package/dist/types/utils/leaked-thinking-stream.d.ts +1 -1
  190. package/dist/types/utils/openai-http.d.ts +3 -9
  191. package/dist/types/utils/provider-response.d.ts +1 -1
  192. package/dist/types/utils/proxy.d.ts +1 -1
  193. package/dist/types/utils/request-debug.d.ts +1 -1
  194. package/dist/types/utils/retry.d.ts +1 -1
  195. package/dist/types/utils/schema/equality.d.ts +1 -1
  196. package/dist/types/utils/schema/index.d.ts +15 -15
  197. package/dist/types/utils/schema/normalize.d.ts +2 -2
  198. package/dist/types/utils/schema/spill.d.ts +1 -1
  199. package/dist/types/utils/schema/wire.d.ts +1 -1
  200. package/dist/types/utils/sse-debug.d.ts +1 -1
  201. package/dist/types/utils/thinking-loop.d.ts +2 -2
  202. package/dist/types/utils/tool-call-loop-guard.d.ts +1 -1
  203. package/dist/types/utils/tool-choice.d.ts +1 -1
  204. package/dist/types/utils/validation.d.ts +1 -1
  205. package/dist/types/utils.d.ts +14 -2
  206. package/package.json +4 -4
  207. package/src/dialect/demotion.ts +18 -19
  208. package/src/error/rate-limit.ts +1 -1
  209. package/src/providers/azure-openai-responses.ts +3 -2
  210. package/src/providers/cursor.ts +36 -0
  211. package/src/providers/google-gemini-cli.ts +12 -3
  212. package/src/providers/google-shared.ts +3 -1
  213. package/src/providers/mock.ts +10 -3
  214. package/src/providers/openai-codex-responses.ts +47 -39
  215. package/src/providers/openai-completions.ts +48 -9
  216. package/src/providers/openai-responses.ts +37 -23
  217. package/src/providers/openai-shared.ts +145 -26
  218. package/src/providers/transform-messages.ts +63 -9
  219. package/src/registry/oauth/wafer.ts +1 -1
  220. package/src/stream.ts +6 -0
  221. package/src/types.ts +27 -3
  222. package/src/utils/block-symbols.ts +18 -0
  223. package/src/utils/http-inspector.ts +32 -4
  224. package/src/utils/openai-http.ts +6 -9
  225. package/src/utils/retry-after.ts +12 -1
  226. package/src/utils.ts +78 -0
@@ -22,6 +22,7 @@ import {
22
22
  import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot";
23
23
  import {
24
24
  $env,
25
+ classifyJsonPrefix,
25
26
  extractHttpStatusFromError,
26
27
  logger,
27
28
  parseStreamingJson,
@@ -39,7 +40,6 @@ import {
39
40
  type MessageAttribution,
40
41
  type Model,
41
42
  OPENAI_MAX_OUTPUT_TOKENS,
42
- type Provider,
43
43
  type ServiceTier,
44
44
  type StopReason,
45
45
  type StreamOptions,
@@ -50,6 +50,7 @@ import {
50
50
  type Tool,
51
51
  type ToolCall,
52
52
  type ToolResultMessage,
53
+ type Usage,
53
54
  } from "../types";
54
55
  import {
55
56
  getOpenAIResponsesHistoryItems,
@@ -57,6 +58,8 @@ import {
57
58
  normalizeResponsesToolCallId,
58
59
  normalizeSystemPrompts,
59
60
  resolveCacheRetention,
61
+ sanitizeOpenAIResponsesAssistantFallbackItemsForReplay,
62
+ sanitizeOpenAIResponsesAssistantHistoryItemsForReplay,
60
63
  sanitizeOpenAIResponsesHistoryItemsForReplay,
61
64
  } from "../utils";
62
65
  import {
@@ -271,9 +274,9 @@ export function resolveOpenAIRequestSetup(
271
274
  export function applyOpenAIServiceTier(
272
275
  params: { service_tier?: ServiceTier | null | undefined },
273
276
  serviceTier: ServiceTier | null | undefined,
274
- provider: Provider | undefined,
277
+ model: Pick<Model, "provider" | "api" | "id">,
275
278
  ): void {
276
- if (!shouldSendServiceTier(serviceTier, provider)) return;
279
+ if (!shouldSendServiceTier(serviceTier, model)) return;
277
280
  if (serviceTier === "flex" || serviceTier === "scale" || serviceTier === "priority") {
278
281
  params.service_tier = serviceTier;
279
282
  }
@@ -341,6 +344,7 @@ export interface OpenAIUsageAccounting {
341
344
  cacheWrite: number;
342
345
  totalTokens: number;
343
346
  reasoningTokens?: number;
347
+ orchestration?: Usage["orchestration"];
344
348
  }
345
349
 
346
350
  export function calculateOpenAIUsageAccounting(accounting: OpenAIUsageAccountingInput): OpenAIUsageAccounting {
@@ -1405,39 +1409,51 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
1405
1409
  });
1406
1410
  } else if (msg.role === "assistant") {
1407
1411
  const assistantMsg = msg as AssistantMessage;
1412
+ // Providers replay stale native items even when the current request has
1413
+ // disabled native replay (cold session state, filter policy). Consult
1414
+ // the payload sanitizer directly so hidden-empty turns are recognized
1415
+ // on both the warm and cold paths.
1408
1416
  const providerPayload =
1409
- options.nativeHistory?.replay &&
1410
- assistantMsg.api === options.model.api &&
1411
- assistantMsg.model === options.model.id
1417
+ assistantMsg.api === options.model.api && assistantMsg.model === options.model.id
1412
1418
  ? getOpenAIResponsesHistoryPayload(
1413
1419
  assistantMsg.providerPayload,
1414
1420
  options.model.provider,
1415
1421
  assistantMsg.provider,
1416
1422
  )
1417
1423
  : undefined;
1424
+ const nativeReplayEnabled = options.nativeHistory?.replay === true;
1418
1425
  const historyItems = providerPayload?.items;
1426
+ let suppressHiddenEmptyFallback = false;
1419
1427
  if (historyItems) {
1420
- const sanitizedHistoryItems = sanitizeOpenAIResponsesHistoryItemsForReplay(filterReasoning(historyItems));
1421
- if (providerPayload?.dt) {
1422
- messages.push(...sanitizedHistoryItems);
1423
- } else {
1424
- messages.splice(0, messages.length, ...sanitizedHistoryItems);
1428
+ const sanitizedHistoryItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
1429
+ filterReasoning(historyItems),
1430
+ );
1431
+ if (nativeReplayEnabled && sanitizedHistoryItems) {
1432
+ if (providerPayload?.dt) {
1433
+ messages.push(...sanitizedHistoryItems);
1434
+ } else {
1435
+ messages.splice(0, messages.length, ...sanitizedHistoryItems);
1436
+ }
1437
+ knownCallIds = collectKnownCallIds(messages);
1438
+ for (const id of collectCustomCallIds(messages)) customCallIds.add(id);
1439
+ msgIndex++;
1440
+ continue;
1425
1441
  }
1426
- knownCallIds = collectKnownCallIds(messages);
1427
- for (const id of collectCustomCallIds(messages)) customCallIds.add(id);
1428
- msgIndex++;
1429
- continue;
1442
+ if (!sanitizedHistoryItems) suppressHiddenEmptyFallback = true;
1430
1443
  }
1431
1444
 
1432
- const outputItems = convertResponsesAssistantMessage(
1445
+ const convertedOutputItems = convertResponsesAssistantMessage(
1433
1446
  assistantMsg,
1434
1447
  options.model,
1435
1448
  msgIndex,
1436
1449
  knownCallIds,
1437
- includeThinkingSignatures,
1450
+ suppressHiddenEmptyFallback ? false : includeThinkingSignatures,
1438
1451
  customCallIds,
1439
1452
  options.preserveAssistantMessageIds,
1440
1453
  );
1454
+ const outputItems = suppressHiddenEmptyFallback
1455
+ ? sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(convertedOutputItems)
1456
+ : convertedOutputItems;
1441
1457
  if (outputItems.length === 0) continue;
1442
1458
  messages.push(...outputItems);
1443
1459
  } else if (msg.role === "toolResult") {
@@ -1874,6 +1890,58 @@ export async function processResponsesStream<TApi extends Api>(
1874
1890
  };
1875
1891
  const hasOpenItemKey = (event: { output_index?: number; item_id?: string }): boolean =>
1876
1892
  typeof event.output_index === "number" || event.item_id !== undefined;
1893
+ const startsJsonObjectDelta = (delta: unknown): boolean => {
1894
+ if (typeof delta !== "string") return false;
1895
+ for (let index = 0; index < delta.length; index++) {
1896
+ const code = delta.charCodeAt(index);
1897
+ if (code === 0x09 || code === 0x0a || code === 0x0d || code === 0x20) continue;
1898
+ return code === 0x7b;
1899
+ }
1900
+ return false;
1901
+ };
1902
+ const shouldAdvanceIdentifierlessFunctionDelta = (
1903
+ event: { output_index?: number; item_id?: string; delta?: unknown },
1904
+ candidate: StreamingItem,
1905
+ ): boolean => {
1906
+ const delta = event.delta;
1907
+ if (
1908
+ hasOpenItemKey(event) ||
1909
+ typeof delta !== "string" ||
1910
+ !startsJsonObjectDelta(delta) ||
1911
+ candidate.item.type !== "function_call" ||
1912
+ candidate.block.type !== "toolCall"
1913
+ ) {
1914
+ return false;
1915
+ }
1916
+ const partial = candidate.block[kStreamingPartialJson];
1917
+ if (partial.trim().length === 0) return false;
1918
+ // A `{`-starting identifierless delta is ambiguous: the opening of a new
1919
+ // sibling call, or continuation bytes inside the candidate's own argument
1920
+ // JSON (`{"command":"echo ` + `{1..3}"}`). Advance only when the candidate
1921
+ // cannot absorb the delta: its buffer is already one complete JSON value,
1922
+ // already unsalvageable (lossy hosts abandon buffers mid-string, leaving
1923
+ // raw control characters strict JSON forbids), or the concatenation would
1924
+ // break it. Otherwise the delta is a legal continuation and must stay.
1925
+ const state = classifyJsonPrefix(partial);
1926
+ if (state !== "prefix") return true;
1927
+ return classifyJsonPrefix(partial + delta) === "invalid";
1928
+ };
1929
+ const hasLaterUnfinishedFunctionCall = (start: number): boolean => {
1930
+ for (let index = start + 1; index < openItemsInOrder.length; index++) {
1931
+ const candidate = openItemsInOrder[index];
1932
+ if (
1933
+ candidate?.item.type === "function_call" &&
1934
+ candidate.block.type === "toolCall" &&
1935
+ !candidate.block[kStreamingArgumentsDone]
1936
+ ) {
1937
+ return true;
1938
+ }
1939
+ }
1940
+ return false;
1941
+ };
1942
+
1943
+ let identifierlessFunctionDeltaTarget: StreamingItem | undefined;
1944
+
1877
1945
  const lookupOpenToolCallAlias = (
1878
1946
  event: { output_index?: number; item_id?: string },
1879
1947
  type: "function_call" | "custom_tool_call",
@@ -1902,17 +1970,42 @@ export async function processResponsesStream<TApi extends Api>(
1902
1970
  const lookupOpenFunctionCallItem = (event: {
1903
1971
  output_index?: number;
1904
1972
  item_id?: string;
1973
+ delta?: unknown;
1905
1974
  }): StreamingItem | undefined => {
1906
1975
  if (hasOpenItemKey(event)) return lookupOpenToolCallAlias(event, "function_call");
1907
- for (const candidate of openItemsInOrder) {
1976
+ const canContinuePreviousIdentifierlessDelta = typeof event.delta === "string";
1977
+ if (canContinuePreviousIdentifierlessDelta && identifierlessFunctionDeltaTarget) {
1978
+ const targetIndex = openItemsInOrder.indexOf(identifierlessFunctionDeltaTarget);
1979
+ const target = targetIndex >= 0 ? openItemsInOrder[targetIndex] : undefined;
1980
+ if (
1981
+ target?.item.type === "function_call" &&
1982
+ target.block.type === "toolCall" &&
1983
+ !target.block[kStreamingArgumentsDone]
1984
+ ) {
1985
+ const shouldAdvanceFromTarget =
1986
+ shouldAdvanceIdentifierlessFunctionDelta(event, target) && hasLaterUnfinishedFunctionCall(targetIndex);
1987
+ if (!shouldAdvanceFromTarget) return target;
1988
+ } else {
1989
+ identifierlessFunctionDeltaTarget = undefined;
1990
+ }
1991
+ }
1992
+ let skippedStartedCandidate = false;
1993
+ for (let index = 0; index < openItemsInOrder.length; index++) {
1994
+ const candidate = openItemsInOrder[index]!;
1908
1995
  if (
1909
1996
  candidate.item.type === "function_call" &&
1910
1997
  candidate.block.type === "toolCall" &&
1911
1998
  !candidate.block[kStreamingArgumentsDone]
1912
1999
  ) {
2000
+ if (shouldAdvanceIdentifierlessFunctionDelta(event, candidate) && hasLaterUnfinishedFunctionCall(index)) {
2001
+ skippedStartedCandidate = true;
2002
+ continue;
2003
+ }
2004
+ if (canContinuePreviousIdentifierlessDelta) identifierlessFunctionDeltaTarget = candidate;
1913
2005
  return candidate;
1914
2006
  }
1915
2007
  }
2008
+ if (skippedStartedCandidate && startsJsonObjectDelta(event.delta)) return undefined;
1916
2009
  return lastOpenItem?.item.type === "function_call" ? lastOpenItem : undefined;
1917
2010
  };
1918
2011
  const closeOpenItem = (
@@ -1937,6 +2030,7 @@ export async function processResponsesStream<TApi extends Api>(
1937
2030
  const index = openItemsInOrder.indexOf(entry);
1938
2031
  if (index >= 0) openItemsInOrder.splice(index, 1);
1939
2032
  }
2033
+ if (entry && identifierlessFunctionDeltaTarget === entry) identifierlessFunctionDeltaTarget = undefined;
1940
2034
  if (entry && lastOpenItem === entry) lastOpenItem = null;
1941
2035
  };
1942
2036
  const contentIndexOf = (block: ThinkingContent | TextContent | StreamingToolCallBlock): number =>
@@ -2150,9 +2244,11 @@ export async function processResponsesStream<TApi extends Api>(
2150
2244
  const block = entry?.block.type === "toolCall" ? entry.block : undefined;
2151
2245
  const args = block?.[kStreamingArgumentsDone]
2152
2246
  ? block.arguments
2153
- : block?.[kStreamingPartialJson]
2154
- ? parseStreamingJson(block[kStreamingPartialJson])
2155
- : parseStreamingJson(item.arguments || "{}");
2247
+ : item.arguments
2248
+ ? parseStreamingJson(item.arguments)
2249
+ : block?.[kStreamingPartialJson]
2250
+ ? parseStreamingJson(block[kStreamingPartialJson])
2251
+ : parseStreamingJson("{}");
2156
2252
  const toolCall: ToolCall = {
2157
2253
  type: "toolCall",
2158
2254
  id: encodeResponsesToolCallId(item.call_id, item.id),
@@ -2385,7 +2481,7 @@ type CommonSamplingOptions = Pick<
2385
2481
  export function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(
2386
2482
  params: P,
2387
2483
  options: CommonSamplingOptions | undefined,
2388
- model: Pick<Model, "provider" | "omitMaxOutputTokens" | "maxTokens">,
2484
+ model: Pick<Model, "provider" | "api" | "id" | "omitMaxOutputTokens" | "maxTokens">,
2389
2485
  ): void {
2390
2486
  if (options?.maxTokens && !model.omitMaxOutputTokens) {
2391
2487
  params.max_output_tokens = Math.min(
@@ -2400,7 +2496,7 @@ export function applyCommonResponsesSamplingParams<P extends CommonResponsesPara
2400
2496
  if (options?.minP !== undefined) params.min_p = options.minP;
2401
2497
  if (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty;
2402
2498
  if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty;
2403
- applyOpenAIServiceTier(params, options?.serviceTier, model.provider);
2499
+ applyOpenAIServiceTier(params, options?.serviceTier, model);
2404
2500
  }
2405
2501
 
2406
2502
  type ReasoningOptions = {
@@ -2522,19 +2618,42 @@ export function populateResponsesUsageFromResponse(
2522
2618
  if (!usage) return;
2523
2619
  const details = usage.input_tokens_details;
2524
2620
  const outputDetails = usage.output_tokens_details;
2621
+ const reportedInputTokens = usage.input_tokens ?? 0;
2622
+ const reportedOutputTokens = usage.output_tokens ?? 0;
2623
+ const reportedCachedTokens = details?.cached_tokens ?? usage.prompt_cache_hit_tokens ?? 0;
2525
2624
  const orchestrationInputTokens = details?.orchestration_input_tokens ?? 0;
2526
2625
  const orchestrationInputCachedTokens = details?.orchestration_input_cached_tokens ?? 0;
2527
2626
  const orchestrationOutputTokens = outputDetails?.orchestration_output_tokens ?? 0;
2627
+ const reportedTotalTokens = typeof usage.total_tokens === "number" ? usage.total_tokens : undefined;
2628
+ const reportedPrimaryTokens = reportedInputTokens + reportedOutputTokens;
2629
+ const reportedWithSeparateOrchestration =
2630
+ reportedPrimaryTokens + orchestrationInputTokens + orchestrationOutputTokens;
2631
+ const primaryIncludesOrchestration =
2632
+ reportedTotalTokens !== undefined &&
2633
+ orchestrationInputTokens + orchestrationOutputTokens > 0 &&
2634
+ Math.abs(reportedTotalTokens - reportedPrimaryTokens) <=
2635
+ Math.abs(reportedTotalTokens - reportedWithSeparateOrchestration);
2636
+ const orchestrationInputCached = Math.min(orchestrationInputTokens, orchestrationInputCachedTokens);
2637
+ const orchestrationInput = Math.max(0, orchestrationInputTokens - orchestrationInputCached);
2528
2638
  const accounting = calculateOpenAIUsageAccounting({
2529
- promptTokens: (usage.input_tokens ?? 0) + orchestrationInputTokens,
2530
- outputTokens: (usage.output_tokens ?? 0) + orchestrationOutputTokens,
2531
- cachedTokens: (details?.cached_tokens ?? usage.prompt_cache_hit_tokens ?? 0) + orchestrationInputCachedTokens,
2639
+ promptTokens: Math.max(0, reportedInputTokens - (primaryIncludesOrchestration ? orchestrationInputTokens : 0)),
2640
+ outputTokens: Math.max(0, reportedOutputTokens - (primaryIncludesOrchestration ? orchestrationOutputTokens : 0)),
2641
+ cachedTokens: Math.max(0, reportedCachedTokens - (primaryIncludesOrchestration ? orchestrationInputCached : 0)),
2532
2642
  reasoningTokens: outputDetails?.reasoning_tokens ?? 0,
2533
2643
  cacheWriteOpenRouter: details?.cache_write_tokens ?? undefined,
2534
2644
  cacheWriteDeepSeek: usage.prompt_cache_miss_tokens ?? undefined,
2535
2645
  hasDeepSeekCacheHitAndMiss:
2536
2646
  usage.prompt_cache_hit_tokens !== undefined && usage.prompt_cache_miss_tokens !== undefined,
2537
2647
  });
2648
+ const orchestrationTotal = orchestrationInput + orchestrationInputCached + orchestrationOutputTokens;
2649
+ if (orchestrationTotal > 0) {
2650
+ accounting.orchestration = {
2651
+ ...(orchestrationInput > 0 ? { input: orchestrationInput } : {}),
2652
+ ...(orchestrationInputCached > 0 ? { cacheRead: orchestrationInputCached } : {}),
2653
+ ...(orchestrationOutputTokens > 0 ? { output: orchestrationOutputTokens } : {}),
2654
+ };
2655
+ accounting.totalTokens = reportedTotalTokens ?? accounting.totalTokens + orchestrationTotal;
2656
+ }
2538
2657
 
2539
2658
  // Wholesale replacement must not drop provider-annotated extras (Copilot
2540
2659
  // premium-request accounting): the failed/cancelled paths throw right after
@@ -1,5 +1,6 @@
1
1
  import { renderDemotedThinking } from "../dialect/demotion";
2
2
  import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage, UserMessage } from "../types";
3
+ import { isDemotedThinking, kDemotedThinking } from "../utils/block-symbols";
3
4
 
4
5
  const enum ToolCallStatus {
5
6
  /** A tool result has already been emitted for this tool call; later duplicates must be skipped. */
@@ -402,6 +403,31 @@ export function transformMessages<TApi extends Api>(
402
403
  assistantMsg.content.some(b => b.type === "toolCall");
403
404
  const lastBlockIndex = assistantMsg.content.length - 1;
404
405
 
406
+ const anthropicVisibleThinkingSurvivesReplay = (
407
+ candidate: AssistantMessage["content"][number],
408
+ candidateIndex: number,
409
+ ): boolean => {
410
+ if (candidate.type !== "thinking") return false;
411
+ if (!isAnthropicReplay) return false;
412
+ if (isLatestSurvivingAssistant && abandonedToolUse) return true;
413
+ const candidateSignatureUntrustworthy =
414
+ abandonedToolUse || (invalidStopReason && candidateIndex === lastBlockIndex);
415
+ const replaySignature =
416
+ candidateSignatureUntrustworthy && candidate.thinkingSignature ? undefined : candidate.thinkingSignature;
417
+ if (!replaySignature && (!candidate.thinking || candidate.thinking.trim() === "")) return false;
418
+ if (isSameModel && isSigningAnthropicTarget && (!replaySignature || replaySignature.trim() === "")) {
419
+ return false;
420
+ }
421
+ return true;
422
+ };
423
+ const hasVisibleAnthropicThinking = assistantMsg.content.some(candidate => candidate.type === "thinking");
424
+ const dropsAllSameModelVisibleThinking =
425
+ isAnthropicReplay &&
426
+ isSameModel &&
427
+ isSigningAnthropicTarget &&
428
+ hasVisibleAnthropicThinking &&
429
+ !assistantMsg.content.some(anthropicVisibleThinkingSurvivesReplay);
430
+
405
431
  const transformedContent = assistantMsg.content.flatMap((block, blockIndex) => {
406
432
  if (block.type === "thinking") {
407
433
  // Only an aborted/errored turn's final (mid-stream) block can hold a
@@ -437,13 +463,15 @@ export function transformMessages<TApi extends Api>(
437
463
  return [];
438
464
  }
439
465
  // Same-model Anthropic replay to a signature-enforcing endpoint
440
- // cannot natively replay thinking blocks whose source explicitly
441
- // recorded an empty signature, but this is not a dialect
442
- // transition. Do not demote that sentinel into the target model's
443
- // textual thinking dialect; keep demotion for signatures stripped
444
- // by the untrustworthy-turn recovery above and for literal thinking
445
- // envelopes that never carried a signature field.
446
- if (isSameModel && isSigningAnthropicTarget && sanitized.thinkingSignature?.trim() === "") {
466
+ // requires valid signatures to natively replay thinking blocks.
467
+ // Both undefined and empty string signatures are invalid and must
468
+ // be dropped entirely — not demoted to text. Demotion would cause
469
+ // the reasoning_extraction safety classifier to refuse the response.
470
+ if (
471
+ isSameModel &&
472
+ isSigningAnthropicTarget &&
473
+ (!sanitized.thinkingSignature || sanitized.thinkingSignature.trim() === "")
474
+ ) {
447
475
  return [];
448
476
  }
449
477
  return sanitized;
@@ -476,9 +504,21 @@ export function transformMessages<TApi extends Api>(
476
504
  // TARGET model's own canonical thinking-block dialect (e.g. a ```thinking
477
505
  // fence for Gemini) so it reads as reasoning rather than bare prose the
478
506
  // model might mimic.
507
+ // Mark the demoted block (symbol-keyed, never serialized) instead of
508
+ // baking a separator into its text: the openai-completions flatten —
509
+ // the one consumer that joins adjacent text blocks into a single
510
+ // string — inserts a paragraph break after marked blocks, so the
511
+ // bare Anthropic-dialect output (or any dialect's wrapped output
512
+ // whose closing tag isn't a natural word boundary) can't glue onto
513
+ // the following visible-text block, while ordinary adjacent text
514
+ // blocks stitched from streaming / bridges / imported transcripts
515
+ // stay byte-identical. A separator baked into the block text would
516
+ // leak to non-flattening targets: Anthropic/Bedrock reject a
517
+ // terminal assistant message whose text ends with whitespace.
479
518
  return {
480
519
  type: "text" as const,
481
520
  text: renderDemotedThinking(model.id, sanitized.thinking),
521
+ [kDemotedThinking]: true,
482
522
  };
483
523
  }
484
524
 
@@ -486,9 +526,11 @@ export function transformMessages<TApi extends Api>(
486
526
  // Redacted thinking is native-only. Keep it for same-model
487
527
  // signed replay, the latest byte-for-byte Anthropic turn, or
488
528
  // compatible targets that will also emit sibling unsigned
489
- // thinking natively. Drop it when the visible thinking was
490
- // cross-model stripped and will be demoted to text.
529
+ // thinking natively. Drop it when the matching visible thinking
530
+ // was discarded, or when visible thinking was cross-model
531
+ // stripped and will be demoted to text.
491
532
  if (isAnthropicReplay) {
533
+ if (dropsAllSameModelVisibleThinking) return [];
492
534
  if (isSameModel || isLatestSurvivingAssistant || replaysUnsignedAnthropicThinking) return block;
493
535
  return [];
494
536
  }
@@ -553,6 +595,18 @@ export function transformMessages<TApi extends Api>(
553
595
  return block;
554
596
  });
555
597
 
598
+ // A demoted-thinking block that survived as the message's final block can
599
+ // still end with the thinking text's own trailing whitespace (bare
600
+ // Anthropic-dialect demotion copies it verbatim), and Anthropic rejects a
601
+ // terminal assistant message whose text ends with trailing whitespace
602
+ // ("final assistant content cannot end with trailing whitespace").
603
+ // trimEnd() is safe: demoted text is synthesized context, never
604
+ // byte-exact replay material.
605
+ const finalBlock = transformedContent[transformedContent.length - 1];
606
+ if (finalBlock?.type === "text" && isDemotedThinking(finalBlock)) {
607
+ transformedContent[transformedContent.length - 1] = { ...finalBlock, text: finalBlock.text.trimEnd() };
608
+ }
609
+
556
610
  return {
557
611
  ...assistantMsg,
558
612
  content: transformedContent,
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { createApiKeyLogin } from "../api-key-login";
9
9
 
10
- const WAFER_AUTH_URL = "https://wafer.ai/dashboard";
10
+ const WAFER_AUTH_URL = "https://app.wafer.ai/usage";
11
11
  const WAFER_MODELS_URL = "https://pass.wafer.ai/v1/models";
12
12
 
13
13
  export const loginWaferServerless = createApiKeyLogin({
package/src/stream.ts CHANGED
@@ -1656,6 +1656,7 @@ function mapOptionsForApi<TApi extends Api>(
1656
1656
  enabled: true,
1657
1657
  level: mapEffortToGoogleThinkingLevel(effort),
1658
1658
  },
1659
+ hideThinkingSummary: options?.hideThinkingSummary,
1659
1660
  toolChoice: mapGoogleToolChoice(options?.toolChoice),
1660
1661
  });
1661
1662
  }
@@ -1666,6 +1667,7 @@ function mapOptionsForApi<TApi extends Api>(
1666
1667
  enabled: true,
1667
1668
  budgetTokens: getGoogleBudget(googleModel, effort, options?.thinkingBudgets),
1668
1669
  },
1670
+ hideThinkingSummary: options?.hideThinkingSummary,
1669
1671
  toolChoice: mapGoogleToolChoice(options?.toolChoice),
1670
1672
  });
1671
1673
  }
@@ -1685,6 +1687,7 @@ function mapOptionsForApi<TApi extends Api>(
1685
1687
  enabled: true,
1686
1688
  level: mapEffortToGoogleThinkingLevel(effort),
1687
1689
  },
1690
+ hideThinkingSummary: options?.hideThinkingSummary,
1688
1691
  toolChoice,
1689
1692
  antigravityEndpointMode: options?.antigravityEndpointMode,
1690
1693
  });
@@ -1707,6 +1710,7 @@ function mapOptionsForApi<TApi extends Api>(
1707
1710
  maxTokens,
1708
1711
  requestModelId: resolveWireModelId(model, effort),
1709
1712
  thinking: { enabled: true, budgetTokens: thinkingBudget },
1713
+ hideThinkingSummary: options?.hideThinkingSummary,
1710
1714
  toolChoice,
1711
1715
  antigravityEndpointMode: options?.antigravityEndpointMode,
1712
1716
  });
@@ -1753,6 +1757,7 @@ function mapOptionsForApi<TApi extends Api>(
1753
1757
  enabled: true,
1754
1758
  level: mapEffortToGoogleThinkingLevel(effort),
1755
1759
  },
1760
+ hideThinkingSummary: options?.hideThinkingSummary,
1756
1761
  toolChoice: mapGoogleToolChoice(options?.toolChoice),
1757
1762
  });
1758
1763
  }
@@ -1764,6 +1769,7 @@ function mapOptionsForApi<TApi extends Api>(
1764
1769
  enabled: true,
1765
1770
  budgetTokens: getGoogleBudget(geminiModel, effort, options?.thinkingBudgets),
1766
1771
  },
1772
+ hideThinkingSummary: options?.hideThinkingSummary,
1767
1773
  toolChoice: mapGoogleToolChoice(options?.toolChoice),
1768
1774
  });
1769
1775
  }
package/src/types.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  WriteResult,
20
20
  } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
21
21
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
22
+ import { isOpenAIModelId } from "@oh-my-pi/pi-catalog/identity/family";
22
23
  import type { Api, FetchImpl, KnownApi, Model, Provider, ThinkingBudgets, Usage } from "@oh-my-pi/pi-catalog/types";
23
24
  import type { Type } from "arktype";
24
25
  import type { ZodType, z } from "zod/v4";
@@ -134,6 +135,22 @@ export type ServiceTierFamily = "openai" | "anthropic" | "google";
134
135
  */
135
136
  export type ServiceTierByFamily = Partial<Record<ServiceTierFamily, ServiceTier>>;
136
137
 
138
+ type ServiceTierModel = Pick<Model, "provider" | "api" | "id">;
139
+
140
+ function isOpenAIServiceTierApi(api: Api | undefined): boolean {
141
+ return api === "openai-completions" || api === "openai-responses" || api === "openai-codex-responses";
142
+ }
143
+
144
+ function hasDedicatedServiceTierControl(provider: Provider | undefined): boolean {
145
+ return provider === "fireworks";
146
+ }
147
+
148
+ function isOpenAIServiceTierModel(model: ServiceTierModel): boolean {
149
+ return (
150
+ !hasDedicatedServiceTierControl(model.provider) && isOpenAIServiceTierApi(model.api) && isOpenAIModelId(model.id)
151
+ );
152
+ }
153
+
137
154
  /**
138
155
  * Classify a model into the service-tier family whose knob governs it, or
139
156
  * `undefined` when the model exposes no serving-priority control.
@@ -141,8 +158,10 @@ export type ServiceTierByFamily = Partial<Record<ServiceTierFamily, ServiceTier>
141
158
  * OpenRouter models are classified by id namespace (`anthropic/`, `google/`,
142
159
  * `openai/`); Claude on Bedrock/Vertex (api `anthropic-messages`) is the
143
160
  * anthropic family even though its provider is `amazon-bedrock`/`google-vertex`.
161
+ * Custom OpenAI-compatible relays that serve OpenAI model ids are OpenAI family
162
+ * too unless that provider owns a separate tier control such as Fireworks.
144
163
  */
145
- export function serviceTierFamily(model: Pick<Model, "provider" | "api" | "id">): ServiceTierFamily | undefined {
164
+ export function serviceTierFamily(model: ServiceTierModel): ServiceTierFamily | undefined {
146
165
  const provider = model.provider;
147
166
  if (provider === "openrouter") {
148
167
  const id = model.id.toLowerCase();
@@ -154,6 +173,7 @@ export function serviceTierFamily(model: Pick<Model, "provider" | "api" | "id">)
154
173
  if (provider === "openai" || provider === "openai-codex") return "openai";
155
174
  if (model.api === "anthropic-messages") return "anthropic";
156
175
  if (provider === "google" || provider === "google-vertex") return "google";
176
+ if (isOpenAIServiceTierModel(model)) return "openai";
157
177
  return undefined;
158
178
  }
159
179
 
@@ -179,12 +199,16 @@ export function resolveModelServiceTier(
179
199
  */
180
200
  export function shouldSendServiceTier(
181
201
  serviceTier: ServiceTier | null | undefined,
182
- provider: Provider | undefined,
202
+ target: Provider | ServiceTierModel | undefined,
183
203
  ): boolean {
184
204
  if (!serviceTier) return false;
205
+ const provider = typeof target === "string" ? target : target?.provider;
185
206
  if (provider === "openai" || provider === "openai-codex" || provider === "openrouter") {
186
207
  return serviceTier === "flex" || serviceTier === "scale" || serviceTier === "priority";
187
208
  }
209
+ if (typeof target !== "string" && target && isOpenAIServiceTierModel(target)) {
210
+ return serviceTier === "flex" || serviceTier === "scale" || serviceTier === "priority";
211
+ }
188
212
  if (provider === "google") {
189
213
  return serviceTier === "flex" || serviceTier === "priority";
190
214
  }
@@ -213,7 +237,7 @@ export function realizesPriorityServiceTier(
213
237
  return family === "openai" || family === "google";
214
238
  }
215
239
  if (model.api === "anthropic-messages") return false;
216
- return shouldSendServiceTier(serviceTier, model.provider);
240
+ return shouldSendServiceTier(serviceTier, model);
217
241
  }
218
242
 
219
243
  /**
@@ -46,3 +46,21 @@ export const kCursorExecResolved = Symbol("provider.block.cursorExecResolved");
46
46
 
47
47
  /** Carries the resolved marker without exposing a string-keyed property. */
48
48
  export type CursorExecResolvedCarrier = object & { [kCursorExecResolved]?: true };
49
+
50
+ /**
51
+ * Marks a text block synthesized by cross-model thinking demotion in
52
+ * `transformMessages`. Converters that flatten adjacent text blocks into one
53
+ * string (openai-completions) insert a paragraph separator after marked
54
+ * blocks; unmarked adjacent blocks keep their original byte sequence.
55
+ * Symbol-keyed so the marker never persists across the JSONL round-trip and
56
+ * never reaches the wire.
57
+ */
58
+ export const kDemotedThinking = Symbol("provider.block.demotedThinking");
59
+
60
+ /** Carries the demoted-thinking marker without exposing a string-keyed property. */
61
+ export type DemotedThinkingCarrier = object & { [kDemotedThinking]?: boolean };
62
+
63
+ /** True for text blocks synthesized by cross-model thinking demotion. */
64
+ export function isDemotedThinking(block: DemotedThinkingCarrier | null | undefined): boolean {
65
+ return block?.[kDemotedThinking] === true;
66
+ }
@@ -23,22 +23,50 @@ export type CapturedHttpErrorResponse = {
23
23
 
24
24
  const SENSITIVE_HEADERS = ["authorization", "x-api-key", "api-key", "cookie", "set-cookie", "proxy-authorization"];
25
25
 
26
+ /**
27
+ * Build the JSON persisted for a rejected request. Request fields stay at the
28
+ * top level (so existing dump parsers still read `body`); the provider's error
29
+ * is added under `errorResponse` so a failed request is diagnosable from the
30
+ * dump file rather than the request alone.
31
+ */
32
+ export function buildHttp400DumpPayload(
33
+ dump: RawHttpRequestDump,
34
+ error: unknown,
35
+ message: string,
36
+ ): RawHttpRequestDump & { errorResponse: { status: number | undefined; message: string } } {
37
+ return {
38
+ ...sanitizeDump(dump),
39
+ errorResponse: { status: AIError.status(error), message },
40
+ };
41
+ }
42
+
43
+ /** HTTP statuses whose rejected request we persist for post-hoc diagnosis: the
44
+ * request-content rejections that wedge a session. 400 (bad request) and 413
45
+ * (payload too large — an oversized image / snapcompact frame payload that 413s
46
+ * and empties the turn). Auth (401/403), not-found (404), rate limits and 5xx
47
+ * are excluded: 429/5xx are retried, so persisting them here would write one
48
+ * dump per attempt. */
49
+ export function shouldDumpRejectedRequest(error: unknown): boolean {
50
+ const status = AIError.status(error);
51
+ return status === 400 || status === 413;
52
+ }
53
+
26
54
  export async function appendRawHttpRequestDumpFor400(
27
55
  message: string,
28
56
  error: unknown,
29
57
  dump: RawHttpRequestDump | undefined,
30
58
  ): Promise<string> {
31
59
  // Never persist dumps under the test runner: providers exercise the 400 path
32
- if (!dump || isBunTestRuntime() || AIError.status(error) !== 400) {
60
+ if (!dump || isBunTestRuntime() || !shouldDumpRejectedRequest(error)) {
33
61
  return message;
34
62
  }
35
63
 
36
- const sanitizedDump = sanitizeDump(dump);
37
- const fileName = `${Date.now()}-${Bun.hash(JSON.stringify(sanitizedDump)).toString(36)}.json`;
64
+ const payload = buildHttp400DumpPayload(dump, error, message);
65
+ const fileName = `${Date.now()}-${Bun.hash(JSON.stringify(payload)).toString(36)}.json`;
38
66
  const filePath = path.join(getLogsDir(), "http-400-requests", fileName);
39
67
 
40
68
  try {
41
- await Bun.write(filePath, `${JSON.stringify(sanitizedDump, null, 2)}\n`);
69
+ await Bun.write(filePath, `${JSON.stringify(payload, null, 2)}\n`);
42
70
  return `${message}\nraw-http-request=${filePath}`;
43
71
  } catch (writeError) {
44
72
  const writeMessage = writeError instanceof Error ? writeError.message : String(writeError);