From cffa590d3e9eac5b59284df1565dd76951e9f049 Mon Sep 17 00:00:00 2001 From: kirs-hi Date: Wed, 10 Jun 2026 18:12:30 +0800 Subject: [PATCH 01/16] 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 02/16] 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 e85290cddcbb5ffc9c235927f4c92e5b4c3ec264 Mon Sep 17 00:00:00 2001 From: kirs-hi Date: Thu, 11 Jun 2026 17:40:41 +0800 Subject: [PATCH 03/16] fix(security): SSRF protection for vision tool + path traversal guard for skill install 1. Vision SSRF (#2878, #2872): Add _validate_url_safe() that resolves the target hostname via DNS and rejects any IP in private (RFC1918), loopback, link-local, or reserved ranges before requests.get() is called. This blocks attacks that use attacker-controlled image URLs to probe internal services or cloud metadata endpoints (169.254.169.254). 2. Skill install path traversal (#2873): Add _safe_skill_dir() that validates the skill name cannot escape the skills/ root directory. Rejects names containing '..', absolute paths, and any resolved path that falls outside the custom_dir boundary. Applied to _add_url(), _add_package(), and delete(). Both fixes include comprehensive unit tests (19 test cases) covering blocked patterns, edge cases, and allowed legitimate usage. Closes #2878 Closes #2873 Ref: #2872 --- agent/skills/service.py | 27 ++- agent/tools/vision/vision.py | 38 ++++ tests/test_security_ssrf_path_traversal.py | 194 +++++++++++++++++++++ 3 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 tests/test_security_ssrf_path_traversal.py diff --git a/agent/skills/service.py b/agent/skills/service.py index a34a546f..95cfb9bb 100644 --- a/agent/skills/service.py +++ b/agent/skills/service.py @@ -34,6 +34,27 @@ class SkillService: """ self.manager = skill_manager + def _safe_skill_dir(self, name: str) -> str: + """Derive and validate the skill directory path. + + Ensures the resolved path stays within the custom_dir root, + preventing path traversal via names like ``../escaped``. + + :raises ValueError: if the name would escape the skills root. + """ + if not name or not name.strip(): + raise ValueError("skill name is required") + # Reject obvious traversal components. + if ".." in name or name.startswith("/") or name.startswith("\\"): + raise ValueError(f"invalid skill name (path traversal detected): {name!r}") + skill_dir = os.path.realpath(os.path.join(self.manager.custom_dir, name)) + root = os.path.realpath(self.manager.custom_dir) + if not skill_dir.startswith(root + os.sep) and skill_dir != root: + raise ValueError( + f"skill name {name!r} resolves outside the skills directory" + ) + return skill_dir + # ------------------------------------------------------------------ # query # ------------------------------------------------------------------ @@ -107,7 +128,7 @@ class SkillService: if not files: raise ValueError("skill files list is empty") - skill_dir = os.path.join(self.manager.custom_dir, name) + skill_dir = self._safe_skill_dir(name) tmp_dir = skill_dir + ".tmp" if os.path.exists(tmp_dir): @@ -146,7 +167,7 @@ class SkillService: raise ValueError("package url is required") url = files[0]["url"] - skill_dir = os.path.join(self.manager.custom_dir, name) + skill_dir = self._safe_skill_dir(name) with tempfile.TemporaryDirectory() as tmp_dir: zip_path = os.path.join(tmp_dir, "package.zip") @@ -217,7 +238,7 @@ class SkillService: if not name: raise ValueError("skill name is required") - skill_dir = os.path.join(self.manager.custom_dir, name) + skill_dir = self._safe_skill_dir(name) if os.path.exists(skill_dir): shutil.rmtree(skill_dir) logger.info(f"[SkillService] delete: removed directory {skill_dir}") diff --git a/agent/tools/vision/vision.py b/agent/tools/vision/vision.py index 19878f6d..170d2175 100644 --- a/agent/tools/vision/vision.py +++ b/agent/tools/vision/vision.py @@ -17,11 +17,14 @@ Provider resolution: """ import base64 +import ipaddress import os +import socket import subprocess import tempfile from dataclasses import dataclass, field from typing import Any, Dict, List, Optional +from urllib.parse import urlparse import requests @@ -654,6 +657,40 @@ class Vision(BaseTool): return api_base return api_base.rstrip("/") + "/v1" + @staticmethod + def _validate_url_safe(url: str) -> None: + """Reject URLs that target private/loopback/link-local addresses (SSRF guard). + + Resolves the hostname to its IP address(es) and blocks any that fall + into non-public ranges. Also rejects URLs with no host, non-HTTP(S) + schemes, or hosts that fail DNS resolution. + + Raises: + ValueError: if the URL targets a disallowed address. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError(f"Unsupported URL scheme: {parsed.scheme}") + + hostname = parsed.hostname + if not hostname: + raise ValueError("URL has no hostname") + + try: + # Resolve all addresses for the hostname. + addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + except socket.gaierror: + raise ValueError(f"Cannot resolve hostname: {hostname}") + + for family, _, _, _, sockaddr in addr_infos: + ip_str = sockaddr[0] + ip = ipaddress.ip_address(ip_str) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + raise ValueError( + f"URL resolves to a non-public address ({ip_str}), " + f"request blocked for security" + ) + def _build_image_content(self, image: str) -> dict: """ Build the image_url content block. @@ -661,6 +698,7 @@ class Vision(BaseTool): so every bot backend can consume them without extra downloads. """ if image.startswith(("http://", "https://")): + self._validate_url_safe(image) return self._download_to_data_url(image) if not os.path.isfile(image): diff --git a/tests/test_security_ssrf_path_traversal.py b/tests/test_security_ssrf_path_traversal.py new file mode 100644 index 00000000..6c2dbb09 --- /dev/null +++ b/tests/test_security_ssrf_path_traversal.py @@ -0,0 +1,194 @@ +# encoding:utf-8 +""" +Unit tests for security fixes: + 1. Vision tool SSRF protection (issue #2878, #2872) + 2. Skill service path traversal protection (issue #2873) +""" +import os +import sys +import tempfile +import types +import unittest +from unittest.mock import patch, MagicMock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# Stub 'requests' if not installed so vision.py can be imported for testing. +if "requests" not in sys.modules: + _requests_stub = types.ModuleType("requests") + _requests_stub.get = lambda *a, **k: None + sys.modules["requests"] = _requests_stub + + +# ============================================================================= +# Vision SSRF tests +# ============================================================================= + +class TestVisionSSRFValidation(unittest.TestCase): + """Test that _validate_url_safe blocks internal/private URLs.""" + + def setUp(self): + from agent.tools.vision.vision import Vision + self.validate = Vision._validate_url_safe + + def test_loopback_ipv4_blocked(self): + """127.0.0.1 must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.validate("http://127.0.0.1/canary.png") + self.assertIn("non-public", str(ctx.exception)) + + def test_loopback_localhost_blocked(self): + """localhost must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.validate("http://localhost/canary.png") + self.assertIn("non-public", str(ctx.exception)) + + def test_private_10_network_blocked(self): + """10.x.x.x RFC1918 must be rejected.""" + with patch("socket.getaddrinfo") as mock_gai: + mock_gai.return_value = [ + (2, 1, 6, "", ("10.0.0.1", 0)), + ] + with self.assertRaises(ValueError) as ctx: + self.validate("http://internal.corp/image.png") + self.assertIn("non-public", str(ctx.exception)) + + def test_private_172_network_blocked(self): + """172.16.x.x RFC1918 must be rejected.""" + with patch("socket.getaddrinfo") as mock_gai: + mock_gai.return_value = [ + (2, 1, 6, "", ("172.16.0.1", 0)), + ] + with self.assertRaises(ValueError) as ctx: + self.validate("http://internal.corp/image.png") + self.assertIn("non-public", str(ctx.exception)) + + def test_private_192_168_blocked(self): + """192.168.x.x RFC1918 must be rejected.""" + with patch("socket.getaddrinfo") as mock_gai: + mock_gai.return_value = [ + (2, 1, 6, "", ("192.168.1.1", 0)), + ] + with self.assertRaises(ValueError) as ctx: + self.validate("http://router.local/image.png") + self.assertIn("non-public", str(ctx.exception)) + + def test_link_local_blocked(self): + """169.254.x.x (link-local / cloud metadata) must be rejected.""" + with patch("socket.getaddrinfo") as mock_gai: + mock_gai.return_value = [ + (2, 1, 6, "", ("169.254.169.254", 0)), + ] + with self.assertRaises(ValueError) as ctx: + self.validate("http://metadata.google.internal/image.png") + self.assertIn("non-public", str(ctx.exception)) + + def test_ipv6_loopback_blocked(self): + """::1 (IPv6 loopback) must be rejected.""" + with patch("socket.getaddrinfo") as mock_gai: + mock_gai.return_value = [ + (10, 1, 6, "", ("::1", 0, 0, 0)), + ] + with self.assertRaises(ValueError) as ctx: + self.validate("http://[::1]/image.png") + self.assertIn("non-public", str(ctx.exception)) + + def test_public_url_allowed(self): + """A URL resolving to a public IP should pass validation.""" + with patch("socket.getaddrinfo") as mock_gai: + mock_gai.return_value = [ + (2, 1, 6, "", ("151.101.1.140", 0)), + ] + # Should not raise + self.validate("https://cdn.example.com/image.png") + + def test_no_hostname_rejected(self): + """A URL with no host must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.validate("http:///path/to/image.png") + self.assertIn("no hostname", str(ctx.exception)) + + def test_non_http_scheme_rejected(self): + """file:// and ftp:// schemes must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.validate("file:///etc/passwd") + self.assertIn("scheme", str(ctx.exception)) + + def test_dns_failure_rejected(self): + """Unresolvable hostname must be rejected.""" + import socket as sock_mod + with patch("socket.getaddrinfo", side_effect=sock_mod.gaierror("Name does not resolve")): + with self.assertRaises(ValueError) as ctx: + self.validate("http://nonexistent.invalid/img.png") + self.assertIn("Cannot resolve", str(ctx.exception)) + + +# ============================================================================= +# Skill service path traversal tests +# ============================================================================= + +class TestSkillServicePathTraversal(unittest.TestCase): + """Test that _safe_skill_dir blocks path traversal attempts.""" + + def setUp(self): + self.tmp_root = tempfile.mkdtemp() + # Create a minimal SkillManager mock with custom_dir set. + from agent.skills.service import SkillService + mock_manager = MagicMock() + mock_manager.custom_dir = self.tmp_root + self.svc = SkillService(mock_manager) + + def tearDown(self): + import shutil + shutil.rmtree(self.tmp_root, ignore_errors=True) + + def test_normal_name_allowed(self): + """A simple name like 'my-skill' should produce a valid path.""" + result = self.svc._safe_skill_dir("my-skill") + expected = os.path.realpath(os.path.join(self.tmp_root, "my-skill")) + self.assertEqual(result, expected) + + def test_dotdot_traversal_blocked(self): + """'../escaped' must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.svc._safe_skill_dir("../escaped") + self.assertIn("path traversal", str(ctx.exception)) + + def test_nested_dotdot_blocked(self): + """'foo/../../escaped' must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.svc._safe_skill_dir("foo/../../escaped") + self.assertIn("path traversal", str(ctx.exception)) + + def test_absolute_path_blocked(self): + """'/tmp/evil' must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.svc._safe_skill_dir("/tmp/evil") + self.assertIn("path traversal", str(ctx.exception)) + + def test_backslash_path_blocked(self): + r"""'\\server\share' must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.svc._safe_skill_dir("\\server\\share") + self.assertIn("path traversal", str(ctx.exception)) + + def test_empty_name_blocked(self): + """Empty name must be rejected.""" + with self.assertRaises(ValueError): + self.svc._safe_skill_dir("") + + def test_whitespace_only_blocked(self): + """Whitespace-only name must be rejected.""" + with self.assertRaises(ValueError): + self.svc._safe_skill_dir(" ") + + def test_subdir_name_allowed(self): + """A name with a forward slash but no traversal is allowed if it stays in root.""" + # e.g. "category/skill-name" is a valid nested skill directory + result = self.svc._safe_skill_dir("category/skill-name") + expected = os.path.realpath(os.path.join(self.tmp_root, "category/skill-name")) + self.assertEqual(result, expected) + + +if __name__ == "__main__": + unittest.main() From 7fd30b608c77e51826bd187c0255f08026d35f2d Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 11 Jun 2026 19:08:30 +0800 Subject: [PATCH 04/16] fix(cow_cli): fix line breaks in CLI replies --- plugins/cow_cli/cow_cli.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py index 62bbc786..5ee25cf9 100644 --- a/plugins/cow_cli/cow_cli.py +++ b/plugins/cow_cli/cow_cli.py @@ -147,6 +147,7 @@ class CowCliPlugin(Plugin): else: # "typo" reply_text = self._typo_hint(token, result[1]) + reply_text = self._harden_line_breaks(reply_text, e_context) e_context["reply"] = Reply(ReplyType.TEXT, reply_text) e_context.action = EventAction.BREAK_PASS @@ -1516,6 +1517,43 @@ class CowCliPlugin(Plugin): except Exception: return False + @staticmethod + def _harden_line_breaks(text: str, e_context) -> str: + """WeChat PC renders bot messages as Markdown, where a lone '\\n' is + collapsed into a space, so plain-text CLI output gets squashed onto + one line. Prefix consecutive text lines with '- ' so the Markdown + list keeps each on its own line (the only form WeChat respects). + WeChat-only; other channels are untouched. Blank lines, code fences, + and lines that are already list items are left intact.""" + if e_context is None or not text or "\n" not in text: + return text + try: + if e_context["context"].kwargs.get("channel_type") != "weixin": + return text + except Exception: + return text + + out = [] + in_code = False + lines = text.split("\n") + for i, line in enumerate(lines): + if line.lstrip().startswith("```"): + in_code = not in_code + out.append(line) + continue + stripped = line.lstrip() + prev_packed = i > 0 and lines[i - 1].strip() != "" + next_packed = i < len(lines) - 1 and lines[i + 1].strip() != "" + # Only convert lines inside a multi-line block (a neighbour line is + # non-blank); standalone paragraphs separated by blank lines, code + # blocks, blank lines, and existing list items are left intact. + if (in_code or not stripped or stripped.startswith(("- ", "* ", "+ ")) + or not (prev_packed or next_packed)): + out.append(line) + else: + out.append("- " + stripped) + return "\n".join(out) + @staticmethod def _build_dream_result(flush_mgr, is_web: bool) -> str: """Build dream completion message with diary content.""" From d5427d967a5ca85d350fc95afc70b6966fecb882 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 11 Jun 2026 19:24:08 +0800 Subject: [PATCH 05/16] fix(installer): fix ASR/TTS default, self-evolution flag, and QuickEdit hang --- run.sh | 6 +++-- scripts/run.ps1 | 62 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/run.sh b/run.sh index 638fecfc..e4f5ba5a 100755 --- a/run.sh +++ b/run.sh @@ -869,8 +869,10 @@ base = { 'mimo_api_key': e('MIMO_KEY', ''), 'deepseek_api_key': e('DEEPSEEK_KEY', ''), 'deepseek_api_base': e('DEEPSEEK_BASE'), - 'voice_to_text': 'openai', - 'text_to_voice': 'openai', + # Leave ASR/TTS provider empty so the web console auto-suggests the vendor + # whose API key is actually configured (e.g. LinkAI), not always OpenAI. + 'voice_to_text': '', + 'text_to_voice': '', 'voice_reply_voice': False, 'speech_recognition': True, 'group_speech_recognition': False, diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 76fa7708..83e969e1 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -32,6 +32,44 @@ try { $OutputEncoding = [System.Text.Encoding]::UTF8 $env:PYTHONIOENCODING = "utf-8" +# ── disable console QuickEdit mode ─────────────────────────────── +# On Windows, QuickEdit is ON by default: a stray click/selection in the +# window FREEZES all output until the user presses a key. During long steps +# (git clone, pip install) this looks like a hang, and the wake-up key gets +# buffered and later auto-confirms a menu default. Turning QuickEdit off +# removes both the freeze and the phantom keystrokes. +try { + if (-not ([Console]::IsInputRedirected)) { + $quickEditType = @' +using System; +using System.Runtime.InteropServices; +public static class ConsoleQuickEdit { + const int STD_INPUT_HANDLE = -10; + const uint ENABLE_QUICK_EDIT = 0x0040; + const uint ENABLE_EXTENDED = 0x0080; + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr GetStdHandle(int nStdHandle); + [DllImport("kernel32.dll")] + static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); + [DllImport("kernel32.dll")] + static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode); + public static void Disable() { + IntPtr handle = GetStdHandle(STD_INPUT_HANDLE); + uint mode; + if (!GetConsoleMode(handle, out mode)) { return; } + mode &= ~ENABLE_QUICK_EDIT; // clear QuickEdit + mode |= ENABLE_EXTENDED; // keep extended flags valid + SetConsoleMode(handle, mode); + } +} +'@ + if (-not ([System.Management.Automation.PSTypeName]'ConsoleQuickEdit').Type) { + Add-Type -TypeDefinition $quickEditType -ErrorAction Stop + } + [ConsoleQuickEdit]::Disable() + } +} catch {} + # ── colours ────────────────────────────────────────────────────── function Write-Cow { param([string]$M) Write-Host $M -ForegroundColor Green } function Write-Warn { param([string]$M) Write-Host $M -ForegroundColor Yellow } @@ -91,6 +129,17 @@ function Initialize-UiLang { } } +# Drain any buffered console keystrokes. During long steps (git clone, pip +# install) users often hit Enter to "wake up" a seemingly stuck console; those +# keys sit in the input buffer and would otherwise be consumed by the next +# ReadKey, auto-confirming a menu's default. Flush them before reading input. +function Clear-InputBuffer { + try { + if ([Console]::IsInputRedirected) { return } + while ([Console]::KeyAvailable) { [void][Console]::ReadKey($true) } + } catch {} +} + # ── arrow-key selectable menu with number fallback ─────────────── # Usage: $idx = Select-Menu -Title "..." -Options @("a","b") [-Default 1] # Returns the selected 1-based index. @@ -125,6 +174,10 @@ function Select-Menu { Write-Info $Title Write-Host (T "↑/↓ 选择,Enter 确认" "Use ↑/↓ to move, Enter to select") -ForegroundColor Cyan + # Discard keystrokes buffered during the previous (possibly long-running) + # step so a leftover Enter doesn't instantly confirm the default option. + Clear-InputBuffer + [Console]::CursorVisible = $false $firstDraw = $true try { @@ -573,8 +626,11 @@ function New-ConfigFile { mimo_api_key = "" deepseek_api_key = "" deepseek_api_base = "https://api.deepseek.com/v1" - voice_to_text = "openai" - text_to_voice = "openai" + # Leave ASR/TTS provider empty so the web console auto-suggests the + # vendor whose API key is actually configured (e.g. LinkAI), instead + # of always defaulting to OpenAI. + voice_to_text = "" + text_to_voice = "" voice_reply_voice = $false speech_recognition = $true group_speech_recognition = $false @@ -585,6 +641,8 @@ function New-ConfigFile { agent_max_context_tokens = 40000 agent_max_context_turns = 30 agent_max_steps = 15 + # New installs opt into self-evolution (matches run.sh). + self_evolution_enabled = $true } # Set the API key into the right field (skipped models leave it empty). From 6fb19a68b5ee812ee70065a94fd61982ff41c522 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 11 Jun 2026 19:30:08 +0800 Subject: [PATCH 06/16] feat: update default config in run script --- run.sh | 6 +++--- scripts/run.ps1 | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/run.sh b/run.sh index e4f5ba5a..3c15b0f0 100755 --- a/run.sh +++ b/run.sh @@ -880,9 +880,9 @@ base = { 'linkai_api_key': e('LINKAI_KEY', ''), 'linkai_app_code': '', 'agent': True, - 'agent_max_context_tokens': 40000, - 'agent_max_context_turns': 30, - 'agent_max_steps': 15, + 'agent_max_context_tokens': 50000, + 'agent_max_context_turns': 20, + 'agent_max_steps': 20, # New installs opt into self-evolution; existing users (no key) keep the # code default (off) so an upgrade never silently changes their behavior. 'self_evolution_enabled': True, diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 83e969e1..2386dd87 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -638,9 +638,9 @@ function New-ConfigFile { linkai_api_key = "" linkai_app_code = "" agent = $true - agent_max_context_tokens = 40000 - agent_max_context_turns = 30 - agent_max_steps = 15 + agent_max_context_tokens = 50000 + agent_max_context_turns = 20 + agent_max_steps = 20 # New installs opt into self-evolution (matches run.sh). self_evolution_enabled = $true } From 0092376c07129de4b353400a0b59b32e0f699b1a Mon Sep 17 00:00:00 2001 From: kirs-hi Date: Fri, 12 Jun 2026 10:05:05 +0800 Subject: [PATCH 07/16] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20no?= =?UTF-8?q?=20auto-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 ad64e17a345826140be844f81a90967a28ffbed9 Mon Sep 17 00:00:00 2001 From: kirs-hi Date: Fri, 12 Jun 2026 11:03:04 +0800 Subject: [PATCH 08/16] fix: avoid KeyError on cancel and infinite loop in image compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent robustness fixes: 1. ChatChannel.cancel_session / cancel_all_session raised KeyError when a session existed in self.sessions but no future had been dispatched yet. self.sessions[sid] is created in produce(), but self.futures[sid] is only created later in consume() on first dispatch. Cancelling in that window (e.g. user sends a message then immediately cancels) crashed the cancel path. Use self.futures.get(sid, []) so an absent entry is a no-op. 2. compress_imgfile decremented JPEG quality by 5 with no lower bound. For an image that cannot be compressed below max_size, quality went 0, negative, ... — the loop never terminated and passed invalid quality values to PIL. Add a min_quality floor (10) and return the best effort once reached. Adds tests/test_robustness_fixes.py covering both paths (5 tests). --- channel/chat_channel.py | 7 +- common/utils.py | 6 +- tests/test_robustness_fixes.py | 121 +++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 tests/test_robustness_fixes.py diff --git a/channel/chat_channel.py b/channel/chat_channel.py index 9104a38e..e2a65c5b 100644 --- a/channel/chat_channel.py +++ b/channel/chat_channel.py @@ -519,7 +519,10 @@ class ChatChannel(Channel): def cancel_session(self, session_id): with self.lock: if session_id in self.sessions: - for future in self.futures[session_id]: + # futures[session_id] is only created in consume() when a task is + # dispatched, so it may be absent if cancel happens right after + # produce() but before the first dispatch. Default to []. + for future in self.futures.get(session_id, []): future.cancel() cnt = self.sessions[session_id][0].qsize() if cnt > 0: @@ -529,7 +532,7 @@ class ChatChannel(Channel): def cancel_all_session(self): with self.lock: for session_id in self.sessions: - for future in self.futures[session_id]: + for future in self.futures.get(session_id, []): future.cancel() cnt = self.sessions[session_id][0].qsize() if cnt > 0: diff --git a/common/utils.py b/common/utils.py index e7264e20..0597d979 100644 --- a/common/utils.py +++ b/common/utils.py @@ -27,10 +27,14 @@ def compress_imgfile(file, max_size): img = Image.open(file) rgb_image = img.convert("RGB") quality = 95 + min_quality = 10 while True: out_buf = io.BytesIO() rgb_image.save(out_buf, "JPEG", quality=quality) - if fsize(out_buf) <= max_size: + if fsize(out_buf) <= max_size or quality <= min_quality: + # Stop at min_quality: further decrements would pass an invalid + # quality (<1) to PIL and the loop would otherwise never terminate + # for images that cannot be compressed below max_size. return out_buf quality -= 5 diff --git a/tests/test_robustness_fixes.py b/tests/test_robustness_fixes.py new file mode 100644 index 00000000..9f8aa453 --- /dev/null +++ b/tests/test_robustness_fixes.py @@ -0,0 +1,121 @@ +# encoding:utf-8 +""" +Unit tests for robustness fixes: + 1. ChatChannel.cancel_session / cancel_all_session must not raise KeyError + when a session has been produced but no task has been dispatched yet + (so self.futures[session_id] does not exist). + 2. common.utils.compress_imgfile must terminate (no infinite loop / invalid + PIL quality) when an image cannot be compressed below max_size. +""" +import io +import os +import sys +import types +import unittest +from unittest.mock import MagicMock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +# ============================================================================= +# 1. cancel_session / cancel_all_session KeyError regression +# ============================================================================= + +class TestCancelSessionMissingFutures(unittest.TestCase): + """A session may exist in self.sessions before any future is recorded.""" + + def _make_channel(self): + # Import lazily and build a bare object without running __init__, + # to avoid pulling the full channel setup / config. + from channel.chat_channel import ChatChannel + + ch = ChatChannel.__new__(ChatChannel) + import threading + + ch.lock = threading.RLock() + # A produced session whose future has NOT been dispatched yet. + queue = MagicMock() + queue.qsize.return_value = 0 + semaphore = MagicMock() + ch.sessions = {"sid": [queue, semaphore]} + ch.futures = {} # intentionally empty: consume() never ran + return ch + + def test_cancel_session_no_futures_entry(self): + ch = self._make_channel() + # Should not raise KeyError. + try: + ch.cancel_session("sid") + except KeyError: + self.fail("cancel_session raised KeyError when futures entry missing") + + def test_cancel_all_session_no_futures_entry(self): + ch = self._make_channel() + try: + ch.cancel_all_session() + except KeyError: + self.fail("cancel_all_session raised KeyError when futures entry missing") + + def test_cancel_session_cancels_existing_futures(self): + ch = self._make_channel() + fut = MagicMock() + ch.futures["sid"] = [fut] + ch.cancel_session("sid") + fut.cancel.assert_called_once() + + +# ============================================================================= +# 2. compress_imgfile termination +# ============================================================================= + +class TestCompressImgfileTermination(unittest.TestCase): + """compress_imgfile must always return, even for incompressible input.""" + + def setUp(self): + # Skip if Pillow is not available in the test environment. + try: + import PIL # noqa: F401 + except ImportError: + self.skipTest("Pillow not installed") + + def _make_image_buf(self, size=(64, 64)): + from PIL import Image + import random + + img = Image.new("RGB", size) + # Fill with random noise so JPEG cannot compress it well. + pixels = [ + (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) + for _ in range(size[0] * size[1]) + ] + img.putdata(pixels) + buf = io.BytesIO() + img.save(buf, "JPEG", quality=95) + buf.seek(0) + return buf + + def test_returns_when_target_unreachable(self): + from common.utils import compress_imgfile + + buf = self._make_image_buf() + # An impossibly small target that even quality=10 won't reach. + out = compress_imgfile(buf, max_size=10) + self.assertIsInstance(out, io.BytesIO) + # Verify the result is still a valid JPEG (PIL never got invalid quality). + from PIL import Image + + out.seek(0) + img = Image.open(out) + img.verify() + + def test_no_compression_needed_returns_same_object(self): + from common.utils import compress_imgfile + + buf = self._make_image_buf() + size = buf.getbuffer().nbytes + out = compress_imgfile(buf, max_size=size + 1) + self.assertIs(out, buf) + + +if __name__ == "__main__": + unittest.main() From 1d7e6b370328a990cf0c7008d40400e92bbb1a5e Mon Sep 17 00:00:00 2001 From: zhayujie Date: Fri, 12 Jun 2026 11:54:19 +0800 Subject: [PATCH 09/16] 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 From 6b5ee245ae7417506653a15432cfa9bf3caf06ca Mon Sep 17 00:00:00 2001 From: zhayujie Date: Fri, 12 Jun 2026 18:03:33 +0800 Subject: [PATCH 10/16] feat(web): integrate custom providers into the provider credentials section - Merge the separate custom-providers section into the unified provider grid; "Custom" in the add-provider picker now acts as an add-new action (trailing + mark) and opens the dedicated modal, supporting multiple OpenAI-compatible endpoints - Simplify the custom provider modal: drop the default-model field, add an inline delete button, align colors with the theme - Keep the legacy single "custom" card visible (models page, chat dropdown and legacy config page) while custom_api_key/custom_api_base is still in use, so existing single-provider setups don't disappear - Unify user-facing wording from "vendor" to "provider" in UI and docs - Restructure custom provider docs (zh/en/ja) around Web console and config file usage --- channel/web/chat.html | 28 ++-- channel/web/static/css/console.css | 8 ++ channel/web/static/js/console.js | 221 +++++++++-------------------- channel/web/web_channel.py | 65 +++++++-- docs/ja/models/custom.mdx | 98 ++++--------- docs/models/custom.mdx | 88 +++--------- docs/models/deepseek.mdx | 2 +- docs/models/index.mdx | 14 +- docs/models/linkai.mdx | 2 +- docs/models/mimo.mdx | 2 +- docs/models/openai.mdx | 2 +- docs/models/qwen.mdx | 2 +- docs/releases/v2.0.3.mdx | 2 +- docs/releases/v2.0.7.mdx | 2 +- docs/zh/models/custom.mdx | 98 ++++--------- 15 files changed, 234 insertions(+), 400 deletions(-) diff --git a/channel/web/chat.html b/channel/web/chat.html index 0b71d745..9e5dadd6 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -1171,8 +1171,8 @@ w-full max-w-md mx-4">
-
- +
+

@@ -1185,15 +1185,14 @@ + focus:outline-none focus:border-primary-500 transition-colors">
@@ -1201,20 +1200,15 @@ -
-
- - + focus:outline-none focus:border-primary-500 font-mono transition-colors">
-
+
+
diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index e260bbc9..155e240a 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -854,6 +854,14 @@ font-size: 11px; flex-shrink: 0; } +/* "Custom" row is an add-new action: trailing + instead of ✓. */ +.vendor-picker-add-mark { + margin-left: auto; + padding-left: 12px; + color: #94a3b8; + font-size: 11px; + flex-shrink: 0; +} /* Chat Input */ #chat-input { diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index f9a7b43c..c2b65819 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -32,21 +32,13 @@ 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_vendor_label: '自定义', 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: '主模型', @@ -240,10 +232,10 @@ const I18N = { menu_logs: 'Logs', models_title: 'Models', models_desc: 'Manage chat, image, voice, embedding and search capabilities in one place', - models_section_vendors: 'Vendor Credentials', + models_section_vendors: 'Provider Credentials', models_section_vendors_desc: 'Configured once, shared by multiple model capabilities', models_section_capabilities: 'Capabilities', - models_add_vendor: 'Add Vendor', + models_add_vendor: 'Add Provider', models_provider: 'Provider', models_model: 'Model', models_voice: 'Voice', @@ -253,21 +245,13 @@ 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_vendor_label: 'Custom', 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', @@ -311,8 +295,8 @@ const I18N = { models_embedding_saved_msg: 'Send /memory rebuild-index in the chat to rebuild the index.', models_embedding_saved_ok: 'Go', models_pick_provider: 'Pick a provider', - models_clear_confirm_title: 'Clear vendor credentials', - models_clear_confirm_msg: 'Remove this vendor\'s API Key and Base URL? Capabilities relying on it will stop working.', + models_clear_confirm_title: 'Clear provider credentials', + models_clear_confirm_msg: 'Remove this provider\'s API Key and Base URL? Capabilities relying on it will stop working.', cancel: 'Cancel', save: 'Save', ok: 'OK', @@ -4766,12 +4750,12 @@ 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. +// providers (id "custom:") — shown in the vendor grid alongside built-in +// vendors, but edited via the dedicated custom-provider modal. function isCustomProviderCard(p) { return !!(p && p.is_custom && p.custom_name); } @@ -4782,10 +4766,9 @@ 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'; - // 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); + // Custom providers always show once created (even without an api key, + // e.g. a local vLLM/Ollama endpoint); built-in vendors show when configured. + const configured = modelsState.providers.filter(p => p.configured || isCustomProviderCard(p)); const header = `
@@ -4796,7 +4779,6 @@ function renderVendorsSection() {

${t('models_section_vendors')}

${t('models_section_vendors_desc')}

- ${configured.length}/${builtinProviders.length}
`; let body; @@ -4822,8 +4804,13 @@ function renderVendorsSection() { function renderVendorChip(p) { // The masked API key is intentionally not surfaced here; it is shown // inside the edit modal so the chip stays uncluttered and scannable. + // Custom providers open their dedicated modal (name + base + key); + // their ids are server-generated hex, safe to inline. + const onclick = isCustomProviderCard(p) + ? `openCustomProviderModal('${escapeHtml(p.custom_id)}')` + : `openVendorModal('${escapeHtml(p.id)}')`; return ` - -
`; - - 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) { @@ -5776,6 +5661,14 @@ function decorateVendorModalPicker(ddEl, opts) { // Tag the row so the global active-row ✓ rule is suppressed in CSS // (otherwise configured AND selected rows would render two checks). item.classList.add('vendor-picker-item'); + if (opt._isAddNew) { + // "Custom" is an add-new action (multiple entries allowed), + // so show a trailing + instead of the configured ✓. + const plus = document.createElement('i'); + plus.className = 'fas fa-plus vendor-picker-add-mark'; + item.appendChild(plus); + return; + } if (!opt._configured) return; const check = document.createElement('i'); check.className = 'fas fa-check vendor-picker-configured-mark'; @@ -6083,22 +5976,42 @@ 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. - // Custom (OpenAI-compatible) providers are managed in their own - // section with a dedicated modal, so they are excluded from this - // built-in vendor picker. + // Expanded custom provider cards ("custom:") are edited via their + // dedicated modal, so they are excluded from this picker. Picking the + // "custom" entry creates a *new* custom provider via that modal — + // this is how multiple OpenAI-compatible endpoints are added. 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 = builtinProviders.map(p => ({ value: p.id, label: localizedLabel(p.label), _configured: !!p.configured, })); - initDropdown(pickerEl, pickerOpts, defaultId, (val) => fillVendorModalForProvider(val)); + // In multi-provider mode the backend replaces the bare "custom" card + // with the expanded ones; re-add it here so the entry stays available. + if (!pickerOpts.some(o => o.value === 'custom')) { + pickerOpts.push({ value: 'custom', label: t('models_custom_vendor_label'), _configured: false }); + } + // "Custom" always behaves as an add-new action (multiple entries + // allowed), so it shows a + mark instead of the configured ✓. + pickerOpts.forEach(o => { if (o.value === 'custom') { o._isAddNew = true; o._configured = false; } }); + const unconfigured = builtinProviders.filter(p => !p.configured); + const defaultId = (unconfigured[0] && unconfigured[0].id) || (builtinProviders[0] && builtinProviders[0].id) || 'custom'; + pickerWrap.classList.remove('hidden'); + const pickerEl = document.getElementById('vendor-modal-picker'); + const onPick = (val) => { + if (val === 'custom') { + // "Custom" in the add flow always creates a new + // OpenAI-compatible provider entry via the dedicated modal + // (name + base + key), supporting multiple custom endpoints. + closeVendorModal(); + openCustomProviderModal(''); + return; + } + fillVendorModalForProvider(val); + }; + initDropdown(pickerEl, pickerOpts, defaultId, onPick); decorateVendorModalPicker(pickerEl, pickerOpts); - fillVendorModalForProvider(defaultId); + onPick(defaultId); } else { pickerWrap.classList.add('hidden'); fillVendorModalForProvider(providerId); @@ -6276,11 +6189,9 @@ function openCustomProviderModal(providerId) { 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 @@ -6307,6 +6218,13 @@ function openCustomProviderModal(providerId) { document.getElementById('custom-provider-modal-cancel').onclick = closeCustomProviderModal; document.getElementById('custom-provider-modal-save').onclick = saveCustomProviderModal; + // Delete is only available when editing an existing provider. + const deleteBtn = document.getElementById('custom-provider-modal-delete'); + if (deleteBtn) { + deleteBtn.classList.toggle('hidden', !editing); + deleteBtn.onclick = editing ? () => deleteCustomProvider(providerId) : null; + } + function onOverlayClick(e) { if (e.target === overlay) { closeCustomProviderModal(); @@ -6325,7 +6243,6 @@ function closeCustomProviderModal() { 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) { @@ -6350,7 +6267,6 @@ function saveCustomProviderModal() { action: 'set_custom_provider', name: name, api_base: apiBase, - model: model, }; if (apiKey) payload.api_key = apiKey; if (editing) payload.id = customProviderModalState.editId; @@ -6375,16 +6291,6 @@ function saveCustomProviderModal() { }); } -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'), @@ -6397,7 +6303,10 @@ function deleteCustomProvider(providerId) { 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(); + if (data.status === 'success') { + closeCustomProviderModal(); + loadModelsView(); + } }).catch(() => { /* noop */ }); } }); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 6a59beb6..e45690f4 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1643,6 +1643,32 @@ class ConfigHandler: "api_key_field": p.get("api_key_field"), } + # Expose user-defined custom providers as "custom:" entries so + # the legacy config page can display and select them. Credentials + # are managed on the Models page, hence the null key/base fields. + # Mirrors the Models page: when expanded entries exist, the bare + # legacy "custom" entry is hidden — unless the flat single-provider + # custom config is still active or filled in. + try: + from models.custom_provider import get_custom_providers + custom_list = get_custom_providers() + legacy_custom_in_use = ModelsHandler._legacy_custom_in_use(local_config) + if custom_list and not legacy_custom_in_use: + providers.pop("custom", None) + for cp in custom_list: + cid = f"custom:{cp.get('id')}" + cname = cp.get("name") or cp.get("id") + providers[cid] = { + "label": {"zh": cname, "en": cname}, + "models": [cp["model"]] if cp.get("model") else [], + "api_base_key": None, + "api_base_default": None, + "api_base_placeholder": "", + "api_key_field": None, + } + except Exception as cp_err: + logger.warning(f"[ConfigHandler] failed to expand custom providers: {cp_err}") + raw_pwd = str(local_config.get("web_password", "") or "") masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else "" @@ -2189,6 +2215,17 @@ class ModelsHandler: }) return cards + @classmethod + def _legacy_custom_in_use(cls, local_config: dict) -> bool: + """True when the flat single-provider custom config is still relevant: + either it is the active bot_type, or its key/base fields are filled. + In that case the legacy "custom" card must stay visible even when + multi ``custom_providers`` entries exist.""" + if (local_config.get("bot_type") or "") == "custom": + return True + return (cls._is_real_key(local_config.get("custom_api_key") or "") + or bool(local_config.get("custom_api_base"))) + @classmethod def _provider_overview(cls) -> List[dict]: """All known providers (configured first, unconfigured after). @@ -2202,13 +2239,18 @@ class ModelsHandler: """ local_config = conf() custom_cards = cls._custom_provider_cards(local_config) + # Keep the legacy single "custom" card visible alongside the expanded + # ones when the flat custom_api_key/base config is active or filled, + # so existing single-provider setups never disappear from the UI. + keep_legacy_custom = cls._legacy_custom_in_use(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. + # Multi-provider mode: emit the expanded cards, plus the + # legacy card when it is still in use. items.extend(custom_cards) - continue + if not keep_legacy_custom: + 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 "" @@ -2253,11 +2295,15 @@ class ModelsHandler: provider_id = "linkai" # In multi-provider mode, replace the single "custom" entry with the # expanded "custom:" ids so the chat dropdown matches the cards. + # The legacy "custom" entry stays when its flat config is still used. provider_ids = [] custom_cards = cls._custom_provider_cards(local_config) + keep_legacy_custom = cls._legacy_custom_in_use(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) + if keep_legacy_custom: + provider_ids.append(pid) else: provider_ids.append(pid) return { @@ -2881,11 +2927,14 @@ class ModelsHandler: 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) + # Only touch model when explicitly provided in the payload; an + # explicit empty string clears it, a missing key keeps it (the + # UI modal no longer sends model, so manual config survives edits). + if "model" in data: + if model: + existing["model"] = model + else: + existing.pop("model", None) created = False # Decide bot_type — only switch when explicitly requested. diff --git a/docs/ja/models/custom.mdx b/docs/ja/models/custom.mdx index b63f4e34..3df7dc11 100644 --- a/docs/ja/models/custom.mdx +++ b/docs/ja/models/custom.mdx @@ -1,69 +1,31 @@ --- -title: Custom -description: Custom vendor configuration for third-party API proxies and local models +title: カスタム +description: サードパーティ API プロキシやローカルモデル向けのカスタムプロバイダー設定 --- -For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as: +OpenAI 互換プロトコルで接続するモデルサービス向けの設定です。例えば: -- **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 +- **サードパーティ API プロキシ**:統一された API アドレスで複数のモデルを呼び出す +- **ローカルモデル**:Ollama、vLLM などのツールでローカルにデプロイしたモデル +- **プライベートデプロイ**:企業内部にデプロイされたモデルサービス - - 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. - +## Web コンソールでの設定 -## Text Chat +推奨方法です。Web コンソールの「モデル」ページで「プロバイダーを追加」をクリックし、「カスタム」を選択して、名称・API Base・API Key を入力します。複数のカスタムプロバイダーを追加でき、追加後は「メインモデル」でプロバイダーとモデルを選択すると有効になります。 -### Third-party API proxy + -```json -{ - "bot_type": "custom", - "model": "", - "custom_api_key": "YOUR_API_KEY", - "custom_api_base": "https://{your-proxy.com}/v1" -} -``` +主なローカルデプロイツールのデフォルトアドレス: -| Parameter | Description | -| --- | --- | -| `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 - -Local models usually do not require an API key — only the API base needs to be filled in: - -```json -{ - "bot_type": "custom", - "model": "qwen3.5:27b", - "custom_api_base": "http://localhost:11434/v1" -} -``` - -Common local deployment tools and their default endpoints: - -| Tool | Default API Base | +| ツール | デフォルト 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 +## 設定ファイルでの設定 -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:"`: +`config.json` を直接編集することもできます。`custom_providers` リストに複数のプロバイダーを定義し、`bot_type` を `"custom:"` に設定していずれかを有効化します: ```json { @@ -71,34 +33,30 @@ If you need to configure several OpenAI-compatible third-party services at once "custom_providers": [ { "id": "3f2a9c1b", - "name": "siliconflow", - "api_key": "YOUR_SILICONFLOW_KEY", - "api_base": "https://api.siliconflow.cn/v1", - "model": "deepseek-ai/DeepSeek-V3" + "name": "プロバイダーA", + "api_key": "YOUR_API_KEY_A", + "api_base": "https://api.a.com/v1", + "model": "deepseek-v3" }, { "id": "a1b2c3d4", - "name": "qiniu", - "api_key": "YOUR_QINIU_KEY", - "api_base": "https://api.qnaigc.com/v1", - "model": "deepseek-v3" + "name": "プロバイダーB", + "api_key": "YOUR_API_KEY_B", + "api_base": "https://api.b.com/v1", + "model": "qwen3-max" } ] } ``` -| 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 | +| `custom_providers` | カスタムプロバイダーのリスト。各項目は `id`、`name`、`api_base`、`api_key`(任意)、`model`(任意)を含む | +| `bot_type` | `"custom:"` に設定して対応するプロバイダーを有効化 | +| `id` | 一意の識別子(8 桁の 16 進数)。Web コンソールから追加すると自動生成され、手動設定の場合は重複しない任意の文字列でよい | +| `name` | 表示名。自由に変更可能 | +| `model` | このプロバイダーで使用するモデル。有効化時に適用される | - 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. + 従来の単一プロバイダー設定(`bot_type` を `"custom"` にし、`custom_api_key` / `custom_api_base` を使用)は引き続き互換性があり、変更なしでそのまま利用できます。 diff --git a/docs/models/custom.mdx b/docs/models/custom.mdx index b63f4e34..33b84fa7 100644 --- a/docs/models/custom.mdx +++ b/docs/models/custom.mdx @@ -1,51 +1,21 @@ --- title: Custom -description: Custom vendor configuration for third-party API proxies and local models +description: Custom provider configuration for third-party API proxies and local models --- -For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as: +For model services accessed via the OpenAI-compatible protocol, such as: - **Third-party API proxies**: call multiple models through a unified API base -- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI +- **Local models**: models deployed locally with tools like Ollama, vLLM - **Private deployments**: model services deployed inside an enterprise - - 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. - +## Web Console -## Text Chat +Recommended. On the "Models" page of the Web console, click "Add Provider" and pick "Custom", then fill in the name, API Base and API Key. Multiple custom providers can be added; after adding one, select it together with a model in the "Main Model" card to enable it. -### Third-party API proxy + -```json -{ - "bot_type": "custom", - "model": "", - "custom_api_key": "YOUR_API_KEY", - "custom_api_base": "https://{your-proxy.com}/v1" -} -``` - -| Parameter | Description | -| --- | --- | -| `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 - -Local models usually do not require an API key — only the API base needs to be filled in: - -```json -{ - "bot_type": "custom", - "model": "qwen3.5:27b", - "custom_api_base": "http://localhost:11434/v1" -} -``` - -Common local deployment tools and their default endpoints: +Default endpoints of common local deployment tools: | Tool | Default API Base | | --- | --- | @@ -53,17 +23,9 @@ Common local deployment tools and their default endpoints: | [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` | | [LocalAI](https://localai.io) | `http://localhost:8080/v1` | -### Switching Models +## Configuration File -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:"`: +You can also edit `config.json` directly: define multiple providers in the `custom_providers` list and set `bot_type` to `"custom:"` to activate one of them: ```json { @@ -71,17 +33,17 @@ If you need to configure several OpenAI-compatible third-party services at once "custom_providers": [ { "id": "3f2a9c1b", - "name": "siliconflow", - "api_key": "YOUR_SILICONFLOW_KEY", - "api_base": "https://api.siliconflow.cn/v1", - "model": "deepseek-ai/DeepSeek-V3" + "name": "ProviderA", + "api_key": "YOUR_API_KEY_A", + "api_base": "https://api.a.com/v1", + "model": "deepseek-v3" }, { "id": "a1b2c3d4", - "name": "qiniu", - "api_key": "YOUR_QINIU_KEY", - "api_base": "https://api.qnaigc.com/v1", - "model": "deepseek-v3" + "name": "ProviderB", + "api_key": "YOUR_API_KEY_B", + "api_base": "https://api.b.com/v1", + "model": "qwen3-max" } ] } @@ -89,16 +51,12 @@ If you need to configure several OpenAI-compatible third-party services at once | 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 | +| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_base`, `api_key` (optional) and `model` (optional) | +| `bot_type` | Set to `"custom:"` to activate the corresponding provider | +| `id` | Unique identifier (8-char hex); auto-generated when adding via the Web console, or any unique string when editing manually | +| `name` | Display label, can be renamed freely | +| `model` | Model used by this provider, takes effect when activated | - 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. + The legacy single-provider configuration (`bot_type` set to `"custom"` with `custom_api_key` / `custom_api_base`) remains fully compatible and keeps working without any changes. diff --git a/docs/models/deepseek.mdx b/docs/models/deepseek.mdx index 6de8d09b..a389701e 100644 --- a/docs/models/deepseek.mdx +++ b/docs/models/deepseek.mdx @@ -3,7 +3,7 @@ title: DeepSeek description: DeepSeek model configuration (Text Chat + Thinking Mode) --- -DeepSeek is one of the default recommended vendors in Agent mode, focused on cost-effective text chat and task planning. +DeepSeek is one of the default recommended providers in Agent mode, focused on cost-effective text chat and task planning. ## Text Chat diff --git a/docs/models/index.mdx b/docs/models/index.mdx index 7575adb4..f99acd6f 100644 --- a/docs/models/index.mdx +++ b/docs/models/index.mdx @@ -1,15 +1,15 @@ --- title: Models Overview -description: Model vendors supported by CowAgent and their capability matrix +description: Model providers supported by CowAgent and their capability matrix --- -CowAgent supports a wide range of mainstream large language models. Model interfaces live under the project's `models/` directory. Beyond text chat, several vendors also provide vision understanding, image generation, speech-to-text, text-to-speech, and embeddings — all of which can be invoked on demand in the Agent flow. +CowAgent supports a wide range of mainstream large language models. Model interfaces live under the project's `models/` directory. Beyond text chat, several providers also provide vision understanding, image generation, speech-to-text, text-to-speech, and embeddings — all of which can be invoked on demand in the Agent flow. ## Capability Matrix -A snapshot of each vendor's capabilities. "Text" refers to the main chat model; the remaining columns show which Agent capabilities the vendor can power. +A snapshot of each provider's capabilities. "Text" refers to the main chat model; the remaining columns show which Agent capabilities the provider can power. -| Vendor | Representative Models | Text | Vision | Image Gen | STT | TTS | Embedding | +| Provider | Representative Models | Text | Vision | Image Gen | STT | TTS | Embedding | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | | [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | @@ -22,11 +22,11 @@ A snapshot of each vendor's capabilities. "Text" refers to the main chat model; | [Kimi](/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | | [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | | [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | | -| [LinkAI](/models/linkai) | 100+ models from multiple vendors | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [LinkAI](/models/linkai) | 100+ models from multiple providers | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Custom](/models/custom) | Local models / third-party proxies | ✅ | | | | | | - Every capability in the Web console (Vision / Image / STT / TTS / Embedding / Web Search) can be configured independently with its own vendor and model — there is no forced binding between them. + Every capability in the Web console (Vision / Image / STT / TTS / Embedding / Web Search) can be configured independently with its own provider and model — there is no forced binding between them. ## How to Configure @@ -35,4 +35,4 @@ A snapshot of each vendor's capabilities. "Text" refers to the main chat model; -**Option 2:** Edit `config.json` manually and fill in the model name and API key for the selected vendor. Every model also supports OpenAI-compatible access — just set `bot_type` to `openai` and configure `open_ai_api_base` and `open_ai_api_key`. +**Option 2:** Edit `config.json` manually and fill in the model name and API key for the selected provider. Every model also supports OpenAI-compatible access — just set `bot_type` to `openai` and configure `open_ai_api_base` and `open_ai_api_key`. diff --git a/docs/models/linkai.mdx b/docs/models/linkai.mdx index e078a945..3610a1a1 100644 --- a/docs/models/linkai.mdx +++ b/docs/models/linkai.mdx @@ -3,7 +3,7 @@ title: LinkAI description: Access text, vision, image, speech, and embedding capabilities through the LinkAI platform --- -A single `linkai_api_key` gives you access to all capabilities of mainstream vendors such as OpenAI, Claude, Gemini, DeepSeek, MiniMax, Qwen, Kimi, and Doubao. +A single `linkai_api_key` gives you access to all capabilities of mainstream providers such as OpenAI, Claude, Gemini, DeepSeek, MiniMax, Qwen, Kimi, and Doubao. All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. diff --git a/docs/models/mimo.mdx b/docs/models/mimo.mdx index 6f808b8e..038c1fff 100644 --- a/docs/models/mimo.mdx +++ b/docs/models/mimo.mdx @@ -49,7 +49,7 @@ Use the global `enable_thinking` flag to toggle visibility (also switchable from Once `mimo_api_key` is configured, the Agent's Vision tool can automatically use MiMo's vision models: - When the main model itself is multimodal (`mimo-v2.5-pro` / `mimo-v2.5`), images are handled directly by the main model with no extra setup. -- When the main model belongs to another vendor, the Vision tool falls back to `mimo-v2.5-pro` in order. +- When the main model belongs to another provider, the Vision tool falls back to `mimo-v2.5-pro` in order. To force a specific Vision model, set it explicitly in the configuration: diff --git a/docs/models/openai.mdx b/docs/models/openai.mdx index f8715562..7d428d95 100644 --- a/docs/models/openai.mdx +++ b/docs/models/openai.mdx @@ -25,7 +25,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat, | `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, the o-series, etc. Agent mode defaults to `gpt-5.5`; use `gpt-5.4` for better cost-efficiency | | `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) | | `open_ai_api_base` | Optional; change it to access a third-party proxy | -| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other vendors via the compatible protocol | +| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other providers via the compatible protocol | ## Image Understanding diff --git a/docs/models/qwen.mdx b/docs/models/qwen.mdx index 76a419fe..60965131 100644 --- a/docs/models/qwen.mdx +++ b/docs/models/qwen.mdx @@ -3,7 +3,7 @@ title: Qwen description: Qwen model configuration (Text / Image Understanding / Image Generation / Speech-to-Text / Text-to-Speech / Embedding) --- -Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured vendors. Text, image understanding, image generation, speech-to-text, text-to-speech, and embedding can all be enabled with a single `dashscope_api_key`. +Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured providers. Text, image understanding, image generation, speech-to-text, text-to-speech, and embedding can all be enabled with a single `dashscope_api_key`. All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. diff --git a/docs/releases/v2.0.3.mdx b/docs/releases/v2.0.3.mdx index 5f9a837d..da68ccfd 100644 --- a/docs/releases/v2.0.3.mdx +++ b/docs/releases/v2.0.3.mdx @@ -34,7 +34,7 @@ Related commits: [30c6d9b](https://github.com/zhayujie/CowAgent/commit/30c6d9b) ## 💰 Coding Plan Support -Added integration with vendor Coding Plan (monthly programming subscription) tiers via the unified OpenAI-compatible path. Supported vendors include Aliyun, MiniMax, GLM, Kimi, and Volcengine. +Added integration with provider Coding Plan (monthly programming subscription) tiers via the unified OpenAI-compatible path. Supported providers include Aliyun, MiniMax, GLM, Kimi, and Volcengine. See [Coding Plan docs](https://docs.cowagent.ai/en/models/coding-plan) for detailed configuration. diff --git a/docs/releases/v2.0.7.mdx b/docs/releases/v2.0.7.mdx index 522e5339..a843bea5 100644 --- a/docs/releases/v2.0.7.mdx +++ b/docs/releases/v2.0.7.mdx @@ -22,7 +22,7 @@ Docs: [Image Generation Skill](https://docs.cowagent.ai/en/skills/image-generati - **Claude Opus 4.7**: Added `claude-opus-4-7` model support - **GLM 5.1**: Added `glm-5.1` model support - **Kimi Coding Plan**: Support for Kimi Coding Plan mode -- **Custom model providers**: New custom model provider configuration for easier integration with additional vendors +- **Custom model providers**: New custom model provider configuration for easier integration with additional providers ## 💬 Web Console Improvements diff --git a/docs/zh/models/custom.mdx b/docs/zh/models/custom.mdx index b63f4e34..79709247 100644 --- a/docs/zh/models/custom.mdx +++ b/docs/zh/models/custom.mdx @@ -1,69 +1,31 @@ --- -title: Custom -description: Custom vendor configuration for third-party API proxies and local models +title: 自定义 +description: 自定义厂商配置,用于第三方 API 代理与本地模型 --- -For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as: +适用于通过 OpenAI 兼容协议接入的模型服务,例如: -- **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 +- **第三方 API 代理**:通过统一的 API 地址调用多种模型 +- **本地模型**:使用 Ollama、vLLM 等工具在本地部署的模型 +- **私有化部署**:企业内部部署的模型服务 - - 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. - +## Web 端配置 -## Text Chat +推荐方式。在 Web 控制台「模型」页面点击「添加厂商」,选择「自定义」,填写名称、API Base 和 API Key 即可。支持添加多个自定义厂商,添加后在「主模型」中选择对应厂商和模型即可启用。 -### Third-party API proxy + -```json -{ - "bot_type": "custom", - "model": "", - "custom_api_key": "YOUR_API_KEY", - "custom_api_base": "https://{your-proxy.com}/v1" -} -``` +本地部署工具的默认地址: -| Parameter | Description | -| --- | --- | -| `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 - -Local models usually do not require an API key — only the API base needs to be filled in: - -```json -{ - "bot_type": "custom", - "model": "qwen3.5:27b", - "custom_api_base": "http://localhost:11434/v1" -} -``` - -Common local deployment tools and their default endpoints: - -| Tool | Default API Base | +| 工具 | 默认 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 +## 配置文件配置 -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:"`: +也可以直接编辑 `config.json`,在 `custom_providers` 列表中定义多个厂商,并将 `bot_type` 设置为 `"custom:"` 来激活其中一个: ```json { @@ -71,34 +33,30 @@ If you need to configure several OpenAI-compatible third-party services at once "custom_providers": [ { "id": "3f2a9c1b", - "name": "siliconflow", - "api_key": "YOUR_SILICONFLOW_KEY", - "api_base": "https://api.siliconflow.cn/v1", - "model": "deepseek-ai/DeepSeek-V3" + "name": "厂商A", + "api_key": "YOUR_API_KEY_A", + "api_base": "https://api.a.com/v1", + "model": "deepseek-v3" }, { "id": "a1b2c3d4", - "name": "qiniu", - "api_key": "YOUR_QINIU_KEY", - "api_base": "https://api.qnaigc.com/v1", - "model": "deepseek-v3" + "name": "厂商B", + "api_key": "YOUR_API_KEY_B", + "api_base": "https://api.b.com/v1", + "model": "qwen3-max" } ] } ``` -| 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 | +| `custom_providers` | 自定义厂商列表,每项包含 `id`、`name`、`api_base`、`api_key`(可选)、`model`(可选) | +| `bot_type` | 设置为 `"custom:"` 激活对应厂商 | +| `id` | 厂商唯一标识(8 位十六进制),在 Web 端添加时自动生成,手动配置时填写任意不重复的标识即可 | +| `name` | 展示名称,可随意修改 | +| `model` | 该厂商使用的模型,激活时生效 | - 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. + 历史的单厂商配置(`bot_type` 为 `"custom"`,配合 `custom_api_key` / `custom_api_base`)仍然兼容,无需改动即可继续使用。 From 583c1de5baa704f6ae6a9c6dd1bf2f31ee2d3625 Mon Sep 17 00:00:00 2001 From: sufan721 Date: Sat, 13 Jun 2026 16:50:54 +0800 Subject: [PATCH 11/16] =?UTF-8?q?=20=20-=20Auto-scan=20roles/*.json=20on?= =?UTF-8?q?=20startup=20and=20merge=20into=20built-in=20roles=20=20=20-=20?= =?UTF-8?q?Same=20title=20overrides=20built-in=20role;=20different=20title?= =?UTF-8?q?=20appends=20as=20new=20=20=20-=20roles/=20directory=20is=20opt?= =?UTF-8?q?ional=20=E2=80=94=20no=20impact=20when=20absent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now add custom roles by simply dropping a .json file into the roles/ directory and restarting. No config changes needed. --- plugins/role/README.md | 53 +++++++++++++++++++++++++++++++++++------- plugins/role/role.py | 29 +++++++++++++++++++++++ 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/plugins/role/README.md b/plugins/role/README.md index f53e9575..25f7640c 100644 --- a/plugins/role/README.md +++ b/plugins/role/README.md @@ -4,19 +4,41 @@ - `$角色/$role <角色名>` - 让AI扮演该角色,角色名支持模糊匹配。 - `$停止扮演` - 停止角色扮演。 -添加自定义角色请在`roles/roles.json`中添加。 +## 目录结构 + +``` +plugins/role/ +├── README.md +├── __init__.py +├── role.py # 插件主逻辑 +├── roles.json # 内置角色库(所有默认角色) +├── schema/ +│ └── role_template.json # 角色模板,新增角色时复制此文件 +└── roles/ # 可选:放自定义角色文件,自动加载 + └── 我的角色.json # 同title会覆盖内置角色,不同则追加 +``` + +## 添加自定义角色 + +1. 新建roles目录 +2. 复制 下面模板 到 `roles/` 目录并重命名(如 `我的角色.json`)。 +3. 编辑文件内容,填写角色信息。 +4. 重启Bot即可生效,插件会自动扫描 `roles/` 目录下的所有 `.json` 文件。 + +> **覆盖规则**:如果自定义角色的 `title` 与内置角色同名,则覆盖内置角色;否则作为新角色追加。无需修改任何映射文件。 (大部分prompt来自https://github.com/rockbenben/ChatGPT-Shortcut/blob/main/src/data/users.tsx) -以下为例子: +以下为角色文件内容例子: ```json - { - "title": "写作助理", - "description": "As a writing improvement assistant, your task is to improve the spelling, grammar, clarity, concision, and overall readability of the text I provided, while breaking down long sentences, reducing repetition, and providing suggestions for improvement. Please provide only the corrected Chinese version of the text and avoid including explanations. Please treat every message I send later as text content.", - "descn": "作为一名中文写作改进助理,你的任务是改进所提供文本的拼写、语法、清晰、简洁和整体可读性,同时分解长句,减少重复,并提供改进建议。请只提供文本的更正版本,避免包括解释。请把我之后的每一条消息都当作文本内容。", - "wrapper": "内容是:\n\"%s\"", - "remark": "最常使用的角色,用于优化文本的语法、清晰度和简洁度,提高可读性。" - } +{ + "title": "写作助理", + "description": "As a writing improvement assistant...", + "descn": "作为一名中文写作改进助理...", + "wrapper": "内容是:\n\"%s\"", + "remark": "最常使用的角色,用于优化文本的语法、清晰度和简洁度,提高可读性。", + "tags": ["favorite", "write"] +} ``` - `title`: 角色名。 @@ -24,3 +46,16 @@ - `descn`: 使用`$角色`触发时,使用中文prompt。 - `wrapper`: 用于包装用户消息,可起到强调作用,避免回复离题。 - `remark`: 简短描述该角色,在打印帮助文档时显示。 +- `tags`: 角色分类标签,可用`$角色类型 <标签>`查看。 + + +```json +{ + "title": "", + "description": "", + "descn": "", + "wrapper": "", + "remark": "", + "tags": [] +} +``` \ No newline at end of file diff --git a/plugins/role/role.py b/plugins/role/role.py index e23ff361..01739fe9 100644 --- a/plugins/role/role.py +++ b/plugins/role/role.py @@ -57,6 +57,14 @@ class Role(Plugin): logger.warning(f"[Role] unknown tag {tag} ") self.tags[tag] = (tag, []) self.tags[tag][1].append(role) + + # 扫描 roles/ 目录,加载可选的扩展/覆盖角色文件 + roles_dir = os.path.join(curdir, "roles") + if os.path.isdir(roles_dir): + for fname in sorted(os.listdir(roles_dir)): + if fname.endswith(".json"): + self._load_role_file(os.path.join(roles_dir, fname)) + for tag in list(self.tags.keys()): if len(self.tags[tag][1]) == 0: logger.debug(f"[Role] no role found for tag {tag} ") @@ -74,6 +82,27 @@ class Role(Plugin): logger.warn("[Role] init failed, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/role .") raise e + def _load_role_file(self, filepath): + """从roles/目录加载单个角色文件,同title则覆盖内置角色,否则追加""" + try: + with open(filepath, "r", encoding="utf-8") as f: + role = json.load(f) + title_lower = role["title"].lower() + if title_lower in self.roles: + old_tags = self.roles[title_lower].get("tags", []) + for tag in old_tags: + if tag in self.tags: + self.tags[tag][1] = [r for r in self.tags[tag][1] if r["title"].lower() != title_lower] + logger.info(f"[Role] overridden: {role['title']}") + self.roles[title_lower] = role + for tag in role.get("tags", []): + if tag not in self.tags: + self.tags[tag] = (tag, []) + self.tags[tag][1].append(role) + logger.debug(f"[Role] loaded: {role['title']} from {os.path.basename(filepath)}") + except Exception as e: + logger.warn(f"[Role] failed to load {filepath}: {e}") + def get_role(self, name, find_closest=True, min_sim=0.35): name = name.lower() found_role = None From 6538843bdfb359d6c078134d65567d159a0f15b0 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 14 Jun 2026 11:06:25 +0800 Subject: [PATCH 12/16] fix(memory): remove max_tokens cap in deep dream distillation --- agent/memory/summarizer.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/agent/memory/summarizer.py b/agent/memory/summarizer.py index 9da349d1..066549dc 100644 --- a/agent/memory/summarizer.py +++ b/agent/memory/summarizer.py @@ -462,13 +462,12 @@ class MemoryFlushManager: daily_content=daily_content or "(no recent daily records)", ) from agent.protocol.models import LLMRequest - # Scale max_tokens based on input size to avoid truncating large MEMORY.md - input_chars = len(memory_content) + len(daily_content) - dream_max_tokens = max(2000, min(input_chars, 8000)) + # No output cap: the prompt already keeps MEMORY.md concise (~50 + # items), so a hard max_tokens would only risk truncating a large + # rewrite. Let the model use its default output budget. request = LLMRequest( messages=[{"role": "user", "content": user_msg}], temperature=0.3, - max_tokens=dream_max_tokens, stream=False, system=_dream_system_prompt(), ) From 7d63e7d8fa4cdd983409ffc0f617d057f73b183e Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 14 Jun 2026 17:20:41 +0800 Subject: [PATCH 13/16] feat(cli): add agent self-restart command --- cli/cli.py | 3 +- cli/commands/process.py | 114 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/cli/cli.py b/cli/cli.py index 99b837ab..b266b372 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -3,7 +3,7 @@ import click from cli import __version__ from cli.commands.skill import skill -from cli.commands.process import start, stop, restart, update, status, logs +from cli.commands.process import start, stop, restart, self_restart, update, status, logs from cli.commands.context import context from cli.commands.install import install_browser from cli.commands.knowledge import knowledge @@ -68,6 +68,7 @@ main.add_command(skill) main.add_command(start) main.add_command(stop) main.add_command(restart) +main.add_command(self_restart) main.add_command(update) main.add_command(status) main.add_command(logs) diff --git a/cli/commands/process.py b/cli/commands/process.py index d6b1aaf4..c7f98448 100644 --- a/cli/commands/process.py +++ b/cli/commands/process.py @@ -195,6 +195,120 @@ def restart(ctx, no_logs): ctx.invoke(start, no_logs=no_logs) +# Detached relay that survives the caller's process tree. Run via `python -c` +# with start_new_session=True so it keeps going after the agent's bash child +# (and the main app it kills) both die. Flow: self-check the new code FIRST +# (import app); abort without touching the old process if it fails. Only when +# the new code is loadable does it SIGTERM the old PID, wait for exit (SIGKILL +# fallback), then start a fresh app.py and write the pid. +_RELAY_SCRIPT = r""" +import os, sys, time, signal, subprocess + +root, python, app_py, pid_file, log_file = sys.argv[1:6] +old_pid = int(sys.argv[6]) if len(sys.argv) > 6 and sys.argv[6] else 0 + + +def alive(pid): + if not pid: + return False + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def log(msg): + try: + with open(log_file, "a") as f: + f.write("[self-restart] " + msg + "\n") + except OSError: + pass + + +# 0) Self-check: make sure the new code actually loads BEFORE killing anything. +# `import app` exercises top-level imports / syntax of the entry module. If it +# fails, abort and leave the running service untouched — never end up with the +# old process killed and the new one unable to start. +check = subprocess.run( + [python, "-c", "import app"], cwd=root, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, +) +if check.returncode != 0: + detail = (check.stdout or b"").decode("utf-8", "replace").strip() + log("self-check FAILED, aborting restart; service left running:\n" + detail) + sys.exit(1) +log("self-check passed") + +# 1) Ask the old process to exit gracefully (its SIGTERM handler persists state). +if alive(old_pid): + try: + os.kill(old_pid, signal.SIGTERM) + except OSError: + pass + # 2) Wait up to ~15s for it to go, then force-kill as a backstop. + for _ in range(150): + if not alive(old_pid): + break + time.sleep(0.1) + else: + try: + os.kill(old_pid, signal.SIGKILL) + except OSError: + pass + time.sleep(0.5) + +# 3) Start a fresh instance, detached, logging to the same file. +with open(log_file, "a") as f: + proc = subprocess.Popen( + [python, app_py], cwd=root, + stdout=f, stderr=f, start_new_session=True, + ) +with open(pid_file, "w") as f: + f.write(str(proc.pid)) +log("restarted, new pid=" + str(proc.pid)) +""" + + +@click.command(name="self-restart", hidden=True) +def self_restart(): + """Restart from inside the running agent (detached; survives parent death). + + Intended to be invoked by the agent itself (e.g. via bash after editing its + own code), not by users — so it is hidden from `cow help`. Unlike `restart`, + the actual stop+start runs in a detached relay process that outlives the + agent's bash child, which would otherwise die together with the main app it + kills. + """ + if _IS_WIN: + click.echo("self-restart is not supported on Windows; use `cow restart`.", err=True) + sys.exit(1) + + root = get_project_root() + app_py = os.path.join(root, "app.py") + if not os.path.exists(app_py): + click.echo("Error: app.py not found in project root.", err=True) + sys.exit(1) + + python = sys.executable + pid = _read_pid() or 0 + + subprocess.Popen( + [ + python, "-c", _RELAY_SCRIPT, + root, python, app_py, _get_pid_file(), _get_log_file(), str(pid), + ], + cwd=root, + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + click.echo(click.style( + "✓ Restart scheduled. The service will stop and come back in a few seconds.", + fg="green", + )) + + @click.command() @click.pass_context def update(ctx): From ab674a3517ca5e15a2b3206e7bb2551fa551e353 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 14 Jun 2026 17:22:41 +0800 Subject: [PATCH 14/16] fix(docs): architecture graph link --- README.md | 2 +- docs/intro/architecture.mdx | 2 +- docs/ja/README.md | 2 +- docs/ja/intro/architecture.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4c91f1cf..4978a30c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major ## 🏗️ Architecture -CowAgent Architecture +CowAgent Architecture CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible. diff --git a/docs/intro/architecture.mdx b/docs/intro/architecture.mdx index a9797bf1..77fdbc8a 100644 --- a/docs/intro/architecture.mdx +++ b/docs/intro/architecture.mdx @@ -9,7 +9,7 @@ CowAgent 2.0 has evolved from a simple chatbot into a super intelligent assistan CowAgent's architecture consists of the following core modules: -CowAgent Architecture +CowAgent Architecture | Module | Description | | --- | --- | diff --git a/docs/ja/README.md b/docs/ja/README.md index afe51342..1ae5cb84 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -48,7 +48,7 @@ CowAgent は軽量でデプロイしやすく、拡張性に優れています ## 🏗️ アーキテクチャ -CowAgent Architecture +CowAgent Architecture CowAgent は完全な **Agent Harness** です:メッセージは各種**チャネル**から流入し、**Agent Core** が記憶・知識・利用可能なツール/Skill を組み合わせてタスクを計画・判断、**モデル**が応答を生成し、結果は元のチャネルに返されます。各レイヤーは疎結合で、独立して拡張可能です。 diff --git a/docs/ja/intro/architecture.mdx b/docs/ja/intro/architecture.mdx index 5e8b202b..682b407e 100644 --- a/docs/ja/intro/architecture.mdx +++ b/docs/ja/intro/architecture.mdx @@ -9,7 +9,7 @@ CowAgent 2.0 は、シンプルなチャットボットから、自律的な思 CowAgent のアーキテクチャは以下のコアモジュールで構成されています: -CowAgent Architecture +CowAgent Architecture | モジュール | 説明 | | --- | --- | From d281a34c6fecea84262f2963f5dded820eea8bf9 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 14 Jun 2026 21:06:41 +0800 Subject: [PATCH 15/16] fix(models): demote claude-fable-5 from Claude default --- channel/web/web_channel.py | 9 ++++----- common/const.py | 4 ++-- run.sh | 4 ++-- scripts/run.ps1 | 4 ++-- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index a290bf31..29abaa6b 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1470,10 +1470,9 @@ class ConfigHandler: _RECOMMENDED_MODELS = [ const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO, const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7, - # claude-fable-5 is intentionally placed at the end of the Claude - # group here: it is expensive, so avoid surfacing it too early in - # the LinkAI dropdown. - const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_FABLE_5, + # claude-fable-5 is placed after claude-opus-4-7 (not as the Claude + # default) since it is often unavailable due to policy restrictions. + const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7, @@ -1518,7 +1517,7 @@ class ConfigHandler: "api_base_key": "claude_api_base", "api_base_default": "https://api.anthropic.com/v1", "api_base_placeholder": _PLACEHOLDER_V1, - "models": [const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS], + "models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS], }), ("gemini", { "label": "Gemini", diff --git a/common/const.py b/common/const.py index a7dde568..4a28c226 100644 --- a/common/const.py +++ b/common/const.py @@ -30,7 +30,7 @@ CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # "latest" tag always points to t CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620" CLAUDE_4_OPUS = "claude-opus-4-0" -CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 - Agent recommended model +CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 (often restricted by policy) CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7 CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6 @@ -194,7 +194,7 @@ MODEL_LIST = [ MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH, # Claude - CLAUDE3, CLAUDE_FABLE_5, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229, + CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_FABLE_5, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229, CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU, "claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet", diff --git a/run.sh b/run.sh index 3c15b0f0..bfe45630 100755 --- a/run.sh +++ b/run.sh @@ -597,7 +597,7 @@ select_model() { # The 12th option is "skip" -> configure later in the web console. select_menu sel "$title" \ "DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" \ - "Claude (claude-fable-5, claude-opus-4-8, etc.)" \ + "Claude (claude-opus-4-8, claude-fable-5, etc.)" \ "Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)" \ "OpenAI (gpt-5.5, etc.)" \ "MiniMax (MiniMax-M3, etc.)" \ @@ -629,7 +629,7 @@ read_model_config() { configure_model() { case "$model_choice" in 1) read_model_config "DeepSeek" "deepseek-v4-flash" "DEEPSEEK_KEY" ;; - 2) read_model_config "Claude" "claude-fable-5" "CLAUDE_KEY" ;; + 2) read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" ;; 3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;; 4) read_model_config "OpenAI" "gpt-5.5" "OPENAI_KEY" ;; 5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_KEY" ;; diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 2386dd87..a9a0673e 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -423,7 +423,7 @@ function Install-Dependencies { # Each entry: Provider / default model name / config key field / optional base. $ModelChoices = @{ 1 = @{ Provider = "DeepSeek"; Default = "deepseek-v4-flash"; Field = "deepseek_api_key" } - 2 = @{ Provider = "Claude"; Default = "claude-fable-5"; Field = "claude_api_key"; BaseField = "claude_api_base" } + 2 = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Field = "claude_api_key"; BaseField = "claude_api_base" } 3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" } 4 = @{ Provider = "OpenAI"; Default = "gpt-5.5"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" } 5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" } @@ -440,7 +440,7 @@ function Select-Model { $title = T "选择 AI 模型" "Select AI Model" $options = @( "DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)", - "Claude (claude-fable-5, claude-opus-4-8, etc.)", + "Claude (claude-opus-4-8, claude-fable-5, etc.)", "Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)", "OpenAI (gpt-5.5, etc.)", "MiniMax (MiniMax-M3, etc.)", From e2cb9e11b00cc7f9547e6de96ac7d0e9e0af69ef Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 15 Jun 2026 11:50:41 +0800 Subject: [PATCH 16/16] fix(install): avoid greenlet source build on Windows & guide browser tool install --- cli/commands/install.py | 23 ++++++++++++++++++++--- common/cloud_client.py | 23 +++++++++++++++++++++++ run.sh | 3 +++ scripts/run.ps1 | 1 + 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/cli/commands/install.py b/cli/commands/install.py index b72bcba1..442694d3 100644 --- a/cli/commands/install.py +++ b/cli/commands/install.py @@ -78,12 +78,13 @@ def _is_china_network() -> bool: def _pip_install(package_spec: str, stream: StreamFn) -> int: - """Install a package, retrying with --user on permission failure.""" + """Install a package, preferring prebuilt wheels; retry with --user on perm error.""" python = sys.executable - ret = subprocess.call([python, "-m", "pip", "install", package_spec]) + base = [python, "-m", "pip", "install", "--prefer-binary"] + ret = subprocess.call(base + [package_spec]) if ret != 0: stream(" Retrying with --user flag...", "yellow") - ret = subprocess.call([python, "-m", "pip", "install", "--user", package_spec]) + ret = subprocess.call(base + ["--user", package_spec]) return ret @@ -155,6 +156,22 @@ def run_install_browser( target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION + # Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it + # from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for + # py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4. + if sys.platform == "win32": + stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow") + ret = subprocess.call( + [python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"] + ) + if ret != 0: + stream( + " Could not pre-install a prebuilt greenlet wheel.\n" + " playwright may try to build greenlet from source, which needs\n" + " Microsoft C++ Build Tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/", + "yellow", + ) + _phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…")) stream("[1/3] Installing playwright Python package...", "yellow") ret = _pip_install(f"playwright=={target_version}", stream) diff --git a/common/cloud_client.py b/common/cloud_client.py index b260c410..c66eecb5 100644 --- a/common/cloud_client.py +++ b/common/cloud_client.py @@ -172,6 +172,11 @@ class CloudClient(LinkAIClient): if key in available_setting and config.get(key) is not None: local_config[key] = config.get(key) + # Self-evolution switch: normalize remote value (bool / "Y"/"N" / "true") + # to a real bool so the evolution config parser reads it correctly. + if config.get("self_evolution_enabled") is not None: + local_config["self_evolution_enabled"] = self._to_bool(config.get("self_evolution_enabled")) + # Voice settings reply_voice_mode = config.get("reply_voice_mode") if reply_voice_mode: @@ -341,6 +346,20 @@ class CloudClient(LinkAIClient): except Exception as e: logger.warning(f"[CloudClient] Failed to remove weixin credentials: {e}") + # ------------------------------------------------------------------ + # value helpers + # ------------------------------------------------------------------ + @staticmethod + def _to_bool(value) -> bool: + """Normalize a remote config value to bool (bool / "Y"/"N" / "true"/"1").""" + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + if isinstance(value, str): + return value.strip().lower() in ("y", "yes", "true", "1", "on") + return False + # ------------------------------------------------------------------ # channel credentials helpers # ------------------------------------------------------------------ @@ -855,6 +874,10 @@ def _build_config(): "agent_max_context_turns": local_conf.get("agent_max_context_turns"), "agent_max_context_tokens": local_conf.get("agent_max_context_tokens"), "agent_max_steps": local_conf.get("agent_max_steps"), + # Self-evolution switch reported so the cloud console can reflect state + "self_evolution_enabled": "Y" if local_conf.get("self_evolution_enabled") else "N", + "self_evolution_idle_minutes": local_conf.get("self_evolution_idle_minutes"), + "self_evolution_min_turns": local_conf.get("self_evolution_min_turns"), "channelType": local_conf.get("channel_type"), } diff --git a/run.sh b/run.sh index bfe45630..769273b5 100755 --- a/run.sh +++ b/run.sh @@ -980,7 +980,10 @@ start_project() { echo -e " ${GREEN}./run.sh status${NC} $(t "查看状态" "Check status")" echo -e " ${GREEN}./run.sh logs${NC} $(t "查看日志" "View logs")" echo -e " ${GREEN}./run.sh update${NC} $(t "更新并重启" "Update and restart")" + echo -e " ${GREEN}cow install-browser${NC} $(t "安装浏览器工具" "Install browser tool")" fi + echo "" + echo -e "${YELLOW}$(t "提示:需要让 Agent 浏览网页时,运行 cow install-browser 安装浏览器工具" "Tip: to let the Agent browse the web, run 'cow install-browser' to install the browser tool")${NC}" echo -e "${CYAN}${BOLD}=========================================${NC}" echo "" diff --git a/scripts/run.ps1 b/scripts/run.ps1 index a9a0673e..5b6c1daa 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -748,6 +748,7 @@ function Install-Mode { # Auto-start after configuration for a true out-of-the-box experience. Write-Host "" if ($script:AccessInfo) { Write-Cow $script:AccessInfo } + Write-Warn (T "提示:需要让 Agent 浏览网页时,运行 cow install-browser 安装浏览器工具" "Tip: to let the Agent browse the web, run 'cow install-browser' to install the browser tool") Start-CowAgent }