diff --git a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts index 3cff92ae95..09d5cab951 100644 --- a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts +++ b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts @@ -793,6 +793,7 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => { it.each([ 'No allowed providers are specified.', 'No allowed providers are available for the selected model.', + 'Not Found: {"error":"No eligible provider can serve the selected model.","error_type":"provider_not_allowed","message":"No eligible provider can serve the selected model. Select another model or update the provider routing settings."}', 'No endpoints found matching your data policy (Free model training). Configure: https://openrouter.ai/settings/privacy', ])( 'infers provider-policy callbacks as selected-model action-required failures', diff --git a/apps/web/src/lib/ai-gateway/llm-proxy-helpers.test.ts b/apps/web/src/lib/ai-gateway/llm-proxy-helpers.test.ts index 05b6324edc..f429448ca7 100644 --- a/apps/web/src/lib/ai-gateway/llm-proxy-helpers.test.ts +++ b/apps/web/src/lib/ai-gateway/llm-proxy-helpers.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from '@jest/globals'; import type { MicrodollarUsageContext, MicrodollarUsageStats } from './processUsage.types'; +import type { GatewayRequest } from './providers/openrouter/types'; // `countAndStoreEditUsage` schedules the usage write through `next/server`'s // `after()` post-response hook, which only works in a request context. Replace @@ -697,17 +698,132 @@ describe('parseTranscriptionUsageFromResponse', () => { }); describe('makeErrorReadable', () => { + const request = { + kind: 'chat_completions', + body: { model: 'test', messages: [], stream: false }, + } satisfies GatewayRequest; + it('returns undefined for non-error responses', async () => { const response = new Response('{}', { status: 200 }); const result = await makeErrorReadable({ providerId: 'openrouter', requestedModel: 'anything', - request: { kind: 'chat_completions', body: { model: 'test', messages: [] } }, + request, response, isUserByok: false, }); expect(result).toBeUndefined(); }); + + it('returns an actionable error when no allowed provider serves the model', async () => { + const response = Response.json( + { + message: 'No allowed providers are available for the selected model.', + code: 404, + metadata: { + available_providers: ['alibaba'], + requested_providers: ['anthropic', 'openai'], + }, + }, + { status: 404 } + ); + + const result = await makeErrorReadable({ + providerId: 'openrouter', + requestedModel: 'qwen/qwen3.7-plus', + request, + response, + isUserByok: false, + }); + + expect(result?.status).toBe(404); + await expect(result?.json()).resolves.toEqual({ + error: 'No eligible provider can serve the selected model.', + error_type: 'provider_not_allowed', + message: + 'No eligible provider can serve the selected model. Select another model or update the provider routing settings.', + }); + }); + + it('recognizes the wrapped OpenRouter error response', async () => { + const response = Response.json( + { + error: { + message: 'No allowed providers are available for the selected model.', + code: 404, + metadata: { + available_providers: ['alibaba'], + requested_providers: ['openai'], + }, + }, + }, + { status: 404 } + ); + + const result = await makeErrorReadable({ + providerId: 'openrouter', + requestedModel: 'qwen/qwen3.7-plus', + request, + response, + isUserByok: false, + }); + + expect(result).toBeDefined(); + if (!result) throw new Error('Expected a readable provider error response'); + expect(result.status).toBe(404); + expect((await result.json()).error_type).toBe('provider_not_allowed'); + }); + + it('does not rewrite matching errors from other providers', async () => { + const response = Response.json( + { + message: 'No allowed providers are available for the selected model.', + code: 404, + metadata: { + available_providers: ['alibaba'], + requested_providers: ['openai'], + }, + }, + { status: 404 } + ); + + await expect( + makeErrorReadable({ + providerId: 'vercel', + requestedModel: 'anything', + request, + response, + isUserByok: false, + }) + ).resolves.toBeUndefined(); + }); + + it('leaves unrelated and malformed upstream errors unchanged', async () => { + const responses = [ + Response.json( + { + message: 'No allowed providers are available for the selected model.', + code: 404, + metadata: { available_providers: ['alibaba'] }, + }, + { status: 404 } + ), + Response.json({ message: 'Model not found', code: 404 }, { status: 404 }), + new Response('not json', { status: 404 }), + ]; + + for (const response of responses) { + await expect( + makeErrorReadable({ + providerId: 'openrouter', + requestedModel: 'anything', + request, + response, + isUserByok: false, + }) + ).resolves.toBeUndefined(); + } + }); }); describe('extractHeaderAndLimitLength', () => { diff --git a/apps/web/src/lib/ai-gateway/llm-proxy-helpers.ts b/apps/web/src/lib/ai-gateway/llm-proxy-helpers.ts index 14ae12088d..a652064944 100644 --- a/apps/web/src/lib/ai-gateway/llm-proxy-helpers.ts +++ b/apps/web/src/lib/ai-gateway/llm-proxy-helpers.ts @@ -1,4 +1,5 @@ import { after, NextResponse, type NextRequest } from 'next/server'; +import * as z from 'zod'; import { FEATURE_HEADER, type FeatureValue } from '@/lib/feature-detection'; import { countAndStoreUsage, @@ -177,6 +178,49 @@ function byokErrorMessage(status: number): string | undefined { return byokErrorMessages[status]; } +const noAllowedProvidersErrorSchema = z.object({ + message: z.literal('No allowed providers are available for the selected model.'), + code: z.literal(404), + metadata: z.object({ + available_providers: z.array(z.string()), + requested_providers: z.array(z.string()), + }), +}); + +const openRouterErrorResponseSchema = z.object({ + error: noAllowedProvidersErrorSchema.optional(), + message: z.string().optional(), + code: z.number().optional(), + metadata: z.unknown().optional(), +}); + +async function providerNotAllowedResponse(providerId: ProviderId, response: Response) { + if (providerId !== 'openrouter' || response.status !== 404) return undefined; + + let body: unknown; + try { + body = await response.clone().json(); + } catch { + return undefined; + } + + const parsedBody = openRouterErrorResponseSchema.safeParse(body); + if (!parsedBody.success) return undefined; + + const upstreamError = parsedBody.data.error ?? parsedBody.data; + if (!noAllowedProvidersErrorSchema.safeParse(upstreamError).success) return undefined; + + const error = 'No eligible provider can serve the selected model.'; + return NextResponse.json( + { + error, + error_type: ProxyErrorType.provider_not_allowed, + message: `${error} Select another model or update the provider routing settings.`, + }, + { status: response.status } + ); +} + export async function makeErrorReadable({ providerId, requestedModel, @@ -209,6 +253,9 @@ export async function makeErrorReadable({ } } + const providerErrorResponse = await providerNotAllowedResponse(providerId, response); + if (providerErrorResponse) return providerErrorResponse; + const overflowResponse = await detectContextOverflow({ requestedModel, request, response }); if (overflowResponse) return overflowResponse; diff --git a/apps/web/src/lib/code-reviews/action-required.test.ts b/apps/web/src/lib/code-reviews/action-required.test.ts index 0a21fb9611..7ae82adaf2 100644 --- a/apps/web/src/lib/code-reviews/action-required.test.ts +++ b/apps/web/src/lib/code-reviews/action-required.test.ts @@ -99,6 +99,12 @@ describe('classifyCodeReviewActionRequiredFailure', () => { ) ).toBe('selected_model_unavailable'); + expect( + classifyCodeReviewActionRequiredFailure( + 'Not Found: {"error":"No eligible provider can serve the selected model.","error_type":"provider_not_allowed","message":"No eligible provider can serve the selected model. Select another model or update the provider routing settings."}' + ) + ).toBe('selected_model_unavailable'); + expect( classifyCodeReviewActionRequiredFailure( 'No endpoints found matching your data policy (Free model training). Configure: https://openrouter.ai/settings/privacy' diff --git a/apps/web/src/lib/code-reviews/action-required.ts b/apps/web/src/lib/code-reviews/action-required.ts index 93c16ff014..95dc9e0b19 100644 --- a/apps/web/src/lib/code-reviews/action-required.ts +++ b/apps/web/src/lib/code-reviews/action-required.ts @@ -125,6 +125,8 @@ export function classifyCodeReviewActionRequiredFailure( if ( normalized.includes(SELECTED_MODEL_UNAVAILABLE_MESSAGE) || normalized.includes(REQUESTED_MODEL_NOT_ALLOWED_FOR_TEAM_MESSAGE) || + normalized.includes('provider_not_allowed') || + normalized.includes('no eligible provider can serve the selected model.') || normalized.includes('no allowed providers are specified.') || normalized.includes('no allowed providers are available for the selected model.') || normalized.includes('no endpoints found matching your data policy') diff --git a/services/code-review-infra/src/code-review-orchestrator.ts b/services/code-review-infra/src/code-review-orchestrator.ts index f01b78d8f3..f498e20a09 100644 --- a/services/code-review-infra/src/code-review-orchestrator.ts +++ b/services/code-review-infra/src/code-review-orchestrator.ts @@ -107,6 +107,8 @@ function isSelectedModelActionRequiredMessage(message: string): boolean { return ( message.includes(SELECTED_MODEL_UNAVAILABLE_MESSAGE) || message.includes(REQUESTED_MODEL_NOT_ALLOWED_FOR_TEAM_MESSAGE) || + message.includes('provider_not_allowed') || + message.includes('no eligible provider can serve the selected model.') || message.includes('no allowed providers are specified.') || message.includes('no allowed providers are available for the selected model.') || message.includes('no endpoints found matching your data policy') diff --git a/services/code-review-infra/test/integration/code-review-orchestrator.test.ts b/services/code-review-infra/test/integration/code-review-orchestrator.test.ts index 778ee98fdc..7e284c40da 100644 --- a/services/code-review-infra/test/integration/code-review-orchestrator.test.ts +++ b/services/code-review-infra/test/integration/code-review-orchestrator.test.ts @@ -1336,6 +1336,48 @@ describe('CodeReviewOrchestrator recovery', () => { }); }); + it('maps provider-routing prepareSession failures to action-required terminal reason', async () => { + const stub = getReviewStub(); + const fetchMock = vi.fn(async (request: RequestInfo | URL) => { + const url = String(request); + if (url.includes('/api/internal/code-review-status/')) { + return Response.json({ success: true }); + } + if (url.includes('/trpc/prepareSession')) { + return trpcError( + 400, + 'Not Found: {"error":"No eligible provider can serve the selected model.","error_type":"provider_not_allowed","message":"No eligible provider can serve the selected model. Select another model or update the provider routing settings."}', + 'BAD_REQUEST' + ); + } + return new Response('unexpected fetch', { status: 500 }); + }); + globalThis.fetch = fetchMock; + + await runInDurableObject(stub, async (_instance: CodeReviewOrchestrator, state) => { + await state.storage.put('state', codeReview()); + await state.storage.setAlarm(Date.now() + 30_000); + }); + + const ran = await runDurableObjectAlarm(stub); + + expect(ran).toBe(true); + await expect(stub.status()).resolves.toMatchObject({ + status: 'failed', + terminalReason: 'selected_model_unavailable', + }); + expect(fetchCalls(fetchMock, '/trpc/prepareSession')).toHaveLength(1); + expect(fetchCalls(fetchMock, '/trpc/initiateFromKilocodeSessionV2')).toHaveLength(0); + expect(lastStatusUpdateBody(fetchMock)).toMatchObject({ + status: 'failed', + terminalReason: 'selected_model_unavailable', + }); + await expect(storedReview(stub)).resolves.toMatchObject({ + status: 'failed', + terminalReason: 'selected_model_unavailable', + }); + }); + it('maps model-not-allowed prepareSession 400 failures to action-required terminal reason', async () => { const stub = getReviewStub(); const fetchMock = vi.fn(async (request: RequestInfo | URL) => {