@oh-my-pi/pi-ai 16.2.1 → 16.2.2

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 (166) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/types/auth-gateway/server.d.ts +0 -19
  3. package/dist/types/auth-retry.d.ts +2 -10
  4. package/dist/types/auth-storage.d.ts +1 -2
  5. package/dist/types/dialect/demotion.d.ts +22 -0
  6. package/dist/types/dialect/index.d.ts +2 -0
  7. package/dist/types/error/abort.d.ts +14 -0
  8. package/dist/types/error/auth-classify.d.ts +16 -0
  9. package/dist/types/error/auth.d.ts +27 -0
  10. package/dist/types/error/aws.d.ts +23 -0
  11. package/dist/types/error/classes.d.ts +102 -0
  12. package/dist/types/error/finalize.d.ts +39 -0
  13. package/dist/types/error/flags.d.ts +70 -0
  14. package/dist/types/error/format.d.ts +20 -0
  15. package/dist/types/error/gateway.d.ts +20 -0
  16. package/dist/types/error/index.d.ts +13 -0
  17. package/dist/types/error/oauth.d.ts +43 -0
  18. package/dist/types/error/provider.d.ts +42 -0
  19. package/dist/types/{rate-limit-utils.d.ts → error/rate-limit.d.ts} +14 -1
  20. package/dist/types/error/retryable.d.ts +27 -0
  21. package/dist/types/error/validation.d.ts +32 -0
  22. package/dist/types/index.d.ts +1 -3
  23. package/dist/types/providers/amazon-bedrock.d.ts +0 -5
  24. package/dist/types/providers/anthropic-client.d.ts +2 -16
  25. package/dist/types/providers/anthropic.d.ts +6 -1
  26. package/dist/types/providers/aws-eventstream.d.ts +2 -1
  27. package/dist/types/providers/cursor.d.ts +8 -7
  28. package/dist/types/providers/google-gemini-cli.d.ts +0 -5
  29. package/dist/types/providers/google-shared.d.ts +0 -5
  30. package/dist/types/providers/ollama.d.ts +0 -5
  31. package/dist/types/providers/openai-codex/request-transformer.d.ts +2 -2
  32. package/dist/types/providers/openai-codex/response-handler.d.ts +1 -1
  33. package/dist/types/providers/openai-codex-responses.d.ts +2 -2
  34. package/dist/types/providers/openai-responses-server-schema.d.ts +6 -0
  35. package/dist/types/providers/openai-responses-wire.d.ts +1 -1
  36. package/dist/types/providers/openai-responses.d.ts +1 -0
  37. package/dist/types/providers/openai-shared.d.ts +8 -6
  38. package/dist/types/providers/pi-native-client.d.ts +0 -9
  39. package/dist/types/registry/oauth/xai-oauth.d.ts +0 -9
  40. package/dist/types/types.d.ts +6 -0
  41. package/dist/types/utils/block-symbols.d.ts +20 -0
  42. package/dist/types/utils/openai-http.d.ts +4 -8
  43. package/dist/types/utils/retry.d.ts +2 -14
  44. package/dist/types/utils/thinking-loop.d.ts +3 -1
  45. package/package.json +8 -4
  46. package/src/api-registry.ts +3 -1
  47. package/src/auth-broker/discover.ts +7 -2
  48. package/src/auth-broker/remote-store.ts +8 -7
  49. package/src/auth-gateway/server.ts +27 -115
  50. package/src/auth-retry.ts +9 -21
  51. package/src/auth-storage.ts +34 -49
  52. package/src/dialect/demotion.ts +31 -0
  53. package/src/dialect/factory.ts +0 -2
  54. package/src/dialect/index.ts +2 -0
  55. package/src/dialect/owned-stream.ts +0 -1
  56. package/src/dialect/thinking.ts +22 -10
  57. package/src/error/abort.ts +18 -0
  58. package/src/error/auth-classify.ts +30 -0
  59. package/src/error/auth.ts +48 -0
  60. package/src/error/aws.ts +31 -0
  61. package/src/error/classes.ts +186 -0
  62. package/src/error/finalize.ts +69 -0
  63. package/src/error/flags.ts +486 -0
  64. package/src/error/format.ts +36 -0
  65. package/src/error/gateway.ts +96 -0
  66. package/src/error/index.ts +13 -0
  67. package/src/error/oauth.ts +58 -0
  68. package/src/error/provider.ts +56 -0
  69. package/src/{rate-limit-utils.ts → error/rate-limit.ts} +9 -3
  70. package/src/error/retryable.ts +70 -0
  71. package/src/error/validation.ts +44 -0
  72. package/src/index.ts +1 -3
  73. package/src/providers/amazon-bedrock.ts +61 -57
  74. package/src/providers/anthropic-client.ts +13 -41
  75. package/src/providers/anthropic-messages-server.ts +9 -2
  76. package/src/providers/anthropic.ts +84 -147
  77. package/src/providers/aws-credentials.ts +41 -11
  78. package/src/providers/aws-eventstream.ts +12 -21
  79. package/src/providers/azure-openai-responses.ts +27 -17
  80. package/src/providers/cursor.ts +59 -53
  81. package/src/providers/devin.ts +28 -20
  82. package/src/providers/gitlab-duo-workflow.ts +30 -10
  83. package/src/providers/gitlab-duo.ts +22 -6
  84. package/src/providers/google-auth.ts +15 -5
  85. package/src/providers/google-gemini-cli.ts +46 -60
  86. package/src/providers/google-shared.ts +40 -38
  87. package/src/providers/google-vertex.ts +3 -2
  88. package/src/providers/google.ts +5 -1
  89. package/src/providers/mock.ts +10 -7
  90. package/src/providers/ollama.ts +34 -29
  91. package/src/providers/openai-chat-server.ts +2 -1
  92. package/src/providers/openai-codex/request-transformer.ts +13 -12
  93. package/src/providers/openai-codex/response-handler.ts +1 -1
  94. package/src/providers/openai-codex-responses.ts +931 -1012
  95. package/src/providers/openai-completions.ts +46 -36
  96. package/src/providers/openai-responses-server-schema.ts +3 -0
  97. package/src/providers/openai-responses-server.ts +82 -30
  98. package/src/providers/openai-responses-wire.ts +1 -1
  99. package/src/providers/openai-responses.ts +46 -22
  100. package/src/providers/openai-shared.ts +118 -51
  101. package/src/providers/pi-native-client.ts +12 -18
  102. package/src/providers/pi-native-server.ts +9 -6
  103. package/src/providers/register-builtins.ts +5 -2
  104. package/src/providers/transform-messages.ts +31 -63
  105. package/src/registry/alibaba-coding-plan.ts +6 -5
  106. package/src/registry/api-key-login.ts +4 -3
  107. package/src/registry/api-key-validation.ts +4 -3
  108. package/src/registry/cloudflare-ai-gateway.ts +4 -3
  109. package/src/registry/coreweave.ts +2 -1
  110. package/src/registry/deepseek.ts +2 -1
  111. package/src/registry/google-antigravity.ts +2 -1
  112. package/src/registry/google-gemini-cli.ts +2 -1
  113. package/src/registry/kagi.ts +4 -3
  114. package/src/registry/kilo.ts +32 -10
  115. package/src/registry/litellm.ts +4 -3
  116. package/src/registry/llama-cpp.ts +3 -2
  117. package/src/registry/lm-studio.ts +3 -2
  118. package/src/registry/nvidia.ts +7 -7
  119. package/src/registry/oauth/anthropic.ts +23 -6
  120. package/src/registry/oauth/callback-server.ts +5 -3
  121. package/src/registry/oauth/cursor.ts +18 -4
  122. package/src/registry/oauth/devin.ts +14 -3
  123. package/src/registry/oauth/github-copilot.ts +21 -10
  124. package/src/registry/oauth/gitlab-duo-workflow.ts +15 -3
  125. package/src/registry/oauth/gitlab-duo.ts +30 -6
  126. package/src/registry/oauth/google-antigravity.ts +14 -5
  127. package/src/registry/oauth/google-gemini-cli.ts +24 -7
  128. package/src/registry/oauth/google-oauth-shared.ts +6 -3
  129. package/src/registry/oauth/index.ts +19 -8
  130. package/src/registry/oauth/kimi.ts +40 -10
  131. package/src/registry/oauth/openai-codex.ts +26 -11
  132. package/src/registry/oauth/opencode.ts +4 -3
  133. package/src/registry/oauth/perplexity.ts +33 -11
  134. package/src/registry/oauth/xai-oauth.ts +62 -18
  135. package/src/registry/oauth/xiaomi.ts +21 -9
  136. package/src/registry/ollama-cloud.ts +5 -4
  137. package/src/registry/ollama.ts +3 -2
  138. package/src/registry/parallel.ts +4 -3
  139. package/src/registry/qwen-portal.ts +4 -3
  140. package/src/registry/tavily.ts +4 -3
  141. package/src/registry/vercel-ai-gateway.ts +4 -3
  142. package/src/registry/vllm.ts +3 -2
  143. package/src/stream.ts +79 -24
  144. package/src/types.ts +6 -0
  145. package/src/usage/claude.ts +2 -1
  146. package/src/usage/github-copilot.ts +4 -3
  147. package/src/usage/google-antigravity.ts +2 -1
  148. package/src/utils/abort.ts +4 -2
  149. package/src/utils/block-symbols.ts +32 -0
  150. package/src/utils/event-stream.ts +15 -3
  151. package/src/utils/http-inspector.ts +4 -4
  152. package/src/utils/idle-iterator.ts +6 -5
  153. package/src/utils/openai-http.ts +11 -46
  154. package/src/utils/parse-bind.ts +7 -5
  155. package/src/utils/proxy.ts +2 -1
  156. package/src/utils/retry.ts +7 -23
  157. package/src/utils/schema/normalize.ts +3 -2
  158. package/src/utils/thinking-loop.ts +15 -11
  159. package/src/utils/validation.ts +4 -3
  160. package/dist/types/dialect/pi.d.ts +0 -9
  161. package/dist/types/errors.d.ts +0 -24
  162. package/dist/types/utils/overflow.d.ts +0 -55
  163. package/src/dialect/pi.md +0 -55
  164. package/src/dialect/pi.ts +0 -600
  165. package/src/errors.ts +0 -32
  166. package/src/utils/overflow.ts +0 -140
@@ -27,6 +27,7 @@ import {
27
27
  parseStreamingJsonThrottled,
28
28
  structuredCloneJSON,
29
29
  } from "@oh-my-pi/pi-utils";
30
+ import * as AIError from "../error";
30
31
  import {
31
32
  type Api,
32
33
  type AssistantMessage,
@@ -59,6 +60,12 @@ import {
59
60
  resolveCacheRetention,
60
61
  sanitizeOpenAIResponsesHistoryItemsForReplay,
61
62
  } from "../utils";
63
+ import {
64
+ clearStreamingPartialJson,
65
+ kStreamingArgumentsDone,
66
+ kStreamingLastParseLen,
67
+ kStreamingPartialJson,
68
+ } from "../utils/block-symbols";
62
69
  import type { AssistantMessageEventStream } from "../utils/event-stream";
63
70
  import type { CapturedHttpErrorResponse } from "../utils/http-inspector";
64
71
  import { getOpenRouterHeaders } from "../utils/openrouter-headers";
@@ -69,6 +76,7 @@ import {
69
76
  resolveGitHubCopilotBaseUrl,
70
77
  } from "./github-copilot-headers";
71
78
  import type { ChatCompletionCreateParamsStreaming } from "./openai-chat-wire";
79
+ import type { InputItem } from "./openai-codex/request-transformer";
72
80
  import type {
73
81
  ResponseContentPartAddedEvent,
74
82
  ResponseCreateParamsStreaming,
@@ -77,6 +85,7 @@ import type {
77
85
  ResponseInput,
78
86
  ResponseInputContent,
79
87
  ResponseInputImage,
88
+ ResponseInputItem,
80
89
  ResponseInputText,
81
90
  ResponseOutputItem,
82
91
  ResponseOutputMessage,
@@ -169,7 +178,8 @@ export function resolveOpenAIRequestSetup(
169
178
  let apiKey = options.apiKey;
170
179
  if (!apiKey) {
171
180
  if (!$env.OPENAI_API_KEY) {
172
- throw new Error(
181
+ throw new AIError.MissingApiKeyError(
182
+ undefined,
173
183
  "OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.",
174
184
  );
175
185
  }
@@ -762,7 +772,7 @@ export function resolveOpenAICompatPolicy<TApi extends Api>(
762
772
  ) {
763
773
  const minEffort = getSupportedEfforts(model)[0];
764
774
  if (minEffort === undefined) {
765
- throw new Error(`Model ${model.provider}/${model.id} has no supported reasoning efforts`);
775
+ throw new AIError.ConfigurationError(`Model ${model.provider}/${model.id} has no supported reasoning efforts`);
766
776
  }
767
777
  wireEffort = mapOpenAIReasoningEffort(model, compat, minEffort);
768
778
  }
@@ -1615,7 +1625,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
1615
1625
  * Codex uses the open item's recorded index) so the emitted stream events match
1616
1626
  * each decoder's existing behavior byte-for-byte.
1617
1627
  */
1618
- type ResponsesToolCallBlock = ToolCall & { partialJson: string; lastParseLen?: number };
1628
+ type ResponsesToolCallBlock = ToolCall & { [kStreamingPartialJson]: string; [kStreamingLastParseLen]?: number };
1619
1629
 
1620
1630
  export function appendReasoningSummaryPart(
1621
1631
  item: ResponseReasoningItem,
@@ -1702,11 +1712,11 @@ export function accumulateToolCallArgumentsDelta(
1702
1712
  output: AssistantMessage,
1703
1713
  contentIndex: number,
1704
1714
  ): void {
1705
- block.partialJson += delta;
1706
- const throttled = parseStreamingJsonThrottled(block.partialJson, block.lastParseLen ?? 0);
1715
+ block[kStreamingPartialJson] += delta;
1716
+ const throttled = parseStreamingJsonThrottled(block[kStreamingPartialJson], block[kStreamingLastParseLen] ?? 0);
1707
1717
  if (throttled) {
1708
1718
  block.arguments = throttled.value;
1709
- block.lastParseLen = throttled.parsedLen;
1719
+ block[kStreamingLastParseLen] = throttled.parsedLen;
1710
1720
  }
1711
1721
  stream.push({ type: "toolcall_delta", contentIndex, delta, partial: output });
1712
1722
  }
@@ -1718,10 +1728,9 @@ export function accumulateToolCallArgumentsDelta(
1718
1728
  * drops the transient accumulation fields.
1719
1729
  */
1720
1730
  export function finalizeToolCallArgumentsDone(block: ResponsesToolCallBlock, args: string): void {
1721
- block.partialJson = args;
1722
- block.arguments = parseStreamingJson(block.partialJson);
1723
- delete (block as { partialJson?: string }).partialJson;
1724
- delete (block as { lastParseLen?: number }).lastParseLen;
1731
+ block[kStreamingPartialJson] = args;
1732
+ block.arguments = parseStreamingJson(block[kStreamingPartialJson]);
1733
+ clearStreamingPartialJson(block);
1725
1734
  }
1726
1735
 
1727
1736
  export function accumulateCustomToolCallInputDelta(
@@ -1731,13 +1740,13 @@ export function accumulateCustomToolCallInputDelta(
1731
1740
  output: AssistantMessage,
1732
1741
  contentIndex: number,
1733
1742
  ): void {
1734
- block.partialJson += delta;
1735
- block.arguments = { input: block.partialJson };
1743
+ block[kStreamingPartialJson] += delta;
1744
+ block.arguments = { input: block[kStreamingPartialJson] };
1736
1745
  stream.push({ type: "toolcall_delta", contentIndex, delta, partial: output });
1737
1746
  }
1738
1747
 
1739
1748
  export function finalizeCustomToolCallInputDone(block: ResponsesToolCallBlock, input: string): void {
1740
- block.partialJson = input;
1749
+ block[kStreamingPartialJson] = input;
1741
1750
  block.arguments = { input };
1742
1751
  }
1743
1752
 
@@ -1766,7 +1775,11 @@ export async function processResponsesStream<TApi extends Api>(
1766
1775
  model: Model<TApi>,
1767
1776
  options?: ProcessResponsesStreamOptions,
1768
1777
  ): Promise<void> {
1769
- type StreamingToolCallBlock = ToolCall & { partialJson: string; lastParseLen?: number; argumentsDone?: boolean };
1778
+ type StreamingToolCallBlock = ToolCall & {
1779
+ [kStreamingPartialJson]: string;
1780
+ [kStreamingLastParseLen]?: number;
1781
+ [kStreamingArgumentsDone]?: boolean;
1782
+ };
1770
1783
  interface StreamingItem {
1771
1784
  item: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | ResponseCustomToolCall;
1772
1785
  block: ThinkingContent | TextContent | StreamingToolCallBlock;
@@ -1863,7 +1876,7 @@ export async function processResponsesStream<TApi extends Api>(
1863
1876
  if (
1864
1877
  candidate.item.type === "function_call" &&
1865
1878
  candidate.block.type === "toolCall" &&
1866
- !candidate.block.argumentsDone
1879
+ !candidate.block[kStreamingArgumentsDone]
1867
1880
  ) {
1868
1881
  return candidate;
1869
1882
  }
@@ -1914,7 +1927,11 @@ export async function processResponsesStream<TApi extends Api>(
1914
1927
  registerOpenItem(event.output_index, item.id, { item, block });
1915
1928
  stream.push({ type: "thinking_start", contentIndex: contentIndexOf(block), partial: output });
1916
1929
  } else if (item.type === "message") {
1917
- const block: TextContent = { type: "text", text: "" };
1930
+ const block: TextContent = {
1931
+ type: "text",
1932
+ text: "",
1933
+ textSignature: encodeTextSignatureV1(item.id, item.phase ?? undefined),
1934
+ };
1918
1935
  output.content.push(block);
1919
1936
  registerOpenItem(event.output_index, item.id, { item, block });
1920
1937
  stream.push({ type: "text_start", contentIndex: contentIndexOf(block), partial: output });
@@ -1924,7 +1941,7 @@ export async function processResponsesStream<TApi extends Api>(
1924
1941
  id: encodeResponsesToolCallId(item.call_id, item.id),
1925
1942
  name: item.name,
1926
1943
  arguments: {},
1927
- partialJson: item.arguments || "",
1944
+ [kStreamingPartialJson]: item.arguments || "",
1928
1945
  };
1929
1946
  output.content.push(block);
1930
1947
  registerOpenItem(
@@ -1948,7 +1965,7 @@ export async function processResponsesStream<TApi extends Api>(
1948
1965
  customWireName: item.name,
1949
1966
  // Custom tools stream a raw string, but we reuse `partialJson` as the
1950
1967
  // accumulation buffer so later code that inspects the field still works.
1951
- partialJson: item.input ?? "",
1968
+ [kStreamingPartialJson]: item.input ?? "",
1952
1969
  };
1953
1970
  output.content.push(block);
1954
1971
  registerOpenItem(
@@ -2031,7 +2048,7 @@ export async function processResponsesStream<TApi extends Api>(
2031
2048
  const entry = lookupOpenFunctionCallItem(event);
2032
2049
  if (entry?.item.type === "function_call" && entry.block.type === "toolCall") {
2033
2050
  finalizeToolCallArgumentsDone(entry.block, event.arguments);
2034
- entry.block.argumentsDone = true;
2051
+ entry.block[kStreamingArgumentsDone] = true;
2035
2052
  }
2036
2053
  } else if (event.type === "response.custom_tool_call_input.delta") {
2037
2054
  const entry = lookupOpenToolCallAlias(event, "custom_tool_call");
@@ -2099,10 +2116,10 @@ export async function processResponsesStream<TApi extends Api>(
2099
2116
  closeOpenItem(event.output_index, item.id, entry);
2100
2117
  } else if (item.type === "function_call") {
2101
2118
  const block = entry?.block.type === "toolCall" ? entry.block : undefined;
2102
- const args = block?.argumentsDone
2119
+ const args = block?.[kStreamingArgumentsDone]
2103
2120
  ? block.arguments
2104
- : block?.partialJson
2105
- ? parseStreamingJson(block.partialJson)
2121
+ : block?.[kStreamingPartialJson]
2122
+ ? parseStreamingJson(block[kStreamingPartialJson])
2106
2123
  : parseStreamingJson(item.arguments || "{}");
2107
2124
  const toolCall: ToolCall = {
2108
2125
  type: "toolCall",
@@ -2117,9 +2134,7 @@ export async function processResponsesStream<TApi extends Api>(
2117
2134
  // leaving block.arguments stale (often `{}`); the emitted toolCall
2118
2135
  // and the persisted block must agree.
2119
2136
  block.arguments = args;
2120
- delete (block as { partialJson?: string }).partialJson;
2121
- delete (block as { lastParseLen?: number }).lastParseLen;
2122
- delete (block as { argumentsDone?: boolean }).argumentsDone;
2137
+ clearStreamingPartialJson(block);
2123
2138
  contentIndex = contentIndexOf(block);
2124
2139
  } else {
2125
2140
  // `output_item.added` never arrived (lossy proxy) — synthesize the
@@ -2132,7 +2147,7 @@ export async function processResponsesStream<TApi extends Api>(
2132
2147
  stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
2133
2148
  } else if (item.type === "custom_tool_call") {
2134
2149
  const block = entry?.block.type === "toolCall" ? entry.block : undefined;
2135
- const rawInput = block?.partialJson ? block.partialJson : (item.input ?? "");
2150
+ const rawInput = block?.[kStreamingPartialJson] ? block[kStreamingPartialJson] : (item.input ?? "");
2136
2151
  const toolCall: ToolCall = {
2137
2152
  type: "toolCall",
2138
2153
  id: encodeResponsesToolCallId(item.call_id, item.id),
@@ -2145,8 +2160,7 @@ export async function processResponsesStream<TApi extends Api>(
2145
2160
  // Persist the final input on the stored block and drop the transient
2146
2161
  // accumulation buffer, mirroring the function_call branch above.
2147
2162
  block.arguments = { input: rawInput };
2148
- delete (block as { partialJson?: string }).partialJson;
2149
- delete (block as { lastParseLen?: number }).lastParseLen;
2163
+ clearStreamingPartialJson(block);
2150
2164
  contentIndex = contentIndexOf(block);
2151
2165
  } else {
2152
2166
  output.content.push(toolCall);
@@ -2181,13 +2195,16 @@ export async function processResponsesStream<TApi extends Api>(
2181
2195
  : typeof statusDetailsReason === "string" && statusDetailsReason.length > 0
2182
2196
  ? `status_details: ${statusDetailsReason}`
2183
2197
  : "Unknown error (no error details in response)";
2184
- throw new Error(message);
2198
+ throw new AIError.ProviderResponseError(message, { provider: model.provider, kind: "output" });
2185
2199
  }
2186
2200
  if (response?.status === "incomplete" && response.incomplete_details?.reason === "content_filter") {
2187
2201
  // A content-filtered turn is a failure, not a token-cap truncation —
2188
2202
  // mapping it to "length" would route the agent loop into "shorten your
2189
2203
  // output" recovery against a filtered prompt.
2190
- throw new Error("incomplete: content_filter");
2204
+ throw new AIError.ProviderResponseError("incomplete: content_filter", {
2205
+ provider: model.provider,
2206
+ kind: "content-blocked",
2207
+ });
2191
2208
  }
2192
2209
  promoteResponsesToolUseStopReason(output, (response as { end_turn?: boolean } | undefined)?.end_turn);
2193
2210
  options?.onCompleted?.();
@@ -2203,7 +2220,10 @@ export async function processResponsesStream<TApi extends Api>(
2203
2220
  const err = (event as any).error ?? event;
2204
2221
  const code = err.code ?? "unknown";
2205
2222
  const message = err.message ?? "no message";
2206
- throw new Error(`Error Code ${code}: ${message}`);
2223
+ throw new AIError.ProviderResponseError(`Error Code ${code}: ${message}`, {
2224
+ provider: model.provider,
2225
+ kind: "output",
2226
+ });
2207
2227
  } else if (event.type === "response.failed") {
2208
2228
  populateResponsesUsageFromResponse(output, event.response?.usage);
2209
2229
  const error = event.response?.error ?? (event.response as any)?.status_details?.error;
@@ -2213,7 +2233,7 @@ export async function processResponsesStream<TApi extends Api>(
2213
2233
  : details?.reason
2214
2234
  ? `incomplete: ${details.reason}`
2215
2235
  : "Unknown error (no error details in response)";
2216
- throw new Error(message);
2236
+ throw new AIError.ProviderResponseError(message, { provider: model.provider, kind: "output" });
2217
2237
  }
2218
2238
  }
2219
2239
  }
@@ -2253,16 +2273,18 @@ export function mapOpenAIResponsesStopReason(status: ResponseStatus | undefined)
2253
2273
  export function finalizePendingResponsesToolCalls(output: AssistantMessage): void {
2254
2274
  for (const block of output.content) {
2255
2275
  if (block.type !== "toolCall") continue;
2256
- const pending = block as ToolCall & { partialJson?: string; lastParseLen?: number; argumentsDone?: boolean };
2257
- if (pending.partialJson && !pending.argumentsDone) {
2276
+ const pending = block as ToolCall & {
2277
+ [kStreamingPartialJson]?: string;
2278
+ [kStreamingLastParseLen]?: number;
2279
+ [kStreamingArgumentsDone]?: boolean;
2280
+ };
2281
+ if (pending[kStreamingPartialJson] && !pending[kStreamingArgumentsDone]) {
2258
2282
  pending.arguments =
2259
2283
  pending.customWireName !== undefined
2260
- ? { input: pending.partialJson }
2261
- : parseStreamingJson(pending.partialJson);
2284
+ ? { input: pending[kStreamingPartialJson] }
2285
+ : parseStreamingJson(pending[kStreamingPartialJson]);
2262
2286
  }
2263
- delete pending.partialJson;
2264
- delete pending.lastParseLen;
2265
- delete pending.argumentsDone;
2287
+ clearStreamingPartialJson(pending);
2266
2288
  }
2267
2289
  }
2268
2290
 
@@ -2514,6 +2536,45 @@ export function populateResponsesUsageFromResponse(
2514
2536
  }
2515
2537
  }
2516
2538
 
2539
+ /**
2540
+ * Structural equality for the chain prefix/option check, equivalent to the
2541
+ * default {@link Bun.deepEquals} (own enumerable keys, `absent ≡ own-undefined`)
2542
+ * except for two deliberate exclusions:
2543
+ * - **symbol-keyed properties are ignored** — `for…in` walks enumerable
2544
+ * *string* keys only (never symbols); these are plain wire items whose
2545
+ * prototype contributes no enumerable keys, so iteration is effectively
2546
+ * own-string-keyed. That is how the transient streaming symbols
2547
+ * (`block-symbols.ts`) stamped onto live request items are excluded (the
2548
+ * deep-cloned baseline never carries them). Do NOT add an
2549
+ * `Object.getOwnPropertySymbols` pass, or those symbols resurface and break
2550
+ * chaining.
2551
+ * - keys listed in `omitKeys` are skipped (the option compare omits `input`
2552
+ * and the per-turn `client_metadata`).
2553
+ * A defined value differing across sides IS a difference; a key undefined or
2554
+ * absent on both stays equal. Nested values use full {@link Bun.deepEquals}.
2555
+ */
2556
+ function deepEqualsWithout(a: unknown, b: unknown, omitKeys?: Record<string, boolean>): boolean {
2557
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return Bun.deepEquals(a, b);
2558
+ const ao = a as Record<string, unknown>;
2559
+ const bo = b as Record<string, unknown>;
2560
+ for (const key in ao) {
2561
+ if (omitKeys?.[key]) continue;
2562
+ const av = ao[key];
2563
+ const bv = bo[key];
2564
+ if (av !== bv && !Bun.deepEquals(av, bv)) return false;
2565
+ }
2566
+ for (const key in bo) {
2567
+ if (omitKeys?.[key]) continue;
2568
+ if (bo[key] !== undefined && !(key in ao)) return false;
2569
+ }
2570
+ return true;
2571
+ }
2572
+
2573
+ const TOP_LEVEL_EXCLUDE_MAP = {
2574
+ input: true,
2575
+ client_metadata: true,
2576
+ };
2577
+
2517
2578
  /**
2518
2579
  * Strict-prefix delta for stateful `previous_response_id` chaining (used by the
2519
2580
  * platform Responses provider and the Codex provider on both transports):
@@ -2523,24 +2584,30 @@ export function populateResponsesUsageFromResponse(
2523
2584
  * `client_metadata` (e.g. rotating turn ids) is excluded from the option
2524
2585
  * comparison; codex-rs excludes it from the same check.
2525
2586
  */
2526
- export function buildResponsesDeltaInput<TItem>(
2527
- previous: { input?: unknown } | undefined,
2587
+ export function buildResponsesDeltaInput<TItem extends ResponseInputItem | InputItem>(
2588
+ previous: { input?: TItem[] } | undefined,
2528
2589
  previousResponseItems: readonly TItem[] | undefined,
2529
- current: { input?: unknown },
2590
+ current: { input?: TItem[] },
2530
2591
  ): TItem[] | null {
2531
2592
  if (!previous) return null;
2532
2593
  if (!Array.isArray(previous.input) || !Array.isArray(current.input)) return null;
2533
- const previousWithoutInput = { ...previous, input: undefined, client_metadata: undefined };
2534
- const currentWithoutInput = { ...current, input: undefined, client_metadata: undefined };
2535
- if (!Bun.deepEquals(previousWithoutInput, currentWithoutInput)) {
2594
+ if (!deepEqualsWithout(previous, current, TOP_LEVEL_EXCLUDE_MAP)) {
2536
2595
  return null;
2537
2596
  }
2538
- const baseline = [...previous.input, ...(previousResponseItems ?? [])];
2539
- if (current.input.length <= baseline.length) return null;
2540
- for (let index = 0; index < baseline.length; index += 1) {
2541
- if (!Bun.deepEquals(baseline[index], current.input[index])) {
2542
- return null;
2597
+
2598
+ const baselineLen = (previous.input?.length ?? 0) + (previousResponseItems?.length ?? 0);
2599
+ if (current.input.length <= baselineLen) return null;
2600
+
2601
+ let index = 0;
2602
+ for (const series of [previous.input, previousResponseItems]) {
2603
+ if (!series) continue;
2604
+ for (const item of series) {
2605
+ if (deepEqualsWithout(item, current.input[index])) {
2606
+ index++;
2607
+ } else {
2608
+ return null;
2609
+ }
2543
2610
  }
2544
2611
  }
2545
- return current.input.slice(baseline.length) as TItem[];
2612
+ return current.input.slice(index) as TItem[];
2546
2613
  }
@@ -16,7 +16,7 @@
16
16
  * itself stays credential-free.
17
17
  */
18
18
  import { readSseJson } from "@oh-my-pi/pi-utils";
19
- import { ProviderHttpError } from "../errors";
19
+ import * as AIError from "../error";
20
20
  import type {
21
21
  Api,
22
22
  AssistantMessage,
@@ -59,19 +59,7 @@ function buildWireOptions(options: SimpleStreamOptions | undefined): Record<stri
59
59
  return wire;
60
60
  }
61
61
 
62
- /**
63
- * Non-2xx response from the auth-gateway `/v1/pi/stream` endpoint. `code`
64
- * carries the gateway's error-type token (`authentication_error`,
65
- * `rate_limit_error`, `upstream_error`, ...).
66
- */
67
- export class AuthGatewayError extends ProviderHttpError {
68
- constructor(message: string, status: number, headers?: Headers, code?: string) {
69
- super(message, status, { headers, code });
70
- this.name = "AuthGatewayError";
71
- }
72
- }
73
-
74
- async function decodeGatewayError(response: Response): Promise<AuthGatewayError> {
62
+ async function decodeGatewayError(response: Response): Promise<AIError.AuthGatewayError> {
75
63
  const status = response.status;
76
64
  let body: unknown;
77
65
  try {
@@ -84,7 +72,7 @@ async function decodeGatewayError(response: Response): Promise<AuthGatewayError>
84
72
  if (typeof err === "object" && err !== null) {
85
73
  const message = (err as { message?: unknown }).message;
86
74
  const type = (err as { type?: unknown }).type;
87
- return new AuthGatewayError(
75
+ return new AIError.AuthGatewayError(
88
76
  typeof message === "string" ? message : `auth-gateway ${status}`,
89
77
  status,
90
78
  response.headers,
@@ -93,7 +81,11 @@ async function decodeGatewayError(response: Response): Promise<AuthGatewayError>
93
81
  }
94
82
  }
95
83
  const text = typeof body === "string" ? body : JSON.stringify(body);
96
- return new AuthGatewayError(`auth-gateway ${status}: ${text || response.statusText}`, status, response.headers);
84
+ return new AIError.AuthGatewayError(
85
+ `auth-gateway ${status}: ${text || response.statusText}`,
86
+ status,
87
+ response.headers,
88
+ );
97
89
  }
98
90
 
99
91
  /**
@@ -104,7 +96,7 @@ async function decodeGatewayError(response: Response): Promise<AuthGatewayError>
104
96
  */
105
97
  function resolveStreamUrl(model: Model<Api>): string {
106
98
  if (!model.baseUrl) {
107
- throw new Error(
99
+ throw new AIError.ConfigurationError(
108
100
  `pi-native transport requires \`baseUrl\` on model ${model.id} (set it on the provider config in models.yml)`,
109
101
  );
110
102
  }
@@ -179,7 +171,9 @@ export function streamPiNative<TApi extends Api>(
179
171
  return;
180
172
  }
181
173
  if (!response.body) {
182
- stream.fail(new Error("auth-gateway returned empty body"));
174
+ stream.fail(
175
+ new AIError.AuthGatewayError("auth-gateway returned empty body", response.status, response.headers),
176
+ );
183
177
  return;
184
178
  }
185
179
 
@@ -25,7 +25,9 @@
25
25
  * 200 JSON (stream=false): { message: AssistantMessage }
26
26
  * 4xx/5xx: { error: { type, message } }
27
27
  */
28
+
28
29
  import type { AuthGatewayStreamControl } from "../auth-gateway/types";
30
+ import * as AIError from "../error";
29
31
  import type { AssistantMessageEventStream, Context, SimpleStreamOptions } from "../types";
30
32
 
31
33
  export interface PiNativeParsedRequest {
@@ -72,6 +74,7 @@ const ALLOWED_OPTION_KEYS: ReadonlySet<keyof SimpleStreamOptions> = new Set([
72
74
  "syntheticApiFormat",
73
75
  "preferWebsockets",
74
76
  "openrouterVariant",
77
+ "loopGuard",
75
78
  ] as const satisfies readonly (keyof SimpleStreamOptions)[]);
76
79
 
77
80
  // ---------------------------------------------------------------------------
@@ -91,7 +94,7 @@ const ALLOWED_OPTION_KEYS: ReadonlySet<keyof SimpleStreamOptions> = new Set([
91
94
  */
92
95
  export function parseRequest(body: unknown, _headers?: Headers): PiNativeParsedRequest {
93
96
  if (typeof body !== "object" || body === null || Array.isArray(body)) {
94
- throw new Error("Request body must be a JSON object");
97
+ throw new AIError.ValidationError("Request body must be a JSON object");
95
98
  }
96
99
  const obj = body as Record<string, unknown>;
97
100
 
@@ -104,21 +107,21 @@ export function parseRequest(body: unknown, _headers?: Headers): PiNativeParsedR
104
107
  const m = obj.model as Record<string, unknown>;
105
108
  if (typeof m.id === "string" && m.id.length > 0) modelId = m.id;
106
109
  }
107
- if (!modelId) throw new Error("Missing `modelId` (or `model.id`) field");
110
+ if (!modelId) throw new AIError.ValidationError("Missing `modelId` (or `model.id`) field");
108
111
 
109
112
  const context = obj.context;
110
113
  if (typeof context !== "object" || context === null || Array.isArray(context)) {
111
- throw new Error("Missing `context` object");
114
+ throw new AIError.ValidationError("Missing `context` object");
112
115
  }
113
116
  const ctxObj = context as Record<string, unknown>;
114
117
  if (!Array.isArray(ctxObj.messages)) {
115
- throw new Error("`context.messages` must be an array");
118
+ throw new AIError.ValidationError("`context.messages` must be an array");
116
119
  }
117
120
  if (ctxObj.systemPrompt !== undefined && !Array.isArray(ctxObj.systemPrompt)) {
118
- throw new Error("`context.systemPrompt` must be an array of strings when present");
121
+ throw new AIError.ValidationError("`context.systemPrompt` must be an array of strings when present");
119
122
  }
120
123
  if (ctxObj.tools !== undefined && !Array.isArray(ctxObj.tools)) {
121
- throw new Error("`context.tools` must be an array when present");
124
+ throw new AIError.ValidationError("`context.tools` must be an array when present");
122
125
  }
123
126
 
124
127
  const options: SimpleStreamOptions = {};
@@ -10,6 +10,8 @@
10
10
  * wired into the main streaming path. It provides the infrastructure for lazy
11
11
  * loading that can be integrated when stream.ts is refactored.
12
12
  */
13
+
14
+ import * as AIError from "../error";
13
15
  import type {
14
16
  Api,
15
17
  AssistantMessage,
@@ -248,8 +250,9 @@ function forwardStream<TApi extends Api>(
248
250
  firstItemTimeoutMs,
249
251
  errorMessage: LAZY_STREAM_IDLE_TIMEOUT_ERROR,
250
252
  firstItemErrorMessage: LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR,
251
- onIdle: () => abortTracker.abortLocally(new Error(LAZY_STREAM_IDLE_TIMEOUT_ERROR)),
252
- onFirstItemTimeout: () => abortTracker.abortLocally(new Error(LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR)),
253
+ onIdle: () => abortTracker.abortLocally(new AIError.StreamTimeoutError(LAZY_STREAM_IDLE_TIMEOUT_ERROR)),
254
+ onFirstItemTimeout: () =>
255
+ abortTracker.abortLocally(new AIError.StreamTimeoutError(LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR)),
253
256
  abortSignal: options.signal,
254
257
  // The synthetic `start` event is yielded immediately by every provider before
255
258
  // the upstream model has emitted any tokens. Treating it as the first "real"
@@ -1,3 +1,4 @@
1
+ import { renderDemotedThinking } from "../dialect/demotion";
1
2
  import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage, UserMessage } from "../types";
2
3
 
3
4
  const enum ToolCallStatus {
@@ -226,52 +227,20 @@ function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messa
226
227
  }
227
228
 
228
229
  /**
229
- * Cross-API `openai-completions` targets that can replay a prior turn's
230
- * reasoning as a native, signature-stripped `thinking` block on the wire.
231
- * Anthropic's same-API path (`replayUnsignedThinking`) covers
232
- * `anthropic-messages` targets directly; this is the analogue for the
233
- * `openai-completions` branch of the cross-API path (#3433/#3434). 3p ↔ 3p
234
- * replays between an Anthropic-compatible source (Z.AI Anthropic, Kimi
235
- * Anthropic, …) and an OpenAI-compat reasoning target on the same vendor must
236
- * keep reasoning as structured `reasoning_content` instead of degrading it to
237
- * conversation text.
238
- *
239
- * `compat` MUST be the request-time RESOLVED compat that `convertMessages`
240
- * threads into `transformMessages`, not `model.compat`. OpenCode-hosted
241
- * reasoning models (`opencode-go`/`opencode-zen`) keep
242
- * `requiresReasoningContentForToolCalls` off on the base compat to dodge the
243
- * thinking-off `Extra inputs are not permitted` 400 (#1071) and reactivate it
244
- * on `compat.whenThinking` for thinking-engaged requests to dodge the
245
- * `thinking is enabled but reasoning_content is missing` 400 (#1484).
246
- * `resolveOpenAICompatPolicy` already swaps in `whenThinking` for thinking-on
247
- * requests, so basing this decision on the resolved compat keeps the predicate
248
- * and the encoder in lockstep; reading `model.compat` would re-open #1484 for
249
- * every cross-API switch into an OpenCode reasoning model.
250
- *
251
- * The downstream encoder MUST then surface the preserved block on the wire via
252
- * `reasoningContentField` — see `openai-completions.ts` for the matching
253
- * branch.
230
+ * Targets that have proven they read unsigned foreign thinking when replayed
231
+ * natively. This is a semantic-carry allowlist only: OpenAI-compatible
232
+ * `reasoning_content` schema requirements and llama.cpp cache-prefix replay are
233
+ * handled by their encoders and MUST NOT make foreign thinking look meaningful.
254
234
  */
255
- function openAICompletionsReplaysUnsignedThinking(model: Model, compat: Model["compat"]): boolean {
235
+ function targetReadsForeignThinking(model: Model, compat: Model["compat"]): boolean {
236
+ if (compat === undefined) return false;
237
+ if (model.api === "anthropic-messages") {
238
+ return "replayUnsignedThinking" in compat && compat.replayUnsignedThinking === true;
239
+ }
256
240
  if (model.api !== "openai-completions") return false;
257
- if (compat === undefined || !("requiresReasoningContentForToolCalls" in compat)) return false;
241
+ if (!("thinkingFormat" in compat)) return false;
258
242
  if (compat.requiresThinkingAsText) return false;
259
- // Local llama.cpp-style servers (`replayReasoningContent`) need the replay
260
- // for KV-cache prefix reuse — Qwen3 / DeepSeek-R1 / GLM chat templates
261
- // reconstruct the prior turn's `<think>` block from `reasoning_content`
262
- // (#3528). Checked BEFORE the `model.reasoning` gate: the runtime discovery
263
- // paths for `llama.cpp` / `lm-studio` / `openai-models-list` hardcode
264
- // `reasoning: false` even when the upstream actually emits reasoning, so
265
- // gating on the spec flag here would let a cross-API switch into such a
266
- // target demote the prior `thinking` block to text and lose the
267
- // cache-stable prefix `replayReasoningContent` is meant to preserve.
268
- if (compat.replayReasoningContent) return true;
269
- if (!model.reasoning) return false;
270
- // Hosts that REQUIRE `reasoning_content` on tool-call turns (DeepSeek
271
- // reasoning, Kimi, OpenRouter reasoning, OpenCode thinking-on) already
272
- // accept the replay; Z.AI-format hosts (Z.AI, Zhipu, Moonshot Kimi native,
273
- // Xiaomi MiMo) advertise `reasoning_content` as a continuation hint.
274
- return compat.requiresReasoningContentForToolCalls || compat.thinkingFormat === "zai";
243
+ return model.reasoning && compat.thinkingFormat === "zai";
275
244
  }
276
245
 
277
246
  const ANTHROPIC_TOOL_CALL_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
@@ -461,29 +430,29 @@ export function transformMessages<TApi extends Api>(
461
430
  // thinking blocks before the cross-model paths.
462
431
  if (!sanitized.thinking || sanitized.thinking.trim() === "") return [];
463
432
  if (isSameModel) return sanitized;
464
- // Cross-model + cross-API: preserve as a native, signature-stripped
465
- // `thinking` block whenever the target encoder can re-emit it on the
466
- // wire (today: `openai-completions` reasoning targets that accept
467
- // `reasoning_content` as a continuation hint — Z.AI, Zhipu, DeepSeek
468
- // reasoning, Kimi native, MiMo, OpenRouter reasoning, …). The source
469
- // signature is always dropped because it is bound to the source
470
- // wire-format (Anthropic crypto sig / OpenAI Responses encrypted
471
- // blob) and would be rejected by the target. Without this branch
472
- // every cross-API 3p ↔ 3p switch (Z.AI Anthropic → Z.AI OpenAI,
473
- // Kimi Anthropic → Kimi OpenAI, etc.) demoted prior reasoning to
474
- // conversation text and lost it as structured reasoning context
475
- // (#3433/#3434).
476
- if (openAICompletionsReplaysUnsignedThinking(model, targetCompat)) {
433
+ // Cross-model + cross-API: preserve native thinking only for
434
+ // targets proven to read unsigned foreign reasoning (Z.AI-format
435
+ // OpenAI-compatible targets, plus Anthropic-compatible
436
+ // `replayUnsignedThinking`). Tool-call schema requirements and
437
+ // llama.cpp cache-prefix replay are orthogonal encoder concerns;
438
+ // keeping inert foreign CoT native for those flags loses the
439
+ // canonical visible-text fallback without adding model context.
440
+ if (targetReadsForeignThinking(model, targetCompat)) {
477
441
  return sanitized.thinkingSignature ? { ...sanitized, thinkingSignature: undefined } : sanitized;
478
442
  }
479
443
  // Other cross-API targets (openai-responses encrypted blobs, google
480
- // signed thought parts, anthropic-target from a non-Anthropic source,
481
- // or any reasoning-disabled target) can't usefully replay an unsigned
482
- // thinking block. Demote to text so the reasoning survives at least
483
- // as visible conversation context.
444
+ // thought parts, anthropic-target from a non-Anthropic source, or any
445
+ // reasoning-disabled target) can't replay an unsigned thinking block:
446
+ // the native reasoning slot either rejects a foreign signature or — as
447
+ // verified end-to-end against Gemini 3 — silently discards unsigned
448
+ // thought content (it is neither recalled nor influences generation).
449
+ // Demote to text so the reasoning survives as context, wrapped in the
450
+ // TARGET model's own canonical thinking-block dialect (e.g. a ```thinking
451
+ // fence for Gemini) so it reads as reasoning rather than bare prose the
452
+ // model might mimic.
484
453
  return {
485
454
  type: "text" as const,
486
- text: sanitized.thinking,
455
+ text: renderDemotedThinking(model.id, sanitized.thinking),
487
456
  };
488
457
  }
489
458
 
@@ -514,8 +483,7 @@ export function transformMessages<TApi extends Api>(
514
483
  let normalizedToolCall: ToolCall = toolCall;
515
484
 
516
485
  if (!isSameModel && toolCall.thoughtSignature) {
517
- normalizedToolCall = { ...toolCall };
518
- delete (normalizedToolCall as { thoughtSignature?: string }).thoughtSignature;
486
+ normalizedToolCall = { ...toolCall, thoughtSignature: undefined };
519
487
  }
520
488
 
521
489
  if (isAnthropicTarget) {