UNPKG

@earendil-works/pi-ai

Version:

Unified LLM API with automatic model discovery and provider configuration

216 lines • 10.2 kB
function buildProviderErrorPattern(patterns) { return new RegExp(patterns.join("|"), "i"); } const NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN = buildProviderErrorPattern([ // OpenCode Go/free-tier limits returned as 429 JSON error types by OpenCode's // Zen API. These are subscription/account limits, not transient throttles. "GoUsageLimitError", "FreeUsageLimitError", // OpenCode Go subscription-limit text asks users to enable available-balance // usage after rolling/weekly/monthly limits are reached. "Monthly usage limit reached", "available balance", // Generic quota/budget/billing exhaustion. `insufficient_quota` is OpenAI's // quota/billing error code; the other strings cover common gateway wording. "insufficient_quota", "out of budget", "quota exceeded", "billing", // Sign in with ChatGPT: the subscription's shared usage limit, which resets // after hours rather than seconds. "subscription_sharing_usage_limit_exceeded", // Sign in with ChatGPT on a ChatGPT Business plan: a usage limit set by the // workspace admin. Only the admin can raise it. "subscription_sharing_workplace_individual_usage_limit_exceeded", // Zhipu/Z.ai (open.bigmodel.cn) error code 1308: the plan's usage limit is // reached, sent as HTTP 429. It resets after hours, not seconds (#11537). '"code"\\s*:\\s*"?1308(?![0-9])', ]); const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([ // Generic provider load, HTTP status, and server-side transient failures. "overloaded", "server_busy", "servers are currently busy", "currently experiencing high demand", "model is at capacity", "rate.?limit", "too many requests", // Status codes must not be part of a longer word or number, e.g. a request id or byte count (#11057). "(?<![a-z0-9])(?:429|500|502|503|504|520|524)(?![a-z0-9])", // Also matches "The service is temporarily unavailable" (#10965). "service.?(?:is.?)?(?:temporarily.?)?unavailable", "server.?error", "internal.?error", // Wrapper/provider text for transient upstream failures, including OpenRouter // "Provider returned error" responses (#2264) and gateway wording such as // "The upstream provider returned an error" (#10842). "provider.?returned.?(?:an.?)?error", "exceeded request buffer limit while retrying upstream", // Gateway failure relaying the upstream response stream, sent as an OpenAI // Responses `response.failed` event without an error code (#10920). "unable to forward upstream", // Other gateway upstream failures relayed as `response.failed` error codes (#10920). "upstream.?request.?failed", "upstream_stream_read_error", "stream was interrupted", "http/?2 stream failed", // Network, proxy, and fetch transport failures. This includes OpenAI Codex // raw-fetch failures such as "upstream connect", "connection refused", and // "reset before headers" (#733), plus OpenRouter connection drops (#3317). "network.?error", "connection.?error", "connection.?refused", "connection.?lost", "other side closed", "fetch failed", // node-fetch system errors, e.g. the Google ADC OAuth token refresh used by // google-vertex. The reason is often empty (#10996). "request to \\S+ failed, reason:", "getaddrinfo", "ENOTFOUND", "EAI_AGAIN", "upstream.?connect", "reset before headers", "socket hang up", "socket connection was closed", "timed? out", "timeout", "terminated", // WebSocket transports can report close/error text instead of HTTP/fetch text. "websocket.?closed", "websocket.?error", // Premature stream endings from SDKs and transports. Anthropic can throw // "stream ended without ..." and "Anthropic stream ended before message_stop" // (#4433); Bedrock/Smithy can throw an HTTP/2 no-response error (#3594). "ended without", "stream ended before message_stop", "stream ended before a terminal response event", "http2 request did not get a response", // Codex CLI mid-stream disconnect, relayed by proxies that wrap the Codex CLI, // e.g. "stream disconnected before completion: stream closed before response.completed" (#11057). "stream disconnected before completion", // Magpie gateway relaying a dropped upstream Anthropic stream, e.g. // "Claude Code's stream from Anthropic was cut short after the reply had begun" (#11879). "cut short after the reply had begun", // Node ERR_HTTP2_STREAM_CANCEL: the HTTP/2 session died before the request was // sent, e.g. after the Bedrock SDK's 5-minute session timeout (#10379). "pending stream has been canceled", // Provider-requested retry delay cap failures should flow through the outer // retry policy so callers can surface/abort the backoff (#1123). "retry delay", // Explicit retry guidance emitted mid-stream by OpenAI Responses and Bedrock // stream exceptions (#6019). "you can retry your request", "try your request again", "please retry your request", // gRPC based providers (e.g. NVIDIA NIM) "ResourceExhausted", // Sign in with ChatGPT: usage or user data temporarily unavailable. Usage // failures can arrive mid-stream without an HTTP 503 in the message. "subscription_sharing_usage_unavailable", "subscription_sharing_user_unavailable", ]); export const DEFAULT_MAX_AGENT_RETRY_DELAY_MS = 60_000; export function retryDelayMs(policy, attempt) { const delay = policy.baseDelayMs * 2 ** Math.max(0, attempt - 1); const safeDelay = Number.isSafeInteger(delay) ? delay : Number.MAX_SAFE_INTEGER; return Math.min(safeDelay, policy.maxAgentDelayMs ?? DEFAULT_MAX_AGENT_RETRY_DELAY_MS); } class RetrySleepAbortError extends Error { constructor() { super("Aborted"); } } function sleep(ms, signal) { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new RetrySleepAbortError()); return; } const timeout = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timeout); reject(new RetrySleepAbortError()); }, { once: true }); }); } /** * Run a single assistant-producing call with bounded retry on transient errors. * * Behavior: * - A successful response is returned immediately. Aborts are terminal and never * retried, but reported as unsuccessful if they happen after a retry was scheduled. * Aborts during the backoff sleep are normalized to an aborted `AssistantMessage` * too, so callers do not need to care when cancellation happened. * - A non-retryable error (per {@link isRetryableAssistantError}, including quota/ * billing exhaustion) is returned immediately so deterministic errors fail fast. * - Otherwise retries up to `maxRetries` times with exponential backoff, emitting * `onRetryScheduled` before each sleep, `onRetryAttemptStart` after each sleep before * the retried call starts, and `onRetryFinished` once at the end (whether the loop * ends in success, exhausted retries, or an aborted backoff). * * When `policy` is undefined or disabled, the first response is returned unchanged * (equivalent to calling `produce()` directly). */ export async function retryAssistantCall(produce, policy, signal, callbacks) { const maxAttempts = policy?.enabled ? policy.maxRetries : 0; let attempt = 0; let lastRetry; for (;;) { const response = await produce(); // Abort: terminal but not successful. Never retry an aborted message. if (response.stopReason === "aborted") { if (lastRetry) await callbacks?.onRetryFinished?.(false, lastRetry.attempt); return response; } // Success: non-error, non-abort responses return as-is. if (response.stopReason !== "error") { if (lastRetry) await callbacks?.onRetryFinished?.(true, lastRetry.attempt); return response; } // Non-retryable, or budget exhausted: return the final error message. if (attempt >= maxAttempts || !isRetryableAssistantError(response)) { if (lastRetry) await callbacks?.onRetryFinished?.(false, lastRetry.attempt, response.errorMessage); return response; } attempt++; lastRetry = { attempt, errorMessage: response.errorMessage || "Unknown error" }; const delayMs = retryDelayMs(policy, attempt); await callbacks?.onRetryScheduled?.(attempt, maxAttempts, delayMs, lastRetry.errorMessage); // Normalize aborts during retry backoff to the same AssistantMessage shape as // provider stream aborts, so callers do not need to care when cancellation happened. try { await sleep(delayMs, signal); } catch (error) { await callbacks?.onRetryFinished?.(false, attempt, lastRetry.errorMessage); if (error instanceof RetrySleepAbortError) { const { errorMessage: _errorMessage, ...rest } = response; return { ...rest, stopReason: "aborted" }; } throw error; } await callbacks?.onRetryAttemptStart?.(); } } /** * Classifies whether a failed assistant message looks like a transient provider * or transport error, so callers can decide if the last assistant turn should be * restarted. * * This does not implement retry policy. Callers should first handle context * overflow separately, then apply their own retry budget, backoff, and reporting * before restarting the assistant turn. */ export function isRetryableAssistantError(message) { if (message.stopReason !== "error" || !message.errorMessage) return false; const errorMessage = message.errorMessage; if (NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN.test(errorMessage)) return false; return RETRYABLE_PROVIDER_ERROR_PATTERN.test(errorMessage); } //# sourceMappingURL=retry.js.map