@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
@@ -15,6 +15,7 @@ import {
15
15
  } from "@oh-my-pi/pi-utils";
16
16
  import { YAML } from "bun";
17
17
  import { AuthStorage } from "../auth-storage";
18
+ import * as AIError from "../error";
18
19
  import { AuthBrokerClient } from "./client";
19
20
  import { RemoteAuthCredentialStore } from "./remote-store";
20
21
  import { readAuthBrokerSnapshotCache, writeAuthBrokerSnapshotCache } from "./snapshot-cache";
@@ -137,7 +138,8 @@ export async function resolveAuthBrokerConfig(
137
138
  const token =
138
139
  (envToken && envToken.length > 0 ? envToken : undefined) ?? configToken ?? (await readTokenFile()) ?? undefined;
139
140
  if (!token) {
140
- throw new Error(
141
+ throw new AIError.MissingApiKeyError(
142
+ undefined,
141
143
  `OMP_AUTH_BROKER_URL is set (${url}) but no bearer token is available. ` +
142
144
  `Set OMP_AUTH_BROKER_TOKEN, the \`auth.broker.token\` config entry, or place one at ${getAuthBrokerTokenFilePath()}.`,
143
145
  );
@@ -190,7 +192,10 @@ export async function discoverAuthStorage(options: DiscoverAuthStorageOptions =
190
192
  }
191
193
  if (!initialSnapshot) {
192
194
  const initialResult = await client.fetchSnapshot();
193
- if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot");
195
+ if (initialResult.status !== 200)
196
+ throw new AIError.AuthBrokerError("Auth broker returned no initial snapshot", {
197
+ status: initialResult.status,
198
+ });
194
199
  initialSnapshot = initialResult.snapshot;
195
200
  persist?.(initialSnapshot);
196
201
  }
@@ -17,6 +17,7 @@ import {
17
17
  REMOTE_REFRESH_SENTINEL,
18
18
  type StoredAuthCredential,
19
19
  } from "../auth-storage";
20
+ import * as AIError from "../error";
20
21
  import type { OAuthCredentials } from "../registry/oauth/types";
21
22
  import type { Provider } from "../types";
22
23
  import type { UsageReport } from "../usage";
@@ -313,26 +314,26 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
313
314
  async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
314
315
  const { entry } = await this.#client.refreshCredential(credentialId, opts.signal);
315
316
  if (entry.credential.type !== "oauth") {
316
- throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
317
+ throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
317
318
  }
318
319
  this.#applyCredentialEntry(entry);
319
320
  this.#maybeRefreshSnapshot("suspect credential refresh");
320
321
  }
321
322
 
322
323
  replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[] {
323
- throw new Error(
324
+ throw new AIError.AuthBrokerError(
324
325
  "RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker login <provider>` to mutate credentials.",
325
326
  );
326
327
  }
327
328
 
328
329
  upsertAuthCredentialForProvider(_provider: string, _credential: AuthCredential): StoredAuthCredential[] {
329
- throw new Error(
330
+ throw new AIError.AuthBrokerError(
330
331
  "RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker login <provider>` to mutate credentials.",
331
332
  );
332
333
  }
333
334
 
334
335
  deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void {
335
- throw new Error(
336
+ throw new AIError.AuthBrokerError(
336
337
  "RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker logout <provider>` to mutate credentials.",
337
338
  );
338
339
  }
@@ -487,7 +488,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
487
488
  });
488
489
  }
489
490
  if (entry.credential.type !== "oauth") {
490
- throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
491
+ throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
491
492
  }
492
493
  const refreshed = entry.credential;
493
494
  return {
@@ -538,11 +539,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
538
539
  */
539
540
  #raceWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
540
541
  if (!signal) return promise;
541
- if (signal.aborted) return Promise.reject(new Error("auth-broker request aborted"));
542
+ if (signal.aborted) return Promise.reject(new AIError.AbortError("auth-broker request aborted"));
542
543
  return new Promise<T>((resolve, reject) => {
543
544
  const onAbort = (): void => {
544
545
  signal.removeEventListener("abort", onAbort);
545
- reject(new Error("auth-broker request aborted"));
546
+ reject(new AIError.AbortError("auth-broker request aborted"));
546
547
  };
547
548
  signal.addEventListener("abort", onAbort, { once: true });
548
549
  promise.then(
@@ -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 = "";