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
25 changes: 16 additions & 9 deletions apps/web/src/components/organizations/byok/BYOKKeysManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
DirectUserByokInferenceProviderIdSchema,
UserByokProviderIdSchema,
VercelUserByokInferenceProviderIdSchema,
AwsCredentialsSchema,
BedrockCredentialsSchema,
VertexCredentialsSchema,
type VercelUserByokInferenceProviderId,
} from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
Expand Down Expand Up @@ -280,7 +280,7 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
if (!value) return null;
const schema =
providerId === VercelUserByokInferenceProviderIdSchema.enum.bedrock
? AwsCredentialsSchema
? BedrockCredentialsSchema
: providerId === VercelUserByokInferenceProviderIdSchema.enum.vertex
? VertexCredentialsSchema
: null;
Expand All @@ -293,11 +293,10 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
}
const result = schema.safeParse(parsed);
if (!result.success) {
const providerName =
providerId === VercelUserByokInferenceProviderIdSchema.enum.bedrock
? 'AWS'
: 'Google Vertex';
return `Invalid ${providerName} credentials:\n${z.prettifyError(result.error)}`;
if (providerId === VercelUserByokInferenceProviderIdSchema.enum.bedrock) {
return 'Enter JSON with apiKey and region, or accessKeyId, secretAccessKey, and region. Use only one authentication method.';
}
return `Invalid Google Vertex credentials:\n${z.prettifyError(result.error)}`;
}
return null;
};
Expand Down Expand Up @@ -581,7 +580,7 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
<div className="space-y-2">
<Label htmlFor="apiKey">
{selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.bedrock
? 'AWS Credentials'
? 'AWS Bedrock Credentials'
: selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.vertex
? 'Google Vertex Credentials'
: 'API Key'}
Expand Down Expand Up @@ -650,7 +649,15 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
<Alert>
<Info className="size-4" />
<AlertDescription>
<p>Enter your AWS credentials as JSON:</p>
<p>Enter a Bedrock API key and AWS region as JSON:</p>
<code className="mt-1 block text-xs break-all">
{'{"apiKey": "...", "region": "us-east-1"}'}
</code>
<p className="mt-1">
Generate an API key in the AWS Bedrock console. Use a region where your key
and model are available, and replace the key before it expires.
</p>
<p className="mt-1">Or enter IAM credentials as JSON:</p>
<code className="mt-1 block text-xs break-all">
{'{"accessKeyId": "...", "secretAccessKey": "...", "region": "us-east-1"}'}
</code>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
BedrockCredentialsSchema,
DirectUserByokInferenceProviderIdSchema,
getVercelUserByokProviderIdForEndpoint,
normalizeVercelInferenceProviderIdForRouting,
Expand All @@ -9,6 +10,40 @@ import {
VercelUserByokInferenceProviderIdSchema,
} from './inference-provider-id';

describe('BedrockCredentialsSchema', () => {
test.each([
{ accessKeyId: 'AKIAEXAMPLE', secretAccessKey: 'secret', region: 'us-east-1' },
{ apiKey: 'bedrock-api-key', region: 'eu-west-1' },
])('accepts Bedrock credentials: %j', credentials => {
expect(BedrockCredentialsSchema.parse(credentials)).toEqual(credentials);
});

test.each([
null,
'bedrock-api-key',
{},
{ region: 'us-east-1' },
{ apiKey: 'bedrock-api-key' },
{ apiKey: '', region: 'us-east-1' },
{ apiKey: ' ', region: 'us-east-1' },
{ apiKey: 123, region: 'us-east-1' },
{ apiKey: 'bedrock-api-key', region: '' },
{ apiKey: 'bedrock-api-key', region: ' ' },
{ apiKey: 'bedrock-api-key', region: 123 },
{ apiKey: 'bedrock-api-key', region: 'us-east-1', accessKeyId: 'AKIAEXAMPLE' },
{ apiKey: 'bedrock-api-key', region: 'us-east-1', secretAccessKey: 'secret' },
{
apiKey: 'bedrock-api-key',
region: 'us-east-1',
accessKeyId: 'AKIAEXAMPLE',
secretAccessKey: 'secret',
},
{ accessKeyId: 'AKIAEXAMPLE', region: 'us-east-1' },
])('rejects incomplete, invalid, or mixed credentials: %j', credentials => {
expect(BedrockCredentialsSchema.safeParse(credentials).success).toBe(false);
});
});

describe('inference provider ids', () => {
test('direct BYOK provider ids do not overlap with OpenRouter provider ids', () => {
const overlappingProviderIds = DirectUserByokInferenceProviderIdSchema.options.filter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,17 @@ export const AwsCredentialsSchema = z.object({
region: z.string(),
});

export type AwsCredentials = z.infer<typeof AwsCredentialsSchema>;
export const BedrockCredentialsSchema = z.union([
AwsCredentialsSchema.extend({ apiKey: z.never().optional() }),
z.object({
apiKey: z.string().trim().min(1),
region: z.string().trim().min(1),
accessKeyId: z.never().optional(),
secretAccessKey: z.never().optional(),
}),
]);

export type BedrockCredentials = z.infer<typeof BedrockCredentialsSchema>;

export const VertexCredentialsSchema = z.object({
project: z.string().min(1),
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lib/ai-gateway/providers/openrouter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { GatewayProviderOptions } from '@ai-sdk/gateway';
import type { AnthropicProviderOptions } from '@ai-sdk/anthropic';
import type { ReasoningDetailUnion } from '@/lib/ai-gateway/custom-llm/reasoning-details';
import type {
AwsCredentials,
BedrockCredentials,
VertexCredentials,
} from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
import type Anthropic from '@anthropic-ai/sdk';
Expand All @@ -26,7 +26,7 @@ export function isOpenRouterProviderConfig(value: unknown): value is OpenRouterP

export type VercelInferenceProviderConfig =
| { apiKey: string; baseURL?: string }
| AwsCredentials
| BedrockCredentials
| VertexCredentials;

export type VercelProviderConfig = {
Expand Down
54 changes: 54 additions & 0 deletions apps/web/src/lib/ai-gateway/providers/vercel/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,60 @@ describe('applyVercelSettings BYOK pinning', () => {
});
});

it.each<GatewayRequest>([
{
kind: 'chat_completions',
body: {
model: 'anthropic/claude-sonnet-4.5',
messages: [{ role: 'user', content: 'hello' }],
},
},
{
kind: 'messages',
body: {
model: 'anthropic/claude-sonnet-4.5',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 100,
},
},
{
kind: 'responses',
body: { model: 'anthropic/claude-sonnet-4.5', input: 'hello' },
},
])('forwards Bedrock API key credentials for $kind', async request => {
const credentials = { apiKey: 'bedrock-api-key', region: 'eu-west-1' };
await applyVercelSettings('anthropic/claude-sonnet-4.5', request, [
{ decryptedAPIKey: JSON.stringify(credentials), providerId: 'bedrock' },
]);

expect(request.body.providerOptions?.gateway?.byok).toEqual({ bedrock: [credentials] });
expect(request.body.providerOptions?.gateway?.only).toEqual(['bedrock']);
});

it('retains a Bedrock API key when the caller ignores its only BYOK provider', async () => {
const request = byokRequest(['amazon-bedrock']);
const credentials = { apiKey: 'bedrock-api-key', region: 'us-east-1' };
await applyVercelSettings('anthropic/claude-sonnet-4.5', request, [
{ decryptedAPIKey: JSON.stringify(credentials), providerId: 'bedrock' },
]);

expect(request.body.providerOptions?.gateway?.byok).toEqual({ bedrock: [credentials] });
expect(request.body.providerOptions?.gateway?.only).toEqual(['bedrock']);
});

it.each([
'bedrock-secret',
'{"apiKey":"bedrock-secret"',
'{"apiKey":"bedrock-secret"}',
'{"apiKey":"bedrock-secret","region":"us-east-1","accessKeyId":"AKIAEXAMPLE","secretAccessKey":"secret"}',
])('rejects malformed Bedrock credentials without exposing secrets: %s', async credentials => {
await expect(
applyVercelSettings('anthropic/claude-sonnet-4.5', byokRequest([]), [
{ decryptedAPIKey: credentials, providerId: 'bedrock' },
])
).rejects.toEqual(new Error('Failed to parse AWS credentials'));
});

it('uses one Vertex credential key for Anthropic models served by Vertex', async () => {
const request = byokRequest([]);

Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/lib/ai-gateway/providers/vercel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { BYOKResult } from '@/lib/ai-gateway/providers/types';
import type { VercelUserByokInferenceProviderId } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
import {
DirectUserByokInferenceProviderIdSchema,
AwsCredentialsSchema,
BedrockCredentialsSchema,
normalizeVercelInferenceProviderIdForRouting,
openRouterToVercelInferenceProviderId,
VertexCredentialsSchema,
Expand Down Expand Up @@ -172,9 +172,9 @@ export function convertProviderOptions(
};
}

function parseAwsCredentials(input: string) {
function parseBedrockCredentials(input: string) {
try {
return AwsCredentialsSchema.parse(JSON.parse(input));
return BedrockCredentialsSchema.parse(JSON.parse(input));
} catch {
throw new Error('Failed to parse AWS credentials');
}
Expand Down Expand Up @@ -228,7 +228,7 @@ export function getVercelInferenceProviderConfigForUserByok(
}

if (key === VercelUserByokInferenceProviderIdSchema.enum.bedrock) {
list.push(parseAwsCredentials(provider.decryptedAPIKey));
list.push(parseBedrockCredentials(provider.decryptedAPIKey));
} else if (key === VercelUserByokInferenceProviderIdSchema.enum.vertex) {
list.push(parseVertexCredentials(provider.decryptedAPIKey));
} else {
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/lib/ai-gateway/rewriteModelResponse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,7 @@ describe('sanitizeApiRequestLogRequest', () => {
secretAccessKey: 'bedrock-secret',
region: 'us-east-1',
},
{ apiKey: 'bedrock-api-key', region: 'us-west-2' },
],
},
},
Expand All @@ -1116,7 +1117,7 @@ describe('sanitizeApiRequestLogRequest', () => {
order: ['friendli', 'novita'],
byok: {
friendli: [{ apiKey: '[redacted]' }],
bedrock: [{ apiKey: '[redacted]' }],
bedrock: [{ apiKey: '[redacted]' }, { apiKey: '[redacted]' }],
},
},
anthropic: { effort: 'high' },
Expand All @@ -1129,6 +1130,10 @@ describe('sanitizeApiRequestLogRequest', () => {
accessKeyId: 'AKIAEXAMPLE',
secretAccessKey: 'bedrock-secret',
});
expect(request.body.providerOptions.gateway.byok.bedrock[1]).toEqual({
apiKey: 'bedrock-api-key',
region: 'us-west-2',
});
});
});

Expand Down
Loading