@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.
- package/CHANGELOG.md +27 -0
- package/dist/types/auth-gateway/server.d.ts +0 -19
- package/dist/types/auth-retry.d.ts +2 -10
- package/dist/types/auth-storage.d.ts +1 -2
- package/dist/types/dialect/demotion.d.ts +22 -0
- package/dist/types/dialect/index.d.ts +2 -0
- package/dist/types/error/abort.d.ts +14 -0
- package/dist/types/error/auth-classify.d.ts +16 -0
- package/dist/types/error/auth.d.ts +27 -0
- package/dist/types/error/aws.d.ts +23 -0
- package/dist/types/error/classes.d.ts +102 -0
- package/dist/types/error/finalize.d.ts +39 -0
- package/dist/types/error/flags.d.ts +70 -0
- package/dist/types/error/format.d.ts +20 -0
- package/dist/types/error/gateway.d.ts +20 -0
- package/dist/types/error/index.d.ts +13 -0
- package/dist/types/error/oauth.d.ts +43 -0
- package/dist/types/error/provider.d.ts +42 -0
- package/dist/types/{rate-limit-utils.d.ts → error/rate-limit.d.ts} +14 -1
- package/dist/types/error/retryable.d.ts +27 -0
- package/dist/types/error/validation.d.ts +32 -0
- package/dist/types/index.d.ts +1 -3
- package/dist/types/providers/amazon-bedrock.d.ts +0 -5
- package/dist/types/providers/anthropic-client.d.ts +2 -16
- package/dist/types/providers/anthropic.d.ts +6 -1
- package/dist/types/providers/aws-eventstream.d.ts +2 -1
- package/dist/types/providers/cursor.d.ts +8 -7
- package/dist/types/providers/google-gemini-cli.d.ts +0 -5
- package/dist/types/providers/google-shared.d.ts +0 -5
- package/dist/types/providers/ollama.d.ts +0 -5
- package/dist/types/providers/openai-codex/request-transformer.d.ts +2 -2
- package/dist/types/providers/openai-codex/response-handler.d.ts +1 -1
- package/dist/types/providers/openai-codex-responses.d.ts +2 -2
- package/dist/types/providers/openai-responses-server-schema.d.ts +6 -0
- package/dist/types/providers/openai-responses-wire.d.ts +1 -1
- package/dist/types/providers/openai-responses.d.ts +1 -0
- package/dist/types/providers/openai-shared.d.ts +8 -6
- package/dist/types/providers/pi-native-client.d.ts +0 -9
- package/dist/types/registry/oauth/xai-oauth.d.ts +0 -9
- package/dist/types/types.d.ts +6 -0
- package/dist/types/utils/block-symbols.d.ts +20 -0
- package/dist/types/utils/openai-http.d.ts +4 -8
- package/dist/types/utils/retry.d.ts +2 -14
- package/dist/types/utils/thinking-loop.d.ts +3 -1
- package/package.json +8 -4
- package/src/api-registry.ts +3 -1
- package/src/auth-broker/discover.ts +7 -2
- package/src/auth-broker/remote-store.ts +8 -7
- package/src/auth-gateway/server.ts +27 -115
- package/src/auth-retry.ts +9 -21
- package/src/auth-storage.ts +34 -49
- package/src/dialect/demotion.ts +31 -0
- package/src/dialect/factory.ts +0 -2
- package/src/dialect/index.ts +2 -0
- package/src/dialect/owned-stream.ts +0 -1
- package/src/dialect/thinking.ts +22 -10
- package/src/error/abort.ts +18 -0
- package/src/error/auth-classify.ts +30 -0
- package/src/error/auth.ts +48 -0
- package/src/error/aws.ts +31 -0
- package/src/error/classes.ts +186 -0
- package/src/error/finalize.ts +69 -0
- package/src/error/flags.ts +486 -0
- package/src/error/format.ts +36 -0
- package/src/error/gateway.ts +96 -0
- package/src/error/index.ts +13 -0
- package/src/error/oauth.ts +58 -0
- package/src/error/provider.ts +56 -0
- package/src/{rate-limit-utils.ts → error/rate-limit.ts} +9 -3
- package/src/error/retryable.ts +70 -0
- package/src/error/validation.ts +44 -0
- package/src/index.ts +1 -3
- package/src/providers/amazon-bedrock.ts +61 -57
- package/src/providers/anthropic-client.ts +13 -41
- package/src/providers/anthropic-messages-server.ts +9 -2
- package/src/providers/anthropic.ts +84 -147
- package/src/providers/aws-credentials.ts +41 -11
- package/src/providers/aws-eventstream.ts +12 -21
- package/src/providers/azure-openai-responses.ts +27 -17
- package/src/providers/cursor.ts +59 -53
- package/src/providers/devin.ts +28 -20
- package/src/providers/gitlab-duo-workflow.ts +30 -10
- package/src/providers/gitlab-duo.ts +22 -6
- package/src/providers/google-auth.ts +15 -5
- package/src/providers/google-gemini-cli.ts +46 -60
- package/src/providers/google-shared.ts +40 -38
- package/src/providers/google-vertex.ts +3 -2
- package/src/providers/google.ts +5 -1
- package/src/providers/mock.ts +10 -7
- package/src/providers/ollama.ts +34 -29
- package/src/providers/openai-chat-server.ts +2 -1
- package/src/providers/openai-codex/request-transformer.ts +13 -12
- package/src/providers/openai-codex/response-handler.ts +1 -1
- package/src/providers/openai-codex-responses.ts +931 -1012
- package/src/providers/openai-completions.ts +46 -36
- package/src/providers/openai-responses-server-schema.ts +3 -0
- package/src/providers/openai-responses-server.ts +82 -30
- package/src/providers/openai-responses-wire.ts +1 -1
- package/src/providers/openai-responses.ts +46 -22
- package/src/providers/openai-shared.ts +118 -51
- package/src/providers/pi-native-client.ts +12 -18
- package/src/providers/pi-native-server.ts +9 -6
- package/src/providers/register-builtins.ts +5 -2
- package/src/providers/transform-messages.ts +31 -63
- package/src/registry/alibaba-coding-plan.ts +6 -5
- package/src/registry/api-key-login.ts +4 -3
- package/src/registry/api-key-validation.ts +4 -3
- package/src/registry/cloudflare-ai-gateway.ts +4 -3
- package/src/registry/coreweave.ts +2 -1
- package/src/registry/deepseek.ts +2 -1
- package/src/registry/google-antigravity.ts +2 -1
- package/src/registry/google-gemini-cli.ts +2 -1
- package/src/registry/kagi.ts +4 -3
- package/src/registry/kilo.ts +32 -10
- package/src/registry/litellm.ts +4 -3
- package/src/registry/llama-cpp.ts +3 -2
- package/src/registry/lm-studio.ts +3 -2
- package/src/registry/nvidia.ts +7 -7
- package/src/registry/oauth/anthropic.ts +23 -6
- package/src/registry/oauth/callback-server.ts +5 -3
- package/src/registry/oauth/cursor.ts +18 -4
- package/src/registry/oauth/devin.ts +14 -3
- package/src/registry/oauth/github-copilot.ts +21 -10
- package/src/registry/oauth/gitlab-duo-workflow.ts +15 -3
- package/src/registry/oauth/gitlab-duo.ts +30 -6
- package/src/registry/oauth/google-antigravity.ts +14 -5
- package/src/registry/oauth/google-gemini-cli.ts +24 -7
- package/src/registry/oauth/google-oauth-shared.ts +6 -3
- package/src/registry/oauth/index.ts +19 -8
- package/src/registry/oauth/kimi.ts +40 -10
- package/src/registry/oauth/openai-codex.ts +26 -11
- package/src/registry/oauth/opencode.ts +4 -3
- package/src/registry/oauth/perplexity.ts +33 -11
- package/src/registry/oauth/xai-oauth.ts +62 -18
- package/src/registry/oauth/xiaomi.ts +21 -9
- package/src/registry/ollama-cloud.ts +5 -4
- package/src/registry/ollama.ts +3 -2
- package/src/registry/parallel.ts +4 -3
- package/src/registry/qwen-portal.ts +4 -3
- package/src/registry/tavily.ts +4 -3
- package/src/registry/vercel-ai-gateway.ts +4 -3
- package/src/registry/vllm.ts +3 -2
- package/src/stream.ts +79 -24
- package/src/types.ts +6 -0
- package/src/usage/claude.ts +2 -1
- package/src/usage/github-copilot.ts +4 -3
- package/src/usage/google-antigravity.ts +2 -1
- package/src/utils/abort.ts +4 -2
- package/src/utils/block-symbols.ts +32 -0
- package/src/utils/event-stream.ts +15 -3
- package/src/utils/http-inspector.ts +4 -4
- package/src/utils/idle-iterator.ts +6 -5
- package/src/utils/openai-http.ts +11 -46
- package/src/utils/parse-bind.ts +7 -5
- package/src/utils/proxy.ts +2 -1
- package/src/utils/retry.ts +7 -23
- package/src/utils/schema/normalize.ts +3 -2
- package/src/utils/thinking-loop.ts +15 -11
- package/src/utils/validation.ts +4 -3
- package/dist/types/dialect/pi.d.ts +0 -9
- package/dist/types/errors.d.ts +0 -24
- package/dist/types/utils/overflow.d.ts +0 -55
- package/src/dialect/pi.md +0 -55
- package/src/dialect/pi.ts +0 -600
- package/src/errors.ts +0 -32
- 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 {
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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> => {
|
package/src/auth-storage.ts
CHANGED
|
@@ -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 {
|
|
13
|
+
import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
|
|
14
14
|
import type { ApiKeyResolver } from "./auth-retry";
|
|
15
|
-
import
|
|
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
|
-
|
|
563
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
() =>
|
|
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
|
|
3469
|
+
cancellation.reject(new AIError.AbortError("OAuth token refresh aborted by caller"));
|
|
3487
3470
|
} else {
|
|
3488
|
-
onAbort = () => cancellation.reject(new
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/dialect/factory.ts
CHANGED
|
@@ -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,
|
package/src/dialect/index.ts
CHANGED
|
@@ -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>"],
|
package/src/dialect/thinking.ts
CHANGED
|
@@ -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
|
+
}
|