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

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 (170) hide show
  1. package/CHANGELOG.md +36 -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 +3 -2
  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/usage/openai-codex-base-url.d.ts +17 -0
  42. package/dist/types/utils/block-symbols.d.ts +20 -0
  43. package/dist/types/utils/openai-http.d.ts +4 -8
  44. package/dist/types/utils/retry.d.ts +2 -14
  45. package/dist/types/utils/stream-markup-healing.d.ts +14 -5
  46. package/dist/types/utils/thinking-loop.d.ts +3 -1
  47. package/package.json +8 -4
  48. package/src/api-registry.ts +3 -1
  49. package/src/auth-broker/discover.ts +7 -2
  50. package/src/auth-broker/remote-store.ts +8 -7
  51. package/src/auth-gateway/server.ts +27 -115
  52. package/src/auth-retry.ts +9 -21
  53. package/src/auth-storage.ts +34 -49
  54. package/src/dialect/demotion.ts +31 -0
  55. package/src/dialect/factory.ts +0 -2
  56. package/src/dialect/index.ts +2 -0
  57. package/src/dialect/owned-stream.ts +0 -1
  58. package/src/dialect/thinking.ts +22 -10
  59. package/src/error/abort.ts +18 -0
  60. package/src/error/auth-classify.ts +30 -0
  61. package/src/error/auth.ts +48 -0
  62. package/src/error/aws.ts +31 -0
  63. package/src/error/classes.ts +186 -0
  64. package/src/error/finalize.ts +69 -0
  65. package/src/error/flags.ts +486 -0
  66. package/src/error/format.ts +36 -0
  67. package/src/error/gateway.ts +96 -0
  68. package/src/error/index.ts +13 -0
  69. package/src/error/oauth.ts +58 -0
  70. package/src/error/provider.ts +56 -0
  71. package/src/{rate-limit-utils.ts → error/rate-limit.ts} +9 -3
  72. package/src/error/retryable.ts +70 -0
  73. package/src/error/validation.ts +44 -0
  74. package/src/index.ts +1 -3
  75. package/src/providers/amazon-bedrock.ts +61 -57
  76. package/src/providers/anthropic-client.ts +13 -41
  77. package/src/providers/anthropic-messages-server.ts +9 -2
  78. package/src/providers/anthropic.ts +84 -147
  79. package/src/providers/aws-credentials.ts +41 -11
  80. package/src/providers/aws-eventstream.ts +12 -21
  81. package/src/providers/azure-openai-responses.ts +27 -17
  82. package/src/providers/cursor.ts +59 -53
  83. package/src/providers/devin.ts +28 -20
  84. package/src/providers/gitlab-duo-workflow.ts +30 -10
  85. package/src/providers/gitlab-duo.ts +22 -6
  86. package/src/providers/google-auth.ts +15 -5
  87. package/src/providers/google-gemini-cli.ts +46 -60
  88. package/src/providers/google-shared.ts +40 -38
  89. package/src/providers/google-vertex.ts +3 -2
  90. package/src/providers/google.ts +5 -1
  91. package/src/providers/mock.ts +10 -7
  92. package/src/providers/ollama.ts +40 -30
  93. package/src/providers/openai-chat-server.ts +2 -1
  94. package/src/providers/openai-codex/request-transformer.ts +13 -12
  95. package/src/providers/openai-codex/response-handler.ts +1 -1
  96. package/src/providers/openai-codex-responses.ts +931 -1012
  97. package/src/providers/openai-completions.ts +46 -36
  98. package/src/providers/openai-responses-server-schema.ts +3 -0
  99. package/src/providers/openai-responses-server.ts +82 -30
  100. package/src/providers/openai-responses-wire.ts +1 -1
  101. package/src/providers/openai-responses.ts +50 -24
  102. package/src/providers/openai-shared.ts +118 -51
  103. package/src/providers/pi-native-client.ts +12 -18
  104. package/src/providers/pi-native-server.ts +9 -6
  105. package/src/providers/register-builtins.ts +5 -2
  106. package/src/providers/transform-messages.ts +31 -63
  107. package/src/registry/alibaba-coding-plan.ts +6 -5
  108. package/src/registry/api-key-login.ts +4 -3
  109. package/src/registry/api-key-validation.ts +4 -3
  110. package/src/registry/cloudflare-ai-gateway.ts +4 -3
  111. package/src/registry/coreweave.ts +2 -1
  112. package/src/registry/deepseek.ts +2 -1
  113. package/src/registry/google-antigravity.ts +2 -1
  114. package/src/registry/google-gemini-cli.ts +2 -1
  115. package/src/registry/kagi.ts +4 -3
  116. package/src/registry/kilo.ts +32 -10
  117. package/src/registry/litellm.ts +4 -3
  118. package/src/registry/llama-cpp.ts +3 -2
  119. package/src/registry/lm-studio.ts +3 -2
  120. package/src/registry/nvidia.ts +7 -7
  121. package/src/registry/oauth/anthropic.ts +23 -6
  122. package/src/registry/oauth/callback-server.ts +5 -3
  123. package/src/registry/oauth/cursor.ts +18 -4
  124. package/src/registry/oauth/devin.ts +14 -3
  125. package/src/registry/oauth/github-copilot.ts +21 -10
  126. package/src/registry/oauth/gitlab-duo-workflow.ts +15 -3
  127. package/src/registry/oauth/gitlab-duo.ts +30 -6
  128. package/src/registry/oauth/google-antigravity.ts +14 -5
  129. package/src/registry/oauth/google-gemini-cli.ts +24 -7
  130. package/src/registry/oauth/google-oauth-shared.ts +6 -3
  131. package/src/registry/oauth/index.ts +19 -8
  132. package/src/registry/oauth/kimi.ts +40 -10
  133. package/src/registry/oauth/openai-codex.ts +26 -11
  134. package/src/registry/oauth/opencode.ts +4 -3
  135. package/src/registry/oauth/perplexity.ts +33 -11
  136. package/src/registry/oauth/xai-oauth.ts +62 -18
  137. package/src/registry/oauth/xiaomi.ts +21 -9
  138. package/src/registry/ollama-cloud.ts +5 -4
  139. package/src/registry/ollama.ts +3 -2
  140. package/src/registry/parallel.ts +4 -3
  141. package/src/registry/qwen-portal.ts +4 -3
  142. package/src/registry/tavily.ts +4 -3
  143. package/src/registry/vercel-ai-gateway.ts +4 -3
  144. package/src/registry/vllm.ts +3 -2
  145. package/src/stream.ts +79 -24
  146. package/src/types.ts +6 -0
  147. package/src/usage/claude.ts +2 -1
  148. package/src/usage/github-copilot.ts +4 -3
  149. package/src/usage/google-antigravity.ts +2 -1
  150. package/src/usage/openai-codex-base-url.ts +30 -10
  151. package/src/utils/abort.ts +4 -2
  152. package/src/utils/block-symbols.ts +32 -0
  153. package/src/utils/event-stream.ts +15 -3
  154. package/src/utils/http-inspector.ts +4 -4
  155. package/src/utils/idle-iterator.ts +6 -5
  156. package/src/utils/openai-http.ts +11 -46
  157. package/src/utils/parse-bind.ts +7 -5
  158. package/src/utils/proxy.ts +2 -1
  159. package/src/utils/retry.ts +7 -23
  160. package/src/utils/schema/normalize.ts +3 -2
  161. package/src/utils/stream-markup-healing.ts +41 -18
  162. package/src/utils/thinking-loop.ts +15 -11
  163. package/src/utils/validation.ts +4 -3
  164. package/dist/types/dialect/pi.d.ts +0 -9
  165. package/dist/types/errors.d.ts +0 -24
  166. package/dist/types/utils/overflow.d.ts +0 -55
  167. package/src/dialect/pi.md +0 -55
  168. package/src/dialect/pi.ts +0 -600
  169. package/src/errors.ts +0 -32
  170. package/src/utils/overflow.ts +0 -140
@@ -4,8 +4,9 @@
4
4
 
5
5
  import { scheduler } from "node:timers/promises";
6
6
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
7
- import { extractHttpStatusFromError, readSseJson } from "@oh-my-pi/pi-utils";
8
- import { ProviderHttpError } from "../errors";
7
+ import { readSseJson } from "@oh-my-pi/pi-utils";
8
+ import { renderDemotedThinking } from "../dialect/demotion";
9
+ import * as AIError from "../error";
9
10
  import type {
10
11
  Api,
11
12
  AssistantMessage,
@@ -22,7 +23,7 @@ import type {
22
23
  } from "../types";
23
24
  import { normalizeSystemPrompts } from "../utils";
24
25
  import { AssistantMessageEventStream } from "../utils/event-stream";
25
- import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
26
+ import type { RawHttpRequestDump } from "../utils/http-inspector";
26
27
  import { normalizeSchemaForCCA, normalizeSchemaForGoogle, toolWireSchema } from "../utils/schema";
27
28
  import type {
28
29
  Content,
@@ -47,11 +48,6 @@ export type {
47
48
  } from "./google-types";
48
49
  export { normalizeSchemaForGoogle };
49
50
 
50
- /** Non-2xx response (or in-stream error chunk) from the Google Generative Language / Vertex API. */
51
- export class GoogleApiError extends ProviderHttpError {
52
- override readonly name = "GoogleApiError";
53
- }
54
-
55
51
  type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex";
56
52
 
57
53
  /**
@@ -234,18 +230,16 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
234
230
  } else if (block.type === "thinking") {
235
231
  // Skip empty thinking blocks
236
232
  if (!block.thinking || block.thinking.trim() === "") continue;
237
- // Only keep as thinking block if same provider AND same model
238
- // Otherwise convert to plain text (no tags to avoid model mimicking them)
239
- if (isSameProviderAndModel) {
240
- const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thinkingSignature);
233
+ const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thinkingSignature);
234
+ if (thoughtSignature) {
241
235
  parts.push({
242
236
  thought: true,
243
237
  text: block.thinking.toWellFormed(),
244
- ...(thoughtSignature && { thoughtSignature }),
238
+ thoughtSignature,
245
239
  });
246
240
  } else {
247
241
  parts.push({
248
- text: block.thinking.toWellFormed(),
242
+ text: renderDemotedThinking(model.id, block.thinking),
249
243
  });
250
244
  }
251
245
  } else if (block.type === "toolCall") {
@@ -420,7 +414,7 @@ export function mapStopReason(reason: FinishReason): StopReason {
420
414
  case "NO_IMAGE":
421
415
  return "error";
422
416
  default: {
423
- throw new Error(`Unhandled stop reason: ${reason satisfies never}`);
417
+ throw new AIError.ConfigurationError(`Unhandled stop reason: ${reason satisfies never}`);
424
418
  }
425
419
  }
426
420
  }
@@ -597,13 +591,14 @@ export async function consumeGoogleStream<T extends GoogleApiType>(args: {
597
591
  const detail = chunk.error.message || chunk.error.status || "unknown error";
598
592
  const message = `Google API stream error: ${detail}`;
599
593
  throw typeof chunk.error.code === "number" && chunk.error.code >= 400
600
- ? new GoogleApiError(message, chunk.error.code)
601
- : new Error(message);
594
+ ? new AIError.GoogleApiError(message, chunk.error.code)
595
+ : new AIError.ProviderResponseError(message, { provider: model.provider, kind: "output" });
602
596
  }
603
597
  if (!chunk.candidates?.length && chunk.promptFeedback?.blockReason) {
604
598
  const detail = chunk.promptFeedback.blockReasonMessage;
605
- throw new Error(
599
+ throw new AIError.ProviderResponseError(
606
600
  `Request blocked by Google (${chunk.promptFeedback.blockReason})${detail ? `: ${detail}` : ""}`,
601
+ { provider: model.provider, kind: "content-blocked" },
607
602
  );
608
603
  }
609
604
  const candidate = chunk.candidates?.[0];
@@ -734,15 +729,21 @@ export async function consumeGoogleStream<T extends GoogleApiType>(args: {
734
729
  flushCurrent();
735
730
 
736
731
  if (options?.signal?.aborted) {
737
- throw new Error("Request was aborted");
732
+ throw new AIError.AbortError();
738
733
  }
739
734
 
740
735
  if (!sawFinishReason) {
741
- throw new Error("Google API stream ended without a finish reason (connection dropped or response truncated)");
736
+ throw new AIError.ProviderResponseError(
737
+ "Google API stream ended without a finish reason (connection dropped or response truncated)",
738
+ { provider: model.provider, kind: "incomplete-stream" },
739
+ );
742
740
  }
743
741
 
744
742
  if (output.stopReason === "aborted" || output.stopReason === "error") {
745
- throw new Error(output.errorMessage ?? "An unknown error occurred");
743
+ throw new AIError.ProviderResponseError(output.errorMessage ?? "An unknown error occurred", {
744
+ provider: model.provider,
745
+ kind: "output",
746
+ });
746
747
  }
747
748
  }
748
749
 
@@ -826,7 +827,7 @@ export function buildGoogleGenerateContentParams<T extends "google-generative-ai
826
827
 
827
828
  if (options.signal) {
828
829
  if (options.signal.aborted) {
829
- throw new Error("Request aborted");
830
+ throw new AIError.AbortError("Request aborted");
830
831
  }
831
832
  config.abortSignal = options.signal;
832
833
  }
@@ -864,7 +865,7 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
864
865
  const stream = new AssistantMessageEventStream();
865
866
 
866
867
  (async () => {
867
- const startTime = Date.now();
868
+ const startTime = performance.now();
868
869
  let firstTokenTime: number | undefined;
869
870
 
870
871
  const output: AssistantMessage = {
@@ -914,14 +915,17 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
914
915
  });
915
916
  if (!response.ok) {
916
917
  const errorText = await response.text().catch(() => "");
917
- throw new GoogleApiError(
918
+ throw new AIError.GoogleApiError(
918
919
  `Google API error (${response.status}): ${extractGoogleErrorMessage(errorText)}`,
919
920
  response.status,
920
921
  { headers: response.headers },
921
922
  );
922
923
  }
923
924
  if (!response.body) {
924
- throw new Error("Google API returned an empty response body");
925
+ throw new AIError.ProviderResponseError("Google API returned an empty response body", {
926
+ provider: model.provider,
927
+ kind: "empty-body",
928
+ });
925
929
  }
926
930
  return response.body as ReadableStream<Uint8Array>;
927
931
  };
@@ -944,39 +948,37 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
944
948
  options,
945
949
  retainTextSignature,
946
950
  onFirstToken: () => {
947
- firstTokenTime = Date.now();
951
+ firstTokenTime = performance.now();
948
952
  },
949
953
  });
950
954
 
951
955
  if (output.stopReason !== "stop" || hasMeaningfulGoogleContent(output)) break;
952
956
  if (emptyAttempt >= MAX_EMPTY_STREAM_RETRIES) {
953
- throw new Error(
957
+ throw new AIError.ProviderResponseError(
954
958
  `Google API returned an empty response (finishReason STOP with no content) after ${MAX_EMPTY_STREAM_RETRIES + 1} attempts`,
959
+ { provider: model.provider, kind: "empty-body" },
955
960
  );
956
961
  }
957
962
  try {
958
963
  await scheduler.wait(EMPTY_STREAM_BASE_DELAY_MS * 2 ** emptyAttempt, { signal: options?.signal });
959
964
  } catch {
960
- throw new Error("Request was aborted");
965
+ throw new AIError.AbortError();
961
966
  }
962
967
  resetGoogleStreamOutputForRetry(output);
963
968
  body = await openStream();
964
969
  }
965
970
 
966
- output.duration = Date.now() - startTime;
971
+ output.duration = performance.now() - startTime;
967
972
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
968
973
  stream.push({ type: "done", reason: output.stopReason as "length" | "stop" | "toolUse", message: output });
969
974
  stream.end();
970
975
  } catch (error) {
971
- for (const block of output.content) {
972
- if ("index" in block) {
973
- delete (block as { index?: number }).index;
974
- }
975
- }
976
- output.stopReason = options?.signal?.aborted ? "aborted" : "error";
977
- output.errorStatus = extractHttpStatusFromError(error);
978
- output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
979
- output.duration = Date.now() - startTime;
976
+ const result = await AIError.finalize(error, { api: model.api, signal: options?.signal, rawRequestDump });
977
+ output.stopReason = result.stopReason;
978
+ output.errorStatus = result.status;
979
+ output.errorId = result.id;
980
+ output.errorMessage = result.message;
981
+ output.duration = performance.now() - startTime;
980
982
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
981
983
  stream.push({ type: "error", reason: output.stopReason, error: output });
982
984
  stream.end();
@@ -1,4 +1,5 @@
1
1
  import { $env } from "@oh-my-pi/pi-utils";
2
+ import * as AIError from "../error";
2
3
  import type { Context, Model, StreamFunction } from "../types";
3
4
  import type { AssistantMessageEventStream } from "../utils/event-stream";
4
5
  import { getVertexAccessToken } from "./google-auth";
@@ -69,7 +70,7 @@ function resolveApiKey(options?: GoogleVertexOptions): string | undefined {
69
70
  function resolveProject(options?: GoogleVertexOptions): string {
70
71
  const project = options?.project || $env.GOOGLE_CLOUD_PROJECT || $env.GCP_PROJECT || $env.GCLOUD_PROJECT;
71
72
  if (!project) {
72
- throw new Error(
73
+ throw new AIError.ConfigurationError(
73
74
  "Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCP_PROJECT/GCLOUD_PROJECT or pass project in options.",
74
75
  );
75
76
  }
@@ -83,7 +84,7 @@ function resolveLocation(options?: GoogleVertexOptions): string {
83
84
  const location =
84
85
  options?.location || $env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION;
85
86
  if (!location) {
86
- throw new Error(
87
+ throw new AIError.ConfigurationError(
87
88
  "Vertex AI requires a location. Set GOOGLE_VERTEX_LOCATION/GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION or pass location in options.",
88
89
  );
89
90
  }
@@ -1,3 +1,4 @@
1
+ import * as AIError from "../error";
1
2
  import { getEnvApiKey } from "../stream";
2
3
  import type { Context, Model, StreamFunction } from "../types";
3
4
  import type { AssistantMessageEventStream } from "../utils/event-stream";
@@ -24,7 +25,10 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = (
24
25
  prepare: (): GoogleGenAIRequestPlan => {
25
26
  const apiKey = options?.apiKey || getEnvApiKey(model.provider);
26
27
  if (!apiKey) {
27
- throw new Error("Google Generative AI requires an API key (GEMINI_API_KEY or options.apiKey).");
28
+ throw new AIError.MissingApiKeyError(
29
+ undefined,
30
+ "Google Generative AI requires an API key (GEMINI_API_KEY or options.apiKey).",
31
+ );
28
32
  }
29
33
  const params = buildGoogleGenerateContentParams(model, context, options ?? {});
30
34
  // `model.baseUrl` already includes the API version segment when set (mirrors the
@@ -43,6 +43,7 @@
43
43
  */
44
44
 
45
45
  import { registerCustomApi } from "../api-registry";
46
+ import * as AIError from "../error";
46
47
  import type {
47
48
  Api,
48
49
  AssistantMessage,
@@ -242,7 +243,7 @@ export function streamMock(
242
243
  if (!isMockModel(model)) {
243
244
  queueMicrotask(() => {
244
245
  stream.fail(
245
- new Error(
246
+ new AIError.ValidationError(
246
247
  "streamMock called with a model not produced by createMockModel(). " + "Pass a MockModel instance.",
247
248
  ),
248
249
  );
@@ -288,6 +289,7 @@ async function runMock(
288
289
  options: SimpleStreamOptions | undefined,
289
290
  ): Promise<void> {
290
291
  const startedAt = Date.now();
292
+ const perfStart = performance.now();
291
293
 
292
294
  let handler: MockHandler | undefined;
293
295
  try {
@@ -299,7 +301,7 @@ async function runMock(
299
301
 
300
302
  if (handler === undefined) {
301
303
  stream.fail(
302
- new Error(
304
+ new AIError.ValidationError(
303
305
  `Mock model "${model.id}" received call ${model.calls.length} but no response or handler is configured.`,
304
306
  ),
305
307
  );
@@ -338,7 +340,7 @@ async function runMock(
338
340
  try {
339
341
  await sleep(response.delayMs, options?.signal);
340
342
  } catch {
341
- emitTerminalError(stream, model, startedAt, "aborted", "Mock aborted during delay.");
343
+ emitTerminalError(stream, model, startedAt, perfStart, "aborted", "Mock aborted during delay.");
342
344
  return;
343
345
  }
344
346
  }
@@ -350,7 +352,7 @@ async function runMock(
350
352
  : response.throw instanceof Error
351
353
  ? response.throw.message
352
354
  : String(response.throw);
353
- emitTerminalError(stream, model, startedAt, "error", message);
355
+ emitTerminalError(stream, model, startedAt, perfStart, "error", message);
354
356
  return;
355
357
  }
356
358
 
@@ -397,7 +399,7 @@ async function runMock(
397
399
  partial.stopDetails = response.stopDetails;
398
400
  partial.errorMessage = response.errorMessage;
399
401
  partial.usage = mergeUsage(response.usage);
400
- partial.duration = Date.now() - startedAt;
402
+ partial.duration = performance.now() - perfStart;
401
403
 
402
404
  if (reason === "aborted" || reason === "error") {
403
405
  stream.push({
@@ -460,6 +462,7 @@ function emitTerminalError(
460
462
  stream: AssistantMessageEventStream,
461
463
  model: Model<Api>,
462
464
  startedAt: number,
465
+ perfStart: number,
463
466
  reason: "aborted" | "error",
464
467
  message: string,
465
468
  ): void {
@@ -473,7 +476,7 @@ function emitTerminalError(
473
476
  stopReason: reason as StopReason,
474
477
  errorMessage: message,
475
478
  timestamp: startedAt,
476
- duration: Date.now() - startedAt,
479
+ duration: performance.now() - perfStart,
477
480
  };
478
481
  stream.push({ type: "start", partial: failure });
479
482
  stream.push({ type: "error", reason, error: failure });
@@ -488,7 +491,7 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
488
491
  const onAbort = () => {
489
492
  clearTimeout(timer);
490
493
  signal?.removeEventListener("abort", onAbort);
491
- reject(signal?.reason ?? new Error("aborted"));
494
+ reject(signal?.reason ?? new AIError.AbortError("aborted"));
492
495
  };
493
496
  const timer = setTimeout(() => {
494
497
  signal?.removeEventListener("abort", onAbort);
@@ -1,5 +1,5 @@
1
- import { extractHttpStatusFromError, fetchWithRetry, parseStreamingJson } from "@oh-my-pi/pi-utils";
2
- import { ProviderHttpError } from "../errors";
1
+ import { fetchWithRetry, parseStreamingJson } from "@oh-my-pi/pi-utils";
2
+ import * as AIError from "../error";
3
3
  import { getEnvApiKey } from "../stream";
4
4
  import type {
5
5
  Api,
@@ -15,8 +15,9 @@ import type {
15
15
  ToolChoice,
16
16
  } from "../types";
17
17
  import { normalizeSystemPrompts } from "../utils";
18
+ import { clearStreamingPartialJson, kStreamingPartialJson } from "../utils/block-symbols";
18
19
  import { AssistantMessageEventStream } from "../utils/event-stream";
19
- import { type CapturedHttpErrorResponse, finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
20
+ import type { CapturedHttpErrorResponse, RawHttpRequestDump } from "../utils/http-inspector";
20
21
  import {
21
22
  armPreResponseTimeout,
22
23
  getOpenAIStreamFirstEventTimeoutMs,
@@ -32,11 +33,6 @@ import {
32
33
  import { transformMessages } from "./transform-messages";
33
34
  import { joinTextWithImagePlaceholder, partitionVisionContent } from "./vision-guard";
34
35
 
35
- /** Non-2xx response from the Ollama `/api/chat` endpoint. */
36
- export class OllamaApiError extends ProviderHttpError {
37
- override readonly name = "OllamaApiError";
38
- }
39
-
40
36
  export interface OllamaChatOptions extends StreamOptions {
41
37
  reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh";
42
38
  disableReasoning?: boolean;
@@ -90,7 +86,7 @@ type OllamaChatChunk = {
90
86
 
91
87
  type InternalToolCallBlock = AssistantMessage["content"][number] & {
92
88
  type: "toolCall";
93
- partialJson?: string;
89
+ [kStreamingPartialJson]?: string;
94
90
  };
95
91
 
96
92
  function normalizeBaseUrl(baseUrl?: string): string {
@@ -416,9 +412,9 @@ function endToolCallBlock(stream: AssistantMessageEventStream, output: Assistant
416
412
  return;
417
413
  }
418
414
  const toolCall = block as InternalToolCallBlock;
419
- if (toolCall.partialJson) {
420
- toolCall.arguments = parseStreamingJson<Record<string, unknown>>(toolCall.partialJson);
421
- delete toolCall.partialJson;
415
+ if (toolCall[kStreamingPartialJson]) {
416
+ toolCall.arguments = parseStreamingJson<Record<string, unknown>>(toolCall[kStreamingPartialJson]);
417
+ clearStreamingPartialJson(toolCall);
422
418
  }
423
419
  stream.push({ type: "toolcall_end", contentIndex: index, toolCall, partial: output });
424
420
  }
@@ -456,7 +452,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
456
452
  ): AssistantMessageEventStream => {
457
453
  const stream = new AssistantMessageEventStream();
458
454
  void (async () => {
459
- const startTime = Date.now();
455
+ const startTime = performance.now();
460
456
  let firstTokenTime: number | undefined;
461
457
  const output = createEmptyOutput(model);
462
458
  let rawRequestDump: RawHttpRequestDump | undefined;
@@ -469,6 +465,10 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
469
465
  ? new StreamMarkupHealing({ pattern: streamMarkupHealingPattern })
470
466
  : undefined;
471
467
  let healedToolCallEmitted = false;
468
+ // Once the provider streams native reasoning (`message.thinking`), drop any
469
+ // thinking the text-channel healer also recovers so a model that emits both
470
+ // does not double-count its reasoning.
471
+ let suppressHealedThinking = false;
472
472
  const endActiveTextBlock = (): void => {
473
473
  if (activeTextIndex === undefined) return;
474
474
  endTextBlock(stream, output, activeTextIndex);
@@ -497,7 +497,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
497
497
  partial: output,
498
498
  });
499
499
  }
500
- if (!firstTokenTime) firstTokenTime = Date.now();
500
+ if (!firstTokenTime) firstTokenTime = performance.now();
501
501
  };
502
502
  const appendVisibleThinking = (thinking: string): void => {
503
503
  if (thinking.length === 0) return;
@@ -517,7 +517,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
517
517
  partial: output,
518
518
  });
519
519
  }
520
- if (!firstTokenTime) firstTokenTime = Date.now();
520
+ if (!firstTokenTime) firstTokenTime = performance.now();
521
521
  };
522
522
  const emitHealedToolCall = (call: HealedToolCall): void => {
523
523
  endActiveThinkingBlock();
@@ -527,7 +527,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
527
527
  id: call.id,
528
528
  name: call.name,
529
529
  arguments: parseStreamingJson<Record<string, unknown>>(call.arguments),
530
- partialJson: call.arguments,
530
+ [kStreamingPartialJson]: call.arguments,
531
531
  };
532
532
  output.content.push(toolCall);
533
533
  const index = output.content.length - 1;
@@ -540,13 +540,13 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
540
540
  });
541
541
  endToolCallBlock(stream, output, index);
542
542
  healedToolCallEmitted = true;
543
- if (!firstTokenTime) firstTokenTime = Date.now();
543
+ if (!firstTokenTime) firstTokenTime = performance.now();
544
544
  };
545
545
  const emitHealingEvent = (event: StreamMarkupHealingEvent): void => {
546
546
  if (event.type === "text") {
547
547
  appendVisibleText(event.text);
548
548
  } else if (event.type === "thinking") {
549
- appendVisibleThinking(event.thinking);
549
+ if (!suppressHealedThinking) appendVisibleThinking(event.thinking);
550
550
  } else {
551
551
  emitHealedToolCall(event.call);
552
552
  }
@@ -558,7 +558,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
558
558
  try {
559
559
  const apiKey = options.apiKey || getEnvApiKey(model.provider);
560
560
  if (!apiKey) {
561
- throw new Error(`No API key for provider: ${model.provider}`);
561
+ throw new AIError.MissingApiKeyError(model.provider);
562
562
  }
563
563
  const baseUrl = normalizeBaseUrl(model.baseUrl);
564
564
  let body = createChatBody(model, context, options);
@@ -606,16 +606,19 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
606
606
  }
607
607
  if (!response.ok) {
608
608
  capturedErrorResponse = await captureHttpErrorResponse(response);
609
- throw new OllamaApiError(`HTTP ${response.status} from ${baseUrl}/api/chat`, response.status, {
609
+ throw new AIError.OllamaApiError(`HTTP ${response.status} from ${baseUrl}/api/chat`, response.status, {
610
610
  headers: response.headers,
611
611
  });
612
612
  }
613
613
  if (!response.body) {
614
- throw new Error("Ollama returned an empty response body");
614
+ throw new AIError.OllamaApiError("Ollama returned an empty response body", response.status, {
615
+ headers: response.headers,
616
+ });
615
617
  }
616
618
  stream.push({ type: "start", partial: output });
617
619
  for await (const chunk of iterateNdjson(response.body)) {
618
620
  if (chunk.message?.thinking) {
621
+ suppressHealedThinking = true;
619
622
  endActiveTextBlock();
620
623
  if (activeThinkingIndex === undefined) {
621
624
  output.content.push({ type: "thinking", thinking: "" });
@@ -633,7 +636,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
633
636
  });
634
637
  }
635
638
  if (!firstTokenTime) {
636
- firstTokenTime = Date.now();
639
+ firstTokenTime = performance.now();
637
640
  }
638
641
  }
639
642
  const chunkContent = chunk.message?.content;
@@ -662,7 +665,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
662
665
  id: `ollama:${output.content.length}:${name}`,
663
666
  name,
664
667
  arguments: parseStreamingJson<Record<string, unknown>>(partialJson),
665
- partialJson,
668
+ [kStreamingPartialJson]: partialJson,
666
669
  };
667
670
  output.content.push(toolCall);
668
671
  const index = output.content.length - 1;
@@ -675,7 +678,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
675
678
  partial: output,
676
679
  });
677
680
  if (!firstTokenTime) {
678
- firstTokenTime = Date.now();
681
+ firstTokenTime = performance.now();
679
682
  }
680
683
  }
681
684
  }
@@ -723,7 +726,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
723
726
  if (output.stopReason === "stop" && output.content.some(block => block.type === "toolCall")) {
724
727
  output.stopReason = "toolUse";
725
728
  }
726
- output.duration = Date.now() - startTime;
729
+ output.duration = performance.now() - startTime;
727
730
  if (firstTokenTime) {
728
731
  output.ttft = firstTokenTime - startTime;
729
732
  }
@@ -739,13 +742,20 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
739
742
  } catch (error) {
740
743
  for (const block of output.content) {
741
744
  if (block.type === "toolCall") {
742
- delete (block as InternalToolCallBlock).partialJson;
745
+ clearStreamingPartialJson(block);
743
746
  }
744
747
  }
745
- output.stopReason = options.signal?.aborted ? "aborted" : "error";
746
- output.errorStatus = extractHttpStatusFromError(error);
747
- output.errorMessage = await finalizeErrorMessage(error, rawRequestDump, capturedErrorResponse);
748
- output.duration = Date.now() - startTime;
748
+ const result = await AIError.finalize(error, {
749
+ api: model.api,
750
+ signal: options.signal,
751
+ rawRequestDump,
752
+ capturedErrorResponse,
753
+ });
754
+ output.stopReason = result.stopReason;
755
+ output.errorStatus = result.status;
756
+ output.errorId = result.id;
757
+ output.errorMessage = result.message;
758
+ output.duration = performance.now() - startTime;
749
759
  if (firstTokenTime) {
750
760
  output.ttft = firstTokenTime - startTime;
751
761
  }
@@ -6,6 +6,7 @@ import { resolvePromptCacheKey } from "../auth-gateway/http";
6
6
  * `stream(model, context, options)`.
7
7
  */
8
8
  import type { AuthGatewayStreamControl, AuthGatewayParsedRequest as ParsedRequest } from "../auth-gateway/types";
9
+ import * as AIError from "../error";
9
10
  import type {
10
11
  AssistantMessage,
11
12
  AssistantMessageEventStream,
@@ -53,7 +54,7 @@ export function parseRequest(body: unknown, headers?: Headers): ParsedRequest {
53
54
  // vendor-neutral headers when the body doesn't carry one.
54
55
  const parsed = openaiChatRequestSchema(body);
55
56
  if (parsed instanceof type.errors) {
56
- throw new Error(`openai-chat: ${parsed.summary}`);
57
+ throw new AIError.ValidationError(`openai-chat: ${parsed.summary}`);
57
58
  }
58
59
  const data = parsed;
59
60
 
@@ -14,11 +14,11 @@ export interface ReasoningConfig {
14
14
  export interface CodexRequestOptions {
15
15
  reasoningEffort?: ReasoningConfig["effort"];
16
16
  reasoningSummary?: ReasoningConfig["summary"] | null;
17
- /** Explicit `reasoning.context` override. Defaults to `all_turns` under {@link CodexRequestOptions.responsesLite}, otherwise omitted (server default is `current_turn`). */
17
+ /** Explicit `reasoning.context` override; defaults to `all_turns` for every Codex request when unset. */
18
18
  reasoningContext?: CodexReasoningContext;
19
19
  textVerbosity?: "low" | "medium" | "high";
20
20
  include?: string[];
21
- /** Responses Lite transport contract: strips image detail and defaults `reasoning.context` to `all_turns`, mirroring codex-rs. */
21
+ /** Responses Lite transport contract: strips image detail and disables parallel tool calling, mirroring codex-rs. */
22
22
  responsesLite?: boolean;
23
23
  }
24
24
 
@@ -195,8 +195,13 @@ function stripImageDetails(input: InputItem[]): void {
195
195
  for (const collection of [item.content, item.output]) {
196
196
  if (!Array.isArray(collection)) continue;
197
197
  for (const part of collection) {
198
- if (part && typeof part === "object" && (part as { type?: unknown }).type === "input_image") {
199
- delete (part as { detail?: unknown }).detail;
198
+ if (
199
+ part &&
200
+ typeof part === "object" &&
201
+ (part as { type?: unknown }).type === "input_image" &&
202
+ "detail" in part
203
+ ) {
204
+ part.detail = undefined;
200
205
  }
201
206
  }
202
207
  }
@@ -249,20 +254,16 @@ export async function transformRequestBody(
249
254
  ...body.reasoning,
250
255
  ...reasoningConfig,
251
256
  };
252
- // Responses Lite keeps reasoning replay server-side; codex-rs requests
253
- // `all_turns` there and otherwise omits context so the server default
254
- // (currently `current_turn`) applies.
255
- const reasoningContext = options.reasoningContext ?? (responsesLite ? "all_turns" : undefined);
256
- if (reasoningContext !== undefined) {
257
- body.reasoning.context = reasoningContext;
258
- }
257
+ // Default reasoning replay to `all_turns` for every Codex request,
258
+ // mirroring codex-rs; an explicit `reasoningContext` overrides it.
259
+ body.reasoning.context = options.reasoningContext ?? "all_turns";
259
260
  } else {
260
261
  delete body.reasoning;
261
262
  }
262
263
 
263
264
  body.text = {
264
265
  ...body.text,
265
- verbosity: options.textVerbosity || "low",
266
+ verbosity: options.textVerbosity || "high",
266
267
  };
267
268
 
268
269
  const include = Array.isArray(options.include) ? [...options.include] : [];
@@ -1,5 +1,5 @@
1
1
  import { toNumber } from "@oh-my-pi/pi-catalog/utils";
2
- import { ProviderHttpError } from "../../errors";
2
+ import { ProviderHttpError } from "../../error";
3
3
 
4
4
  export type CodexRateLimit = {
5
5
  used_percent?: number;