diff --git a/apps/desktop/src/main/ipc/generate.ts b/apps/desktop/src/main/ipc/generate.ts index 1e977931..9ac863e3 100644 --- a/apps/desktop/src/main/ipc/generate.ts +++ b/apps/desktop/src/main/ipc/generate.ts @@ -20,7 +20,12 @@ import { routeRunPreferences, updateDesignSessionBrief, } from '@open-codesign/core'; -import { complete, detectProviderFromKey, generateImage } from '@open-codesign/providers'; +import { + classifyRecoveryCategory, + complete, + detectProviderFromKey, + generateImage, +} from '@open-codesign/providers'; import { ApplyCommentPayload, CancelGenerationPayloadV1, @@ -251,6 +256,21 @@ function extractUpstreamHttpStatus(err: unknown): number | undefined { return undefined; } +/** + * Pull a system-level error code (e.g. 'ECONNREFUSED', 'ETIMEDOUT') + * from an error object so recovery classification can detect network issues. + */ +function extractUpstreamSystemCode(err: unknown): string | undefined { + if (typeof err !== 'object' || err === null) return undefined; + const rec = err as Record; + const direct = rec['code']; + if (typeof direct === 'string' && direct.length > 0) return direct; + if (err instanceof Error && (err as NodeJS.ErrnoException).code !== undefined) { + return (err as NodeJS.ErrnoException).code; + } + return undefined; +} + function normalizeGenerationFailure(opts: { err: unknown; signal: AbortSignal; @@ -260,6 +280,14 @@ function normalizeGenerationFailure(opts: { wire: string | undefined; }): { error: unknown; upstreamStatus: number | undefined } { const upstreamStatus = extractUpstreamHttpStatus(opts.err); + const upstreamMessage = opts.err instanceof Error ? opts.err.message : String(opts.err ?? ''); + const upstreamCode = extractUpstreamSystemCode(opts.err); + const recoveryCategory = classifyRecoveryCategory( + upstreamStatus, + upstreamCode, + upstreamMessage, + opts.wire, + ); if (opts.err !== null && typeof opts.err === 'object') { const errAsRec = opts.err as Record; if (upstreamStatus !== undefined && errAsRec['upstream_status'] === undefined) { @@ -277,6 +305,9 @@ function normalizeGenerationFailure(opts: { if (errAsRec['upstream_wire'] === undefined && opts.wire !== undefined) { errAsRec['upstream_wire'] = opts.wire; } + if (errAsRec['upstream_recovery_category'] === undefined) { + errAsRec['upstream_recovery_category'] = recoveryCategory; + } } return { error: extractGenerationTimeoutError(opts.signal) ?? opts.err, diff --git a/apps/desktop/src/renderer/src/store/slices/errors.ts b/apps/desktop/src/renderer/src/store/slices/errors.ts index 08217e6f..83eca75d 100644 --- a/apps/desktop/src/renderer/src/store/slices/errors.ts +++ b/apps/desktop/src/renderer/src/store/slices/errors.ts @@ -104,6 +104,7 @@ export function extractUpstreamContext(err: unknown): Record | 'retry_count', 'redacted_body_head', 'original_error_name', + 'upstream_recovery_category', ]; const out: Record = {}; for (const key of keys) { @@ -148,6 +149,7 @@ export function pickUpstreamString(err: unknown, key: string): string | undefine return typeof v === 'string' && v.length > 0 ? v : undefined; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: existing complexity, see #118 export function deriveGenerateHypothesis( err: unknown, cfg: OnboardingState | null, @@ -159,6 +161,7 @@ export function deriveGenerateHypothesis( const code = extractCodesignErrorCode(err); const status = extractGenerateStatus(err); const message = err instanceof Error ? err.message : undefined; + const recoveryCategory = pickUpstreamString(err, 'upstream_recovery_category'); const ctx = { provider, ...(baseUrl !== undefined && baseUrl !== null ? { baseUrl } : {}), @@ -173,8 +176,14 @@ export function deriveGenerateHypothesis( // Skip the bare "unknown" hypothesis — appending "Unknown error" to a // toast that already shows the upstream message is just noise. if (primary === undefined || primary.cause === 'diagnostics.cause.unknown') { + if (recoveryCategory && recoveryCategory !== 'unknown') { + return { cause: 'diagnostics.cause.unknown', recoveryCategory }; + } return undefined; } + if (recoveryCategory && recoveryCategory !== 'unknown') { + primary.recoveryCategory = recoveryCategory; + } return primary; } @@ -227,10 +236,22 @@ export function buildGenerateErrorDescription( hypothesis: DiagnosticHypothesis | undefined, ): string { if (hypothesis === undefined) return originalMessage; + const parts: string[] = [originalMessage]; const hint = tr(hypothesis.cause); // When the i18n key was missing, tr() falls back to returning the key // itself; don't double up "diagnostics.cause.x" in the toast. - if (hint === hypothesis.cause) return originalMessage; - if (originalMessage.includes(hint)) return originalMessage; - return `${originalMessage}\n\n${tr('diagnostics.mostLikelyCause')} ${hint}`; + if (hint !== hypothesis.cause && !originalMessage.includes(hint)) { + parts.push(`${tr('diagnostics.mostLikelyCause')} ${hint}`); + } + if (hypothesis.recoveryCategory && hypothesis.recoveryCategory !== 'unknown') { + const categoryLabel = hypothesis.recoveryCategory + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) + .replace(/\bTls\b/g, 'TLS') + .replace(/\bApi\b/g, 'API') + .replace(/\bSsl\b/g, 'SSL') + .replace(/\bUrl\b/g, 'URL'); + parts.push(`Recovery: ${categoryLabel}`); + } + return parts.join('\n\n'); } diff --git a/packages/providers/src/errors.ts b/packages/providers/src/errors.ts index 21340475..4a4151dc 100644 --- a/packages/providers/src/errors.ts +++ b/packages/providers/src/errors.ts @@ -11,6 +11,8 @@ * lines is fine", the duplication is intentional. */ +import { looksLikeGatewayMissingMessagesApi } from './gateway-compat'; + const API_KEY_RE = /(sk-[A-Za-z0-9-_]{20,}|AIzaSy[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|[A-Za-z0-9+/]{43}=|[A-Fa-f0-9]{32,}|Bearer\s+[A-Za-z0-9._~+/=-]+)/g; const REDACTION = '***REDACTED***'; @@ -24,6 +26,31 @@ const REQUEST_ID_KEYS = [ 'x-amzn-requestid', ]; +/** + * Stable taxonomy for recovery actions. When the UI receives a + * NormalizedProviderError, it can use this category to show a targeted + * hint ("Check your API key", "Try adding /v1", etc.) instead of a + * generic "something went wrong" message. + */ +export type RecoveryCategory = + | 'auth_key_invalid' + | 'auth_key_expired' + | 'auth_permission' + | 'endpoint_not_found' + | 'endpoint_missing_v1' + | 'wire_incompatible' + | 'gateway_incompatible' + | 'model_not_found' + | 'model_not_supported_role' + | 'rate_limit' + | 'billing' + | 'network_unreachable' + | 'network_timeout' + | 'upstream_server_error' + | 'request_too_large' + | 'tls_error' + | 'unknown'; + export interface NormalizedProviderError { upstream_provider: string; upstream_status: number | undefined; @@ -33,6 +60,8 @@ export interface NormalizedProviderError { retry_count: number; redacted_body_head: string | undefined; original_error_name: string; + /** Machine-readable recovery category so the UI can show a targeted hint. */ + recovery_category: RecoveryCategory; } function scrub(s: string): string { @@ -163,21 +192,167 @@ function extractErrorName(err: unknown, errRec: Record): string return 'UnknownError'; } +/** + * Classify a provider error into a recovery category so the UI can show + * a targeted hint instead of a generic failure message. + * + * Priority: status code > message patterns > network/system code > fallback. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: deliberate per-status/message classification switch +export function classifyRecoveryCategory( + status: number | undefined, + code: string | undefined, + message: string, + wire?: string | undefined, +): RecoveryCategory { + const msg = message.toLowerCase(); + const sysCode = (code ?? '').toLowerCase(); + + if (sysCode === 'econnrefused' || sysCode === 'enotfound' || sysCode === 'econnreset') { + return 'network_unreachable'; + } + if (sysCode === 'etimedout' || sysCode === 'eai_again') { + return 'network_timeout'; + } + if (sysCode.includes('ssl') || sysCode.includes('cert') || sysCode.includes('tls')) { + return 'tls_error'; + } + + if (status !== undefined) { + if (status === 401) { + if (msg.includes('expired') || msg.includes('revoked')) return 'auth_key_expired'; + if (msg.includes('permission') || msg.includes('insufficient')) return 'auth_permission'; + return 'auth_key_invalid'; + } + if (status === 403) { + if (msg.includes('permission') || msg.includes('insufficient')) return 'auth_permission'; + return 'auth_key_invalid'; + } + if (status === 402) return 'billing'; + if (status === 404) { + if (msg.includes('model')) return 'model_not_found'; + return 'endpoint_not_found'; + } + if (status === 413 || (status === 400 && msg.includes('too large'))) { + return 'request_too_large'; + } + if (status === 429) return 'rate_limit'; + if (status >= 500 && status < 600) { + if (msg.includes('not implemented') || msg.includes('unsupported')) { + return 'gateway_incompatible'; + } + return 'upstream_server_error'; + } + } + + if (msg.includes('not implemented') || msg.includes('unsupported') || msg.includes('501')) { + return 'gateway_incompatible'; + } + if ( + msg.includes('model') && + (msg.includes('not found') || msg.includes('not exist') || msg.includes('does not exist')) + ) { + return 'model_not_found'; + } + if ( + msg.includes('developer role') || + msg.includes('system role') || + msg.includes('unsupported role') + ) { + return 'model_not_supported_role'; + } + if (msg.includes('quota') || msg.includes('billing') || msg.includes('credit')) { + return 'billing'; + } + if (msg.includes('rate limit') || msg.includes('too many requests')) { + return 'rate_limit'; + } + if (msg.includes('expired') || msg.includes('revoked')) { + return 'auth_key_expired'; + } + if ( + msg.includes('invalid') && + (msg.includes('api key') || msg.includes('apikey') || msg.includes('auth')) + ) { + return 'auth_key_invalid'; + } + + if (wire === 'anthropic' && looksLikeGatewayMissingMessagesApi({ message } as Error)) { + return 'gateway_incompatible'; + } + + return 'unknown'; +} + +/** + * Return a user-facing recovery hint for a given recovery category. + * The returned text is English-only; downstream UI layers may optionally + * map these to i18n keys. + */ +export function recoveryHintFor(category: RecoveryCategory): string { + switch (category) { + case 'auth_key_invalid': + return 'Check your API key is correct and active in provider settings'; + case 'auth_key_expired': + return 'Your API key may have expired — generate a new one and update provider settings'; + case 'auth_permission': + return 'Your API key may lack required permissions — check provider dashboard'; + case 'endpoint_not_found': + return 'Verify your base URL is correct (typo, wrong path, or wrong port)'; + case 'endpoint_missing_v1': + return 'Try adding /v1 to your base URL — many OpenAI-compatible gateways require it'; + case 'wire_incompatible': + return 'Try switching wire type in advanced provider settings'; + case 'gateway_incompatible': + return 'This gateway does not support the selected wire — try switching to a compatible wire or a different provider'; + case 'model_not_found': + return 'Check model ID spelling or try listing available models from your provider'; + case 'model_not_supported_role': + return 'The selected model does not support the developer/system role — try a different model or disable reasoning'; + case 'rate_limit': + return 'Rate limited — wait a moment and retry, or upgrade your provider plan'; + case 'billing': + return 'Check your billing status and quota in your provider dashboard'; + case 'network_unreachable': + return 'Cannot reach the server — check your network, base URL, and firewall settings'; + case 'network_timeout': + return 'Connection timed out — check your network, or increase timeout in advanced settings'; + case 'upstream_server_error': + return 'Upstream server error — the provider may be experiencing issues, try again later'; + case 'request_too_large': + return 'Request payload too large — reduce attachment size, prompt length, or conversation history'; + case 'tls_error': + return 'SSL/TLS certificate error — check TLS settings in advanced provider configuration'; + default: + return 'An unexpected error occurred — check the diagnostics panel for details'; + } +} + export function normalizeProviderError( err: unknown, provider: string, retryCount: number, + wire?: string | undefined, ): NormalizedProviderError { const rec = asRecord(err) ?? {}; const rawMessage = extractMessage(err, rec); + const upstreamStatus = extractStatus(rec); + const upstreamCode = extractCode(rec); + const upstreamMessage = scrub(rawMessage); return { upstream_provider: provider, - upstream_status: extractStatus(rec), - upstream_code: extractCode(rec), - upstream_message: scrub(rawMessage), + upstream_status: upstreamStatus, + upstream_code: upstreamCode, + upstream_message: upstreamMessage, upstream_request_id: extractRequestId(rec), retry_count: retryCount, redacted_body_head: extractBodyHead(rec), original_error_name: extractErrorName(err, rec), + recovery_category: classifyRecoveryCategory( + upstreamStatus, + upstreamCode, + upstreamMessage, + wire, + ), }; } diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index 34cfdf70..72f0f3ab 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -584,6 +584,12 @@ export { shouldForceClaudeCodeIdentity, withClaudeCodeIdentity, } from './claude-code-compat'; +export type { NormalizedProviderError, RecoveryCategory } from './errors'; +export { + classifyRecoveryCategory, + normalizeProviderError, + recoveryHintFor, +} from './errors'; export { looksLikeGatewayMissingMessagesApi } from './gateway-compat'; export { isGeminiOpenAICompat, normalizeGeminiModelId } from './gemini-compat'; export type { diff --git a/packages/providers/src/retry.ts b/packages/providers/src/retry.ts index b192301c..d6d635be 100644 --- a/packages/providers/src/retry.ts +++ b/packages/providers/src/retry.ts @@ -364,7 +364,7 @@ export async function completeWithRetry( classify: (err) => { const decision = classifyError(err, retryOpts.wire); const retryCount = Math.max(0, attemptForLog - 1); - const normalized = normalizeProviderError(err, provider, retryCount); + const normalized = normalizeProviderError(err, provider, retryCount, retryOpts.wire); if (shouldStop(decision, attemptForLog, maxRetries)) { logger?.warn('provider.error.final', normalized as unknown as Record); } else { diff --git a/packages/shared/src/diagnostics.ts b/packages/shared/src/diagnostics.ts index bf3008f0..dda8039f 100644 --- a/packages/shared/src/diagnostics.ts +++ b/packages/shared/src/diagnostics.ts @@ -81,6 +81,8 @@ export interface DiagnosticHypothesis { severity?: DiagnosticSeverity; /** Primary action the user should take */ suggestedFix?: DiagnosticFix; + /** Machine-readable recovery category from provider error normalization. */ + recoveryCategory?: string; } export interface DiagnoseContext {