From bb45d9cb54ee47df142acff9a0c9c9cdc14b4e6d Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sat, 13 Dec 2025 17:16:07 +0800 Subject: [PATCH 01/26] stage --- PROVIDER_REFACTOR_INSTRUCTION.md | 56 + astrbot/core/config/default.py | 128 +- astrbot/core/provider/manager.py | 19 + .../core/provider/sources/anthropic_source.py | 12 +- astrbot/core/utils/migra_helper.py | 92 + astrbot/dashboard/routes/config.py | 93 +- .../src/components/shared/AstrBotConfig.vue | 1 + .../i18n/locales/en-US/features/provider.json | 37 + .../i18n/locales/zh-CN/features/provider.json | 37 + dashboard/src/views/ProviderPage.vue | 1613 +++++++++-------- dashboard/src/views/ProviderPage.vue.backup | 892 +++++++++ 11 files changed, 2156 insertions(+), 824 deletions(-) create mode 100644 PROVIDER_REFACTOR_INSTRUCTION.md create mode 100644 dashboard/src/views/ProviderPage.vue.backup diff --git a/PROVIDER_REFACTOR_INSTRUCTION.md b/PROVIDER_REFACTOR_INSTRUCTION.md new file mode 100644 index 000000000..73fbacdbb --- /dev/null +++ b/PROVIDER_REFACTOR_INSTRUCTION.md @@ -0,0 +1,56 @@ +总览:对于 chat_completion 的提供商,目前需要将 provider 分成 provider_source 和 provider 两个部分,这样可以更好地支持同一 provider_source 下的多模型添加。 + +目前已经在 astrbot/core/config/default.py 中对 provider 配置进行了拆分,接下来需要修改: + +## provider/manager 部分: + +需要传入 provider_sources 给 ProviderManager,然后在创建 provider 的时候: + +1. 如果 provider.provider_source_id 存在且不为空,则从 provider_sources 中找到对应的 provider_source,并将 provider_source 的配置合并到 provider_config 中。provider 的配置优先级更高。 +2. 如果 provider.provider_source_id 不存在或为空,则使用 provider 自身的配置。 + +## WebUI 部分: + +我们主要需要大量修改 ProviderPage.vue 文件。 + +将整体页面换成左右多栏布局。 + +分为 三个 栏目: + +1. 左第一个栏目用于选择 provider type。 +2. 中间第二个栏目用于选择 provider source。 +3. 右第三个栏目用于配置 provider。 + +中间的栏目最上面有一个添加按钮,点击后会出现一个 dropdown,列出所有的 provider source type,选择后会在中间栏目添加一个 provider source。 + +选中某个 provider source 后,右侧栏目会显示该 provider source 的配置。指向这个 provider source 的 provider 本质上是 model 的配置。在 provider source 配置下面,会有一个“获取模型”的按钮(如果没有保存/更新 source 配置,则写“保存并获取模型”),点击后会在下方有一个 v-list 列出该 provider source 支持的所有模型,选择后会在下方增加一个新的模型配置(也就是provider的配置)。整个过程从设计上看不要有任何 dialog。 + +有一个保存按钮,用于保存当前 provider source 和 provider 的配置。 + +### 模型列表的获取 + +需要增加一个 API,用于获取某个 provider source 支持的模型列表。前端在点击“添加模型”按钮时,调用该 API 获取模型列表并展示在 dropdown 中。 + +GET /config/provider_sources//models + +本质上这个会初始化一下 Provider,然后调用 Provider.get_models() 来获取模型列表,然后销毁 Provider 实例。 + +### 测试模型 + +在 provider 的配置那里,增加一个“测试模型”按钮,类同现在的测试功能。 + +## 迁移 + +需要编写一个迁移脚本,将现有的 provider 配置拆分成 provider_source 和 provider 两部分,provider_source 的配置从现有的 provider 中提取公共部分,provider 则只保留模型相关的配置。 + +仅对 chat_completion 类型的 provider 进行迁移,其他类型的 provider 保持不变。 + +可以在 astrbot/core/utils/migra_helper.py 的 migra 方法中添加迁移逻辑。 + +## UI 风格 + +整体风格需要现代化、简洁化,不要使用任何渐变。 + +## 其他 + +请最小化改动。 \ No newline at end of file diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index daa2c4fac..6e7f919fd 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1,6 +1,7 @@ """如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。""" import os +from typing import Any, TypedDict from astrbot.core.utils.astrbot_path import get_astrbot_data_path @@ -61,7 +62,8 @@ DEFAULT_CONFIG = { "ignore_bot_self_message": False, "ignore_at_all": False, }, - "provider": [], + "provider_sources": [], # provider sources + "provider": [], # models from provider_sources "provider_settings": { "enable": True, "default_provider_id": "", @@ -170,6 +172,22 @@ DEFAULT_CONFIG = { } +class ChatProviderTemplate(TypedDict): + id: str + provider_source_id: str + model: str + modalities: list + custom_extra_body: dict[str, Any] + + +CHAT_PROVIDER_TEMPLATE = { + "id": "", + "provide_source_id": "", + "model": "", + "modalities": [], + "custom_extra_body": {}, +} + """ AstrBot v3 时代的配置元数据,目前仅承担以下功能: @@ -843,6 +861,7 @@ CONFIG_METADATA_2 = { "metadata": { "provider": { "type": "list", + # provider sources templates "config_template": { "OpenAI": { "id": "openai", @@ -853,10 +872,7 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://api.openai.com/v1", "timeout": 120, - "model_config": {"model": "gpt-4o-mini", "temperature": 0.4}, "custom_headers": {}, - "custom_extra_body": {}, - "modalities": ["text", "image", "tool_use"], "hint": "也兼容所有与 OpenAI API 兼容的服务。", }, "Azure OpenAI": { @@ -869,10 +885,7 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "", "timeout": 120, - "model_config": {"model": "gpt-4o-mini", "temperature": 0.4}, "custom_headers": {}, - "custom_extra_body": {}, - "modalities": ["text", "image", "tool_use"], }, "xAI": { "id": "xai", @@ -883,11 +896,8 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://api.x.ai/v1", "timeout": 120, - "model_config": {"model": "grok-2-latest", "temperature": 0.4}, "custom_headers": {}, - "custom_extra_body": {}, "xai_native_search": False, - "modalities": ["text", "image", "tool_use"], }, "Anthropic": { "hint": "注意Claude系列模型的温度调节范围为0到1.0,超出可能导致报错", @@ -899,12 +909,6 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://api.anthropic.com/v1", "timeout": 120, - "model_config": { - "model": "claude-3-5-sonnet-latest", - "max_tokens": 4096, - "temperature": 0.2, - }, - "modalities": ["text", "image", "tool_use"], }, "Ollama": { "hint": "启用前请确保已正确安装并运行 Ollama 服务端,Ollama默认不带鉴权,无需修改key", @@ -915,10 +919,7 @@ CONFIG_METADATA_2 = { "enable": True, "key": ["ollama"], # ollama 的 key 默认是 ollama "api_base": "http://localhost:11434/v1", - "model_config": {"model": "llama3.1-8b", "temperature": 0.4}, "custom_headers": {}, - "custom_extra_body": {}, - "modalities": ["text", "image", "tool_use"], }, "LM Studio": { "id": "lm_studio", @@ -928,12 +929,7 @@ CONFIG_METADATA_2 = { "enable": True, "key": ["lmstudio"], "api_base": "http://localhost:1234/v1", - "model_config": { - "model": "llama-3.1-8b", - }, "custom_headers": {}, - "custom_extra_body": {}, - "modalities": ["text", "image", "tool_use"], }, "Gemini(OpenAI兼容)": { "id": "gemini_default", @@ -944,13 +940,7 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://generativelanguage.googleapis.com/v1beta/openai/", "timeout": 120, - "model_config": { - "model": "gemini-1.5-flash", - "temperature": 0.4, - }, "custom_headers": {}, - "custom_extra_body": {}, - "modalities": ["text", "image", "tool_use"], }, "Gemini": { "id": "gemini_default", @@ -961,10 +951,6 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://generativelanguage.googleapis.com/", "timeout": 120, - "model_config": { - "model": "gemini-2.0-flash-exp", - "temperature": 0.4, - }, "gm_resp_image_modal": False, "gm_native_search": False, "gm_native_coderunner": False, @@ -978,7 +964,6 @@ CONFIG_METADATA_2 = { "gm_thinking_config": { "budget": 0, }, - "modalities": ["text", "image", "tool_use"], }, "DeepSeek": { "id": "deepseek_default", @@ -989,10 +974,7 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://api.deepseek.com/v1", "timeout": 120, - "model_config": {"model": "deepseek-chat", "temperature": 0.4}, "custom_headers": {}, - "custom_extra_body": {}, - "modalities": ["text", "tool_use"], }, "Groq": { "id": "groq_default", @@ -1003,13 +985,7 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://api.groq.com/openai/v1", "timeout": 120, - "model_config": { - "model": "openai/gpt-oss-20b", - "temperature": 0.4, - }, "custom_headers": {}, - "custom_extra_body": {}, - "modalities": ["text", "tool_use"], }, "302.AI": { "id": "302ai", @@ -1020,10 +996,7 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://api.302.ai/v1", "timeout": 120, - "model_config": {"model": "gpt-4.1-mini", "temperature": 0.4}, "custom_headers": {}, - "custom_extra_body": {}, - "modalities": ["text", "image", "tool_use"], }, "硅基流动": { "id": "siliconflow", @@ -1034,13 +1007,7 @@ CONFIG_METADATA_2 = { "key": [], "timeout": 120, "api_base": "https://api.siliconflow.cn/v1", - "model_config": { - "model": "deepseek-ai/DeepSeek-V3", - "temperature": 0.4, - }, "custom_headers": {}, - "custom_extra_body": {}, - "modalities": ["text", "image", "tool_use"], }, "PPIO派欧云": { "id": "ppio", @@ -1051,12 +1018,7 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://api.ppinfra.com/v3/openai", "timeout": 120, - "model_config": { - "model": "deepseek/deepseek-r1", - "temperature": 0.4, - }, "custom_headers": {}, - "custom_extra_body": {}, }, "小马算力": { "id": "tokenpony", @@ -1067,12 +1029,7 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://api.tokenpony.cn/v1", "timeout": 120, - "model_config": { - "model": "kimi-k2-instruct-0905", - "temperature": 0.7, - }, "custom_headers": {}, - "custom_extra_body": {}, }, "优云智算": { "id": "compshare", @@ -1083,12 +1040,7 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://api.modelverse.cn/v1", "timeout": 120, - "model_config": { - "model": "moonshotai/Kimi-K2-Instruct", - }, "custom_headers": {}, - "custom_extra_body": {}, - "modalities": ["text", "image", "tool_use"], }, "Kimi": { "id": "moonshot", @@ -1099,10 +1051,7 @@ CONFIG_METADATA_2 = { "key": [], "timeout": 120, "api_base": "https://api.moonshot.cn/v1", - "model_config": {"model": "moonshot-v1-8k", "temperature": 0.4}, "custom_headers": {}, - "custom_extra_body": {}, - "modalities": ["text", "image", "tool_use"], }, "智谱 AI": { "id": "zhipu_default", @@ -1113,12 +1062,18 @@ CONFIG_METADATA_2 = { "key": [], "timeout": 120, "api_base": "https://open.bigmodel.cn/api/paas/v4/", - "model_config": { - "model": "glm-4-flash", - }, "custom_headers": {}, - "custom_extra_body": {}, - "modalities": ["text", "image", "tool_use"], + }, + "ModelScope": { + "id": "modelscope", + "provider": "modelscope", + "type": "openai_chat_completion", + "provider_type": "chat_completion", + "enable": True, + "key": [], + "timeout": 120, + "api_base": "https://api-inference.modelscope.cn/v1", + "custom_headers": {}, }, "Dify": { "id": "dify_app_default", @@ -1164,20 +1119,6 @@ CONFIG_METADATA_2 = { "variables": {}, "timeout": 60, }, - "ModelScope": { - "id": "modelscope", - "provider": "modelscope", - "type": "openai_chat_completion", - "provider_type": "chat_completion", - "enable": True, - "key": [], - "timeout": 120, - "api_base": "https://api-inference.modelscope.cn/v1", - "model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4}, - "custom_headers": {}, - "custom_extra_body": {}, - "modalities": ["text", "image", "tool_use"], - }, "FastGPT": { "id": "fastgpt", "provider": "fastgpt", @@ -1448,6 +1389,10 @@ CONFIG_METADATA_2 = { }, }, "items": { + "provider_source_id": { + "invisible": True, + "type": "string", + }, "xai_native_search": { "description": "启用原生搜索功能", "type": "bool", @@ -2005,7 +1950,6 @@ CONFIG_METADATA_2 = { "id": { "description": "ID", "type": "string", - "hint": "模型提供商名字。", }, "type": { "description": "模型提供商种类", @@ -2025,12 +1969,10 @@ CONFIG_METADATA_2 = { "description": "API Key", "type": "list", "items": {"type": "string"}, - "hint": "提供商 API Key。", }, "api_base": { "description": "API Base URL", "type": "string", - "hint": "API Base URL 请在模型提供商处获得。如出现 404 报错,尝试在地址末尾加上 /v1", }, "model_config": { "description": "模型配置", diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index be8edc282..e3e0e7595 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -36,6 +36,7 @@ class ProviderManager: self.acm = acm config = acm.confs["default"] self.providers_config: list = config["provider"] + self.provider_sources_config: list = config.get("provider_sources", []) self.provider_settings: dict = config["provider_settings"] self.provider_stt_settings: dict = config.get("provider_stt_settings", {}) self.provider_tts_settings: dict = config.get("provider_tts_settings", {}) @@ -252,6 +253,23 @@ class ProviderManager: asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients") async def load_provider(self, provider_config: dict): + + # 如果 provider_source_id 存在且不为空,则从 provider_sources 中找到对应的配置并合并 + provider_source_id = provider_config.get("provider_source_id", "") + if provider_source_id: + provider_source = None + for ps in self.provider_sources_config: + if ps.get("id") == provider_source_id: + provider_source = ps + break + + if provider_source: + # 合并配置,provider 的配置优先级更高 + merged_config = {**provider_source, **provider_config} + # 保持 id 为 provider 的 id,而不是 source 的 id + merged_config["id"] = provider_config["id"] + provider_config = merged_config + if not provider_config["enable"]: logger.info(f"Provider {provider_config['id']} is disabled, skipping") return @@ -499,6 +517,7 @@ class ProviderManager: # 和配置文件保持同步 self.providers_config = astrbot_config["provider"] + self.provider_sources_config = astrbot_config.get("provider_sources", []) config_ids = [provider["id"] for provider in self.providers_config] logger.info(f"providers in user's config: {config_ids}") for key in list(self.inst_map.keys()): diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index bd0f06fba..f2b7fac6f 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -112,7 +112,11 @@ class ProviderAnthropic(Provider): if tool_list := tools.get_func_desc_anthropic_style(): payloads["tools"] = tool_list - completion = await self.client.messages.create(**payloads, stream=False) + extra_body = self.provider_config.get("custom_extra_body", {}) + + completion = await self.client.messages.create( + **payloads, stream=False, extra_body=extra_body + ) assert isinstance(completion, Message) logger.debug(f"completion: {completion}") @@ -152,7 +156,11 @@ class ProviderAnthropic(Provider): final_text = "" final_tool_calls = [] - async with self.client.messages.stream(**payloads) as stream: + extra_body = self.provider_config.get("custom_extra_body", {}) + + async with self.client.messages.stream( + **payloads, extra_body=extra_body + ) as stream: assert isinstance(stream, anthropic.AsyncMessageStream) async for event in stream: if event.type == "content_block_start": diff --git a/astrbot/core/utils/migra_helper.py b/astrbot/core/utils/migra_helper.py index 5642d606e..52715caf9 100644 --- a/astrbot/core/utils/migra_helper.py +++ b/astrbot/core/utils/migra_helper.py @@ -32,6 +32,91 @@ def _migra_agent_runner_configs(conf: AstrBotConfig, ids_map: dict) -> None: logger.error(traceback.format_exc()) +def _migra_provider_to_source_structure(conf: AstrBotConfig) -> None: + """ + Migrate old provider structure to new provider-source separation. + Provider only keeps: id, provider_source_id, model, modalities, custom_extra_body + All other fields move to provider_sources. + """ + providers = conf.get("provider", []) + provider_sources = conf.get("provider_sources", []) + + # Track if any migration happened + migrated = False + + # Provider-only fields that should stay in provider + provider_only_fields = { + "id", + "provider_source_id", + "model", + "modalities", + "custom_extra_body", + } + + # Fields that should not go to source + source_exclude_fields = provider_only_fields | {"model_config"} + + for provider in providers: + # Skip if already has provider_source_id + if provider.get("provider_source_id"): + continue + + # Skip non-chat-completion types (they don't need source separation) + provider_type = provider.get("provider_type", "") + if provider_type != "chat_completion": + # For old types without provider_type, check type field + old_type = provider.get("type", "") + if not ("chat_completion" in old_type): + continue + + migrated = True + logger.info(f"Migrating provider {provider.get('id')} to new structure") + + # Extract source fields from provider + source_fields = {} + for key, value in list(provider.items()): + if key not in source_exclude_fields: + source_fields[key] = value + + # Create new provider_source + source_id = provider.get("id", "") + "_source" + new_source = {"id": source_id, **source_fields} + + # Update provider to only keep necessary fields + provider["provider_source_id"] = source_id + + # Extract model from model_config if exists + if "model_config" in provider and isinstance(provider["model_config"], dict): + model_config = provider["model_config"] + provider["model"] = model_config.get("model", "") + + # Put other model_config fields into custom_extra_body + extra_body_fields = {k: v for k, v in model_config.items() if k != "model"} + if extra_body_fields: + if "custom_extra_body" not in provider: + provider["custom_extra_body"] = {} + provider["custom_extra_body"].update(extra_body_fields) + + # Initialize new fields if not present + if "modalities" not in provider: + provider["modalities"] = [] + if "custom_extra_body" not in provider: + provider["custom_extra_body"] = {} + + # Remove fields that should be in source + keys_to_remove = [k for k in provider.keys() if k not in provider_only_fields] + for key in keys_to_remove: + del provider[key] + + # Add source to provider_sources + provider_sources.append(new_source) + + if migrated: + conf["provider_sources"] = provider_sources + conf.save_config() + logger.info("Provider-source structure migration completed") + + async def migra( db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager ) -> None: @@ -71,3 +156,10 @@ async def migra( for conf in acm.confs.values(): _migra_agent_runner_configs(conf, ids_map) + + # Migrate providers to new structure: extract source fields to provider_sources + try: + _migra_provider_to_source_structure(astrbot_config) + except Exception as e: + logger.error(f"Migration for provider-source structure failed: {e!s}") + logger.error(traceback.format_exc()) diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 0edbe8377..22a24892e 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -6,7 +6,7 @@ from typing import Any from quart import request -from astrbot.core import file_token_service, logger +from astrbot.core import astrbot_config, file_token_service, logger from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.config.default import ( CONFIG_METADATA_2, @@ -179,13 +179,29 @@ class ConfigRoute(Route): "/config/provider/new": ("POST", self.post_new_provider), "/config/provider/update": ("POST", self.post_update_provider), "/config/provider/delete": ("POST", self.post_delete_provider), + "/config/provider/template": ("GET", self.get_provider_template), "/config/provider/check_one": ("GET", self.check_one_provider_status), "/config/provider/list": ("GET", self.get_provider_config_list), "/config/provider/model_list": ("GET", self.get_provider_model_list), "/config/provider/get_embedding_dim": ("POST", self.get_embedding_dim), + "/config/provider_sources//models": ( + "GET", + self.get_provider_source_models, + ), } self.register_routes() + async def get_provider_template(self): + provider_config = astrbot_config["provider"] + config_schema = { + "provider": CONFIG_METADATA_2["provider_group"]["metadata"]["provider"] + } + data = { + "config_schema": config_schema, + "providers": provider_config, + } + return Response().ok(data=data).__dict__ + async def get_uc_table(self): """获取 UMOP 配置路由表""" return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__ @@ -522,6 +538,81 @@ class ConfigRoute(Route): logger.error(traceback.format_exc()) return Response().error(f"获取嵌入维度失败: {e!s}").__dict__ + async def get_provider_source_models(self, provider_source_id: str): + """获取指定 provider_source 支持的模型列表 + + 本质上会临时初始化一个 Provider 实例,调用 get_models() 获取模型列表,然后销毁实例 + """ + try: + from astrbot.core.provider.register import provider_cls_map + + # 从配置中查找对应的 provider_source + provider_sources = self.config.get("provider_sources", []) + provider_source = None + for ps in provider_sources: + if ps.get("id") == provider_source_id: + provider_source = ps + break + + if not provider_source: + return ( + Response() + .error(f"未找到 ID 为 {provider_source_id} 的 provider_source") + .__dict__ + ) + + # 获取 provider 类型 + provider_type = provider_source.get("type", None) + if not provider_type: + return Response().error("provider_source 缺少 type 字段").__dict__ + + # 获取对应的 provider 类 + if provider_type not in provider_cls_map: + return ( + Response() + .error(f"未找到适用于 {provider_type} 的提供商适配器") + .__dict__ + ) + + provider_metadata = provider_cls_map[provider_type] + cls_type = provider_metadata.cls_type + + if not cls_type: + return Response().error(f"无法找到 {provider_type} 的类").__dict__ + + # 检查是否是 Provider 类型 + if not issubclass(cls_type, Provider): + return ( + Response() + .error(f"提供商 {provider_type} 不支持获取模型列表") + .__dict__ + ) + + # 临时实例化 provider + inst = cls_type(provider_source, {}) + + # 如果有 initialize 方法,调用它 + init_fn = getattr(inst, "initialize", None) + if inspect.iscoroutinefunction(init_fn): + await init_fn() + + # 获取模型列表 + models = await inst.get_models() + + # 销毁实例(如果有 terminate 方法) + terminate_fn = getattr(inst, "terminate", None) + if inspect.iscoroutinefunction(terminate_fn): + await terminate_fn() + + logger.info( + f"获取到 provider_source {provider_source_id} 的模型列表: {models}", + ) + + return Response().ok({"models": models}).__dict__ + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"获取模型列表失败: {e!s}").__dict__ + async def get_platform_list(self): """获取所有平台的列表""" platform_list = [] diff --git a/dashboard/src/components/shared/AstrBotConfig.vue b/dashboard/src/components/shared/AstrBotConfig.vue index d6c6fee9c..85a7c7f07 100644 --- a/dashboard/src/components/shared/AstrBotConfig.vue +++ b/dashboard/src/components/shared/AstrBotConfig.vue @@ -508,6 +508,7 @@ function hasVisibleItemsAfter(items, currentIndex) { font-size: 0.85em; opacity: 0.7; font-weight: normal; + display: none; } .important-hint { diff --git a/dashboard/src/i18n/locales/en-US/features/provider.json b/dashboard/src/i18n/locales/en-US/features/provider.json index e08177d3d..2f1ffeb63 100644 --- a/dashboard/src/i18n/locales/en-US/features/provider.json +++ b/dashboard/src/i18n/locales/en-US/features/provider.json @@ -85,5 +85,42 @@ "confirm": { "delete": "Are you sure you want to delete service provider {id}?" } + }, + "providerTypes": { + "title": "Provider Types" + }, + "providerSources": { + "title": "Provider Sources", + "empty": "No provider sources", + "selectHint": "Please select a provider source", + "save": "Save Configuration", + "saveAndFetchModels": "Save and Fetch Models", + "fetchModels": "Fetch Model List", + "saveSuccess": "Provider source saved successfully", + "saveError": "Failed to save provider source", + "deleteConfirm": "Are you sure you want to delete provider source {id}? This will also delete all associated model configurations.", + "deleteSuccess": "Provider source deleted successfully", + "deleteError": "Failed to delete provider source", + "enabled": "Enabled", + "disabled": "Disabled", + "advancedConfig": "Advanced Configuration...", + "fields": { + "name": "Name", + "apiKey": "API Key", + "baseUrl": "Base URL" + } + }, + "models": { + "available": "Available Models", + "configured": "Configured Models", + "empty": "No configured models yet. Click \"Fetch Models\" above to add.", + "noModelsFound": "No available models found", + "fetchError": "Failed to fetch models", + "addSuccess": "Model {model} added successfully", + "deleteConfirm": "Are you sure you want to delete model {id}?", + "deleteSuccess": "Model deleted successfully", + "deleteError": "Failed to delete model", + "testSuccess": "Model {id} test passed", + "testError": "Model test failed" } } \ No newline at end of file diff --git a/dashboard/src/i18n/locales/zh-CN/features/provider.json b/dashboard/src/i18n/locales/zh-CN/features/provider.json index 234018829..8733c6e52 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/provider.json +++ b/dashboard/src/i18n/locales/zh-CN/features/provider.json @@ -86,5 +86,42 @@ "confirm": { "delete": "确定要删除模型提供商 {id} 吗?" } + }, + "providerTypes": { + "title": "提供商类型" + }, + "providerSources": { + "title": "提供商源", + "empty": "暂无提供商源", + "selectHint": "请选择一个提供商源", + "save": "保存配置", + "saveAndFetchModels": "保存并获取模型", + "fetchModels": "获取模型列表", + "saveSuccess": "提供商源保存成功", + "saveError": "提供商源保存失败", + "deleteConfirm": "确定要删除提供商源 {id} 吗?这将同时删除关联的所有模型配置。", + "deleteSuccess": "提供商源删除成功", + "deleteError": "提供商源删除失败", + "enabled": "已启用", + "disabled": "已禁用", + "advancedConfig": "高级配置...", + "fields": { + "name": "名称", + "apiKey": "API Key", + "baseUrl": "Base URL" + } + }, + "models": { + "available": "可用模型", + "configured": "已配置的模型", + "empty": "暂无已配置的模型,点击上方的\"获取模型列表\"添加", + "noModelsFound": "未找到可用模型", + "fetchError": "获取模型列表失败", + "addSuccess": "模型 {model} 添加成功", + "deleteConfirm": "确定要删除模型 {id} 吗?", + "deleteSuccess": "模型删除成功", + "deleteError": "模型删除失败", + "testSuccess": "模型 {id} 测试通过", + "testError": "模型测试失败" } } \ No newline at end of file diff --git a/dashboard/src/views/ProviderPage.vue b/dashboard/src/views/ProviderPage.vue index 643beaac6..afdd02c2e 100644 --- a/dashboard/src/views/ProviderPage.vue +++ b/dashboard/src/views/ProviderPage.vue @@ -11,7 +11,7 @@ {{ tm('subtitle') }}

-
+
{{ tm('providers.addProvider') }} @@ -20,88 +20,236 @@
- - - - mdi-filter-variant - {{ tm('providers.tabs.all') }} - - - mdi-message-text - {{ tm('providers.tabs.chatCompletion') }} - - - mdi-message-text - {{ tm('providers.tabs.agentRunner') }} - - - mdi-microphone-message - {{ tm('providers.tabs.speechToText') }} - - - mdi-volume-high - {{ tm('providers.tabs.textToSpeech') }} - - - mdi-code-json - {{ tm('providers.tabs.embedding') }} - - - mdi-compare-vertical - {{ tm('providers.tabs.rerank') }} + + + + {{ type.icon }} + {{ type.label }} - - diff --git a/dashboard/src/views/ProviderPage.vue.backup b/dashboard/src/views/ProviderPage.vue.backup new file mode 100644 index 000000000..643beaac6 --- /dev/null +++ b/dashboard/src/views/ProviderPage.vue.backup @@ -0,0 +1,892 @@ + + + + + From 45110200ea3e6e7718c0d4924b9ed63d27a0aaa3 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 15 Dec 2025 12:31:29 +0800 Subject: [PATCH 02/26] feat: update provider and provider source configuration handling --- astrbot/core/config/default.py | 20 +- astrbot/core/provider/manager.py | 4 + .../core/provider/sources/anthropic_source.py | 13 +- .../core/provider/sources/gemini_source.py | 12 +- .../core/provider/sources/openai_source.py | 8 +- astrbot/core/utils/migra_helper.py | 1 + astrbot/dashboard/routes/config.py | 75 +++++++- .../components/shared/ProviderSelector.vue | 2 +- dashboard/src/views/ProviderPage.vue | 181 ++++++++++++++---- 9 files changed, 237 insertions(+), 79 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 3c805cf99..f8dbbf469 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1974,22 +1974,10 @@ CONFIG_METADATA_2 = { "description": "API Base URL", "type": "string", }, - "model_config": { - "description": "模型配置", - "type": "object", - "items": { - "model": { - "description": "模型名称", - "type": "string", - "hint": "模型名称,如 gpt-4o-mini, deepseek-chat。", - }, - "max_tokens": { - "description": "模型最大输出长度(tokens)", - "type": "int", - }, - "temperature": {"description": "温度", "type": "float"}, - "top_p": {"description": "Top P值", "type": "float"}, - }, + "model": { + "description": "模型 ID", + "type": "string", + "hint": "模型名称,如 gpt-4o-mini, deepseek-chat。", }, "dify_api_key": { "description": "API Key", diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index e3e0e7595..e4f2a9e24 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -37,6 +37,8 @@ class ProviderManager: config = acm.confs["default"] self.providers_config: list = config["provider"] self.provider_sources_config: list = config.get("provider_sources", []) + self.merged_provider_config: dict = {} + """合并 provider 和 provider_sources 配置后的结果""" self.provider_settings: dict = config["provider_settings"] self.provider_stt_settings: dict = config.get("provider_stt_settings", {}) self.provider_tts_settings: dict = config.get("provider_tts_settings", {}) @@ -270,6 +272,8 @@ class ProviderManager: merged_config["id"] = provider_config["id"] provider_config = merged_config + self.merged_provider_config[provider_config["id"]] = provider_config + if not provider_config["enable"]: logger.info(f"Provider {provider_config['id']} is disabled, skipping") return diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index f2b7fac6f..21acd87e8 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -45,7 +45,7 @@ class ProviderAnthropic(Provider): base_url=self.base_url, ) - self.set_model(provider_config["model_config"]["model"]) + self.set_model(provider_config.get("model", "unknown")) def _prepare_payload(self, messages: list[dict]): """准备 Anthropic API 的请求 payload @@ -285,10 +285,9 @@ class ProviderAnthropic(Provider): system_prompt, new_messages = self._prepare_payload(context_query) - model_config = self.provider_config.get("model_config", {}) - model_config["model"] = model or self.get_model() + model = model or self.get_model() - payloads = {"messages": new_messages, **model_config} + payloads = {"messages": new_messages, "model": model} # Anthropic has a different way of handling system prompts if system_prompt: @@ -298,7 +297,6 @@ class ProviderAnthropic(Provider): try: llm_response = await self._query(payloads, func_tool) except Exception as e: - # logger.error(f"发生了错误。Provider 配置如下: {model_config}") raise e return llm_response @@ -340,10 +338,9 @@ class ProviderAnthropic(Provider): system_prompt, new_messages = self._prepare_payload(context_query) - model_config = self.provider_config.get("model_config", {}) - model_config["model"] = model or self.get_model() + model = model or self.get_model() - payloads = {"messages": new_messages, **model_config} + payloads = {"messages": new_messages, "model": model} # Anthropic has a different way of handling system prompts if system_prompt: diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index e2efc6aab..ebb000c03 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -68,7 +68,7 @@ class ProviderGoogleGenAI(Provider): self.api_base = self.api_base[:-1] self._init_client() - self.set_model(provider_config["model_config"]["model"]) + self.set_model(provider_config.get("model", "unknown")) self._init_safety_settings() def _init_client(self) -> None: @@ -652,10 +652,9 @@ class ProviderGoogleGenAI(Provider): for tcr in tool_calls_result: context_query.extend(tcr.to_openai_messages()) - model_config = self.provider_config.get("model_config", {}) - model_config["model"] = model or self.get_model() + model = model or self.get_model() - payloads = {"messages": context_query, **model_config} + payloads = {"messages": context_query, "model": model} retry = 10 keys = self.api_keys.copy() @@ -705,10 +704,9 @@ class ProviderGoogleGenAI(Provider): for tcr in tool_calls_result: context_query.extend(tcr.to_openai_messages()) - model_config = self.provider_config.get("model_config", {}) - model_config["model"] = model or self.get_model() + model = model or self.get_model() - payloads = {"messages": context_query, **model_config} + payloads = {"messages": context_query, "model": model} retry = 10 keys = self.api_keys.copy() diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 788b649a9..6e3bd0bfd 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -68,8 +68,7 @@ class ProviderOpenAIOfficial(Provider): self.client.chat.completions.create, ).parameters.keys() - model_config = provider_config.get("model_config", {}) - model = model_config.get("model", "unknown") + model = provider_config.get("model", "unknown") self.set_model(model) self.reasoning_key = "reasoning_content" @@ -358,10 +357,9 @@ class ProviderOpenAIOfficial(Provider): for tcr in tool_calls_result: context_query.extend(tcr.to_openai_messages()) - model_config = self.provider_config.get("model_config", {}) - model_config["model"] = model or self.get_model() + model = model or self.get_model() - payloads = {"messages": context_query, **model_config} + payloads = {"messages": context_query, "model": model} # xAI origin search tool inject self._maybe_inject_xai_search(payloads, **kwargs) diff --git a/astrbot/core/utils/migra_helper.py b/astrbot/core/utils/migra_helper.py index 52715caf9..42046eab8 100644 --- a/astrbot/core/utils/migra_helper.py +++ b/astrbot/core/utils/migra_helper.py @@ -51,6 +51,7 @@ def _migra_provider_to_source_structure(conf: AstrBotConfig) -> None: "model", "modalities", "custom_extra_body", + "enable", } # Fields that should not go to source diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 22a24892e..6e9942e5b 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -188,9 +188,80 @@ class ConfigRoute(Route): "GET", self.get_provider_source_models, ), + "/config/provider_sources//update": ( + "POST", + self.update_provider_source, + ), } self.register_routes() + async def update_provider_source(self, provider_source_id: str): + """更新或新增 provider_source,并重载关联的 providers""" + + post_data = await request.json + if not post_data: + return Response().error("缺少配置数据").__dict__ + + new_source_config = post_data.get("config") or post_data + original_id = post_data.get("original_id") or provider_source_id + + if not isinstance(new_source_config, dict): + return Response().error("缺少或错误的配置数据").__dict__ + + # 确保配置中有 id 字段 + if not new_source_config.get("id"): + new_source_config["id"] = original_id + + provider_sources = self.config.get("provider_sources", []) + + # 查找旧的 provider_source,若不存在则追加为新配置 + target_idx = next( + (i for i, ps in enumerate(provider_sources) if ps.get("id") == original_id), + -1, + ) + + old_id = original_id + if target_idx == -1: + provider_sources.append(new_source_config) + else: + old_id = provider_sources[target_idx].get("id") + provider_sources[target_idx] = new_source_config + + # 更新引用了该 provider_source 的 providers + affected_providers = [] + for provider in self.config.get("provider", []): + if provider.get("provider_source_id") == old_id: + provider["provider_source_id"] = new_source_config["id"] + affected_providers.append(provider) + + # 写回配置 + self.config["provider_sources"] = provider_sources + + try: + save_config(self.config, self.config, is_core=True) + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(str(e)).__dict__ + + # 重载受影响的 providers,使新的 source 配置生效 + reload_errors = [] + prov_mgr = self.core_lifecycle.provider_manager + for provider in affected_providers: + try: + await prov_mgr.reload(provider) + except Exception as e: + logger.error(traceback.format_exc()) + reload_errors.append(f"{provider.get('id')}: {e}") + + if reload_errors: + return ( + Response() + .error("更新成功,但部分提供商重载失败: " + ", ".join(reload_errors)) + .__dict__ + ) + + return Response().ok(message="更新 provider source 成功").__dict__ + async def get_provider_template(self): provider_config = astrbot_config["provider"] config_schema = { @@ -449,8 +520,8 @@ class ConfigRoute(Route): return Response().error("缺少参数 provider_type").__dict__ provider_type_ls = provider_type.split(",") provider_list = [] - astrbot_config = self.core_lifecycle.astrbot_config - for provider in astrbot_config["provider"]: + pc = self.core_lifecycle.provider_manager.merged_provider_config + for provider in pc.values(): if provider.get("provider_type", None) in provider_type_ls: provider_list.append(provider) return Response().ok(provider_list).__dict__ diff --git a/dashboard/src/components/shared/ProviderSelector.vue b/dashboard/src/components/shared/ProviderSelector.vue index 050738a94..ffade98d1 100644 --- a/dashboard/src/components/shared/ProviderSelector.vue +++ b/dashboard/src/components/shared/ProviderSelector.vue @@ -51,7 +51,7 @@ {{ provider.id }} {{ provider.type || provider.provider_type || tm('providerSelector.unknownType') }} - - {{ provider.model_config.model }} + - {{ provider.model }}