From 1940d628a82b307786722402b69d0d2e350d06a5 Mon Sep 17 00:00:00 2001 From: kirs-hi Date: Thu, 11 Jun 2026 17:25:24 +0800 Subject: [PATCH] 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__":