From e52916a7ef05866db8cd1f6aaca71579ed1bf8aa Mon Sep 17 00:00:00 2001 From: Lingxuan Zuo Date: Tue, 3 Mar 2026 10:18:52 +0800 Subject: [PATCH] fallback model/providers (#259) Co-authored-by: zuolingxuan --- electron/main/ipc-handlers.ts | 64 +++- electron/utils/openclaw-auth.ts | 37 ++- electron/utils/secure-storage.ts | 2 + src/components/settings/ProvidersSettings.tsx | 276 +++++++++++++----- src/i18n/locales/en/settings.json | 17 ++ src/i18n/locales/ja/settings.json | 20 +- src/i18n/locales/zh/settings.json | 17 ++ src/lib/providers.ts | 2 + 8 files changed, 343 insertions(+), 92 deletions(-) diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index ae3bc1af2..9de83b2c8 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -16,6 +16,7 @@ import { hasApiKey, saveProvider, getProvider, + getAllProviders, deleteProvider, setDefaultProvider, getDefaultProvider, @@ -48,6 +49,7 @@ import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-set import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config'; import { whatsAppLoginManager } from '../utils/whatsapp-login'; import { getProviderConfig } from '../utils/provider-registry'; +import { getProviderDefaultModel } from '../utils/provider-registry'; import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; import { applyProxySettings } from './proxy'; import { proxyAwareFetch } from '../utils/proxy-fetch'; @@ -73,6 +75,54 @@ export function getOpenClawProviderKey(type: string, providerId: string): string return type; } +function getProviderModelRef(config: ProviderConfig): string | undefined { + const providerKey = getOpenClawProviderKey(config.type, config.id); + + if (config.model) { + return config.model.startsWith(`${providerKey}/`) + ? config.model + : `${providerKey}/${config.model}`; + } + + return getProviderDefaultModel(config.type); +} + +async function getProviderFallbackModelRefs(config: ProviderConfig): Promise { + const allProviders = await getAllProviders(); + const providerMap = new Map(allProviders.map((provider) => [provider.id, provider])); + const seen = new Set(); + const results: string[] = []; + const providerKey = getOpenClawProviderKey(config.type, config.id); + + for (const fallbackModel of config.fallbackModels ?? []) { + const normalizedModel = fallbackModel.trim(); + if (!normalizedModel) continue; + + const modelRef = normalizedModel.startsWith(`${providerKey}/`) + ? normalizedModel + : `${providerKey}/${normalizedModel}`; + + if (seen.has(modelRef)) continue; + seen.add(modelRef); + results.push(modelRef); + } + + for (const fallbackId of config.fallbackProviderIds ?? []) { + if (!fallbackId || fallbackId === config.id) continue; + + const fallbackProvider = providerMap.get(fallbackId); + if (!fallbackProvider) continue; + + const modelRef = getProviderModelRef(fallbackProvider); + if (!modelRef || seen.has(modelRef)) continue; + + seen.add(modelRef); + results.push(modelRef); + } + + return results; +} + /** * Register all IPC handlers */ @@ -1107,6 +1157,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Sync the provider configuration to openclaw.json so Gateway knows about it try { + const fallbackModels = await getProviderFallbackModelRefs(nextConfig); const meta = getProviderConfig(nextConfig.type); const api = nextConfig.type === 'custom' || nextConfig.type === 'ollama' ? 'openai-completions' : meta?.api; @@ -1141,12 +1192,12 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { ? `${ock}/${nextConfig.model}` : undefined; if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') { - await setOpenClawDefaultModel(nextConfig.type, modelOverride); + await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); } else { await setOpenClawDefaultModelWithOverride(ock, modelOverride, { baseUrl: nextConfig.baseUrl, api: 'openai-completions', - }); + }, fallbackModels); } } @@ -1222,6 +1273,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { try { const ock = getOpenClawProviderKey(provider.type, providerId); const providerKey = await getApiKey(providerId); + const fallbackModels = await getProviderFallbackModelRefs(provider); // OAuth providers (qwen-portal, minimax-portal, minimax-portal-cn) might use OAuth OR a direct API key. // Treat them as OAuth only if they don't have a local API key configured. @@ -1240,9 +1292,9 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { await setOpenClawDefaultModelWithOverride(ock, modelOverride, { baseUrl: provider.baseUrl, api: 'openai-completions', - }); + }, fallbackModels); } else { - await setOpenClawDefaultModel(provider.type, modelOverride); + await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); } // Keep auth-profiles in sync with the default provider instance. @@ -1270,13 +1322,13 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { ? 'minimax-portal' : provider.type; - await setOpenClawDefaultModelWithOverride(targetProviderKey, undefined, { + await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), { baseUrl, api, authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, // Relies on OpenClaw Gateway native auth-profiles syncing apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', - }); + }, fallbackModels); logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`); diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index e51e4c873..3beabb5bc 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -358,7 +358,11 @@ export function buildProviderEnvVars(providers: Array<{ type: string; apiKey: st * Update the OpenClaw config to use the given provider and model * Writes to ~/.openclaw/openclaw.json */ -export async function setOpenClawDefaultModel(provider: string, modelOverride?: string): Promise { +export async function setOpenClawDefaultModel( + provider: string, + modelOverride?: string, + fallbackModels: string[] = [] +): Promise { const config = await readOpenClawJson(); const model = modelOverride || getProviderDefaultModel(provider); @@ -370,11 +374,17 @@ export async function setOpenClawDefaultModel(provider: string, modelOverride?: const modelId = model.startsWith(`${provider}/`) ? model.slice(provider.length + 1) : model; + const fallbackModelIds = fallbackModels + .filter((fallback) => fallback.startsWith(`${provider}/`)) + .map((fallback) => fallback.slice(provider.length + 1)); // Set the default model for the agents const agents = (config.agents || {}) as Record; const defaults = (agents.defaults || {}) as Record; - defaults.model = { primary: model }; + defaults.model = { + primary: model, + fallbacks: fallbackModels, + }; agents.defaults = defaults; config.agents = agents; @@ -401,8 +411,10 @@ export async function setOpenClawDefaultModel(provider: string, modelOverride?: mergedModels.push(item); } } - if (modelId && !mergedModels.some((m) => m.id === modelId)) { - mergedModels.push({ id: modelId, name: modelId }); + for (const candidateModelId of [modelId, ...fallbackModelIds]) { + if (candidateModelId && !mergedModels.some((m) => m.id === candidateModelId)) { + mergedModels.push({ id: candidateModelId, name: candidateModelId }); + } } const providerEntry: Record = { @@ -500,7 +512,8 @@ export async function syncProviderConfigToOpenClaw( export async function setOpenClawDefaultModelWithOverride( provider: string, modelOverride: string | undefined, - override: RuntimeProviderConfigOverride + override: RuntimeProviderConfigOverride, + fallbackModels: string[] = [] ): Promise { const config = await readOpenClawJson(); @@ -513,10 +526,16 @@ export async function setOpenClawDefaultModelWithOverride( const modelId = model.startsWith(`${provider}/`) ? model.slice(provider.length + 1) : model; + const fallbackModelIds = fallbackModels + .filter((fallback) => fallback.startsWith(`${provider}/`)) + .map((fallback) => fallback.slice(provider.length + 1)); const agents = (config.agents || {}) as Record; const defaults = (agents.defaults || {}) as Record; - defaults.model = { primary: model }; + defaults.model = { + primary: model, + fallbacks: fallbackModels, + }; agents.defaults = defaults; config.agents = agents; @@ -525,7 +544,11 @@ export async function setOpenClawDefaultModelWithOverride( const providers = (models.providers || {}) as Record; const nextModels: Array> = []; - if (modelId) nextModels.push({ id: modelId, name: modelId }); + for (const candidateModelId of [modelId, ...fallbackModelIds]) { + if (candidateModelId && !nextModels.some((entry) => entry.id === candidateModelId)) { + nextModels.push({ id: candidateModelId, name: candidateModelId }); + } + } const nextProvider: Record = { baseUrl: override.baseUrl, diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index 495f1df8a..3b408473a 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -35,6 +35,8 @@ export interface ProviderConfig { type: ProviderType; baseUrl?: string; model?: string; + fallbackModels?: string[]; + fallbackProviderIds?: string[]; enabled: boolean; createdAt: string; updatedAt: string; diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index ff18d2456..5bcc3613f 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -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() { 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( + 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 } = {}; + 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 = {}; - 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 ? ( -
- {canEditConfig && ( - <> +
+ {canEditModelConfig && ( +
+

{t('aiProviders.sections.model')}

{typeInfo?.showBaseUrl && (
@@ -333,87 +382,158 @@ function ProviderCard({ />
)} - - )} - {typeInfo?.apiKeyUrl && ( - )} -
-
- setNewKey(e.target.value)} - className="pr-10 h-9 text-sm" +
+

{t('aiProviders.sections.fallback')}

+
+ +