mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
refactor(custom): id-based routing, single source of truth, security fixes
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:<id>'. 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
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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:<id>"
|
||||
# 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")
|
||||
|
||||
@@ -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:<id>"`` (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:<id>"``, 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", ""),
|
||||
|
||||
Reference in New Issue
Block a user