Skip to content
Open
2 changes: 2 additions & 0 deletions apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,8 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
request: requestBodyParsed,
response,
isUserByok: !!effectiveProviderContext.userByok,
hasVertexUserByok:
effectiveProviderContext.userByok?.some(byok => byok.providerId === 'vertex') ?? false,
});
if (errorResponse) {
await logUnrewrittenResponse({
Expand Down
102 changes: 75 additions & 27 deletions apps/web/src/components/organizations/byok/BYOKKeysManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
UserByokProviderIdSchema,
VercelUserByokInferenceProviderIdSchema,
AwsCredentialsSchema,
VertexCredentialsSchema,
type VercelUserByokInferenceProviderId,
} from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
import { DIRECT_BYOK_PROVIDERS_META } from '@/lib/ai-gateway/providers/direct-byok/direct-byok-meta';
Expand All @@ -66,6 +67,7 @@ const VERCEL_BYOK_PROVIDER_NAMES = {
moonshotai: 'Moonshot AI',
novita: 'Novita',
perplexity: 'Perplexity',
vertex: 'Google Vertex AI',
xai: 'SpaceXAI',
xiaomi: 'Xiaomi (pay as you go)',
zai: 'Z.ai (pay as you go)',
Expand Down Expand Up @@ -151,7 +153,7 @@ type BYOKDialogState = {
selectedProvider: string;
apiKey: string;
showApiKey: boolean;
awsCredentialError: string | null;
credentialError: string | null;
};

const INITIAL_BYOK_DIALOG_STATE: BYOKDialogState = {
Expand All @@ -160,7 +162,7 @@ const INITIAL_BYOK_DIALOG_STATE: BYOKDialogState = {
selectedProvider: '',
apiKey: '',
showApiKey: false,
awsCredentialError: null,
credentialError: null,
};

function updateBYOKDialogState(state: BYOKDialogState, update: Partial<BYOKDialogState>) {
Expand All @@ -172,15 +174,15 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
updateBYOKDialogState,
INITIAL_BYOK_DIALOG_STATE
);
const { isDialogOpen, editingKeyId, selectedProvider, apiKey, showApiKey, awsCredentialError } =
const { isDialogOpen, editingKeyId, selectedProvider, apiKey, showApiKey, credentialError } =
dialogState;
const setIsDialogOpen = (isDialogOpen: boolean) => updateDialogState({ isDialogOpen });
const setEditingKeyId = (editingKeyId: string | null) => updateDialogState({ editingKeyId });
const setSelectedProvider = (selectedProvider: string) => updateDialogState({ selectedProvider });
const setApiKey = (apiKey: string) => updateDialogState({ apiKey });
const setShowApiKey = (showApiKey: boolean) => updateDialogState({ showApiKey });
const setAwsCredentialError = (awsCredentialError: string | null) =>
updateDialogState({ awsCredentialError });
const setCredentialError = (credentialError: string | null) =>
updateDialogState({ credentialError });
const trpc = useTRPC();
const queryClient = useQueryClient();
const confirm = useConfirm();
Expand Down Expand Up @@ -274,17 +276,28 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
return keys?.some(k => k.provider_id === providerSlug) ?? false;
};

const validateAwsCredentials = (value: string): string | null => {
const validateStructuredCredentials = (providerId: string, value: string): string | null => {
if (!value) return null;
const schema =
providerId === VercelUserByokInferenceProviderIdSchema.enum.bedrock
? AwsCredentialsSchema
: providerId === VercelUserByokInferenceProviderIdSchema.enum.vertex
? VertexCredentialsSchema
: null;
if (!schema) return null;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return 'Invalid JSON — please enter a valid JSON object.';
return 'Invalid JSON. Enter a valid JSON object.';
}
const result = AwsCredentialsSchema.safeParse(parsed);
const result = schema.safeParse(parsed);
if (!result.success) {
return `Invalid AWS credentials:\n${z.prettifyError(result.error)}`;
const providerName =
providerId === VercelUserByokInferenceProviderIdSchema.enum.bedrock
? 'AWS'
: 'Google Vertex';
return `Invalid ${providerName} credentials:\n${z.prettifyError(result.error)}`;
}
return null;
};
Expand All @@ -295,15 +308,13 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
setSelectedProvider('');
setApiKey('');
setShowApiKey(false);
setAwsCredentialError(null);
setCredentialError(null);
};

const handleSave = () => {
if (selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.bedrock) {
const error = validateAwsCredentials(apiKey);
setAwsCredentialError(error);
if (error) return;
}
const error = validateStructuredCredentials(selectedProvider, apiKey);
setCredentialError(error);
if (error) return;
if (editingKeyId) {
updateMutation.mutate({
...(organizationId && { organizationId }),
Expand Down Expand Up @@ -526,7 +537,10 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
<Label htmlFor="provider">Provider</Label>
<Select
value={selectedProvider}
onValueChange={setSelectedProvider}
onValueChange={providerId => {
setSelectedProvider(providerId);
setCredentialError(null);
}}
disabled={!!editingKeyId}
>
<SelectTrigger id="provider">
Expand Down Expand Up @@ -568,26 +582,44 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
<Label htmlFor="apiKey">
{selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.bedrock
? 'AWS Credentials'
: 'API Key'}
: selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.vertex
? 'Google Vertex Credentials'
: 'API Key'}
</Label>
{selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.bedrock ? (
{selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.bedrock ||
selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.vertex ? (
<>
<textarea
id="apiKey"
value={apiKey}
onChange={e => {
setApiKey(e.target.value);
setAwsCredentialError(validateAwsCredentials(e.target.value));
setCredentialError(null);
}}
placeholder='{"accessKeyId": "...", "secretAccessKey": "...", "region": "us-east-1"}'
onBlur={e =>
setCredentialError(
validateStructuredCredentials(selectedProvider, e.target.value)
)
}
placeholder={
selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.bedrock
? '{"accessKeyId": "...", "secretAccessKey": "...", "region": "us-east-1"}'
: '{"project": "...", "location": "global", "googleCredentials": {"clientEmail": "...", "privateKey": "..."}}'
}
className="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-20 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
rows={4}
aria-label="AWS credentials"
rows={6}
aria-label={
selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.bedrock
? 'AWS credentials'
: 'Google Vertex credentials'
}
aria-invalid={credentialError ? true : undefined}
aria-describedby={credentialError ? 'credential-error' : undefined}
/>
{awsCredentialError && (
<Alert variant="destructive">
{credentialError && (
<Alert id="credential-error" variant="destructive">
<AlertDescription className="whitespace-break-spaces">
{awsCredentialError}
{credentialError}
</AlertDescription>
</Alert>
)}
Expand Down Expand Up @@ -630,6 +662,23 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
</AlertDescription>
</Alert>
)}
{selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.vertex && (
<Alert>
<Info className="size-4" />
<AlertDescription>
<p>Enter your Google Cloud project, Vertex location, and service account:</p>
<code className="mt-1 block text-xs break-all">
{
'{"project": "...", "location": "global", "googleCredentials": {"clientEmail": "...", "privateKey": "..."}}'
}
</code>
<p className="mt-1">
Enable the Vertex AI API and grant the service account permission to invoke
the models you use. Claude models may also require access in Model Garden.
</p>
</AlertDescription>
</Alert>
)}
{editingKeyId ? (
<Alert>
<Lock className="size-4" />
Expand Down Expand Up @@ -722,8 +771,7 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
disabled={
!selectedProvider ||
!apiKey ||
(selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.bedrock &&
!!awsCredentialError) ||
!!credentialError ||
createMutation.isPending ||
updateMutation.isPending
}
Expand Down
13 changes: 8 additions & 5 deletions apps/web/src/lib/ai-gateway/byok/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { decryptApiKey } from '@/lib/ai-gateway/byok/encryption';
import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server';
import {
UserByokProviderIdSchema,
VercelUserByokInferenceProviderIdSchema,
getVercelUserByokProviderIdForEndpoint,
type UserByokProviderId,
} from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
import { isCodestralModel } from '@/lib/ai-gateway/providers/mistral';
Expand All @@ -22,10 +22,13 @@ export async function getModelUserByokProviders(modelId: string): Promise<UserBy
console.error('[getModelUserByokProviders] no Vercel model metadata for model %s', modelId);
return [];
}
const providers: UserByokProviderId[] =
vercelModelMetadata[mapModelIdToVercel(modelId)]?.endpoints
.map(ep => VercelUserByokInferenceProviderIdSchema.safeParse(ep.provider_name ?? ep.tag).data)
.filter(providerId => providerId !== undefined) ?? [];
const providers: UserByokProviderId[] = [
...new Set(
vercelModelMetadata[mapModelIdToVercel(modelId)]?.endpoints
.map(ep => getVercelUserByokProviderIdForEndpoint(ep.provider_name ?? ep.tag))
.filter(providerId => providerId !== undefined) ?? []
),
];
if (providers.length === 0) {
console.debug(`[getModelUserByokProviders] no user byok providers for ${modelId}`);
return [];
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/lib/ai-gateway/llm-proxy-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,42 @@ describe('makeErrorReadable', () => {
expect(result).toBeUndefined();
});

it('explains a missing model for a Vertex user BYOK request', async () => {
const response = Response.json({ error: { message: 'AI_APICallError' } }, { status: 404 });

const result = await makeErrorReadable({
providerId: 'vercel',
requestedModel: 'anthropic/claude-sonnet-5',
request,
response,
isUserByok: true,
hasVertexUserByok: true,
});

expect(result?.status).toBe(404);
await expect(result?.json()).resolves.toEqual({
error:
'[BYOK] Google Vertex AI could not find the requested model. The model might not be enabled for your Google Cloud project or selected Vertex location.',
error_type: 'byok_error',
message:
'[BYOK] Google Vertex AI could not find the requested model. The model might not be enabled for your Google Cloud project or selected Vertex location.',
});
});

it('does not label other user BYOK 404 responses as Vertex errors', async () => {
const response = Response.json({ error: { message: 'Model not found' } }, { status: 404 });

await expect(
makeErrorReadable({
providerId: 'vercel',
requestedModel: 'anthropic/claude-sonnet-5',
request,
response,
isUserByok: true,
})
).resolves.toBeUndefined();
});

it('returns an actionable error when no allowed provider serves the model', async () => {
const response = Response.json(
{
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/lib/ai-gateway/llm-proxy-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,18 @@ function byokErrorMessage(status: number): string | undefined {
return byokErrorMessages[status];
}

function vertexByokModelNotFoundResponse(response: Response, hasVertexUserByok: boolean) {
if (!hasVertexUserByok || response.status !== 404) return undefined;

const error =
'[BYOK] Google Vertex AI could not find the requested model. The model might not be enabled for your Google Cloud project or selected Vertex location.';
warnExceptInTest(`Responding with ${response.status} ${error}`);
return NextResponse.json(
{ error, error_type: ProxyErrorType.byok_error, message: error },
{ status: response.status }
);
}

const noAllowedProvidersErrorSchema = z.object({
message: z.literal('No allowed providers are available for the selected model.'),
code: z.literal(404),
Expand Down Expand Up @@ -252,17 +264,22 @@ export async function makeErrorReadable({
request,
response,
isUserByok,
hasVertexUserByok = false,
}: {
providerId: ProviderId;
requestedModel: string;
request: GatewayRequest;
response: Response;
isUserByok: boolean;
hasVertexUserByok?: boolean;
}) {
if (response.status < 400) {
return undefined;
}

const vertexByokResponse = vertexByokModelNotFoundResponse(response, hasVertexUserByok);
if (vertexByokResponse) return vertexByokResponse;

if (isUserByok) {
const byokMessage = byokErrorMessage(response.status);
if (byokMessage) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import {
DirectUserByokInferenceProviderIdSchema,
getVercelUserByokProviderIdForEndpoint,
normalizeVercelInferenceProviderIdForRouting,
openRouterToVercelInferenceProviderId,
OpenRouterInferenceProviderIdSchema,
VercelInferenceProviderIdSchema,
VercelNonUserByokInferenceProviderIdSchema,
VercelUserByokInferenceProviderIdSchema,
} from './inference-provider-id';

describe('inference provider ids', () => {
Expand All @@ -14,7 +18,7 @@ describe('inference provider ids', () => {
expect(overlappingProviderIds).toEqual([]);
});

test('direct BYOK provider ids do not overlap with Vercel provider ids', () => {
test('direct BYOK provider ids do not overlap with known Vercel provider ids', () => {
const overlappingProviderIds = DirectUserByokInferenceProviderIdSchema.options.filter(
providerId => VercelInferenceProviderIdSchema.safeParse(providerId).success
);
Expand All @@ -25,4 +29,22 @@ describe('inference provider ids', () => {
test('maps the OpenRouter Claude AWS provider to its Vercel provider id', () => {
expect(openRouterToVercelInferenceProviderId('claude-on-aws')).toBe('claudeaws');
});

test('preserves provider ids that are not in the known provider registry', () => {
expect(openRouterToVercelInferenceProviderId('future-provider')).toBe('future-provider');
expect(normalizeVercelInferenceProviderIdForRouting('future-provider')).toBe('future-provider');
});

test('promotes Vertex to a user BYOK provider', () => {
expect(VercelUserByokInferenceProviderIdSchema.safeParse('vertex').success).toBe(true);
expect(VercelNonUserByokInferenceProviderIdSchema.safeParse('vertex').success).toBe(false);
expect(openRouterToVercelInferenceProviderId('google-vertex')).toBe('vertex');
});

test('uses the Vertex user key for Vertex Anthropic endpoints', () => {
expect(normalizeVercelInferenceProviderIdForRouting('vertexAnthropic')).toBe('vertex');
expect(getVercelUserByokProviderIdForEndpoint('vertexAnthropic')).toBe('vertex');
expect(getVercelUserByokProviderIdForEndpoint('vertex')).toBe('vertex');
expect(getVercelUserByokProviderIdForEndpoint('unsupported')).toBeUndefined();
});
});
Loading