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..f9a7b43c 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -32,6 +32,23 @@ const I18N = { models_clear_credential: '清除凭据', models_base_default_hint: '留空将使用官方默认地址', models_base_default: '默认', + models_custom_section: '自定义厂商', + models_custom_section_desc: '配置多个 OpenAI 兼容厂商,自由切换', + models_custom_add: '添加自定义厂商', + models_custom_name: '名称', + models_custom_name_placeholder: '例如 siliconflow', + models_custom_default_model: '默认模型(可选)', + models_custom_default_model_placeholder: '例如 deepseek-ai/DeepSeek-V3', + models_custom_active: '使用中', + models_custom_set_active: '设为使用中', + models_custom_delete: '删除', + models_custom_delete_confirm_title: '删除自定义厂商', + models_custom_delete_confirm_msg: '确定删除该自定义厂商吗?此操作无法撤销。', + models_custom_name_required: '请填写名称', + models_custom_base_required: '请填写 API Base', + models_custom_empty: '尚未配置自定义厂商,点击添加', + models_custom_edit_title: '编辑自定义厂商', + models_custom_add_title: '添加自定义厂商', models_capability_chat: '主模型', models_capability_chat_desc: '用于基础对话和 Agent 推理', models_capability_vision: '图像理解', @@ -236,6 +253,23 @@ const I18N = { models_clear_credential: 'Clear credentials', models_base_default_hint: 'Leave blank to use the official default base URL', models_base_default: 'Default', + models_custom_section: 'Custom Providers', + models_custom_section_desc: 'Configure multiple OpenAI-compatible providers and switch freely', + models_custom_add: 'Add custom provider', + models_custom_name: 'Name', + models_custom_name_placeholder: 'e.g. siliconflow', + models_custom_default_model: 'Default model (optional)', + models_custom_default_model_placeholder: 'e.g. deepseek-ai/DeepSeek-V3', + models_custom_active: 'Active', + models_custom_set_active: 'Set active', + models_custom_delete: 'Delete', + models_custom_delete_confirm_title: 'Delete custom provider', + models_custom_delete_confirm_msg: 'Delete this custom provider? This cannot be undone.', + models_custom_name_required: 'Name is required', + models_custom_base_required: 'API Base is required', + models_custom_empty: 'No custom providers yet, click to add', + models_custom_edit_title: 'Edit custom provider', + models_custom_add_title: 'Add custom provider', models_capability_chat: 'Main Model', models_capability_chat_desc: 'Used for basic chat and agent reasoning', models_capability_vision: 'Image Understanding', @@ -4732,16 +4766,26 @@ function renderModelsView() { const container = document.getElementById('models-content'); container.innerHTML = ''; container.appendChild(renderVendorsSection()); + container.appendChild(renderCustomProvidersSection()); MODELS_CAPABILITY_DEFS.forEach(def => container.appendChild(renderCapabilityCard(def))); } +// True when a provider card is one of the expanded custom (OpenAI-compatible) +// providers — these are managed in their own section, not the vendor grid. +function isCustomProviderCard(p) { + return !!(p && p.is_custom && p.custom_name); +} + // ---------- Vendor section (Layer 1) ----------------------------------- function renderVendorsSection() { const wrap = document.createElement('div'); wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6'; - const configured = modelsState.providers.filter(p => p.configured); + // Expanded custom providers live in their own section; keep the built-in + // vendor grid focused on the canonical (field-based) providers. + const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p)); + const configured = builtinProviders.filter(p => p.configured); const header = `
@@ -4752,7 +4796,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 +4856,112 @@ 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_id, custom_name and an `active` flag (see +// ModelsHandler._custom_provider_cards / _provider_overview). +// All button interactions use data-* attributes + event delegation (no inline +// onclick) to avoid XSS via user-supplied names. + +function getCustomProviderCards() { + return modelsState.providers.filter(isCustomProviderCard); +} + +function renderCustomProvidersSection() { + const wrap = document.createElement('div'); + wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6'; + + const customs = getCustomProviderCards(); + + const header = ` +
+
+ +
+
+

${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; + // Event delegation — handles all custom-provider actions via data-action attrs. + wrap.addEventListener('click', function(e) { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const action = btn.getAttribute('data-action'); + const providerId = btn.getAttribute('data-provider-id') || ''; + if (action === 'add-custom') openCustomProviderModal(''); + else if (action === 'edit-custom') openCustomProviderModal(providerId); + else if (action === 'delete-custom') deleteCustomProvider(providerId); + else if (action === 'set-active-custom') setActiveCustomProvider(providerId); + }); + return wrap; +} + +function renderCustomProviderRow(p) { + const id = p.custom_id || ''; + const name = p.custom_name || ''; + const nameEsc = escapeHtml(name); + // The active provider gets a highlighted ring + badge; others show a + // "set active" affordance via data-attributes (no inline onclick — XSS safe). + const activeBadge = p.active + ? ` + ${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 +6083,15 @@ function openVendorModal(providerId, onSaved) { // currently selected vendor via its own background highlight, so we // intentionally suppress the global active-row ✓ for this picker // (see CSS) — otherwise configured + selected rows would show two. - const unconfigured = modelsState.providers.filter(p => !p.configured); - const defaultId = (unconfigured[0] && unconfigured[0].id) || (modelsState.providers[0] && modelsState.providers[0].id) || ''; + // Custom (OpenAI-compatible) providers are managed in their own + // section with a dedicated modal, so they are excluded from this + // built-in vendor picker. + const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p)); + const unconfigured = builtinProviders.filter(p => !p.configured); + const defaultId = (unconfigured[0] && unconfigured[0].id) || (builtinProviders[0] && builtinProviders[0].id) || ''; pickerWrap.classList.remove('hidden'); const pickerEl = document.getElementById('vendor-modal-picker'); - const pickerOpts = modelsState.providers.map(p => ({ + const pickerOpts = builtinProviders.map(p => ({ value: p.id, label: localizedLabel(p.label), _configured: !!p.configured, @@ -6100,6 +6254,155 @@ function clearVendorModal() { }); } +// ===================================================================== +// Custom (OpenAI-compatible) provider modal — add / edit +// ===================================================================== +// State for the dedicated custom-provider modal. `editId` is empty when +// adding and set to the provider id when editing. +let customProviderModalState = { editId: '' }; + +function openCustomProviderModal(providerId) { + const editing = !!providerId; + customProviderModalState = { editId: editing ? providerId : '' }; + + const card = editing ? getCustomProviderCards().find(p => p.custom_id === providerId) : null; + + const overlay = document.getElementById('custom-provider-modal-overlay'); + if (!overlay) return; + + document.getElementById('custom-provider-modal-title').textContent = + editing ? t('models_custom_edit_title') : t('models_custom_add_title'); + + const nameInput = document.getElementById('custom-provider-name'); + const baseInput = document.getElementById('custom-provider-base'); + const keyInput = document.getElementById('custom-provider-key'); + const modelInput = document.getElementById('custom-provider-model'); + + nameInput.value = card ? (card.custom_name || '') : ''; + baseInput.value = card ? (card.api_base || '') : ''; + modelInput.value = card ? (card.model || '') : ''; + + // Surface the masked key as the value for configured providers so the + // "already set" state is unambiguous; an untouched masked value means + // "keep the existing key" on save (mirrors the vendor modal contract). + if (card && card.configured && card.api_key_masked) { + keyInput.value = card.api_key_masked; + keyInput.dataset.masked = '1'; + keyInput.dataset.maskedVal = card.api_key_masked; + } else { + keyInput.value = ''; + keyInput.dataset.masked = ''; + keyInput.dataset.maskedVal = ''; + } + keyInput.oninput = function () { + if (keyInput.dataset.masked === '1' && keyInput.value !== keyInput.dataset.maskedVal) { + keyInput.dataset.masked = ''; + } + }; + + const statusEl = document.getElementById('custom-provider-modal-status'); + if (statusEl) { statusEl.textContent = ''; statusEl.classList.add('opacity-0'); } + + overlay.classList.remove('hidden'); + document.getElementById('custom-provider-modal-cancel').onclick = closeCustomProviderModal; + document.getElementById('custom-provider-modal-save').onclick = saveCustomProviderModal; + + function onOverlayClick(e) { + if (e.target === overlay) { + closeCustomProviderModal(); + overlay.removeEventListener('click', onOverlayClick); + } + } + overlay.addEventListener('click', onOverlayClick); + nameInput.focus(); +} + +function closeCustomProviderModal() { + const overlay = document.getElementById('custom-provider-modal-overlay'); + if (overlay) overlay.classList.add('hidden'); +} + +function saveCustomProviderModal() { + const name = document.getElementById('custom-provider-name').value.trim(); + const apiBase = document.getElementById('custom-provider-base').value.trim(); + const model = document.getElementById('custom-provider-model').value.trim(); + const keyInput = document.getElementById('custom-provider-key'); + + if (!name) { + showStatus('custom-provider-modal-status', 'models_custom_name_required', true); + document.getElementById('custom-provider-name').focus(); + return; + } + const editing = !!customProviderModalState.editId; + if (!editing && !apiBase) { + showStatus('custom-provider-modal-status', 'models_custom_base_required', true); + document.getElementById('custom-provider-base').focus(); + return; + } + + // Untouched masked key => no change (omit from payload). + let apiKey = keyInput.value.trim(); + if (keyInput.dataset.masked === '1' && apiKey === (keyInput.dataset.maskedVal || '')) { + apiKey = ''; + } + + const payload = { + action: 'set_custom_provider', + name: name, + api_base: apiBase, + model: model, + }; + if (apiKey) payload.api_key = apiKey; + if (editing) payload.id = customProviderModalState.editId; + + const btn = document.getElementById('custom-provider-modal-save'); + btn.disabled = true; + fetch('/api/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).then(r => r.json()).then(data => { + btn.disabled = false; + if (data.status === 'success') { + closeCustomProviderModal(); + loadModelsView(); + } else { + showStatus('custom-provider-modal-status', 'models_save_failed', true); + } + }).catch(() => { + btn.disabled = false; + showStatus('custom-provider-modal-status', 'models_save_failed', true); + }); +} + +function setActiveCustomProvider(providerId) { + fetch('/api/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'set_active_custom_provider', id: providerId }), + }).then(r => r.json()).then(data => { + if (data.status === 'success') loadModelsView(); + }).catch(() => { /* noop */ }); +} + +function deleteCustomProvider(providerId) { + showConfirmDialog({ + title: t('models_custom_delete_confirm_title'), + message: t('models_custom_delete_confirm_msg'), + okText: t('models_custom_delete'), + cancelText: t('cancel'), + onConfirm: () => { + fetch('/api/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'delete_custom_provider', id: providerId }), + }).then(r => r.json()).then(data => { + if (data.status === 'success') loadModelsView(); + }).catch(() => { /* noop */ }); + } + }); +} + // ===================================================================== // Channels View // ===================================================================== diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 1be31c6a..6a59beb6 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", "agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps", "enable_thinking", "self_evolution_enabled", "web_password", } @@ -2131,13 +2132,83 @@ class ModelsHandler: def _is_real_key(value: str) -> bool: return bool(value) and value not in ("", "YOUR API KEY", "YOUR_API_KEY") + @classmethod + def _custom_provider_cards(cls, local_config: dict) -> List[dict]: + """Expand ``custom_providers`` into one card per provider. + + Each user-defined OpenAI-compatible provider becomes its own card with + id ``custom:`` so the frontend can render, edit, delete and + activate them independently. The card carries ``is_custom=True`` and + ``active`` flags that the UI uses to render the extra controls. + + Returns an empty list when no multi-providers are configured, in which + case the caller keeps the single legacy ``custom`` card untouched — + guaranteeing backward compatibility with the flat + ``custom_api_key`` / ``custom_api_base`` config. + """ + try: + from models.custom_provider import get_custom_providers, parse_custom_bot_type + providers = get_custom_providers() + except Exception as e: # pragma: no cover - defensive + logger.warning(f"[ModelsHandler] failed to load custom_providers: {e}") + providers = [] + if not providers: + return [] + + # Determine the currently active provider id from bot_type. + bot_type = local_config.get("bot_type") or "" + _, active_id = parse_custom_bot_type(bot_type) + + meta = ConfigHandler.PROVIDER_MODELS.get("custom") or {} + cards = [] + for p in providers: + pid = p.get("id") or "" + name = p.get("name") or pid + raw_key = p.get("api_key") or "" + raw_base = p.get("api_base") or "" + configured = cls._is_real_key(raw_key) + cards.append({ + "id": f"custom:{pid}", + "label": {"zh": name, "en": name}, + "configured": configured, + "is_custom": True, + "custom_id": pid, + "custom_name": name, + "active": (pid == active_id), + "model": p.get("model") or "", + # Custom cards are edited via the dedicated set_custom_provider + # action, not the field-based set_provider flow, so the field + # names are intentionally null. + "api_key_field": None, + "api_base_field": None, + "api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "", + "api_base": raw_base, + "api_base_default": "", + "api_base_placeholder": meta.get("api_base_placeholder") or "", + "models": [p.get("model")] if p.get("model") else [], + }) + return cards + @classmethod def _provider_overview(cls) -> List[dict]: """All known providers (configured first, unconfigured after). - Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list.""" + Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list. + + When the user has defined multiple custom (OpenAI-compatible) + providers via ``custom_providers``, the single built-in ``custom`` + card is replaced by one card per provider (see + ``_custom_provider_cards``). Otherwise the legacy single ``custom`` + card is shown unchanged. + """ local_config = conf() + custom_cards = cls._custom_provider_cards(local_config) items = [] for pid, p in ConfigHandler.PROVIDER_MODELS.items(): + if pid == "custom" and custom_cards: + # Multi-provider mode: emit the expanded cards instead of the + # single legacy custom card. + items.extend(custom_cards) + continue key_field = p.get("api_key_field") base_field = p.get("api_base_key") raw_key = local_config.get(key_field, "") if key_field else "" @@ -2147,6 +2218,7 @@ class ModelsHandler: "id": pid, "label": p["label"], "configured": configured, + "is_custom": (pid == "custom"), "api_key_field": key_field, "api_base_field": base_field, "api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "", @@ -2155,7 +2227,19 @@ class ModelsHandler: "api_base_placeholder": p.get("api_base_placeholder") or "", "models": list(p.get("models") or []), }) - items.sort(key=lambda it: (0 if it["configured"] else 1, list(ConfigHandler.PROVIDER_MODELS.keys()).index(it["id"]))) + + def _sort_key(it): + pid = it["id"] + # Custom expanded cards share the sort weight of the base "custom" + # entry so they cluster where the single custom card used to be. + base_id = "custom" if it.get("is_custom") else pid + try: + order = list(ConfigHandler.PROVIDER_MODELS.keys()).index(base_id) + except ValueError: + order = len(ConfigHandler.PROVIDER_MODELS) + return (0 if it["configured"] else 1, order) + + items.sort(key=_sort_key) return items @classmethod @@ -2163,13 +2247,24 @@ class ModelsHandler: """Main chat model — drives the agent. bot_type maps to a provider id.""" bot_type = local_config.get("bot_type") or "" provider_id = "openai" if bot_type == "chatGPT" else bot_type - if provider_id not in ConfigHandler.PROVIDER_MODELS and local_config.get("use_linkai"): + is_custom_id = provider_id.startswith("custom:") + if (provider_id not in ConfigHandler.PROVIDER_MODELS and not is_custom_id + and local_config.get("use_linkai")): provider_id = "linkai" + # In multi-provider mode, replace the single "custom" entry with the + # expanded "custom:" ids so the chat dropdown matches the cards. + provider_ids = [] + custom_cards = cls._custom_provider_cards(local_config) + for pid in ConfigHandler.PROVIDER_MODELS.keys(): + if pid == "custom" and custom_cards: + provider_ids.extend(c["id"] for c in custom_cards) + else: + provider_ids.append(pid) return { "editable": True, "current_provider": provider_id, "current_model": local_config.get("model", ""), - "providers": list(ConfigHandler.PROVIDER_MODELS.keys()), + "providers": provider_ids, "use_linkai": bool(local_config.get("use_linkai", False)), } @@ -2603,6 +2698,12 @@ class ModelsHandler: return self._handle_set_provider(data) if action == "delete_provider": return self._handle_delete_provider(data) + if action == "set_custom_provider": + return self._handle_set_custom_provider(data) + if action == "delete_custom_provider": + return self._handle_delete_custom_provider(data) + if action == "set_active_custom_provider": + return self._handle_set_active_custom_provider(data) if action == "set_capability": return self._handle_set_capability(data) if action == "set_voice_reply_mode": @@ -2686,6 +2787,167 @@ class ModelsHandler: self._reset_bridge() return json.dumps({"status": "success", "provider": provider_id, "cleared": cleared}) + # ------------------------------------------------------------------ + # Multiple custom (OpenAI-compatible) providers + # ------------------------------------------------------------------ + # These actions manage the ``custom_providers`` list. Activation is done + # by setting ``bot_type`` to ``"custom:"``. There is no separate + # ``custom_active_provider`` field — a single source of truth. + + @staticmethod + def _normalize_custom_providers(raw) -> List[dict]: + """Return a clean list of provider dicts (drops malformed entries).""" + if not isinstance(raw, list): + return [] + out = [] + for p in raw: + if isinstance(p, dict) and (p.get("id") or "").strip(): + out.append(p) + return out + + def _persist_custom_providers(self, providers: List[dict], bot_type=None) -> None: + """Write the providers list to both in-memory conf and the on-disk + config, then reset the bridge so bots rebuild. + + If ``bot_type`` is given, also update ``bot_type``. When activating a + provider (bot_type is ``custom:``), also write the provider's + ``model`` into the global ``model`` field so that all paths (chat, + agent, vision) automatically use the correct model.""" + from models.custom_provider import parse_custom_bot_type + + local_config = conf() + file_cfg = self._read_file_config() + local_config["custom_providers"] = providers + file_cfg["custom_providers"] = providers + if bot_type is not None: + local_config["bot_type"] = bot_type + file_cfg["bot_type"] = bot_type + # Sync the provider's model into the global model field. + _, pid = parse_custom_bot_type(bot_type) + if pid: + provider = next((p for p in providers if p.get("id") == pid), None) + if provider and provider.get("model"): + local_config["model"] = provider["model"] + file_cfg["model"] = provider["model"] + self._write_file_config(file_cfg) + self._reset_bridge() + + def _handle_set_custom_provider(self, data: dict) -> str: + """Add a new custom provider or update an existing one. + + Payload:: + + { + "action": "set_custom_provider", + "id": "3f2a9c1b", # required for edit; omit for create + "name": "siliconflow", # required, display label + "api_base": "https://...", # required when creating + "api_key": "sk-...", # optional on edit (keep existing) + "model": "deepseek-ai/...", # optional default model + "make_active": true # optional, also activate it + } + """ + from models.custom_provider import generate_provider_id, parse_custom_bot_type + + name = (data.get("name") or "").strip() + if not name: + return json.dumps({"status": "error", "message": "name is required"}) + + provider_id = (data.get("id") or "").strip() + api_base = (data.get("api_base") or "").strip() + # api_key omitted/empty on edit => keep the existing one. + api_key_raw = data.get("api_key") + api_key = api_key_raw.strip() if isinstance(api_key_raw, str) else "" + model = (data.get("model") or "").strip() + make_active = bool(data.get("make_active")) + + local_config = conf() + providers = self._normalize_custom_providers(local_config.get("custom_providers")) + + existing = next((p for p in providers if p.get("id") == provider_id), None) if provider_id else None + if existing is None: + # Creating a new provider — api_base is mandatory. + if not api_base: + return json.dumps({"status": "error", "message": "api_base is required"}) + provider_id = generate_provider_id() + entry = {"id": provider_id, "name": name, "api_key": api_key, "api_base": api_base} + if model: + entry["model"] = model + providers.append(entry) + created = True + else: + existing["name"] = name + if api_base: + existing["api_base"] = api_base + if api_key: + existing["api_key"] = api_key + # model is always overwritten (empty clears the default model). + if model: + existing["model"] = model + else: + existing.pop("model", None) + created = False + + # Decide bot_type — only switch when explicitly requested. + new_bot_type = None + if make_active: + new_bot_type = f"custom:{provider_id}" + + self._persist_custom_providers(providers, new_bot_type) + logger.info( + f"[ModelsHandler] custom provider {name!r} (id={provider_id}) " + f"{'created' if created else 'updated'}" + ) + return json.dumps({ + "status": "success", + "id": provider_id, + "name": name, + "created": created, + }) + + def _handle_delete_custom_provider(self, data: dict) -> str: + """Remove a custom provider by id.""" + from models.custom_provider import parse_custom_bot_type + + provider_id = (data.get("id") or "").strip() + if not provider_id: + return json.dumps({"status": "error", "message": "id is required"}) + + local_config = conf() + providers = self._normalize_custom_providers(local_config.get("custom_providers")) + remaining = [p for p in providers if p.get("id") != provider_id] + if len(remaining) == len(providers): + return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"}) + + # If the deleted provider was active, fall back to the first remaining. + _, current_active_id = parse_custom_bot_type(local_config.get("bot_type") or "") + new_bot_type = None + if current_active_id == provider_id: + if remaining: + new_bot_type = f"custom:{remaining[0]['id']}" + else: + new_bot_type = "custom" # revert to legacy + + self._persist_custom_providers(remaining, new_bot_type) + logger.info(f"[ModelsHandler] custom provider id={provider_id} deleted") + return json.dumps({"status": "success", "id": provider_id}) + + def _handle_set_active_custom_provider(self, data: dict) -> str: + """Activate a custom provider by setting bot_type to 'custom:'.""" + provider_id = (data.get("id") or "").strip() + if not provider_id: + return json.dumps({"status": "error", "message": "id is required"}) + + local_config = conf() + providers = self._normalize_custom_providers(local_config.get("custom_providers")) + if not any(p.get("id") == provider_id for p in providers): + return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"}) + + new_bot_type = f"custom:{provider_id}" + self._persist_custom_providers(providers, new_bot_type) + logger.info(f"[ModelsHandler] active custom provider set to id={provider_id}") + return json.dumps({"status": "success", "active_id": provider_id}) + def _handle_set_capability(self, data: dict) -> str: capability = (data.get("capability") or "").strip() provider_id = (data.get("provider_id") or "").strip() @@ -2750,13 +3012,28 @@ class ModelsHandler: }) def _set_chat(self, provider_id: str, model: str) -> str: - if provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS: + # Accept expanded custom provider ids ("custom:") 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 diff --git a/config.py b/config.py index f711aad2..6d81f5ff 100644 --- a/config.py +++ b/config.py @@ -24,8 +24,11 @@ available_setting = { "open_ai_api_base": "https://api.openai.com/v1", "claude_api_base": "https://api.anthropic.com/v1", # claude api base "gemini_api_base": "https://generativelanguage.googleapis.com", # gemini api base - "custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom") - "custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom") + "custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom"); legacy single-provider field + "custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom"); legacy single-provider field + # Multiple custom (OpenAI-compatible) providers. Activated via bot_type: "custom:". + # Each item: {"id": "3f2a9c1b", "name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"} + "custom_providers": [], "proxy": "", # proxy used by openai # chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name "model": "gpt-3.5-turbo", # options: gpt-4o, gpt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini, etc. See common/const.py for the full list @@ -321,24 +324,37 @@ class Config(dict): config = Config() +def _mask_value(val): + """Mask a sensitive string value, keeping first 3 and last 3 chars.""" + if not isinstance(val, str) or len(val) <= 8: + return val + return val[0:3] + "*" * 5 + val[-3:] + + +def _mask_sensitive_recursive(obj): + """Recursively mask values whose keys contain 'key' or 'secret'.""" + if isinstance(obj, dict): + masked = {} + for k, v in obj.items(): + if ("key" in k or "secret" in k) and isinstance(v, str): + masked[k] = _mask_value(v) + else: + masked[k] = _mask_sensitive_recursive(v) + return masked + elif isinstance(obj, list): + return [_mask_sensitive_recursive(item) for item in obj] + return obj + + def drag_sensitive(config): try: if isinstance(config, str): conf_dict: dict = json.loads(config) - conf_dict_copy = copy.deepcopy(conf_dict) - for key in conf_dict_copy: - if "key" in key or "secret" in key: - if isinstance(conf_dict_copy[key], str): - conf_dict_copy[key] = conf_dict_copy[key][0:3] + "*" * 5 + conf_dict_copy[key][-3:] + conf_dict_copy = _mask_sensitive_recursive(conf_dict) return json.dumps(conf_dict_copy, indent=4) elif isinstance(config, dict): - config_copy = copy.deepcopy(config) - for key in config: - if "key" in key or "secret" in key: - if isinstance(config_copy[key], str): - config_copy[key] = config_copy[key][0:3] + "*" * 5 + config_copy[key][-3:] - return config_copy + return _mask_sensitive_recursive(config) except Exception as e: logger.exception(e) return config diff --git a/docs/ja/models/custom.mdx b/docs/ja/models/custom.mdx index c2a3cfa9..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,18 +45,60 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ } ``` -一般的なローカルデプロイツールとデフォルトアドレス: +Common local deployment tools and their default endpoints: -| ツール | デフォルト API Base | +| Tool | Default API Base | | --- | --- | | [Ollama](https://ollama.com) | `http://localhost:11434/v1` | | [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` | | [LocalAI](https://localai.io) | `http://localhost:8080/v1` | -### モデル切り替え +### Switching Models -カスタムベンダーでモデルを切り替える際は `model` のみが変更され、`bot_type` と API アドレスは変わりません: +Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged: ``` /config model qwen3.5:27b ``` + +## Configuring Multiple Custom Providers + +If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:"`: + +```json +{ + "bot_type": "custom:3f2a9c1b", + "custom_providers": [ + { + "id": "3f2a9c1b", + "name": "siliconflow", + "api_key": "YOUR_SILICONFLOW_KEY", + "api_base": "https://api.siliconflow.cn/v1", + "model": "deepseek-ai/DeepSeek-V3" + }, + { + "id": "a1b2c3d4", + "name": "qiniu", + "api_key": "YOUR_QINIU_KEY", + "api_base": "https://api.qnaigc.com/v1", + "model": "deepseek-v3" + } + ] +} +``` + +| Parameter | Description | +| --- | --- | +| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` | +| `bot_type` | Set to `"custom:"` 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 | + + + 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 45a7d2e1..b63f4e34 100644 --- a/docs/models/custom.mdx +++ b/docs/models/custom.mdx @@ -60,3 +60,45 @@ Switching models under a custom vendor only changes `model` — `bot_type` and t ``` /config model qwen3.5:27b ``` + +## Configuring Multiple Custom Providers + +If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:"`: + +```json +{ + "bot_type": "custom:3f2a9c1b", + "custom_providers": [ + { + "id": "3f2a9c1b", + "name": "siliconflow", + "api_key": "YOUR_SILICONFLOW_KEY", + "api_base": "https://api.siliconflow.cn/v1", + "model": "deepseek-ai/DeepSeek-V3" + }, + { + "id": "a1b2c3d4", + "name": "qiniu", + "api_key": "YOUR_QINIU_KEY", + "api_base": "https://api.qnaigc.com/v1", + "model": "deepseek-v3" + } + ] +} +``` + +| Parameter | Description | +| --- | --- | +| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` | +| `bot_type` | Set to `"custom:"` 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 | + + + 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 2673a8de..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,18 +45,60 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模 } ``` -常见的本地部署工具及默认地址: +Common local deployment tools and their default endpoints: -| 工具 | 默认 API Base | +| Tool | Default API Base | | --- | --- | | [Ollama](https://ollama.com) | `http://localhost:11434/v1` | | [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` | | [LocalAI](https://localai.io) | `http://localhost:8080/v1` | -### 切换模型 +### Switching Models -自定义厂商下切换模型时,只会修改 `model`,不会改变 `bot_type` 和 API 地址: +Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged: ``` /config model qwen3.5:27b ``` + +## Configuring Multiple Custom Providers + +If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:"`: + +```json +{ + "bot_type": "custom:3f2a9c1b", + "custom_providers": [ + { + "id": "3f2a9c1b", + "name": "siliconflow", + "api_key": "YOUR_SILICONFLOW_KEY", + "api_base": "https://api.siliconflow.cn/v1", + "model": "deepseek-ai/DeepSeek-V3" + }, + { + "id": "a1b2c3d4", + "name": "qiniu", + "api_key": "YOUR_QINIU_KEY", + "api_base": "https://api.qnaigc.com/v1", + "model": "deepseek-v3" + } + ] +} +``` + +| Parameter | Description | +| --- | --- | +| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` | +| `bot_type` | Set to `"custom:"` 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 | + + + 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 999986bc..1bc2a9da 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, parse_custom_bot_type from models.chatgpt.chat_gpt_session import ChatGPTSession from models.openai.open_ai_image import OpenAIImage from models.session_manager import SessionManager @@ -32,9 +33,12 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): def __init__(self): super().__init__() # Resolve api key / base from config (no global SDK state anymore). - if conf().get("bot_type") == "custom": - self._api_key = conf().get("custom_api_key", "") - self._api_base = conf().get("custom_api_base") or None + is_custom, _ = parse_custom_bot_type(conf().get("bot_type", "")) + custom_model = None + if is_custom: + # Supports multiple custom providers via bot_type "custom:" + # with automatic fallback to the legacy custom_api_key/base fields. + self._api_key, self._api_base, custom_model = resolve_custom_credentials() else: self._api_key = conf().get("open_ai_api_key") self._api_base = conf().get("open_ai_api_base") or None @@ -46,8 +50,9 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): ) if conf().get("rate_limit_chatgpt"): self.tb4chatgpt = TokenBucket(conf().get("rate_limit_chatgpt", 20)) - conf_model = conf().get("model") or "gpt-3.5-turbo" - self.sessions = SessionManager(ChatGPTSession, model=conf().get("model") or "gpt-3.5-turbo") + # Per-provider model takes precedence over global model. + conf_model = custom_model or conf().get("model") or "gpt-3.5-turbo" + self.sessions = SessionManager(ChatGPTSession, model=conf_model) # o1相关模型不支持system prompt,暂时用文心模型的session self.args = { @@ -70,11 +75,20 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): def get_api_config(self): """Get API configuration for OpenAI-compatible base class""" - is_custom = conf().get("bot_type") == "custom" + is_custom, _ = parse_custom_bot_type(conf().get("bot_type", "")) + if is_custom: + custom_key, custom_base, custom_model = resolve_custom_credentials() + api_key = custom_key + api_base = custom_base + model = custom_model or conf().get("model", "gpt-3.5-turbo") + else: + api_key = conf().get("open_ai_api_key") + api_base = conf().get("open_ai_api_base") + model = conf().get("model", "gpt-3.5-turbo") return { - 'api_key': conf().get("custom_api_key") if is_custom else conf().get("open_ai_api_key"), - 'api_base': conf().get("custom_api_base") if is_custom else conf().get("open_ai_api_base"), - 'model': conf().get("model", "gpt-3.5-turbo"), + 'api_key': api_key, + 'api_base': api_base, + 'model': model, 'default_temperature': conf().get("temperature", 0.9), 'default_top_p': conf().get("top_p", 1.0), 'default_frequency_penalty': conf().get("frequency_penalty", 0.0), @@ -185,10 +199,16 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): mime_type = mime_type_map.get(extension, "image/jpeg") # Get model and API config - is_custom = conf().get("bot_type") == "custom" - model = context.get("gpt_model") or conf().get("model", "gpt-4o") - api_key = context.get("openai_api_key") or (conf().get("custom_api_key") if is_custom else conf().get("open_ai_api_key")) - api_base = conf().get("custom_api_base") if is_custom else conf().get("open_ai_api_base") + is_custom, _ = parse_custom_bot_type(conf().get("bot_type", "")) + if is_custom: + custom_key, custom_base, custom_model = resolve_custom_credentials() + model = context.get("gpt_model") or custom_model or conf().get("model", "gpt-4o") + api_key = context.get("openai_api_key") or custom_key + api_base = custom_base + else: + model = context.get("gpt_model") or conf().get("model", "gpt-4o") + api_key = context.get("openai_api_key") or conf().get("open_ai_api_key") + api_base = conf().get("open_ai_api_base") # Build vision request messages = [ diff --git a/models/custom_provider.py b/models/custom_provider.py new file mode 100644 index 00000000..f6db2d34 --- /dev/null +++ b/models/custom_provider.py @@ -0,0 +1,125 @@ +# encoding:utf-8 + +""" +Centralized resolver for custom (OpenAI-compatible) provider credentials. + +CowAgent historically supported only a *single* custom provider via the flat +config keys ``custom_api_key`` / ``custom_api_base``. This module adds support +for *multiple* custom providers (see issue #2838) while remaining 100% +backward compatible. + +Config model +------------ +- ``custom_providers``: list of dicts, each describing one custom provider:: + + { + "id": "3f2a9c1b", # server-generated short uuid (primary key) + "name": "siliconflow", # user-facing display label (not a key) + "api_key": "sk-...", # required + "api_base": "https://...", # required, must be OpenAI-compatible + "model": "deepseek-ai/DeepSeek-V3" # optional default model + } + +Routing +------- +- ``bot_type: "custom"`` (legacy): reads the flat ``custom_api_key`` / ``custom_api_base``. +- ``bot_type: "custom:"`` (multi-provider): looks up the provider by id in + ``custom_providers``. There is a single source of truth — no separate + ``custom_active_provider`` field. + +Backward-compatibility contract +------------------------------- +When ``bot_type`` is exactly ``"custom"`` (no colon suffix), behaviour is +unchanged: we return ``custom_api_key`` / ``custom_api_base`` values. +""" + +import uuid +from config import conf +from common.log import logger + + +def generate_provider_id() -> str: + """Generate a short random id for a new custom provider.""" + return uuid.uuid4().hex[:8] + + +def get_custom_providers(): + """Return the list of configured custom providers (always a list).""" + providers = conf().get("custom_providers") + if not isinstance(providers, list): + return [] + # Keep only well-formed entries with an id. + return [p for p in providers if isinstance(p, dict) and p.get("id")] + + +def _find_provider_by_id(providers, provider_id): + """Look up a provider by its id, or None if not found.""" + if not providers or not provider_id: + return None + for p in providers: + if p.get("id") == provider_id: + return p + return None + + +def parse_custom_bot_type(bot_type): + """Parse bot_type to extract custom provider id. + + Returns: + (is_custom, provider_id) where: + - is_custom: True if bot_type starts with "custom" + - provider_id: the id suffix (e.g. "3f2a9c1b") or empty string for legacy mode + """ + if not bot_type or not isinstance(bot_type, str): + return False, "" + if bot_type == "custom": + return True, "" + if bot_type.startswith("custom:"): + return True, bot_type[7:] # len("custom:") == 7 + return False, "" + + +def resolve_custom_credentials(): + """Resolve the effective (api_key, api_base, model) for custom mode. + + Resolution order: + 1. If ``bot_type`` is ``"custom:"``, look up that id in + ``custom_providers``. + 2. If ``bot_type`` is exactly ``"custom"`` (legacy), return the flat + ``custom_api_key`` / ``custom_api_base``. + + :return: tuple ``(api_key, api_base, model)``. ``api_base`` and ``model`` + may be ``None`` / empty when not configured. + """ + bot_type = conf().get("bot_type", "") + is_custom, provider_id = parse_custom_bot_type(bot_type) + + if not is_custom: + # Not custom at all — should not happen but be defensive. + return ( + conf().get("open_ai_api_key", ""), + conf().get("open_ai_api_base") or None, + None, + ) + + if provider_id: + # Multi-provider mode: look up by id. + providers = get_custom_providers() + provider = _find_provider_by_id(providers, provider_id) + if provider is not None: + return ( + provider.get("api_key", ""), + provider.get("api_base") or None, + provider.get("model") or None, + ) + logger.warning( + "[CUSTOM] provider id '%s' not found in custom_providers, " + "falling back to legacy fields", provider_id + ) + + # Legacy single-provider fallback — unchanged behavior. + return ( + conf().get("custom_api_key", ""), + conf().get("custom_api_base") or None, + None, + ) diff --git a/tests/test_custom_provider.py b/tests/test_custom_provider.py new file mode 100644 index 00000000..fcd5dceb --- /dev/null +++ b/tests/test_custom_provider.py @@ -0,0 +1,240 @@ +# encoding:utf-8 +""" +Unit tests for multiple custom (OpenAI-compatible) provider support (issue #2838). + +Covers models/custom_provider.py: + - Backward compatibility: legacy custom_api_key / custom_api_base fallback + - Multi-provider selection via bot_type "custom:" routing + - parse_custom_bot_type helper + - Robustness against malformed config (missing id, non-dict, non-list) +""" +import sys +import os +import unittest + +# Add project root to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import config as config_module +from config import Config + + +def set_conf(d): + """Install a fresh Config as the global config used by conf().""" + config_module.config = Config(d) + + +class TestParseCustomBotType(unittest.TestCase): + """parse_custom_bot_type() parsing logic.""" + + def setUp(self): + from models.custom_provider import parse_custom_bot_type + self.parse = parse_custom_bot_type + + def test_legacy_custom(self): + is_custom, pid = self.parse("custom") + self.assertTrue(is_custom) + self.assertEqual(pid, "") + + def test_custom_with_id(self): + is_custom, pid = self.parse("custom:3f2a9c1b") + self.assertTrue(is_custom) + self.assertEqual(pid, "3f2a9c1b") + + def test_non_custom(self): + is_custom, pid = self.parse("openai") + self.assertFalse(is_custom) + self.assertEqual(pid, "") + + def test_empty(self): + is_custom, pid = self.parse("") + self.assertFalse(is_custom) + self.assertEqual(pid, "") + + def test_none(self): + is_custom, pid = self.parse(None) + self.assertFalse(is_custom) + self.assertEqual(pid, "") + + +class TestResolveCustomCredentials(unittest.TestCase): + """resolve_custom_credentials() resolution order and fallbacks.""" + + def setUp(self): + from models.custom_provider import resolve_custom_credentials, get_custom_providers + self.resolve = resolve_custom_credentials + self.get_providers = get_custom_providers + + # --- Backward compatibility --- + + def test_legacy_fallback_when_no_providers(self): + set_conf({ + "bot_type": "custom", + "custom_api_key": "legacy-key", + "custom_api_base": "https://legacy.example.com/v1", + }) + self.assertEqual( + self.resolve(), + ("legacy-key", "https://legacy.example.com/v1", None), + ) + + def test_empty_config(self): + set_conf({"bot_type": "custom"}) + self.assertEqual(self.resolve(), ("", None, None)) + + # --- Multi-provider selection via bot_type --- + + def test_provider_selected_by_id(self): + set_conf({ + "bot_type": "custom:abc12345", + "custom_providers": [ + {"id": "sf001", "name": "siliconflow", "api_key": "sf-key", + "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"}, + {"id": "abc12345", "name": "qiniu", "api_key": "qn-key", + "api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"}, + ], + }) + self.assertEqual( + self.resolve(), + ("qn-key", "https://api.qnaigc.com/v1", "deepseek-v3"), + ) + + def test_id_not_found_falls_back_to_legacy(self): + set_conf({ + "bot_type": "custom:ghost", + "custom_api_key": "legacy-key", + "custom_api_base": "https://legacy.example.com/v1", + "custom_providers": [ + {"id": "sf001", "name": "siliconflow", "api_key": "sf-key", + "api_base": "https://api.siliconflow.cn/v1"}, + ], + }) + self.assertEqual( + self.resolve(), + ("legacy-key", "https://legacy.example.com/v1", None), + ) + + def test_provider_without_model_returns_none_model(self): + set_conf({ + "bot_type": "custom:local01", + "custom_providers": [ + {"id": "local01", "name": "local", "api_key": "", "api_base": "http://localhost:11434/v1"}, + ], + }) + self.assertEqual( + self.resolve(), + ("", "http://localhost:11434/v1", None), + ) + + # --- Robustness against malformed config --- + + def test_malformed_entries_filtered_and_fallback(self): + set_conf({ + "bot_type": "custom:nope", + "custom_api_key": "legacy-key", + "custom_api_base": "https://legacy.example.com/v1", + "custom_providers": [ + {"name": "no-id", "api_key": "no-id-key"}, # invalid: no id + "not-a-dict", # invalid: wrong type + ], + }) + # All entries invalid -> treated as empty -> legacy fallback + self.assertEqual( + self.resolve(), + ("legacy-key", "https://legacy.example.com/v1", None), + ) + + def test_get_custom_providers_filters_invalid(self): + set_conf({ + "bot_type": "custom", + "custom_providers": [ + {"id": "ok1", "name": "ok", "api_key": "k", "api_base": "https://x/v1"}, + {"name": "no-id", "api_key": "no-id"}, # dropped: no id + 123, # dropped + ], + }) + providers = self.get_providers() + self.assertEqual(len(providers), 1) + self.assertEqual(providers[0]["id"], "ok1") + + def test_custom_providers_not_a_list_falls_back(self): + set_conf({ + "bot_type": "custom", + "custom_api_key": "legacy-key", + "custom_api_base": "https://legacy.example.com/v1", + "custom_providers": "oops-a-string", + }) + self.assertEqual( + self.resolve(), + ("legacy-key", "https://legacy.example.com/v1", None), + ) + + +class TestGenerateProviderId(unittest.TestCase): + """generate_provider_id() produces valid short ids.""" + + def test_length_and_hex(self): + from models.custom_provider import generate_provider_id + pid = generate_provider_id() + self.assertEqual(len(pid), 8) + # Must be valid hex characters + int(pid, 16) + + def test_uniqueness(self): + from models.custom_provider import generate_provider_id + ids = {generate_provider_id() for _ in range(100)} + self.assertEqual(len(ids), 100) + + +class TestConfigDefaults(unittest.TestCase): + """The new config fields must exist with safe defaults.""" + + def test_default_config_has_custom_providers(self): + from config import available_setting + self.assertIn("custom_providers", available_setting) + self.assertEqual(available_setting["custom_providers"], []) + + def test_default_config_no_custom_active_provider(self): + """custom_active_provider was removed — replaced by bot_type routing.""" + from config import available_setting + self.assertNotIn("custom_active_provider", available_setting) + + +class TestDragSensitiveNested(unittest.TestCase): + """drag_sensitive() must mask api_key in nested structures.""" + + def test_nested_api_key_masked(self): + from config import drag_sensitive + import json + test_config = { + "open_ai_api_key": "sk-1234567890abcdef", + "custom_providers": [ + {"id": "x1", "name": "test", "api_key": "sk-nested-secret-key-long", "api_base": "https://x/v1"} + ], + } + result = drag_sensitive(test_config) + # Top-level key should be masked + self.assertNotIn("1234567890abcdef", str(result)) + # Nested key should also be masked + self.assertNotIn("nested-secret-key-long", str(result)) + # But the id/name/api_base should not be masked + self.assertIn("x1", str(result)) + self.assertIn("test", str(result)) + self.assertIn("https://x/v1", str(result)) + + def test_string_config_masked(self): + from config import drag_sensitive + import json + test_str = json.dumps({ + "open_ai_api_key": "sk-1234567890abcdef", + "custom_providers": [ + {"id": "x1", "api_key": "sk-nested-very-long-secret"} + ], + }) + result = drag_sensitive(test_str) + self.assertNotIn("1234567890abcdef", result) + self.assertNotIn("nested-very-long-secret", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_custom_provider_handlers.py b/tests/test_custom_provider_handlers.py new file mode 100644 index 00000000..9a47b564 --- /dev/null +++ b/tests/test_custom_provider_handlers.py @@ -0,0 +1,289 @@ +# encoding:utf-8 +""" +Unit tests for the multi custom-provider management API (issue #2838, web UI). + +Covers channel/web/web_channel.py::ModelsHandler: + - _custom_provider_cards / _provider_overview expansion + - _handle_set_custom_provider (create / edit / activate) + - _handle_delete_custom_provider + - _handle_set_active_custom_provider + +Uses id-based routing (bot_type: "custom:") — no custom_active_provider. +""" +import json +import os +import sys +import types +import unittest + +# Add project root to path. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# Stub the web.py framework so web_channel imports without the dependency. +if "web" not in sys.modules: + _web_stub = types.ModuleType("web") + _web_stub.header = lambda *a, **k: None + _web_stub.data = lambda: b"{}" + _web_stub.ctx = types.SimpleNamespace() + sys.modules["web"] = _web_stub + +import config as config_module +from config import Config +from channel.web.web_channel import ModelsHandler + + +def set_conf(d): + """Install a fresh Config as the global config used by conf().""" + config_module.config = Config(d) + + +class _HandlerHarness: + """Test double around ModelsHandler that captures persisted config in + memory instead of touching config.json, and no-ops the Bridge reset.""" + + def __init__(self): + self.handler = ModelsHandler.__new__(ModelsHandler) + self._file_cfg = {} + self.bridge_resets = 0 + # Patch the disk + bridge boundary on this instance. + self.handler._read_file_config = lambda: dict(self._file_cfg) + self.handler._write_file_config = self._capture_write + self.handler._reset_bridge = self._capture_reset + + def _capture_write(self, data): + self._file_cfg = dict(data) + + def _capture_reset(self): + self.bridge_resets += 1 + + def call(self, **payload): + # Resolve the bound method by action for convenience. + action = payload.get("action") + method = { + "set_custom_provider": self.handler._handle_set_custom_provider, + "delete_custom_provider": self.handler._handle_delete_custom_provider, + "set_active_custom_provider": self.handler._handle_set_active_custom_provider, + }[action] + return json.loads(method(payload)) + + +class TestSetCustomProvider(unittest.TestCase): + def setUp(self): + set_conf({"bot_type": "custom", "custom_providers": []}) + self.h = _HandlerHarness() + + def test_create_provider_does_not_hijack_bot_type(self): + """Creating a provider without make_active must not change bot_type.""" + res = self.h.call(action="set_custom_provider", name="siliconflow", + api_base="https://api.siliconflow.cn/v1", api_key="sf-key") + self.assertEqual(res["status"], "success") + self.assertTrue(res["created"]) + self.assertIn("id", res) + # bot_type must remain unchanged — no auto-activation. + bot_type = config_module.conf().get("bot_type") + self.assertEqual(bot_type, "custom") # unchanged from setUp + providers = config_module.conf().get("custom_providers") + self.assertEqual(len(providers), 1) + self.assertEqual(providers[0]["id"], res["id"]) + self.assertEqual(providers[0]["name"], "siliconflow") + self.assertEqual(self.h.bridge_resets, 1) + + def test_create_with_make_active_switches_bot_type(self): + """Creating a provider with make_active=true must switch bot_type.""" + res = self.h.call(action="set_custom_provider", name="siliconflow", + api_base="https://api.siliconflow.cn/v1", api_key="sf-key", + make_active=True) + self.assertEqual(res["status"], "success") + bot_type = config_module.conf().get("bot_type") + self.assertEqual(bot_type, f"custom:{res['id']}") + + def test_create_requires_api_base(self): + res = self.h.call(action="set_custom_provider", name="x", api_key="k") + self.assertEqual(res["status"], "error") + self.assertIn("api_base", res["message"]) + + def test_create_requires_name(self): + res = self.h.call(action="set_custom_provider", name="", api_base="https://x/v1") + self.assertEqual(res["status"], "error") + + def test_second_provider_does_not_steal_active(self): + # Explicitly activate the first provider. + res1 = self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak", make_active=True) + res2 = self.h.call(action="set_custom_provider", name="b", + api_base="https://b/v1", api_key="bk") + self.assertTrue(res2["created"]) + # First provider stays active — second creation doesn't steal it. + bot_type = config_module.conf().get("bot_type") + self.assertEqual(bot_type, f"custom:{res1['id']}") + + def test_make_active_flag(self): + self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak") + res2 = self.h.call(action="set_custom_provider", name="b", + api_base="https://b/v1", api_key="bk", make_active=True) + bot_type = config_module.conf().get("bot_type") + self.assertEqual(bot_type, f"custom:{res2['id']}") + + def test_edit_keeps_key_when_omitted(self): + res = self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="secret") + pid = res["id"] + # Edit only the base; omit api_key. + res2 = self.h.call(action="set_custom_provider", name="a", + id=pid, api_base="https://a2/v1") + self.assertEqual(res2["status"], "success") + self.assertFalse(res2["created"]) + providers = config_module.conf().get("custom_providers") + self.assertEqual(providers[0]["api_base"], "https://a2/v1") + self.assertEqual(providers[0]["api_key"], "secret") # preserved + + def test_edit_can_rename(self): + res = self.h.call(action="set_custom_provider", name="old", + api_base="https://a/v1", api_key="ak") + pid = res["id"] + res2 = self.h.call(action="set_custom_provider", name="new", + id=pid, api_base="https://a/v1") + self.assertEqual(res2["status"], "success") + providers = config_module.conf().get("custom_providers") + self.assertEqual(providers[0]["name"], "new") + # ID stays the same + self.assertEqual(providers[0]["id"], pid) + + def test_edit_clears_model_when_empty(self): + res = self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak", model="m1") + pid = res["id"] + self.assertEqual(config_module.conf().get("custom_providers")[0]["model"], "m1") + self.h.call(action="set_custom_provider", name="a", id=pid, + api_base="https://a/v1", model="") + self.assertNotIn("model", config_module.conf().get("custom_providers")[0]) + + +class TestDeleteCustomProvider(unittest.TestCase): + def setUp(self): + set_conf({"bot_type": "custom", "custom_providers": []}) + self.h = _HandlerHarness() + self.res_a = self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak", + make_active=True) + self.res_b = self.h.call(action="set_custom_provider", name="b", + api_base="https://b/v1", api_key="bk") + + def test_delete_unknown(self): + res = self.h.call(action="delete_custom_provider", id="ghost") + self.assertEqual(res["status"], "error") + + def test_delete_non_active(self): + res = self.h.call(action="delete_custom_provider", id=self.res_b["id"]) + self.assertEqual(res["status"], "success") + ids = [p["id"] for p in config_module.conf().get("custom_providers")] + self.assertEqual(ids, [self.res_a["id"]]) + # bot_type unchanged (still pointing to a) + self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}") + + def test_delete_active_falls_back_to_first_remaining(self): + # 'a' is active (created first); deleting it should re-point to 'b'. + self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}") + res = self.h.call(action="delete_custom_provider", id=self.res_a["id"]) + self.assertEqual(res["status"], "success") + self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_b['id']}") + + def test_delete_last_reverts_to_legacy(self): + self.h.call(action="delete_custom_provider", id=self.res_a["id"]) + self.h.call(action="delete_custom_provider", id=self.res_b["id"]) + self.assertEqual(config_module.conf().get("custom_providers"), []) + # When all providers deleted, reverts to legacy "custom" + self.assertEqual(config_module.conf().get("bot_type"), "custom") + + +class TestSetActiveCustomProvider(unittest.TestCase): + def setUp(self): + set_conf({"bot_type": "custom", "custom_providers": []}) + self.h = _HandlerHarness() + self.res_a = self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak", + make_active=True) + self.res_b = self.h.call(action="set_custom_provider", name="b", + api_base="https://b/v1", api_key="bk") + + def test_set_active_valid(self): + res = self.h.call(action="set_active_custom_provider", id=self.res_b["id"]) + self.assertEqual(res["status"], "success") + self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_b['id']}") + + def test_set_active_unknown(self): + res = self.h.call(action="set_active_custom_provider", id="ghost") + self.assertEqual(res["status"], "error") + # bot_type unchanged + self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}") + + def test_activation_syncs_model_to_global(self): + """Activating a provider must write its model into global model field.""" + set_conf({"bot_type": "custom", "custom_providers": [], "model": "gpt-4o"}) + h = _HandlerHarness() + res = h.call(action="set_custom_provider", name="sf", + api_base="https://sf/v1", api_key="k", model="deepseek-v3", + make_active=True) + # Global model field should now be the provider's model. + self.assertEqual(config_module.conf().get("model"), "deepseek-v3") + self.assertEqual(config_module.conf().get("bot_type"), f"custom:{res['id']}") + + def test_activation_without_model_keeps_global_model(self): + """Activating a provider with no model must not overwrite global model.""" + set_conf({"bot_type": "custom", "custom_providers": [], "model": "gpt-4o"}) + h = _HandlerHarness() + h.call(action="set_custom_provider", name="local", + api_base="http://localhost:11434/v1", api_key="", + make_active=True) + # Global model field should remain unchanged. + self.assertEqual(config_module.conf().get("model"), "gpt-4o") + + +class TestProviderOverviewExpansion(unittest.TestCase): + """_provider_overview / _custom_provider_cards should expand the list.""" + + def test_no_custom_providers_keeps_single_card(self): + set_conf({"bot_type": "custom", "custom_providers": []}) + cards = ModelsHandler._custom_provider_cards(config_module.conf()) + self.assertEqual(cards, []) + overview = ModelsHandler._provider_overview() + custom_cards = [c for c in overview if c.get("id") == "custom"] + # Legacy single custom card remains present. + self.assertEqual(len(custom_cards), 1) + + def test_multi_providers_expand_into_cards(self): + set_conf({ + "bot_type": "custom:id_b", + "custom_providers": [ + {"id": "id_a", "name": "a", "api_key": "ak", "api_base": "https://a/v1"}, + {"id": "id_b", "name": "b", "api_key": "bk", "api_base": "https://b/v1", "model": "m"}, + ], + }) + overview = ModelsHandler._provider_overview() + custom_cards = [c for c in overview if c.get("is_custom")] + self.assertEqual(len(custom_cards), 2) + by_id = {c["custom_id"]: c for c in custom_cards} + self.assertEqual(by_id["id_a"]["id"], "custom:id_a") + self.assertFalse(by_id["id_a"]["active"]) + self.assertTrue(by_id["id_b"]["active"]) + self.assertEqual(by_id["id_b"]["model"], "m") + # No single legacy "custom" card when expanded. + self.assertFalse(any(c.get("id") == "custom" for c in overview)) + + def test_no_active_shows_none_active(self): + """When bot_type is plain 'custom', no card is marked active.""" + set_conf({ + "bot_type": "custom", + "custom_providers": [ + {"id": "id_a", "name": "a", "api_key": "ak", "api_base": "https://a/v1"}, + {"id": "id_b", "name": "b", "api_key": "bk", "api_base": "https://b/v1"}, + ], + }) + cards = ModelsHandler._custom_provider_cards(config_module.conf()) + active_cards = [c for c in cards if c.get("active")] + self.assertEqual(len(active_cards), 0) + + +if __name__ == "__main__": + unittest.main()