fallback model/providers (#259)

Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
Lingxuan Zuo
2026-03-03 10:18:52 +08:00
committed by GitHub
Unverified
parent bc47b455b5
commit e52916a7ef
8 changed files with 343 additions and 92 deletions

View File

@@ -36,6 +36,26 @@ import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
function normalizeFallbackProviderIds(ids?: string[]): string[] {
return Array.from(new Set((ids ?? []).filter(Boolean)));
}
function fallbackProviderIdsEqual(a?: string[], b?: string[]): boolean {
const left = normalizeFallbackProviderIds(a).sort();
const right = normalizeFallbackProviderIds(b).sort();
return left.length === right.length && left.every((id, index) => id === right[index]);
}
function normalizeFallbackModels(models?: string[]): string[] {
return Array.from(new Set((models ?? []).map((model) => model.trim()).filter(Boolean)));
}
function fallbackModelsEqual(a?: string[], b?: string[]): boolean {
const left = normalizeFallbackModels(a);
const right = normalizeFallbackModels(b);
return left.length === right.length && left.every((model, index) => model === right[index]);
}
export function ProvidersSettings() {
const { t } = useTranslation('settings');
const {
@@ -144,6 +164,7 @@ export function ProvidersSettings() {
<ProviderCard
key={provider.id}
provider={provider}
allProviders={providers}
isDefault={provider.id === defaultProviderId}
isEditing={editingProvider === provider.id}
onEdit={() => setEditingProvider(provider.id)}
@@ -179,6 +200,7 @@ export function ProvidersSettings() {
interface ProviderCardProps {
provider: ProviderWithKeyInfo;
allProviders: ProviderWithKeyInfo[];
isDefault: boolean;
isEditing: boolean;
onEdit: () => void;
@@ -196,6 +218,7 @@ interface ProviderCardProps {
function ProviderCard({
provider,
allProviders,
isDefault,
isEditing,
onEdit,
@@ -209,12 +232,18 @@ function ProviderCard({
const [newKey, setNewKey] = useState('');
const [baseUrl, setBaseUrl] = useState(provider.baseUrl || '');
const [modelId, setModelId] = useState(provider.model || '');
const [fallbackModelsText, setFallbackModelsText] = useState(
normalizeFallbackModels(provider.fallbackModels).join('\n')
);
const [fallbackProviderIds, setFallbackProviderIds] = useState<string[]>(
normalizeFallbackProviderIds(provider.fallbackProviderIds)
);
const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false);
const [saving, setSaving] = useState(false);
const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === provider.type);
const canEditConfig = Boolean(typeInfo?.showBaseUrl || typeInfo?.showModelId);
const canEditModelConfig = Boolean(typeInfo?.showBaseUrl || typeInfo?.showModelId);
useEffect(() => {
if (isEditing) {
@@ -222,13 +251,26 @@ function ProviderCard({
setShowKey(false);
setBaseUrl(provider.baseUrl || '');
setModelId(provider.model || '');
setFallbackModelsText(normalizeFallbackModels(provider.fallbackModels).join('\n'));
setFallbackProviderIds(normalizeFallbackProviderIds(provider.fallbackProviderIds));
}
}, [isEditing, provider.baseUrl, provider.model]);
}, [isEditing, provider.baseUrl, provider.fallbackModels, provider.fallbackProviderIds, provider.model]);
const fallbackOptions = allProviders.filter((candidate) => candidate.id !== provider.id);
const toggleFallbackProvider = (providerId: string) => {
setFallbackProviderIds((current) => (
current.includes(providerId)
? current.filter((id) => id !== providerId)
: [...current, providerId]
));
};
const handleSaveEdits = async () => {
setSaving(true);
try {
const payload: { newApiKey?: string; updates?: Partial<ProviderConfig> } = {};
const normalizedFallbackModels = normalizeFallbackModels(fallbackModelsText.split('\n'));
if (newKey.trim()) {
setValidating(true);
@@ -244,7 +286,7 @@ function ProviderCard({
payload.newApiKey = newKey.trim();
}
if (canEditConfig) {
{
if (typeInfo?.showModelId && !modelId.trim()) {
toast.error(t('aiProviders.toast.modelRequired'));
setSaving(false);
@@ -252,12 +294,18 @@ function ProviderCard({
}
const updates: Partial<ProviderConfig> = {};
if ((baseUrl.trim() || undefined) !== (provider.baseUrl || undefined)) {
if (typeInfo?.showBaseUrl && (baseUrl.trim() || undefined) !== (provider.baseUrl || undefined)) {
updates.baseUrl = baseUrl.trim() || undefined;
}
if ((modelId.trim() || undefined) !== (provider.model || undefined)) {
if (typeInfo?.showModelId && (modelId.trim() || undefined) !== (provider.model || undefined)) {
updates.model = modelId.trim() || undefined;
}
if (!fallbackModelsEqual(normalizedFallbackModels, provider.fallbackModels)) {
updates.fallbackModels = normalizedFallbackModels;
}
if (!fallbackProviderIdsEqual(fallbackProviderIds, provider.fallbackProviderIds)) {
updates.fallbackProviderIds = normalizeFallbackProviderIds(fallbackProviderIds);
}
if (Object.keys(updates).length > 0) {
payload.updates = updates;
}
@@ -308,9 +356,10 @@ function ProviderCard({
{/* Key row */}
{isEditing ? (
<div className="space-y-2">
{canEditConfig && (
<>
<div className="space-y-4">
{canEditModelConfig && (
<div className="space-y-3 rounded-md border p-3">
<p className="text-sm font-medium">{t('aiProviders.sections.model')}</p>
{typeInfo?.showBaseUrl && (
<div className="space-y-1">
<Label className="text-xs">{t('aiProviders.dialog.baseUrl')}</Label>
@@ -333,87 +382,158 @@ function ProviderCard({
/>
</div>
)}
</>
)}
{typeInfo?.apiKeyUrl && (
<div className="flex justify-start mb-1">
<a
href={typeInfo.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-1"
tabIndex={-1}
>
{t('aiProviders.oauth.getApiKey')} <ExternalLink className="h-3 w-3" />
</a>
</div>
)}
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showKey ? 'text' : 'password'}
placeholder={typeInfo?.requiresApiKey ? typeInfo?.placeholder : (typeInfo?.id === 'ollama' ? t('aiProviders.notRequired') : t('aiProviders.card.editKey'))}
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
className="pr-10 h-9 text-sm"
<div className="space-y-3 rounded-md border p-3">
<p className="text-sm font-medium">{t('aiProviders.sections.fallback')}</p>
<div className="space-y-1">
<Label className="text-xs">{t('aiProviders.dialog.fallbackModelIds')}</Label>
<textarea
value={fallbackModelsText}
onChange={(e) => setFallbackModelsText(e.target.value)}
placeholder={t('aiProviders.dialog.fallbackModelIdsPlaceholder')}
className="min-h-24 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showKey ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
<p className="text-xs text-muted-foreground">
{t('aiProviders.dialog.fallbackModelIdsHelp')}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleSaveEdits}
disabled={
validating
|| saving
|| (
!newKey.trim()
&& (baseUrl.trim() || undefined) === (provider.baseUrl || undefined)
&& (modelId.trim() || undefined) === (provider.model || undefined)
)
|| Boolean(typeInfo?.showModelId && !modelId.trim())
}
>
{validating || saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<div className="space-y-2">
<Label className="text-xs">{t('aiProviders.dialog.fallbackProviders')}</Label>
{fallbackOptions.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('aiProviders.dialog.noFallbackOptions')}</p>
) : (
<Check className="h-3.5 w-3.5" />
<div className="space-y-2 rounded-md border p-2">
{fallbackOptions.map((candidate) => (
<label key={candidate.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={fallbackProviderIds.includes(candidate.id)}
onChange={() => toggleFallbackProvider(candidate.id)}
/>
<span className="font-medium">{candidate.name}</span>
<span className="text-xs text-muted-foreground">{candidate.model || candidate.type}</span>
</label>
))}
</div>
)}
</Button>
<Button variant="ghost" size="sm" onClick={onCancelEdit}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<Label className="text-xs">{t('aiProviders.dialog.apiKey')}</Label>
<p className="text-xs text-muted-foreground">
{provider.hasKey
? t('aiProviders.dialog.apiKeyConfigured')
: t('aiProviders.dialog.apiKeyMissing')}
</p>
</div>
{provider.hasKey ? (
<Badge variant="secondary">{t('aiProviders.card.configured')}</Badge>
) : null}
</div>
{typeInfo?.apiKeyUrl && (
<div className="flex justify-start">
<a
href={typeInfo.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-1"
tabIndex={-1}
>
{t('aiProviders.oauth.getApiKey')} <ExternalLink className="h-3 w-3" />
</a>
</div>
)}
<div className="space-y-1">
<Label className="text-xs">{t('aiProviders.dialog.replaceApiKey')}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showKey ? 'text' : 'password'}
placeholder={typeInfo?.requiresApiKey ? typeInfo?.placeholder : (typeInfo?.id === 'ollama' ? t('aiProviders.notRequired') : t('aiProviders.card.editKey'))}
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
className="pr-10 h-9 text-sm"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showKey ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
<Button
variant="outline"
size="sm"
onClick={handleSaveEdits}
disabled={
validating
|| saving
|| (
!newKey.trim()
&& (baseUrl.trim() || undefined) === (provider.baseUrl || undefined)
&& (modelId.trim() || undefined) === (provider.model || undefined)
&& fallbackModelsEqual(normalizeFallbackModels(fallbackModelsText.split('\n')), provider.fallbackModels)
&& fallbackProviderIdsEqual(fallbackProviderIds, provider.fallbackProviderIds)
)
|| Boolean(typeInfo?.showModelId && !modelId.trim())
}
>
{validating || saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Check className="h-3.5 w-3.5" />
)}
</Button>
<Button variant="ghost" size="sm" onClick={onCancelEdit}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t('aiProviders.dialog.replaceApiKeyHelp')}
</p>
</div>
</div>
</div>
) : (
<div className="flex items-center justify-between rounded-md bg-muted/50 px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
{typeInfo?.isOAuth ? (
<>
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
</>
) : (
<>
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-mono text-muted-foreground truncate">
{provider.hasKey
? (provider.keyMasked && provider.keyMasked.length > 12
? `${provider.keyMasked.substring(0, 4)}...${provider.keyMasked.substring(provider.keyMasked.length - 4)}`
: provider.keyMasked)
: t('aiProviders.card.noKey')}
</span>
{provider.hasKey && (
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2 min-w-0">
{typeInfo?.isOAuth ? (
<>
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
)}
</>
)}
</>
) : (
<>
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-mono text-muted-foreground truncate">
{provider.hasKey
? (provider.keyMasked && provider.keyMasked.length > 12
? `${provider.keyMasked.substring(0, 4)}...${provider.keyMasked.substring(provider.keyMasked.length - 4)}`
: provider.keyMasked)
: t('aiProviders.card.noKey')}
</span>
{provider.hasKey && (
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
)}
</>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{t('aiProviders.card.fallbacks', {
count: (provider.fallbackModels?.length ?? 0) + (provider.fallbackProviderIds?.length ?? 0),
names: [
...normalizeFallbackModels(provider.fallbackModels),
...normalizeFallbackProviderIds(provider.fallbackProviderIds)
.map((fallbackId) => allProviders.find((candidate) => candidate.id === fallbackId)?.name)
.filter(Boolean),
].join(', ') || t('aiProviders.card.none'),
})}
</p>
</div>
<div className="flex gap-0.5 shrink-0 ml-2">
<Button

View File

@@ -13,6 +13,10 @@
"aiProviders": {
"title": "AI Providers",
"description": "Configure your AI model providers and API keys",
"sections": {
"model": "Model Settings",
"fallback": "Fallback Settings"
},
"add": "Add Provider",
"custom": "Custom",
"notRequired": "Not required",
@@ -26,9 +30,19 @@
"desc": "Configure a new AI model provider",
"displayName": "Display Name",
"apiKey": "API Key",
"apiKeyConfigured": "An API key is already stored for this provider.",
"apiKeyMissing": "No API key is stored for this provider yet.",
"apiKeyStored": "Your API key is stored locally on your machine.",
"replaceApiKey": "Replace API Key",
"replaceApiKeyHelp": "Leave this field empty if you want to keep the currently stored API key.",
"baseUrl": "Base URL",
"modelId": "Model ID",
"fallbackModels": "Fallback Models",
"fallbackProviders": "Fallback Providers",
"fallbackModelIds": "Fallback Model IDs",
"fallbackModelIdsPlaceholder": "gpt-4.1-mini\nanother-model-id",
"fallbackModelIdsHelp": "One model ID per line. These models use the current provider config before falling back to other providers.",
"noFallbackOptions": "Add another provider first to use it as a fallback target.",
"cancel": "Cancel",
"change": "Change provider",
"add": "Add Provider",
@@ -39,6 +53,9 @@
"default": "Default",
"configured": "Configured",
"noKey": "No API key set",
"none": "None",
"fallbacks_one": "Fallback: {{names}}",
"fallbacks_other": "Fallbacks ({{count}}): {{names}}",
"setDefault": "Set as default",
"editKey": "Edit API key",
"delete": "Delete provider"

View File

@@ -13,6 +13,10 @@
"aiProviders": {
"title": "AI プロバイダー",
"description": "AI モデルプロバイダーと API キーを設定",
"sections": {
"model": "モデル設定",
"fallback": "フォールバック設定"
},
"add": "プロバイダーを追加",
"custom": "カスタム",
"notRequired": "不要",
@@ -26,9 +30,19 @@
"desc": "新しい AI モデルプロバイダーを構成",
"displayName": "表示名",
"apiKey": "API キー",
"apiKeyConfigured": "このプロバイダーには API キーが保存されています。",
"apiKeyMissing": "このプロバイダーにはまだ API キーが保存されていません。",
"apiKeyStored": "API キーはローカルマシンに保存されます。",
"replaceApiKey": "API キーを置き換える",
"replaceApiKeyHelp": "現在保存されている API キーをそのまま使う場合は、この欄を空のままにしてください。",
"baseUrl": "ベース URL",
"modelId": "モデル ID",
"fallbackModels": "フォールバックモデル",
"fallbackProviders": "別プロバイダーへのフォールバック",
"fallbackModelIds": "同一プロバイダーのフォールバックモデル ID",
"fallbackModelIdsPlaceholder": "gpt-4.1-mini\nanother-model-id",
"fallbackModelIdsHelp": "1 行につき 1 つのモデル ID を指定します。まず現在のプロバイダー内でこれらを試し、その後ほかのプロバイダーへフォールバックします。",
"noFallbackOptions": "フォールバック先にするには、先に別のプロバイダーを追加してください。",
"cancel": "キャンセル",
"change": "プロバイダーを変更",
"add": "プロバイダーを追加",
@@ -39,6 +53,9 @@
"default": "デフォルト",
"configured": "構成済み",
"noKey": "API キー未設定",
"none": "なし",
"fallbacks_one": "フォールバック: {{names}}",
"fallbacks_other": "フォールバック ({{count}}): {{names}}",
"setDefault": "デフォルトに設定",
"editKey": "API キーを編集",
"delete": "プロバイダーを削除"
@@ -53,7 +70,8 @@
"updated": "プロバイダーが更新されました",
"failedUpdate": "プロバイダーの更新に失敗しました",
"invalidKey": "無効な API キー",
"modelRequired": "モデル ID が必要です"
"modelRequired": "モデル ID が必要です",
"minimaxConflict": "MiniMax (Global) と MiniMax (CN) は同時に追加できません。"
},
"oauth": {
"loginMode": "OAuthログイン",

View File

@@ -13,6 +13,10 @@
"aiProviders": {
"title": "AI 模型提供商",
"description": "配置 AI 模型提供商和 API 密钥",
"sections": {
"model": "模型配置",
"fallback": "回退配置"
},
"add": "添加提供商",
"custom": "自定义",
"notRequired": "非必填",
@@ -26,9 +30,19 @@
"desc": "配置新的 AI 模型提供商",
"displayName": "显示名称",
"apiKey": "API 密钥",
"apiKeyConfigured": "这个 provider 已经保存了 API key。",
"apiKeyMissing": "这个 provider 还没有保存 API key。",
"apiKeyStored": "您的 API 密钥存储在本地机器上。",
"replaceApiKey": "替换 API Key",
"replaceApiKeyHelp": "如果想保留当前已保存的 API key这里留空即可。",
"baseUrl": "基础 URL",
"modelId": "模型 ID",
"fallbackModels": "回退模型",
"fallbackProviders": "跨 Provider 回退",
"fallbackModelIds": "同 Provider 回退模型 ID",
"fallbackModelIdsPlaceholder": "gpt-4.1-mini\nanother-model-id",
"fallbackModelIdsHelp": "每行一个模型 ID。会先使用当前 provider 的这些模型,再回退到其他 provider。",
"noFallbackOptions": "请先添加其他 provider才能把它设为回退目标。",
"cancel": "取消",
"change": "更换提供商",
"add": "添加提供商",
@@ -39,6 +53,9 @@
"default": "默认",
"configured": "已配置",
"noKey": "未设置 API 密钥",
"none": "无",
"fallbacks_one": "回退:{{names}}",
"fallbacks_other": "回退({{count}} 个):{{names}}",
"setDefault": "设为默认",
"editKey": "编辑 API 密钥",
"delete": "删除提供商"

View File

@@ -29,6 +29,8 @@ export interface ProviderConfig {
type: ProviderType;
baseUrl?: string;
model?: string;
fallbackModels?: string[];
fallbackProviderIds?: string[];
enabled: boolean;
createdAt: string;
updatedAt: string;