Merge pull request #2877 from kirs-hi/feat/custom-providers-ui

feat(web): manage multiple custom (OpenAI-compatible) providers in console UI
This commit is contained in:
zhayujie
2026-06-12 11:54:48 +08:00
12 changed files with 1539 additions and 77 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,23 @@ 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_empty: '尚未配置自定义厂商,点击添加',
models_custom_edit_title: '编辑自定义厂商',
models_custom_add_title: '添加自定义厂商',
models_capability_chat: '主模型',
models_capability_chat_desc: '用于基础对话和 Agent 推理',
models_capability_vision: '图像理解',
@@ -236,6 +253,23 @@ 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_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 +4766,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 +4796,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 +4856,112 @@ 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:<id>",
// is_custom=true, custom_id, custom_name and an `active` flag (see
// ModelsHandler._custom_provider_cards / _provider_overview).
// All button interactions use data-* attributes + event delegation (no inline
// onclick) to avoid XSS via user-supplied names.
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 data-action="add-custom"
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;
// Event delegation — handles all custom-provider actions via data-action attrs.
wrap.addEventListener('click', function(e) {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.getAttribute('data-action');
const providerId = btn.getAttribute('data-provider-id') || '';
if (action === 'add-custom') openCustomProviderModal('');
else if (action === 'edit-custom') openCustomProviderModal(providerId);
else if (action === 'delete-custom') deleteCustomProvider(providerId);
else if (action === 'set-active-custom') setActiveCustomProvider(providerId);
});
return wrap;
}
function renderCustomProviderRow(p) {
const id = p.custom_id || '';
const name = p.custom_name || '';
const nameEsc = escapeHtml(name);
// The active provider gets a highlighted ring + badge; others show a
// "set active" affordance via data-attributes (no inline onclick — XSS safe).
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 data-action="set-active-custom" data-provider-id="${escapeHtml(id)}"
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" data-provider-id="${escapeHtml(id)}">
${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 data-action="edit-custom" data-provider-id="${escapeHtml(id)}" 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 data-action="delete-custom" data-provider-id="${escapeHtml(id)}" 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 +6083,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 +6254,155 @@ function clearVendorModal() {
});
}
// =====================================================================
// Custom (OpenAI-compatible) provider modal — add / edit
// =====================================================================
// State for the dedicated custom-provider modal. `editId` is empty when
// adding and set to the provider id when editing.
let customProviderModalState = { editId: '' };
function openCustomProviderModal(providerId) {
const editing = !!providerId;
customProviderModalState = { editId: editing ? providerId : '' };
const card = editing ? getCustomProviderCards().find(p => p.custom_id === providerId) : 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.editId;
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.id = customProviderModalState.editId;
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 {
showStatus('custom-provider-modal-status', 'models_save_failed', true);
}
}).catch(() => {
btn.disabled = false;
showStatus('custom-provider-modal-status', 'models_save_failed', true);
});
}
function setActiveCustomProvider(providerId) {
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_active_custom_provider', id: providerId }),
}).then(r => r.json()).then(data => {
if (data.status === 'success') loadModelsView();
}).catch(() => { /* noop */ });
}
function deleteCustomProvider(providerId) {
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', id: providerId }),
}).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",
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
"enable_thinking", "self_evolution_enabled", "web_password",
}
@@ -2131,13 +2132,83 @@ 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
id ``custom:<id>`` 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.
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, parse_custom_bot_type
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 []
# Determine the currently active provider id from bot_type.
bot_type = local_config.get("bot_type") or ""
_, active_id = parse_custom_bot_type(bot_type)
meta = ConfigHandler.PROVIDER_MODELS.get("custom") or {}
cards = []
for p in providers:
pid = p.get("id") or ""
name = p.get("name") or pid
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:{pid}",
"label": {"zh": name, "en": name},
"configured": configured,
"is_custom": True,
"custom_id": pid,
"custom_name": name,
"active": (pid == active_id),
"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 +2218,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 +2227,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
@@ -2163,13 +2247,24 @@ class ModelsHandler:
"""Main chat model — drives the agent. bot_type maps to a provider id."""
bot_type = local_config.get("bot_type") or ""
provider_id = "openai" if bot_type == "chatGPT" else bot_type
if provider_id not in ConfigHandler.PROVIDER_MODELS and local_config.get("use_linkai"):
is_custom_id = provider_id.startswith("custom:")
if (provider_id not in ConfigHandler.PROVIDER_MODELS and not is_custom_id
and local_config.get("use_linkai")):
provider_id = "linkai"
# In multi-provider mode, replace the single "custom" entry with the
# expanded "custom:<id>" ids so the chat dropdown matches the cards.
provider_ids = []
custom_cards = cls._custom_provider_cards(local_config)
for pid in ConfigHandler.PROVIDER_MODELS.keys():
if pid == "custom" and custom_cards:
provider_ids.extend(c["id"] for c in custom_cards)
else:
provider_ids.append(pid)
return {
"editable": True,
"current_provider": provider_id,
"current_model": local_config.get("model", ""),
"providers": list(ConfigHandler.PROVIDER_MODELS.keys()),
"providers": provider_ids,
"use_linkai": bool(local_config.get("use_linkai", False)),
}
@@ -2603,6 +2698,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 +2787,167 @@ 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. Activation is done
# by setting ``bot_type`` to ``"custom:<id>"``. There is no separate
# ``custom_active_provider`` field — a single source of truth.
@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("id") or "").strip():
out.append(p)
return out
def _persist_custom_providers(self, providers: List[dict], bot_type=None) -> None:
"""Write the providers list to both in-memory conf and the on-disk
config, then reset the bridge so bots rebuild.
If ``bot_type`` is given, also update ``bot_type``. When activating a
provider (bot_type is ``custom:<id>``), also write the provider's
``model`` into the global ``model`` field so that all paths (chat,
agent, vision) automatically use the correct model."""
from models.custom_provider import parse_custom_bot_type
local_config = conf()
file_cfg = self._read_file_config()
local_config["custom_providers"] = providers
file_cfg["custom_providers"] = providers
if bot_type is not None:
local_config["bot_type"] = bot_type
file_cfg["bot_type"] = bot_type
# Sync the provider's model into the global model field.
_, pid = parse_custom_bot_type(bot_type)
if pid:
provider = next((p for p in providers if p.get("id") == pid), None)
if provider and provider.get("model"):
local_config["model"] = provider["model"]
file_cfg["model"] = provider["model"]
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",
"id": "3f2a9c1b", # required for edit; omit for create
"name": "siliconflow", # required, display label
"api_base": "https://...", # required when creating
"api_key": "sk-...", # optional on edit (keep existing)
"model": "deepseek-ai/...", # optional default model
"make_active": true # optional, also activate it
}
"""
from models.custom_provider import generate_provider_id, parse_custom_bot_type
name = (data.get("name") or "").strip()
if not name:
return json.dumps({"status": "error", "message": "name is required"})
provider_id = (data.get("id") or "").strip()
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()
make_active = bool(data.get("make_active"))
local_config = conf()
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
existing = next((p for p in providers if p.get("id") == provider_id), None) if provider_id 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"})
provider_id = generate_provider_id()
entry = {"id": provider_id, "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 bot_type — only switch when explicitly requested.
new_bot_type = None
if make_active:
new_bot_type = f"custom:{provider_id}"
self._persist_custom_providers(providers, new_bot_type)
logger.info(
f"[ModelsHandler] custom provider {name!r} (id={provider_id}) "
f"{'created' if created else 'updated'}"
)
return json.dumps({
"status": "success",
"id": provider_id,
"name": name,
"created": created,
})
def _handle_delete_custom_provider(self, data: dict) -> str:
"""Remove a custom provider by id."""
from models.custom_provider import parse_custom_bot_type
provider_id = (data.get("id") or "").strip()
if not provider_id:
return json.dumps({"status": "error", "message": "id is required"})
local_config = conf()
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
remaining = [p for p in providers if p.get("id") != provider_id]
if len(remaining) == len(providers):
return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"})
# If the deleted provider was active, fall back to the first remaining.
_, current_active_id = parse_custom_bot_type(local_config.get("bot_type") or "")
new_bot_type = None
if current_active_id == provider_id:
if remaining:
new_bot_type = f"custom:{remaining[0]['id']}"
else:
new_bot_type = "custom" # revert to legacy
self._persist_custom_providers(remaining, new_bot_type)
logger.info(f"[ModelsHandler] custom provider id={provider_id} deleted")
return json.dumps({"status": "success", "id": provider_id})
def _handle_set_active_custom_provider(self, data: dict) -> str:
"""Activate a custom provider by setting bot_type to 'custom:<id>'."""
provider_id = (data.get("id") or "").strip()
if not provider_id:
return json.dumps({"status": "error", "message": "id is required"})
local_config = conf()
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
if not any(p.get("id") == provider_id for p in providers):
return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"})
new_bot_type = f"custom:{provider_id}"
self._persist_custom_providers(providers, new_bot_type)
logger.info(f"[ModelsHandler] active custom provider set to id={provider_id}")
return json.dumps({"status": "success", "active_id": provider_id})
def _handle_set_capability(self, data: dict) -> str:
capability = (data.get("capability") or "").strip()
provider_id = (data.get("provider_id") or "").strip()
@@ -2750,13 +3012,28 @@ class ModelsHandler:
})
def _set_chat(self, provider_id: str, model: str) -> str:
if provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS:
# Accept expanded custom provider ids ("custom:<id>") as well as the
# built-in vendors, so the chat capability card and the custom
# providers section behave consistently.
custom_provider = None
if provider_id.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type
_, custom_id = parse_custom_bot_type(provider_id)
providers = self._normalize_custom_providers(conf().get("custom_providers"))
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
if custom_provider is None:
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
elif provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS:
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
applied = {}
local_config = conf()
file_cfg = self._read_file_config()
# Fall back to the custom provider's default model when none is given.
if not model and custom_provider:
model = custom_provider.get("model") or ""
if provider_id:
bot_type_value = "chatGPT" if provider_id == "openai" else provider_id
local_config["bot_type"] = bot_type_value

View File

@@ -24,8 +24,11 @@ available_setting = {
"open_ai_api_base": "https://api.openai.com/v1",
"claude_api_base": "https://api.anthropic.com/v1", # claude api base
"gemini_api_base": "https://generativelanguage.googleapis.com", # gemini api base
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom")
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom")
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom"); legacy single-provider field
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom"); legacy single-provider field
# Multiple custom (OpenAI-compatible) providers. Activated via bot_type: "custom:<id>".
# Each item: {"id": "3f2a9c1b", "name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"}
"custom_providers": [],
"proxy": "", # proxy used by openai
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
"model": "gpt-3.5-turbo", # options: gpt-4o, gpt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini, etc. See common/const.py for the full list
@@ -321,24 +324,37 @@ class Config(dict):
config = Config()
def _mask_value(val):
"""Mask a sensitive string value, keeping first 3 and last 3 chars."""
if not isinstance(val, str) or len(val) <= 8:
return val
return val[0:3] + "*" * 5 + val[-3:]
def _mask_sensitive_recursive(obj):
"""Recursively mask values whose keys contain 'key' or 'secret'."""
if isinstance(obj, dict):
masked = {}
for k, v in obj.items():
if ("key" in k or "secret" in k) and isinstance(v, str):
masked[k] = _mask_value(v)
else:
masked[k] = _mask_sensitive_recursive(v)
return masked
elif isinstance(obj, list):
return [_mask_sensitive_recursive(item) for item in obj]
return obj
def drag_sensitive(config):
try:
if isinstance(config, str):
conf_dict: dict = json.loads(config)
conf_dict_copy = copy.deepcopy(conf_dict)
for key in conf_dict_copy:
if "key" in key or "secret" in key:
if isinstance(conf_dict_copy[key], str):
conf_dict_copy[key] = conf_dict_copy[key][0:3] + "*" * 5 + conf_dict_copy[key][-3:]
conf_dict_copy = _mask_sensitive_recursive(conf_dict)
return json.dumps(conf_dict_copy, indent=4)
elif isinstance(config, dict):
config_copy = copy.deepcopy(config)
for key in config:
if "key" in key or "secret" in key:
if isinstance(config_copy[key], str):
config_copy[key] = config_copy[key][0:3] + "*" * 5 + config_copy[key][-3:]
return config_copy
return _mask_sensitive_recursive(config)
except Exception as e:
logger.exception(e)
return config

View File

@@ -1,21 +1,21 @@
---
title: カスタム
description: カスタムベンダー設定。サードパーティ API プロキシやローカルモデル向け
title: Custom
description: Custom vendor configuration for third-party API proxies and local models
---
OpenAI 互換プロトコルで接続するサードパーティのモデルサービスや、ローカルにデプロイしたモデルに適しています。例えば:
For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as:
- **サードパーティ API プロキシ**:統一された API Base から複数のモデルを呼び出す
- **ローカルモデル**OllamavLLMLocalAI などのツールでローカルにデプロイしたモデル
- **プライベートデプロイ**:企業内部にデプロイしたモデルサービス
- **Third-party API proxies**: call multiple models through a unified API base
- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI
- **Private deployments**: model services deployed inside an enterprise
<Note>
`openai` ベンダーとの違い:カスタムベンダーを選択した場合、`/config model` でモデルを切り替えてもベンダータイプは自動で切り替わらず、常にカスタムの API アドレスを使用します。
Difference from the `openai` vendor: when a custom vendor is selected, switching models via `/config model` does not automatically switch the vendor type — the custom API address is always used.
</Note>
## テキスト対話
## Text Chat
### サードパーティ API プロキシ
### Third-party API proxy
```json
{
@@ -26,16 +26,16 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ
}
```
| パラメータ | 説明 |
| Parameter | Description |
| --- | --- |
| `bot_type` | `custom` に設定する必要があります |
| `model` | モデル名。プロキシサービスがサポートする任意のモデル名を指定 |
| `custom_api_key` | API キー。プロキシサービスから提供されます |
| `custom_api_base` | API アドレス。プロキシサービスから提供され、OpenAI プロトコル互換である必要があります |
| `bot_type` | Must be set to `custom` |
| `model` | Model name; any model name supported by the proxy service |
| `custom_api_key` | API key provided by the proxy service |
| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible |
### ローカルモデル
### Local models
ローカルモデルは通常 API Key が不要で、API Base のみ設定します:
Local models usually do not require an API key — only the API base needs to be filled in:
```json
{
@@ -45,18 +45,60 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ
}
```
一般的なローカルデプロイツールとデフォルトアドレス:
Common local deployment tools and their default endpoints:
| ツール | デフォルト API Base |
| Tool | Default API Base |
| --- | --- |
| [Ollama](https://ollama.com) | `http://localhost:11434/v1` |
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
### モデル切り替え
### Switching Models
カスタムベンダーでモデルを切り替える際は `model` のみが変更され、`bot_type` と API アドレスは変わりません:
Switching models under a custom vendor only changes `model` `bot_type` and the API endpoint remain unchanged:
```
/config model qwen3.5:27b
```
## Configuring Multiple Custom Providers
If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:<id>"`:
```json
{
"bot_type": "custom:3f2a9c1b",
"custom_providers": [
{
"id": "3f2a9c1b",
"name": "siliconflow",
"api_key": "YOUR_SILICONFLOW_KEY",
"api_base": "https://api.siliconflow.cn/v1",
"model": "deepseek-ai/DeepSeek-V3"
},
{
"id": "a1b2c3d4",
"name": "qiniu",
"api_key": "YOUR_QINIU_KEY",
"api_base": "https://api.qnaigc.com/v1",
"model": "deepseek-v3"
}
]
}
```
| Parameter | Description |
| --- | --- |
| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` |
| `bot_type` | Set to `"custom:<id>"` to activate a specific provider; `id` is the provider's unique identifier |
| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider |
| `name` | User-facing display label — can be freely renamed without breaking anything |
| `model` | Optional; when set, this model is used instead of the global `model` field |
<Note>
The `id` is auto-generated by the web console when you add a provider — you don't need to create it manually. If editing `config.json` by hand, use any unique 8-character hex string (e.g. run `python3 -c "import uuid; print(uuid.uuid4().hex[:8])"`).
</Note>
<Note>
When `bot_type` is exactly `"custom"` (no colon suffix), CowAgent uses the legacy single-provider `custom_api_key` / `custom_api_base` fields, so existing configurations keep working without any changes.
</Note>

View File

@@ -60,3 +60,45 @@ Switching models under a custom vendor only changes `model` — `bot_type` and t
```
/config model qwen3.5:27b
```
## Configuring Multiple Custom Providers
If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:<id>"`:
```json
{
"bot_type": "custom:3f2a9c1b",
"custom_providers": [
{
"id": "3f2a9c1b",
"name": "siliconflow",
"api_key": "YOUR_SILICONFLOW_KEY",
"api_base": "https://api.siliconflow.cn/v1",
"model": "deepseek-ai/DeepSeek-V3"
},
{
"id": "a1b2c3d4",
"name": "qiniu",
"api_key": "YOUR_QINIU_KEY",
"api_base": "https://api.qnaigc.com/v1",
"model": "deepseek-v3"
}
]
}
```
| Parameter | Description |
| --- | --- |
| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` |
| `bot_type` | Set to `"custom:<id>"` to activate a specific provider; `id` is the provider's unique identifier |
| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider |
| `name` | User-facing display label — can be freely renamed without breaking anything |
| `model` | Optional; when set, this model is used instead of the global `model` field |
<Note>
The `id` is auto-generated by the web console when you add a provider — you don't need to create it manually. If editing `config.json` by hand, use any unique 8-character hex string (e.g. run `python3 -c "import uuid; print(uuid.uuid4().hex[:8])"`).
</Note>
<Note>
When `bot_type` is exactly `"custom"` (no colon suffix), CowAgent uses the legacy single-provider `custom_api_key` / `custom_api_base` fields, so existing configurations keep working without any changes.
</Note>

View File

@@ -1,21 +1,21 @@
---
title: 自定义
description: 自定义厂商配置,适用于第三方 API 代理和本地模型
title: Custom
description: Custom vendor configuration for third-party API proxies and local models
---
适用于通过 OpenAI 兼容协议接入的第三方模型服务或本地部署的模型,例如:
For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as:
- **第三方 API 代理**:使用统一的 API Base 调用多种模型
- **本地模型**:通过 OllamavLLMLocalAI 等工具在本地部署的模型
- **私有化部署**:企业内部部署的模型服务
- **Third-party API proxies**: call multiple models through a unified API base
- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI
- **Private deployments**: model services deployed inside an enterprise
<Note>
与 `openai` 厂商的区别:选择自定义厂商后,通过 `/config model` 切换模型时,不会自动切换厂商类型,始终使用自定义的 API 地址。
Difference from the `openai` vendor: when a custom vendor is selected, switching models via `/config model` does not automatically switch the vendor type — the custom API address is always used.
</Note>
## 文本对话
## Text Chat
### 第三方 API 代理
### Third-party API proxy
```json
{
@@ -26,16 +26,16 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模
}
```
| 参数 | 说明 |
| Parameter | Description |
| --- | --- |
| `bot_type` | 必须设为 `custom` |
| `model` | 模型名称,填写代理服务支持的任意模型名 |
| `custom_api_key` | API 密钥,由代理服务提供 |
| `custom_api_base` | API 地址,由代理服务提供,需兼容 OpenAI 协议 |
| `bot_type` | Must be set to `custom` |
| `model` | Model name; any model name supported by the proxy service |
| `custom_api_key` | API key provided by the proxy service |
| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible |
### 本地模型
### Local models
本地模型通常不需要 API Key,只需填写 API Base
Local models usually do not require an API key — only the API base needs to be filled in:
```json
{
@@ -45,18 +45,60 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模
}
```
常见的本地部署工具及默认地址:
Common local deployment tools and their default endpoints:
| 工具 | 默认 API Base |
| Tool | Default API Base |
| --- | --- |
| [Ollama](https://ollama.com) | `http://localhost:11434/v1` |
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
### 切换模型
### Switching Models
自定义厂商下切换模型时,只会修改 `model`,不会改变 `bot_type` 和 API 地址:
Switching models under a custom vendor only changes `model` `bot_type` and the API endpoint remain unchanged:
```
/config model qwen3.5:27b
```
## Configuring Multiple Custom Providers
If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:<id>"`:
```json
{
"bot_type": "custom:3f2a9c1b",
"custom_providers": [
{
"id": "3f2a9c1b",
"name": "siliconflow",
"api_key": "YOUR_SILICONFLOW_KEY",
"api_base": "https://api.siliconflow.cn/v1",
"model": "deepseek-ai/DeepSeek-V3"
},
{
"id": "a1b2c3d4",
"name": "qiniu",
"api_key": "YOUR_QINIU_KEY",
"api_base": "https://api.qnaigc.com/v1",
"model": "deepseek-v3"
}
]
}
```
| Parameter | Description |
| --- | --- |
| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` |
| `bot_type` | Set to `"custom:<id>"` to activate a specific provider; `id` is the provider's unique identifier |
| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider |
| `name` | User-facing display label — can be freely renamed without breaking anything |
| `model` | Optional; when set, this model is used instead of the global `model` field |
<Note>
The `id` is auto-generated by the web console when you add a provider — you don't need to create it manually. If editing `config.json` by hand, use any unique 8-character hex string (e.g. run `python3 -c "import uuid; print(uuid.uuid4().hex[:8])"`).
</Note>
<Note>
When `bot_type` is exactly `"custom"` (no colon suffix), CowAgent uses the legacy single-provider `custom_api_key` / `custom_api_base` fields, so existing configurations keep working without any changes.
</Note>

View File

@@ -29,7 +29,7 @@ def create_bot(bot_type):
from models.mimo.mimo_bot import MimoBot
return MimoBot()
elif bot_type in (const.OPENAI, const.CHATGPT, const.CUSTOM): # OpenAI-compatible API
elif bot_type in (const.OPENAI, const.CHATGPT, const.CUSTOM) or bot_type.startswith("custom:"): # OpenAI-compatible API
from models.chatgpt.chat_gpt_bot import ChatGPTBot
return ChatGPTBot()

View File

@@ -17,6 +17,7 @@ from common import const
from common.i18n import t as _t
from models.bot import Bot
from models.openai_compatible_bot import OpenAICompatibleBot
from models.custom_provider import resolve_custom_credentials, parse_custom_bot_type
from models.chatgpt.chat_gpt_session import ChatGPTSession
from models.openai.open_ai_image import OpenAIImage
from models.session_manager import SessionManager
@@ -32,9 +33,12 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
def __init__(self):
super().__init__()
# Resolve api key / base from config (no global SDK state anymore).
if conf().get("bot_type") == "custom":
self._api_key = conf().get("custom_api_key", "")
self._api_base = conf().get("custom_api_base") or None
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
custom_model = None
if is_custom:
# Supports multiple custom providers via bot_type "custom:<id>"
# with automatic fallback to the legacy custom_api_key/base fields.
self._api_key, self._api_base, custom_model = resolve_custom_credentials()
else:
self._api_key = conf().get("open_ai_api_key")
self._api_base = conf().get("open_ai_api_base") or None
@@ -46,8 +50,9 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
)
if conf().get("rate_limit_chatgpt"):
self.tb4chatgpt = TokenBucket(conf().get("rate_limit_chatgpt", 20))
conf_model = conf().get("model") or "gpt-3.5-turbo"
self.sessions = SessionManager(ChatGPTSession, model=conf().get("model") or "gpt-3.5-turbo")
# Per-provider model takes precedence over global model.
conf_model = custom_model or conf().get("model") or "gpt-3.5-turbo"
self.sessions = SessionManager(ChatGPTSession, model=conf_model)
# o1相关模型不支持system prompt暂时用文心模型的session
self.args = {
@@ -70,11 +75,20 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
def get_api_config(self):
"""Get API configuration for OpenAI-compatible base class"""
is_custom = conf().get("bot_type") == "custom"
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
if is_custom:
custom_key, custom_base, custom_model = resolve_custom_credentials()
api_key = custom_key
api_base = custom_base
model = custom_model or conf().get("model", "gpt-3.5-turbo")
else:
api_key = conf().get("open_ai_api_key")
api_base = conf().get("open_ai_api_base")
model = conf().get("model", "gpt-3.5-turbo")
return {
'api_key': conf().get("custom_api_key") if is_custom else conf().get("open_ai_api_key"),
'api_base': conf().get("custom_api_base") if is_custom else conf().get("open_ai_api_base"),
'model': conf().get("model", "gpt-3.5-turbo"),
'api_key': api_key,
'api_base': api_base,
'model': model,
'default_temperature': conf().get("temperature", 0.9),
'default_top_p': conf().get("top_p", 1.0),
'default_frequency_penalty': conf().get("frequency_penalty", 0.0),
@@ -185,10 +199,16 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
mime_type = mime_type_map.get(extension, "image/jpeg")
# Get model and API config
is_custom = conf().get("bot_type") == "custom"
model = context.get("gpt_model") or conf().get("model", "gpt-4o")
api_key = context.get("openai_api_key") or (conf().get("custom_api_key") if is_custom else conf().get("open_ai_api_key"))
api_base = conf().get("custom_api_base") if is_custom else conf().get("open_ai_api_base")
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
if is_custom:
custom_key, custom_base, custom_model = resolve_custom_credentials()
model = context.get("gpt_model") or custom_model or conf().get("model", "gpt-4o")
api_key = context.get("openai_api_key") or custom_key
api_base = custom_base
else:
model = context.get("gpt_model") or conf().get("model", "gpt-4o")
api_key = context.get("openai_api_key") or conf().get("open_ai_api_key")
api_base = conf().get("open_ai_api_base")
# Build vision request
messages = [

125
models/custom_provider.py Normal file
View File

@@ -0,0 +1,125 @@
# encoding:utf-8
"""
Centralized resolver for custom (OpenAI-compatible) provider credentials.
CowAgent historically supported only a *single* custom provider via the flat
config keys ``custom_api_key`` / ``custom_api_base``. This module adds support
for *multiple* custom providers (see issue #2838) while remaining 100%
backward compatible.
Config model
------------
- ``custom_providers``: list of dicts, each describing one custom provider::
{
"id": "3f2a9c1b", # server-generated short uuid (primary key)
"name": "siliconflow", # user-facing display label (not a key)
"api_key": "sk-...", # required
"api_base": "https://...", # required, must be OpenAI-compatible
"model": "deepseek-ai/DeepSeek-V3" # optional default model
}
Routing
-------
- ``bot_type: "custom"`` (legacy): reads the flat ``custom_api_key`` / ``custom_api_base``.
- ``bot_type: "custom:<id>"`` (multi-provider): looks up the provider by id in
``custom_providers``. There is a single source of truth — no separate
``custom_active_provider`` field.
Backward-compatibility contract
-------------------------------
When ``bot_type`` is exactly ``"custom"`` (no colon suffix), behaviour is
unchanged: we return ``custom_api_key`` / ``custom_api_base`` values.
"""
import uuid
from config import conf
from common.log import logger
def generate_provider_id() -> str:
"""Generate a short random id for a new custom provider."""
return uuid.uuid4().hex[:8]
def get_custom_providers():
"""Return the list of configured custom providers (always a list)."""
providers = conf().get("custom_providers")
if not isinstance(providers, list):
return []
# Keep only well-formed entries with an id.
return [p for p in providers if isinstance(p, dict) and p.get("id")]
def _find_provider_by_id(providers, provider_id):
"""Look up a provider by its id, or None if not found."""
if not providers or not provider_id:
return None
for p in providers:
if p.get("id") == provider_id:
return p
return None
def parse_custom_bot_type(bot_type):
"""Parse bot_type to extract custom provider id.
Returns:
(is_custom, provider_id) where:
- is_custom: True if bot_type starts with "custom"
- provider_id: the id suffix (e.g. "3f2a9c1b") or empty string for legacy mode
"""
if not bot_type or not isinstance(bot_type, str):
return False, ""
if bot_type == "custom":
return True, ""
if bot_type.startswith("custom:"):
return True, bot_type[7:] # len("custom:") == 7
return False, ""
def resolve_custom_credentials():
"""Resolve the effective (api_key, api_base, model) for custom mode.
Resolution order:
1. If ``bot_type`` is ``"custom:<id>"``, look up that id in
``custom_providers``.
2. If ``bot_type`` is exactly ``"custom"`` (legacy), return the flat
``custom_api_key`` / ``custom_api_base``.
:return: tuple ``(api_key, api_base, model)``. ``api_base`` and ``model``
may be ``None`` / empty when not configured.
"""
bot_type = conf().get("bot_type", "")
is_custom, provider_id = parse_custom_bot_type(bot_type)
if not is_custom:
# Not custom at all — should not happen but be defensive.
return (
conf().get("open_ai_api_key", ""),
conf().get("open_ai_api_base") or None,
None,
)
if provider_id:
# Multi-provider mode: look up by id.
providers = get_custom_providers()
provider = _find_provider_by_id(providers, provider_id)
if provider is not None:
return (
provider.get("api_key", ""),
provider.get("api_base") or None,
provider.get("model") or None,
)
logger.warning(
"[CUSTOM] provider id '%s' not found in custom_providers, "
"falling back to legacy fields", provider_id
)
# Legacy single-provider fallback — unchanged behavior.
return (
conf().get("custom_api_key", ""),
conf().get("custom_api_base") or None,
None,
)

View File

@@ -0,0 +1,240 @@
# encoding:utf-8
"""
Unit tests for multiple custom (OpenAI-compatible) provider support (issue #2838).
Covers models/custom_provider.py:
- Backward compatibility: legacy custom_api_key / custom_api_base fallback
- Multi-provider selection via bot_type "custom:<id>" routing
- parse_custom_bot_type helper
- Robustness against malformed config (missing id, non-dict, non-list)
"""
import sys
import os
import unittest
# Add project root to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import config as config_module
from config import Config
def set_conf(d):
"""Install a fresh Config as the global config used by conf()."""
config_module.config = Config(d)
class TestParseCustomBotType(unittest.TestCase):
"""parse_custom_bot_type() parsing logic."""
def setUp(self):
from models.custom_provider import parse_custom_bot_type
self.parse = parse_custom_bot_type
def test_legacy_custom(self):
is_custom, pid = self.parse("custom")
self.assertTrue(is_custom)
self.assertEqual(pid, "")
def test_custom_with_id(self):
is_custom, pid = self.parse("custom:3f2a9c1b")
self.assertTrue(is_custom)
self.assertEqual(pid, "3f2a9c1b")
def test_non_custom(self):
is_custom, pid = self.parse("openai")
self.assertFalse(is_custom)
self.assertEqual(pid, "")
def test_empty(self):
is_custom, pid = self.parse("")
self.assertFalse(is_custom)
self.assertEqual(pid, "")
def test_none(self):
is_custom, pid = self.parse(None)
self.assertFalse(is_custom)
self.assertEqual(pid, "")
class TestResolveCustomCredentials(unittest.TestCase):
"""resolve_custom_credentials() resolution order and fallbacks."""
def setUp(self):
from models.custom_provider import resolve_custom_credentials, get_custom_providers
self.resolve = resolve_custom_credentials
self.get_providers = get_custom_providers
# --- Backward compatibility ---
def test_legacy_fallback_when_no_providers(self):
set_conf({
"bot_type": "custom",
"custom_api_key": "legacy-key",
"custom_api_base": "https://legacy.example.com/v1",
})
self.assertEqual(
self.resolve(),
("legacy-key", "https://legacy.example.com/v1", None),
)
def test_empty_config(self):
set_conf({"bot_type": "custom"})
self.assertEqual(self.resolve(), ("", None, None))
# --- Multi-provider selection via bot_type ---
def test_provider_selected_by_id(self):
set_conf({
"bot_type": "custom:abc12345",
"custom_providers": [
{"id": "sf001", "name": "siliconflow", "api_key": "sf-key",
"api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"},
{"id": "abc12345", "name": "qiniu", "api_key": "qn-key",
"api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"},
],
})
self.assertEqual(
self.resolve(),
("qn-key", "https://api.qnaigc.com/v1", "deepseek-v3"),
)
def test_id_not_found_falls_back_to_legacy(self):
set_conf({
"bot_type": "custom:ghost",
"custom_api_key": "legacy-key",
"custom_api_base": "https://legacy.example.com/v1",
"custom_providers": [
{"id": "sf001", "name": "siliconflow", "api_key": "sf-key",
"api_base": "https://api.siliconflow.cn/v1"},
],
})
self.assertEqual(
self.resolve(),
("legacy-key", "https://legacy.example.com/v1", None),
)
def test_provider_without_model_returns_none_model(self):
set_conf({
"bot_type": "custom:local01",
"custom_providers": [
{"id": "local01", "name": "local", "api_key": "", "api_base": "http://localhost:11434/v1"},
],
})
self.assertEqual(
self.resolve(),
("", "http://localhost:11434/v1", None),
)
# --- Robustness against malformed config ---
def test_malformed_entries_filtered_and_fallback(self):
set_conf({
"bot_type": "custom:nope",
"custom_api_key": "legacy-key",
"custom_api_base": "https://legacy.example.com/v1",
"custom_providers": [
{"name": "no-id", "api_key": "no-id-key"}, # invalid: no id
"not-a-dict", # invalid: wrong type
],
})
# All entries invalid -> treated as empty -> legacy fallback
self.assertEqual(
self.resolve(),
("legacy-key", "https://legacy.example.com/v1", None),
)
def test_get_custom_providers_filters_invalid(self):
set_conf({
"bot_type": "custom",
"custom_providers": [
{"id": "ok1", "name": "ok", "api_key": "k", "api_base": "https://x/v1"},
{"name": "no-id", "api_key": "no-id"}, # dropped: no id
123, # dropped
],
})
providers = self.get_providers()
self.assertEqual(len(providers), 1)
self.assertEqual(providers[0]["id"], "ok1")
def test_custom_providers_not_a_list_falls_back(self):
set_conf({
"bot_type": "custom",
"custom_api_key": "legacy-key",
"custom_api_base": "https://legacy.example.com/v1",
"custom_providers": "oops-a-string",
})
self.assertEqual(
self.resolve(),
("legacy-key", "https://legacy.example.com/v1", None),
)
class TestGenerateProviderId(unittest.TestCase):
"""generate_provider_id() produces valid short ids."""
def test_length_and_hex(self):
from models.custom_provider import generate_provider_id
pid = generate_provider_id()
self.assertEqual(len(pid), 8)
# Must be valid hex characters
int(pid, 16)
def test_uniqueness(self):
from models.custom_provider import generate_provider_id
ids = {generate_provider_id() for _ in range(100)}
self.assertEqual(len(ids), 100)
class TestConfigDefaults(unittest.TestCase):
"""The new config fields must exist with safe defaults."""
def test_default_config_has_custom_providers(self):
from config import available_setting
self.assertIn("custom_providers", available_setting)
self.assertEqual(available_setting["custom_providers"], [])
def test_default_config_no_custom_active_provider(self):
"""custom_active_provider was removed — replaced by bot_type routing."""
from config import available_setting
self.assertNotIn("custom_active_provider", available_setting)
class TestDragSensitiveNested(unittest.TestCase):
"""drag_sensitive() must mask api_key in nested structures."""
def test_nested_api_key_masked(self):
from config import drag_sensitive
import json
test_config = {
"open_ai_api_key": "sk-1234567890abcdef",
"custom_providers": [
{"id": "x1", "name": "test", "api_key": "sk-nested-secret-key-long", "api_base": "https://x/v1"}
],
}
result = drag_sensitive(test_config)
# Top-level key should be masked
self.assertNotIn("1234567890abcdef", str(result))
# Nested key should also be masked
self.assertNotIn("nested-secret-key-long", str(result))
# But the id/name/api_base should not be masked
self.assertIn("x1", str(result))
self.assertIn("test", str(result))
self.assertIn("https://x/v1", str(result))
def test_string_config_masked(self):
from config import drag_sensitive
import json
test_str = json.dumps({
"open_ai_api_key": "sk-1234567890abcdef",
"custom_providers": [
{"id": "x1", "api_key": "sk-nested-very-long-secret"}
],
})
result = drag_sensitive(test_str)
self.assertNotIn("1234567890abcdef", result)
self.assertNotIn("nested-very-long-secret", result)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,289 @@
# encoding:utf-8
"""
Unit tests for the multi custom-provider management API (issue #2838, web UI).
Covers channel/web/web_channel.py::ModelsHandler:
- _custom_provider_cards / _provider_overview expansion
- _handle_set_custom_provider (create / edit / activate)
- _handle_delete_custom_provider
- _handle_set_active_custom_provider
Uses id-based routing (bot_type: "custom:<id>") — no custom_active_provider.
"""
import json
import os
import sys
import types
import unittest
# Add project root to path.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Stub the web.py framework so web_channel imports without the dependency.
if "web" not in sys.modules:
_web_stub = types.ModuleType("web")
_web_stub.header = lambda *a, **k: None
_web_stub.data = lambda: b"{}"
_web_stub.ctx = types.SimpleNamespace()
sys.modules["web"] = _web_stub
import config as config_module
from config import Config
from channel.web.web_channel import ModelsHandler
def set_conf(d):
"""Install a fresh Config as the global config used by conf()."""
config_module.config = Config(d)
class _HandlerHarness:
"""Test double around ModelsHandler that captures persisted config in
memory instead of touching config.json, and no-ops the Bridge reset."""
def __init__(self):
self.handler = ModelsHandler.__new__(ModelsHandler)
self._file_cfg = {}
self.bridge_resets = 0
# Patch the disk + bridge boundary on this instance.
self.handler._read_file_config = lambda: dict(self._file_cfg)
self.handler._write_file_config = self._capture_write
self.handler._reset_bridge = self._capture_reset
def _capture_write(self, data):
self._file_cfg = dict(data)
def _capture_reset(self):
self.bridge_resets += 1
def call(self, **payload):
# Resolve the bound method by action for convenience.
action = payload.get("action")
method = {
"set_custom_provider": self.handler._handle_set_custom_provider,
"delete_custom_provider": self.handler._handle_delete_custom_provider,
"set_active_custom_provider": self.handler._handle_set_active_custom_provider,
}[action]
return json.loads(method(payload))
class TestSetCustomProvider(unittest.TestCase):
def setUp(self):
set_conf({"bot_type": "custom", "custom_providers": []})
self.h = _HandlerHarness()
def test_create_provider_does_not_hijack_bot_type(self):
"""Creating a provider without make_active must not change bot_type."""
res = self.h.call(action="set_custom_provider", name="siliconflow",
api_base="https://api.siliconflow.cn/v1", api_key="sf-key")
self.assertEqual(res["status"], "success")
self.assertTrue(res["created"])
self.assertIn("id", res)
# bot_type must remain unchanged — no auto-activation.
bot_type = config_module.conf().get("bot_type")
self.assertEqual(bot_type, "custom") # unchanged from setUp
providers = config_module.conf().get("custom_providers")
self.assertEqual(len(providers), 1)
self.assertEqual(providers[0]["id"], res["id"])
self.assertEqual(providers[0]["name"], "siliconflow")
self.assertEqual(self.h.bridge_resets, 1)
def test_create_with_make_active_switches_bot_type(self):
"""Creating a provider with make_active=true must switch bot_type."""
res = self.h.call(action="set_custom_provider", name="siliconflow",
api_base="https://api.siliconflow.cn/v1", api_key="sf-key",
make_active=True)
self.assertEqual(res["status"], "success")
bot_type = config_module.conf().get("bot_type")
self.assertEqual(bot_type, f"custom:{res['id']}")
def test_create_requires_api_base(self):
res = self.h.call(action="set_custom_provider", name="x", api_key="k")
self.assertEqual(res["status"], "error")
self.assertIn("api_base", res["message"])
def test_create_requires_name(self):
res = self.h.call(action="set_custom_provider", name="", api_base="https://x/v1")
self.assertEqual(res["status"], "error")
def test_second_provider_does_not_steal_active(self):
# Explicitly activate the first provider.
res1 = self.h.call(action="set_custom_provider", name="a",
api_base="https://a/v1", api_key="ak", make_active=True)
res2 = self.h.call(action="set_custom_provider", name="b",
api_base="https://b/v1", api_key="bk")
self.assertTrue(res2["created"])
# First provider stays active — second creation doesn't steal it.
bot_type = config_module.conf().get("bot_type")
self.assertEqual(bot_type, f"custom:{res1['id']}")
def test_make_active_flag(self):
self.h.call(action="set_custom_provider", name="a",
api_base="https://a/v1", api_key="ak")
res2 = self.h.call(action="set_custom_provider", name="b",
api_base="https://b/v1", api_key="bk", make_active=True)
bot_type = config_module.conf().get("bot_type")
self.assertEqual(bot_type, f"custom:{res2['id']}")
def test_edit_keeps_key_when_omitted(self):
res = self.h.call(action="set_custom_provider", name="a",
api_base="https://a/v1", api_key="secret")
pid = res["id"]
# Edit only the base; omit api_key.
res2 = self.h.call(action="set_custom_provider", name="a",
id=pid, api_base="https://a2/v1")
self.assertEqual(res2["status"], "success")
self.assertFalse(res2["created"])
providers = config_module.conf().get("custom_providers")
self.assertEqual(providers[0]["api_base"], "https://a2/v1")
self.assertEqual(providers[0]["api_key"], "secret") # preserved
def test_edit_can_rename(self):
res = self.h.call(action="set_custom_provider", name="old",
api_base="https://a/v1", api_key="ak")
pid = res["id"]
res2 = self.h.call(action="set_custom_provider", name="new",
id=pid, api_base="https://a/v1")
self.assertEqual(res2["status"], "success")
providers = config_module.conf().get("custom_providers")
self.assertEqual(providers[0]["name"], "new")
# ID stays the same
self.assertEqual(providers[0]["id"], pid)
def test_edit_clears_model_when_empty(self):
res = self.h.call(action="set_custom_provider", name="a",
api_base="https://a/v1", api_key="ak", model="m1")
pid = res["id"]
self.assertEqual(config_module.conf().get("custom_providers")[0]["model"], "m1")
self.h.call(action="set_custom_provider", name="a", id=pid,
api_base="https://a/v1", model="")
self.assertNotIn("model", config_module.conf().get("custom_providers")[0])
class TestDeleteCustomProvider(unittest.TestCase):
def setUp(self):
set_conf({"bot_type": "custom", "custom_providers": []})
self.h = _HandlerHarness()
self.res_a = self.h.call(action="set_custom_provider", name="a",
api_base="https://a/v1", api_key="ak",
make_active=True)
self.res_b = self.h.call(action="set_custom_provider", name="b",
api_base="https://b/v1", api_key="bk")
def test_delete_unknown(self):
res = self.h.call(action="delete_custom_provider", id="ghost")
self.assertEqual(res["status"], "error")
def test_delete_non_active(self):
res = self.h.call(action="delete_custom_provider", id=self.res_b["id"])
self.assertEqual(res["status"], "success")
ids = [p["id"] for p in config_module.conf().get("custom_providers")]
self.assertEqual(ids, [self.res_a["id"]])
# bot_type unchanged (still pointing to a)
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}")
def test_delete_active_falls_back_to_first_remaining(self):
# 'a' is active (created first); deleting it should re-point to 'b'.
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}")
res = self.h.call(action="delete_custom_provider", id=self.res_a["id"])
self.assertEqual(res["status"], "success")
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_b['id']}")
def test_delete_last_reverts_to_legacy(self):
self.h.call(action="delete_custom_provider", id=self.res_a["id"])
self.h.call(action="delete_custom_provider", id=self.res_b["id"])
self.assertEqual(config_module.conf().get("custom_providers"), [])
# When all providers deleted, reverts to legacy "custom"
self.assertEqual(config_module.conf().get("bot_type"), "custom")
class TestSetActiveCustomProvider(unittest.TestCase):
def setUp(self):
set_conf({"bot_type": "custom", "custom_providers": []})
self.h = _HandlerHarness()
self.res_a = self.h.call(action="set_custom_provider", name="a",
api_base="https://a/v1", api_key="ak",
make_active=True)
self.res_b = self.h.call(action="set_custom_provider", name="b",
api_base="https://b/v1", api_key="bk")
def test_set_active_valid(self):
res = self.h.call(action="set_active_custom_provider", id=self.res_b["id"])
self.assertEqual(res["status"], "success")
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_b['id']}")
def test_set_active_unknown(self):
res = self.h.call(action="set_active_custom_provider", id="ghost")
self.assertEqual(res["status"], "error")
# bot_type unchanged
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}")
def test_activation_syncs_model_to_global(self):
"""Activating a provider must write its model into global model field."""
set_conf({"bot_type": "custom", "custom_providers": [], "model": "gpt-4o"})
h = _HandlerHarness()
res = h.call(action="set_custom_provider", name="sf",
api_base="https://sf/v1", api_key="k", model="deepseek-v3",
make_active=True)
# Global model field should now be the provider's model.
self.assertEqual(config_module.conf().get("model"), "deepseek-v3")
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{res['id']}")
def test_activation_without_model_keeps_global_model(self):
"""Activating a provider with no model must not overwrite global model."""
set_conf({"bot_type": "custom", "custom_providers": [], "model": "gpt-4o"})
h = _HandlerHarness()
h.call(action="set_custom_provider", name="local",
api_base="http://localhost:11434/v1", api_key="",
make_active=True)
# Global model field should remain unchanged.
self.assertEqual(config_module.conf().get("model"), "gpt-4o")
class TestProviderOverviewExpansion(unittest.TestCase):
"""_provider_overview / _custom_provider_cards should expand the list."""
def test_no_custom_providers_keeps_single_card(self):
set_conf({"bot_type": "custom", "custom_providers": []})
cards = ModelsHandler._custom_provider_cards(config_module.conf())
self.assertEqual(cards, [])
overview = ModelsHandler._provider_overview()
custom_cards = [c for c in overview if c.get("id") == "custom"]
# Legacy single custom card remains present.
self.assertEqual(len(custom_cards), 1)
def test_multi_providers_expand_into_cards(self):
set_conf({
"bot_type": "custom:id_b",
"custom_providers": [
{"id": "id_a", "name": "a", "api_key": "ak", "api_base": "https://a/v1"},
{"id": "id_b", "name": "b", "api_key": "bk", "api_base": "https://b/v1", "model": "m"},
],
})
overview = ModelsHandler._provider_overview()
custom_cards = [c for c in overview if c.get("is_custom")]
self.assertEqual(len(custom_cards), 2)
by_id = {c["custom_id"]: c for c in custom_cards}
self.assertEqual(by_id["id_a"]["id"], "custom:id_a")
self.assertFalse(by_id["id_a"]["active"])
self.assertTrue(by_id["id_b"]["active"])
self.assertEqual(by_id["id_b"]["model"], "m")
# No single legacy "custom" card when expanded.
self.assertFalse(any(c.get("id") == "custom" for c in overview))
def test_no_active_shows_none_active(self):
"""When bot_type is plain 'custom', no card is marked active."""
set_conf({
"bot_type": "custom",
"custom_providers": [
{"id": "id_a", "name": "a", "api_key": "ak", "api_base": "https://a/v1"},
{"id": "id_b", "name": "b", "api_key": "bk", "api_base": "https://b/v1"},
],
})
cards = ModelsHandler._custom_provider_cards(config_module.conf())
active_cards = [c for c in cards if c.get("active")]
self.assertEqual(len(active_cards), 0)
if __name__ == "__main__":
unittest.main()