mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +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:
170
tests/test_custom_provider.py
Normal file
170
tests/test_custom_provider.py
Normal file
@@ -0,0 +1,170 @@
|
||||
# encoding:utf-8
|
||||
"""
|
||||
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)
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import config as config_module
|
||||
from config import Config
|
||||
|
||||
|
||||
def set_conf(d):
|
||||
"""Install a fresh Config as the global config used by conf()."""
|
||||
config_module.config = Config(d)
|
||||
|
||||
|
||||
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
|
||||
|
||||
# --- Backward compatibility ---
|
||||
|
||||
def test_legacy_fallback_when_no_providers(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_api_key": "legacy-key",
|
||||
"custom_api_base": "https://legacy.example.com/v1",
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("legacy-key", "https://legacy.example.com/v1", None),
|
||||
)
|
||||
|
||||
def test_empty_config(self):
|
||||
set_conf({"bot_type": "custom"})
|
||||
self.assertEqual(self.resolve(), ("", None, None))
|
||||
|
||||
# --- Multi-provider selection ---
|
||||
|
||||
def test_multi_providers_no_active_uses_first(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_providers": [
|
||||
{"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",
|
||||
"api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"},
|
||||
],
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("qn-key", "https://api.qnaigc.com/v1", "deepseek-v3"),
|
||||
)
|
||||
|
||||
def test_active_name_missing_falls_back_to_first(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_active_provider": "ghost",
|
||||
"custom_providers": [
|
||||
{"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),
|
||||
)
|
||||
|
||||
def test_provider_without_model_returns_none_model(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_providers": [
|
||||
{"name": "local", "api_key": "", "api_base": "http://localhost:11434/v1"},
|
||||
],
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("", "http://localhost:11434/v1", None),
|
||||
)
|
||||
|
||||
# --- Robustness against malformed config ---
|
||||
|
||||
def test_malformed_entries_filtered_and_fallback(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"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
|
||||
],
|
||||
})
|
||||
# All entries invalid -> treated as empty -> legacy fallback
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("legacy-key", "https://legacy.example.com/v1", None),
|
||||
)
|
||||
|
||||
def test_get_custom_providers_filters_invalid(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_providers": [
|
||||
{"name": "ok", "api_key": "k", "api_base": "https://x/v1"},
|
||||
{"api_key": "no-name"}, # dropped
|
||||
123, # dropped
|
||||
],
|
||||
})
|
||||
providers = self.get_providers()
|
||||
self.assertEqual(len(providers), 1)
|
||||
self.assertEqual(providers[0]["name"], "ok")
|
||||
|
||||
def test_custom_providers_not_a_list_falls_back(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_api_key": "legacy-key",
|
||||
"custom_api_base": "https://legacy.example.com/v1",
|
||||
"custom_providers": "oops-a-string",
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("legacy-key", "https://legacy.example.com/v1", None),
|
||||
)
|
||||
|
||||
|
||||
class TestConfigDefaults(unittest.TestCase):
|
||||
"""The new config fields must exist with safe defaults."""
|
||||
|
||||
def test_default_config_has_custom_providers(self):
|
||||
from config import available_setting
|
||||
self.assertIn("custom_providers", available_setting)
|
||||
self.assertEqual(available_setting["custom_providers"], [])
|
||||
|
||||
def test_default_config_has_active_provider(self):
|
||||
from config import available_setting
|
||||
self.assertIn("custom_active_provider", available_setting)
|
||||
self.assertEqual(available_setting["custom_active_provider"], "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user