feat: show model metadata in selectors

Surface cached model metadata in model/provider selectors, show neutral capability/context badges, and add provider test actions.
This commit is contained in:
Weilong Liao
2026-07-06 20:38:30 +08:00
committed by GitHub
parent 25cbd41e08
commit 6627fd53c2
11 changed files with 665 additions and 143 deletions

View File

@@ -24,6 +24,7 @@ from astrbot.core.platform.register import platform_cls_map, platform_registry
from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import star_registry
from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path
from astrbot.core.utils.llm_metadata import LLM_METADATAS
from astrbot.core.utils.totp import (
is_totp_enabled,
revoke_user_trusted_devices,
@@ -1297,9 +1298,14 @@ class ProviderConfigService:
for provider in provider_registry:
if provider.default_config_tmpl:
provider_default_tmpl[provider.type] = provider.default_config_tmpl
providers = copy.deepcopy(self.config.get("provider", []))
for provider in providers:
model_id = provider.get("model")
if isinstance(model_id, str) and model_id in LLM_METADATAS:
provider["model_metadata"] = LLM_METADATAS[model_id]
return {
"config_schema": config_schema,
"providers": self.config.get("provider", []),
"providers": providers,
"provider_sources": self.config.get("provider_sources", []),
}
@@ -1562,11 +1568,15 @@ class ProviderConfigService:
if provider_type and effective_type != provider_type:
continue
if provider.get("provider_source_id"):
providers.append(
self.provider_manager.get_merged_provider_config(provider)
provider_response = self.provider_manager.get_merged_provider_config(
provider
)
else:
providers.append(copy.deepcopy(provider))
provider_response = copy.deepcopy(provider)
model_id = provider_response.get("model")
if isinstance(model_id, str) and model_id in LLM_METADATAS:
provider_response["model_metadata"] = LLM_METADATAS[model_id]
providers.append(provider_response)
return {"providers": providers}
def list_providers_for_dashboard_types(
@@ -1597,6 +1607,9 @@ class ProviderConfigService:
)
if provider is None:
raise ValueError(f"Provider {provider_id} not found")
model_id = provider.get("model")
if isinstance(model_id, str) and model_id in LLM_METADATAS:
provider["model_metadata"] = LLM_METADATAS[model_id]
return {"provider": provider}
async def create_provider(self, config: dict, source_id: str | None = None) -> None:

View File

@@ -54,28 +54,70 @@
<v-list-item-subtitle class="provider-subtitle">
<span class="model-name">{{ provider.model }}</span>
<span class="meta-icons">
<v-icon v-if="supportsImageInput(provider)" size="13">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsAudioInput(provider)" size="13">
mdi-music-note-outline
</v-icon>
<v-icon v-if="supportsToolCall(provider)" size="13">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(provider)" size="13">
mdi-brain
</v-icon>
<v-tooltip
v-for="item in capabilityBadges(provider)"
:key="item.key"
location="top"
max-width="320"
>
<template #activator="{ props: badgeTooltipProps }">
<span
v-bind="badgeTooltipProps"
class="meta-icon-badge"
:class="{ 'meta-icon-badge--disabled': !item.enabled }"
@click.stop
>
<v-icon size="13">{{ item.icon }}</v-icon>
</span>
</template>
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(provider)"
location="top"
max-width="320"
>
<template #activator="{ props: contextTooltipProps }">
<span
v-bind="contextTooltipProps"
class="meta-context-badge"
@click.stop
>
{{ formatContextLimit(provider) }}
</span>
</template>
<span>{{
tm("models.metadata.context", {
tokens: formatContextLimit(provider),
})
}}</span>
</v-tooltip>
</span>
</v-list-item-subtitle>
<template #append>
<v-icon
v-if="selectedProviderId === provider.id"
class="provider-selected-icon"
size="18"
>
mdi-check
</v-icon>
<div class="provider-menu-actions" @click.stop>
<v-tooltip location="top">
<template #activator="{ props: testTooltipProps }">
<v-btn
v-bind="testTooltipProps"
icon="mdi-connection"
size="x-small"
variant="text"
:loading="testingProviderIds.includes(provider.id)"
:disabled="testingProviderIds.includes(provider.id)"
@click.stop="testProvider(provider)"
/>
</template>
<span>{{ tm("models.testButton") }}</span>
</v-tooltip>
<v-icon
v-if="selectedProviderId === provider.id"
class="provider-selected-icon"
size="18"
>
mdi-check
</v-icon>
</div>
</template>
</v-list-item>
</v-list>
@@ -94,18 +136,18 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { providerApi } from "@/api/v1";
import { useModuleI18n } from "@/i18n/composables";
import { useToast } from "@/utils/toast";
import {
formatContextLimit,
providerCapabilityBadges,
type ProviderMetadataSource,
} from "@/utils/providerMetadata";
interface ModelMetadata {
modalities?: { input?: string[] };
tool_call?: boolean;
reasoning?: boolean;
}
interface ProviderConfig {
interface ProviderConfig extends ProviderMetadataSource {
id: string;
model: string;
api_base?: string;
model_metadata?: ModelMetadata;
enable?: boolean;
}
@@ -127,6 +169,9 @@ const searchQuery = ref("");
const menuOpen = ref(false);
const loadingProviders = ref(false);
const providersLoaded = ref(false);
const testingProviderIds = ref<string[]>([]);
const { tm } = useModuleI18n("features/provider");
const { success: toastSuccess, error: toastError } = useToast();
const variant = computed(() => props.variant);
const menuLocation = computed(() =>
@@ -134,7 +179,9 @@ const menuLocation = computed(() =>
);
const selectedProvider = computed(() =>
providerConfigs.value.find((provider) => provider.id === selectedProviderId.value),
providerConfigs.value.find(
(provider) => provider.id === selectedProviderId.value,
),
);
const triggerTitle = computed(() => {
@@ -183,9 +230,9 @@ async function loadProviderConfigs(force = false) {
try {
const response = await providerApi.listByProviderType("chat_completion");
if (response.data.status === "ok") {
providerConfigs.value = ((response.data.data || []) as unknown as ProviderConfig[]).filter(
(provider: ProviderConfig) => provider.enable !== false,
);
providerConfigs.value = (
(response.data.data || []) as unknown as ProviderConfig[]
).filter((provider: ProviderConfig) => provider.enable !== false);
providersLoaded.value = true;
const selected = selectedProvider.value;
if (selected) {
@@ -207,22 +254,36 @@ function selectProvider(provider: ProviderConfig) {
menuOpen.value = false;
}
function supportsImageInput(provider: ProviderConfig): boolean {
const inputs = provider.model_metadata?.modalities?.input || [];
return inputs.includes("image");
function capabilityBadges(provider: ProviderConfig) {
return providerCapabilityBadges(provider, tm);
}
function supportsAudioInput(provider: ProviderConfig): boolean {
const inputs = provider.model_metadata?.modalities?.input || [];
return inputs.includes("audio");
}
function supportsToolCall(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.tool_call);
}
function supportsReasoning(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.reasoning);
async function testProvider(provider: ProviderConfig) {
if (testingProviderIds.value.includes(provider.id)) return;
testingProviderIds.value.push(provider.id);
try {
const startTime = performance.now();
const response = await providerApi.test(provider.id);
if (response.data.status === "ok" && response.data.data.error === null) {
const latency = Math.max(0, Math.round(performance.now() - startTime));
toastSuccess(
tm("models.testSuccessWithLatency", {
id: provider.id,
latency,
}),
);
} else {
throw new Error(response.data.data.error || tm("models.testError"));
}
} catch (error: any) {
toastError(
error.response?.data?.message || error.message || tm("models.testError"),
);
} finally {
testingProviderIds.value = testingProviderIds.value.filter(
(id) => id !== provider.id,
);
}
}
function getCurrentSelection() {
@@ -404,6 +465,35 @@ defineExpose({
color: rgba(var(--v-theme-on-surface), 0.5);
}
.meta-icon-badge {
display: inline-flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.72);
}
.meta-icon-badge--disabled {
color: rgba(var(--v-theme-on-surface), 0.34);
}
.meta-context-badge {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 5px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.06);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 10px;
font-weight: 650;
line-height: 16px;
}
.provider-menu-actions {
display: inline-flex;
align-items: center;
gap: 2px;
}
.provider-selected-icon {
color: rgba(var(--v-theme-on-surface), 0.72);
}

View File

@@ -76,18 +76,46 @@
<v-list-item-subtitle class="regenerate-model-subtitle">
<span class="regenerate-model-name">{{ provider.model }}</span>
<span class="regenerate-model-icons">
<v-icon v-if="supportsImageInput(provider)" size="12">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsAudioInput(provider)" size="12">
mdi-music-note-outline
</v-icon>
<v-icon v-if="supportsToolCall(provider)" size="12">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(provider)" size="12">
mdi-brain
</v-icon>
<v-tooltip
v-for="item in capabilityBadges(provider)"
:key="item.key"
location="top"
max-width="320"
>
<template #activator="{ props: badgeTooltipProps }">
<span
v-bind="badgeTooltipProps"
class="regenerate-model-icon-badge"
:class="{
'regenerate-model-icon-badge--disabled': !item.enabled,
}"
@click.stop
>
<v-icon size="12">{{ item.icon }}</v-icon>
</span>
</template>
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(provider)"
location="top"
max-width="320"
>
<template #activator="{ props: contextTooltipProps }">
<span
v-bind="contextTooltipProps"
class="regenerate-model-context-badge"
@click.stop
>
{{ formatContextLimit(provider) }}
</span>
</template>
<span>{{
providerTm("models.metadata.context", {
tokens: formatContextLimit(provider),
})
}}</span>
</v-tooltip>
</span>
</v-list-item-subtitle>
</v-list-item>
@@ -106,17 +134,15 @@ import { ref } from "vue";
import { providerApi } from "@/api/v1";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import { useModuleI18n } from "@/i18n/composables";
import {
formatContextLimit,
providerCapabilityBadges,
type ProviderMetadataSource,
} from "@/utils/providerMetadata";
interface ModelMetadata {
modalities?: { input?: string[] };
tool_call?: boolean;
reasoning?: boolean;
}
interface ProviderConfig {
interface ProviderConfig extends ProviderMetadataSource {
id: string;
model: string;
model_metadata?: ModelMetadata;
enable?: boolean;
}
@@ -131,6 +157,7 @@ const emit = defineEmits<{
}>();
const { tm } = useModuleI18n("features/chat");
const { tm: providerTm } = useModuleI18n("features/provider");
const providerConfigs = ref<ProviderConfig[]>([]);
const loadingProviders = ref(false);
const providersLoaded = ref(false);
@@ -141,9 +168,9 @@ async function loadProviderConfigs(force = false) {
try {
const response = await providerApi.listByProviderType("chat_completion");
if (response.data.status === "ok") {
providerConfigs.value = ((response.data.data || []) as unknown as ProviderConfig[]).filter(
(provider: ProviderConfig) => provider.enable !== false,
);
providerConfigs.value = (
(response.data.data || []) as unknown as ProviderConfig[]
).filter((provider: ProviderConfig) => provider.enable !== false);
providersLoaded.value = true;
}
} catch (error) {
@@ -172,20 +199,8 @@ function retryWithModel(provider: ProviderConfig) {
});
}
function supportsImageInput(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.modalities?.input?.includes("image"));
}
function supportsAudioInput(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.modalities?.input?.includes("audio"));
}
function supportsToolCall(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.tool_call);
}
function supportsReasoning(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.reasoning);
function capabilityBadges(provider: ProviderConfig) {
return providerCapabilityBadges(provider, providerTm);
}
</script>
@@ -226,6 +241,29 @@ function supportsReasoning(provider: ProviderConfig): boolean {
color: var(--chat-muted, rgba(var(--v-theme-on-surface), 0.62));
}
.regenerate-model-icon-badge {
display: inline-flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.72);
}
.regenerate-model-icon-badge--disabled {
color: rgba(var(--v-theme-on-surface), 0.34);
}
.regenerate-model-context-badge {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 5px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.06);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 10px;
font-weight: 650;
line-height: 16px;
}
.regenerate-empty {
padding: 14px 16px;
color: var(--chat-muted, rgba(var(--v-theme-on-surface), 0.62));

View File

@@ -66,19 +66,47 @@
<div class="provider-model-row__title">{{ entry.provider.id }}</div>
<div class="provider-model-row__subtitle">{{ entry.provider.model }}</div>
<div class="provider-model-row__meta">
<span
v-for="item in capabilityIcons(entry.metadata)"
:key="item.icon"
class="provider-model-row__badge"
<v-tooltip
v-for="item in capabilityBadges(entry)"
:key="item.key"
location="top"
max-width="320"
>
<v-icon size="14">{{ item.icon }}</v-icon>
</span>
<span
<template #activator="{ props: badgeTooltipProps }">
<span
v-bind="badgeTooltipProps"
class="provider-model-row__badge"
:class="{
'provider-model-row__badge--enabled': item.enabled,
'provider-model-row__badge--disabled': !item.enabled
}"
@click.stop
>
<v-icon size="14">{{ item.icon }}</v-icon>
</span>
</template>
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(entry.metadata)"
class="provider-model-row__badge provider-model-row__badge--text"
location="top"
max-width="320"
>
{{ formatContextLimit(entry.metadata) }}
</span>
<template #activator="{ props: contextTooltipProps }">
<span
v-bind="contextTooltipProps"
class="provider-model-row__badge provider-model-row__badge--text provider-model-row__badge--enabled"
@click.stop
>
{{ formatContextLimit(entry.metadata) }}
</span>
</template>
<span>{{
tm('models.metadata.context', {
tokens: formatContextLimit(entry.metadata)
})
}}</span>
</v-tooltip>
</div>
</button>
@@ -94,14 +122,20 @@
@update:modelValue="emit('toggle-provider-enable', entry.provider, $event)"
></v-switch>
<v-btn
icon="mdi-connection"
size="small"
variant="text"
:disabled="!entry.provider.enable || isProviderSaving(entry.provider.id)"
:loading="isProviderTesting(entry.provider.id)"
@click.stop="emit('test-provider', entry.provider)"
></v-btn>
<v-tooltip location="top">
<template #activator="{ props: testTooltipProps }">
<v-btn
v-bind="testTooltipProps"
icon="mdi-connection"
size="small"
variant="text"
:disabled="!entry.provider.enable || isProviderSaving(entry.provider.id)"
:loading="isProviderTesting(entry.provider.id)"
@click.stop="emit('test-provider', entry.provider)"
></v-btn>
</template>
<span>{{ tm('models.testButton') }}</span>
</v-tooltip>
<v-btn
icon="mdi-cog-outline"
size="small"
@@ -117,8 +151,14 @@
</div>
</div>
</template>
<div><strong>{{ tm('models.tooltips.providerId') }}:</strong> {{ entry.provider.id }}</div>
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.provider.model }}</div>
<div>
<strong>{{ tm('models.tooltips.providerId') }}:</strong>
{{ entry.provider.id }}
</div>
<div>
<strong>{{ tm('models.tooltips.modelId') }}:</strong>
{{ entry.provider.model }}
</div>
</v-tooltip>
</div>
@@ -152,19 +192,47 @@
>
<div class="provider-model-row__title provider-model-row__title--mono">{{ entry.model }}</div>
<div class="provider-model-row__meta">
<span
v-for="item in capabilityIcons(entry.metadata)"
:key="item.icon"
class="provider-model-row__badge"
<v-tooltip
v-for="item in capabilityBadges(entry)"
:key="item.key"
location="top"
max-width="320"
>
<v-icon size="14">{{ item.icon }}</v-icon>
</span>
<span
<template #activator="{ props: badgeTooltipProps }">
<span
v-bind="badgeTooltipProps"
class="provider-model-row__badge"
:class="{
'provider-model-row__badge--enabled': item.enabled,
'provider-model-row__badge--disabled': !item.enabled
}"
@click.stop
>
<v-icon size="14">{{ item.icon }}</v-icon>
</span>
</template>
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(entry.metadata)"
class="provider-model-row__badge provider-model-row__badge--text"
location="top"
max-width="320"
>
{{ formatContextLimit(entry.metadata) }}
</span>
<template #activator="{ props: contextTooltipProps }">
<span
v-bind="contextTooltipProps"
class="provider-model-row__badge provider-model-row__badge--text provider-model-row__badge--enabled"
@click.stop
>
{{ formatContextLimit(entry.metadata) }}
</span>
</template>
<span>{{
tm('models.metadata.context', {
tokens: formatContextLimit(entry.metadata)
})
}}</span>
</v-tooltip>
</div>
</button>
@@ -179,7 +247,10 @@
</div>
</div>
</template>
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.model }}</div>
<div>
<strong>{{ tm('models.tooltips.modelId') }}:</strong>
{{ entry.model }}
</div>
</v-tooltip>
</div>
@@ -275,21 +346,64 @@ const availableEntries = computed(() =>
(props.entries || []).filter((entry) => entry.type === 'available')
)
const capabilityIcons = (metadata) => {
const icons = []
if (props.supportsImageInput(metadata)) {
icons.push({ icon: 'mdi-image-outline' })
}
if (props.supportsAudioInput(metadata)) {
icons.push({ icon: 'mdi-music-note-outline' })
}
if (props.supportsToolCall(metadata)) {
icons.push({ icon: 'mdi-wrench-outline' })
}
if (props.supportsReasoning(metadata)) {
icons.push({ icon: 'mdi-brain' })
}
return icons
const capabilityBadges = (entry) => {
const metadata = entry?.metadata
const provider = entry?.provider
const modalities = Array.isArray(provider?.modalities) ? provider.modalities : []
const isConfigured = entry?.type === 'configured'
const hasModelMetadata = Boolean(entry?.hasModelMetadata)
const definitions = [
{
key: 'image',
icon: 'mdi-image-outline',
supported: props.supportsImageInput(metadata),
enabled: !isConfigured || modalities.includes('image'),
label: props.tm('models.metadata.image')
},
{
key: 'audio',
icon: 'mdi-music-note-outline',
supported: props.supportsAudioInput(metadata),
enabled: !isConfigured || modalities.includes('audio'),
label: props.tm('models.metadata.audio')
},
{
key: 'tool_use',
icon: 'mdi-wrench-outline',
supported: props.supportsToolCall(metadata),
enabled: !isConfigured || modalities.includes('tool_use'),
label: props.tm('models.metadata.toolUse')
},
{
key: 'reasoning',
icon: 'mdi-brain',
supported: props.supportsReasoning(metadata),
enabled: !isConfigured || Boolean(provider?.reasoning),
label: props.tm('models.metadata.reasoning')
}
]
return definitions
.filter((item) => item.supported || (isConfigured && item.enabled))
.map((item) => {
const enabled = !isConfigured || !hasModelMetadata || item.enabled
let tooltip = props.tm('models.metadata.available', {
capability: item.label
})
if (isConfigured) {
tooltip = enabled
? props.tm('models.metadata.enabled', { capability: item.label })
: props.tm('models.metadata.supportedDisabled', {
capability: item.label
})
}
return {
key: item.key,
icon: item.icon,
enabled,
tooltip
}
})
}
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
@@ -445,13 +559,21 @@ const isProviderSaving = (providerId) => props.savingProviders.includes(provider
width: 24px;
height: 24px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.04);
color: rgba(var(--v-theme-on-surface), 0.58);
display: inline-flex;
align-items: center;
justify-content: center;
}
.provider-model-row__badge--enabled {
background: rgba(var(--v-theme-on-surface), 0.06);
color: rgba(var(--v-theme-on-surface), 0.72);
}
.provider-model-row__badge--disabled {
background: rgba(var(--v-theme-on-surface), 0.04);
color: rgba(var(--v-theme-on-surface), 0.34);
}
.provider-model-row__badge--text {
width: auto;
padding: 0 8px;

View File

@@ -106,7 +106,7 @@
<v-list-item-subtitle>{{ tm('providerSelector.clearSelectionSubtitle') }}</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedProvider === ''" color="primary">mdi-check-circle</v-icon>
<v-icon v-if="selectedProvider === ''">mdi-check-circle</v-icon>
</template>
</v-list-item>
@@ -124,10 +124,69 @@
<v-list-item-subtitle>
{{ provider.type || provider.provider_type || tm('providerSelector.unknownType') }}
<span v-if="provider.model">- {{ provider.model }}</span>
<span
v-if="capabilityBadges(provider).length || formatContextLimit(provider)"
class="provider-selector-meta"
>
<v-tooltip
v-for="item in capabilityBadges(provider)"
:key="item.key"
location="top"
max-width="320"
>
<template #activator="{ props: badgeTooltipProps }">
<span
v-bind="badgeTooltipProps"
class="provider-selector-meta__badge"
:class="{ 'provider-selector-meta__badge--disabled': !item.enabled }"
@click.stop
>
<v-icon size="13">{{ item.icon }}</v-icon>
</span>
</template>
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(provider)"
location="top"
max-width="320"
>
<template #activator="{ props: contextTooltipProps }">
<span
v-bind="contextTooltipProps"
class="provider-selector-meta__context"
@click.stop
>
{{ formatContextLimit(provider) }}
</span>
</template>
<span>{{
providerTm('models.metadata.context', {
tokens: formatContextLimit(provider)
})
}}</span>
</v-tooltip>
</span>
</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="isProviderSelected(provider.id)" color="primary">mdi-check-circle</v-icon>
<div class="provider-selector-actions" @click.stop>
<v-tooltip location="top">
<template #activator="{ props: testTooltipProps }">
<v-btn
v-bind="testTooltipProps"
icon="mdi-connection"
size="x-small"
variant="text"
:loading="isProviderTesting(provider.id)"
:disabled="!isProviderTestable(provider)"
@click.stop="testProvider(provider)"
/>
</template>
<span>{{ providerTm('models.testButton') }}</span>
</v-tooltip>
<v-icon v-if="isProviderSelected(provider.id)">mdi-check-circle</v-icon>
</div>
</template>
</v-list-item>
</v-list>
@@ -181,6 +240,8 @@
import { computed, ref, watch } from 'vue'
import { providerApi } from '@/api/v1'
import { useModuleI18n } from '@/i18n/composables'
import { useToast } from '@/utils/toast'
import { formatContextLimit, providerCapabilityBadges } from '@/utils/providerMetadata'
import ProviderChatCompletionPanel from '@/components/provider/ProviderChatCompletionPanel.vue'
import ProviderPage from '@/views/ProviderPage.vue'
@@ -209,6 +270,8 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const { tm } = useModuleI18n('core.shared')
const { tm: providerTm } = useModuleI18n('features/provider')
const { success: toastSuccess, error: toastError } = useToast()
const dialog = ref(false)
const providerList = ref([])
@@ -216,6 +279,7 @@ const loading = ref(false)
const selectedProvider = ref('')
const selectedProviders = ref([])
const providerDrawer = ref(false)
const testingProviders = ref([])
const hasSelection = computed(() => {
if (props.multiple) {
@@ -333,6 +397,42 @@ function isProviderSelected(providerId) {
return selectedProvider.value === providerId
}
function capabilityBadges(provider) {
return providerCapabilityBadges(provider, providerTm)
}
function isProviderTesting(providerId) {
return testingProviders.value.includes(providerId)
}
function isProviderTestable(provider) {
return Boolean(provider?.id) && provider.enable !== false && !isProviderTesting(provider.id)
}
async function testProvider(provider) {
if (!isProviderTestable(provider)) {
return
}
testingProviders.value.push(provider.id)
try {
const startTime = performance.now()
const response = await providerApi.test(String(provider.id))
if (response.data.status === 'ok' && response.data.data.error === null) {
const latency = Math.max(0, Math.round(performance.now() - startTime))
toastSuccess(providerTm('models.testSuccessWithLatency', {
id: provider.id,
latency
}))
} else {
throw new Error(response.data.data.error || providerTm('models.testError'))
}
} catch (error) {
toastError(error.response?.data?.message || error.message || providerTm('models.testError'))
} finally {
testingProviders.value = testingProviders.value.filter((id) => id !== provider.id)
}
}
function removeSelected(providerId) {
const idx = selectedProviders.value.indexOf(providerId)
if (idx >= 0) {
@@ -384,6 +484,44 @@ function closeProviderDrawer() {
border-radius: 10px;
}
.provider-selector-meta {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
vertical-align: middle;
}
.provider-selector-meta__badge {
display: inline-flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.72);
}
.provider-selector-meta__badge--disabled {
color: rgba(var(--v-theme-on-surface), 0.34);
}
.provider-selector-meta__context {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 5px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.06);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 10px;
font-weight: 650;
line-height: 16px;
}
.provider-selector-actions {
display: inline-flex;
align-items: center;
gap: 2px;
color: rgba(var(--v-theme-on-surface), 0.72);
}
.v-list-item {
transition: all 0.2s ease;
}

View File

@@ -58,7 +58,9 @@ export function useProviderModelConfigDialog(options: UseProviderModelConfigDial
})
function openProviderEdit(provider: any) {
providerEditData.value = JSON.parse(JSON.stringify(provider))
const editableProvider = JSON.parse(JSON.stringify(provider))
delete editableProvider.model_metadata
providerEditData.value = editableProvider
providerEditOriginalId.value = provider.id
providerEditMode.value = 'edit'
showProviderEditDialog.value = true
@@ -89,6 +91,7 @@ export function useProviderModelConfigDialog(options: UseProviderModelConfigDial
savingProviders.value.push(providerEditData.value.id)
try {
delete providerEditData.value.model_metadata
const isAdding = providerEditMode.value === 'add'
const sourceId = providerEditData.value.provider_source_id || selectedProviderSource.value?.id
if (isAdding && !sourceId) {

View File

@@ -150,11 +150,15 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
}
const mergedModelEntries = computed(() => {
const configuredEntries = (sourceProviders.value || []).map((provider: any) => ({
type: 'configured',
provider,
metadata: getModelMetadata(provider.model) || buildMetadataFromProvider(provider)
}))
const configuredEntries = (sourceProviders.value || []).map((provider: any) => {
const metadata = getModelMetadata(provider.model) || provider.model_metadata || null
return {
type: 'configured',
provider,
metadata: metadata || buildMetadataFromProvider(provider),
hasModelMetadata: Boolean(metadata)
}
})
const availableEntries = (sortedAvailableModels.value || [])
.filter((item: any) => {
@@ -166,7 +170,8 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
return {
type: 'available',
model: name,
metadata: typeof item === 'object' ? item?.metadata : getModelMetadata(name)
metadata: typeof item === 'object' ? item?.metadata : getModelMetadata(name),
hasModelMetadata: Boolean(typeof item === 'object' ? item?.metadata : getModelMetadata(name))
}
})

View File

@@ -146,6 +146,17 @@
"manualModelRequired": "Please enter a model ID",
"manualModelExists": "Model already exists",
"configure": "Configure",
"testButton": "Test model",
"metadata": {
"image": "Image input",
"audio": "Audio input",
"toolUse": "Tool use",
"reasoning": "Reasoning",
"context": "{tokens} context window",
"available": "Model metadata supports {capability}",
"enabled": "{capability} enabled",
"supportedDisabled": "Model metadata supports {capability}, but it is not enabled in this provider's modalities."
},
"tooltips": {
"providerId": "Provider ID",
"modelId": "Model ID"

View File

@@ -136,6 +136,7 @@
"deleteSuccess": "Модель удалена",
"deleteError": "Ошибка удаления модели",
"testSuccess": "Тест модели «{id}» пройден успешно",
"testSuccessWithLatency": "Тест модели «{id}» пройден успешно, задержка {latency} мс",
"testError": "Тест модели провален",
"searchPlaceholder": "Поиск по имени или ID",
"manualAddButton": "Добавить вручную",
@@ -146,6 +147,17 @@
"manualModelRequired": "Укажите ID модели",
"manualModelExists": "Эта модель уже добавлена",
"configure": "Настроить",
"testButton": "Тестировать модель",
"metadata": {
"image": "Ввод изображений",
"audio": "Ввод аудио",
"toolUse": "Использование инструментов",
"reasoning": "Рассуждение",
"context": "Контекстное окно {tokens}",
"available": "Metadata модели поддерживает {capability}",
"enabled": "{capability} включено",
"supportedDisabled": "Metadata модели поддерживает {capability}, но эта возможность не включена в modalities текущего провайдера."
},
"tooltips": {
"providerId": "ID провайдера",
"modelId": "ID модели"

View File

@@ -147,6 +147,17 @@
"manualModelRequired": "请输入模型 ID",
"manualModelExists": "该模型已存在",
"configure": "配置",
"testButton": "测试模型",
"metadata": {
"image": "图像输入",
"audio": "音频输入",
"toolUse": "工具使用",
"reasoning": "推理",
"context": "{tokens} 上下文窗口",
"available": "模型 metadata 支持{capability}",
"enabled": "已启用{capability}",
"supportedDisabled": "模型 metadata 支持{capability},但当前提供商的模型能力未启用。"
},
"tooltips": {
"providerId": "提供商 ID",
"modelId": "模型 ID"

View File

@@ -0,0 +1,79 @@
export interface ProviderModelMetadata {
modalities?: { input?: string[] }
tool_call?: boolean
reasoning?: boolean
limit?: { context?: number }
}
export interface ProviderMetadataSource {
modalities?: string[]
model_metadata?: ProviderModelMetadata | null
max_context_tokens?: number
reasoning?: boolean
}
export interface ProviderCapabilityBadge {
key: string
icon: string
enabled: boolean
tooltip: string
}
export function formatContextLimit(provider: ProviderMetadataSource | null | undefined): string {
const context = provider?.model_metadata?.limit?.context || provider?.max_context_tokens
if (!context || typeof context !== 'number') return ''
if (context >= 1_000_000) return `${Math.round(context / 1_000_000)}M`
if (context >= 1_000) return `${Math.round(context / 1_000)}K`
return `${context}`
}
export function providerCapabilityBadges(
provider: ProviderMetadataSource | null | undefined,
tm: (key: string, params?: Record<string, string>) => string
): ProviderCapabilityBadge[] {
const inputs = provider?.model_metadata?.modalities?.input || []
const providerModalities = provider?.modalities
const modalities = Array.isArray(providerModalities) ? providerModalities : []
const definitions = [
{
key: 'image',
icon: 'mdi-image-outline',
supported: inputs.includes('image'),
enabled: modalities.includes('image'),
label: tm('models.metadata.image')
},
{
key: 'audio',
icon: 'mdi-music-note-outline',
supported: inputs.includes('audio'),
enabled: modalities.includes('audio'),
label: tm('models.metadata.audio')
},
{
key: 'tool_use',
icon: 'mdi-wrench-outline',
supported: Boolean(provider?.model_metadata?.tool_call),
enabled: modalities.includes('tool_use'),
label: tm('models.metadata.toolUse')
},
{
key: 'reasoning',
icon: 'mdi-brain',
supported: Boolean(provider?.model_metadata?.reasoning),
enabled: Boolean(provider?.reasoning),
label: tm('models.metadata.reasoning')
}
]
return definitions
.filter((item) => item.supported || item.enabled)
.map((item) => ({
key: item.key,
icon: item.icon,
enabled: !provider?.model_metadata || item.enabled,
tooltip:
provider?.model_metadata && !item.enabled
? tm('models.metadata.supportedDisabled', { capability: item.label })
: tm('models.metadata.enabled', { capability: item.label })
}))
}