import { ORDERING_ERROR_PATTERNS, WEB_SEARCH_ORDERING_PATTERNS, } from "../agent/history-repair/history-repair.js"; import type { ConversationErrorCode, ConversationErrorEvent, } from "../api/events/conversation-error.js"; import { getIsPlatform } from "../config/env-registry.js"; import { isImageDimensionsTooLargeError, isImageMediaTypeMismatchError, isImageUnprocessableError, isImageUnsupportedFormatError, } from "../plugins/defaults/image-recovery/detect.js"; import { ConnectionResolutionError } from "../providers/connection-resolution.js"; import { PROVIDER_CATALOG } from "../providers/model-catalog.js"; import { getProviderRoutingSource } from "../providers/registry.js"; import { isAbortReason } from "../util/abort-reasons.js"; import { type ProviderCredentialSource, ProviderError, type ProviderErrorReason, ProviderNotConfiguredError, } from "../util/errors.js"; import { INSUFFICIENT_CREDITS_PATTERNS, isVisionNotSupportedError, } from "../util/provider-error-patterns.js"; /** * Classified conversation error ready for client emission. */ export interface ClassifiedConversationError { code: ConversationErrorCode; userMessage: string; retryable: boolean; debugDetails?: string; /** Machine-readable error category for log report metadata and triage. */ errorCategory: string; /** * Name of the `provider_connections` row in play when the error * occurred. Forwarded to the wire `ConversationErrorEvent` so chat * banners can point users at the specific slot to fix. Only set by * classifiers / callers that have the resolved connection in scope — * generic regex fallbacks leave it undefined. */ connectionName?: string; /** * Name of the resolved profile (`llm.activeProfile` / per-call override) * in play when the error occurred. Forwarded to the wire message so * banners can name the profile when the connection identifier is * generic (e.g. the canonical managed connection). Optional for the * same reason as `connectionName`. */ profileName?: string; } /** * Optional resolved-config context that callers can attach to error * classification so the resulting `ConversationErrorEvent` can name the * exact connection / profile in play. Used in particular by the chat * dispatch sites to make `PROVIDER_INVALID_KEY` and `PROVIDER_NOT_CONFIGURED` * actionable (the macOS banner reads these to render "Invalid API key for * profile X" instead of a generic "API key required"). */ export interface ConversationErrorAttribution { connectionName?: string; profileName?: string; /** Whether the resolved turn route uses Vellum-managed inference. */ isManagedRoute?: boolean; /** * Which credential the failed request presented. Splits the non-managed side * of `isManagedRoute` into personal keys, subscription logins, and keyless * endpoints so rejection copy names the right thing to fix. */ credentialSource?: ProviderCredentialSource; } // Network-level error patterns (connection refused, timeout, DNS, reset) const NETWORK_PATTERNS = [ /ECONNREFUSED/i, /ECONNRESET/i, /ETIMEDOUT/i, /ENOTFOUND/i, /socket hang up/i, /socket.*closed unexpectedly/i, /network.*error/i, /fetch failed/i, /connection.*refused/i, /connection.*reset/i, /connection.*timeout/i, ]; // Rate limit patterns (HTTP 429 or explicit rate limit messages) const RATE_LIMIT_PATTERNS = [/429/, /rate.?limit/i, /too many requests/i]; // Managed usage-limit responses are generated by Vellum, even though they can // travel through provider SDKs and get wrapped as ProviderError. const MANAGED_USAGE_LIMIT_PATTERNS = [ /"code"\s*:\s*"daily_quota_exceeded"/i, /"code"\s*:\s*"rate_limit_exceeded"/i, /system credential proxy rate limit/i, /you've reached your usage limit for today/i, /current plan allows/i, ]; const PROVIDER_BILLING_PATTERNS = [ ...INSUFFICIENT_CREDITS_PATTERNS, /requires more credits/i, /can only afford/i, ]; // Overloaded patterns — provider is capacity-constrained (distinct from rate limiting) const OVERLOADED_PATTERNS = [/overloaded/i]; // Context-too-large patterns (request exceeds the model's context window) const CONTEXT_TOO_LARGE_PATTERNS = [ /context.?length.?exceeded/i, /maximum.?context.?length/i, /token.?limit.?exceeded/i, /prompt.?is.?too.?long/i, /conversation.*too long.*model.*process/i, /too long for the model to process/i, /request too large/i, /too many.*input.*tokens/i, /max_tokens.*exceeded/i, /exceeded.*max_tokens/i, ]; // Generic timeout patterns — checked after NETWORK_PATTERNS and PROVIDER_API_PATTERNS // so that "connection timeout" → PROVIDER_NETWORK and "gateway timeout" → PROVIDER_API const TIMEOUT_PATTERNS = [ /\btimeout\b/i, /deadline.?exceeded/i, /request.?timed?.?out/i, ]; // Provider API error patterns (5xx, server error, etc.) const PROVIDER_API_PATTERNS = [ /\b5\d{2}\b/, /server error/i, /internal server error/i, /bad gateway/i, /service unavailable/i, /gateway timeout/i, ]; // Stale/invalid opaque `encrypted_content` token in replayed web-search results. // Anthropic's tokens have bounded validity; the daemon replaces historical // `web_search_tool_result` blocks with text summaries to avoid this, but this // classifier remains as defense-in-depth for any path that bypasses the strip. const STALE_WEB_SEARCH_CONTENT_PATTERNS = [ /invalid\s+`?encrypted_content`?\s+in\s+`?(?:web_)?search_result`?\s+block/i, ]; // Empty-request-messages: Anthropic's 400 "messages: at least one message is // required". Reaching the provider with zero messages is an internal // request-construction failure (e.g. over-aggressive history filtering / // provenance scoping), never bad user input — so it earns a clear, non-alarming // banner instead of the raw provider-rejection string. const EMPTY_REQUEST_MESSAGES_PATTERNS = [/at least one message is required/i]; // Streaming corruption patterns (Anthropic SDK throws non-HTTP errors for SSE issues) const STREAMING_ERROR_PATTERNS = [ /unexpected event order/i, /stream ended without producing/i, /request ended without sending any chunks/i, /stream has ended.*this shouldn't happen/i, ]; const CANCEL_PATTERNS = [/abort/i, /cancel/i]; /** * Context about where the error occurred, used to refine classification. */ export interface ErrorContext { /** Where in the processing pipeline the error occurred. */ phase: "agent_loop" | "handler" | "persist"; /** Whether the abort signal was active when the error occurred. */ aborted?: boolean; /** * Optional name of the `provider_connections` row in play. Plumbed by * dispatch sites that know the resolved connection (chat agent loop) * so credential-related classifications (`PROVIDER_INVALID_KEY`, * `PROVIDER_NOT_CONFIGURED`) can name the exact slot to fix. */ connectionName?: string; /** * Optional name of the resolved profile in play. Plumbed alongside * `connectionName` by dispatch sites; surfaces in the wire message so * the macOS chat banner can reference the profile even when the * underlying connection name is generic. */ profileName?: string; /** * Whether the resolved turn route uses Vellum-managed inference. Turn routing * takes precedence over the provider registry's boot-time default. */ isManagedRoute?: boolean; } /** * Returns true if the error looks like a user-initiated cancellation * (AbortError or explicit cancel). These should use `generation_cancelled` * instead of `conversation_error`. * * Provider SDKs wrap the underlying AbortError in their own error class * (e.g. `ProviderError("Anthropic API error: Request was aborted.")`), * which erases the `AbortError` name. To compensate, the daemon tags every * `controller.abort(reason)` call with an `AbortReason` object — when the * wrapped `ProviderError` carries that tagged reason, we treat it as a user * cancellation regardless of error class. The same tagged object can also * surface directly (e.g. when code calls `AbortSignal.throwIfAborted()`, * which throws `signal.reason` verbatim), so a bare `AbortReason` is * recognized as cancellation too. */ export function isUserCancellation(error: unknown, ctx: ErrorContext): boolean { if (!ctx.aborted) { return false; } if (error instanceof DOMException && error.name === "AbortError") { return true; } if (error instanceof Error && error.name === "AbortError") { return true; } if (error instanceof ProviderError && isAbortReason(error.abortReason)) { return true; } if (isAbortReason(error)) { return true; } return false; } /** Maximum length for debugDetails to prevent unbounded event payloads. */ const MAX_DEBUG_DETAIL_LENGTH = 4000; /** * Truncate debug details to a reasonable size for transport. */ function truncateDebugDetails(details: string): string { if (details.length <= MAX_DEBUG_DETAIL_LENGTH) { return details; } return details.slice(0, MAX_DEBUG_DETAIL_LENGTH) + "\n… (truncated)"; } /** * Classify an unknown error into a structured conversation error. * Does NOT handle user-initiated cancellation — callers should check * `isUserCancellation` first and emit `generation_cancelled` instead. * * Classification priority: * 1. Phase-specific overrides (queue, regenerate) * 2. ProviderError.statusCode (deterministic for provider failures) * 3. Regex fallback for network/cancel/unknown errors */ export function classifyConversationError( error: unknown, ctx: ErrorContext, ): ClassifiedConversationError { const message = error instanceof Error ? error.message : String(error); const rawDetails = (error instanceof Error ? error.stack : undefined) ?? message; const debugDetails = truncateDebugDetails(rawDetails); // Extract optional attribution (connection / profile names) so // credential-related classifications can name the exact slot to fix. // A `ProviderNotConfiguredError` instance carries its own attribution // (from the throw site) which takes priority over context when present. // The failed call's own route wins field by field — it is resolved at // dispatch, while context describes the turn and can be stale for this // call. Per-field, so a route carrying only some fields doesn't blank the // rest. const providerRoute = error instanceof ProviderError ? error.routeAttribution : undefined; const connectionName = providerRoute?.connectionName ?? ctx.connectionName; const profileName = providerRoute?.profileName ?? ctx.profileName; const isManagedRoute = providerRoute?.isManagedRoute ?? ctx.isManagedRoute; const attribution: ConversationErrorAttribution = { ...(connectionName ? { connectionName } : {}), ...(profileName ? { profileName } : {}), ...(isManagedRoute !== undefined ? { isManagedRoute } : {}), ...(providerRoute?.credentialSource ? { credentialSource: providerRoute.credentialSource } : {}), }; // Dedicated classification for missing provider API key if (error instanceof ProviderNotConfiguredError) { return { ...providerNotConfiguredClassification({ connectionName: error.connectionName ?? attribution.connectionName, profileName: error.profileName ?? attribution.profileName, }), debugDetails, }; } if (error instanceof ConnectionResolutionError) { const profileName = error.profileName ?? attribution.profileName; const connectionName = displayableConnectionName(error.connectionName); return { code: "PROVIDER_NOT_CONFIGURED", userMessage: connectionResolutionUserMessage( error, connectionName, profileName, ), retryable: true, // The reason discriminant rides in debugDetails so telemetry can // distinguish the five failure classes without a new wire field. debugDetails: `connection_resolution:${error.reason} — ${debugDetails}`, errorCategory: "provider_not_configured", ...(connectionName ? { connectionName } : {}), ...(profileName ? { profileName } : {}), }; } // Classify using statusCode (if ProviderError) then regex fallback const classified = classifyCore(error, message, attribution); return { ...classified, debugDetails, }; } /** * Internal throw sites use sentinel pseudo-names (``, * ``) when no real connection row is involved; those must * not render as literal connection names. */ function displayableConnectionName(name: string): string | undefined { return name && !name.startsWith("<") ? name : undefined; } function connectionResolutionUserMessage( error: ConnectionResolutionError, connectionName: string | undefined, profileName: string | undefined, ): string { const connection = connectionName ? `Provider connection "${connectionName}"` : "The provider connection"; const usedBy = profileName ? ` (used by profile "${profileName}")` : ""; const fixPath = "Settings → Models & Services"; switch (error.reason) { case "lookup_failed": return `${connection}${usedBy} couldn't be read from the connections database. Restart the assistant, then check ${fixPath}.`; case "not_found": return `${connection}${usedBy} no longer exists. Pick a provider in ${fixPath}.`; case "provider_mismatch": return `${connection}${usedBy} is bound to a different provider than the profile declares. Update the profile's connection in ${fixPath}.`; case "missing_connection": return `No provider connection is configured${usedBy}. Ask me to set one up right here, or add an API key in ${fixPath}.`; case "unroutable_managed_model": return `The model "${error.model ?? ""}"${usedBy} isn't served by the Vellum managed route. Pick a model from the Vellum catalog, or choose a concrete provider in ${fixPath}.`; case "missing_credential": // Provider-neutral: api_key connections store keys, oauth_subscription // connections store login tokens — the fix differs but the location // doesn't. return `${connection}${usedBy} has no stored credential. Add an API key or reconnect it in ${fixPath}.`; case "platform_unauthenticated": // A platform-managed assistant cannot log in or switch providers, and // this classifier only sees the reason code, so one honest wording covers // both the transient and the re-provision cases. if (getIsPlatform()) { return `${connection}${usedBy} is unavailable. If this persists, the platform credential may need to be re-provisioned on the Vellum platform.`; } return `${connection}${usedBy} requires a Vellum platform login. Log in, or pick a different provider in ${fixPath}.`; case "model_incompatible": return `${error.model ? `Model "${error.model}"` : "The requested model"} isn't available on ${connectionName ? `connection "${connectionName}"` : "the configured connection"}${usedBy}. Pick a different model or connection in ${fixPath}.`; } } /** * Core classification: the provider-stamped `reason` short-circuit is the * primary source of truth. The status switch + regex battery below is the * reason-less fallback — reached only when no `reason` classified the error. * * `attribution` carries the resolved connection / profile names (when the * caller knows them) so credential-related results can name the exact * slot for the user to fix. Pass an empty object when no attribution is * available — the classifier falls back to a generic message. */ function classifyCore( error: unknown, message: string, attribution: ConversationErrorAttribution = {}, ): Omit { const isManagedRoute = error instanceof ProviderError && (attribution.isManagedRoute ?? getProviderRoutingSource(error.provider) === "managed-proxy"); // Which credential the request actually presented. Only rejection copy needs // this finer axis; every other branch keys off `isManagedRoute`. Routes that // predate per-request stamping collapse to the managed/personal split. const credentialSource: ProviderCredentialSource = attribution.credentialSource ?? (isManagedRoute ? "vellum-managed" : "byok"); // Prefer the semantic reason stamped by the provider layer, regardless of // HTTP status — statusless errors (e.g. SDK streaming failures) still carry a // reason. Reasons that map cleanly win here; `bad_request`/`unknown` (and a // reason-less error) fall through to the status switch + regex battery below. if (error instanceof ProviderError && error.reason) { const c = reasonToClassification(error.reason, { isManagedRoute, credentialSource, attribution, message, providerName: error.provider, }); if (c) { return c; } } // Reason-less (or bad_request/unknown) ProviderError with a status — the // deterministic status switch + regex battery, sharing the same producers. if (error instanceof ProviderError && error.statusCode !== undefined) { if (error.statusCode === 413) { return contextTooLargeClassification(); } if (error.statusCode === 401 || error.statusCode === 403) { // Managed routes through the assistant API key; if that credential is // stale, the user cannot fix it from model settings. Everything else is // a credential the user owns, so the copy names which one to update and // the chat banner points at Settings. return rejectedCredentialClassification( error.provider, credentialSource, attribution, ); } if (error.statusCode === 402) { if (isManagedRoute) { return managedBalanceClassification(); } return providerBillingClassification(); } if (error.statusCode === 429) { if (isManagedUsageLimitError(message, isManagedRoute)) { return managedUsageLimitClassification(); } return rateLimitClassification(); } // Anthropic uses 529 for overloaded_error if (error.statusCode === 529) { return providerOverloadedClassification(); } if (error.statusCode >= 500) { return providerServerErrorClassification(); } // 4xx (non-429) — check for context-too-large, ordering errors, then generic fallback if (error.statusCode >= 400) { if (isEmptyRequestMessages(message)) { return emptyRequestMessagesClassification(); } if (isContextTooLarge(message)) { return contextTooLargeClassification(); } if (isWebSearchOrderingError(message)) { return { code: "PROVIDER_WEB_SEARCH", userMessage: "An internal error occurred with web search. Please try again.", retryable: true, errorCategory: "web_search_ordering", }; } if (isStaleWebSearchContent(message)) { return { code: "PROVIDER_WEB_SEARCH", userMessage: "Stale web-search results in conversation history. Please try again.", retryable: true, errorCategory: "stale_web_search_content", }; } if (isOrderingError(message)) { return { code: "PROVIDER_ORDERING", userMessage: "An internal error occurred. Please try again.", retryable: true, errorCategory: "tool_ordering", }; } if (isProviderBillingError(message)) { return providerBillingClassification(); } if ( /invalid.*api.?key|invalid.*x-api-key|authentication.?error|invalid.authentication/i.test( message, ) ) { // Mirror the 401/403 branch: a credential-shaped 4xx is an // "invalid key" surface (banner: "Invalid API key"), distinct // from "no key configured" (banner: "API key required"). return rejectedCredentialClassification( error.provider, credentialSource, attribution, ); } if (isImageDimensionsTooLargeError(message)) { return { code: "IMAGE_TOO_LARGE", userMessage: "An image in this conversation was too large for the AI provider and was automatically reduced. Send your message again to continue.", retryable: false, errorCategory: "image_dimensions_too_large", }; } if (isImageUnprocessableError(message)) { // Reuses the IMAGE_TOO_LARGE wire code: clients validate the code // enum, so a new value would break stale clients, and they render // `userMessage` verbatim anyway. `errorCategory` carries the // distinction for triage. return { code: "IMAGE_TOO_LARGE", userMessage: "An image in this conversation could not be processed by the AI provider — it may be below the provider's minimum image size. It was automatically adjusted where possible; send your message again to continue.", retryable: false, errorCategory: "image_unprocessable", }; } if (isImageMediaTypeMismatchError(message)) { // Same wire-code reuse as image_unprocessable above. This surfaces only // when auto-correction could not resolve the mismatch — a recognized // format is relabeled and the retry succeeds without ever reaching here, // so the copy must not claim a fix that didn't happen. return { code: "IMAGE_TOO_LARGE", userMessage: "An image in this conversation is in a format the AI provider can't read, and it couldn't be converted automatically. Re-save it as PNG or JPEG and upload it again.", retryable: false, errorCategory: "image_media_type_mismatch", }; } if (isImageUnsupportedFormatError(message)) { // Same wire-code reuse as image_unprocessable above. The provider read // the bytes and found no image it accepts, so no relabel or resize can // rescue the attachment: name the accepted formats instead. return { code: "IMAGE_TOO_LARGE", userMessage: "An image in this conversation is not in a format the AI provider accepts (PNG, JPEG, GIF, and WebP are). Remove or convert it and send your message again.", retryable: false, errorCategory: "image_unsupported_format", }; } if (isVisionNotSupportedError(message)) { return visionNotSupportedClassification(); } // Extract the provider detail after "API error (NNN): " prefix const detailMatch = message.match(/API error \(\d+\):\s*(.+)/i); const detail = detailMatch?.[1]; const suffix = detail ? `: ${detail.length > 200 ? detail.slice(0, 200) + "…" : detail}` : ""; return { code: "PROVIDER_API", userMessage: `The AI provider rejected the request (HTTP ${error.statusCode})${suffix}`, retryable: true, errorCategory: "provider_api_error", }; } } // Regex fallback for non-ProviderError or ProviderError without statusCode return classifyByMessage(message, isManagedRoute); } /** * Strip the `API error (NNN): ` prefix and a trailing `[type=…]` bracket from a * provider error message, returning the human-readable detail (truncated to * ~200 chars) or `undefined` when nothing meaningful remains. */ function extractProviderDetail(message: string): string | undefined { let detail = message.replace(/^.*?API error \(\d+\):\s*/i, ""); detail = detail.replace(/\s*\[[^\]]*\]\s*$/, "").trim(); if (!detail || detail === message.trim()) { // No recognizable prefix — only surface prose that isn't the whole raw line. if (/API error \(\d+\)/i.test(message)) { return undefined; } } if (!detail) { return undefined; } return detail.length > 200 ? `${detail.slice(0, 200)}…` : detail; } /** * Map a provider-stamped {@link ProviderErrorReason} to a classification, * applying the managed-proxy-vs-user-key routing overlay inline. Returns `null` * for reasons that should defer to the legacy status/regex fallback * (`bad_request`, `unknown`). */ function reasonToClassification( reason: ProviderErrorReason, args: { isManagedRoute: boolean; credentialSource: ProviderCredentialSource; attribution?: ConversationErrorAttribution; message: string; providerName: string; }, ): Omit | null { const managed = args.isManagedRoute; switch (reason) { case "invalid_credentials": return rejectedCredentialClassification( args.providerName, args.credentialSource, args.attribution, ); case "rate_limited": // Match managed usage-limit body patterns, as the legacy path does. if ( managed || MANAGED_USAGE_LIMIT_PATTERNS.some((p) => p.test(args.message)) ) { return managedUsageLimitClassification(); } return rateLimitClassification(); case "insufficient_credits": return managed ? managedBalanceClassification() : providerBillingClassification(); case "daily_limit_reached": // The reason is stamped only from the platform proxy's // `"code":"daily_limit_reached"` body, so it is authoritative on its // own — the global routing map can lag per-connection platform-auth // routes and must not downgrade this to the generic billing surface. return dailyLimitClassification(); case "overloaded": return providerOverloadedClassification(); case "server_error": return providerServerErrorClassification(); case "context_overflow": return contextTooLargeClassification(); case "vision_unsupported": return visionNotSupportedClassification(); case "network_error": return { code: "PROVIDER_NETWORK", userMessage: "The provider returned an empty response with no body — this typically indicates a network proxy or egress filter intercepting the request, not a genuine provider error. Check your network configuration.", retryable: true, errorCategory: "provider_network_proxy_intercepted", }; case "model_not_found": return { code: "PROVIDER_API", userMessage: "The selected model wasn't found by the provider. Switch models in Settings → Models & Services.", retryable: false, errorCategory: "provider_model_not_found", }; case "model_restricted": { const detail = extractProviderDetail(args.message); const prefix = "This model isn't available on your current provider plan"; const suffix = "Switch to a different model or upgrade your plan in Settings → Models & Services."; // Skew-safe code; the specific signal rides errorCategory. return { code: "PROVIDER_API", userMessage: detail ? `${prefix}: ${detail} ${suffix}` : `${prefix}. ${suffix}`, retryable: false, errorCategory: "provider_model_restricted", }; } case "bad_request": case "unknown": return null; } } /** Check whether an error message indicates a context-too-large failure. */ export function isContextTooLarge(message: string): boolean { return CONTEXT_TOO_LARGE_PATTERNS.some((p) => p.test(message)); } /** * Check whether an error message indicates the request reached the provider * with an empty `messages` array. See `EMPTY_REQUEST_MESSAGES_PATTERNS`. */ function isEmptyRequestMessages(message: string): boolean { return EMPTY_REQUEST_MESSAGES_PATTERNS.some((p) => p.test(message)); } /** * Classification for a request that reached the provider with no messages to * send. This is an internal request-construction failure, so the user-facing * copy avoids surfacing the raw provider 400. */ function emptyRequestMessagesClassification(): Omit< ClassifiedConversationError, "debugDetails" > { return { code: "PROVIDER_API", userMessage: "This request failed to be sent to the model because there was no content.", retryable: true, errorCategory: "empty_request_messages", }; } /** Check whether an error message indicates a web-search-specific ordering failure. */ function isWebSearchOrderingError(message: string): boolean { return WEB_SEARCH_ORDERING_PATTERNS.some((p) => p.test(message)); } /** * Check whether an error message indicates a stale/invalid `encrypted_content` * opaque token in a replayed `web_search_tool_result`. See * `stripHistoricalWebSearchResults()` for the proactive mitigation. */ function isStaleWebSearchContent(message: string): boolean { return STALE_WEB_SEARCH_CONTENT_PATTERNS.some((p) => p.test(message)); } /** Check whether an error message indicates a tool_use/tool_result ordering failure. */ function isOrderingError(message: string): boolean { return ORDERING_ERROR_PATTERNS.some((p) => p.test(message)); } /** Check whether an error message indicates an Anthropic SDK streaming corruption. */ function isStreamingError(message: string): boolean { return STREAMING_ERROR_PATTERNS.some((p) => p.test(message)); } function isManagedUsageLimitError( message: string, isManagedRoute: boolean, ): boolean { return ( isManagedRoute || MANAGED_USAGE_LIMIT_PATTERNS.some((p) => p.test(message)) ); } function isProviderBillingError(message: string): boolean { return PROVIDER_BILLING_PATTERNS.some((p) => p.test(message)); } function managedBalanceClassification(): Omit< ClassifiedConversationError, "debugDetails" > { return { code: "PROVIDER_BILLING", // This classification feeds two surfaces with different semantics: the // terminal provider-error path (the turn ends with no reply) and the // non-terminal memory-v3 degraded notice (a normal reply still follows). // Keep the wording context-neutral so it is true in both places; the // terminal persist site in conversation-agent-loop.ts swaps in // assistant-voice copy for the synthetic assistant row. userMessage: "You're out of credits. Add credits in Settings → Billing to continue.", retryable: false, errorCategory: "credits_exhausted", }; } function providerBillingClassification(): Omit< ClassifiedConversationError, "debugDetails" > { return { code: "PROVIDER_BILLING", userMessage: "Your API provider account or key needs credits. Add funds with the provider or update the key in Settings → Models & Services.", retryable: false, errorCategory: "provider_billing", }; } function dailyLimitClassification(): Omit< ClassifiedConversationError, "debugDetails" > { return { code: "PROVIDER_BILLING", userMessage: "You've hit your daily credit limit. Raise the limit in Billing settings to keep going today.", retryable: false, errorCategory: "daily_limit_reached", }; } // Shared classification producers — single source of `code`+`errorCategory` // for both the reason short-circuit and the reason-less fallback. function rateLimitClassification(): Omit< ClassifiedConversationError, "debugDetails" > { return { code: "PROVIDER_RATE_LIMIT", userMessage: "You are being rate limited by the AI provider. Please try again in a moment.", retryable: true, errorCategory: "rate_limit", }; } function managedUsageLimitClassification(): Omit< ClassifiedConversationError, "debugDetails" > { return { code: "MANAGED_USAGE_LIMIT", userMessage: "Vellum managed inference is rate limited. This is a Vellum-side usage limit, not an AI provider outage.", retryable: true, errorCategory: "managed_usage_limit", }; } /** * Deliberately instruction-free. The assistant API key is provisioned and * pushed by the platform — the assistant only ever reads it — so there is no * Settings affordance to re-provision it and no user action that would help. * The copy's job is to rule out the user's own provider key as the cause; * clients that can offer a real next step attach one (web renders Doctor). */ function managedKeyInvalidClassification(): Omit< ClassifiedConversationError, "debugDetails" > { return { code: "MANAGED_KEY_INVALID", userMessage: "Vellum's managed inference credential was rejected. This isn't a personal provider API key — Vellum provisions this one, so there's nothing to update in Settings.", retryable: false, errorCategory: "managed_key_invalid", }; } function providerOverloadedClassification(): Omit< ClassifiedConversationError, "debugDetails" > { return { code: "PROVIDER_OVERLOADED", userMessage: "The AI provider is temporarily overloaded. Please try again in a moment.", retryable: true, errorCategory: "provider_overloaded", }; } function providerServerErrorClassification(): Omit< ClassifiedConversationError, "debugDetails" > { return { code: "PROVIDER_API", userMessage: "The AI provider returned a server error.", retryable: true, errorCategory: "provider_server_error", }; } function contextTooLargeClassification(): Omit< ClassifiedConversationError, "debugDetails" > { return { code: "CONTEXT_TOO_LARGE", userMessage: "This conversation is too long. Please start a new conversation.", retryable: false, errorCategory: "context_too_large", }; } function visionNotSupportedClassification(): Omit< ClassifiedConversationError, "debugDetails" > { return { code: "PROVIDER_API", userMessage: "This model doesn't support image input. Remove the image or switch to a vision-capable model.", retryable: false, errorCategory: "vision_not_supported", }; } /** * Build a user-facing message that names the exact profile / connection * to fix when one is known, falling back to a generic phrase otherwise. * Profile is preferred because that's the entity the user picks in the * chat picker; connection is shown when no profile is in play (e.g. the * profileless anchor dispatch) or as a parenthetical when both differ. */ function describeAttribution( attribution: ConversationErrorAttribution | undefined, ): string { if (!attribution) { return ""; } const { profileName, connectionName } = attribution; if (profileName && connectionName && profileName !== connectionName) { return ` for profile "${profileName}" (connection "${connectionName}")`; } if (profileName) { return ` for profile "${profileName}"`; } if (connectionName) { return ` for connection "${connectionName}"`; } return ""; } function providerDisplayName(providerName: string): string { return ( PROVIDER_CATALOG.find((provider) => provider.id === providerName) ?.displayName ?? providerName ); } function classificationAttribution( attribution: ConversationErrorAttribution | undefined, ): Pick { return { ...(attribution?.connectionName ? { connectionName: attribution.connectionName } : {}), ...(attribution?.profileName ? { profileName: attribution.profileName } : {}), }; } function rejectedCredentialClassification( providerName: string, credentialSource: ProviderCredentialSource, attribution?: ConversationErrorAttribution, ): Omit { switch (credentialSource) { case "vellum-managed": return managedKeyInvalidClassification(); case "oauth-subscription": return subscriptionLoginRejectedClassification(providerName, attribution); case "no-auth": return endpointAuthenticationRequiredClassification( providerName, attribution, ); case "byok": return invalidApiKeyClassification(providerName, attribution); } } function endpointAuthenticationRequiredClassification( providerName: string, attribution?: ConversationErrorAttribution, ): Omit { const provider = providerDisplayName(providerName); const target = describeAttribution(attribution); return { code: "PROVIDER_API", userMessage: `The ${provider} endpoint${target} requires authentication, but that connection is configured without credentials. Configure authentication for that endpoint in Settings → Models & Services.`, retryable: false, errorCategory: "provider_endpoint_auth_required", ...classificationAttribution(attribution), }; } function subscriptionLoginRejectedClassification( providerName: string, attribution?: ConversationErrorAttribution, ): Omit { const provider = providerDisplayName(providerName); const target = describeAttribution(attribution); return { code: "PROVIDER_API", userMessage: `Your ${provider} subscription login${target} was rejected by ${provider}. Reconnect that account in Settings → Models & Services.`, retryable: false, errorCategory: "provider_subscription_auth", ...classificationAttribution(attribution), }; } /** * Classification for an invalid (rejected by the upstream provider, e.g. * Anthropic 401/403) API key. Distinct from `PROVIDER_NOT_CONFIGURED` * (which is for a key that was never set) so the macOS chat banner can * render "Invalid API key" rather than "API key required" — they have * different recovery actions (update vs. add). */ function invalidApiKeyClassification( providerName: string, attribution?: ConversationErrorAttribution, ): Omit { const provider = providerDisplayName(providerName); const target = describeAttribution(attribution); return { code: "PROVIDER_INVALID_KEY", userMessage: `Your personal ${provider} API key${target} was rejected by ${provider}. Update that key in Settings → Models & Services.`, retryable: false, errorCategory: "provider_invalid_key", ...classificationAttribution(attribution), }; } /** * Classification for a genuinely-missing provider credential — vault has * no entry for the resolved connection's `auth.credential`, or no * provider is registered at all. The macOS chat banner renders this as * "API key required" with an "Open Settings" CTA. Distinct from * `PROVIDER_INVALID_KEY` (where a key exists but was rejected). */ function providerNotConfiguredClassification( attribution?: ConversationErrorAttribution, ): Omit { const target = describeAttribution(attribution); return { code: "PROVIDER_NOT_CONFIGURED", userMessage: target ? `No API key configured${target}. Add one in Settings → Models & Services to start chatting.` : "No API key configured for inference. Add one in Settings → Models & Services to start chatting.", retryable: true, errorCategory: "provider_not_configured", ...(attribution?.connectionName ? { connectionName: attribution.connectionName } : {}), ...(attribution?.profileName ? { profileName: attribution.profileName } : {}), }; } /** * Last-resort regex fallback for reason-less, non-HTTP, or otherwise * unclassifiable errors (network failures, streaming corruption, re-wrapped * errors, ProviderErrors with no statusCode). Reached only after the reason * short-circuit and the status switch both decline to classify. */ function classifyByMessage( message: string, isManagedRoute: boolean, ): Omit { // Empty-request-messages is always an internal construction failure — check // it before the provider/network patterns so a ProviderError that carries the // message but no statusCode still gets the friendly banner. if (isEmptyRequestMessages(message)) { return emptyRequestMessagesClassification(); } // Check context-too-large before other patterns if (isContextTooLarge(message)) { return contextTooLargeClassification(); } // Check rate limit first (before network, since 429 could match both) for (const pattern of RATE_LIMIT_PATTERNS) { if (pattern.test(message)) { if (isManagedUsageLimitError(message, isManagedRoute)) { return managedUsageLimitClassification(); } return rateLimitClassification(); } } // Overloaded — provider is capacity-constrained (not the user's fault) for (const pattern of OVERLOADED_PATTERNS) { if (pattern.test(message)) { return providerOverloadedClassification(); } } // Web-search ordering errors (before general ordering errors) if (isWebSearchOrderingError(message)) { return { code: "PROVIDER_WEB_SEARCH", userMessage: "An internal error occurred with web search. Please try again.", retryable: true, errorCategory: "web_search_ordering", }; } if (isStaleWebSearchContent(message)) { return { code: "PROVIDER_WEB_SEARCH", userMessage: "Stale web-search results in conversation history. Please try again.", retryable: true, errorCategory: "stale_web_search_content", }; } // General tool_use/tool_result ordering errors if (isOrderingError(message)) { return { code: "PROVIDER_ORDERING", userMessage: "An internal error occurred. Please try again.", retryable: true, errorCategory: "tool_ordering", }; } // Network errors (before timeout so "connection timeout" is classified as network) for (const pattern of NETWORK_PATTERNS) { if (pattern.test(message)) { return { code: "PROVIDER_NETWORK", userMessage: "Could not connect to the AI provider.", retryable: true, errorCategory: "provider_network", }; } } // Provider API errors (before timeout so "gateway timeout" keeps its specific message) for (const pattern of PROVIDER_API_PATTERNS) { if (pattern.test(message)) { return providerServerErrorClassification(); } } // Generic timeout errors (checked after network and provider API patterns so // specific timeouts like "connection timeout" and "gateway timeout" aren't misclassified) for (const pattern of TIMEOUT_PATTERNS) { if (pattern.test(message)) { return { code: "PROVIDER_API", userMessage: "The request to the AI provider timed out.", retryable: true, errorCategory: "provider_timeout", }; } } // Streaming corruption errors (Anthropic SDK SSE issues — transient, retryable) if (isStreamingError(message)) { return { code: "PROVIDER_API", userMessage: "The AI provider's response was interrupted. Please try again.", retryable: true, errorCategory: "stream_corruption", }; } // Non-user abort/failure (e.g. AbortError from internal logic, not user cancel) for (const pattern of CANCEL_PATTERNS) { if (pattern.test(message)) { return { code: "CONVERSATION_ABORTED", userMessage: "The request was interrupted.", retryable: true, errorCategory: "session_aborted", }; } } // Default: processing failure — include the first non-empty line of the actual error // so users know what went wrong instead of seeing a completely generic message. const firstLine = message .split("\n") .map((l) => l.trim()) .find((l) => l.length > 0) ?? ""; const userMessage = firstLine ? `Processing failed: ${firstLine}` : "Something went wrong processing your message. Please try again."; return { code: "CONVERSATION_PROCESSING_FAILED", userMessage, retryable: true, errorCategory: "processing_failed", }; } /** * Classify a `budget_yield_unrecovered` terminal exit. * * Emitted when the agent loop's `auto_compress_latest_turn` rerun * (the last layer of the overflow-recovery ladder) still yields at * the mid-loop preflight budget checkpoint. The turn cannot proceed, * but it is not a provider rejection — every compaction the loop ran * has already been applied to the conversation, so the user's next * message starts from the compacted history and typically succeeds. * * The returned `userMessage` is persisted as a `role="assistant"` row * by the same path that already persists `PROVIDER_BILLING` etc., so * the notice is durable across reload (not just a transient banner). */ export function budgetYieldUnrecoveredClassification(): ClassifiedConversationError { return { code: "BUDGET_YIELD_UNRECOVERED", userMessage: "I tried to compact this conversation but couldn't fit the next step into the model's context window. Send another message to continue — the compaction I did run has been saved, so your next turn starts from a smaller history.", retryable: true, errorCategory: "budget_yield_unrecovered", }; } /** * Classify a model response that stopped because the output-token limit was * reached. The turn may have produced useful partial text, so the recovery is * a follow-up user turn rather than a retry of the same request. */ export function maxTokensReachedClassification(): ClassifiedConversationError { return { code: "MAX_TOKENS_REACHED", userMessage: "I hit the response limit before I could finish. Continue and I'll pick up from where I stopped.", retryable: true, errorCategory: "max_tokens_reached", }; } /** * Build a `conversation_error` server message from a classified error. */ export function buildConversationErrorMessage( conversationId: string, classified: ClassifiedConversationError, ): ConversationErrorEvent { return { type: "conversation_error", conversationId, code: classified.code, userMessage: classified.userMessage, retryable: classified.retryable, debugDetails: classified.debugDetails, errorCategory: classified.errorCategory, // Optional attribution — only forwarded when the classifier was able // to pin the failure to a specific provider connection / profile. // The macOS chat banner reads these to render targeted CTAs. ...(classified.connectionName ? { connectionName: classified.connectionName } : {}), ...(classified.profileName ? { profileName: classified.profileName } : {}), }; }