@oh-my-pi/pi-ai 15.10.0 → 15.10.1

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 CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.10.1] - 2026-06-07
6
+
7
+ ### Breaking Changes
8
+
9
+ - Removed the `onAuthError` option from stream request options and shifted auth retry handling to resolver-based `apiKey` behavior, requiring callers using custom auth-retry hooks to migrate
10
+
11
+ ### Added
12
+
13
+ - Added `ApiKeyResolver` and `ApiKey` auth helpers, including `isApiKeyResolver`, `isAuthRetryableError`, `resolveApiKeyOnce`, and `withAuth`, and exported them from the package root
14
+ - Added support for a function-valued `apiKey` in `SimpleStreamOptions` so a single stream request can refresh or rotate credentials during retry
15
+ - Added `forceRefresh` credential option to `AuthStorage.getApiKey` and `rotateSessionCredential` support for session-level credential rotation after auth failures
16
+ - Added `AuthStorage.resolver(provider, options)` method that builds an `ApiKeyResolver` implementing the a/b/c auth-retry policy directly on the storage instance
17
+
18
+ ### Changed
19
+
20
+ - Changed gateway and stream auth flows to share the a/b/c retry policy, refreshing the same session credential first and then switching to a sibling credential on repeated auth failures
21
+
22
+ ### Fixed
23
+
24
+ - Fixed streaming auth retries to handle `401` and usage-limit errors before replay-unsafe content is emitted, including failures surfaced only via `errorStatus`
25
+ - Fixed tool argument validation to coerce singleton non-string values into arrays when the schema expects an array, preventing Anthropic-compatible models that emit `todo.ops` as an object from getting stuck in repeated validation-error loops. ([#2026](https://github.com/can1357/oh-my-pi/issues/2026))
26
+ - Fixed streaming retries to buffer and suppress partial `start` events from failed auth attempts so only clean retried events are delivered
27
+ - Fixed the HTTP 400 raw-request dumper (`appendRawHttpRequestDumpFor400`) littering the real `~/.omp/logs/http-400-requests` directory during tests. Provider suites exercise the 400 error path with mocked `fetch` responses, which the dumper could not distinguish from genuine failures; it now skips persistence under the Bun test runner (`isBunTestRuntime()`).
28
+ - Fixed Anthropic Opus requests unnecessarily forcing `tool_choice.disable_parallel_tool_use`, allowing Claude Opus to use the provider's default parallel tool-calling behavior again.
29
+ - Fixed parallel `function_call` items losing arguments against llama.cpp's OpenAI Responses endpoint (`/v1/responses`), where every call but the last finalized with `{}` and the agent rejected them with `path: Invalid input: expected string, received undefined`. llama.cpp's `to_json_oaicompat_resp` emits `output_item.added` with only `item.call_id` (no `item.id`, no `output_index`) while the matching `function_call_arguments.delta` carries `item_id: "fc_<call_id>"`. `processResponsesStream` now registers function-call and custom-tool-call items under `item.call_id` as a secondary lookup key (alongside `item.id`/`output_index`) so identifier-deviant hosts route deltas and done events to the right block. ([#2015](https://github.com/can1357/oh-my-pi/issues/2015))
30
+ - Fixed `PI_REQ_DEBUG` response recording truncating the captured body when a streamed response was cancelled mid-flight. The response tee in `wrapResponse` could call `FileRequestDebugResponseLog.close()` from both the `cancel` callback and the resumed `pull` (which observes `done` once the source reader is cancelled); the second caller saw the handle already nulled and returned before the first caller's pending write flushed, so the `.res.log` lost the already-buffered chunk. `close()` now memoizes its flush-and-close promise so every caller awaits the same completion.
31
+
5
32
  ## [15.10.0] - 2026-06-06
6
33
 
7
34
  ### Added
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Context passed to an {@link ApiKeyResolver} on each resolution attempt.
3
+ *
4
+ * The `error`/`lastChance` pair drives the central a/b/c retry policy shared by
5
+ * the streaming ({@link streamSimple}) and non-streaming ({@link withAuth})
6
+ * drivers:
7
+ * - `error === undefined` → **initial resolve** (no force-refresh; cheap, may
8
+ * return a locally-cached not-yet-expired token).
9
+ * - `error !== undefined && !lastChance` → **step (b): refresh the SAME
10
+ * account** (force a token re-mint / await an in-flight broker refresh).
11
+ * - `error !== undefined && lastChance` → **step (c): switch account**
12
+ * (invalidate/usage-limit the current credential and rotate to a sibling).
13
+ *
14
+ * The resolver returns the bearer to send, or `undefined` to stop retrying and
15
+ * surface the last error to the caller.
16
+ */
17
+ export interface ApiKeyResolveContext {
18
+ /** True on the final retry step — the resolver should rotate to a sibling credential. */
19
+ lastChance: boolean;
20
+ /** The auth error that triggered this re-resolution, or `undefined` on the initial resolve. */
21
+ error: unknown;
22
+ /** Caller cancel signal, threaded into any credential refresh / rotation work. */
23
+ signal?: AbortSignal;
24
+ }
25
+ /**
26
+ * Resolves the API key to send for a request, retried through the a/b/c policy
27
+ * described on {@link ApiKeyResolveContext}.
28
+ */
29
+ export type ApiKeyResolver = (ctx: ApiKeyResolveContext) => Promise<string | undefined> | string | undefined;
30
+ /** A static bearer string, or a {@link ApiKeyResolver} that mints/rotates one. */
31
+ export type ApiKey = string | ApiKeyResolver;
32
+ /** Narrows {@link ApiKey} to its resolver form. */
33
+ export declare function isApiKeyResolver(key: ApiKey | undefined): key is ApiKeyResolver;
34
+ /**
35
+ * Performs the initial resolve of an {@link ApiKey} (`error: undefined`,
36
+ * `lastChance: false`). Static keys pass through unchanged.
37
+ */
38
+ export declare function resolveApiKeyOnce(key: ApiKey | undefined, signal?: AbortSignal): Promise<string | undefined>;
39
+ /**
40
+ * Classifies whether an error should trigger a credential refresh/rotation
41
+ * retry: a hard `401`, or a rotatable usage-limit ("usage_limit_reached",
42
+ * Codex's "you have hit your ChatGPT usage limit", etc.).
43
+ */
44
+ export declare function isAuthRetryableError(error: unknown): boolean;
45
+ /**
46
+ * The ordered `lastChance` values for the retry steps after the initial
47
+ * attempt fails: `false` → step (b) refresh-same, `true` → step (c) switch.
48
+ * Shared by {@link withAuth} and the streaming retry driver so both run the
49
+ * same policy.
50
+ */
51
+ export declare const AUTH_RETRY_STEPS: readonly boolean[];
52
+ /** Resolve a single retry step, swallowing resolver failures into `undefined`. */
53
+ export declare function resolveRetryKey(resolver: ApiKeyResolver, lastChance: boolean, error: unknown, signal?: AbortSignal): Promise<string | undefined>;
54
+ /**
55
+ * Runs an auth-protected operation through the central a/b/c retry policy.
56
+ *
57
+ * - A static string key (or any non-resolver) → a single `attempt` with no
58
+ * retry (identical to the legacy static-key path).
59
+ * - A resolver → initial `attempt`, then on a retryable auth error up to two
60
+ * more attempts (refresh-same, then switch). A step is skipped when the
61
+ * resolver returns the same key it just tried or `undefined`; non-auth errors
62
+ * propagate immediately.
63
+ *
64
+ * Used by non-streaming consumers (image generation, web search, completion
65
+ * helpers). The streaming driver in `stream.ts` implements the same policy with
66
+ * its replay-safe buffering machinery.
67
+ */
68
+ export declare function withAuth<T>(key: ApiKey | undefined, attempt: (key: string) => Promise<T>, opts?: {
69
+ isAuthError?: (error: unknown) => boolean;
70
+ signal?: AbortSignal;
71
+ missingKeyMessage?: string;
72
+ }): Promise<T>;
@@ -8,6 +8,7 @@
8
8
  * - `SqliteAuthCredentialStore`: concrete SQLite-backed implementation
9
9
  */
10
10
  import { Database } from "bun:sqlite";
11
+ import type { ApiKeyResolver } from "./auth-retry";
11
12
  import type { Provider } from "./types";
12
13
  import type { CredentialRankingStrategy, UsageLogger, UsageProvider, UsageReport } from "./usage";
13
14
  import type { OAuthController, OAuthCredentials, OAuthProviderId } from "./utils/oauth/types";
@@ -366,6 +367,13 @@ type AuthApiKeyOptions = {
366
367
  * stranding the caller for `timeoutMs * (maxRetries + 1)`.
367
368
  */
368
369
  signal?: AbortSignal;
370
+ /**
371
+ * Force a re-mint of the session-preferred OAuth credential's access token,
372
+ * bypassing the not-yet-expired short-circuit. Powers step (b) of the
373
+ * auth-retry policy ("refresh the SAME account") so a locally-cached token
374
+ * that a peer/broker rotated out from under us is replaced before retrying.
375
+ */
376
+ forceRefresh?: boolean;
369
377
  };
370
378
  /**
371
379
  * Refreshed OAuth access plus identity metadata returned by
@@ -647,6 +655,41 @@ export declare class AuthStorage {
647
655
  getOAuthAccesses(provider: string, options?: AuthApiKeyOptions): Promise<OAuthAccessResolution[]>;
648
656
  invalidateCredentialMatching(provider: string, apiKey: string, options?: InvalidateCredentialMatchingOptions): Promise<boolean>;
649
657
  invalidateCredentialMatching(provider: string, apiKey: string, signal?: AbortSignal): Promise<boolean>;
658
+ /**
659
+ * Rotate away from the session's current credential after a retryable auth
660
+ * error — step (c) of the auth-retry policy. Stateless: looks up the
661
+ * session-sticky credential (no API-key matching needed), applies the
662
+ * storage action for the error class, then clears the sticky so the next
663
+ * {@link AuthStorage.getApiKey} for this session picks a sibling.
664
+ *
665
+ * - usage-limit / account-rate-limit error → {@link AuthStorage.markUsageLimitReached}
666
+ * (temporary block via its own backoff — default plus server usage-report
667
+ * reset; sticky left intact so the next resolve re-ranks around the block).
668
+ * - otherwise (hard 401 / auth failure) → mark the credential suspect (or
669
+ * reload when no broker hook is wired) and block it, then drop the sticky.
670
+ *
671
+ * Returns whether another usable credential of the same type remains.
672
+ */
673
+ rotateSessionCredential(provider: string, sessionId: string | undefined, options?: {
674
+ error?: unknown;
675
+ signal?: AbortSignal;
676
+ }): Promise<boolean>;
677
+ /**
678
+ * Build an {@link ApiKeyResolver} backed by this storage, implementing the
679
+ * central a/b/c auth-retry policy:
680
+ *
681
+ * - initial (`error: undefined`) → resolve the session credential.
682
+ * - step (b) `!lastChance` → force-refresh the SAME session-sticky credential.
683
+ * - step (c) `lastChance` → rotate to a sibling credential, then re-resolve.
684
+ *
685
+ * Used by web-search providers and other consumers that hold an AuthStorage
686
+ * directly (no ModelRegistry in scope).
687
+ */
688
+ resolver(provider: string, options?: {
689
+ sessionId?: string;
690
+ baseUrl?: string;
691
+ modelId?: string;
692
+ }): ApiKeyResolver;
650
693
  /**
651
694
  * Build a redacted snapshot of all loaded credentials for the auth-broker
652
695
  * wire. OAuth refresh tokens are replaced with {@link REMOTE_REFRESH_SENTINEL}
@@ -3,6 +3,7 @@ export * from "./api-registry";
3
3
  export * from "./auth-broker";
4
4
  export { type AuthGatewayBootOptions, type ModelResolver, startAuthGateway } from "./auth-gateway/server";
5
5
  export * from "./auth-gateway/types";
6
+ export * from "./auth-retry";
6
7
  export * from "./auth-storage";
7
8
  export * from "./effort";
8
9
  export * from "./model-cache";
@@ -83,10 +83,3 @@ export declare function hasOpus47ApiRestrictions(modelId: string): boolean;
83
83
  * @see https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages
84
84
  */
85
85
  export declare function supportsMidConversationSystemMessages(modelId: string): boolean;
86
- /**
87
- * Claude Opus 4.8 must emit at most one tool call per turn: the Anthropic
88
- * Messages provider sends `tool_choice.disable_parallel_tool_use = true` for
89
- * this model. Scoped to exactly 4.8 — earlier and later Opus versions keep
90
- * Anthropic's default parallel tool-calling.
91
- */
92
- export declare function disablesParallelToolUse(modelId: string): boolean;
@@ -131,6 +131,11 @@ export declare function isFireworksKimiK2ModelId(modelId: string): boolean;
131
131
  * on Fireworks-backed providers, leaving every other model untouched.
132
132
  */
133
133
  export declare function clampFireworksKimiMaxTokens(modelId: string, candidate: number): number;
134
+ /**
135
+ * Fireworks DeepSeek V4 accepts effort via `reasoning_effort` but rejects the
136
+ * DeepSeek-native binary `thinking` toggle when both are present.
137
+ */
138
+ export declare function stripFireworksDeepSeekThinkingToggle(model: Model<"openai-completions">, publicModelId: string): Model<"openai-completions">;
134
139
  export interface FireworksModelManagerConfig {
135
140
  apiKey?: string;
136
141
  baseUrl?: string;
@@ -1,4 +1,5 @@
1
1
  import type { ZodType, z } from "zod/v4";
2
+ import type { ApiKey } from "./auth-retry";
2
3
  import type { BedrockOptions } from "./providers/amazon-bedrock";
3
4
  import type { AnthropicOptions } from "./providers/anthropic";
4
5
  import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses";
@@ -159,12 +160,6 @@ export interface StreamOptions {
159
160
  maxTokens?: number;
160
161
  signal?: AbortSignal;
161
162
  apiKey?: string;
162
- /**
163
- * Called when a provider returns 401 before any replay-unsafe assistant
164
- * event has been emitted. Returning a different key retries the provider
165
- * request once.
166
- */
167
- onAuthError?: (provider: string, apiKey: string, error: unknown) => Promise<string | undefined>;
168
163
  cacheRetention?: CacheRetention;
169
164
  /**
170
165
  * Additional headers to include in provider requests.
@@ -274,7 +269,15 @@ export interface StreamOptions {
274
269
  /** Cursor exec/MCP tool handlers (cursor-agent only). */
275
270
  execHandlers?: CursorExecHandlers;
276
271
  }
277
- export interface SimpleStreamOptions extends StreamOptions {
272
+ export interface SimpleStreamOptions extends Omit<StreamOptions, "apiKey"> {
273
+ /**
274
+ * API key for the request: either a static bearer string, or an
275
+ * {@link ApiKeyResolver} that mints/rotates the key across the central
276
+ * a/b/c auth-retry policy. `streamSimple`/`completeSimple` resolve a
277
+ * resolver to a string before per-provider dispatch, so providers only
278
+ * ever see the resolved {@link StreamOptions.apiKey} string.
279
+ */
280
+ apiKey?: ApiKey;
278
281
  reasoning?: Effort;
279
282
  /**
280
283
  * Force-disable reasoning for the request even when the model supports it.
@@ -3,6 +3,14 @@ export interface JsonSchemaValidationIssue {
3
3
  message: string;
4
4
  expectedTypes?: string[];
5
5
  keyword?: string;
6
+ /**
7
+ * Marks issues that originate inside a failed `anyOf` / `oneOf` branch.
8
+ * Consumers such as the tool-argument coercion layer use this to avoid
9
+ * applying type repairs (e.g. singleton-array wrapping) that would be
10
+ * authoritative outside of a combinator but are only one candidate
11
+ * branch's expectation here.
12
+ */
13
+ fromUnionBranch?: boolean;
6
14
  }
7
15
  export interface JsonSchemaValidationResult {
8
16
  success: boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "15.10.0",
4
+ "version": "15.10.1",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@bufbuild/protobuf": "^2.12.0",
42
- "@oh-my-pi/pi-utils": "15.10.0",
42
+ "@oh-my-pi/pi-utils": "15.10.1",
43
43
  "openai": "^6.39.0",
44
44
  "partial-json": "^0.1.7",
45
45
  "zod": "4.4.3"
@@ -18,6 +18,7 @@
18
18
  * POST /v1/responses → OpenAI Responses in/out
19
19
  */
20
20
  import { extractRetryHint, logger } from "@oh-my-pi/pi-utils";
21
+ import type { ApiKeyResolver } from "../auth-retry";
21
22
  import type { AuthStorage } from "../auth-storage";
22
23
  import { Effort } from "../effort";
23
24
  import * as anthropicMessages from "../providers/anthropic-messages-server";
@@ -340,6 +341,60 @@ async function refreshGatewayApiKeyAfterAuthError(
340
341
  return storage.getApiKey(provider, sessionId, { modelId: model.id, signal });
341
342
  }
342
343
 
344
+ /**
345
+ * Build the {@link ApiKeyResolver} handed to `streamSimple` for a gateway
346
+ * request. Drives the central a/b/c auth-retry policy server-side:
347
+ *
348
+ * - initial resolve → the credential already resolved for this request.
349
+ * - step (b) `!lastChance` → force-refresh the SAME session-sticky credential
350
+ * (a peer/broker may have rotated its token out from under our cached copy).
351
+ * - step (c) `lastChance` → {@link refreshGatewayApiKeyAfterAuthError} switches
352
+ * to a sibling (usage-limit block vs credential invalidation by error class).
353
+ *
354
+ * `lastKey` tracks the most recent bearer so the switch step invalidates the
355
+ * credential that actually failed.
356
+ */
357
+ function buildGatewayApiKeyResolver(
358
+ storage: AuthStorage,
359
+ model: Model<Api>,
360
+ sessionId: string,
361
+ initialKey: string,
362
+ requestSignal: AbortSignal,
363
+ format: string,
364
+ peer: string,
365
+ ): ApiKeyResolver {
366
+ let lastKey = initialKey;
367
+ return async ({ lastChance, error, signal }) => {
368
+ const sig = signal ?? requestSignal;
369
+ if (error === undefined) {
370
+ lastKey = initialKey;
371
+ return initialKey;
372
+ }
373
+ if (!lastChance) {
374
+ const refreshed = await storage.getApiKey(model.provider, sessionId, {
375
+ modelId: model.id,
376
+ signal: sig,
377
+ forceRefresh: true,
378
+ });
379
+ lastKey = refreshed ?? lastKey;
380
+ return refreshed;
381
+ }
382
+ const next = await refreshGatewayApiKeyAfterAuthError(
383
+ storage,
384
+ model,
385
+ sessionId,
386
+ model.provider,
387
+ lastKey,
388
+ error,
389
+ sig,
390
+ format,
391
+ peer,
392
+ );
393
+ lastKey = next ?? lastKey;
394
+ return next;
395
+ };
396
+ }
397
+
343
398
  function clientClosedResponse(route: { module: FormatModule }): Response {
344
399
  return route.module.formatError(499, "request_aborted", "client closed request");
345
400
  }
@@ -447,19 +502,15 @@ async function handleFormatEndpoint(
447
502
  }
448
503
 
449
504
  const streamOpts = buildStreamOptions(parsed, model.api, controller.signal);
450
- streamOpts.apiKey = apiKey;
451
- streamOpts.onAuthError = (provider, oldKey, error) =>
452
- refreshGatewayApiKeyAfterAuthError(
453
- bootOpts.storage,
454
- model,
455
- sessionId,
456
- provider,
457
- oldKey,
458
- error,
459
- controller.signal,
460
- route.label,
461
- peer,
462
- );
505
+ streamOpts.apiKey = buildGatewayApiKeyResolver(
506
+ bootOpts.storage,
507
+ model,
508
+ sessionId,
509
+ apiKey,
510
+ controller.signal,
511
+ route.label,
512
+ peer,
513
+ );
463
514
 
464
515
  logger.info("auth-gateway request", {
465
516
  format: route.label,
@@ -604,18 +655,15 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
604
655
  // only inject server-controlled fields. The codex temperature/topP strip
605
656
  // matches `buildStreamOptions` — Codex rejects them with a 400.
606
657
  const streamOpts: SimpleStreamOptions = { ...parsed.options, apiKey, signal: controller.signal };
607
- streamOpts.onAuthError = (provider, oldKey, error) =>
608
- refreshGatewayApiKeyAfterAuthError(
609
- bootOpts.storage,
610
- model,
611
- sessionId,
612
- provider,
613
- oldKey,
614
- error,
615
- controller.signal,
616
- "pi-native",
617
- peer,
618
- );
658
+ streamOpts.apiKey = buildGatewayApiKeyResolver(
659
+ bootOpts.storage,
660
+ model,
661
+ sessionId,
662
+ apiKey,
663
+ controller.signal,
664
+ "pi-native",
665
+ peer,
666
+ );
619
667
  if (model.api === "openai-codex-responses") {
620
668
  delete streamOpts.temperature;
621
669
  delete streamOpts.topP;
@@ -0,0 +1,141 @@
1
+ import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
2
+ import { isUsageLimitError } from "./rate-limit-utils";
3
+
4
+ /**
5
+ * Context passed to an {@link ApiKeyResolver} on each resolution attempt.
6
+ *
7
+ * The `error`/`lastChance` pair drives the central a/b/c retry policy shared by
8
+ * the streaming ({@link streamSimple}) and non-streaming ({@link withAuth})
9
+ * drivers:
10
+ * - `error === undefined` → **initial resolve** (no force-refresh; cheap, may
11
+ * return a locally-cached not-yet-expired token).
12
+ * - `error !== undefined && !lastChance` → **step (b): refresh the SAME
13
+ * account** (force a token re-mint / await an in-flight broker refresh).
14
+ * - `error !== undefined && lastChance` → **step (c): switch account**
15
+ * (invalidate/usage-limit the current credential and rotate to a sibling).
16
+ *
17
+ * The resolver returns the bearer to send, or `undefined` to stop retrying and
18
+ * surface the last error to the caller.
19
+ */
20
+ export interface ApiKeyResolveContext {
21
+ /** True on the final retry step — the resolver should rotate to a sibling credential. */
22
+ lastChance: boolean;
23
+ /** The auth error that triggered this re-resolution, or `undefined` on the initial resolve. */
24
+ error: unknown;
25
+ /** Caller cancel signal, threaded into any credential refresh / rotation work. */
26
+ signal?: AbortSignal;
27
+ }
28
+
29
+ /**
30
+ * Resolves the API key to send for a request, retried through the a/b/c policy
31
+ * described on {@link ApiKeyResolveContext}.
32
+ */
33
+ export type ApiKeyResolver = (ctx: ApiKeyResolveContext) => Promise<string | undefined> | string | undefined;
34
+
35
+ /** A static bearer string, or a {@link ApiKeyResolver} that mints/rotates one. */
36
+ export type ApiKey = string | ApiKeyResolver;
37
+
38
+ /** Narrows {@link ApiKey} to its resolver form. */
39
+ export function isApiKeyResolver(key: ApiKey | undefined): key is ApiKeyResolver {
40
+ return typeof key === "function";
41
+ }
42
+
43
+ /**
44
+ * Performs the initial resolve of an {@link ApiKey} (`error: undefined`,
45
+ * `lastChance: false`). Static keys pass through unchanged.
46
+ */
47
+ export async function resolveApiKeyOnce(key: ApiKey | undefined, signal?: AbortSignal): Promise<string | undefined> {
48
+ if (key === undefined) return undefined;
49
+ if (isApiKeyResolver(key)) return (await key({ lastChance: false, error: undefined, signal })) || undefined;
50
+ return key;
51
+ }
52
+
53
+ /**
54
+ * Classifies whether an error should trigger a credential refresh/rotation
55
+ * retry: a hard `401`, or a rotatable usage-limit ("usage_limit_reached",
56
+ * Codex's "you have hit your ChatGPT usage limit", etc.).
57
+ */
58
+ export function isAuthRetryableError(error: unknown): boolean {
59
+ if (extractHttpStatusFromError(error) === 401) return true;
60
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
61
+ if (!message) return false;
62
+ if (extractHttpStatusFromError({ message }) === 401) return true;
63
+ return isUsageLimitError(message);
64
+ }
65
+
66
+ /**
67
+ * The ordered `lastChance` values for the retry steps after the initial
68
+ * attempt fails: `false` → step (b) refresh-same, `true` → step (c) switch.
69
+ * Shared by {@link withAuth} and the streaming retry driver so both run the
70
+ * same policy.
71
+ */
72
+ export const AUTH_RETRY_STEPS: readonly boolean[] = [false, true];
73
+
74
+ /** Resolve a single retry step, swallowing resolver failures into `undefined`. */
75
+ export async function resolveRetryKey(
76
+ resolver: ApiKeyResolver,
77
+ lastChance: boolean,
78
+ error: unknown,
79
+ signal?: AbortSignal,
80
+ ): Promise<string | undefined> {
81
+ try {
82
+ return (await resolver({ lastChance, error, signal })) || undefined;
83
+ } catch {
84
+ return undefined;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Runs an auth-protected operation through the central a/b/c retry policy.
90
+ *
91
+ * - A static string key (or any non-resolver) → a single `attempt` with no
92
+ * retry (identical to the legacy static-key path).
93
+ * - A resolver → initial `attempt`, then on a retryable auth error up to two
94
+ * more attempts (refresh-same, then switch). A step is skipped when the
95
+ * resolver returns the same key it just tried or `undefined`; non-auth errors
96
+ * propagate immediately.
97
+ *
98
+ * Used by non-streaming consumers (image generation, web search, completion
99
+ * helpers). The streaming driver in `stream.ts` implements the same policy with
100
+ * its replay-safe buffering machinery.
101
+ */
102
+ export async function withAuth<T>(
103
+ key: ApiKey | undefined,
104
+ attempt: (key: string) => Promise<T>,
105
+ opts?: { isAuthError?: (error: unknown) => boolean; signal?: AbortSignal; missingKeyMessage?: string },
106
+ ): Promise<T> {
107
+ const isAuthError = opts?.isAuthError ?? isAuthRetryableError;
108
+ const missingKey = (): Error => new Error(opts?.missingKeyMessage ?? "No API key available");
109
+
110
+ if (!isApiKeyResolver(key)) {
111
+ if (key === undefined) throw missingKey();
112
+ return attempt(key);
113
+ }
114
+
115
+ const resolver = key;
116
+ const signal = opts?.signal;
117
+ let lastKey = await resolveRetryKey(resolver, false, undefined, signal);
118
+ if (lastKey === undefined) throw missingKey();
119
+
120
+ let lastError: unknown;
121
+ try {
122
+ return await attempt(lastKey);
123
+ } catch (error) {
124
+ if (!isAuthError(error)) throw error;
125
+ lastError = error;
126
+ }
127
+
128
+ for (let i = 0; i < AUTH_RETRY_STEPS.length; i++) {
129
+ const nextKey = await resolveRetryKey(resolver, AUTH_RETRY_STEPS[i]!, lastError, signal);
130
+ if (nextKey === undefined || nextKey === lastKey) continue;
131
+ lastKey = nextKey;
132
+ try {
133
+ return await attempt(nextKey);
134
+ } catch (error) {
135
+ if (!isAuthError(error)) throw error;
136
+ lastError = error;
137
+ }
138
+ }
139
+
140
+ throw lastError;
141
+ }