mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-18 20:17:09 +08:00
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.
This commit is contained in:
@@ -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 = [
|
||||
|
||||
84
models/custom_provider.py
Normal file
84
models/custom_provider.py
Normal file
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user