feat: implement LLM metadata fetching and integrate into provider model selection

This commit is contained in:
Soulter
2025-12-16 12:19:40 +08:00
parent fd66a0ac00
commit c20c1b84bf
5 changed files with 260 additions and 70 deletions
+3
View File
@@ -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() 方法
+63
View File
@@ -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
+35 -1
View File
@@ -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)
@@ -50,12 +50,22 @@
<v-text-field v-model="tempSelectedModelName" placeholder="自定义模型" hide-details solo variant="outlined" density="compact" class="mb-2 mx-2"></v-text-field>
<v-list-item v-for="model in modelList" :key="model" :value="model"
@click="selectModel(model)" :active="tempSelectedModelName === model" rounded="lg"
<v-list-item v-for="model in modelList" :key="model.name" :value="model.name"
@click="selectModel(model.name)" :active="tempSelectedModelName === model.name" rounded="lg"
class="model-item">
<v-list-item-title>{{ model }}</v-list-item-title>
<v-list-item-subtitle v-if="model.description">{{ model.description
}}</v-list-item-subtitle>
<v-list-item-title>{{ model.name }}</v-list-item-title>
<v-list-item-subtitle v-if="model.metadata" class="text-caption text-grey metadata-line">
<v-icon v-if="supportsImageInput(model.metadata)" size="14" color="grey">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsToolCall(model.metadata)" size="14" color="grey">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(model.metadata)" size="14" color="grey">
mdi-brain
</v-icon>
<span v-if="formatContextLimit(model.metadata)">{{ formatContextLimit(model.metadata) }}</span>
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="empty-state">
@@ -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;
}
</style>
+107 -63
View File
@@ -97,8 +97,7 @@
<div class="d-flex align-center ga-2" v-if="selectedProviderSource">
<v-btn color="success" prepend-icon="mdi-check" :loading="savingSource"
:disabled="!isSourceModified"
@click="saveProviderSource" variant="flat">
:disabled="!isSourceModified" @click="saveProviderSource" variant="flat">
{{ tm('providerSources.save') }}
</v-btn>
</div>
@@ -127,7 +126,8 @@
<div class="d-flex align-center ga-2 mb-2">
<h3 class="text-h5 font-weight-bold mb-0">{{ tm('models.configured') }}</h3>
<!-- <v-chip color="success" variant="tonal" size="small">{{ displayedChatProviders.length }}</v-chip> -->
<small style="color: grey;" v-if="availableModels.length">{{ tm('models.available') }} {{ availableModels.length }}</small>
<small style="color: grey;" v-if="availableModels.length">{{ tm('models.available') }} {{
availableModels.length }}</small>
<v-spacer></v-spacer>
<v-btn color="primary" prepend-icon="mdi-download" :loading="loadingModels"
@click="fetchAvailableModels" variant="tonal" size="small">
@@ -139,14 +139,27 @@
<v-list density="compact" class="rounded-lg border"
style="max-height: 520px; overflow-y: auto; font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;">
<template v-if="mergedModelEntries.length > 0">
<template v-for="entry in mergedModelEntries" :key="entry.type === 'configured' ? `provider-${entry.provider.id}` : `model-${entry.model}`">
<template v-for="entry in mergedModelEntries"
:key="entry.type === 'configured' ? `provider-${entry.provider.id}` : `model-${entry.model}`">
<v-list-item v-if="entry.type === 'configured'" class="provider-compact-item"
@click="openProviderEdit(entry.provider)">
<v-list-item-title class="font-weight-medium text-truncate">
{{ entry.provider.id }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption text-grey text-truncate">
{{ entry.provider.model }}
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1">
<span>{{ entry.provider.model }}</span>
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
mdi-brain
</v-icon>
<span v-if="formatContextLimit(entry.metadata)">
{{ formatContextLimit(entry.metadata) }}
</span>
</v-list-item-subtitle>
<template #append>
<div class="d-flex align-center ga-1" @click.stop>
@@ -168,9 +181,23 @@
</template>
</v-list-item>
<v-list-item v-else class="cursor-pointer"
@click="addModelProvider(entry.model)">
<v-list-item v-else class="cursor-pointer" @click="addModelProvider(entry.model)">
<v-list-item-title>{{ entry.model }}</v-list-item-title>
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1">
<span>{{ entry.model }}</span>
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
mdi-brain
</v-icon>
<span v-if="formatContextLimit(entry.metadata)">
{{ formatContextLimit(entry.metadata) }}
</span>
</v-list-item-subtitle>
<template v-slot:append>
<v-btn icon="mdi-plus" size="small" variant="text" color="primary"></v-btn>
</template>
@@ -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