@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
@@ -1,3 +1,4 @@
1
+ import * as AIError from "../error";
1
2
  import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
2
3
  import type { ProviderDefinition } from "./types";
3
4
 
@@ -5,7 +6,7 @@ const AUTH_URL = "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway%2Fapi
5
6
 
6
7
  export async function loginVercelAiGateway(options: OAuthController): Promise<string> {
7
8
  if (!options.onPrompt) {
8
- throw new Error("Vercel AI Gateway login requires onPrompt callback");
9
+ throw new AIError.OnPromptRequiredError("Vercel AI Gateway");
9
10
  }
10
11
 
11
12
  options.onAuth?.({
@@ -19,12 +20,12 @@ export async function loginVercelAiGateway(options: OAuthController): Promise<st
19
20
  });
20
21
 
21
22
  if (options.signal?.aborted) {
22
- throw new Error("Login cancelled");
23
+ throw new AIError.LoginCancelledError();
23
24
  }
24
25
 
25
26
  const trimmed = apiKey.trim();
26
27
  if (!trimmed) {
27
- throw new Error("API key is required");
28
+ throw new AIError.ApiKeyRequiredError();
28
29
  }
29
30
 
30
31
  return trimmed;
@@ -1,3 +1,4 @@
1
+ import * as AIError from "../error";
1
2
  import type { OAuthController, OAuthLoginCallbacks, OAuthProvider } from "./oauth/types";
2
3
  import type { ProviderDefinition } from "./types";
3
4
 
@@ -8,7 +9,7 @@ const DEFAULT_LOCAL_TOKEN = "vllm-local";
8
9
 
9
10
  export async function loginVllm(options: OAuthController): Promise<string> {
10
11
  if (!options.onPrompt) {
11
- throw new Error(`${PROVIDER_ID} login requires onPrompt callback`);
12
+ throw new AIError.OnPromptRequiredError(PROVIDER_ID);
12
13
  }
13
14
  options.onAuth?.({
14
15
  url: AUTH_URL,
@@ -20,7 +21,7 @@ export async function loginVllm(options: OAuthController): Promise<string> {
20
21
  allowEmpty: true,
21
22
  });
22
23
  if (options.signal?.aborted) {
23
- throw new Error("Login cancelled");
24
+ throw new AIError.LoginCancelledError();
24
25
  }
25
26
  const trimmed = apiKey.trim();
26
27
  return trimmed || DEFAULT_LOCAL_TOKEN;
package/src/stream.ts CHANGED
@@ -2,6 +2,7 @@ import * as crypto from "node:crypto";
2
2
  import * as fsSync from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
+ import { scheduler } from "node:timers/promises";
5
6
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
6
7
  import { isVertexExpressOpenAIUrl, isVertexRawPredictUrl } from "@oh-my-pi/pi-catalog/hosts";
7
8
  import {
@@ -12,10 +13,12 @@ import {
12
13
  resolveWireModelId,
13
14
  } from "@oh-my-pi/pi-catalog/model-thinking";
14
15
  import { CATALOG_PROVIDERS, type ProviderCatalogEntry } from "@oh-my-pi/pi-catalog/provider-models";
15
- import { $env, $pickenv, extractHttpStatusFromError, getConfigRootDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
16
+ import { $env, $pickenv, getConfigRootDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
16
17
  import { getCustomApi } from "./api-registry";
17
18
  import { AUTH_RETRY_STEPS, isApiKeyResolver, resolveRetryKey } from "./auth-retry";
18
- import { ProviderHttpError } from "./errors";
19
+ import * as AIError from "./error";
20
+ import { ProviderHttpError } from "./error";
21
+ import { isUsageLimitOutcome } from "./error/rate-limit";
19
22
  import type { BedrockOptions } from "./providers/amazon-bedrock";
20
23
  import type { AnthropicOptions } from "./providers/anthropic";
21
24
  import type { CursorOptions } from "./providers/cursor";
@@ -53,7 +56,6 @@ import {
53
56
  streamOpenAIResponses,
54
57
  } from "./providers/register-builtins";
55
58
  import { isSyntheticModel, streamSynthetic } from "./providers/synthetic";
56
- import { isUsageLimitOutcome } from "./rate-limit-utils";
57
59
  import { PROVIDER_REGISTRY } from "./registry";
58
60
  import type {
59
61
  Api,
@@ -269,7 +271,7 @@ async function acquireProviderInFlightLock(provider: string, signal?: AbortSigna
269
271
  await fs.mkdir(path.dirname(lockDir), { recursive: true });
270
272
 
271
273
  while (true) {
272
- if (signal?.aborted) throw signal.reason ?? new Error("Provider request aborted before dispatch");
274
+ if (signal?.aborted) throw signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch");
273
275
  try {
274
276
  await fs.mkdir(lockDir);
275
277
  const lockIdentity = await readProviderInFlightLockIdentity(lockDir);
@@ -374,7 +376,8 @@ async function signalProviderInFlightWaiters(provider: string): Promise<void> {
374
376
  }
375
377
 
376
378
  function waitForProviderInFlightSignal(provider: string, signal?: AbortSignal): Promise<void> {
377
- if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Provider request aborted before dispatch"));
379
+ if (signal?.aborted)
380
+ return Promise.reject(signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch"));
378
381
  const signalPath = providerInFlightSignalPath(provider);
379
382
  const waitStarted = Date.now();
380
383
  const { promise, resolve, reject } = Promise.withResolvers<void>();
@@ -390,7 +393,7 @@ function waitForProviderInFlightSignal(provider: string, signal?: AbortSignal):
390
393
  settle();
391
394
  };
392
395
  const onAbort = () => {
393
- finish(() => reject(signal?.reason ?? new Error("Provider request aborted before dispatch")));
396
+ finish(() => reject(signal?.reason ?? new AIError.AbortError("Provider request aborted before dispatch")));
394
397
  };
395
398
  signal?.addEventListener("abort", onAbort, { once: true });
396
399
  try {
@@ -446,7 +449,7 @@ async function acquireProviderInFlightSlot(
446
449
  if (limit === undefined) return async () => {};
447
450
  let loggedWait = false;
448
451
  while (true) {
449
- if (signal?.aborted) throw signal.reason ?? new Error("Provider request aborted before dispatch");
452
+ if (signal?.aborted) throw signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch");
450
453
  const lease = await tryAcquireProviderInFlightLease(provider, limit, signal);
451
454
  if (lease) return () => releaseProviderInFlightLease(provider, lease);
452
455
  if (!loggedWait) {
@@ -508,7 +511,7 @@ function withProviderInFlightLimit<TOptions extends Pick<StreamOptions, "signal"
508
511
  logger.debug("Provider in-flight limit wait completed", { provider: model.provider, limit });
509
512
  }
510
513
  if (options?.signal?.aborted) {
511
- throw options.signal.reason ?? new Error("Provider request aborted before dispatch");
514
+ throw options.signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch");
512
515
  }
513
516
  const inner = dispatch();
514
517
  try {
@@ -710,7 +713,7 @@ function streamDispatch<TApi extends Api>(
710
713
  if (isGitLabDuoModel(model)) {
711
714
  const apiKey = requestOptions.apiKey || getEnvApiKey(model.provider);
712
715
  if (!apiKey) {
713
- throw new Error(`No API key for provider: ${model.provider}`);
716
+ throw new AIError.MissingApiKeyError(model.provider);
714
717
  }
715
718
  return streamGitLabDuo(model, context, {
716
719
  ...(requestOptions as SimpleStreamOptions),
@@ -721,7 +724,7 @@ function streamDispatch<TApi extends Api>(
721
724
  if (model.api === "gitlab-duo-agent") {
722
725
  const apiKey = (requestOptions as StreamOptions | undefined)?.apiKey || getEnvApiKey(model.provider);
723
726
  if (!apiKey) {
724
- throw new Error(`No API key for provider: ${model.provider}`);
727
+ throw new AIError.MissingApiKeyError(model.provider);
725
728
  }
726
729
  return streamGitLabDuoWorkflow(model as Model<"gitlab-duo-agent">, context, {
727
730
  ...(requestOptions as StreamOptions | undefined),
@@ -739,7 +742,7 @@ function streamDispatch<TApi extends Api>(
739
742
 
740
743
  const apiKey = requestOptions.apiKey || getEnvApiKey(model.provider);
741
744
  if (!apiKey) {
742
- throw new Error(`No API key for provider: ${model.provider}`);
745
+ throw new AIError.MissingApiKeyError(model.provider);
743
746
  }
744
747
  const providerOptions = isGoogleVertexAuthenticatedModel(model)
745
748
  ? {
@@ -823,17 +826,61 @@ function streamDispatch<TApi extends Api>(
823
826
  return streamDevin(model as Model<"devin-agent">, context, providerOptions as DevinOptions);
824
827
 
825
828
  default:
826
- throw new Error(`Unhandled API: ${api}`);
829
+ throw new AIError.ConfigurationError(`Unhandled API: ${api}`);
827
830
  }
828
831
  }
829
832
 
833
+ /** Thinking-loop re-samples spent before {@link resolveWithThinkingLoopCook} cooks. */
834
+ const THINKING_LOOP_MAX_ABORTS = 3;
835
+ const THINKING_LOOP_RETRY_BASE_DELAY_MS = 500;
836
+ const THINKING_LOOP_RETRY_MAX_DELAY_MS = 8_000;
837
+
838
+ /**
839
+ * Resolve a completion, re-sampling a thinking-loop stall up to
840
+ * {@link THINKING_LOOP_MAX_ABORTS} times before letting it cook. The loop guard
841
+ * raises an empty `stopReason: "error"` stall on each guarded attempt; this
842
+ * result-path consumer re-dispatches a fresh request per stall and, once the abort
843
+ * budget is spent, runs one final pass with the guard disabled so a stubborn loop
844
+ * returns the model's raw output instead of a fatal stall. Non-stall results —
845
+ * including genuine errors — return immediately; a caller abort during backoff
846
+ * propagates so cancellation surfaces as an abort, never a stale stall result.
847
+ */
848
+ async function resolveWithThinkingLoopCook(
849
+ signal: AbortSignal | undefined,
850
+ dispatch: () => AssistantMessageEventStream,
851
+ cook: () => AssistantMessageEventStream,
852
+ ): Promise<AssistantMessage> {
853
+ let message = await dispatch().result();
854
+ let thinkingLoopRetry = AIError.is(message.errorId, AIError.Flag.ThinkingLoop);
855
+ for (let attempt = 0; thinkingLoopRetry && attempt < THINKING_LOOP_MAX_ABORTS - 1; attempt += 1) {
856
+ // A caller abort surfaces as a thrown abort (never the stall, which would
857
+ // misclassify as a 502): throwIfAborted before backoff, and scheduler.wait
858
+ // rejects if the abort lands mid-delay.
859
+ signal?.throwIfAborted();
860
+ const delay = Math.min(THINKING_LOOP_RETRY_BASE_DELAY_MS * 2 ** attempt, THINKING_LOOP_RETRY_MAX_DELAY_MS);
861
+ await scheduler.wait(delay, { signal });
862
+ message = await dispatch().result();
863
+ thinkingLoopRetry =
864
+ message.stopReason === "error" &&
865
+ message.content.length === 0 &&
866
+ AIError.is(message.errorId, AIError.Flag.ThinkingLoop);
867
+ }
868
+ if (!thinkingLoopRetry) return message;
869
+ signal?.throwIfAborted();
870
+ // Abort budget spent and still looping: let it cook with the guard disabled.
871
+ return cook().result();
872
+ }
873
+
830
874
  export async function complete<TApi extends Api>(
831
875
  model: Model<TApi>,
832
876
  context: Context,
833
877
  options?: OptionsForApi<TApi>,
834
878
  ): Promise<AssistantMessage> {
835
- const s = stream(model, context, options);
836
- return s.result();
879
+ return resolveWithThinkingLoopCook(
880
+ options?.signal,
881
+ () => stream(model, context, options),
882
+ () => stream(model, context, { ...options, loopGuard: { ...options?.loopGuard, enabled: false } }),
883
+ );
837
884
  }
838
885
 
839
886
  type AuthRetryFailure = {
@@ -845,7 +892,7 @@ type AuthRetryFailure = {
845
892
  function extractStatusFromAssistantError(message: AssistantMessage): number | undefined {
846
893
  if (message.errorStatus !== undefined) return message.errorStatus;
847
894
  if (!message.errorMessage) return undefined;
848
- return extractHttpStatusFromError({ message: message.errorMessage });
895
+ return AIError.status({ message: message.errorMessage });
849
896
  }
850
897
 
851
898
  function isRetryableUpstreamError(error: unknown, status: number | undefined, message: string | undefined): boolean {
@@ -868,7 +915,9 @@ function isRetryableUpstreamError(error: unknown, status: number | undefined, me
868
915
  function createAssistantAuthError(message: AssistantMessage): Error {
869
916
  const text = message.errorMessage ?? "Provider authentication failed";
870
917
  const status = extractStatusFromAssistantError(message);
871
- return status === undefined ? new Error(text) : new ProviderHttpError(text, status);
918
+ return status === undefined
919
+ ? new AIError.ProviderResponseError(text, { kind: "runtime" })
920
+ : new ProviderHttpError(text, status);
872
921
  }
873
922
 
874
923
  function emitBufferedEvents(stream: AssistantMessageEventStream, events: AssistantMessageEvent[]): void {
@@ -937,7 +986,7 @@ export function streamSimple<TApi extends Api>(
937
986
  captureAuthFailure &&
938
987
  isRetryableUpstreamError(
939
988
  error,
940
- extractHttpStatusFromError(error),
989
+ AIError.status(error),
941
990
  error instanceof Error ? error.message : undefined,
942
991
  )
943
992
  ) {
@@ -965,7 +1014,7 @@ export function streamSimple<TApi extends Api>(
965
1014
  // A thrown resolver is a broker/OAuth/network failure, not a missing
966
1015
  // key — surface the cause instead of masking it as "No API key".
967
1016
  outer.fail(
968
- new Error(
1017
+ new AIError.ConfigurationError(
969
1018
  `Failed to resolve API key for provider ${model.provider}: ${error instanceof Error ? error.message : String(error)}`,
970
1019
  { cause: error },
971
1020
  ),
@@ -973,7 +1022,7 @@ export function streamSimple<TApi extends Api>(
973
1022
  return;
974
1023
  }
975
1024
  if (lastKey === undefined) {
976
- outer.fail(new Error(`No API key for provider: ${model.provider}`));
1025
+ outer.fail(new AIError.MissingApiKeyError(model.provider));
977
1026
  return;
978
1027
  }
979
1028
  let failure = await runAttempt(lastKey, true);
@@ -1034,7 +1083,7 @@ export function streamSimple<TApi extends Api>(
1034
1083
  const apiKey =
1035
1084
  (typeof requestOptions?.apiKey === "string" ? requestOptions.apiKey : undefined) || getEnvApiKey(model.provider);
1036
1085
  if (!apiKey) {
1037
- throw new Error(`No API key for provider: ${model.provider}`);
1086
+ throw new AIError.MissingApiKeyError(model.provider);
1038
1087
  }
1039
1088
 
1040
1089
  // GitLab Duo - wraps Anthropic/OpenAI behind GitLab AI Gateway direct access tokens
@@ -1087,8 +1136,11 @@ export async function completeSimple<TApi extends Api>(
1087
1136
  context: Context,
1088
1137
  options?: SimpleStreamOptions,
1089
1138
  ): Promise<AssistantMessage> {
1090
- const s = streamSimple(model, context, options);
1091
- return s.result();
1139
+ return resolveWithThinkingLoopCook(
1140
+ options?.signal,
1141
+ () => streamSimple(model, context, options),
1142
+ () => streamSimple(model, context, { ...options, loopGuard: { ...options?.loopGuard, enabled: false } }),
1143
+ );
1092
1144
  }
1093
1145
 
1094
1146
  const MIN_OUTPUT_TOKENS = 1024;
@@ -1441,6 +1493,7 @@ function mapOptionsForApi<TApi extends Api>(
1441
1493
  openrouterVariant: options?.openrouterVariant,
1442
1494
  maxTokensExplicit: rawOptions?.maxTokens !== undefined,
1443
1495
  disableReasoning: options?.disableReasoning,
1496
+ textVerbosity: options?.textVerbosity,
1444
1497
  });
1445
1498
  }
1446
1499
  return castApi<"openai-completions">({
@@ -1475,6 +1528,7 @@ function mapOptionsForApi<TApi extends Api>(
1475
1528
  openrouterVariant: options?.openrouterVariant,
1476
1529
  maxTokensExplicit: rawOptions?.maxTokens !== undefined,
1477
1530
  disableReasoning: options?.disableReasoning,
1531
+ textVerbosity: options?.textVerbosity,
1478
1532
  });
1479
1533
 
1480
1534
  case "azure-openai-responses":
@@ -1493,7 +1547,8 @@ function mapOptionsForApi<TApi extends Api>(
1493
1547
  toolChoice: mapOpenAiToolChoice(options?.toolChoice),
1494
1548
  serviceTier: options?.serviceTier,
1495
1549
  preferWebsockets: options?.preferWebsockets,
1496
- reasoningSummary: options?.hideThinkingSummary ? null : undefined,
1550
+ reasoningSummary: options?.hideThinkingSummary ? null : "detailed",
1551
+ textVerbosity: options?.textVerbosity,
1497
1552
  });
1498
1553
 
1499
1554
  case "google-generative-ai": {
@@ -1665,7 +1720,7 @@ function mapOptionsForApi<TApi extends Api>(
1665
1720
  });
1666
1721
  }
1667
1722
  default:
1668
- throw new Error(`Unhandled API in mapOptionsForApi: ${model.api}`);
1723
+ throw new AIError.ConfigurationError(`Unhandled API in mapOptionsForApi: ${model.api}`);
1669
1724
  }
1670
1725
  }
1671
1726
 
package/src/types.ts CHANGED
@@ -37,6 +37,7 @@ import type { OllamaChatOptions } from "./providers/ollama";
37
37
  import type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses";
38
38
  import type { OpenAICompletionsOptions } from "./providers/openai-completions";
39
39
  import type { OpenAIResponsesOptions } from "./providers/openai-responses";
40
+ import type { kStreamingPartialJson } from "./utils/block-symbols";
40
41
  import type { AssistantMessageEventStream } from "./utils/event-stream";
41
42
 
42
43
  export type { StopDetails } from "./providers/anthropic-wire";
@@ -379,6 +380,8 @@ export interface SimpleStreamOptions extends Omit<StreamOptions, "apiKey"> {
379
380
  * Useful when the UI hides thinking blocks anyway and the summary is wasted bandwidth.
380
381
  */
381
382
  hideThinkingSummary?: boolean;
383
+ /** OpenAI Responses/Codex `text.verbosity` response detail level. */
384
+ textVerbosity?: "low" | "medium" | "high";
382
385
  /** Custom token budgets for thinking levels (token-based providers only) */
383
386
  thinkingBudgets?: ThinkingBudgets;
384
387
  /** Cursor exec handlers for local tool execution */
@@ -457,6 +460,7 @@ export interface ToolCall {
457
460
  id: string;
458
461
  name: string;
459
462
  arguments: Record<string, unknown>;
463
+ [kStreamingPartialJson]?: string;
460
464
  thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context
461
465
  intent?: string; // Harness-level intent metadata extracted from traced tool arguments
462
466
  /**
@@ -537,6 +541,8 @@ export interface AssistantMessage {
537
541
  errorMessage?: string;
538
542
  /** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
539
543
  errorStatus?: number;
544
+ /** Structured machine-readable error classifier; see `utils/error-id.ts` for bit layout and helpers. */
545
+ errorId?: number;
540
546
  /**
541
547
  * Stable identifiers for request features the provider silently dropped
542
548
  * during this turn (e.g. `"priority"`). Set when a server-side rejection
@@ -1,5 +1,6 @@
1
1
  import { scheduler } from "node:timers/promises";
2
2
  import { toNumber } from "@oh-my-pi/pi-catalog/utils";
3
+ import * as AIError from "../error";
3
4
  import { claudeCodeVersion } from "../providers/anthropic";
4
5
  import type {
5
6
  CredentialRankingStrategy,
@@ -148,7 +149,7 @@ function hasUsageData(payload: ClaudeUsageResponse): boolean {
148
149
  }
149
150
 
150
151
  function isRetryableStatus(status: number): boolean {
151
- return status === 429 || (status >= 500 && status < 600);
152
+ return AIError.isTransientStatus(status);
152
153
  }
153
154
 
154
155
  function isAbortError(error: unknown, signal?: AbortSignal): boolean {
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { toBoolean, toNumber } from "@oh-my-pi/pi-catalog/utils";
8
8
  import { OPENCODE_HEADERS } from "@oh-my-pi/pi-catalog/wire/github-copilot";
9
+ import * as AIError from "../error";
9
10
  import type {
10
11
  UsageAmount,
11
12
  UsageFetchContext,
@@ -142,7 +143,7 @@ async function fetchJson(ctx: UsageFetchContext, url: string, init: RequestInit)
142
143
  const response = await ctx.fetch(url, init);
143
144
  if (!response.ok) {
144
145
  const text = await response.text();
145
- throw new Error(`${response.status} ${response.statusText}: ${text}`);
146
+ throw new AIError.ProviderHttpError(`${response.status} ${response.statusText}: ${text}`, response.status);
146
147
  }
147
148
  return response.json();
148
149
  }
@@ -182,7 +183,7 @@ async function fetchInternalUsage(
182
183
  ...OPENCODE_HEADERS,
183
184
  };
184
185
  const data = await fetchJson(ctx, `${githubApiBaseUrl}/copilot_internal/user`, { headers, signal });
185
- if (!isRecord(data)) throw new Error("Invalid Copilot usage response");
186
+ if (!isRecord(data)) throw new AIError.ProviderHttpError("Invalid Copilot usage response", 200);
186
187
  return data as CopilotUsageResponse;
187
188
  }
188
189
 
@@ -206,7 +207,7 @@ async function fetchBillingUsage(
206
207
  },
207
208
  );
208
209
 
209
- if (!isRecord(data)) throw new Error("Invalid Copilot billing usage response");
210
+ if (!isRecord(data)) throw new AIError.ProviderHttpError("Invalid Copilot billing usage response", 200);
210
211
  return data as BillingUsageResponse;
211
212
  }
212
213
 
@@ -1,4 +1,5 @@
1
1
  import { getAntigravityUserAgent } from "@oh-my-pi/pi-catalog/wire/gemini-headers";
2
+ import * as AIError from "../error";
2
3
  import type {
3
4
  CredentialRankingContext,
4
5
  CredentialRankingStrategy,
@@ -181,7 +182,7 @@ async function fetchAntigravityUsage(params: UsageFetchParams, ctx: UsageFetchCo
181
182
  break;
182
183
  }
183
184
 
184
- if (response.status === 429 || (response.status >= 500 && response.status < 600)) {
185
+ if (AIError.isTransientStatus(response.status)) {
185
186
  continue;
186
187
  }
187
188
  break;
@@ -1,15 +1,35 @@
1
1
  import { CODEX_BASE_URL } from "@oh-my-pi/pi-catalog/wire/codex";
2
2
 
3
+ /**
4
+ * Resolve the base URL for ChatGPT account-API requests (`wham/usage`,
5
+ * `wham/rate-limit-reset-credits`).
6
+ *
7
+ * These endpoints live on the canonical ChatGPT origin and authenticate with
8
+ * the Codex OAuth bearer minted for that origin. They are NOT part of the
9
+ * `/responses` API surface that streaming proxies (Headroom, 9router, etc.)
10
+ * forward, so a `providers.openai-codex.baseUrl` override pointed at such a
11
+ * proxy MUST NOT be used here — doing so 404s and silently breaks
12
+ * `/usage show` (issue #3679).
13
+ *
14
+ * Accepted overrides are the canonical `chatgpt.com` / `chat.openai.com`
15
+ * origins. Any extra path (e.g. a streaming override like
16
+ * `/backend-api/codex/responses`) is normalized to `${origin}/backend-api`,
17
+ * since the account endpoints always live directly under `/backend-api`. Any
18
+ * other host falls back to {@link CODEX_BASE_URL}.
19
+ */
3
20
  export function normalizeCodexBaseUrl(baseUrl?: string): string {
4
- const fallback = CODEX_BASE_URL;
5
- const trimmed = baseUrl?.trim() ? baseUrl.trim() : fallback;
6
- const base = trimmed.replace(/\/+$/, "");
7
- const lower = base.toLowerCase();
8
- if (
9
- (lower.startsWith("https://chatgpt.com") || lower.startsWith("https://chat.openai.com")) &&
10
- !lower.includes("/backend-api")
11
- ) {
12
- return `${base}/backend-api`;
21
+ const trimmed = baseUrl?.trim().replace(/\/+$/, "");
22
+ if (!trimmed) return CODEX_BASE_URL;
23
+ let parsed: URL;
24
+ try {
25
+ parsed = new URL(trimmed);
26
+ } catch {
27
+ return CODEX_BASE_URL;
13
28
  }
14
- return base;
29
+ const host = parsed.host.toLowerCase();
30
+ if (host !== "chatgpt.com" && host !== "chat.openai.com") return CODEX_BASE_URL;
31
+ // wham/usage always lives at `${origin}/backend-api/...` on the canonical
32
+ // ChatGPT origin, so ignore any extra path (e.g. `/backend-api/codex/responses`
33
+ // from a streaming baseUrl) — otherwise we'd build `.../codex/responses/wham/usage`.
34
+ return `${parsed.origin}/backend-api`;
15
35
  }
@@ -1,3 +1,5 @@
1
+ import * as AIError from "../error";
2
+
1
3
  export interface AbortSourceTracker {
2
4
  requestAbortController: AbortController;
3
5
  requestSignal: AbortSignal;
@@ -57,9 +59,9 @@ export function createAbortSourceTracker(callerSignal?: AbortSignal): AbortSourc
57
59
  */
58
60
  export function raceWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
59
61
  if (!signal) return promise;
60
- if (signal.aborted) return Promise.reject(signal.reason ?? new Error("Request was aborted"));
62
+ if (signal.aborted) return Promise.reject(signal.reason ?? new AIError.AbortError());
61
63
  const { promise: aborted, reject } = Promise.withResolvers<never>();
62
- const onAbort = () => reject(signal.reason ?? new Error("Request was aborted"));
64
+ const onAbort = () => reject(signal.reason ?? new AIError.AbortError());
63
65
  signal.addEventListener("abort", onAbort, { once: true });
64
66
  return Promise.race([promise, aborted]).finally(() => signal.removeEventListener("abort", onAbort));
65
67
  }
@@ -0,0 +1,32 @@
1
+ /** Stores streamed tool-call argument JSON for live renderers and parser recovery. */
2
+ export const kStreamingPartialJson = Symbol("provider.block.partialJson");
3
+
4
+ /** Carries streamed tool-call argument JSON without exposing a string-keyed property. */
5
+ export type StreamingPartialJsonCarrier = object & { [kStreamingPartialJson]?: string };
6
+
7
+ /** Reads streamed tool-call argument JSON from a block or event snapshot. */
8
+ export function getStreamingPartialJson(block: StreamingPartialJsonCarrier | null | undefined): string | undefined {
9
+ return block?.[kStreamingPartialJson];
10
+ }
11
+
12
+ /** Writes streamed tool-call argument JSON to a block or clears it with `undefined`. */
13
+ export function setStreamingPartialJson(block: StreamingPartialJsonCarrier, value: string | undefined): void {
14
+ block[kStreamingPartialJson] = value;
15
+ }
16
+
17
+ /** Clears streamed tool-call argument JSON without deleting or changing object shape. */
18
+ export function clearStreamingPartialJson(block: StreamingPartialJsonCarrier): void {
19
+ if (Object.hasOwn(block, kStreamingPartialJson)) block[kStreamingPartialJson] = undefined;
20
+ }
21
+
22
+ /** Stores a provider-local stream block index without exposing a string-keyed property. */
23
+ export const kStreamingBlockIndex = Symbol("provider.block.index");
24
+
25
+ /** Stores the last parsed argument prefix length for throttled streaming JSON parsing. */
26
+ export const kStreamingLastParseLen = Symbol("provider.block.lastParseLen");
27
+
28
+ /** Marks streamed tool-call arguments that already received an authoritative done payload. */
29
+ export const kStreamingArgumentsDone = Symbol("provider.block.argumentsDone");
30
+
31
+ /** Classifies Cursor's in-flight tool-call kind without leaking provider-private state. */
32
+ export const kStreamingBlockKind = Symbol("provider.block.kind");
@@ -1,3 +1,4 @@
1
+ import * as AIError from "../error";
1
2
  import type { AssistantMessage, AssistantMessageEvent } from "../types";
2
3
 
3
4
  // Generic event stream class for async iteration
@@ -63,7 +64,9 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
63
64
  // end() without a terminal value must still settle result() —
64
65
  // otherwise complete()/result() awaits hang forever.
65
66
  this.resultSettled = true;
66
- this.rejectFinalResult(new Error("Stream ended without a final result"));
67
+ this.rejectFinalResult(
68
+ new AIError.ProviderResponseError("Stream ended without a final result", { kind: "envelope" }),
69
+ );
67
70
  }
68
71
  // Notify all waiting consumers that we're done
69
72
  while (this.waiting.length > 0) {
@@ -125,7 +128,7 @@ export class AssistantMessageEventStream extends EventStream<AssistantMessageEve
125
128
  } else if (event.type === "error") {
126
129
  return event.error;
127
130
  }
128
- throw new Error("Unexpected event type for final result");
131
+ throw new AIError.ProviderResponseError("Unexpected event type for final result", { kind: "envelope" });
129
132
  },
130
133
  );
131
134
  }
@@ -133,6 +136,10 @@ export class AssistantMessageEventStream extends EventStream<AssistantMessageEve
133
136
  override push(event: AssistantMessageEvent): void {
134
137
  if (this.done) return;
135
138
 
139
+ if (event.type === "error" && event.error.stopReason === "error") {
140
+ AIError.classifyMessage(event.error);
141
+ }
142
+
136
143
  // Completion resolves the final result and still emits the terminal event.
137
144
  if (this.isComplete(event)) {
138
145
  this.done = true;
@@ -146,13 +153,18 @@ export class AssistantMessageEventStream extends EventStream<AssistantMessageEve
146
153
  override end(result?: AssistantMessage): void {
147
154
  this.done = true;
148
155
  if (result !== undefined) {
156
+ if (result.stopReason === "error") {
157
+ AIError.classifyMessage(result);
158
+ }
149
159
  this.resultSettled = true;
150
160
  this.resolveFinalResult(result);
151
161
  } else if (!this.resultSettled) {
152
162
  // Mirror the base class: a result-less end() must not leave
153
163
  // result() pending forever.
154
164
  this.resultSettled = true;
155
- this.rejectFinalResult(new Error("Stream ended without a final result"));
165
+ this.rejectFinalResult(
166
+ new AIError.ProviderResponseError("Stream ended without a final result", { kind: "envelope" }),
167
+ );
156
168
  }
157
169
  this.endWaiting();
158
170
  }
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
- import { extractHttpStatusFromError, getLogsDir, isBunTestRuntime } from "@oh-my-pi/pi-utils";
2
+ import { getLogsDir, isBunTestRuntime } from "@oh-my-pi/pi-utils";
3
+ import * as AIError from "../error/flags";
3
4
  import { isCopilotTransientModelError } from "./retry.js";
4
5
  import { formatErrorMessageWithRetryAfter } from "./retry-after.js";
5
6
 
@@ -28,8 +29,7 @@ export async function appendRawHttpRequestDumpFor400(
28
29
  dump: RawHttpRequestDump | undefined,
29
30
  ): Promise<string> {
30
31
  // Never persist dumps under the test runner: providers exercise the 400 path
31
- // with mocked fetch responses, which would otherwise litter the real ~/.omp logs.
32
- if (!dump || isBunTestRuntime() || extractHttpStatusFromError(error) !== 400) {
32
+ if (!dump || isBunTestRuntime() || AIError.status(error) !== 400) {
33
33
  return message;
34
34
  }
35
35
 
@@ -77,7 +77,7 @@ export async function finalizeErrorMessage(
77
77
  */
78
78
  export function rewriteCopilotError(errorMessage: string, error: unknown, provider: string): string {
79
79
  if (provider !== "github-copilot") return errorMessage;
80
- const status = extractHttpStatusFromError(error);
80
+ const status = AIError.status(error);
81
81
  if (status === 401) {
82
82
  return `GitHub Copilot authentication failed (HTTP 401). Your token may have been revoked. Please re-login with /login github-copilot`;
83
83
  }
@@ -1,4 +1,5 @@
1
1
  import { $env } from "@oh-my-pi/pi-utils";
2
+ import * as AIError from "../error";
2
3
 
3
4
  const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;
4
5
  const DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS = 100_000;
@@ -292,7 +293,7 @@ export async function* iterateWithIdleTimeout<T>(
292
293
  if (activeTimeoutMs <= 0) {
293
294
  options.onFirstItemTimeout?.();
294
295
  closeIterator();
295
- throw new Error(options.firstItemErrorMessage ?? options.errorMessage);
296
+ throw new AIError.StreamTimeoutError(options.firstItemErrorMessage ?? options.errorMessage);
296
297
  }
297
298
  }
298
299
  } else if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) {
@@ -300,7 +301,7 @@ export async function* iterateWithIdleTimeout<T>(
300
301
  if (activeTimeoutMs <= 0) {
301
302
  options.onIdle?.();
302
303
  closeIterator();
303
- throw new Error(options.errorMessage);
304
+ throw new AIError.StreamTimeoutError(options.errorMessage);
304
305
  }
305
306
  }
306
307
 
@@ -343,7 +344,7 @@ export async function* iterateWithIdleTimeout<T>(
343
344
  options.onFirstItemTimeout?.();
344
345
  }
345
346
  closeIterator();
346
- throw new Error(
347
+ throw new AIError.StreamTimeoutError(
347
348
  !awaitingFirstItem ? options.errorMessage : (options.firstItemErrorMessage ?? options.errorMessage),
348
349
  );
349
350
  }
@@ -467,6 +468,6 @@ export async function* iterateWithTerminalGrace<T>(
467
468
  function abortReason(signal: AbortSignal): Error {
468
469
  const reason = signal.reason;
469
470
  if (reason instanceof Error) return reason;
470
- if (typeof reason === "string") return new Error(reason);
471
- return new Error("Request was aborted");
471
+ if (typeof reason === "string") return new AIError.AbortError(reason);
472
+ return new AIError.AbortError();
472
473
  }