@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
@@ -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;
@@ -497,7 +493,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
497
493
  partial: output,
498
494
  });
499
495
  }
500
- if (!firstTokenTime) firstTokenTime = Date.now();
496
+ if (!firstTokenTime) firstTokenTime = performance.now();
501
497
  };
502
498
  const appendVisibleThinking = (thinking: string): void => {
503
499
  if (thinking.length === 0) return;
@@ -517,7 +513,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
517
513
  partial: output,
518
514
  });
519
515
  }
520
- if (!firstTokenTime) firstTokenTime = Date.now();
516
+ if (!firstTokenTime) firstTokenTime = performance.now();
521
517
  };
522
518
  const emitHealedToolCall = (call: HealedToolCall): void => {
523
519
  endActiveThinkingBlock();
@@ -527,7 +523,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
527
523
  id: call.id,
528
524
  name: call.name,
529
525
  arguments: parseStreamingJson<Record<string, unknown>>(call.arguments),
530
- partialJson: call.arguments,
526
+ [kStreamingPartialJson]: call.arguments,
531
527
  };
532
528
  output.content.push(toolCall);
533
529
  const index = output.content.length - 1;
@@ -540,7 +536,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
540
536
  });
541
537
  endToolCallBlock(stream, output, index);
542
538
  healedToolCallEmitted = true;
543
- if (!firstTokenTime) firstTokenTime = Date.now();
539
+ if (!firstTokenTime) firstTokenTime = performance.now();
544
540
  };
545
541
  const emitHealingEvent = (event: StreamMarkupHealingEvent): void => {
546
542
  if (event.type === "text") {
@@ -558,7 +554,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
558
554
  try {
559
555
  const apiKey = options.apiKey || getEnvApiKey(model.provider);
560
556
  if (!apiKey) {
561
- throw new Error(`No API key for provider: ${model.provider}`);
557
+ throw new AIError.MissingApiKeyError(model.provider);
562
558
  }
563
559
  const baseUrl = normalizeBaseUrl(model.baseUrl);
564
560
  let body = createChatBody(model, context, options);
@@ -606,12 +602,14 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
606
602
  }
607
603
  if (!response.ok) {
608
604
  capturedErrorResponse = await captureHttpErrorResponse(response);
609
- throw new OllamaApiError(`HTTP ${response.status} from ${baseUrl}/api/chat`, response.status, {
605
+ throw new AIError.OllamaApiError(`HTTP ${response.status} from ${baseUrl}/api/chat`, response.status, {
610
606
  headers: response.headers,
611
607
  });
612
608
  }
613
609
  if (!response.body) {
614
- throw new Error("Ollama returned an empty response body");
610
+ throw new AIError.OllamaApiError("Ollama returned an empty response body", response.status, {
611
+ headers: response.headers,
612
+ });
615
613
  }
616
614
  stream.push({ type: "start", partial: output });
617
615
  for await (const chunk of iterateNdjson(response.body)) {
@@ -633,7 +631,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
633
631
  });
634
632
  }
635
633
  if (!firstTokenTime) {
636
- firstTokenTime = Date.now();
634
+ firstTokenTime = performance.now();
637
635
  }
638
636
  }
639
637
  const chunkContent = chunk.message?.content;
@@ -662,7 +660,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
662
660
  id: `ollama:${output.content.length}:${name}`,
663
661
  name,
664
662
  arguments: parseStreamingJson<Record<string, unknown>>(partialJson),
665
- partialJson,
663
+ [kStreamingPartialJson]: partialJson,
666
664
  };
667
665
  output.content.push(toolCall);
668
666
  const index = output.content.length - 1;
@@ -675,7 +673,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
675
673
  partial: output,
676
674
  });
677
675
  if (!firstTokenTime) {
678
- firstTokenTime = Date.now();
676
+ firstTokenTime = performance.now();
679
677
  }
680
678
  }
681
679
  }
@@ -723,7 +721,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
723
721
  if (output.stopReason === "stop" && output.content.some(block => block.type === "toolCall")) {
724
722
  output.stopReason = "toolUse";
725
723
  }
726
- output.duration = Date.now() - startTime;
724
+ output.duration = performance.now() - startTime;
727
725
  if (firstTokenTime) {
728
726
  output.ttft = firstTokenTime - startTime;
729
727
  }
@@ -739,13 +737,20 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
739
737
  } catch (error) {
740
738
  for (const block of output.content) {
741
739
  if (block.type === "toolCall") {
742
- delete (block as InternalToolCallBlock).partialJson;
740
+ clearStreamingPartialJson(block);
743
741
  }
744
742
  }
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;
743
+ const result = await AIError.finalize(error, {
744
+ api: model.api,
745
+ signal: options.signal,
746
+ rawRequestDump,
747
+ capturedErrorResponse,
748
+ });
749
+ output.stopReason = result.stopReason;
750
+ output.errorStatus = result.status;
751
+ output.errorId = result.id;
752
+ output.errorMessage = result.message;
753
+ output.duration = performance.now() - startTime;
749
754
  if (firstTokenTime) {
750
755
  output.ttft = firstTokenTime - startTime;
751
756
  }
@@ -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;