Files
oh-my-pi/packages/ai/src/auth-retry.ts
T
can1357 cba6641299 feat: enabled resolver-based API key retries with refresh and rotation
- Added `ApiKeyResolver`/`ApiKey` types and exported auth-retry helpers.
- Changed stream and gateway auth retry handling to use resolver steps.
- Added initial-key, force-refresh, and rotate credential retries for auth failures.
- Updated agent and coding-agent integrations to use context-aware API-key resolvers.
2026-06-07 04:20:48 +02:00

142 lines
5.3 KiB
TypeScript

import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
import { isUsageLimitError } from "./rate-limit-utils";
/**
* Context passed to an {@link ApiKeyResolver} on each resolution attempt.
*
* The `error`/`lastChance` pair drives the central a/b/c retry policy shared by
* the streaming ({@link streamSimple}) and non-streaming ({@link withAuth})
* drivers:
* - `error === undefined` → **initial resolve** (no force-refresh; cheap, may
* return a locally-cached not-yet-expired token).
* - `error !== undefined && !lastChance` → **step (b): refresh the SAME
* account** (force a token re-mint / await an in-flight broker refresh).
* - `error !== undefined && lastChance` → **step (c): switch account**
* (invalidate/usage-limit the current credential and rotate to a sibling).
*
* The resolver returns the bearer to send, or `undefined` to stop retrying and
* surface the last error to the caller.
*/
export interface ApiKeyResolveContext {
/** True on the final retry step — the resolver should rotate to a sibling credential. */
lastChance: boolean;
/** The auth error that triggered this re-resolution, or `undefined` on the initial resolve. */
error: unknown;
/** Caller cancel signal, threaded into any credential refresh / rotation work. */
signal?: AbortSignal;
}
/**
* Resolves the API key to send for a request, retried through the a/b/c policy
* described on {@link ApiKeyResolveContext}.
*/
export type ApiKeyResolver = (ctx: ApiKeyResolveContext) => Promise<string | undefined> | string | undefined;
/** A static bearer string, or a {@link ApiKeyResolver} that mints/rotates one. */
export type ApiKey = string | ApiKeyResolver;
/** Narrows {@link ApiKey} to its resolver form. */
export function isApiKeyResolver(key: ApiKey | undefined): key is ApiKeyResolver {
return typeof key === "function";
}
/**
* Performs the initial resolve of an {@link ApiKey} (`error: undefined`,
* `lastChance: false`). Static keys pass through unchanged.
*/
export async function resolveApiKeyOnce(key: ApiKey | undefined, signal?: AbortSignal): Promise<string | undefined> {
if (key === undefined) return undefined;
if (isApiKeyResolver(key)) return (await key({ lastChance: false, error: undefined, signal })) || undefined;
return key;
}
/**
* Classifies whether an error should trigger a credential refresh/rotation
* retry: a hard `401`, or a rotatable usage-limit ("usage_limit_reached",
* Codex's "you have hit your ChatGPT usage limit", etc.).
*/
export function isAuthRetryableError(error: unknown): boolean {
if (extractHttpStatusFromError(error) === 401) return true;
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
if (!message) return false;
if (extractHttpStatusFromError({ message }) === 401) return true;
return isUsageLimitError(message);
}
/**
* The ordered `lastChance` values for the retry steps after the initial
* attempt fails: `false` → step (b) refresh-same, `true` → step (c) switch.
* Shared by {@link withAuth} and the streaming retry driver so both run the
* same policy.
*/
export const AUTH_RETRY_STEPS: readonly boolean[] = [false, true];
/** Resolve a single retry step, swallowing resolver failures into `undefined`. */
export async function resolveRetryKey(
resolver: ApiKeyResolver,
lastChance: boolean,
error: unknown,
signal?: AbortSignal,
): Promise<string | undefined> {
try {
return (await resolver({ lastChance, error, signal })) || undefined;
} catch {
return undefined;
}
}
/**
* Runs an auth-protected operation through the central a/b/c retry policy.
*
* - A static string key (or any non-resolver) → a single `attempt` with no
* retry (identical to the legacy static-key path).
* - A resolver → initial `attempt`, then on a retryable auth error up to two
* more attempts (refresh-same, then switch). A step is skipped when the
* resolver returns the same key it just tried or `undefined`; non-auth errors
* propagate immediately.
*
* Used by non-streaming consumers (image generation, web search, completion
* helpers). The streaming driver in `stream.ts` implements the same policy with
* its replay-safe buffering machinery.
*/
export async function withAuth<T>(
key: ApiKey | undefined,
attempt: (key: string) => Promise<T>,
opts?: { isAuthError?: (error: unknown) => boolean; signal?: AbortSignal; missingKeyMessage?: string },
): Promise<T> {
const isAuthError = opts?.isAuthError ?? isAuthRetryableError;
const missingKey = (): Error => new Error(opts?.missingKeyMessage ?? "No API key available");
if (!isApiKeyResolver(key)) {
if (key === undefined) throw missingKey();
return attempt(key);
}
const resolver = key;
const signal = opts?.signal;
let lastKey = await resolveRetryKey(resolver, false, undefined, signal);
if (lastKey === undefined) throw missingKey();
let lastError: unknown;
try {
return await attempt(lastKey);
} catch (error) {
if (!isAuthError(error)) throw error;
lastError = error;
}
for (let i = 0; i < AUTH_RETRY_STEPS.length; i++) {
const nextKey = await resolveRetryKey(resolver, AUTH_RETRY_STEPS[i]!, lastError, signal);
if (nextKey === undefined || nextKey === lastKey) continue;
lastKey = nextKey;
try {
return await attempt(nextKey);
} catch (error) {
if (!isAuthError(error)) throw error;
lastError = error;
}
}
throw lastError;
}