feat(byok): add model selection to BYOK settings panel and overlay custom model on route resolution
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled

This commit is contained in:
Antigravity
2026-05-28 21:41:34 +00:00
parent 3b2570d981
commit 11a07adee7
3 changed files with 119 additions and 12 deletions

View File

@@ -42,12 +42,40 @@ function providerLabel(t: (key: string) => string, provider: string): string {
return translated === key ? provider : translated return translated === key ? provider : translated
} }
const PROVIDER_MODEL_SUGGESTIONS: Record<string, string[]> = {
openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-3.5-turbo'],
anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest', 'claude-3-opus-latest'],
google: ['gemini-1.5-flash', 'gemini-1.5-pro', 'gemini-2.0-flash-exp'],
deepseek: ['deepseek-chat', 'deepseek-coder'],
minimax: ['abab6.5-chat', 'abab6.5s-chat'],
mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest'],
glm: ['glm-4', 'glm-4-flash'],
openrouter: ['openai/gpt-4o-mini', 'anthropic/claude-3.5-sonnet', 'deepseek/deepseek-chat'],
custom: [],
}
export function ByokSettingsPanel() { export function ByokSettingsPanel() {
const { t } = useLanguage() const { t } = useLanguage()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [provider, setProvider] = useState('') const [provider, setProvider] = useState('')
const [apiKey, setApiKey] = useState('') const [apiKey, setApiKey] = useState('')
const [alias, setAlias] = useState('') const [alias, setAlias] = useState('')
const [model, setModel] = useState('')
const [customModel, setCustomModel] = useState('')
const [isCustomModel, setIsCustomModel] = useState(false)
const handleProviderChange = (p: string) => {
setProvider(p)
const sug = PROVIDER_MODEL_SUGGESTIONS[p] || []
if (sug.length > 0) {
setModel(sug[0])
setIsCustomModel(false)
} else {
setModel('')
setCustomModel('')
setIsCustomModel(true)
}
}
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['user', 'api-keys'], queryKey: ['user', 'api-keys'],
@@ -61,10 +89,16 @@ export function ByokSettingsPanel() {
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const resolvedModel = isCustomModel ? customModel : model
const res = await fetch('/api/user/api-keys', { const res = await fetch('/api/user/api-keys', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, apiKey, alias: alias || undefined }), body: JSON.stringify({
provider,
apiKey,
alias: alias || undefined,
model: resolvedModel || undefined,
}),
}) })
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => ({}))
if (!res.ok) { if (!res.ok) {
@@ -77,6 +111,9 @@ export function ByokSettingsPanel() {
setApiKey('') setApiKey('')
setAlias('') setAlias('')
setProvider('') setProvider('')
setModel('')
setCustomModel('')
setIsCustomModel(false)
invalidate() invalidate()
}, },
onError: (err: Error) => { onError: (err: Error) => {
@@ -171,7 +208,7 @@ export function ByokSettingsPanel() {
<Label htmlFor="byok-provider" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label> <Label htmlFor="byok-provider" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
<Select <Select
value={provider} value={provider}
onValueChange={setProvider} onValueChange={handleProviderChange}
disabled={saveMutation.isPending} disabled={saveMutation.isPending}
> >
<SelectTrigger id="byok-provider"> <SelectTrigger id="byok-provider">
@@ -197,6 +234,64 @@ export function ByokSettingsPanel() {
/> />
</div> </div>
</div> </div>
{/* Model Selection Row */}
{provider && (
<div className="grid gap-4 sm:grid-cols-2 pt-2 border-t border-border/40">
<div className="space-y-2">
<Label htmlFor="byok-model-select" className="text-[10px] font-bold uppercase tracking-widest text-concrete">Modèle de l'IA (Optionnel)</Label>
{PROVIDER_MODEL_SUGGESTIONS[provider] && PROVIDER_MODEL_SUGGESTIONS[provider].length > 0 ? (
<Select
value={isCustomModel ? 'custom' : model}
onValueChange={(val) => {
if (val === 'custom') {
setIsCustomModel(true)
} else {
setIsCustomModel(false)
setModel(val)
}
}}
disabled={saveMutation.isPending}
>
<SelectTrigger id="byok-model-select">
<SelectValue placeholder="Choisir un modèle..." />
</SelectTrigger>
<SelectContent>
{PROVIDER_MODEL_SUGGESTIONS[provider].map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
<SelectItem value="custom">Autre / Modèle personnalisé...</SelectItem>
</SelectContent>
</Select>
) : (
<div className="text-xs text-concrete py-2 italic">
Spécifiez le modèle ci-contre si besoin.
</div>
)}
</div>
{(isCustomModel || !(PROVIDER_MODEL_SUGGESTIONS[provider] && PROVIDER_MODEL_SUGGESTIONS[provider].length > 0)) && (
<div className="space-y-2">
<Label htmlFor="byok-model-custom" className="text-[10px] font-bold uppercase tracking-widest text-concrete">Saisir le nom du modèle</Label>
<Input
id="byok-model-custom"
value={isCustomModel ? customModel : model}
onChange={(e) => {
if (isCustomModel) {
setCustomModel(e.target.value)
} else {
setModel(e.target.value)
}
}}
placeholder="ex. deepseek-reasoner, minimax-abab6.5"
disabled={saveMutation.isPending}
/>
</div>
)}
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="byok-key" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.apiKey')}</Label> <Label htmlFor="byok-key" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.apiKey')}</Label>
<Input <Input
@@ -236,9 +331,14 @@ export function ByokSettingsPanel() {
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[13px] font-bold text-ink">{providerLabel(t, key.provider)}</div> <div className="text-[13px] font-bold text-ink">{providerLabel(t, key.provider)}</div>
{key.alias ? ( <div className="flex flex-col gap-0.5 mt-0.5">
<p className="text-[10px] text-concrete truncate">{key.alias}</p> {key.alias ? (
) : null} <p className="text-[10px] text-concrete truncate">{key.alias}</p>
) : null}
{key.model ? (
<p className="text-[9px] text-brand-accent font-mono truncate">Modèle : {key.model}</p>
) : null}
</div>
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<label className="relative inline-flex items-center cursor-pointer"> <label className="relative inline-flex items-center cursor-pointer">

View File

@@ -25,6 +25,7 @@ async function resolveProviderForLane(
const cfg = { ...config }; const cfg = { ...config };
const route = resolveAiRoute(lane, cfg); const route = resolveAiRoute(lane, cfg);
let usedByok = false; let usedByok = false;
let byokModel: string | null = null;
if (billingUserId) { if (billingUserId) {
const overlay = await applyByokToConfig( const overlay = await applyByokToConfig(
@@ -34,17 +35,22 @@ async function resolveProviderForLane(
); );
Object.assign(cfg, overlay.config); Object.assign(cfg, overlay.config);
usedByok = overlay.usedByok; usedByok = overlay.usedByok;
byokModel = overlay.model;
} }
const resolvedModel = byokModel && byokModel.trim() !== '' ? byokModel : route.modelName;
const provider = getProviderInstance( const provider = getProviderInstance(
route.providerType as ProviderType, route.providerType as ProviderType,
cfg, cfg,
route.modelName, resolvedModel,
route.embeddingModelName, route.embeddingModelName,
route.ollamaBaseUrl, route.ollamaBaseUrl,
); );
return { provider, usedByok, route }; const updatedRoute = { ...route, modelName: resolvedModel };
return { provider, usedByok, route: updatedRoute };
} }
async function getChatProviderForBillingUser( async function getChatProviderForBillingUser(

View File

@@ -51,12 +51,12 @@ export async function getActiveByokKey(userId: string, provider: string) {
export async function resolveByokApiKey( export async function resolveByokApiKey(
userId: string, userId: string,
providerType: string, providerType: string,
): Promise<{ plaintext: string; provider: string } | null> { ): Promise<{ plaintext: string; provider: string; model: string | null } | null> {
const row = await getActiveByokKey(userId, providerType); const row = await getActiveByokKey(userId, providerType);
if (!row) return null; if (!row) return null;
try { try {
const plaintext = await decryptApiKey(row.encryptedKey); const plaintext = await decryptApiKey(row.encryptedKey);
return { plaintext, provider: row.provider }; return { plaintext, provider: row.provider, model: row.model };
} catch (err) { } catch (err) {
console.error('[byok] Failed to decrypt key for provider', providerType, err); console.error('[byok] Failed to decrypt key for provider', providerType, err);
return null; return null;
@@ -67,16 +67,17 @@ export async function applyByokToConfig(
billingUserId: string, billingUserId: string,
providerType: string, providerType: string,
config: Record<string, string>, config: Record<string, string>,
): Promise<{ config: Record<string, string>; usedByok: boolean }> { ): Promise<{ config: Record<string, string>; usedByok: boolean; model: string | null }> {
const byok = await resolveByokApiKey(billingUserId, providerType); const byok = await resolveByokApiKey(billingUserId, providerType);
if (!byok) return { config, usedByok: false }; if (!byok) return { config, usedByok: false, model: null };
const { apiKeyConfigKey } = getProviderConfigKeys(providerType); const { apiKeyConfigKey } = getProviderConfigKeys(providerType);
if (!apiKeyConfigKey) return { config, usedByok: false }; if (!apiKeyConfigKey) return { config, usedByok: false, model: null };
return { return {
config: { ...config, [apiKeyConfigKey]: byok.plaintext }, config: { ...config, [apiKeyConfigKey]: byok.plaintext },
usedByok: true, usedByok: true,
model: byok.model,
}; };
} }