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 @@
-
+
{{ entry.provider.id }}
-
- {{ entry.provider.model }}
+
+ {{ entry.provider.model }}
+
+ mdi-eye-outline
+
+
+ mdi-wrench
+
+
+ mdi-brain
+
+
+ {{ formatContextLimit(entry.metadata) }}
+
@@ -168,9 +181,23 @@
-
+
{{ entry.model }}
+
+ {{ entry.model }}
+
+ mdi-eye-outline
+
+
+ mdi-wrench
+
+
+ mdi-brain
+
+
+ {{ formatContextLimit(entry.metadata) }}
+
+
@@ -344,6 +371,7 @@ const selectedProviderSource = ref(null)
const selectedProviderSourceOriginalId = ref(null)
const editableProviderSource = ref(null)
const availableModels = ref([])
+const modelMetadata = ref({})
const loadingModels = ref(false)
const savingSource = ref(false)
const testingProviders = ref([])
@@ -453,8 +481,10 @@ const existingModelsForSelectedSource = computed(() => {
const sortedAvailableModels = computed(() => {
const existing = existingModelsForSelectedSource.value
return [...(availableModels.value || [])].sort((a, b) => {
- const aExists = existing.has(a)
- const bExists = existing.has(b)
+ const aName = typeof a === 'string' ? a : a?.name
+ const bName = typeof b === 'string' ? b : b?.name
+ const aExists = existing.has(aName)
+ const bExists = existing.has(bName)
if (aExists && !bExists) return -1
if (!aExists && bExists) return 1
return 0
@@ -464,15 +494,23 @@ const sortedAvailableModels = computed(() => {
const mergedModelEntries = computed(() => {
const configuredEntries = (sourceProviders.value || []).map(provider => ({
type: 'configured',
- provider
+ provider,
+ metadata: getModelMetadata(provider.model)
}))
const availableEntries = (sortedAvailableModels.value || [])
- .filter(model => !existingModelsForSelectedSource.value.has(model))
- .map(model => ({
- type: 'available',
- model
- }))
+ .filter(item => {
+ const name = typeof item === 'string' ? item : item?.name
+ return !existingModelsForSelectedSource.value.has(name)
+ })
+ .map(item => {
+ const name = typeof item === 'string' ? item : item?.name
+ return {
+ type: 'available',
+ model: name,
+ metadata: typeof item === 'object' ? item?.metadata : getModelMetadata(name)
+ }
+ })
return [...configuredEntries, ...availableEntries]
})
@@ -548,6 +586,32 @@ function resolveSourceIcon(source) {
return getProviderIcon(source.provider) || ''
}
+function getModelMetadata(modelName) {
+ if (!modelName) return null
+ return modelMetadata.value?.[modelName] || null
+}
+
+function supportsImageInput(meta) {
+ const inputs = meta?.modalities?.input || []
+ return inputs.includes('image')
+}
+
+function supportsToolCall(meta) {
+ return Boolean(meta?.tool_call)
+}
+
+function supportsReasoning(meta) {
+ return Boolean(meta?.reasoning)
+}
+
+function 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}`
+}
+
function openProviderEdit(provider) {
providerEditData.value = JSON.parse(JSON.stringify(provider))
showProviderEditDialog.value = true
@@ -567,6 +631,7 @@ function selectProviderSource(source) {
suppressSourceWatch = false
})
availableModels.value = []
+ modelMetadata.value = {}
isSourceModified.value = false
sourceProviderPanels.value = null
}
@@ -582,13 +647,13 @@ function addProviderSource(templateKey) {
// 使用模板中的默认 ID
const newId = template.id
const newSource = {
+ // 复制模板中的字段(排除 id, enable 等 provider 特有字段)
+ ...extractSourceFieldsFromTemplate(template),
id: newId,
type: template.type,
provider_type: template.provider_type,
provider: template.provider,
enable: true,
- // 复制模板中的字段(排除 id, enable, type, provider_type 等 provider 特有字段)
- ...extractSourceFieldsFromTemplate(template)
}
providerSources.value.push(newSource)
@@ -602,9 +667,9 @@ function extractSourceFieldsFromTemplate(template) {
// 从模板中提取 source 相关的字段
const sourceFields = {}
const excludeKeys = [
- 'id', 'enable', 'type', 'provider_type', 'model',
- 'provider_source_id', 'provider', 'hint', 'modalities',
- 'custom_extra_body', 'custom_headers'
+ 'id', 'enable', 'model',
+ 'provider_source_id', 'modalities',
+ 'custom_extra_body'
]
for (const [key, value] of Object.entries(template)) {
@@ -710,7 +775,12 @@ async function fetchAvailableModels() {
`/api/config/provider_sources/${sourceId}/models`
)
if (response.data.status === 'ok') {
- availableModels.value = response.data.data.models || []
+ const metadataMap = response.data.data.model_metadata || {}
+ modelMetadata.value = metadataMap
+ availableModels.value = (response.data.data.models || []).map(model => ({
+ name: model,
+ metadata: metadataMap?.[model] || null
+ }))
if (availableModels.value.length === 0) {
showMessage(tm('models.noModelsFound'), 'info')
}
@@ -718,6 +788,7 @@ async function fetchAvailableModels() {
throw new Error(response.data.message)
}
} catch (error) {
+ modelMetadata.value = {}
showMessage(error.response?.data?.message || error.message || tm('models.fetchError'), 'error')
} finally {
loadingModels.value = false
@@ -729,12 +800,21 @@ async function addModelProvider(modelName) {
const sourceId = editableProviderSource.value?.id || selectedProviderSource.value.id
const newId = `${sourceId}/${modelName}`
+
+ let modalities = ["text"]
+ if (supportsImageInput(getModelMetadata(modelName))) {
+ modalities.push("image")
+ }
+ if (supportsToolCall(getModelMetadata(modelName))) {
+ modalities.push("tool_use")
+ }
+
const newProvider = {
id: newId,
enable: false,
provider_source_id: sourceId,
model: modelName,
- modalities: [],
+ modalities: modalities,
custom_extra_body: {}
}
@@ -743,16 +823,12 @@ async function addModelProvider(modelName) {
if (res.data.status === 'error') {
throw new Error(res.data.message)
}
-
- if (Array.isArray(config.value.provider)) {
- config.value.provider.push(newProvider)
- } else {
- config.value.provider = [newProvider]
- }
-
+ config.value.provider.push(newProvider)
showMessage(res.data.message || tm('models.addSuccess', { model: modelName }))
} catch (error) {
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
+ } finally {
+ await loadConfig()
}
}
@@ -790,27 +866,6 @@ async function testProvider(provider) {
}
}
-async function saveSingleProvider(provider) {
- if (!provider) return
-
- const exists = (config.value.provider || []).some(p => p.id === provider.id)
- savingProviders.value.push(provider.id)
- try {
- const url = exists ? '/api/config/provider/update' : '/api/config/provider/new'
- const payload = exists ? { id: provider.id, config: provider } : provider
- const res = await axios.post(url, payload)
- if (res.data.status === 'error') {
- throw new Error(res.data.message)
- }
- showMessage(res.data.message || tm('providerSources.saveSuccess'))
- await loadConfig()
- } catch (err) {
- showMessage(err.response?.data?.message || err.message || tm('providerSources.saveError'), 'error')
- } finally {
- savingProviders.value = savingProviders.value.filter(id => id !== provider.id)
- }
-}
-
async function saveConfig() {
try {
config.value.provider_sources = providerSources.value
@@ -832,6 +887,7 @@ async function loadConfig() {
config.value = response.data.data.config
providerSources.value = config.value.provider_sources || []
providers.value = config.value.provider || []
+ metadata.value = response.data.data.metadata
}
} catch (error) {
showMessage(error.message || 'Failed to load config', 'error')
@@ -858,7 +914,6 @@ async function loadProviderTemplate() {
onMounted(async () => {
await loadConfig()
await loadProviderTemplate()
- await loadMetadata()
})
// 跟踪编辑中的 provider source 是否被修改
@@ -1180,17 +1235,6 @@ function getStatusText(status) {
return messages[status] || status
}
-async function loadMetadata() {
- try {
- const response = await axios.get('/api/config/get')
- if (response.data.status === 'ok') {
- metadata.value = response.data.data.metadata
- }
- } catch (error) {
- console.error('Failed to load metadata:', error)
- }
-}
-
function goToConfigPage() {
router.push('/config')
showAgentRunnerDialog.value = false