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
30 changes: 27 additions & 3 deletions services/platform/convex/lib/agent_chat/internal_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string>();
// 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
Expand Down Expand Up @@ -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',
Expand All @@ -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;

Expand Down Expand Up @@ -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) };
Expand Down
82 changes: 82 additions & 0 deletions services/platform/convex/providers/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand Down
43 changes: 43 additions & 0 deletions services/platform/convex/providers/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

/**
Expand Down Expand Up @@ -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}`),
]);
}
9 changes: 5 additions & 4 deletions services/platform/convex/providers/file_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ProviderWithSecrets[]> {
Expand Down