-
Notifications
You must be signed in to change notification settings - Fork 30
fix(agent): normalize provider failures #907
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
16edb0c
b3969c7
b13de4e
ba738ac
7caec3d
4a6654c
9c8cade
774bbca
404cf26
22698f0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,244 @@ | ||
| export type ProviderErrorKind = | ||
| | "auth" | ||
| | "permission" | ||
| | "rate_limit" | ||
| | "capacity" | ||
| | "timeout" | ||
| | "network" | ||
| | "server" | ||
| | "invalid_request" | ||
| | "quota" | ||
| | "content_policy" | ||
| | "unknown"; | ||
|
|
||
| interface ProviderErrorContext { | ||
| modelId?: string; | ||
| } | ||
|
|
||
| interface ProviderErrorDetails extends ProviderErrorContext { | ||
| kind: ProviderErrorKind; | ||
| status?: number; | ||
| retryable: boolean; | ||
| retryAfterMs?: number; | ||
| fallbackEligible: boolean; | ||
| } | ||
|
|
||
| /** Structured failure produced at an AI provider boundary. */ | ||
| export class ProviderError extends Error { | ||
| readonly code = "provider_error"; | ||
| readonly kind: ProviderErrorKind; | ||
| readonly status?: number; | ||
| readonly modelId?: string; | ||
| readonly retryable: boolean; | ||
| readonly retryAfterMs?: number; | ||
| readonly fallbackEligible: boolean; | ||
|
|
||
| constructor(message: string, details: ProviderErrorDetails, cause?: unknown) { | ||
| super(`AI provider error: ${message || "Unknown provider error"}`, { | ||
| cause, | ||
| }); | ||
| this.name = "ProviderError"; | ||
| this.kind = details.kind; | ||
| this.status = details.status; | ||
| this.modelId = details.modelId; | ||
| this.retryable = details.retryable; | ||
| this.retryAfterMs = details.retryAfterMs; | ||
| this.fallbackEligible = details.fallbackEligible; | ||
| } | ||
| } | ||
|
|
||
| type ErrorShape = Error & { | ||
| status?: unknown; | ||
| statusCode?: unknown; | ||
| responseHeaders?: unknown; | ||
| response?: { headers?: unknown }; | ||
| }; | ||
|
|
||
| function providerMessage(error: unknown): string { | ||
| return (error instanceof Error ? error.message : String(error)).trim(); | ||
| } | ||
|
|
||
| function extractStatus(error: unknown, message: string): number | undefined { | ||
| if (error instanceof Error) { | ||
| const shaped = error as ErrorShape; | ||
| if (typeof shaped.status === "number") return shaped.status; | ||
| if (typeof shaped.statusCode === "number") return shaped.statusCode; | ||
| } | ||
| const match = message.match( | ||
| /^(?:Error:\s*)?([45]\d\d)\b|\bstatus(?:Code)?["'=:\s]+([45]\d\d)\b/i, | ||
| ); | ||
| const status = match?.[1] ?? match?.[2]; | ||
| return status ? Number(status) : undefined; | ||
| } | ||
|
|
||
| function extractField(message: string, field: string): string | undefined { | ||
| return message.match(new RegExp(`"${field}"\\s*:\\s*"([^"]+)"`, "i"))?.[1]; | ||
| } | ||
|
|
||
| function extractRetryAfterMs( | ||
| error: unknown, | ||
| message: string, | ||
| ): number | undefined { | ||
| const headers = | ||
| error instanceof Error | ||
| ? ((error as ErrorShape).responseHeaders ?? | ||
| (error as ErrorShape).response?.headers) | ||
| : undefined; | ||
| const retryAfter = | ||
| headers instanceof Headers | ||
| ? headers.get("retry-after") | ||
| : headers && typeof headers === "object" | ||
| ? Object.entries(headers as Record<string, unknown>).find( | ||
| ([name]) => name.toLowerCase() === "retry-after", | ||
| )?.[1] | ||
| : undefined; | ||
| const raw = | ||
| typeof retryAfter === "string" || typeof retryAfter === "number" | ||
| ? String(retryAfter) | ||
| : message.match(/"retry-after"\s*:\s*"?([^",}]+)"?/i)?.[1]; | ||
| if (!raw) return undefined; | ||
| const seconds = Number(raw); | ||
| if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000; | ||
| const date = Date.parse(raw); | ||
| return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now()); | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| function classifyProviderError( | ||
| status: number | undefined, | ||
| message: string, | ||
| ): ProviderErrorKind { | ||
| if (/content.?policy|safety|moderation/i.test(message)) | ||
| return "content_policy"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Overbroad safety error classificationMedium Severity
Reviewed by Cursor Bugbot for commit 22698f0. Configure here. |
||
| if ( | ||
| /insufficient.?quota|quota exceeded|usage limit|monthly usage limit|available balance|out of budget|billing/i.test( | ||
| message, | ||
| ) | ||
| ) | ||
| return "quota"; | ||
| if ( | ||
| status === 401 || | ||
| /invalid.?api.?key|no api key|authentication|(?:invalid|missing|expired|revoked).{0,24}\bcredentials?\b|\bcredentials?\b.{0,24}(?:invalid|missing|expired|revoked)|\bno\b.{0,16}\bcredentials?\b/i.test( | ||
| message, | ||
| ) | ||
| ) | ||
| return "auth"; | ||
| if (status === 403 || /permission|forbidden|authorization/i.test(message)) | ||
| return "permission"; | ||
| if (status === 429 || /rate.?limit|too many requests/i.test(message)) | ||
| return "rate_limit"; | ||
| if (/overloaded|at capacity|capacity exceeded/i.test(message)) | ||
| return "capacity"; | ||
| if (/timed? out|timeout/i.test(message)) return "timeout"; | ||
| if ( | ||
| /network.?error|connection|fetch failed|socket|stream ended|ended before|ECONNRESET/i.test( | ||
| message, | ||
| ) | ||
| ) | ||
| return "network"; | ||
| if (status !== undefined && status >= 500) return "server"; | ||
| if ( | ||
| /service.?unavailable|internal server error|bad gateway|gateway timeout|upstream (?:request )?failed|unexpected error occurred/i.test( | ||
| message, | ||
| ) | ||
| ) | ||
| return "server"; | ||
| if (status !== undefined && status >= 400) return "invalid_request"; | ||
| if ( | ||
| /context.?length|context.?window|validation|bad request|unsupported model|invalid model|unknown (?:ai gateway )?model|mismatched api/i.test( | ||
| message, | ||
| ) | ||
| ) | ||
| return "invalid_request"; | ||
| return "unknown"; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** Normalize SDK, gateway, and Pi failures into one provider error contract. */ | ||
| export function createProviderError( | ||
| error: unknown, | ||
| context: ProviderErrorContext = {}, | ||
| ): ProviderError { | ||
| if (error instanceof ProviderError) return error; | ||
| const message = providerMessage(error); | ||
| const status = extractStatus(error, message); | ||
| const kind = classifyProviderError(status, message); | ||
| return new ProviderError( | ||
| message, | ||
| { | ||
| kind, | ||
| status, | ||
| modelId: context.modelId ?? extractField(message, "originalModelId"), | ||
| retryable: | ||
| Boolean(message) && | ||
| ["rate_limit", "capacity", "timeout", "network", "server"].includes( | ||
| kind, | ||
| ), | ||
| retryAfterMs: extractRetryAfterMs(error, message), | ||
| fallbackEligible: ["capacity", "timeout", "network", "server"].includes( | ||
| kind, | ||
| ), | ||
| }, | ||
| error, | ||
| ); | ||
| } | ||
|
|
||
| /** Return whether a provider-boundary error should be retried. */ | ||
| export function isProviderRetryError(error: unknown): error is ProviderError { | ||
| return error instanceof ProviderError && error.retryable; | ||
| } | ||
|
|
||
| /** Classify a terminal Pi assistant error through the provider error contract. */ | ||
| export function isRetryableProviderMessage(message: { | ||
| stopReason?: string; | ||
| errorMessage?: string; | ||
| }): boolean { | ||
| return ( | ||
| message.stopReason === "error" && | ||
| Boolean(message.errorMessage) && | ||
| createProviderError(message.errorMessage).retryable | ||
| ); | ||
| } | ||
|
|
||
| /** Return stable, sanitized copy suitable for a terminal user response. */ | ||
| export function getProviderErrorUserMessage(error: ProviderError): string { | ||
| switch (error.kind) { | ||
| case "capacity": | ||
| return "The selected model is temporarily unavailable because it is at capacity. Please try again shortly."; | ||
| case "rate_limit": { | ||
| const seconds = error.retryAfterMs | ||
| ? Math.max(1, Math.ceil(error.retryAfterMs / 1_000)) | ||
| : undefined; | ||
| return seconds | ||
| ? `The model is rate-limited. Please try again in about ${seconds} seconds.` | ||
| : "The model is rate-limited. Please try again shortly."; | ||
| } | ||
| case "timeout": | ||
| case "network": | ||
| case "server": | ||
| return "The model provider had a temporary connection problem. Please try again."; | ||
| case "auth": | ||
| case "permission": | ||
| return "The model provider rejected Junior's credentials. This needs an administrator or configuration fix."; | ||
| case "quota": | ||
| return "The model provider's usage quota has been exhausted. This needs an administrator or billing configuration fix."; | ||
| default: | ||
| return ""; | ||
| } | ||
| } | ||
|
|
||
| /** Return safe structured fields for terminal failure telemetry. */ | ||
| export function getProviderErrorAttributes( | ||
| error: ProviderError, | ||
| ): Record<string, unknown> { | ||
| return { | ||
| "app.ai.provider_error.kind": error.kind, | ||
| "app.ai.provider_error.retryable": error.retryable, | ||
| "app.ai.provider_error.fallback_eligible": error.fallbackEligible, | ||
| ...(error.status !== undefined | ||
| ? { "app.ai.provider_error.status": error.status } | ||
| : {}), | ||
| ...(error.retryAfterMs !== undefined | ||
| ? { "app.ai.provider_error.retry_after_ms": error.retryAfterMs } | ||
| : {}), | ||
| ...(error.modelId ? { "gen_ai.request.model": error.modelId } : {}), | ||
| }; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thrown failures skip normalization
Medium Severity
The agent catch path stores the raw thrown value as
providerError, whilefinalizeFailedTurnReplyonly emits actionable copy and provider attributes when that value is aProviderError.buildTurnResultnormalizes viacreateProviderError, so transport throws that never become assistantstopReason: "error"messages still get the generic terminal reply and empty provider telemetry.Additional Locations (2)
packages/junior/src/chat/services/turn-failure-response.ts#L147-L154packages/junior/src/chat/services/turn-result.ts#L390-L394Reviewed by Cursor Bugbot for commit 22698f0. Configure here.