diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 5a8672837..823fdf260 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -33,6 +33,7 @@ from astrbot.core.star.context import Context from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map from astrbot.core.umop_config_router import UmopConfigRouter from astrbot.core.updator import AstrBotUpdator +from astrbot.core.utils.llm_metadata import update_llm_metadata from astrbot.core.utils.migra_helper import migra from . import astrbot_config, html_renderer @@ -185,6 +186,8 @@ class AstrBotCoreLifecycle: # 初始化关闭控制面板的事件 self.dashboard_shutdown_event = asyncio.Event() + asyncio.create_task(update_llm_metadata()) + def _load(self) -> None: """加载事件总线和任务并初始化.""" # 创建一个异步任务来执行事件总线的 dispatch() 方法 diff --git a/astrbot/core/utils/llm_metadata.py b/astrbot/core/utils/llm_metadata.py new file mode 100644 index 000000000..540c1efd9 --- /dev/null +++ b/astrbot/core/utils/llm_metadata.py @@ -0,0 +1,63 @@ +from typing import Literal, TypedDict + +import aiohttp + +from astrbot.core import logger + + +class LLMModalities(TypedDict): + input: list[Literal["text", "image", "audio", "video"]] + output: list[Literal["text", "image", "audio", "video"]] + + +class LLMLimit(TypedDict): + context: int + output: int + + +class LLMMetadata(TypedDict): + id: str + reasoning: bool + tool_call: bool + knowledge: str + release_date: str + modalities: LLMModalities + open_weights: bool + limit: LLMLimit + + +LLM_METADATAS: dict[str, LLMMetadata] = {} + + +async def update_llm_metadata(): + url = "https://models.dev/api.json" + try: + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + data = await response.json() + global LLM_METADATAS + models = {} + for info in data.values(): + for model in info.get("models", {}).values(): + model_id = model.get("id") + if not model_id: + continue + models[model_id] = LLMMetadata( + id=model_id, + reasoning=model.get("reasoning", False), + tool_call=model.get("tool_call", False), + knowledge=model.get("knowledge", "none"), + release_date=model.get("release_date", ""), + modalities=model.get( + "modalities", {"input": [], "output": []} + ), + open_weights=model.get("open_weights", False), + limit=model.get("limit", {"context": 0, "output": 0}), + ) + # Replace the global cache in-place so references remain valid + LLM_METADATAS.clear() + LLM_METADATAS.update(models) + logger.info(f"Successfully fetched metadata for {len(models)} LLMs.") + except Exception as e: + logger.error(f"Failed to fetch LLM metadata: {e}") + return diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 6e9942e5b..23f28337e 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -20,6 +20,7 @@ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.platform.register import platform_cls_map, platform_registry from astrbot.core.provider import Provider from astrbot.core.provider.register import provider_registry +from astrbot.core.utils.llm_metadata import LLM_METADATAS, update_llm_metadata from astrbot.core.star.star import star_registry from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config @@ -545,9 +546,18 @@ class ConfigRoute(Route): try: models = await provider.get_models() + models = models or [] + + metadata_map = {} + for model_id in models: + meta = LLM_METADATAS.get(model_id) + if meta: + metadata_map[model_id] = meta + ret = { "models": models, "provider_id": provider_id, + "model_metadata": metadata_map, } return Response().ok(ret).__dict__ except Exception as e: @@ -669,6 +679,13 @@ class ConfigRoute(Route): # 获取模型列表 models = await inst.get_models() + models = models or [] + + metadata_map = {} + for model_id in models: + meta = LLM_METADATAS.get(model_id) + if meta: + metadata_map[model_id] = meta # 销毁实例(如果有 terminate 方法) terminate_fn = getattr(inst, "terminate", None) @@ -679,7 +696,11 @@ class ConfigRoute(Route): f"获取到 provider_source {provider_source_id} 的模型列表: {models}", ) - return Response().ok({"models": models}).__dict__ + return ( + Response() + .ok({"models": models, "model_metadata": metadata_map}) + .__dict__ + ) except Exception as e: logger.error(traceback.format_exc()) return Response().error(f"获取模型列表失败: {e!s}").__dict__ @@ -735,6 +756,19 @@ class ConfigRoute(Route): async def post_new_provider(self): new_provider_config = await request.json + + # check id uniqueness + npid = new_provider_config.get("id", None) + if not npid: + return Response().error("服务提供商配置缺少 id 字段").__dict__ + for provider in self.config["provider"]: + if provider.get("id", None) == npid: + return ( + Response() + .error(f"provider with ID '{npid}' already exists") + .__dict__ + ) + self.config["provider"].append(new_provider_config) try: save_config(self.config, self.config, is_core=True) diff --git a/dashboard/src/components/chat/ProviderModelSelector.vue b/dashboard/src/components/chat/ProviderModelSelector.vue index 645ff5a55..00a53adf7 100644 --- a/dashboard/src/components/chat/ProviderModelSelector.vue +++ b/dashboard/src/components/chat/ProviderModelSelector.vue @@ -50,12 +50,22 @@ - - {{ model }} - {{ model.description - }} + {{ model.name }} + + + mdi-eye-outline + + + mdi-wrench + + + mdi-brain + + {{ formatContextLimit(model.metadata) }} +
@@ -104,6 +114,7 @@ export default { showDialog: false, providerConfigs: [], modelList: [], + modelMetadata: {}, selectedProviderId: '', selectedModelName: '', // 临时选择状态,用于对话框内的选择 @@ -182,15 +193,22 @@ export default { }) .then(response => { if (response.data.status === 'ok') { - this.modelList = response.data.data.models || []; + const metadataMap = response.data.data.model_metadata || {}; + this.modelMetadata = metadataMap; + this.modelList = (response.data.data.models || []).map(name => ({ + name, + metadata: metadataMap[name] || null + })); } else { console.error('获取模型列表失败:', response.data.message); this.modelList = []; + this.modelMetadata = {}; } }) .catch(error => { console.error('获取模型列表失败:', error); this.modelList = []; + this.modelMetadata = {}; }) .finally(() => { this.loadingModels = false; @@ -202,6 +220,7 @@ export default { this.tempSelectedProviderId = provider.id; this.tempSelectedModelName = ''; // 清空已选择的模型 this.modelList = []; // 清空模型列表 + this.modelMetadata = {}; this.getProviderModels(provider.id); // 获取该提供商的模型列表 }, @@ -260,6 +279,27 @@ export default { this.showDialog = true; }, + supportsImageInput(meta) { + const inputs = meta?.modalities?.input || []; + return inputs.includes('image'); + }, + + supportsToolCall(meta) { + return Boolean(meta?.tool_call); + }, + + supportsReasoning(meta) { + return Boolean(meta?.reasoning); + }, + + formatContextLimit(meta) { + const ctx = meta?.limit?.context; + if (!ctx || typeof ctx !== 'number') return ''; + if (ctx >= 1_000_000) return `${Math.round(ctx / 1_000_000)}M`; + if (ctx >= 1_000) return `${Math.round(ctx / 1_000)}K`; + return `${ctx}`; + }, + // 公开方法:获取当前选择 getCurrentSelection() { return { @@ -356,4 +396,10 @@ export default { font-size: 14px; color: var(--v-theme-secondaryText); } + +.metadata-line { + display: flex; + align-items: center; + gap: 6px; +} diff --git a/dashboard/src/views/ProviderPage.vue b/dashboard/src/views/ProviderPage.vue index 81847cdb9..88ea0f1cc 100644 --- a/dashboard/src/views/ProviderPage.vue +++ b/dashboard/src/views/ProviderPage.vue @@ -97,8 +97,7 @@
+ :disabled="!isSourceModified" @click="saveProviderSource" variant="flat"> {{ tm('providerSources.save') }}
@@ -127,7 +126,8 @@

{{ tm('models.configured') }}

- {{ tm('models.available') }} {{ availableModels.length }} + {{ tm('models.available') }} {{ + availableModels.length }} @@ -139,14 +139,27 @@