Files
chatgpt-on-wechat/models/custom_provider.py
kirs-hi cffa590d3e 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:<name>`, 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.
2026-06-10 18:12:30 +08:00

85 lines
3.0 KiB
Python

# 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,
)