@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
@@ -22,12 +22,13 @@ import { Effort } from "@oh-my-pi/pi-catalog/effort";
22
22
  import { extractHttpStatusFromError, extractRetryHint, logger } from "@oh-my-pi/pi-utils";
23
23
  import type { ApiKeyResolver } from "../auth-retry";
24
24
  import type { AuthStorage } from "../auth-storage";
25
+ import { classifyGatewayError } from "../error/gateway";
26
+ import { isUsageLimitOutcome } from "../error/rate-limit";
25
27
  import * as anthropicMessages from "../providers/anthropic-messages-server";
26
28
  import * as openaiChat from "../providers/openai-chat-server";
27
29
  import * as openaiResponses from "../providers/openai-responses-server";
28
30
  import * as piNative from "../providers/pi-native-server";
29
- import { isUsageLimitError, isUsageLimitOutcome } from "../rate-limit-utils";
30
- import { streamSimple } from "../stream";
31
+ import { completeSimple, streamSimple } from "../stream";
31
32
  import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "../types";
32
33
  import { deterministicUuid } from "../utils/deterministic-id";
33
34
  import { parseBind } from "../utils/parse-bind";
@@ -192,95 +193,6 @@ function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: Abort
192
193
  return opts;
193
194
  }
194
195
 
195
- /**
196
- * Classify an upstream / gateway-internal error into a status code and a
197
- * format-neutral type. The order is intentional:
198
- *
199
- * 1. Honour an explicit numeric `status` property on the thrown error.
200
- * 2. Parse a status code embedded in the message string. Provider errors
201
- * virtually always carry one (`Google API error (400): …`, `HTTP 429`,
202
- * `status=503`) and the embedded value is authoritative.
203
- * 3. Fall through to **word-boundaried** substring heuristics. The old
204
- * `lower.includes("rate")` test famously matched
205
- * `GenerateContentRequest`, surfacing every Google 400 as a 429
206
- * `rate_limit_error`. The patterns here all require boundaries so they
207
- * don't collide with provider field names.
208
- */
209
- export function classifyGatewayError(err: unknown): { status: number; type: string; message: string } {
210
- const message = err instanceof Error ? err.message : String(err);
211
-
212
- // 1. Custom pi-ai errors may attach a numeric `status` property.
213
- const statusProp =
214
- typeof err === "object" && err !== null && typeof (err as { status?: unknown }).status === "number"
215
- ? (err as { status: number }).status | 0
216
- : undefined;
217
- if (statusProp !== undefined) return bucketStatus(statusProp, message);
218
-
219
- if (err instanceof Error && err.name === "AbortError") return { status: 499, type: "request_aborted", message };
220
-
221
- // 2. Status code embedded in the message. Requires a contextual keyword
222
- // (`HTTP`, `API error`, `status`, …) or a leading `(NNN)` token so we
223
- // don't trip on incidental three-digit numbers ("took 200ms").
224
- const embedded = extractEmbeddedStatus(message);
225
- if (embedded !== undefined) return bucketStatus(embedded, message);
226
-
227
- // 3. Word-boundaried substring heuristics.
228
- if (/\baborted\b|\babort signal\b/i.test(message)) {
229
- return { status: 499, type: "request_aborted", message };
230
- }
231
- if (
232
- // Match rate-limit phrasings before auth wording: some providers
233
- // describe throttling as "unauthorized due to rate limit".
234
- // Keep boundaries so this does not collide with
235
- // `GenerateContentRequest`, `accelerate`, `iterate`, `deprecated`, etc.
236
- /\brate[- _]?limit(?:s|ed|ing)?\b|\bquota(?:_exceeded| exceeded)?\b|\btoo[- _]many[- _]requests\b/i.test(
237
- message,
238
- ) ||
239
- // Usage-limit phrasings emit no embedded status. Codex friendly text
240
- // reads "You have hit your ChatGPT usage limit … Try again in ~158
241
- // min."; pi-ai's central `isUsageLimitError` already encodes every
242
- // known provider variant, so reuse it instead of forking the regex.
243
- // Without this branch the classifier falls through to the default
244
- // 502/upstream_error, which is what callers were seeing when their
245
- // account hit its cap.
246
- isUsageLimitError(message)
247
- ) {
248
- return { status: 429, type: "rate_limit_error", message };
249
- }
250
- if (/\b(?:unauthorized|forbidden)\b/i.test(message)) {
251
- return { status: 401, type: "authentication_error", message };
252
- }
253
- if (/\b(?:unsupported|invalid_request|invalid request|bad request|malformed)\b/i.test(message)) {
254
- return { status: 400, type: "invalid_request_error", message };
255
- }
256
- return { status: 502, type: "upstream_error", message };
257
- }
258
-
259
- function bucketStatus(status: number, message: string): { status: number; type: string; message: string } {
260
- if (status === 401 || status === 403) return { status, type: "authentication_error", message };
261
- if (status === 429) return { status, type: "rate_limit_error", message };
262
- if (status >= 400 && status < 500) return { status, type: "invalid_request_error", message };
263
- if (status >= 500) return { status, type: "upstream_error", message };
264
- return { status: 502, type: "upstream_error", message };
265
- }
266
-
267
- /**
268
- * Pull a status code from common error-message shapes. Returns undefined when
269
- * no contextual keyword is present, so we never guess at incidental numbers.
270
- */
271
- function extractEmbeddedStatus(message: string): number | undefined {
272
- // `Google API error (400)`, `OpenAI API error (429): …`, `(503)`
273
- // `HTTP 429: too many requests`
274
- // `status: 503`, `status_code=429`, `status=400`
275
- const re = /(?:\bHTTP\b|\bAPI error\b|\bstatus(?:[- _]?code)?\b)\s*[:=]?\s*\(?\s*(\d{3})\b|\((\d{3})\)/i;
276
- const m = message.match(re);
277
- if (!m) return undefined;
278
- const raw = m[1] ?? m[2];
279
- if (!raw) return undefined;
280
- const code = Number.parseInt(raw, 10);
281
- return Number.isFinite(code) && code >= 100 && code < 600 ? code : undefined;
282
- }
283
-
284
196
  /**
285
197
  * Hook fired by {@link streamSimple} when the upstream request fails in a
286
198
  * way that's rotatable — today that's HTTP 401 (credential is bad) and
@@ -525,20 +437,10 @@ async function handleFormatEndpoint(
525
437
  peer,
526
438
  });
527
439
 
528
- let events: AssistantMessageEventStream;
529
- try {
530
- if (controller.signal.aborted) return clientClosedResponse(route);
531
- events = streamSimple(model, parsed.context, streamOpts);
532
- } catch (error) {
533
- const classified = classifyGatewayError(error);
534
- logger.warn("auth-gateway streamSimple threw", { format: route.label, error: classified.message, peer });
535
- return route.module.formatError(classified.status, classified.type, classified.message);
536
- }
537
-
538
440
  if (!parsed.stream) {
539
441
  try {
540
442
  if (controller.signal.aborted) return clientClosedResponse(route);
541
- const message = await events.result();
443
+ const message = await completeSimple(model, parsed.context, streamOpts);
542
444
  if (message.stopReason === "aborted" || message.stopReason === "error") {
543
445
  const errorMessage =
544
446
  message.errorMessage ??
@@ -552,7 +454,7 @@ async function handleFormatEndpoint(
552
454
  if (message.stopReason === "aborted") {
553
455
  return route.module.formatError(499, "request_aborted", errorMessage);
554
456
  }
555
- const classified = classifyGatewayError(new Error(errorMessage));
457
+ const classified = classifyGatewayError(errorMessage);
556
458
  return route.module.formatError(classified.status, classified.type, errorMessage);
557
459
  }
558
460
  return json(200, route.module.encodeResponse(message, parsed.modelId));
@@ -567,6 +469,16 @@ async function handleFormatEndpoint(
567
469
  return route.module.formatError(classified.status, classified.type, classified.message);
568
470
  }
569
471
  }
472
+
473
+ let events: AssistantMessageEventStream;
474
+ try {
475
+ if (controller.signal.aborted) return clientClosedResponse(route);
476
+ events = streamSimple(model, parsed.context, streamOpts);
477
+ } catch (error) {
478
+ const classified = classifyGatewayError(error);
479
+ logger.warn("auth-gateway streamSimple threw", { format: route.label, error: classified.message, peer });
480
+ return route.module.formatError(classified.status, classified.type, classified.message);
481
+ }
570
482
  if (controller.signal.aborted) return clientClosedResponse(route);
571
483
 
572
484
  const sseStream = route.module.encodeStream(events, parsed.modelId, parsed.options, {
@@ -700,20 +612,10 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
700
612
  peer,
701
613
  });
702
614
 
703
- let events: AssistantMessageEventStream;
704
- try {
705
- if (controller.signal.aborted) return aborted();
706
- events = streamSimple(model, parsed.context, streamOpts);
707
- } catch (error) {
708
- const classified = classifyGatewayError(error);
709
- logger.warn("auth-gateway streamSimple threw", { format: "pi-native", error: classified.message, peer });
710
- return piNative.formatError(classified.status, classified.type, classified.message);
711
- }
712
-
713
615
  if (!parsed.stream) {
714
616
  try {
715
617
  if (controller.signal.aborted) return aborted();
716
- const message = await events.result();
618
+ const message = await completeSimple(model, parsed.context, streamOpts);
717
619
  if (message.stopReason === "aborted" || message.stopReason === "error") {
718
620
  const errorMessage =
719
621
  message.errorMessage ??
@@ -727,7 +629,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
727
629
  if (message.stopReason === "aborted") {
728
630
  return piNative.formatError(499, "request_aborted", errorMessage);
729
631
  }
730
- const classified = classifyGatewayError(new Error(errorMessage));
632
+ const classified = classifyGatewayError(errorMessage);
731
633
  return piNative.formatError(classified.status, classified.type, errorMessage);
732
634
  }
733
635
  return json(200, { message });
@@ -738,6 +640,16 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
738
640
  return piNative.formatError(classified.status, classified.type, classified.message);
739
641
  }
740
642
  }
643
+
644
+ let events: AssistantMessageEventStream;
645
+ try {
646
+ if (controller.signal.aborted) return aborted();
647
+ events = streamSimple(model, parsed.context, streamOpts);
648
+ } catch (error) {
649
+ const classified = classifyGatewayError(error);
650
+ logger.warn("auth-gateway streamSimple threw", { format: "pi-native", error: classified.message, peer });
651
+ return piNative.formatError(classified.status, classified.type, classified.message);
652
+ }
741
653
  if (controller.signal.aborted) return aborted();
742
654
 
743
655
  const sseStream = piNative.encodeStream(events, parsed.modelId, parsed.options, {
package/src/auth-retry.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
2
1
  import type { OAuthAccess } from "./auth-storage";
3
- import { isUsageLimitOutcome } from "./rate-limit-utils";
2
+ import * as AIError from "./error";
3
+ import { isAuthRetryableError } from "./error/auth-classify";
4
4
 
5
5
  /**
6
6
  * Context passed to an {@link ApiKeyResolver} on each resolution attempt.
@@ -70,23 +70,8 @@ export function seedApiKeyResolver(seed: string | undefined, resolver: ApiKeyRes
70
70
  };
71
71
  }
72
72
 
73
- /**
74
- * Classifies whether an error should trigger a credential refresh/rotation
75
- * retry: a hard `401`, body-classified usage limit (Codex
76
- * `usage_limit_reached`, Anthropic account rate-limit, Google
77
- * `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
78
- * whose payload did not preserve a richer quota code. Transient 429s
79
- * (`Too many requests`, per-minute caps) classify as `RATE_LIMIT_EXCEEDED`
80
- * via {@link parseRateLimitReason} and stay in the upstream-backoff lane.
81
- */
82
- export function isAuthRetryableError(error: unknown): boolean {
83
- const status = extractHttpStatusFromError(error);
84
- if (status === 401) return true;
85
- const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
86
- const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined;
87
- if (embeddedStatus === 401) return true;
88
- return isUsageLimitOutcome(status ?? embeddedStatus, message);
89
- }
73
+ // Re-exported from the error module (its new home); see error/auth-classify.ts.
74
+ export { isAuthRetryableError };
90
75
 
91
76
  /**
92
77
  * The ordered `lastChance` values for the retry steps after the initial
@@ -130,7 +115,7 @@ export async function withAuth<T>(
130
115
  opts?: { isAuthError?: (error: unknown) => boolean; signal?: AbortSignal; missingKeyMessage?: string },
131
116
  ): Promise<T> {
132
117
  const isAuthError = opts?.isAuthError ?? isAuthRetryableError;
133
- const missingKey = (): Error => new Error(opts?.missingKeyMessage ?? "No API key available");
118
+ const missingKey = (): Error => new AIError.MissingApiKeyError(undefined, opts?.missingKeyMessage);
134
119
 
135
120
  if (!isApiKeyResolver(key)) {
136
121
  if (key === undefined) throw missingKey();
@@ -225,7 +210,10 @@ export async function withOAuthAccess<T>(
225
210
 
226
211
  let lastAccess = opts?.seed ?? (await storage.getOAuthAccess(provider, sessionId, { signal }));
227
212
  if (!lastAccess) {
228
- throw new Error(opts?.missingAccessMessage ?? `No OAuth credential available for provider: ${provider}`);
213
+ throw new AIError.MissingApiKeyError(
214
+ provider,
215
+ opts?.missingAccessMessage ?? `No OAuth credential available for provider: ${provider}`,
216
+ );
229
217
  }
230
218
 
231
219
  const resolveStep = async (lastChance: boolean, error: unknown): Promise<OAuthAccess | undefined> => {
@@ -10,9 +10,10 @@
10
10
  import { Database, type Statement } from "bun:sqlite";
11
11
  import * as fs from "node:fs/promises";
12
12
  import * as path from "node:path";
13
- import { extractHttpStatusFromError, getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
13
+ import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
14
14
  import type { ApiKeyResolver } from "./auth-retry";
15
- import { isUsageLimitOutcome } from "./rate-limit-utils";
15
+ import * as AIError from "./error";
16
+ import { isUsageLimitOutcome } from "./error/rate-limit";
16
17
  import { getProviderDefinition, PASTE_CODE_LOGIN_PROVIDERS } from "./registry";
17
18
  import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./registry/oauth";
18
19
  import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./registry/oauth/types";
@@ -558,36 +559,9 @@ const OAUTH_REFRESH_SKEW_MS = 60_000;
558
559
  */
559
560
  const MAX_PENDING_DISABLED_EVENTS = 32;
560
561
 
561
- /**
562
- * Classify an OAuth refresh error as a definitive credential failure (the
563
- * refresh token is dead — re-login required) versus a transient blip
564
- * (network/5xx — retry next sweep).
565
- *
566
- * Anchored at module scope so all three refresh sites — in-stream
567
- * {@link AuthStorage.getApiKey}, the usage probe in
568
- * {@link AuthStorage.fetchUsageReports}, and the auth-broker background
569
- * refresher — disable rows on the same criteria. A drifting classifier
570
- * between sites would let stale last-good usage reports surface indefinitely
571
- * while streaming requests correctly tear the row down.
572
- */
573
- const OAUTH_DEFINITIVE_FAILURE_REGEX =
574
- /invalid_grant|invalid_token|unauthorized_client|\brevoked\b|refresh[\s_]?token.*expired/i;
575
- // Transient: network blips, rate limits, gateway/5xx, and infra denials
576
- // (WAF / egress 403, permission / account-verification) — block-and-retry,
577
- // never tear the credential down for these.
578
- const OAUTH_TRANSIENT_FAILURE_REGEX =
579
- /timeout|network|fetch failed|ECONN(?:REFUSED|RESET)|ETIMEDOUT|EAI_AGAIN|socket hang up|\b(?:408|425|429|5\d{2})\b|rate.?limit|too many requests|temporar|unavailable|forbidden|permission_denied|cloudflare|captcha/i;
580
- // A bare 401 from an OAuth token endpoint means the stored grant/client is
581
- // dead. 403 is deliberately excluded: it is overwhelmingly WAF / egress
582
- // rate-limit / permission / account-verification — none of which mean the
583
- // refresh token itself is invalid.
584
- const OAUTH_HTTP_AUTH_REGEX = /\b401\b/;
585
-
586
- export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
587
- if (OAUTH_DEFINITIVE_FAILURE_REGEX.test(errorMsg)) return true;
588
- if (OAUTH_HTTP_AUTH_REGEX.test(errorMsg) && !OAUTH_TRANSIENT_FAILURE_REGEX.test(errorMsg)) return true;
589
- return false;
590
- }
562
+ // Re-exported from the error module (its new home) to preserve the public
563
+ // `@oh-my-pi/pi-ai` entrypoint and the in-module call sites below.
564
+ export { isDefinitiveOAuthFailure } from "./error/auth-classify";
591
565
 
592
566
  /**
593
567
  * Outcome of {@link AuthStorage.markUsageLimitReached}.
@@ -811,11 +785,11 @@ function parseUsageCacheEntry<T>(raw: string): UsageCacheEntry<T> | undefined {
811
785
  */
812
786
  function raceUsageWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
813
787
  if (!signal) return promise;
814
- if (signal.aborted) return Promise.reject(new Error("usage fetch aborted"));
788
+ if (signal.aborted) return Promise.reject(new AIError.AbortError("usage fetch aborted"));
815
789
  return new Promise<T>((resolve, reject) => {
816
790
  const onAbort = (): void => {
817
791
  signal.removeEventListener("abort", onAbort);
818
- reject(new Error("usage fetch aborted"));
792
+ reject(new AIError.AbortError("usage fetch aborted"));
819
793
  };
820
794
  signal.addEventListener("abort", onAbort, { once: true });
821
795
  promise.then(
@@ -837,9 +811,9 @@ function raceCredentialRefreshWithSignal<T>(
837
811
  message = "credential refresh aborted",
838
812
  ): Promise<T> {
839
813
  if (!signal) return promise;
840
- if (signal.aborted) return Promise.reject(new Error(message));
814
+ if (signal.aborted) return Promise.reject(new AIError.AbortError(message));
841
815
  const abort = Promise.withResolvers<never>();
842
- const onAbort = (): void => abort.reject(new Error(message));
816
+ const onAbort = (): void => abort.reject(new AIError.AbortError(message));
843
817
  signal.addEventListener("abort", onAbort, { once: true });
844
818
  return Promise.race([promise, abort.promise]).finally(() => {
845
819
  signal.removeEventListener("abort", onAbort);
@@ -1901,7 +1875,7 @@ export class AuthStorage {
1901
1875
  // Built-in registry first, then runtime-registered extension providers.
1902
1876
  const def = getProviderDefinition(provider) ?? getOAuthProvider(provider);
1903
1877
  if (!def?.login) {
1904
- throw new Error(`Unknown OAuth provider: ${provider}`);
1878
+ throw new AIError.ConfigurationError(`Unknown OAuth provider: ${provider}`);
1905
1879
  }
1906
1880
  const result = await def.login({
1907
1881
  onAuth: ctrl.onAuth,
@@ -2177,7 +2151,7 @@ export class AuthStorage {
2177
2151
  // (including its already-elapsed `resetsAt`). CAS-disable the row and
2178
2152
  // clear the cache so the credential drops out of the report instead of
2179
2153
  // freezing in place until the user notices and re-logs in.
2180
- if (isDefinitiveOAuthFailure(errorMsg)) {
2154
+ if (AIError.isDefinitiveOAuthFailure(errorMsg)) {
2181
2155
  const credentialId = this.#findStoredCredentialIdForUsageCredential(
2182
2156
  request.provider,
2183
2157
  request.credential,
@@ -3464,7 +3438,10 @@ export class AuthStorage {
3464
3438
  const customProvider = getOAuthProvider(provider);
3465
3439
  if (customProvider) {
3466
3440
  if (!customProvider.refreshToken) {
3467
- throw new Error(`OAuth provider "${provider}" does not support token refresh`);
3441
+ throw new AIError.OAuthError(`OAuth provider "${provider}" does not support token refresh`, {
3442
+ kind: "configuration",
3443
+ provider,
3444
+ });
3468
3445
  }
3469
3446
  refreshPromise = customProvider.refreshToken(credential);
3470
3447
  } else {
@@ -3478,14 +3455,20 @@ export class AuthStorage {
3478
3455
  let onAbort: (() => void) | undefined;
3479
3456
  const cancellation = Promise.withResolvers<never>();
3480
3457
  timeout = setTimeout(
3481
- () => cancellation.reject(new Error(`OAuth token refresh timed out for provider: ${provider}`)),
3458
+ () =>
3459
+ cancellation.reject(
3460
+ new AIError.OAuthError(`OAuth token refresh timed out for provider: ${provider}`, {
3461
+ kind: "timeout",
3462
+ provider,
3463
+ }),
3464
+ ),
3482
3465
  DEFAULT_OAUTH_REFRESH_TIMEOUT_MS,
3483
3466
  );
3484
3467
  if (signal) {
3485
3468
  if (signal.aborted) {
3486
- cancellation.reject(new Error("OAuth token refresh aborted by caller"));
3469
+ cancellation.reject(new AIError.AbortError("OAuth token refresh aborted by caller"));
3487
3470
  } else {
3488
- onAbort = () => cancellation.reject(new Error("OAuth token refresh aborted by caller"));
3471
+ onAbort = () => cancellation.reject(new AIError.AbortError("OAuth token refresh aborted by caller"));
3489
3472
  signal.addEventListener("abort", onAbort, { once: true });
3490
3473
  }
3491
3474
  }
@@ -3691,7 +3674,7 @@ export class AuthStorage {
3691
3674
  const errorMsg = String(error);
3692
3675
  // Only remove credentials for definitive auth failures
3693
3676
  // Keep credentials for transient errors (network, 5xx) and block temporarily
3694
- const isDefinitiveFailure = isDefinitiveOAuthFailure(errorMsg);
3677
+ const isDefinitiveFailure = AIError.isDefinitiveOAuthFailure(errorMsg);
3695
3678
 
3696
3679
  logger.warn("OAuth token refresh failed", {
3697
3680
  provider,
@@ -4251,7 +4234,7 @@ export class AuthStorage {
4251
4234
  if (!sessionCredential) return false;
4252
4235
 
4253
4236
  const error = options?.error;
4254
- const status = extractHttpStatusFromError(error);
4237
+ const status = AIError.status(error);
4255
4238
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
4256
4239
  if (isUsageLimitOutcome(status, message)) {
4257
4240
  return (
@@ -4388,7 +4371,9 @@ export class AuthStorage {
4388
4371
  if (index === -1) continue;
4389
4372
  const target = entries[index];
4390
4373
  if (target.credential.type !== "oauth") {
4391
- throw new Error(`Credential ${id} is not OAuth (provider=${provider}, type=${target.credential.type})`);
4374
+ throw new AIError.ValidationError(
4375
+ `Credential ${id} is not OAuth (provider=${provider}, type=${target.credential.type})`,
4376
+ );
4392
4377
  }
4393
4378
  // The exact credential we are about to refresh — captured before the
4394
4379
  // await so a definitive failure can CAS-disable the row against the
@@ -4404,7 +4389,7 @@ export class AuthStorage {
4404
4389
  // A definitively-dead grant tears the row down here, where the
4405
4390
  // attempted credential is known. CAS on the persisted credential so a
4406
4391
  // peer/login rotation in flight leaves the freshly-rotated row intact.
4407
- if (isDefinitiveOAuthFailure(String(error))) {
4392
+ if (AIError.isDefinitiveOAuthFailure(String(error))) {
4408
4393
  // CAS-loss (false) means a peer/login rotated the row mid-refresh, so
4409
4394
  // our #data copy is stale — reload so the next caller serves the
4410
4395
  // freshly-rotated credential rather than the dead token we attempted.
@@ -4437,7 +4422,7 @@ export class AuthStorage {
4437
4422
  // -1 means the row was disabled/removed mid-refresh — surface that as a
4438
4423
  // miss rather than implying a live row the snapshot won't contain.
4439
4424
  if (this.#replaceCredentialById(provider, id, updated) === -1) {
4440
- throw new Error(`No credential with id=${id}`);
4425
+ throw new AIError.ValidationError(`No credential with id=${id}`);
4441
4426
  }
4442
4427
  return {
4443
4428
  id,
@@ -4446,7 +4431,7 @@ export class AuthStorage {
4446
4431
  identityKey: resolveCredentialIdentityKey(provider, updated),
4447
4432
  };
4448
4433
  }
4449
- throw new Error(`No credential with id=${id}`);
4434
+ throw new AIError.ValidationError(`No credential with id=${id}`);
4450
4435
  }
4451
4436
 
4452
4437
  /**
@@ -4869,7 +4854,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4869
4854
  }
4870
4855
  }
4871
4856
  }
4872
- throw new Error(
4857
+ throw new AIError.ConfigurationError(
4873
4858
  `Failed to open auth database at '${dbPath}' after ${maxAttempts} attempts: ${lastBusyError?.message}`,
4874
4859
  { cause: lastBusyError },
4875
4860
  );
@@ -0,0 +1,31 @@
1
+ import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
2
+ import { getDialectDefinition } from "./factory";
3
+
4
+ /**
5
+ * Wrap a prior-turn reasoning string for demotion into native conversation
6
+ * history — the cross-provider / cross-model case where the target cannot replay
7
+ * it as a structured thinking block (verified end-to-end against Gemini 3: a
8
+ * replayed unsigned `thought` part is schema-accepted but silently discarded —
9
+ * neither recalled nor influencing generation).
10
+ *
11
+ * The reasoning is rendered in the TARGET model's canonical inline thinking
12
+ * delimiters so it reads as reasoning in that model's own idiom instead of bare
13
+ * prose the model might continue. Harmony and Gemma are the exception: their
14
+ * `renderThinking` emits chat-template control tokens (`<|channel|>analysis`,
15
+ * `<|channel>thought`) that must not appear inside a structured native message,
16
+ * so they fall back to a plain `<think>` block. Every other dialect's thinking
17
+ * form is inline-safe XML tags or a markdown fence.
18
+ *
19
+ * The result ends with a trailing newline so the block stays separated from the
20
+ * turn's reply text when the wire encoder concatenates parts.
21
+ *
22
+ * Distinct from {@link DialectDefinition.renderThinking}, which targets the
23
+ * owned-dialect *text transport* where those control tokens are legal.
24
+ */
25
+ export function renderDemotedThinking(modelId: string, text: string): string {
26
+ if (!text) return "";
27
+ text = text.toWellFormed();
28
+ const dialect = preferredDialect(modelId);
29
+ if (dialect === "harmony" || dialect === "gemma") return `<think>\n${text}\n</think>\n`;
30
+ return `${getDialectDefinition(dialect).renderThinking(text)}\n`;
31
+ }
@@ -7,7 +7,6 @@ import harmonyDefinition from "./harmony";
7
7
  import hermesDefinition from "./hermes";
8
8
  import kimiDefinition from "./kimi";
9
9
  import minimaxDefinition from "./minimax";
10
- import piDefinition from "./pi";
11
10
  import qwen3Definition from "./qwen3";
12
11
  import type { Dialect, DialectDefinition, InbandScanner, InbandScannerOptions } from "./types";
13
12
  import xmlDefinition from "./xml";
@@ -21,7 +20,6 @@ const DIALECT_DEFINITIONS: Record<Dialect, DialectDefinition> = {
21
20
  deepseek: deepseekDefinition,
22
21
  minimax: minimaxDefinition,
23
22
  harmony: harmonyDefinition,
24
- pi: piDefinition,
25
23
  qwen3: qwen3Definition,
26
24
  gemini: geminiDefinition,
27
25
  gemma: gemmaDefinition,
@@ -1,5 +1,6 @@
1
1
  export * from "./catalog";
2
2
  export * from "./coercion";
3
+ export * from "./demotion";
3
4
  export * from "./examples";
4
5
  export * from "./factory";
5
6
  export * from "./history";
@@ -10,4 +11,5 @@ export * from "./owned-stream";
10
11
  // consumer needs (the legacy markdown `/dump` reuses its `<thinking>` envelope
11
12
  // unwrap), so re-export only that symbol rather than `export *`-ing the rest.
12
13
  export { renderDelimitedThinking } from "./rendering";
14
+ export * from "./thinking";
13
15
  export * from "./types";
@@ -19,7 +19,6 @@ const RESPONSE_OPEN_TOKENS: Record<Dialect, readonly string[]> = {
19
19
  minimax: ["<function_results>", "<tool_response>"],
20
20
  deepseek: ["<|tool▁outputs▁begin|>", "<|tool▁output▁begin|>"],
21
21
  harmony: ["<|start|>functions."],
22
- pi: ["‡‡"],
23
22
  qwen3: ["<tool_response>"],
24
23
  gemini: ["```tool_outputs"],
25
24
  gemma: ["<|tool_response>"],
@@ -1,18 +1,30 @@
1
1
  import { partialSuffixOverlapAny } from "./coercion";
2
2
  import type { InbandScanEvent, InbandScanner } from "./types";
3
3
 
4
- const THINK_OPEN = "<think>";
5
- const THINK_CLOSE = "</think>";
6
- const THINKING_OPEN = "<thinking>";
7
- const THINKING_CLOSE = "</thinking>";
8
- const TAGS = [
9
- { open: THINK_OPEN, close: THINK_CLOSE },
10
- { open: THINKING_OPEN, close: THINKING_CLOSE },
11
- ] as const;
12
- const OPENS = [THINK_OPEN, THINKING_OPEN] as const;
13
-
14
4
  type Tag = { readonly open: string; readonly close: string };
15
5
 
6
+ /**
7
+ * Every dialect's in-band thinking section in its canonical `renderThinking`
8
+ * form (see the sibling `./*.ts` scanners). {@link ThinkingInbandScanner} heals
9
+ * reasoning a model leaked into its visible text channel back into thinking
10
+ * events, whichever dialect idiom the leak used.
11
+ *
12
+ * Plain (attribute-free) delimiters only — matching what `renderThinking`
13
+ * emits and what models leak in practice. Attributed or namespaced XML thinking
14
+ * tags (`<thinking signature="…">`, `antml:thinking`) are recovered by the owned
15
+ * anthropic-dialect parser, not this text-channel healing fallback.
16
+ */
17
+ const TAGS: readonly Tag[] = [
18
+ { open: "<think>", close: "</think>" }, // deepseek, glm, hermes, kimi, qwen3 (and anthropic/minimax/xml)
19
+ { open: "<thinking>", close: "</thinking>" }, // anthropic, minimax, xml
20
+ { open: "<scratchpad>", close: "</scratchpad>" }, // anthropic
21
+ { open: "```thinking\n", close: "```" }, // gemini fenced thinking
22
+ { open: "<|channel>thought\n", close: "<channel|>" }, // gemma reasoning channel
23
+ { open: "<|start|>assistant<|channel|>analysis<|message|>", close: "<|end|>" }, // harmony analysis (rendered)
24
+ { open: "<|channel|>analysis<|message|>", close: "<|end|>" }, // harmony analysis (bare leak)
25
+ ];
26
+ const OPENS = TAGS.map(tag => tag.open);
27
+
16
28
  export class ThinkingInbandScanner implements InbandScanner {
17
29
  #buffer = "";
18
30
  #closeTag = "";
@@ -0,0 +1,18 @@
1
+ import { attach, create, Flag } from "./flags";
2
+
3
+ /**
4
+ * A request was cancelled — by the caller's `AbortSignal` or a provider-local
5
+ * watchdog. Carries the {@link Flag.Abort} classification structurally so retry
6
+ * logic does not have to regex the message text.
7
+ *
8
+ * The default message is kept byte-identical to the historical
9
+ * `"Request was aborted"` string so any remaining text-based matchers keep
10
+ * working through the migration.
11
+ */
12
+ export class AbortError extends Error {
13
+ constructor(message = "Request was aborted", options?: { cause?: unknown }) {
14
+ super(message, options?.cause === undefined ? undefined : { cause: options.cause });
15
+ this.name = "AbortError";
16
+ attach(this, create(Flag.Abort));
17
+ }
18
+ }
@@ -0,0 +1,30 @@
1
+ import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
2
+ import { isOAuthExpiry } from "./flags";
3
+ import { isUsageLimitOutcome } from "./rate-limit";
4
+
5
+ /**
6
+ * Whether an OAuth refresh failure is definitive (the credential must be
7
+ * disabled) versus transient. Thin alias over the {@link Flag.OAuthExpiry}
8
+ * text classifier {@link isOAuthExpiry}; retained as the public
9
+ * `@oh-my-pi/pi-ai` entrypoint name used by the coding agent and auth-broker.
10
+ */
11
+ export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
12
+ return isOAuthExpiry(errorMsg);
13
+ }
14
+
15
+ /**
16
+ * Whether an upstream failure should rotate to a sibling credential: a hard
17
+ * `401`, a body-classified usage limit (Codex `usage_limit_reached`, Anthropic
18
+ * account rate-limit, Google `resource_exhausted`, OpenAI `insufficient_quota`,
19
+ * …), or a bare `429` whose payload did not preserve a richer quota code.
20
+ * Transient 429s (`Too many requests`, per-minute caps) stay in the
21
+ * upstream-backoff lane.
22
+ */
23
+ export function isAuthRetryableError(error: unknown): boolean {
24
+ const httpStatus = extractHttpStatusFromError(error);
25
+ if (httpStatus === 401) return true;
26
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
27
+ const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined;
28
+ if (embeddedStatus === 401) return true;
29
+ return isUsageLimitOutcome(httpStatus ?? embeddedStatus, message);
30
+ }