fallback model/providers (#259)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
bc47b455b5
commit
e52916a7ef
@@ -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<string[]> {
|
||||
const allProviders = await getAllProviders();
|
||||
const providerMap = new Map(allProviders.map((provider) => [provider.id, provider]));
|
||||
const seen = new Set<string>();
|
||||
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}"`);
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
export async function setOpenClawDefaultModel(
|
||||
provider: string,
|
||||
modelOverride?: string,
|
||||
fallbackModels: string[] = []
|
||||
): Promise<void> {
|
||||
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<string, unknown>;
|
||||
const defaults = (agents.defaults || {}) as Record<string, unknown>;
|
||||
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<string, unknown> = {
|
||||
@@ -500,7 +512,8 @@ export async function syncProviderConfigToOpenClaw(
|
||||
export async function setOpenClawDefaultModelWithOverride(
|
||||
provider: string,
|
||||
modelOverride: string | undefined,
|
||||
override: RuntimeProviderConfigOverride
|
||||
override: RuntimeProviderConfigOverride,
|
||||
fallbackModels: string[] = []
|
||||
): Promise<void> {
|
||||
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<string, unknown>;
|
||||
const defaults = (agents.defaults || {}) as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const nextModels: Array<Record<string, unknown>> = [];
|
||||
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<string, unknown> = {
|
||||
baseUrl: override.baseUrl,
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface ProviderConfig {
|
||||
type: ProviderType;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
fallbackModels?: string[];
|
||||
fallbackProviderIds?: string[];
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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ログイン",
|
||||
|
||||
@@ -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": "删除提供商"
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface ProviderConfig {
|
||||
type: ProviderType;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
fallbackModels?: string[];
|
||||
fallbackProviderIds?: string[];
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
Reference in New Issue
Block a user