From b7389b6258e6dc163fa662e92e72d4a2ead10fa1 Mon Sep 17 00:00:00 2001 From: Tale Agent Date: Tue, 23 Jun 2026 11:38:32 +0000 Subject: [PATCH] fix(platform): surface actionable error when whole fallback chain is unconfigured (#1455) --- .../convex/lib/agent_chat/internal_actions.ts | 30 ++++++- .../platform/convex/providers/errors.test.ts | 82 +++++++++++++++++++ services/platform/convex/providers/errors.ts | 43 ++++++++++ .../platform/convex/providers/file_actions.ts | 9 +- 4 files changed, 157 insertions(+), 7 deletions(-) diff --git a/services/platform/convex/lib/agent_chat/internal_actions.ts b/services/platform/convex/lib/agent_chat/internal_actions.ts index fa95e74ef..4494c7591 100644 --- a/services/platform/convex/lib/agent_chat/internal_actions.ts +++ b/services/platform/convex/lib/agent_chat/internal_actions.ts @@ -31,6 +31,7 @@ import { routeSeedValidator, routeTuningValidator } from '../../agents/schema'; import { resolveOrgSlug } from '../../organizations/resolve_org_slug'; import { recordFailure } from '../../providers/circuit_breaker'; import { + buildChainExhaustionError, classifyFailureScope, isTransientProviderError, } from '../../providers/errors'; @@ -511,6 +512,12 @@ export async function runGenerationCore( // model with its own `secretsEnv` key is still tried after another key dies. let lastFallbackError: unknown; const deadScopes = new Set(); + // Resolution (config) failures collected across the chain. When NO model + // ever reaches a live provider call (`attemptedCount === 0`) these are the + // whole story, and the chain collapses into one actionable + // "configure a provider" error instead of the last entry's per-model + // message (issue #1455). + const configFailures: Array<{ model: string; message: string }> = []; // RESOLUTION (no HTTP) is memoized per model index so the catch can look // ahead for the next attemptable model without paying for it twice. A @@ -568,6 +575,13 @@ export async function runGenerationCore( const resolution = await resolveAt(attempt); if (!resolution.ok) { lastFallbackError = resolution.error; + configFailures.push({ + model: currentModelId ?? 'default', + message: + resolution.error instanceof Error + ? resolution.error.message + : String(resolution.error), + }); if (attempt < modelsToTry.length - 1) { debugLog('SKIP_UNCONFIGURED_MODEL', { model: currentModelId ?? 'default', @@ -579,7 +593,10 @@ export async function runGenerationCore( }); continue; } - throw resolution.error; + // Last entry also failed to resolve. Fall through to the unified + // exhaustion handler below so an all-unconfigured chain surfaces one + // actionable error rather than this tail model's per-model message. + break; } const resolved = resolution.resolved; @@ -959,8 +976,15 @@ export async function runGenerationCore( } } - // Should not reach here, but satisfy TypeScript - throw lastFallbackError ?? new Error('No model could be resolved'); + // Chain exhausted. When no model ever reached a live provider call, every + // failure was a config/resolution error — collapse the chain into a single + // actionable "configure a provider" error instead of the tail model's + // per-model message (issue #1455). Otherwise surface the real last cause. + throw buildChainExhaustionError({ + attemptedCount, + configFailures, + lastError: lastFallbackError, + }); } catch (error) { // Log full error details for debugging const err = isRecord(error) ? error : { message: String(error) }; diff --git a/services/platform/convex/providers/errors.test.ts b/services/platform/convex/providers/errors.test.ts index 43c0c6fce..d6c4478b1 100644 --- a/services/platform/convex/providers/errors.test.ts +++ b/services/platform/convex/providers/errors.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; +import { classifyChatErrorCode } from '../../lib/shared/chat-errors'; import { + buildChainExhaustionError, classifyFailureScope, + FRIENDLY_NO_PROVIDER, isTransientProviderError, MissingApiKeyError, NoProviderAvailableError, @@ -347,6 +350,85 @@ describe('ProviderUnavailableError', () => { }); }); +describe('buildChainExhaustionError', () => { + const missingKey = (model: string) => ({ + model, + message: `No API key configured for model "${model}"`, + }); + + it('collapses an all-unconfigured chain into one actionable NoProviderAvailableError (issue #1455)', () => { + const result = buildChainExhaustionError({ + attemptedCount: 0, + configFailures: [ + missingKey('gemini'), + missingKey('mistral-large'), + missingKey('qwen-next'), + ], + lastError: new MissingApiKeyError('qwen', 'qwen-next'), + }); + expect(result).toBeInstanceOf(NoProviderAvailableError); + const err = result as NoProviderAvailableError; + expect(err.reason).toBe('missing_api_key'); + expect(err.message).toBe(FRIENDLY_NO_PROVIDER); + // Every failed model is preserved in the details for debugging. + expect(err.details.join('\n')).toContain('gemini'); + expect(err.details.join('\n')).toContain('qwen-next'); + // And the UI maps it to the actionable setup hint, not a tail-model error. + expect(classifyChatErrorCode(result)).toBe('missing_api_key'); + }); + + it('surfaces the genuine runtime error once a model reached the provider', () => { + const runtimeError = new ProviderUnavailableError( + 'overloaded', + 'openrouter', + 'gemini', + 503, + ); + const result = buildChainExhaustionError({ + attemptedCount: 1, + configFailures: [missingKey('qwen-next')], + lastError: runtimeError, + }); + // A real attempt happened — keep the true cause, do not collapse to config. + expect(result).toBe(runtimeError); + }); + + it('passes an already-terminal NoProviderAvailableError through untouched', () => { + const terminal = new NoProviderAvailableError( + FRIENDLY_NO_PROVIDER, + 'no_providers', + ['directory missing'], + ); + const result = buildChainExhaustionError({ + attemptedCount: 0, + configFailures: [], + lastError: terminal, + }); + expect(result).toBe(terminal); + }); + + it('falls back to the last error when there is nothing to collapse', () => { + const last = new Error('weird'); + expect( + buildChainExhaustionError({ + attemptedCount: 0, + configFailures: [], + lastError: last, + }), + ).toBe(last); + }); + + it('synthesizes a generic error when no cause is available at all', () => { + const result = buildChainExhaustionError({ + attemptedCount: 0, + configFailures: [], + lastError: undefined, + }); + expect(result).toBeInstanceOf(Error); + expect((result as Error).message).toBe('No model could be resolved'); + }); +}); + describe('NoProviderAvailableError', () => { it('stores reason and details', () => { const err = new NoProviderAvailableError( diff --git a/services/platform/convex/providers/errors.ts b/services/platform/convex/providers/errors.ts index 668149fa2..ea53025e8 100644 --- a/services/platform/convex/providers/errors.ts +++ b/services/platform/convex/providers/errors.ts @@ -79,6 +79,16 @@ export class MissingApiKeyError extends Error { } } +/** + * User-facing copy for "this org has no usable AI provider". Shared by the + * provider loader (which throws {@link NoProviderAvailableError} eagerly) and + * the agent fallback chain (which collapses an all-unconfigured chain into the + * same actionable error — see {@link buildChainExhaustionError}). One sentence, + * one source of truth so the two paths can never drift. + */ +export const FRIENDLY_NO_PROVIDER = + 'No API key is configured for this organization yet. Open Settings → AI providers and add one to start chatting.'; + const TRANSIENT_STATUS_CODES = new Set([429, 502, 503, 504]); /** @@ -275,3 +285,36 @@ export function classifyFailureScope( ? 'provider' : 'model'; } + +/** + * Pick the terminal error to surface when the agent fallback chain is exhausted + * without any model producing a response (issue #1455). + * + * - `attemptedCount > 0` — at least one model reached a live provider call, so + * the last caught error (a transient/runtime failure) is the genuine cause + * and is surfaced unchanged. + * - `attemptedCount === 0` — NO model ever reached a provider; every entry + * failed during resolution for a configuration reason (missing API key, + * unconfigured model/provider). Throwing the LAST entry's per-model error + * misleads the user into thinking only that one model is broken, when the + * whole chain is unconfigured. Collapse it into a single actionable + * {@link NoProviderAvailableError} so the chat surface renders the "configure + * a provider" hint (`classifyChatErrorCode` → `missing_api_key`) instead of a + * confusing tail-model message. An error that is already a + * NoProviderAvailableError is passed through untouched. + */ +export function buildChainExhaustionError(opts: { + attemptedCount: number; + configFailures: ReadonlyArray<{ model: string; message: string }>; + lastError: unknown; +}): unknown { + const { attemptedCount, configFailures, lastError } = opts; + const fallback = lastError ?? new Error('No model could be resolved'); + if (attemptedCount > 0) return fallback; + if (lastError instanceof NoProviderAvailableError) return lastError; + if (configFailures.length === 0) return fallback; + return new NoProviderAvailableError(FRIENDLY_NO_PROVIDER, 'missing_api_key', [ + `All ${configFailures.length} model(s) in the fallback chain are unconfigured:`, + ...configFailures.map((f) => `${f.model}: ${f.message}`), + ]); +} diff --git a/services/platform/convex/providers/file_actions.ts b/services/platform/convex/providers/file_actions.ts index 9a34b0a64..18bb02658 100644 --- a/services/platform/convex/providers/file_actions.ts +++ b/services/platform/convex/providers/file_actions.ts @@ -58,7 +58,11 @@ import { requireOrgMembership, requireOrgMembershipById, } from './auth'; -import { MissingApiKeyError, NoProviderAvailableError } from './errors'; +import { + FRIENDLY_NO_PROVIDER, + MissingApiKeyError, + NoProviderAvailableError, +} from './errors'; import type { ProviderJson, ProviderReadResult } from './file_utils'; import { MAX_FILE_SIZE_BYTES, @@ -421,9 +425,6 @@ interface ProviderWithSecrets { secrets: ProviderSecrets | null; } -const FRIENDLY_NO_PROVIDER = - 'No API key is configured for this organization yet. Open Settings → AI providers and add one to start chatting.'; - async function loadAllProviders( orgSlug: string, ): Promise {