From cffa590d3e9eac5b59284df1565dd76951e9f049 Mon Sep 17 00:00:00 2001 From: kirs-hi Date: Wed, 10 Jun 2026 18:12:30 +0800 Subject: [PATCH 1/4] 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() From 1940d628a82b307786722402b69d0d2e350d06a5 Mon Sep 17 00:00:00 2001 From: kirs-hi Date: Thu, 11 Jun 2026 17:25:24 +0800 Subject: [PATCH 2/4] refactor(custom): id-based routing, single source of truth, security fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the multi custom-provider design per maintainer review: 1. Data model: use server-generated uuid4 short id as primary key; 'name' is now a pure display label that can be freely renamed. 2. Routing: drop 'custom_active_provider'; activate a provider by setting bot_type to 'custom:'. Single source of truth — no pointer drift between bot_type and a separate active selector. 3. Security: drag_sensitive() now recursively masks api_key/secret in nested structures (custom_providers list); previously only top-level string fields were masked. 4. Per-provider model: the provider's 'model' field now takes effect on the main chat path and agent path (was silently ignored before). 5. XSS fix: replace all inline onclick handlers in custom-provider UI with data-* attributes + event delegation. Provider names never appear in executable HTML contexts. Legacy compatibility: bot_type='custom' (no colon) still reads the flat custom_api_key/custom_api_base fields byte-for-byte identically. Closes: consolidates #2876 into this PR as requested. Ref: #2838 --- channel/web/static/js/console.js | 62 +++++---- channel/web/web_channel.py | 155 +++++++++++----------- config.py | 40 ++++-- docs/ja/models/custom.mdx | 64 +++++---- docs/models/custom.mdx | 20 ++- docs/zh/models/custom.mdx | 64 +++++---- models/bot_factory.py | 2 +- models/chatgpt/chat_gpt_bot.py | 21 +-- models/custom_provider.py | 103 ++++++++++----- tests/test_custom_provider.py | 152 +++++++++++++++------ tests/test_custom_provider_handlers.py | 174 ++++++++++++------------- 11 files changed, 504 insertions(+), 353 deletions(-) diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 74bd885f..d15f0248 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -4861,9 +4861,11 @@ 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 +// expands `custom_providers` into provider cards with id="custom:", +// 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); @@ -4884,7 +4886,7 @@ function renderCustomProvidersSection() {

${t('models_custom_section')}

${t('models_custom_section_desc')}

- @@ -4903,18 +4905,30 @@ function renderCustomProvidersSection() { } 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. + // "set active" affordance via data-attributes (no inline onclick — XSS safe). const activeBadge = p.active ? ` ${t('models_custom_active')}` - : ``; @@ -4930,7 +4944,7 @@ function renderCustomProviderRow(p) { : ''; return ` -
+
${renderProviderLogo(p, 28)}
@@ -4939,11 +4953,11 @@ function renderCustomProviderRow(p) {
${base}${model ? '·' + model : ''}
- - @@ -6245,16 +6259,15 @@ 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: '' }; +// 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(name) { - const editing = !!name; - customProviderModalState = { originalName: editing ? name : '' }; +function openCustomProviderModal(providerId) { + const editing = !!providerId; + customProviderModalState = { editId: editing ? providerId : '' }; - const card = editing ? getCustomProviderCards().find(p => p.custom_name === name) : null; + const card = editing ? getCustomProviderCards().find(p => p.custom_id === providerId) : null; const overlay = document.getElementById('custom-provider-modal-overlay'); if (!overlay) return; @@ -6322,7 +6335,7 @@ function saveCustomProviderModal() { document.getElementById('custom-provider-name').focus(); return; } - const editing = !!customProviderModalState.originalName; + const editing = !!customProviderModalState.editId; if (!editing && !apiBase) { showStatus('custom-provider-modal-status', 'models_custom_base_required', true); document.getElementById('custom-provider-base').focus(); @@ -6342,7 +6355,7 @@ function saveCustomProviderModal() { model: model, }; if (apiKey) payload.api_key = apiKey; - if (editing) payload.original_name = customProviderModalState.originalName; + if (editing) payload.id = customProviderModalState.editId; const btn = document.getElementById('custom-provider-modal-save'); btn.disabled = true; @@ -6356,10 +6369,7 @@ function saveCustomProviderModal() { 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); + showStatus('custom-provider-modal-status', 'models_save_failed', true); } }).catch(() => { btn.disabled = false; @@ -6367,17 +6377,17 @@ function saveCustomProviderModal() { }); } -function setActiveCustomProvider(name) { +function setActiveCustomProvider(providerId) { fetch('/api/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'set_active_custom_provider', name: name }), + 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(name) { +function deleteCustomProvider(providerId) { showConfirmDialog({ title: t('models_custom_delete_confirm_title'), message: t('models_custom_delete_confirm_msg'), @@ -6387,7 +6397,7 @@ function deleteCustomProvider(name) { fetch('/api/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'delete_custom_provider', name: name }), + body: JSON.stringify({ action: 'delete_custom_provider', id: providerId }), }).then(r => r.json()).then(data => { if (data.status === 'success') loadModelsView(); }).catch(() => { /* noop */ }); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index d1185000..6eb933db 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1601,7 +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", + "custom_providers", "agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps", "enable_thinking", "self_evolution_enabled", "web_password", } @@ -2137,10 +2137,9 @@ class ModelsHandler: """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). + 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. Returns an empty list when no multi-providers are configured, in which case the caller keeps the single legacy ``custom`` card untouched — @@ -2148,7 +2147,7 @@ class ModelsHandler: ``custom_api_key`` / ``custom_api_base`` config. """ try: - from models.custom_provider import get_custom_providers + 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}") @@ -2156,27 +2155,26 @@ class ModelsHandler: 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 "" + # 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: - name = p.get("name") or "" + 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:{name}", + "id": f"custom:{pid}", "label": {"zh": name, "en": name}, "configured": configured, "is_custom": True, + "custom_id": pid, "custom_name": name, - "active": (name == active_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 @@ -2781,10 +2779,9 @@ class ModelsHandler: # ------------------------------------------------------------------ # 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. + # These actions manage the ``custom_providers`` list. Activation is done + # by setting ``bot_type`` to ``"custom:"``. There is no separate + # ``custom_active_provider`` field — a single source of truth. @staticmethod def _normalize_custom_providers(raw) -> List[dict]: @@ -2793,20 +2790,22 @@ class ModelsHandler: return [] out = [] for p in raw: - if isinstance(p, dict) and (p.get("name") or "").strip(): + if isinstance(p, dict) and (p.get("id") 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.""" + 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``.""" 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 + if bot_type is not None: + local_config["bot_type"] = bot_type + file_cfg["bot_type"] = bot_type self._write_file_config(file_cfg) self._reset_bridge() @@ -2817,48 +2816,38 @@ class ModelsHandler: { "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 + "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() - # ``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 + 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"}) - entry = {"name": name, "api_key": api_key, "api_base": api_base} + 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) @@ -2876,64 +2865,68 @@ class ModelsHandler: 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: + # Decide bot_type. + _, current_active_id = parse_custom_bot_type(local_config.get("bot_type") or "") + new_bot_type = None + if make_active or (created and not current_active_id): # 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 + new_bot_type = f"custom:{provider_id}" - self._persist_custom_providers(providers, active_name) + self._persist_custom_providers(providers, new_bot_type) logger.info( - f"[ModelsHandler] custom provider {name!r} " - f"{'created' if created else 'updated'} (active={active_name!r})" + 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, - "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"}) + """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("name") != name] + 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: {name}"}) + return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"}) - 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 "" + # 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, 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}) + 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: - """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"}) + """Activate a custom provider by setting bot_type to 'custom:'.""" + 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("name") == name for p in providers): - return json.dumps({"status": "error", "message": f"unknown custom provider: {name}"}) + 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}"}) - self._persist_custom_providers(providers, name) - logger.info(f"[ModelsHandler] active custom provider set to {name!r}") - return json.dumps({"status": "success", "active": name}) + 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() diff --git a/config.py b/config.py index 5c12e4b0..6d81f5ff 100644 --- a/config.py +++ b/config.py @@ -26,10 +26,9 @@ available_setting = { "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"); 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"} + # Multiple custom (OpenAI-compatible) providers. Activated via bot_type: "custom:". + # Each item: {"id": "3f2a9c1b", "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 @@ -325,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 diff --git a/docs/ja/models/custom.mdx b/docs/ja/models/custom.mdx index 8ce33d0e..b63f4e34 100644 --- a/docs/ja/models/custom.mdx +++ b/docs/ja/models/custom.mdx @@ -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 から複数のモデルを呼び出す -- **ローカルモデル**:Ollama、vLLM、LocalAI などのツールでローカルにデプロイしたモデル -- **プライベートデプロイ**:企業内部にデプロイしたモデルサービス +- **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 - `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. -## テキスト対話 +## 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,38 +45,39 @@ 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 -複数の OpenAI 互換サードパーティサービス(例:SiliconFlow、Qiniu)を同時に設定したい場合は、`custom_providers` リストを使用し、`custom_active_provider` で現在有効なベンダーを指定します: +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:"`: ```json { - "bot_type": "custom", - "custom_active_provider": "siliconflow", + "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", @@ -86,11 +87,18 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ } ``` -| パラメータ | 説明 | +| Parameter | Description | | --- | --- | -| `custom_providers` | カスタムベンダーのリスト。各項目に `name`、`api_key`、`api_base`、任意の `model` を含む | -| `custom_active_provider` | 有効なベンダーの `name`。空の場合はリストの最初の項目が使用される | +| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` | +| `bot_type` | Set to `"custom:"` 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 | - `custom_providers` が空の場合、上記の単一ベンダー設定 `custom_api_key` / `custom_api_base` に自動的にフォールバックするため、既存の設定はそのまま動作します。 + 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])"`). + + + + 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. diff --git a/docs/models/custom.mdx b/docs/models/custom.mdx index d65b60b6..b63f4e34 100644 --- a/docs/models/custom.mdx +++ b/docs/models/custom.mdx @@ -63,20 +63,21 @@ Switching models under a custom vendor only changes `model` — `bot_type` and t ## 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`: +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:"`: ```json { - "bot_type": "custom", - "custom_active_provider": "siliconflow", + "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", @@ -88,9 +89,16 @@ If you need to configure several OpenAI-compatible third-party services at once | 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 | +| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` | +| `bot_type` | Set to `"custom:"` 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 | - 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. + 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])"`). + + + + 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. diff --git a/docs/zh/models/custom.mdx b/docs/zh/models/custom.mdx index 69c71494..b63f4e34 100644 --- a/docs/zh/models/custom.mdx +++ b/docs/zh/models/custom.mdx @@ -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 调用多种模型 -- **本地模型**:通过 Ollama、vLLM、LocalAI 等工具在本地部署的模型 -- **私有化部署**:企业内部部署的模型服务 +- **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 - 与 `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. -## 文本对话 +## 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,38 +45,39 @@ 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 -如果需要同时配置多个第三方 OpenAI 兼容服务(例如硅基流动、七牛云等),可以使用 `custom_providers` 列表,并通过 `custom_active_provider` 指定当前生效的厂商: +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:"`: ```json { - "bot_type": "custom", - "custom_active_provider": "siliconflow", + "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", @@ -86,11 +87,18 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `custom_providers` | 自定义厂商列表,每项包含 `name`、`api_key`、`api_base`、可选的 `model` | -| `custom_active_provider` | 当前生效厂商的 `name`;留空时默认使用列表中的第一个厂商 | +| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` | +| `bot_type` | Set to `"custom:"` 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 | - 当 `custom_providers` 为空时,将自动回退到上文的 `custom_api_key` / `custom_api_base` 单厂商配置,已有配置无需改动即可正常工作。 + 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])"`). + + + + 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. diff --git a/models/bot_factory.py b/models/bot_factory.py index 5d07a236..415e9229 100644 --- a/models/bot_factory.py +++ b/models/bot_factory.py @@ -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() diff --git a/models/chatgpt/chat_gpt_bot.py b/models/chatgpt/chat_gpt_bot.py index 991ca646..1bc2a9da 100644 --- a/models/chatgpt/chat_gpt_bot.py +++ b/models/chatgpt/chat_gpt_bot.py @@ -17,7 +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.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 @@ -33,10 +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": - # 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() + is_custom, _ = parse_custom_bot_type(conf().get("bot_type", "")) + custom_model = None + if is_custom: + # Supports multiple custom providers via bot_type "custom:" + # 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 @@ -48,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 = { @@ -72,7 +75,7 @@ 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 @@ -196,7 +199,7 @@ 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" + 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") diff --git a/models/custom_provider.py b/models/custom_provider.py index a1570f7a..f6db2d34 100644 --- a/models/custom_provider.py +++ b/models/custom_provider.py @@ -13,69 +13,110 @@ 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 + "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 } -- ``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``. +Routing +------- +- ``bot_type: "custom"`` (legacy): reads the flat ``custom_api_key`` / ``custom_api_base``. +- ``bot_type: "custom:"`` (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 ``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. +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 a name. - return [p for p in providers if isinstance(p, dict) and p.get("name")] + # Keep only well-formed entries with an id. + return [p for p in providers if isinstance(p, dict) and p.get("id")] -def _find_active_provider(providers): - """Pick the active provider from the list, or None when list is empty.""" - if not providers: +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 - 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] + 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. The active entry in ``custom_providers`` (multi-provider mode). - 2. The legacy flat keys ``custom_api_key`` / ``custom_api_base``. + 1. If ``bot_type`` is ``"custom:"``, 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. """ - provider = _find_active_provider(get_custom_providers()) - if provider is not None: + 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 ( - provider.get("api_key", ""), - provider.get("api_base") or None, - provider.get("model") or None, + 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", ""), diff --git a/tests/test_custom_provider.py b/tests/test_custom_provider.py index 26df3bfa..fcd5dceb 100644 --- a/tests/test_custom_provider.py +++ b/tests/test_custom_provider.py @@ -4,8 +4,9 @@ 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) + - Multi-provider selection via bot_type "custom:" routing + - parse_custom_bot_type helper + - Robustness against malformed config (missing id, non-dict, non-list) """ import sys import os @@ -23,11 +24,43 @@ def set_conf(d): 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): - # 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 @@ -49,31 +82,15 @@ class TestResolveCustomCredentials(unittest.TestCase): set_conf({"bot_type": "custom"}) self.assertEqual(self.resolve(), ("", None, None)) - # --- Multi-provider selection --- + # --- Multi-provider selection via bot_type --- - def test_multi_providers_no_active_uses_first(self): + def test_provider_selected_by_id(self): set_conf({ - "bot_type": "custom", + "bot_type": "custom:abc12345", "custom_providers": [ - {"name": "siliconflow", "api_key": "sf-key", + {"id": "sf001", "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", + {"id": "abc12345", "name": "qiniu", "api_key": "qn-key", "api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"}, ], }) @@ -82,25 +99,26 @@ class TestResolveCustomCredentials(unittest.TestCase): ("qn-key", "https://api.qnaigc.com/v1", "deepseek-v3"), ) - def test_active_name_missing_falls_back_to_first(self): + def test_id_not_found_falls_back_to_legacy(self): set_conf({ - "bot_type": "custom", - "custom_active_provider": "ghost", + "bot_type": "custom:ghost", + "custom_api_key": "legacy-key", + "custom_api_base": "https://legacy.example.com/v1", "custom_providers": [ - {"name": "siliconflow", "api_key": "sf-key", + {"id": "sf001", "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), + ("legacy-key", "https://legacy.example.com/v1", None), ) def test_provider_without_model_returns_none_model(self): set_conf({ - "bot_type": "custom", + "bot_type": "custom:local01", "custom_providers": [ - {"name": "local", "api_key": "", "api_base": "http://localhost:11434/v1"}, + {"id": "local01", "name": "local", "api_key": "", "api_base": "http://localhost:11434/v1"}, ], }) self.assertEqual( @@ -112,12 +130,12 @@ class TestResolveCustomCredentials(unittest.TestCase): def test_malformed_entries_filtered_and_fallback(self): set_conf({ - "bot_type": "custom", + "bot_type": "custom:nope", "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 + {"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 @@ -130,14 +148,14 @@ class TestResolveCustomCredentials(unittest.TestCase): set_conf({ "bot_type": "custom", "custom_providers": [ - {"name": "ok", "api_key": "k", "api_base": "https://x/v1"}, - {"api_key": "no-name"}, # dropped - 123, # dropped + {"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]["name"], "ok") + self.assertEqual(providers[0]["id"], "ok1") def test_custom_providers_not_a_list_falls_back(self): set_conf({ @@ -152,6 +170,22 @@ class TestResolveCustomCredentials(unittest.TestCase): ) +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.""" @@ -160,10 +194,46 @@ class TestConfigDefaults(unittest.TestCase): self.assertIn("custom_providers", available_setting) self.assertEqual(available_setting["custom_providers"], []) - def test_default_config_has_active_provider(self): + 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.assertIn("custom_active_provider", available_setting) - self.assertEqual(available_setting["custom_active_provider"], "") + 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__": diff --git a/tests/test_custom_provider_handlers.py b/tests/test_custom_provider_handlers.py index 1420776d..f7792261 100644 --- a/tests/test_custom_provider_handlers.py +++ b/tests/test_custom_provider_handlers.py @@ -4,14 +4,11 @@ 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_set_custom_provider (create / edit / 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). +Uses id-based routing (bot_type: "custom:") — no custom_active_provider. """ import json import os @@ -72,7 +69,7 @@ class _HandlerHarness: class TestSetCustomProvider(unittest.TestCase): def setUp(self): - set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""}) + set_conf({"bot_type": "custom", "custom_providers": []}) self.h = _HandlerHarness() def test_create_first_provider_auto_activates(self): @@ -80,11 +77,15 @@ class TestSetCustomProvider(unittest.TestCase): 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") + self.assertIn("id", res) + # bot_type should be updated to "custom:" + bot_type = config_module.conf().get("bot_type") + self.assertTrue(bot_type.startswith("custom:")) + self.assertEqual(bot_type, f"custom:{res['id']}") 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(config_module.conf().get("custom_active_provider"), "siliconflow") self.assertEqual(self.h.bridge_resets, 1) def test_create_requires_api_base(self): @@ -97,161 +98,158 @@ class TestSetCustomProvider(unittest.TestCase): 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"]) + res1 = 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") + self.assertTrue(res2["created"]) # First provider stays active unless make_active is requested. - self.assertEqual(config_module.conf().get("custom_active_provider"), "a") + 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") - 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") + 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): - 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"]) + 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_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_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): - self.h.call(action="set_custom_provider", name="a", - api_base="https://a/v1", api_key="ak", model="m1") + 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", original_name="a", + 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": [], "custom_active_provider": ""}) + set_conf({"bot_type": "custom", "custom_providers": []}) 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") + self.res_a = self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak") + 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", name="ghost") + 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", name="b") + res = self.h.call(action="delete_custom_provider", id=self.res_b["id"]) 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") + 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("custom_active_provider"), "a") - res = self.h.call(action="delete_custom_provider", name="a") + 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("custom_active_provider"), "b") + self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_b['id']}") - 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") + 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"), []) - self.assertEqual(config_module.conf().get("custom_active_provider"), "") + # 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": [], "custom_active_provider": ""}) + set_conf({"bot_type": "custom", "custom_providers": []}) 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") + self.res_a = self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak") + 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", name="b") + 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("custom_active_provider"), "b") + 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", name="ghost") + res = self.h.call(action="set_active_custom_provider", id="ghost") self.assertEqual(res["status"], "error") - self.assertEqual(config_module.conf().get("custom_active_provider"), "a") + # bot_type unchanged + self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}") 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": ""}) + 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) - 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", + "bot_type": "custom:id_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"}, + {"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_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") + 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_active_defaults_to_first_when_unset(self): + 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_active_provider": "", "custom_providers": [ - {"name": "a", "api_key": "ak", "api_base": "https://a/v1"}, - {"name": "b", "api_key": "bk", "api_base": "https://b/v1"}, + {"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()) - by_name = {c["custom_name"]: c for c in cards} - self.assertTrue(by_name["a"]["active"]) - self.assertFalse(by_name["b"]["active"]) + active_cards = [c for c in cards if c.get("active")] + self.assertEqual(len(active_cards), 0) if __name__ == "__main__": From 0092376c07129de4b353400a0b59b32e0f699b1a Mon Sep 17 00:00:00 2001 From: kirs-hi Date: Fri, 12 Jun 2026 10:05:05 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20no=20?= =?UTF-8?q?auto-hijack,=20sync=20model=20on=20activation,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Creating a provider no longer auto-switches bot_type. Only an explicit make_active=true changes the active model — prevents silently hijacking users on Claude/OpenAI/etc. 2. When a provider IS activated, its 'model' is now written into the global 'model' field. This ensures all three paths (regular chat, agent_bridge, vision) use the correct model without per-path patches. 3. Removed unused i18n key 'models_custom_name_exists' (no longer referenced after the id-based rework removed name-collision checks). 4. Updated tests: 39 passing (added model-sync tests, fixed tests that relied on the removed auto-activation behavior). --- channel/web/static/js/console.js | 2 -- channel/web/web_channel.py | 21 +++++++---- tests/test_custom_provider_handlers.py | 49 +++++++++++++++++++++----- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index d15f0248..f9a7b43c 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -46,7 +46,6 @@ const I18N = { 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: '添加自定义厂商', @@ -268,7 +267,6 @@ const I18N = { 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', diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 6eb933db..9a02b4d3 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -2798,7 +2798,12 @@ class ModelsHandler: """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``.""" + If ``bot_type`` is given, also update ``bot_type``. When activating a + provider (bot_type is ``custom:``), 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 @@ -2806,6 +2811,13 @@ class ModelsHandler: 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() @@ -2865,12 +2877,9 @@ class ModelsHandler: existing.pop("model", None) created = False - # Decide bot_type. - _, current_active_id = parse_custom_bot_type(local_config.get("bot_type") or "") + # Decide bot_type — only switch when explicitly requested. new_bot_type = None - if make_active or (created and not current_active_id): - # Activate on explicit request, or auto-activate the very first - # provider so the resolver has a definite target. + if make_active: new_bot_type = f"custom:{provider_id}" self._persist_custom_providers(providers, new_bot_type) diff --git a/tests/test_custom_provider_handlers.py b/tests/test_custom_provider_handlers.py index f7792261..9a47b564 100644 --- a/tests/test_custom_provider_handlers.py +++ b/tests/test_custom_provider_handlers.py @@ -72,22 +72,31 @@ class TestSetCustomProvider(unittest.TestCase): set_conf({"bot_type": "custom", "custom_providers": []}) self.h = _HandlerHarness() - def test_create_first_provider_auto_activates(self): + 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 should be updated to "custom:" + # bot_type must remain unchanged — no auto-activation. bot_type = config_module.conf().get("bot_type") - self.assertTrue(bot_type.startswith("custom:")) - self.assertEqual(bot_type, f"custom:{res['id']}") + 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") @@ -98,12 +107,13 @@ class TestSetCustomProvider(unittest.TestCase): 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") + 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 unless make_active is requested. + # 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']}") @@ -155,7 +165,8 @@ class TestDeleteCustomProvider(unittest.TestCase): 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") + 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") @@ -191,7 +202,8 @@ class TestSetActiveCustomProvider(unittest.TestCase): 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") + 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") @@ -206,6 +218,27 @@ class TestSetActiveCustomProvider(unittest.TestCase): # 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.""" From 1d7e6b370328a990cf0c7008d40400e92bbb1a5e Mon Sep 17 00:00:00 2001 From: zhayujie Date: Fri, 12 Jun 2026 11:54:19 +0800 Subject: [PATCH 4/4] fix(web): accept custom: providers in the chat capability card _set_chat rejected the expanded "custom:" ids with "unknown provider", so switching to a custom provider was only possible from the custom providers section. Now the chat card and the custom section behave consistently: _set_chat validates the id against custom_providers, falls back to the provider's default model when none is picked, and _chat_capability expands the dropdown with the "custom:" entries (legacy single-custom mode unchanged). Co-authored-by: Cursor --- channel/web/web_channel.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 9a02b4d3..6a59beb6 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -2247,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:" 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)), } @@ -3001,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:") 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