feat(byok): add model selection to BYOK settings panel and overlay custom model on route resolution
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user