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
143 changes: 104 additions & 39 deletions apps/web/src/components/organizations/byok/BYOKKeysManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,22 @@ import {
DirectUserByokInferenceProviderIdSchema,
UserByokProviderIdSchema,
VercelUserByokInferenceProviderIdSchema,
AzureCredentialsSchema,
BedrockCredentialsSchema,
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';
import { getCodingPlanManagedKeyLabel } from '@/components/subscriptions/coding-plans/coding-plan-provider';
import { cn } from '@/lib/utils';
import * as z from 'zod';

// Exhaustive map of Vercel BYOK providers to their display names. The `satisfies`
// clause forces new entries here whenever a provider is added to
// VercelUserByokInferenceProviderIdSchema.
const VERCEL_BYOK_PROVIDER_NAMES = {
anthropic: 'Anthropic',
azure: 'Azure Foundry (experimental)',
bedrock: 'AWS Bedrock',
deepseek: 'DeepSeek',
openai: 'OpenAI',
Expand Down Expand Up @@ -279,11 +282,13 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
const validateStructuredCredentials = (providerId: string, value: string): string | null => {
if (!value) return null;
const schema =
providerId === VercelUserByokInferenceProviderIdSchema.enum.bedrock
? BedrockCredentialsSchema
: providerId === VercelUserByokInferenceProviderIdSchema.enum.vertex
? VertexCredentialsSchema
: null;
providerId === VercelUserByokInferenceProviderIdSchema.enum.azure
? AzureCredentialsSchema
: providerId === VercelUserByokInferenceProviderIdSchema.enum.bedrock
? BedrockCredentialsSchema
: providerId === VercelUserByokInferenceProviderIdSchema.enum.vertex
? VertexCredentialsSchema
: null;
if (!schema) return null;
let parsed: unknown;
try {
Expand All @@ -293,6 +298,11 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
}
const result = schema.safeParse(parsed);
if (!result.success) {
if (providerId === VercelUserByokInferenceProviderIdSchema.enum.azure) {
return result.error.issues.some(issue => issue.path[0] === 'modelMappings')
? 'Each model mapping must include gatewayModelSlug and customModelId.'
: 'Enter JSON with apiKey and resourceName.';
}
if (providerId === VercelUserByokInferenceProviderIdSchema.enum.bedrock) {
return 'Enter JSON with apiKey and region, or accessKeyId, secretAccessKey, and region. Use only one authentication method.';
}
Expand Down Expand Up @@ -394,6 +404,10 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
const getProviderModels = (providerId: string): string[] => {
return supportedModels?.[providerId] ?? [];
};
const usesStructuredCredentials =
selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.azure ||
selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.bedrock ||
selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.vertex;
return (
<div className="space-y-4">
<BYOKDescription showsCodingPlanKey={showsCodingPlanKey} />
Expand Down Expand Up @@ -579,42 +593,71 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {

<div className="space-y-2">
<Label htmlFor="apiKey">
{selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.bedrock
? 'AWS Bedrock Credentials'
: selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.vertex
? 'Google Vertex Credentials'
: 'API Key'}
{selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.azure
? 'Azure OpenAI Credentials'
: selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.bedrock
? 'AWS Bedrock Credentials'
: selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.vertex
? 'Google Vertex Credentials'
: 'API Key'}
</Label>
{selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.bedrock ||
selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.vertex ? (
{usesStructuredCredentials ? (
<>
<textarea
id="apiKey"
value={apiKey}
onChange={e => {
setApiKey(e.target.value);
setCredentialError(null);
}}
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={6}
aria-label={
selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.bedrock
? 'AWS credentials'
: 'Google Vertex credentials'
}
aria-invalid={credentialError ? true : undefined}
aria-describedby={credentialError ? 'credential-error' : undefined}
/>
<div className="relative">
<textarea
id="apiKey"
name="provider-credentials"
value={apiKey}
onChange={e => {
setApiKey(e.target.value);
setCredentialError(null);
}}
onBlur={e =>
setCredentialError(
validateStructuredCredentials(selectedProvider, e.target.value)
)
}
placeholder={
selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.azure
? '{"apiKey": "...", "resourceName": "..."}'
: selectedProvider ===
VercelUserByokInferenceProviderIdSchema.enum.bedrock
? '{"accessKeyId": "...", "secretAccessKey": "...", "region": "us-east-1"}'
: '{"project": "...", "location": "global", "googleCredentials": {"clientEmail": "...", "privateKey": "..."}}'
}
className={cn(
'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 pr-10 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
showApiKey ? '' : '[-webkit-text-security:disc] [text-security:disc]'
)}
rows={6}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
data-1p-ignore="true"
data-lpignore="true"
data-form-type="other"
aria-label={
selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.azure
? 'Azure OpenAI credentials'
: selectedProvider ===
VercelUserByokInferenceProviderIdSchema.enum.bedrock
? 'AWS credentials'
: 'Google Vertex credentials'
}
aria-invalid={credentialError ? true : undefined}
aria-describedby={credentialError ? 'credential-error' : undefined}
/>
<button
type="button"
aria-label={showApiKey ? 'Hide credentials' : 'Reveal credentials'}
aria-pressed={showApiKey}
onClick={() => setShowApiKey(!showApiKey)}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute top-0 right-0 flex size-10 items-center justify-center rounded-tr-md focus-visible:ring-2 focus-visible:outline-none"
>
{showApiKey ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
{credentialError && (
<Alert id="credential-error" variant="destructive">
<AlertDescription className="whitespace-break-spaces">
Expand Down Expand Up @@ -645,6 +688,28 @@ export function BYOKKeysManager({ organizationId }: BYOKKeysManagerProps) {
</Button>
</div>
)}
{selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.azure && (
<Alert>
<Info className="size-4" />
<AlertDescription>
<p>Enter your Azure OpenAI API key and resource name as JSON:</p>
<code className="mt-1 block text-xs break-all">
{'{"apiKey": "...", "resourceName": "..."}'}
</code>
<p className="mt-1">
The resource name is the subdomain from your Azure OpenAI endpoint, for
example <code className="text-xs">my-resource</code> from{' '}
<code className="text-xs">my-resource.openai.azure.com</code>.
</p>
<p className="mt-1">
For custom deployment names, add{' '}
<code className="text-xs">modelMappings</code> with{' '}
<code className="text-xs">gatewayModelSlug</code> and{' '}
<code className="text-xs">customModelId</code> entries.
</p>
</AlertDescription>
</Alert>
)}
{selectedProvider === VercelUserByokInferenceProviderIdSchema.enum.bedrock && (
<Alert>
<Info className="size-4" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AzureCredentialsSchema,
BedrockCredentialsSchema,
DirectUserByokInferenceProviderIdSchema,
getVercelUserByokProviderIdForEndpoint,
Expand All @@ -10,6 +11,40 @@ import {
VercelUserByokInferenceProviderIdSchema,
} from './inference-provider-id';

describe('AzureCredentialsSchema', () => {
test('accepts Azure credentials with optional model mappings', () => {
const credentials = {
apiKey: 'azure-api-key',
resourceName: 'example-resource',
modelMappings: [
{
gatewayModelSlug: 'openai/gpt-5.4-nano',
customModelId: 'custom-gpt-5.4-nano',
},
],
};

expect(AzureCredentialsSchema.parse(credentials)).toEqual(credentials);
});

test.each([
null,
'azure-api-key',
{},
{ apiKey: 'azure-api-key' },
{ resourceName: 'example-resource' },
{ apiKey: '', resourceName: 'example-resource' },
{ apiKey: 'azure-api-key', resourceName: '' },
{
apiKey: 'azure-api-key',
resourceName: 'example-resource',
modelMappings: [{ gatewayModelSlug: 'openai/gpt-5.4-nano' }],
},
])('rejects incomplete or invalid Azure credentials: %j', credentials => {
expect(AzureCredentialsSchema.safeParse(credentials).success).toBe(false);
});
});

describe('BedrockCredentialsSchema', () => {
test.each([
{ accessKeyId: 'AKIAEXAMPLE', secretAccessKey: 'secret', region: 'us-east-1' },
Expand Down Expand Up @@ -76,6 +111,12 @@ describe('inference provider ids', () => {
expect(openRouterToVercelInferenceProviderId('google-vertex')).toBe('vertex');
});

test('promotes Azure to a user BYOK provider', () => {
expect(VercelUserByokInferenceProviderIdSchema.safeParse('azure').success).toBe(true);
expect(VercelNonUserByokInferenceProviderIdSchema.safeParse('azure').success).toBe(false);
expect(getVercelUserByokProviderIdForEndpoint('azure')).toBe('azure');
});

test('uses the Vertex user key for Vertex Anthropic endpoints', () => {
expect(normalizeVercelInferenceProviderIdForRouting('vertexAnthropic')).toBe('vertex');
expect(getVercelUserByokProviderIdForEndpoint('vertexAnthropic')).toBe('vertex');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const OpenRouterInferenceProviderIdSchema = z.enum([

export const VercelUserByokInferenceProviderIdSchema = z.enum([
'anthropic',
'azure',
'bedrock',
'deepseek',
'fireworks',
Expand Down Expand Up @@ -136,6 +137,7 @@ export type UserByokProviderId = z.infer<typeof UserByokProviderIdSchema>;

export const UserByokTestModels = {
[VercelUserByokInferenceProviderIdSchema.enum.anthropic]: 'anthropic/claude-haiku-4.5',
[VercelUserByokInferenceProviderIdSchema.enum.azure]: 'openai/gpt-5.4-nano',
[VercelUserByokInferenceProviderIdSchema.enum.bedrock]: 'anthropic/claude-haiku-4.5',
[VercelUserByokInferenceProviderIdSchema.enum.deepseek]: 'deepseek/deepseek-v3.2',
[VercelUserByokInferenceProviderIdSchema.enum.fireworks]: 'openai/gpt-oss-20b',
Expand Down Expand Up @@ -178,7 +180,6 @@ export const UserByokTestModels = {
export const VercelNonUserByokInferenceProviderIdSchema = z.enum([
'alibaba',
'arcee-ai',
'azure',
'baseten',
'bfl',
'blackbox',
Expand Down Expand Up @@ -305,6 +306,21 @@ export const BedrockCredentialsSchema = z.union([

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

export const AzureCredentialsSchema = z.object({
apiKey: z.string().trim().min(1),
resourceName: z.string().trim().min(1),
modelMappings: z
.array(
z.object({
gatewayModelSlug: z.string().trim().min(1),
customModelId: z.string().trim().min(1),
})
)
.optional(),
});

export type AzureCredentials = z.infer<typeof AzureCredentialsSchema>;

export const VertexCredentialsSchema = z.object({
project: z.string().min(1),
location: z.string().min(1),
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/ai-gateway/providers/openrouter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +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 {
AzureCredentials,
BedrockCredentials,
VertexCredentials,
} from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
Expand All @@ -27,6 +28,7 @@ export function isOpenRouterProviderConfig(value: unknown): value is OpenRouterP

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

Expand Down
31 changes: 31 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 @@ -395,6 +395,16 @@ describe('applyVercelSettings BYOK pinning', () => {
secretAccessKey: 'secret',
region: 'us-east-1',
});
const azureCredentials = JSON.stringify({
apiKey: 'azure-api-key',
resourceName: 'example-resource',
modelMappings: [
{
gatewayModelSlug: 'openai/gpt-5.4-nano',
customModelId: 'custom-gpt-5.4-nano',
},
],
});
const vertexCredentials = JSON.stringify({
project: 'example-project',
location: 'us-east5',
Expand Down Expand Up @@ -511,6 +521,27 @@ describe('applyVercelSettings BYOK pinning', () => {
expect(request.body.providerOptions?.gateway?.only).toEqual(['bedrock']);
});

it('forwards structured Azure credentials', async () => {
const request = byokRequest([]);

await applyVercelSettings('openai/gpt-5.4-nano', request, [
{ decryptedAPIKey: azureCredentials, providerId: 'azure' },
]);

expect(request.body.providerOptions?.gateway?.byok).toEqual({
azure: [JSON.parse(azureCredentials)],
});
expect(request.body.providerOptions?.gateway?.only).toEqual(['azure']);
});

it('rejects malformed Azure credentials without including credential contents', async () => {
await expect(
applyVercelSettings('openai/gpt-5.4-nano', byokRequest([]), [
{ decryptedAPIKey: '{"apiKey":"secret"}', providerId: 'azure' },
])
).rejects.toEqual(new Error('Failed to parse Azure credentials'));
});

it.each([
'bedrock-secret',
'{"apiKey":"bedrock-secret"',
Expand Down
Loading