feat(web): manage multiple custom (OpenAI-compatible) providers in console UI

Adds first-class support for configuring more than one custom
OpenAI-compatible provider (e.g. SiliconFlow, DeepSeek, local vLLM)
and switching the active one from the web console, addressing #2838.

Backend:
- config: new `custom_providers` (list) and `custom_active_provider`
  fields, fully backward compatible with the legacy single
  `open_ai_api_base`/`model` fields (used as fallback).
- models/custom_provider.py: centralized resolver
  `resolve_custom_credentials()` returning (api_key, api_base, model),
  with active-provider selection and graceful fallback.
- chat_gpt_bot.py wired to use the resolver.
- web_channel.py: `_provider_overview` expands `custom_providers` into
  one card per provider (id `custom:<name>`, active flag, masked key);
  new POST actions `set_custom_provider`, `delete_custom_provider`,
  `set_active_custom_provider` with hermetic persistence + bridge reset.

Frontend:
- console.js: dedicated "Custom providers" section with add / edit /
  delete / set-active actions, masked-key keep-existing handling, and
  ~20 new zh/en i18n strings.
- chat.html: custom provider modal.

Tests:
- tests/test_custom_provider.py (11) - resolver/config behavior.
- tests/test_custom_provider_handlers.py (18) - write-side handlers and
  overview expansion, including duplicate-name rejection.

All 29 unit tests pass.
This commit is contained in:
kirs-hi
2026-06-10 18:12:30 +08:00
parent f5caba81d6
commit cffa590d3e
11 changed files with 1262 additions and 17 deletions

View File

@@ -1165,6 +1165,72 @@
</div>
</div>
<!-- Custom Provider Modal (multiple OpenAI-compatible providers) -->
<div id="custom-provider-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
w-full max-w-md mx-4">
<div class="p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-violet-50 dark:bg-violet-900/20 flex items-center justify-center flex-shrink-0">
<i class="fas fa-sliders text-violet-500"></i>
</div>
<div class="min-w-0 flex-1">
<h3 id="custom-provider-modal-title" class="font-semibold text-slate-800 dark:text-slate-100 text-base"></h3>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="models_custom_name">名称</label>
<input id="custom-provider-name" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-violet-500 transition-colors"
placeholder="siliconflow">
</div>
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
<input id="custom-provider-base" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-violet-500 font-mono transition-colors"
placeholder="https://...../v1">
</div>
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
<input id="custom-provider-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-violet-500 font-mono transition-colors"
placeholder="sk-...">
</div>
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="models_custom_default_model">默认模型(可选)</label>
<input id="custom-provider-model" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-violet-500 font-mono transition-colors"
placeholder="deepseek-ai/DeepSeek-V3">
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
<span id="custom-provider-modal-status"
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-left"></span>
<button id="custom-provider-modal-cancel"
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
text-slate-600 dark:text-slate-300 text-sm font-medium
hover:bg-slate-50 dark:hover:bg-white/5
cursor-pointer transition-colors duration-150"
data-i18n="cancel">取消</button>
<button id="custom-provider-modal-save"
class="px-4 py-2 rounded-lg bg-violet-500 hover:bg-violet-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
data-i18n="save">保存</button>
</div>
</div>
</div>
<script defer src="assets/js/console.js"></script>
</body>
</html>

View File

@@ -32,6 +32,24 @@ const I18N = {
models_clear_credential: '清除凭据',
models_base_default_hint: '留空将使用官方默认地址',
models_base_default: '默认',
models_custom_section: '自定义厂商',
models_custom_section_desc: '配置多个 OpenAI 兼容厂商,自由切换',
models_custom_add: '添加自定义厂商',
models_custom_name: '名称',
models_custom_name_placeholder: '例如 siliconflow',
models_custom_default_model: '默认模型(可选)',
models_custom_default_model_placeholder: '例如 deepseek-ai/DeepSeek-V3',
models_custom_active: '使用中',
models_custom_set_active: '设为使用中',
models_custom_delete: '删除',
models_custom_delete_confirm_title: '删除自定义厂商',
models_custom_delete_confirm_msg: '确定删除该自定义厂商吗?此操作无法撤销。',
models_custom_name_required: '请填写名称',
models_custom_base_required: '请填写 API Base',
models_custom_name_exists: '该名称已存在',
models_custom_empty: '尚未配置自定义厂商,点击添加',
models_custom_edit_title: '编辑自定义厂商',
models_custom_add_title: '添加自定义厂商',
models_capability_chat: '主模型',
models_capability_chat_desc: '用于基础对话和 Agent 推理',
models_capability_vision: '图像理解',
@@ -236,6 +254,24 @@ const I18N = {
models_clear_credential: 'Clear credentials',
models_base_default_hint: 'Leave blank to use the official default base URL',
models_base_default: 'Default',
models_custom_section: 'Custom Providers',
models_custom_section_desc: 'Configure multiple OpenAI-compatible providers and switch freely',
models_custom_add: 'Add custom provider',
models_custom_name: 'Name',
models_custom_name_placeholder: 'e.g. siliconflow',
models_custom_default_model: 'Default model (optional)',
models_custom_default_model_placeholder: 'e.g. deepseek-ai/DeepSeek-V3',
models_custom_active: 'Active',
models_custom_set_active: 'Set active',
models_custom_delete: 'Delete',
models_custom_delete_confirm_title: 'Delete custom provider',
models_custom_delete_confirm_msg: 'Delete this custom provider? This cannot be undone.',
models_custom_name_required: 'Name is required',
models_custom_base_required: 'API Base is required',
models_custom_name_exists: 'A provider with this name already exists',
models_custom_empty: 'No custom providers yet, click to add',
models_custom_edit_title: 'Edit custom provider',
models_custom_add_title: 'Add custom provider',
models_capability_chat: 'Main Model',
models_capability_chat_desc: 'Used for basic chat and agent reasoning',
models_capability_vision: 'Image Understanding',
@@ -4732,16 +4768,26 @@ function renderModelsView() {
const container = document.getElementById('models-content');
container.innerHTML = '';
container.appendChild(renderVendorsSection());
container.appendChild(renderCustomProvidersSection());
MODELS_CAPABILITY_DEFS.forEach(def => container.appendChild(renderCapabilityCard(def)));
}
// True when a provider card is one of the expanded custom (OpenAI-compatible)
// providers — these are managed in their own section, not the vendor grid.
function isCustomProviderCard(p) {
return !!(p && p.is_custom && p.custom_name);
}
// ---------- Vendor section (Layer 1) -----------------------------------
function renderVendorsSection() {
const wrap = document.createElement('div');
wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6';
const configured = modelsState.providers.filter(p => p.configured);
// Expanded custom providers live in their own section; keep the built-in
// vendor grid focused on the canonical (field-based) providers.
const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p));
const configured = builtinProviders.filter(p => p.configured);
const header = `
<div class="flex items-start gap-3 mb-5">
@@ -4752,7 +4798,7 @@ function renderVendorsSection() {
<h3 class="font-semibold text-slate-800 dark:text-slate-100">${t('models_section_vendors')}</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">${t('models_section_vendors_desc')}</p>
</div>
<span class="text-xs text-slate-400 dark:text-slate-500 mt-2 flex-shrink-0">${configured.length}/${modelsState.providers.length}</span>
<span class="text-xs text-slate-400 dark:text-slate-500 mt-2 flex-shrink-0">${configured.length}/${builtinProviders.length}</span>
</div>`;
let body;
@@ -4812,6 +4858,98 @@ function renderProviderLogo(p, sizePx) {
</span>`;
}
// ---------- Custom providers section (multiple OpenAI-compatible) -------
// Renders the user-defined OpenAI-compatible providers as a dedicated,
// independently managed list: add / edit / delete / activate. The backend
// expands `custom_providers` into provider cards with id="custom:<name>",
// is_custom=true, custom_name and an `active` flag (see
// ModelsHandler._custom_provider_cards / _provider_overview).
function getCustomProviderCards() {
return modelsState.providers.filter(isCustomProviderCard);
}
function renderCustomProvidersSection() {
const wrap = document.createElement('div');
wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6';
const customs = getCustomProviderCards();
const header = `
<div class="flex items-start gap-3 mb-5">
<div class="w-9 h-9 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center flex-shrink-0">
<i class="fas fa-sliders text-violet-500 text-sm"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-slate-800 dark:text-slate-100">${t('models_custom_section')}</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">${t('models_custom_section_desc')}</p>
</div>
<button onclick="openCustomProviderModal('')"
class="mt-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-violet-50 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400 hover:bg-violet-100 dark:hover:bg-violet-900/50 cursor-pointer transition-colors flex-shrink-0">
<i class="fas fa-plus text-[10px] mr-1"></i>${t('models_custom_add')}
</button>
</div>`;
let body;
if (customs.length === 0) {
body = `
<div class="flex flex-col items-center justify-center py-8 px-4 rounded-lg border border-dashed border-slate-200 dark:border-white/10">
<p class="text-sm text-slate-500 dark:text-slate-400 text-center">${t('models_custom_empty')}</p>
</div>`;
} else {
body = `<div class="space-y-2.5">
${customs.map(renderCustomProviderRow).join('')}
</div>`;
}
wrap.innerHTML = header + body;
return wrap;
}
function renderCustomProviderRow(p) {
const name = p.custom_name || '';
const nameEsc = escapeHtml(name);
// The active provider gets a highlighted ring + badge; others show a
// "set active" affordance.
const activeBadge = p.active
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-medium bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 flex-shrink-0">
<i class="fas fa-check text-[9px] mr-0.5"></i>${t('models_custom_active')}</span>`
: `<button onclick="setActiveCustomProvider('${nameEsc}')"
class="px-2 py-0.5 rounded-full text-[10px] font-medium border border-slate-200 dark:border-white/10 text-slate-500 dark:text-slate-400 hover:border-emerald-300 hover:text-emerald-500 cursor-pointer transition-colors flex-shrink-0">
${t('models_custom_set_active')}</button>`;
const ring = p.active
? 'border-emerald-300 dark:border-emerald-500/40 bg-emerald-50/40 dark:bg-emerald-900/10'
: 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5';
const model = p.model
? `<span class="text-[11px] text-slate-400 dark:text-slate-500 font-mono truncate">${escapeHtml(p.model)}</span>`
: '';
const base = p.api_base
? `<span class="text-[11px] text-slate-400 dark:text-slate-500 font-mono truncate">${escapeHtml(p.api_base)}</span>`
: '';
return `
<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg border ${ring} transition-colors">
${renderProviderLogo(p, 28)}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 truncate">${nameEsc}</span>
${activeBadge}
</div>
<div class="flex items-center gap-2 mt-0.5">${base}${model ? '<span class="text-slate-300 dark:text-slate-600">·</span>' + model : ''}</div>
</div>
<button onclick="openCustomProviderModal('${nameEsc}')" title="${t('models_custom_edit_title')}"
class="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 dark:text-slate-500 hover:text-primary-500 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors flex-shrink-0">
<i class="fas fa-pen-to-square text-[12px]"></i>
</button>
<button onclick="deleteCustomProvider('${nameEsc}')" title="${t('models_custom_delete')}"
class="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 dark:text-slate-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer transition-colors flex-shrink-0">
<i class="fas fa-trash-can text-[12px]"></i>
</button>
</div>`;
}
// ---------- Capability cards (Layer 2) ---------------------------------
function renderCapabilityCard(def) {
@@ -5933,11 +6071,15 @@ function openVendorModal(providerId, onSaved) {
// currently selected vendor via its own background highlight, so we
// intentionally suppress the global active-row ✓ for this picker
// (see CSS) — otherwise configured + selected rows would show two.
const unconfigured = modelsState.providers.filter(p => !p.configured);
const defaultId = (unconfigured[0] && unconfigured[0].id) || (modelsState.providers[0] && modelsState.providers[0].id) || '';
// Custom (OpenAI-compatible) providers are managed in their own
// section with a dedicated modal, so they are excluded from this
// built-in vendor picker.
const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p));
const unconfigured = builtinProviders.filter(p => !p.configured);
const defaultId = (unconfigured[0] && unconfigured[0].id) || (builtinProviders[0] && builtinProviders[0].id) || '';
pickerWrap.classList.remove('hidden');
const pickerEl = document.getElementById('vendor-modal-picker');
const pickerOpts = modelsState.providers.map(p => ({
const pickerOpts = builtinProviders.map(p => ({
value: p.id,
label: localizedLabel(p.label),
_configured: !!p.configured,
@@ -6100,6 +6242,159 @@ function clearVendorModal() {
});
}
// =====================================================================
// Custom (OpenAI-compatible) provider modal — add / edit
// =====================================================================
// State for the dedicated custom-provider modal. `originalName` is empty when
// adding and set to the provider name when editing (so the backend can rename
// without losing the entry).
let customProviderModalState = { originalName: '' };
function openCustomProviderModal(name) {
const editing = !!name;
customProviderModalState = { originalName: editing ? name : '' };
const card = editing ? getCustomProviderCards().find(p => p.custom_name === name) : null;
const overlay = document.getElementById('custom-provider-modal-overlay');
if (!overlay) return;
document.getElementById('custom-provider-modal-title').textContent =
editing ? t('models_custom_edit_title') : t('models_custom_add_title');
const nameInput = document.getElementById('custom-provider-name');
const baseInput = document.getElementById('custom-provider-base');
const keyInput = document.getElementById('custom-provider-key');
const modelInput = document.getElementById('custom-provider-model');
nameInput.value = card ? (card.custom_name || '') : '';
baseInput.value = card ? (card.api_base || '') : '';
modelInput.value = card ? (card.model || '') : '';
// Surface the masked key as the value for configured providers so the
// "already set" state is unambiguous; an untouched masked value means
// "keep the existing key" on save (mirrors the vendor modal contract).
if (card && card.configured && card.api_key_masked) {
keyInput.value = card.api_key_masked;
keyInput.dataset.masked = '1';
keyInput.dataset.maskedVal = card.api_key_masked;
} else {
keyInput.value = '';
keyInput.dataset.masked = '';
keyInput.dataset.maskedVal = '';
}
keyInput.oninput = function () {
if (keyInput.dataset.masked === '1' && keyInput.value !== keyInput.dataset.maskedVal) {
keyInput.dataset.masked = '';
}
};
const statusEl = document.getElementById('custom-provider-modal-status');
if (statusEl) { statusEl.textContent = ''; statusEl.classList.add('opacity-0'); }
overlay.classList.remove('hidden');
document.getElementById('custom-provider-modal-cancel').onclick = closeCustomProviderModal;
document.getElementById('custom-provider-modal-save').onclick = saveCustomProviderModal;
function onOverlayClick(e) {
if (e.target === overlay) {
closeCustomProviderModal();
overlay.removeEventListener('click', onOverlayClick);
}
}
overlay.addEventListener('click', onOverlayClick);
nameInput.focus();
}
function closeCustomProviderModal() {
const overlay = document.getElementById('custom-provider-modal-overlay');
if (overlay) overlay.classList.add('hidden');
}
function saveCustomProviderModal() {
const name = document.getElementById('custom-provider-name').value.trim();
const apiBase = document.getElementById('custom-provider-base').value.trim();
const model = document.getElementById('custom-provider-model').value.trim();
const keyInput = document.getElementById('custom-provider-key');
if (!name) {
showStatus('custom-provider-modal-status', 'models_custom_name_required', true);
document.getElementById('custom-provider-name').focus();
return;
}
const editing = !!customProviderModalState.originalName;
if (!editing && !apiBase) {
showStatus('custom-provider-modal-status', 'models_custom_base_required', true);
document.getElementById('custom-provider-base').focus();
return;
}
// Untouched masked key => no change (omit from payload).
let apiKey = keyInput.value.trim();
if (keyInput.dataset.masked === '1' && apiKey === (keyInput.dataset.maskedVal || '')) {
apiKey = '';
}
const payload = {
action: 'set_custom_provider',
name: name,
api_base: apiBase,
model: model,
};
if (apiKey) payload.api_key = apiKey;
if (editing) payload.original_name = customProviderModalState.originalName;
const btn = document.getElementById('custom-provider-modal-save');
btn.disabled = true;
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(r => r.json()).then(data => {
btn.disabled = false;
if (data.status === 'success') {
closeCustomProviderModal();
loadModelsView();
} else {
// Surface the most useful known error; fall back to generic save fail.
const msg = (data.message || '').includes('already exists')
? 'models_custom_name_exists' : 'models_save_failed';
showStatus('custom-provider-modal-status', msg, true);
}
}).catch(() => {
btn.disabled = false;
showStatus('custom-provider-modal-status', 'models_save_failed', true);
});
}
function setActiveCustomProvider(name) {
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_active_custom_provider', name: name }),
}).then(r => r.json()).then(data => {
if (data.status === 'success') loadModelsView();
}).catch(() => { /* noop */ });
}
function deleteCustomProvider(name) {
showConfirmDialog({
title: t('models_custom_delete_confirm_title'),
message: t('models_custom_delete_confirm_msg'),
okText: t('models_custom_delete'),
cancelText: t('cancel'),
onConfirm: () => {
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete_custom_provider', name: name }),
}).then(r => r.json()).then(data => {
if (data.status === 'success') loadModelsView();
}).catch(() => { /* noop */ });
}
});
}
// =====================================================================
// Channels View
// =====================================================================

View File

@@ -1601,6 +1601,7 @@ class ConfigHandler:
"open_ai_api_key", "deepseek_api_key", "qianfan_api_key", "claude_api_key", "gemini_api_key",
"zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key",
"ark_api_key", "minimax_api_key", "linkai_api_key", "custom_api_key", "mimo_api_key",
"custom_providers", "custom_active_provider",
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
"enable_thinking", "self_evolution_enabled", "web_password",
}
@@ -2131,13 +2132,85 @@ class ModelsHandler:
def _is_real_key(value: str) -> bool:
return bool(value) and value not in ("", "YOUR API KEY", "YOUR_API_KEY")
@classmethod
def _custom_provider_cards(cls, local_config: dict) -> List[dict]:
"""Expand ``custom_providers`` into one card per provider.
Each user-defined OpenAI-compatible provider becomes its own card with
a synthetic id ``custom:<name>`` so the frontend can render, edit,
delete and activate them independently. The card carries
``is_custom=True`` and ``active`` flags that the UI uses to render the
extra controls (delete button, "set active" affordance).
Returns an empty list when no multi-providers are configured, in which
case the caller keeps the single legacy ``custom`` card untouched —
guaranteeing backward compatibility with the flat
``custom_api_key`` / ``custom_api_base`` config.
"""
try:
from models.custom_provider import get_custom_providers
providers = get_custom_providers()
except Exception as e: # pragma: no cover - defensive
logger.warning(f"[ModelsHandler] failed to load custom_providers: {e}")
providers = []
if not providers:
return []
active_name = (local_config.get("custom_active_provider") or "").strip()
# When no valid active name is set, the resolver treats the first entry
# as active; mirror that here so exactly one card is highlighted.
names = [p.get("name") for p in providers]
if active_name not in names:
active_name = names[0] if names else ""
meta = ConfigHandler.PROVIDER_MODELS.get("custom") or {}
cards = []
for p in providers:
name = p.get("name") or ""
raw_key = p.get("api_key") or ""
raw_base = p.get("api_base") or ""
configured = cls._is_real_key(raw_key)
cards.append({
"id": f"custom:{name}",
"label": {"zh": name, "en": name},
"configured": configured,
"is_custom": True,
"custom_name": name,
"active": (name == active_name),
"model": p.get("model") or "",
# Custom cards are edited via the dedicated set_custom_provider
# action, not the field-based set_provider flow, so the field
# names are intentionally null.
"api_key_field": None,
"api_base_field": None,
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
"api_base": raw_base,
"api_base_default": "",
"api_base_placeholder": meta.get("api_base_placeholder") or "",
"models": [p.get("model")] if p.get("model") else [],
})
return cards
@classmethod
def _provider_overview(cls) -> List[dict]:
"""All known providers (configured first, unconfigured after).
Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list."""
Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list.
When the user has defined multiple custom (OpenAI-compatible)
providers via ``custom_providers``, the single built-in ``custom``
card is replaced by one card per provider (see
``_custom_provider_cards``). Otherwise the legacy single ``custom``
card is shown unchanged.
"""
local_config = conf()
custom_cards = cls._custom_provider_cards(local_config)
items = []
for pid, p in ConfigHandler.PROVIDER_MODELS.items():
if pid == "custom" and custom_cards:
# Multi-provider mode: emit the expanded cards instead of the
# single legacy custom card.
items.extend(custom_cards)
continue
key_field = p.get("api_key_field")
base_field = p.get("api_base_key")
raw_key = local_config.get(key_field, "") if key_field else ""
@@ -2147,6 +2220,7 @@ class ModelsHandler:
"id": pid,
"label": p["label"],
"configured": configured,
"is_custom": (pid == "custom"),
"api_key_field": key_field,
"api_base_field": base_field,
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
@@ -2155,7 +2229,19 @@ class ModelsHandler:
"api_base_placeholder": p.get("api_base_placeholder") or "",
"models": list(p.get("models") or []),
})
items.sort(key=lambda it: (0 if it["configured"] else 1, list(ConfigHandler.PROVIDER_MODELS.keys()).index(it["id"])))
def _sort_key(it):
pid = it["id"]
# Custom expanded cards share the sort weight of the base "custom"
# entry so they cluster where the single custom card used to be.
base_id = "custom" if it.get("is_custom") else pid
try:
order = list(ConfigHandler.PROVIDER_MODELS.keys()).index(base_id)
except ValueError:
order = len(ConfigHandler.PROVIDER_MODELS)
return (0 if it["configured"] else 1, order)
items.sort(key=_sort_key)
return items
@classmethod
@@ -2603,6 +2689,12 @@ class ModelsHandler:
return self._handle_set_provider(data)
if action == "delete_provider":
return self._handle_delete_provider(data)
if action == "set_custom_provider":
return self._handle_set_custom_provider(data)
if action == "delete_custom_provider":
return self._handle_delete_custom_provider(data)
if action == "set_active_custom_provider":
return self._handle_set_active_custom_provider(data)
if action == "set_capability":
return self._handle_set_capability(data)
if action == "set_voice_reply_mode":
@@ -2686,6 +2778,163 @@ class ModelsHandler:
self._reset_bridge()
return json.dumps({"status": "success", "provider": provider_id, "cleared": cleared})
# ------------------------------------------------------------------
# Multiple custom (OpenAI-compatible) providers
# ------------------------------------------------------------------
# These actions manage the ``custom_providers`` list and the
# ``custom_active_provider`` selector. They are the write-side companion to
# ``_custom_provider_cards`` and let the console add / edit / delete /
# activate user-defined OpenAI-compatible providers individually.
@staticmethod
def _normalize_custom_providers(raw) -> List[dict]:
"""Return a clean list of provider dicts (drops malformed entries)."""
if not isinstance(raw, list):
return []
out = []
for p in raw:
if isinstance(p, dict) and (p.get("name") or "").strip():
out.append(p)
return out
def _persist_custom_providers(self, providers: List[dict], active_name) -> None:
"""Write the providers list + active selector to both in-memory conf
and the on-disk config, then reset the bridge so bots rebuild."""
local_config = conf()
file_cfg = self._read_file_config()
local_config["custom_providers"] = providers
file_cfg["custom_providers"] = providers
if active_name is not None:
local_config["custom_active_provider"] = active_name
file_cfg["custom_active_provider"] = active_name
self._write_file_config(file_cfg)
self._reset_bridge()
def _handle_set_custom_provider(self, data: dict) -> str:
"""Add a new custom provider or update an existing one.
Payload::
{
"action": "set_custom_provider",
"name": "siliconflow", # required, unique
"api_base": "https://...", # required when creating
"api_key": "sk-...", # optional on edit (keep existing)
"model": "deepseek-ai/...", # optional default model
"original_name": "old-name", # optional, set when renaming
"make_active": true # optional, also activate it
}
"""
name = (data.get("name") or "").strip()
if not name:
return json.dumps({"status": "error", "message": "name is required"})
api_base = (data.get("api_base") or "").strip()
# api_key omitted/empty on edit => keep the existing one.
api_key_raw = data.get("api_key")
api_key = api_key_raw.strip() if isinstance(api_key_raw, str) else ""
model = (data.get("model") or "").strip()
# ``original_name`` is supplied only when editing an existing entry
# (so it can be renamed). Its absence means "create a new provider";
# we must keep that distinction explicit, otherwise a create request
# for an already-taken name would be misread as an in-place edit.
original_name = (data.get("original_name") or "").strip()
is_edit = bool(original_name)
make_active = bool(data.get("make_active"))
local_config = conf()
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
# Reject a name collision unless it is the very entry being edited.
for p in providers:
if p.get("name") == name and p.get("name") != original_name:
return json.dumps({
"status": "error",
"message": f"a custom provider named {name!r} already exists",
})
existing = next((p for p in providers if p.get("name") == original_name), None) if is_edit else None
if existing is None:
# Creating a new provider — api_base is mandatory.
if not api_base:
return json.dumps({"status": "error", "message": "api_base is required"})
entry = {"name": name, "api_key": api_key, "api_base": api_base}
if model:
entry["model"] = model
providers.append(entry)
created = True
else:
existing["name"] = name
if api_base:
existing["api_base"] = api_base
if api_key:
existing["api_key"] = api_key
# model is always overwritten (empty clears the default model).
if model:
existing["model"] = model
else:
existing.pop("model", None)
created = False
# Decide the active selector.
active_name = (local_config.get("custom_active_provider") or "").strip()
if make_active or created and not active_name:
# Activate on explicit request, or auto-activate the very first
# provider so the resolver has a definite target.
active_name = name
elif active_name == original_name and original_name != name:
# The active provider was renamed; keep it pointed at the new name.
active_name = name
self._persist_custom_providers(providers, active_name)
logger.info(
f"[ModelsHandler] custom provider {name!r} "
f"{'created' if created else 'updated'} (active={active_name!r})"
)
return json.dumps({
"status": "success",
"name": name,
"created": created,
"active": active_name,
})
def _handle_delete_custom_provider(self, data: dict) -> str:
"""Remove a custom provider by name."""
name = (data.get("name") or "").strip()
if not name:
return json.dumps({"status": "error", "message": "name is required"})
local_config = conf()
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
remaining = [p for p in providers if p.get("name") != name]
if len(remaining) == len(providers):
return json.dumps({"status": "error", "message": f"unknown custom provider: {name}"})
active_name = (local_config.get("custom_active_provider") or "").strip()
if active_name == name:
# The active provider was removed — fall back to the first
# remaining entry (resolver does the same when the name is stale).
active_name = remaining[0]["name"] if remaining else ""
self._persist_custom_providers(remaining, active_name)
logger.info(f"[ModelsHandler] custom provider {name!r} deleted (active={active_name!r})")
return json.dumps({"status": "success", "name": name, "active": active_name})
def _handle_set_active_custom_provider(self, data: dict) -> str:
"""Mark one of the existing custom providers as active."""
name = (data.get("name") or "").strip()
if not name:
return json.dumps({"status": "error", "message": "name is required"})
local_config = conf()
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
if not any(p.get("name") == name for p in providers):
return json.dumps({"status": "error", "message": f"unknown custom provider: {name}"})
self._persist_custom_providers(providers, name)
logger.info(f"[ModelsHandler] active custom provider set to {name!r}")
return json.dumps({"status": "success", "active": name})
def _handle_set_capability(self, data: dict) -> str:
capability = (data.get("capability") or "").strip()
provider_id = (data.get("provider_id") or "").strip()