import type { FinishReason } from './provider'; /** * Base error for all chat provider errors. */ export class ChatProviderError extends Error { constructor(message: string) { super(message); this.name = 'ChatProviderError'; } } /** * Network-level connection failure. */ export class APIConnectionError extends ChatProviderError { constructor(message: string) { super(message); this.name = 'APIConnectionError'; } } /** * Request timed out. */ export class APITimeoutError extends ChatProviderError { constructor(message: string) { super(message); this.name = 'APITimeoutError'; } } /** * HTTP status error from the API. */ export class APIStatusError extends ChatProviderError { readonly statusCode: number; readonly requestId: string | null; /** * Server-requested backoff from the `retry-after` response header, in * milliseconds. When present, the retry loop honors it instead of its own * computed backoff — a server `Retry-After` directive overrides the local * exponential delay. */ readonly retryAfterMs: number | null; /** * Provider trace identifier from the `x-trace-id` response header * (Kimi/KFC only), or `null` when the error response did not carry one. * A failed request usually still returns response headers, so hosts can * attribute the failure to its server-side request. */ readonly traceId: string | null; constructor( statusCode: number, message: string, requestId?: string | null, retryAfterMs?: number | null, traceId?: string | null, ) { super(message); this.name = 'APIStatusError'; this.statusCode = statusCode; this.requestId = requestId ?? null; this.retryAfterMs = retryAfterMs ?? null; this.traceId = traceId ?? null; } } /** * HTTP status error that specifically means the request exceeded the model * context window. */ export class APIContextOverflowError extends APIStatusError { constructor( statusCode: number, message: string, requestId?: string | null, retryAfterMs?: number | null, traceId?: string | null, ) { super(statusCode, message, requestId, retryAfterMs, traceId); this.name = 'APIContextOverflowError'; } } /** * HTTP 413 that specifically means the serialized request body exceeded the * provider's byte ceiling (e.g. accumulated base64 images), as opposed to a * token-count overflow. Token overflow is recoverable by compaction; a body * size rejection is not — it needs media to be dropped or shrunk. */ export class APIRequestTooLargeError extends APIStatusError { constructor( statusCode: number, message: string, requestId?: string | null, retryAfterMs?: number | null, traceId?: string | null, ) { super(statusCode, message, requestId, retryAfterMs, traceId); this.name = 'APIRequestTooLargeError'; } } /** * HTTP status error that specifically means the provider rate-limited the * request. */ export class APIProviderRateLimitError extends APIStatusError { constructor( message: string, requestId?: string | null, retryAfterMs?: number | null, traceId?: string | null, ) { super(429, message, requestId, retryAfterMs, traceId); this.name = 'APIProviderRateLimitError'; } } /** * HTTP 429 that specifically means the account's quota or balance is * exhausted, as opposed to a transient rate limit. Deliberately NOT a * subclass of `APIProviderRateLimitError`: a rate limit clears on its own * (retry/requeue helps), while quota exhaustion is deterministic until the * account is recharged — so this class is excluded from retry and from the * rate-limit requeue/suspend paths. * * Observed shapes: Moonshot returns `error.type = * "exceeded_current_quota_error"` with wording that varies by account state * ("You exceeded your current token quota: ... please check your account * balance" vs "Your account ... is suspended due to insufficient balance, * please recharge your account ..."); OpenAI uses `insufficient_quota` as * both `error.type` and `error.code`. */ export class APIProviderQuotaExhaustedError extends APIStatusError { constructor( message: string, requestId?: string | null, retryAfterMs?: number | null, traceId?: string | null, ) { super(429, message, requestId, retryAfterMs, traceId); this.name = 'APIProviderQuotaExhaustedError'; } } /** * The API returned an empty response (no content, no tool calls). */ export class APIEmptyResponseError extends ChatProviderError { readonly finishReason: FinishReason | null; readonly rawFinishReason: string | null; constructor( message: string, options: { readonly finishReason?: FinishReason | null; readonly rawFinishReason?: string | null; } = {}, ) { super(message); this.name = 'APIEmptyResponseError'; this.finishReason = options.finishReason ?? null; this.rawFinishReason = options.rawFinishReason ?? null; } } /** * The single standard abort shape for the wire layer: a DOMException named * `'AbortError'`, matching the platform's own `AbortSignal.reason` * convention. Every user-cancellation path — the `generate()` driver, * provider error converters, stream wrappers — throws exactly this shape so * upstream code can recognize cancellation without SDK knowledge. */ export function createAbortError(): DOMException { return new DOMException('The operation was aborted.', 'AbortError'); } /** * Whether `error` is any abort shape that can surface from a provider call: * * - the standard abort DOMException (`createAbortError`, `signal.reason`), * - a bare `Error` named `'AbortError'` (generic abort helpers), or * - an SDK user-abort (`APIUserAbortError` in both the OpenAI and Anthropic * SDKs) — recognized structurally by constructor name so this module * stays SDK-free. */ export function isAbortError(error: unknown): boolean { if (error instanceof DOMException && error.name === 'AbortError') return true; if (error instanceof Error && error.name === 'AbortError') return true; return ( typeof error === 'object' && error !== null && error.constructor?.name === 'APIUserAbortError' ); } /** * The abort guard for provider error converters. Must run at the very front * of every error classification chain: when `error` is abort-shaped this * THROWS the standard abort DOMException — it never returns a converted * error — so a user cancellation can never be misclassified as a retryable * provider failure. Does nothing for non-abort errors. */ export function throwIfAbortError(error: unknown): void { if (isAbortError(error)) { throw createAbortError(); } } export function isRetryableGenerateError(error: unknown): boolean { if (error instanceof APIConnectionError || error instanceof APITimeoutError) { return true; } if (error instanceof APIEmptyResponseError) { // A filtered response is deterministic: replaying the same request just // re-triggers the provider's safety filter, so fail fast and surface the // filter notice instead of burning the whole step-retry budget. return error.finishReason !== 'filtered'; } if (error instanceof APIStatusError) { // Quota/balance exhaustion is a 429 but deterministic until the account // is recharged — retrying can never succeed, so it fails fast instead of // burning the whole retry budget (~2-3 minutes of backoff). if (error instanceof APIProviderQuotaExhaustedError) { return false; } // Transient statuses worth retrying: 408 (request timeout), 409 // (lock/conflict timeout), 429 (rate limit), 5xx (server errors) and 529 // (provider overloaded — the "engine is currently overloaded" case). return [408, 409, 429, 500, 502, 503, 504, 529].includes(error.statusCode); } // Fallback safety net: an unclassified provider failure — typically an // upstream gateway that forwards the original error only as text, with no // usable HTTP status (e.g. llmproxy embedding `status_code=429` in the // message) — lands here as a base `ChatProviderError`. Retrying beats // failing the run on the first transient blip. Typed `APIStatusError` // instances are deliberately excluded above: deterministic 4xx // (400/401/403/404/422) and the recovery-owned context-overflow / // request-too-large subclasses keep their dedicated handling instead of // burning retries first. Image-format rejections are likewise excluded: // they are deterministic per history and recovered by the media-stripped // resend (see isImageFormatError), so retrying the identical request first // would only burn the retry budget. return error instanceof ChatProviderError && !isImageFormatError(error); } // Client-side image rejections thrown before the request is sent (kosong's // own media whitelist in the Anthropic adapter). const IMAGE_FORMAT_PROVIDER_MESSAGE_PATTERNS = [ /unsupported media type for base64 image/, /invalid data url for image/, ] as const; // Server-side image rejections that are safe to recover by stripping media: // an unsupported/invalid media type or undecodable image data. These are // deliberately narrow and grounded in the documented messages of the major // providers (Anthropic, OpenAI, Moonshot/Kimi, Gemini) — image COUNT/SIZE // limits or image-input-disabled errors also mention "image", but stripping // media either over-recovers or hides a real configuration problem the user // should see; only format/data rejections are guaranteed to be fixed by // removing the offending image. // // Matching on provider message text is inherently best-effort: these strings // are not a stable contract, so a novel phrasing is missed and the error // propagates (the pre-recovery behavior). The entry-point format gate is the // structural defense; this recovery only backstops the residue. // Every pattern mentions "image" literally, and MEDIA_TYPE_FIELD_PATTERN is // separately gated on an "image" anchor — so audio/video media rejections // ("unsupported media type", "invalid media type") can never be classified // as image errors here. All documented provider image rejections mention // "image", so the restriction costs no known match. const IMAGE_FORMAT_STATUS_MESSAGE_PATTERNS = [ // Unsupported format — OpenAI / Moonshot "unsupported image …". /unsupported image (?:url|format|type)/, // Undecodable / corrupt image data. /does not represent a valid image/, /could not (?:process|decode) (?:the |input )?image/, /unable to process (?:the |input )?image/, /failed to decode (?:the )?image/, /invalid image(?: data| type| format)?/, ] as const; // Anthropic `media_type` & Gemini `mime_type` enum violations name the field // — recoverable only when the message is about an IMAGE. A video/audio // `media_type` rejection must surface instead of being blindly // media-stripped: unlike images there is no conversion-guidance path for // video today, so dropping the user's video silently would hide the real // error. Every documented image media_type message also mentions "image", // so the anchor costs nothing on the known cases. const MEDIA_TYPE_FIELD_PATTERN = /(?:media|mime)_?type/; /** * Whether the provider rejected an IMAGE in the request because of its * FORMAT or DATA — an unsupported media type or undecodable image bytes. * The rejection is deterministic for a given history (the same image is * re-sent on every request, so the session would fail every turn), and the * only recovery is to resend once with all media stripped (see the * media-stripped resend in the agent loop). Body-size (413), context * overflow, image count/size limits, image-input-disabled rejections, and * non-image (audio/video) media rejections are excluded — the first two * have their own recoveries, and the rest are not fixed by stripping media. */ export function isImageFormatError(error: unknown): boolean { if (error instanceof APIStatusError) { if (error instanceof APIContextOverflowError) return false; if (error instanceof APIRequestTooLargeError) return false; if (error.statusCode !== 400) return false; const lowerMessage = error.message.toLowerCase(); return ( IMAGE_FORMAT_STATUS_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)) || (MEDIA_TYPE_FIELD_PATTERN.test(lowerMessage) && lowerMessage.includes('image')) ); } if (error instanceof ChatProviderError) { const lowerMessage = error.message.toLowerCase(); return IMAGE_FORMAT_PROVIDER_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } return false; } // `terminated` is the undici signature for an SSE/HTTP body stream that is // dropped mid-flight (common with Node's native fetch on long reasoning // streams). It surfaces as a raw `TypeError: terminated`, so it must be // recognized here as a transport-layer connection failure. Shared by the // Anthropic and OpenAI providers so a raw, non-SDK transport error classifies // the same way regardless of which provider was streaming. const NETWORK_RE = /network|connection|connect|disconnect|terminated/i; const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i; /** * Classify a raw (non-SDK) error message into the right transport-layer * `ChatProviderError` subclass: a timeout becomes a retryable `APITimeoutError`, * a dropped connection / undici `terminated` becomes a retryable * `APIConnectionError`, and anything else stays a non-retryable base * `ChatProviderError`. Timeout is checked first so "connection timed out" * classifies as a timeout rather than a bare connection error. */ export function classifyBaseApiError(message: string): ChatProviderError { if (TIMEOUT_RE.test(message)) { return new APITimeoutError(message); } if (NETWORK_RE.test(message)) { return new APIConnectionError(message); } return new ChatProviderError(`Error: ${message}`); } const CONTEXT_OVERFLOW_MESSAGE_PATTERNS = [ /context[ _-]?length/, /(?:context[ _-]?window.*exceed|exceed.*context[ _-]?window)/, /maximum context/, /exceed(?:ed|s|ing)?\s+(?:the\s+)?max(?:imum)?\s+tokens?/, /(?:too many tokens.*(?:prompt|input|context)|(?:prompt|input|context).*too many tokens)/, /prompt is too long.*maximum/, /input token count.*exceeds?.*maximum number of tokens/, /request.*exceed(?:ed|s|ing)?.*model token limit/, ] as const; const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [ /(?:apistatuserror.*429|429.*apistatuserror)/, /429.*too many requests/, /too many requests/, /provider\.rate_limit/, /reached .*max rpm/, /rate[ _-]?limit(?:ed)?/, /rate-limited/, ] as const; // Wordings that mean the serialized request BODY was too big, matched against // the lowercased message of a 413. Kept separate from the context-overflow // patterns above: those describe token counts, these describe bytes. A 413 // whose message matches neither family stays a plain `APIStatusError` — // Vertex phrases prompt-too-long as a 413, so the status alone is not proof // of a body-size rejection. const REQUEST_TOO_LARGE_MESSAGE_PATTERNS = [ // Moonshot / Kimi: "Request exceeds the maximum size". /request exceeds the maximum size/, // Reverse proxies (nginx-style HTML body): "413 Request Entity Too Large". /request entity too large/, // Anthropic: error type `request_too_large`, message "Request exceeds the // maximum allowed number of bytes". /request_too_large/, /exceeds? the maximum allowed number of bytes/, // RFC 9110 reason phrase (both the pre-2022 and current names). /payload too large/, /content too large/, // Plain wordings: generic gateways say "request too large"; Go's // http.MaxBytesReader (common in Go proxies) says "request body too large". /request (?:body )?too large/, ] as const; const THINKING_EFFORT_CONFIG_DOCS_URL = 'https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#thinking'; const THINKING_EFFORT_STATUS_MESSAGE_PATTERNS = [ /reasoning[_ .-]?effort/, /thinking[_ .-]?effort/, /output_config[\s\S]*effort/, /unsupported[\s\S]*effort/, /invalid[\s\S]*effort/, ] as const; function appendThinkingEffortConfigHint(statusCode: number, message: string): string { if (statusCode !== 400 && statusCode !== 422) return message; const lowerMessage = message.toLowerCase(); if (!THINKING_EFFORT_STATUS_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage))) { return message; } if (message.includes(THINKING_EFFORT_CONFIG_DOCS_URL)) return message; return `${message} The provider rejected the configured thinking effort. Non-Kimi providers receive effort strings without client-side mapping; choose an effort supported by the selected model. For Kimi models, check support_efforts and default_effort. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; } export function isContextOverflowErrorCode(code: string | null | undefined): boolean { return code === 'context_length_exceeded'; } export function normalizeAPIStatusError( statusCode: number, message: string, requestId?: string | null, retryAfterMs?: number | null, traceId?: string | null, ): APIStatusError { if (statusCode === 429) { return new APIProviderRateLimitError(message, requestId, retryAfterMs, traceId); } // Context overflow first: Vertex returns prompt-too-long as a 413, and a // token overflow must keep routing to compaction even on that status. if (isContextOverflowStatusError(statusCode, message)) { return new APIContextOverflowError(statusCode, message, requestId, retryAfterMs, traceId); } if (isRequestTooLargeStatusError(statusCode, message)) { return new APIRequestTooLargeError(statusCode, message, requestId, retryAfterMs, traceId); } return new APIStatusError( statusCode, appendThinkingEffortConfigHint(statusCode, message), requestId, retryAfterMs, traceId, ); } /** * Read a single response header from an unknown headers-like object (anything * exposing a `get(name)` method, e.g. the Fetch `Headers` the SDKs carry on * their errors). Returns `null` when the object is not headers-like or the * header is absent. */ function readResponseHeader(headers: unknown, name: string): string | null { return headers !== null && typeof headers === 'object' && typeof (headers as { get?: unknown }).get === 'function' ? (headers as { get(name: string): string | null }).get(name) : null; } /** * Parse the provider trace identifier from the `x-trace-id` response header * (Kimi/KFC only). Returns `null` when the header is absent or empty. */ export function parseTraceId(headers: unknown): string | null { const raw = readResponseHeader(headers, 'x-trace-id'); if (raw === null || raw === undefined || raw.length === 0) return null; return raw; } /** * Parse a `retry-after` response header into milliseconds. Only integer * seconds is honored; an HTTP-date (or any non-integer / missing value) * returns null and the caller falls back to its computed backoff. Shared by * the provider error converters so every backend honors the same server * backoff directive. */ export function parseRetryAfterMs(headers: unknown): number | null { const raw = readResponseHeader(headers, 'retry-after'); if (raw === null || raw === undefined) return null; const seconds = Number.parseInt(raw, 10); if (!Number.isFinite(seconds) || seconds < 0) return null; return seconds * 1000; } export function isContextOverflowStatusError(statusCode: number, message: string): boolean { if (statusCode !== 400 && statusCode !== 413 && statusCode !== 422) return false; const lowerMessage = message.toLowerCase(); return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } export function isRequestTooLargeStatusError(statusCode: number, message: string): boolean { if (statusCode !== 413) return false; const lowerMessage = message.toLowerCase(); return REQUEST_TOO_LARGE_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } // Strict providers reject a request whose assistant `tool_use`/`tool_calls` and // `tool_result`/`tool` blocks are not correctly paired and adjacent — a missing // result, a stray result with no matching call, or a result that does not // immediately follow its call. Anthropic phrases this in terms of // `tool_use`/`tool_result`. OpenAI-compatible providers phrase it in terms of // `tool_call_id` / `role 'tool'` / `tool_calls`: Moonshot / Kimi as a // `tool_call_id` that "is not found", and OpenAI / DeepSeek / vLLM / Qwen as a // `role 'tool'` message without a preceding `tool_calls`, or an assistant // `tool_calls` not followed by its tool results. The validation runs before any // generation, so the error is a non-retryable 4xx. A caller can react by // resending a re-projected, strictly wire-compliant request rather than leaving // the session permanently stuck. const TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS = [ /tool_use[\s\S]*tool_result/, /tool_result[\s\S]*tool_use/, /unexpected\s+`?tool_result/, // OpenAI-compatible (Moonshot / Kimi): a `tool` message references a // `tool_call_id` with no matching `tool_calls` entry in the preceding // assistant message. Observed verbatim as `tool_call_id is not found` // (doubled space). Anchored on `tool_call_id` so an unrelated "not found" // (e.g. a 404-style body) cannot trip the recovery. /tool_call_id[\s\S]*not found/, // OpenAI / DeepSeek / vLLM and other OpenAI-compatible providers phrase the // same structural rejection in terms of `role 'tool'` / `tool_calls` instead // of Anthropic's `tool_use` / `tool_result`, in two mirror-image shapes: // // - An orphan `tool` result whose preceding assistant carries no matching // `tool_calls`: "messages with role 'tool' must be a response to a // preceding message with 'tool_calls'". // - An assistant `tool_calls` with no following `tool` results: "an // assistant message with 'tool_calls' must be followed by tool messages // responding to each 'tool_call_id'. the following tool_call_ids did not // have response messages: ...", or the terse "(insufficient tool messages // following tool_calls message)". // // Both are wire-structure defects the strict resend repairs (drop the orphan // result / synthesize the missing one). Quote style around `tool`/`tool_calls` // varies by provider (straight or backtick), so the anchors tolerate an // optional surrounding quote char. /role\s+['"`]?tool['"`]?\s+must be a response to a preceding message/, /assistant message with\s+['"`]?tool_calls['"`]?\s+must be followed by tool messages/, /tool_call_ids? did not have response messages/, /insufficient tool messages following/, ] as const; export function isToolExchangeAdjacencyError(error: unknown): boolean { if (!(error instanceof APIStatusError)) return false; if (error instanceof APIContextOverflowError) return false; if (error.statusCode !== 400 && error.statusCode !== 422) return false; const lowerMessage = error.message.toLowerCase(); return TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } // The broader family of structural request rejections a strict provider returns // when the message array itself is malformed — tool_use/tool_result pairing, // empty or whitespace-only text blocks, a non-user first message, or // non-alternating roles. All are deterministic 4xx validation failures (no // generation happened) on a history that is re-sent every turn, so the only // recovery is to resend a re-projected, strictly wire-compliant request rather // than leave the session permanently stuck. Context-overflow 400s are excluded — // they are handled by compaction, not by re-projection. const STRUCTURAL_REQUEST_MESSAGE_PATTERNS = [ /text content blocks must be non-empty/, /text content blocks must contain non-whitespace/, /first message must use the .*user.* role/, /roles must alternate/, /multiple .*(?:user|assistant).* roles in a row/, // Anthropic rejects a request whose assistant messages carry two `tool_use` // blocks with the same id: "messages: `tool_use` ids must be unique". Seen // when a provider reused a call id (e.g. per-response counter ids) earlier // in the session; the strict resend dedupes the ids. /tool_use[\s\S]*ids must be unique/, // Moonshot / Kimi rejects a message whose serialized form carries nothing — // no content, no tool_calls, an empty reasoning_content: "the message at // position N with role 'assistant' must not be empty". Seen when a filtered // response left an assistant message holding only an empty thinking part in // the history; the strict resend's projection drops such vacuous messages. /message at position \d+ with role ['"`]?[a-z]+['"`]? must not be empty/, ] as const; export function isRecoverableRequestStructureError(error: unknown): boolean { if (isToolExchangeAdjacencyError(error)) return true; if (!(error instanceof APIStatusError)) return false; if (error instanceof APIContextOverflowError) return false; if (error.statusCode !== 400 && error.statusCode !== 422) return false; const lowerMessage = error.message.toLowerCase(); return STRUCTURAL_REQUEST_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } export function isProviderRateLimitError(error: unknown): boolean { // Quota exhaustion is a 429 but not a rate limit: the rate-limit reactions // (retry, requeue, suspend) cannot help until the account is recharged. if (error instanceof APIProviderQuotaExhaustedError) return false; if (error instanceof APIProviderRateLimitError) return true; const statusCode = getStatusCode(error); if (statusCode !== undefined) return statusCode === 429; const lowerMessage = errorMessage(error).toLowerCase(); return PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } function getStatusCode(error: unknown): number | undefined { if (typeof error !== 'object' || error === null) return undefined; const record = error as Record; const statusCode = record['statusCode']; if (typeof statusCode === 'number') return statusCode; const status = record['status']; if (typeof status === 'number') return status; const response = record['response']; if (typeof response !== 'object' || response === null) return undefined; const responseRecord = response as Record; const responseStatusCode = responseRecord['statusCode']; if (typeof responseStatusCode === 'number') return responseStatusCode; const responseStatus = responseRecord['status']; return typeof responseStatus === 'number' ? responseStatus : undefined; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); }