Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion apps/desktop/src/main/ipc/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>;
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;
Expand All @@ -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<string, unknown>;
if (upstreamStatus !== undefined && errAsRec['upstream_status'] === undefined) {
Expand All @@ -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,
Expand Down
27 changes: 24 additions & 3 deletions apps/desktop/src/renderer/src/store/slices/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export function extractUpstreamContext(err: unknown): Record<string, unknown> |
'retry_count',
'redacted_body_head',
'original_error_name',
'upstream_recovery_category',
];
const out: Record<string, unknown> = {};
for (const key of keys) {
Expand Down Expand Up @@ -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,
Expand All @@ -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 } : {}),
Expand All @@ -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;
}

Expand Down Expand Up @@ -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');
}
181 changes: 178 additions & 3 deletions packages/providers/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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***';
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -163,21 +192,167 @@ function extractErrorName(err: unknown, errRec: Record<string, unknown>): 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,
),
};
}
6 changes: 6 additions & 0 deletions packages/providers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/providers/src/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>);
} else {
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading