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:
kirs-hi
2026-06-10 18:12:30 +08:00
parent f5caba81d6
commit cffa590d3e
11 changed files with 1262 additions and 17 deletions

View File

@@ -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 = [