Skip to content
Merged
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
5 changes: 4 additions & 1 deletion apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1103,7 +1103,10 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
requestedModel: effectiveModelIdLowerCased,
request: requestBodyParsed,
response,
isUserByok: !!effectiveProviderContext.userByok,
userByokProviderIds:
effectiveProviderContext.userByok === null
? null
: effectiveProviderContext.userByok.map(byok => byok.providerId),
});
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
82 changes: 74 additions & 8 deletions apps/web/src/lib/ai-gateway/llm-proxy-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -872,11 +872,77 @@ describe('makeErrorReadable', () => {
requestedModel: 'anything',
request,
response,
isUserByok: false,
userByokProviderIds: null,
});
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,
userByokProviderIds: ['vertex'],
});

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,
userByokProviderIds: ['anthropic'],
})
).resolves.toBeUndefined();
});

it.each([
[401, []],
[402, ['anthropic']],
[403, []],
[429, ['anthropic']],
] as const)('treats a non-null provider set as user BYOK for status %i', async (status, ids) => {
const result = await makeErrorReadable({
providerId: 'vercel',
requestedModel: 'anthropic/claude-sonnet-5',
request,
response: Response.json({}, { status }),
userByokProviderIds: [...ids],
});

expect(result).toBeDefined();
if (!result) throw new Error('Expected a readable BYOK error response');
expect((await result.json()).error_type).toBe('byok_error');
});

it('does not use generic BYOK errors for a null provider set', async () => {
await expect(
makeErrorReadable({
providerId: 'vercel',
requestedModel: 'anthropic/claude-sonnet-5',
request,
response: Response.json({}, { status: 401 }),
userByokProviderIds: null,
})
).resolves.toBeUndefined();
});

it('returns an actionable error when no allowed provider serves the model', async () => {
const response = Response.json(
{
Expand All @@ -895,7 +961,7 @@ describe('makeErrorReadable', () => {
requestedModel: 'qwen/qwen3.7-plus',
request,
response,
isUserByok: false,
userByokProviderIds: null,
});

expect(result?.status).toBe(404);
Expand Down Expand Up @@ -927,7 +993,7 @@ describe('makeErrorReadable', () => {
requestedModel: 'qwen/qwen3.7-plus',
request,
response,
isUserByok: false,
userByokProviderIds: null,
});

expect(result).toBeDefined();
Expand Down Expand Up @@ -955,7 +1021,7 @@ describe('makeErrorReadable', () => {
requestedModel: 'anything',
request,
response,
isUserByok: false,
userByokProviderIds: null,
})
).resolves.toBeUndefined();
});
Expand All @@ -981,7 +1047,7 @@ describe('makeErrorReadable', () => {
requestedModel: 'anything',
request,
response,
isUserByok: false,
userByokProviderIds: null,
})
).resolves.toBeUndefined();
}
Expand All @@ -998,7 +1064,7 @@ describe('makeErrorReadable', () => {
requestedModel: 'kilo-internal/custom-endpoint',
request,
response,
isUserByok: false,
userByokProviderIds: null,
});

expect(result).toBeUndefined();
Expand All @@ -1015,7 +1081,7 @@ describe('makeErrorReadable', () => {
requestedModel: 'experiment/test-model',
request,
response,
isUserByok: false,
userByokProviderIds: null,
});

expect(result).toBeDefined();
Expand All @@ -1038,7 +1104,7 @@ describe('makeErrorReadable', () => {
requestedModel: 'stealth/claude-opus-4.7',
request,
response,
isUserByok: false,
userByokProviderIds: null,
});

expect(result).toBeDefined();
Expand Down
Loading