From cffa590d3e9eac5b59284df1565dd76951e9f049 Mon Sep 17 00:00:00 2001 From: kirs-hi Date: Wed, 10 Jun 2026 18:12:30 +0800 Subject: [PATCH] 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:`, 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. --- channel/web/chat.html | 66 ++++++ channel/web/static/js/console.js | 305 ++++++++++++++++++++++++- channel/web/web_channel.py | 253 +++++++++++++++++++- config.py | 8 +- docs/ja/models/custom.mdx | 34 +++ docs/models/custom.mdx | 34 +++ docs/zh/models/custom.mdx | 34 +++ models/chatgpt/chat_gpt_bot.py | 33 ++- models/custom_provider.py | 84 +++++++ tests/test_custom_provider.py | 170 ++++++++++++++ tests/test_custom_provider_handlers.py | 258 +++++++++++++++++++++ 11 files changed, 1262 insertions(+), 17 deletions(-) create mode 100644 models/custom_provider.py create mode 100644 tests/test_custom_provider.py create mode 100644 tests/test_custom_provider_handlers.py diff --git a/channel/web/chat.html b/channel/web/chat.html index 4f527bb4..0b71d745 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -1165,6 +1165,72 @@ + + + diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 903c7df6..74bd885f 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -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 = `
@@ -4752,7 +4798,7 @@ function renderVendorsSection() {

${t('models_section_vendors')}

${t('models_section_vendors_desc')}

- ${configured.length}/${modelsState.providers.length} + ${configured.length}/${builtinProviders.length} `; let body; @@ -4812,6 +4858,98 @@ function renderProviderLogo(p, sizePx) { `; } +// ---------- 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:", +// 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 = ` +
+
+ +
+
+

${t('models_custom_section')}

+

${t('models_custom_section_desc')}

+
+ +
`; + + let body; + if (customs.length === 0) { + body = ` +
+

${t('models_custom_empty')}

+
`; + } else { + body = `
+ ${customs.map(renderCustomProviderRow).join('')} +
`; + } + + 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 + ? ` + ${t('models_custom_active')}` + : ``; + + 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 + ? `${escapeHtml(p.model)}` + : ''; + const base = p.api_base + ? `${escapeHtml(p.api_base)}` + : ''; + + return ` +
+ ${renderProviderLogo(p, 28)} +
+
+ ${nameEsc} + ${activeBadge} +
+
${base}${model ? '·' + model : ''}
+
+ + +
`; +} + // ---------- 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 // ===================================================================== diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 1be31c6a..d1185000 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -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:`` 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() diff --git a/config.py b/config.py index f711aad2..5c12e4b0 100644 --- a/config.py +++ b/config.py @@ -24,8 +24,12 @@ 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. When non-empty, supersedes the legacy custom_api_key/base above. + # Each item: {"name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"} + "custom_providers": [], + "custom_active_provider": "", # name of the active provider in custom_providers; empty = use the first entry / legacy fields "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 diff --git a/docs/ja/models/custom.mdx b/docs/ja/models/custom.mdx index c2a3cfa9..8ce33d0e 100644 --- a/docs/ja/models/custom.mdx +++ b/docs/ja/models/custom.mdx @@ -60,3 +60,37 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ ``` /config model qwen3.5:27b ``` + +## 複数のカスタムベンダーを設定する + +複数の OpenAI 互換サードパーティサービス(例:SiliconFlow、Qiniu)を同時に設定したい場合は、`custom_providers` リストを使用し、`custom_active_provider` で現在有効なベンダーを指定します: + +```json +{ + "bot_type": "custom", + "custom_active_provider": "siliconflow", + "custom_providers": [ + { + "name": "siliconflow", + "api_key": "YOUR_SILICONFLOW_KEY", + "api_base": "https://api.siliconflow.cn/v1", + "model": "deepseek-ai/DeepSeek-V3" + }, + { + "name": "qiniu", + "api_key": "YOUR_QINIU_KEY", + "api_base": "https://api.qnaigc.com/v1", + "model": "deepseek-v3" + } + ] +} +``` + +| パラメータ | 説明 | +| --- | --- | +| `custom_providers` | カスタムベンダーのリスト。各項目に `name`、`api_key`、`api_base`、任意の `model` を含む | +| `custom_active_provider` | 有効なベンダーの `name`。空の場合はリストの最初の項目が使用される | + + + `custom_providers` が空の場合、上記の単一ベンダー設定 `custom_api_key` / `custom_api_base` に自動的にフォールバックするため、既存の設定はそのまま動作します。 + diff --git a/docs/models/custom.mdx b/docs/models/custom.mdx index 45a7d2e1..d65b60b6 100644 --- a/docs/models/custom.mdx +++ b/docs/models/custom.mdx @@ -60,3 +60,37 @@ 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 select the active one with `custom_active_provider`: + +```json +{ + "bot_type": "custom", + "custom_active_provider": "siliconflow", + "custom_providers": [ + { + "name": "siliconflow", + "api_key": "YOUR_SILICONFLOW_KEY", + "api_base": "https://api.siliconflow.cn/v1", + "model": "deepseek-ai/DeepSeek-V3" + }, + { + "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 `name`, `api_key`, `api_base`, and an optional `model` | +| `custom_active_provider` | The `name` of the active provider; when empty, the first entry in the list is used | + + + When `custom_providers` is empty, CowAgent automatically falls back to the single-provider `custom_api_key` / `custom_api_base` fields above, so existing configurations keep working without any changes. + diff --git a/docs/zh/models/custom.mdx b/docs/zh/models/custom.mdx index 2673a8de..69c71494 100644 --- a/docs/zh/models/custom.mdx +++ b/docs/zh/models/custom.mdx @@ -60,3 +60,37 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模 ``` /config model qwen3.5:27b ``` + +## 配置多个自定义厂商 + +如果需要同时配置多个第三方 OpenAI 兼容服务(例如硅基流动、七牛云等),可以使用 `custom_providers` 列表,并通过 `custom_active_provider` 指定当前生效的厂商: + +```json +{ + "bot_type": "custom", + "custom_active_provider": "siliconflow", + "custom_providers": [ + { + "name": "siliconflow", + "api_key": "YOUR_SILICONFLOW_KEY", + "api_base": "https://api.siliconflow.cn/v1", + "model": "deepseek-ai/DeepSeek-V3" + }, + { + "name": "qiniu", + "api_key": "YOUR_QINIU_KEY", + "api_base": "https://api.qnaigc.com/v1", + "model": "deepseek-v3" + } + ] +} +``` + +| 参数 | 说明 | +| --- | --- | +| `custom_providers` | 自定义厂商列表,每项包含 `name`、`api_key`、`api_base`、可选的 `model` | +| `custom_active_provider` | 当前生效厂商的 `name`;留空时默认使用列表中的第一个厂商 | + + + 当 `custom_providers` 为空时,将自动回退到上文的 `custom_api_key` / `custom_api_base` 单厂商配置,已有配置无需改动即可正常工作。 + diff --git a/models/chatgpt/chat_gpt_bot.py b/models/chatgpt/chat_gpt_bot.py index 999986bc..991ca646 100644 --- a/models/chatgpt/chat_gpt_bot.py +++ b/models/chatgpt/chat_gpt_bot.py @@ -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 from models.chatgpt.chat_gpt_session import ChatGPTSession from models.openai.open_ai_image import OpenAIImage from models.session_manager import SessionManager @@ -33,8 +34,9 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): 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 + # Supports multiple custom providers (custom_providers) with + # automatic fallback to the legacy custom_api_key/base fields. + self._api_key, self._api_base, _ = resolve_custom_credentials() else: self._api_key = conf().get("open_ai_api_key") self._api_base = conf().get("open_ai_api_base") or None @@ -71,10 +73,19 @@ 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" + 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), @@ -186,9 +197,15 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): # 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") + 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 = [ diff --git a/models/custom_provider.py b/models/custom_provider.py new file mode 100644 index 00000000..a1570f7a --- /dev/null +++ b/models/custom_provider.py @@ -0,0 +1,84 @@ +# 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:: + + { + "name": "siliconflow", # unique, user-facing identifier + "api_key": "sk-...", # required + "api_base": "https://...", # required, must be OpenAI-compatible + "model": "deepseek-ai/DeepSeek-V3" # optional default model + } + +- ``custom_active_provider``: the ``name`` of the provider to use. When empty + (or pointing to a non-existent name) we fall back to the first provider in + the list, and finally to the legacy ``custom_api_key`` / ``custom_api_base``. + +Backward-compatibility contract +------------------------------- +When ``custom_providers`` is empty, ``resolve_custom_credentials`` returns +exactly the legacy ``custom_api_key`` / ``custom_api_base`` values, so existing +deployments behave byte-for-byte identically. +""" + +from config import conf +from common.log import logger + + +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 a name. + return [p for p in providers if isinstance(p, dict) and p.get("name")] + + +def _find_active_provider(providers): + """Pick the active provider from the list, or None when list is empty.""" + if not providers: + return None + active_name = conf().get("custom_active_provider") or "" + if active_name: + for p in providers: + if p.get("name") == active_name: + return p + logger.warning( + "[CUSTOM] active provider '%s' not found in custom_providers, " + "falling back to the first entry", active_name + ) + return providers[0] + + +def resolve_custom_credentials(): + """Resolve the effective (api_key, api_base, model) for custom mode. + + Resolution order: + 1. The active entry in ``custom_providers`` (multi-provider mode). + 2. The legacy flat keys ``custom_api_key`` / ``custom_api_base``. + + :return: tuple ``(api_key, api_base, model)``. ``api_base`` and ``model`` + may be ``None`` / empty when not configured. + """ + provider = _find_active_provider(get_custom_providers()) + if provider is not None: + return ( + provider.get("api_key", ""), + provider.get("api_base") or None, + provider.get("model") or None, + ) + # Legacy single-provider fallback — unchanged behavior. + return ( + conf().get("custom_api_key", ""), + conf().get("custom_api_base") or None, + None, + ) diff --git a/tests/test_custom_provider.py b/tests/test_custom_provider.py new file mode 100644 index 00000000..26df3bfa --- /dev/null +++ b/tests/test_custom_provider.py @@ -0,0 +1,170 @@ +# 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 custom_providers / custom_active_provider + - Robustness against malformed config (missing name, 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 TestResolveCustomCredentials(unittest.TestCase): + """resolve_custom_credentials() resolution order and fallbacks.""" + + def setUp(self): + # Import here so the module picks up our config-swapping helper. + 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 --- + + def test_multi_providers_no_active_uses_first(self): + set_conf({ + "bot_type": "custom", + "custom_providers": [ + {"name": "siliconflow", "api_key": "sf-key", + "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"}, + {"name": "qiniu", "api_key": "qn-key", + "api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"}, + ], + }) + self.assertEqual( + self.resolve(), + ("sf-key", "https://api.siliconflow.cn/v1", "deepseek-ai/DeepSeek-V3"), + ) + + def test_active_provider_selected(self): + set_conf({ + "bot_type": "custom", + "custom_active_provider": "qiniu", + "custom_providers": [ + {"name": "siliconflow", "api_key": "sf-key", + "api_base": "https://api.siliconflow.cn/v1", "model": "m1"}, + {"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_active_name_missing_falls_back_to_first(self): + set_conf({ + "bot_type": "custom", + "custom_active_provider": "ghost", + "custom_providers": [ + {"name": "siliconflow", "api_key": "sf-key", + "api_base": "https://api.siliconflow.cn/v1"}, + ], + }) + self.assertEqual( + self.resolve(), + ("sf-key", "https://api.siliconflow.cn/v1", None), + ) + + def test_provider_without_model_returns_none_model(self): + set_conf({ + "bot_type": "custom", + "custom_providers": [ + {"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", + "custom_api_key": "legacy-key", + "custom_api_base": "https://legacy.example.com/v1", + "custom_providers": [ + {"api_key": "no-name-key"}, # invalid: no name + "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": [ + {"name": "ok", "api_key": "k", "api_base": "https://x/v1"}, + {"api_key": "no-name"}, # dropped + 123, # dropped + ], + }) + providers = self.get_providers() + self.assertEqual(len(providers), 1) + self.assertEqual(providers[0]["name"], "ok") + + 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 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_has_active_provider(self): + from config import available_setting + self.assertIn("custom_active_provider", available_setting) + self.assertEqual(available_setting["custom_active_provider"], "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_custom_provider_handlers.py b/tests/test_custom_provider_handlers.py new file mode 100644 index 00000000..1420776d --- /dev/null +++ b/tests/test_custom_provider_handlers.py @@ -0,0 +1,258 @@ +# 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 / rename / activate) + - _handle_delete_custom_provider + - _handle_set_active_custom_provider + +These handlers are normally driven by the `web.py` framework, which isn't +available in the headless test environment, so we stub the `web` module before +import. The on-disk config read/write and the Bridge reset are patched to keep +the tests hermetic (no file I/O, no live bot routing). +""" +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": [], "custom_active_provider": ""}) + self.h = _HandlerHarness() + + def test_create_first_provider_auto_activates(self): + 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.assertEqual(res["active"], "siliconflow") + providers = config_module.conf().get("custom_providers") + self.assertEqual(len(providers), 1) + self.assertEqual(providers[0]["name"], "siliconflow") + self.assertEqual(config_module.conf().get("custom_active_provider"), "siliconflow") + self.assertEqual(self.h.bridge_resets, 1) + + 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): + self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak") + res = self.h.call(action="set_custom_provider", name="b", + api_base="https://b/v1", api_key="bk") + self.assertTrue(res["created"]) + # First provider stays active unless make_active is requested. + self.assertEqual(config_module.conf().get("custom_active_provider"), "a") + + def test_make_active_flag(self): + self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak") + self.h.call(action="set_custom_provider", name="b", + api_base="https://b/v1", api_key="bk", make_active=True) + self.assertEqual(config_module.conf().get("custom_active_provider"), "b") + + def test_duplicate_name_rejected(self): + self.h.call(action="set_custom_provider", name="dup", + api_base="https://a/v1", api_key="ak") + res = self.h.call(action="set_custom_provider", name="dup", + api_base="https://b/v1", api_key="bk") + self.assertEqual(res["status"], "error") + self.assertIn("already exists", res["message"]) + # The original entry must be untouched. + providers = config_module.conf().get("custom_providers") + self.assertEqual(len(providers), 1) + self.assertEqual(providers[0]["api_base"], "https://a/v1") + + def test_edit_keeps_key_when_omitted(self): + self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="secret") + # Edit only the base; omit api_key. + res = self.h.call(action="set_custom_provider", name="a", + original_name="a", api_base="https://a2/v1") + self.assertEqual(res["status"], "success") + self.assertFalse(res["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_rename_updates_active_pointer(self): + self.h.call(action="set_custom_provider", name="old", + api_base="https://a/v1", api_key="ak") + self.assertEqual(config_module.conf().get("custom_active_provider"), "old") + res = self.h.call(action="set_custom_provider", name="new", + original_name="old", api_base="https://a/v1") + self.assertEqual(res["status"], "success") + self.assertEqual(config_module.conf().get("custom_active_provider"), "new") + names = [p["name"] for p in config_module.conf().get("custom_providers")] + self.assertEqual(names, ["new"]) + + def test_edit_clears_model_when_empty(self): + self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak", model="m1") + self.assertEqual(config_module.conf().get("custom_providers")[0]["model"], "m1") + self.h.call(action="set_custom_provider", name="a", original_name="a", + 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": [], "custom_active_provider": ""}) + self.h = _HandlerHarness() + self.h.call(action="set_custom_provider", name="a", api_base="https://a/v1", api_key="ak") + 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", name="ghost") + self.assertEqual(res["status"], "error") + + def test_delete_non_active(self): + res = self.h.call(action="delete_custom_provider", name="b") + self.assertEqual(res["status"], "success") + names = [p["name"] for p in config_module.conf().get("custom_providers")] + self.assertEqual(names, ["a"]) + self.assertEqual(config_module.conf().get("custom_active_provider"), "a") + + 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("custom_active_provider"), "a") + res = self.h.call(action="delete_custom_provider", name="a") + self.assertEqual(res["status"], "success") + self.assertEqual(config_module.conf().get("custom_active_provider"), "b") + + def test_delete_last_clears_active(self): + self.h.call(action="delete_custom_provider", name="a") + self.h.call(action="delete_custom_provider", name="b") + self.assertEqual(config_module.conf().get("custom_providers"), []) + self.assertEqual(config_module.conf().get("custom_active_provider"), "") + + +class TestSetActiveCustomProvider(unittest.TestCase): + def setUp(self): + set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""}) + self.h = _HandlerHarness() + self.h.call(action="set_custom_provider", name="a", api_base="https://a/v1", api_key="ak") + 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", name="b") + self.assertEqual(res["status"], "success") + self.assertEqual(config_module.conf().get("custom_active_provider"), "b") + + def test_set_active_unknown(self): + res = self.h.call(action="set_active_custom_provider", name="ghost") + self.assertEqual(res["status"], "error") + self.assertEqual(config_module.conf().get("custom_active_provider"), "a") + + +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": [], "custom_active_provider": ""}) + 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) + self.assertTrue(custom_cards[0].get("is_custom")) + + def test_multi_providers_expand_into_cards(self): + set_conf({ + "bot_type": "custom", + "custom_active_provider": "b", + "custom_providers": [ + {"name": "a", "api_key": "ak", "api_base": "https://a/v1"}, + {"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_name = {c["custom_name"]: c for c in custom_cards} + self.assertEqual(by_name["a"]["id"], "custom:a") + self.assertFalse(by_name["a"]["active"]) + self.assertTrue(by_name["b"]["active"]) + self.assertEqual(by_name["b"]["model"], "m") + # No single legacy "custom" card when expanded. + self.assertFalse(any(c.get("id") == "custom" for c in overview)) + + def test_active_defaults_to_first_when_unset(self): + set_conf({ + "bot_type": "custom", + "custom_active_provider": "", + "custom_providers": [ + {"name": "a", "api_key": "ak", "api_base": "https://a/v1"}, + {"name": "b", "api_key": "bk", "api_base": "https://b/v1"}, + ], + }) + cards = ModelsHandler._custom_provider_cards(config_module.conf()) + by_name = {c["custom_name"]: c for c in cards} + self.assertTrue(by_name["a"]["active"]) + self.assertFalse(by_name["b"]["active"]) + + +if __name__ == "__main__": + unittest.main()