feat(models): add model capability metadata

This commit is contained in:
sinohqb 2026-07-17 21:41:32 +08:00
parent b3c6c1fa7a
commit affbf60945
11 changed files with 592 additions and 20 deletions

View File

@ -115,6 +115,10 @@ SQLite StorageRepository 模式)
`/api/files` 端点提供分类树 + 文件上传/下载功能。`FileCategoryDB` 自引用(`parent_id`)实现树形结构,`FileRecordDB` 关联分类。文件物理存储在 `data/uploads/`,按分类子目录组织。删除分类会级联删除子分类 + 文件记录 + 物理文件。 `/api/files` 端点提供分类树 + 文件上传/下载功能。`FileCategoryDB` 自引用(`parent_id`)实现树形结构,`FileRecordDB` 关联分类。文件物理存储在 `data/uploads/`,按分类子目录组织。删除分类会级联删除子分类 + 文件记录 + 物理文件。
### 模型配置中心
`/api/model-configs` 统一管理工程使用的外部模型连接。`provider` 表示调用协议,`capability` 表示评测用途能力(对话、向量、审核),`input_modalities` / `output_modalities` 表示模型自身支持的文本、图像、音频和视频模态,三者禁止混用。厂商、区域、上下文窗口、最大输出和模型特性属于描述性元数据,会写入评测运行快照,但不直接改变网关请求参数。
### 前端 ### 前端
SPA 由 FastAPI 托管(`GET /{full_path:path}` → `index.html`)。`frontend/web/src/api.ts` 是所有接口定义的单一出口axios 拦截器统一处理错误。`useRunSession.ts` hook 管理 WebSocket 实时状态。 SPA 由 FastAPI 托管(`GET /{full_path:path}` → `index.html`)。`frontend/web/src/api.ts` 是所有接口定义的单一出口axios 拦截器统一处理错误。`useRunSession.ts` hook 管理 WebSocket 实时状态。

View File

@ -43,6 +43,13 @@ class ModelProtocol(str, Enum):
DASHSCOPE = "dashscope" DASHSCOPE = "dashscope"
class ModelModality(str, Enum):
TEXT = "text"
IMAGE = "image"
AUDIO = "audio"
VIDEO = "video"
class ModelPurpose(str, Enum): class ModelPurpose(str, Enum):
GENERATOR = "generator" GENERATOR = "generator"
JUDGE = "judge" JUDGE = "judge"

View File

@ -2,6 +2,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
@ -9,7 +10,7 @@ from sqlmodel import Session
from agenteval.config import get_settings from agenteval.config import get_settings
from agenteval.model_protocols import ProtocolAdapterError, get_protocol_adapter from agenteval.model_protocols import ProtocolAdapterError, get_protocol_adapter
from agenteval.models import ModelCapability, ModelPurpose from agenteval.models import ModelCapability, ModelModality, ModelPurpose
from agenteval.storage.db import ModelConfigDB, ScenarioDB from agenteval.storage.db import ModelConfigDB, ScenarioDB
from agenteval.storage.model_config_repository import ModelConfigRepository from agenteval.storage.model_config_repository import ModelConfigRepository
@ -49,8 +50,19 @@ class ModelRuntimeConfig:
model_name: str | None model_name: str | None
api_key: str | None api_key: str | None
updated_at: datetime | None updated_at: datetime | None
vendor_name: str = ""
input_modalities: tuple[str, ...] = (ModelModality.TEXT.value,)
output_modalities: tuple[str, ...] = (ModelModality.TEXT.value,)
context_window: int | None = None
max_output_tokens: int | None = None
supports_streaming: bool = False
supports_tool_calling: bool = False
supports_structured_output: bool = False
supports_reasoning: bool = False
region: str = ""
documentation_url: str | None = None
def snapshot(self) -> dict[str, str | None]: def snapshot(self) -> dict[str, Any]:
return { return {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
@ -58,6 +70,17 @@ class ModelRuntimeConfig:
"capability": self.capability.value, "capability": self.capability.value,
"endpoint_url": self.endpoint_url, "endpoint_url": self.endpoint_url,
"model_name": self.model_name, "model_name": self.model_name,
"vendor_name": self.vendor_name,
"input_modalities": list(self.input_modalities),
"output_modalities": list(self.output_modalities),
"context_window": self.context_window,
"max_output_tokens": self.max_output_tokens,
"supports_streaming": self.supports_streaming,
"supports_tool_calling": self.supports_tool_calling,
"supports_structured_output": self.supports_structured_output,
"supports_reasoning": self.supports_reasoning,
"region": self.region,
"documentation_url": self.documentation_url,
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None,
} }
@ -123,6 +146,36 @@ class ModelConfigService:
if is_default and not enabled: if is_default and not enabled:
raise ModelConfigError("停用的模型配置不能设为默认") raise ModelConfigError("停用的模型配置不能设为默认")
@staticmethod
def normalize_modalities(modalities: list[str] | None) -> list[str]:
values = modalities if modalities is not None else [ModelModality.TEXT.value]
normalized: list[str] = []
for value in values:
try:
modality = ModelModality(value).value
except ValueError as exc:
raise ModelConfigError(f"不支持的模型模态: {value}") from exc
if modality not in normalized:
normalized.append(modality)
if not normalized:
raise ModelConfigError("输入和输出模态至少选择一项")
return normalized
@staticmethod
def validate_metadata(
context_window: int | None,
max_output_tokens: int | None,
documentation_url: str | None,
) -> None:
if context_window is not None and context_window <= 0:
raise ModelConfigError("上下文窗口必须大于 0")
if max_output_tokens is not None and max_output_tokens <= 0:
raise ModelConfigError("最大输出 Token 必须大于 0")
if documentation_url:
parsed = urlparse(documentation_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ModelConfigError("官方文档地址必须是有效的 HTTP 或 HTTPS URL")
def create( def create(
self, self,
*, *,
@ -135,10 +188,24 @@ class ModelConfigService:
enabled: bool, enabled: bool,
is_default: bool, is_default: bool,
description: str, description: str,
vendor_name: str = "",
input_modalities: list[str] | None = None,
output_modalities: list[str] | None = None,
context_window: int | None = None,
max_output_tokens: int | None = None,
supports_streaming: bool = False,
supports_tool_calling: bool = False,
supports_structured_output: bool = False,
supports_reasoning: bool = False,
region: str = "",
documentation_url: str | None = None,
) -> ModelConfigDB: ) -> ModelConfigDB:
name = name.strip() name = name.strip()
input_modalities = self.normalize_modalities(input_modalities)
output_modalities = self.normalize_modalities(output_modalities)
self.validate_common(name, provider, enabled, is_default) self.validate_common(name, provider, enabled, is_default)
self.validate_fields(provider, capability, endpoint_url.strip(), model_name) self.validate_fields(provider, capability, endpoint_url.strip(), model_name)
self.validate_metadata(context_window, max_output_tokens, documentation_url)
if self.repo.get_by_name(name): if self.repo.get_by_name(name):
raise ModelConfigError("模型配置名称已存在") raise ModelConfigError("模型配置名称已存在")
config = ModelConfigDB( config = ModelConfigDB(
@ -147,11 +214,21 @@ class ModelConfigService:
capability=capability, capability=capability,
endpoint_url=endpoint_url.strip(), endpoint_url=endpoint_url.strip(),
model_name=model_name.strip() if model_name else None, model_name=model_name.strip() if model_name else None,
vendor_name=vendor_name.strip(),
context_window=context_window,
max_output_tokens=max_output_tokens,
supports_streaming=supports_streaming,
supports_tool_calling=supports_tool_calling,
supports_structured_output=supports_structured_output,
supports_reasoning=supports_reasoning,
region=region.strip(),
documentation_url=documentation_url.strip() if documentation_url else None,
api_key_encrypted=self.cipher.encrypt(api_key), api_key_encrypted=self.cipher.encrypt(api_key),
enabled=enabled, enabled=enabled,
is_default=is_default, is_default=is_default,
description=description.strip(), description=description.strip(),
) )
config.set_modalities(input_modalities, output_modalities)
return self.repo.create(config) return self.repo.create(config)
def update( def update(
@ -168,11 +245,25 @@ class ModelConfigService:
enabled: bool, enabled: bool,
is_default: bool, is_default: bool,
description: str, description: str,
vendor_name: str = "",
input_modalities: list[str] | None = None,
output_modalities: list[str] | None = None,
context_window: int | None = None,
max_output_tokens: int | None = None,
supports_streaming: bool = False,
supports_tool_calling: bool = False,
supports_structured_output: bool = False,
supports_reasoning: bool = False,
region: str = "",
documentation_url: str | None = None,
) -> ModelConfigDB: ) -> ModelConfigDB:
config = self.require(config_id) config = self.require(config_id)
name = name.strip() name = name.strip()
input_modalities = self.normalize_modalities(input_modalities)
output_modalities = self.normalize_modalities(output_modalities)
self.validate_common(name, provider, enabled, is_default) self.validate_common(name, provider, enabled, is_default)
self.validate_fields(provider, capability, endpoint_url.strip(), model_name) self.validate_fields(provider, capability, endpoint_url.strip(), model_name)
self.validate_metadata(context_window, max_output_tokens, documentation_url)
references = self.repo.list_references(config_id) references = self.repo.list_references(config_id)
if references and not enabled: if references and not enabled:
raise ModelConfigError("模型配置正在被场景引用,不能停用") raise ModelConfigError("模型配置正在被场景引用,不能停用")
@ -190,6 +281,16 @@ class ModelConfigService:
config.capability = capability config.capability = capability
config.endpoint_url = endpoint_url.strip() config.endpoint_url = endpoint_url.strip()
config.model_name = model_name.strip() if model_name else None config.model_name = model_name.strip() if model_name else None
config.vendor_name = vendor_name.strip()
config.set_modalities(input_modalities, output_modalities)
config.context_window = context_window
config.max_output_tokens = max_output_tokens
config.supports_streaming = supports_streaming
config.supports_tool_calling = supports_tool_calling
config.supports_structured_output = supports_structured_output
config.supports_reasoning = supports_reasoning
config.region = region.strip()
config.documentation_url = documentation_url.strip() if documentation_url else None
config.enabled = enabled config.enabled = enabled
config.is_default = is_default config.is_default = is_default
config.description = description.strip() config.description = description.strip()
@ -227,6 +328,17 @@ class ModelConfigService:
model_name=config.model_name, model_name=config.model_name,
api_key=self.cipher.decrypt(config.api_key_encrypted), api_key=self.cipher.decrypt(config.api_key_encrypted),
updated_at=config.updated_at, updated_at=config.updated_at,
vendor_name=config.vendor_name,
input_modalities=tuple(config.get_input_modalities()),
output_modalities=tuple(config.get_output_modalities()),
context_window=config.context_window,
max_output_tokens=config.max_output_tokens,
supports_streaming=config.supports_streaming,
supports_tool_calling=config.supports_tool_calling,
supports_structured_output=config.supports_structured_output,
supports_reasoning=config.supports_reasoning,
region=config.region,
documentation_url=config.documentation_url,
) )
def delete(self, config_id: str) -> None: def delete(self, config_id: str) -> None:

View File

@ -120,6 +120,17 @@ class ModelConfigDB(SQLModel, table=True):
capability: str = Field(index=True) capability: str = Field(index=True)
endpoint_url: str endpoint_url: str
model_name: Optional[str] = None model_name: Optional[str] = None
vendor_name: str = ""
input_modalities: str = '["text"]'
output_modalities: str = '["text"]'
context_window: Optional[int] = None
max_output_tokens: Optional[int] = None
supports_streaming: bool = False
supports_tool_calling: bool = False
supports_structured_output: bool = False
supports_reasoning: bool = False
region: str = ""
documentation_url: Optional[str] = None
api_key_encrypted: Optional[str] = None api_key_encrypted: Optional[str] = None
enabled: bool = True enabled: bool = True
is_default: bool = False is_default: bool = False
@ -127,6 +138,16 @@ class ModelConfigDB(SQLModel, table=True):
created_at: Optional[datetime] = Field(default_factory=utc_now) created_at: Optional[datetime] = Field(default_factory=utc_now)
updated_at: Optional[datetime] = Field(default_factory=utc_now) updated_at: Optional[datetime] = Field(default_factory=utc_now)
def get_input_modalities(self) -> list[str]:
return json.loads(self.input_modalities)
def get_output_modalities(self) -> list[str]:
return json.loads(self.output_modalities)
def set_modalities(self, input_modalities: list[str], output_modalities: list[str]) -> None:
self.input_modalities = json.dumps(input_modalities, ensure_ascii=True)
self.output_modalities = json.dumps(output_modalities, ensure_ascii=True)
class ScenarioModelBindingDB(SQLModel, table=True): class ScenarioModelBindingDB(SQLModel, table=True):
"""Bind one model configuration to a purpose within a scenario.""" """Bind one model configuration to a purpose within a scenario."""

View File

@ -4,7 +4,7 @@ from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from agenteval.models import ModelCapability, ModelProtocol from agenteval.models import ModelCapability, ModelModality, ModelProtocol
from agenteval.storage.db import ModelConfigDB, iso_utc from agenteval.storage.db import ModelConfigDB, iso_utc
@ -14,6 +14,17 @@ class ModelConfigCreate(BaseModel):
capability: ModelCapability capability: ModelCapability
endpoint_url: str endpoint_url: str
model_name: str | None = None model_name: str | None = None
vendor_name: str = ""
input_modalities: list[ModelModality] = Field(default_factory=lambda: [ModelModality.TEXT], min_length=1)
output_modalities: list[ModelModality] = Field(default_factory=lambda: [ModelModality.TEXT], min_length=1)
context_window: int | None = Field(default=None, gt=0)
max_output_tokens: int | None = Field(default=None, gt=0)
supports_streaming: bool = False
supports_tool_calling: bool = False
supports_structured_output: bool = False
supports_reasoning: bool = False
region: str = ""
documentation_url: str | None = None
api_key: str | None = None api_key: str | None = None
enabled: bool = True enabled: bool = True
is_default: bool = False is_default: bool = False
@ -31,6 +42,17 @@ class ModelConfigResponse(BaseModel):
capability: ModelCapability capability: ModelCapability
endpoint_url: str endpoint_url: str
model_name: str | None model_name: str | None
vendor_name: str
input_modalities: list[ModelModality]
output_modalities: list[ModelModality]
context_window: int | None
max_output_tokens: int | None
supports_streaming: bool
supports_tool_calling: bool
supports_structured_output: bool
supports_reasoning: bool
region: str
documentation_url: str | None
has_api_key: bool has_api_key: bool
enabled: bool enabled: bool
is_default: bool is_default: bool
@ -47,6 +69,17 @@ class ModelConfigResponse(BaseModel):
capability=ModelCapability(config.capability), capability=ModelCapability(config.capability),
endpoint_url=config.endpoint_url, endpoint_url=config.endpoint_url,
model_name=config.model_name, model_name=config.model_name,
vendor_name=config.vendor_name,
input_modalities=[ModelModality(item) for item in config.get_input_modalities()],
output_modalities=[ModelModality(item) for item in config.get_output_modalities()],
context_window=config.context_window,
max_output_tokens=config.max_output_tokens,
supports_streaming=config.supports_streaming,
supports_tool_calling=config.supports_tool_calling,
supports_structured_output=config.supports_structured_output,
supports_reasoning=config.supports_reasoning,
region=config.region,
documentation_url=config.documentation_url,
has_api_key=bool(config.api_key_encrypted), has_api_key=bool(config.api_key_encrypted),
enabled=config.enabled, enabled=config.enabled,
is_default=config.is_default, is_default=config.is_default,

View File

@ -42,6 +42,7 @@ export interface Scenario {
export type ModelCapability = 'chat' | 'embedding' | 'moderation' export type ModelCapability = 'chat' | 'embedding' | 'moderation'
export type ModelProtocol = 'openai_compatible' | 'anthropic' | 'google_gemini' | 'dashscope' export type ModelProtocol = 'openai_compatible' | 'anthropic' | 'google_gemini' | 'dashscope'
export type ModelModality = 'text' | 'image' | 'audio' | 'video'
export interface ModelConfig { export interface ModelConfig {
id: string id: string
@ -50,6 +51,17 @@ export interface ModelConfig {
capability: ModelCapability capability: ModelCapability
endpoint_url: string endpoint_url: string
model_name: string | null model_name: string | null
vendor_name: string
input_modalities: ModelModality[]
output_modalities: ModelModality[]
context_window: number | null
max_output_tokens: number | null
supports_streaming: boolean
supports_tool_calling: boolean
supports_structured_output: boolean
supports_reasoning: boolean
region: string
documentation_url: string | null
has_api_key: boolean has_api_key: boolean
enabled: boolean enabled: boolean
is_default: boolean is_default: boolean
@ -64,6 +76,17 @@ export interface ModelConfigPayload {
capability: ModelCapability capability: ModelCapability
endpoint_url: string endpoint_url: string
model_name?: string | null model_name?: string | null
vendor_name: string
input_modalities: ModelModality[]
output_modalities: ModelModality[]
context_window?: number | null
max_output_tokens?: number | null
supports_streaming: boolean
supports_tool_calling: boolean
supports_structured_output: boolean
supports_reasoning: boolean
region: string
documentation_url?: string | null
api_key?: string | null api_key?: string | null
clear_api_key?: boolean clear_api_key?: boolean
enabled: boolean enabled: boolean

View File

@ -1,12 +1,14 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
Button, Drawer, Form, Input, message, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip, Button, Divider, Drawer, Form, Input, InputNumber, message, Popconfirm, Select, Space, Switch, Table, Tag,
Tooltip,
} from 'antd' } from 'antd'
import { import {
ApiOutlined, CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, ApiOutlined, CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { import {
modelConfigsApi, type ModelCapability, type ModelConfig, type ModelConfigPayload, type ModelProtocol, modelConfigsApi, type ModelCapability, type ModelConfig, type ModelConfigPayload, type ModelModality,
type ModelProtocol,
} from '../api' } from '../api'
import PageWrapper from '../components/PageWrapper' import PageWrapper from '../components/PageWrapper'
import { formatDateTime } from '../utils/date' import { formatDateTime } from '../utils/date'
@ -24,6 +26,34 @@ const capabilityColors: Record<ModelCapability, string> = {
moderation: 'orange', moderation: 'orange',
} }
const modalityLabels: Record<ModelModality, string> = {
text: '文本',
image: '图像',
audio: '音频',
video: '视频',
}
const modalityColors: Record<ModelModality, string> = {
text: 'default',
image: 'cyan',
audio: 'magenta',
video: 'purple',
}
const modalityOptions = Object.entries(modalityLabels).map(([value, label]) => ({ value, label }))
const featureLabels: Array<[keyof Pick<
ModelConfig,
'supports_streaming' | 'supports_tool_calling' | 'supports_structured_output' | 'supports_reasoning'
>, string]> = [
['supports_streaming', '流式'],
['supports_tool_calling', '工具调用'],
['supports_structured_output', '结构化输出'],
['supports_reasoning', '推理'],
]
const formatTokens = (value: number | null) => value ? new Intl.NumberFormat('zh-CN').format(value) : '-'
const protocolOptions: Record<ModelProtocol, { const protocolOptions: Record<ModelProtocol, {
label: string label: string
capabilities: ModelCapability[] capabilities: ModelCapability[]
@ -79,7 +109,22 @@ export default function ModelConfigsPage() {
setEditing(null) setEditing(null)
form.resetFields() form.resetFields()
form.setFieldsValue({ form.setFieldsValue({
provider: 'openai_compatible', capability: 'chat', enabled: true, is_default: false, description: '', provider: 'openai_compatible',
capability: 'chat',
vendor_name: '',
input_modalities: ['text'],
output_modalities: ['text'],
context_window: null,
max_output_tokens: null,
supports_streaming: false,
supports_tool_calling: false,
supports_structured_output: false,
supports_reasoning: false,
region: '',
documentation_url: null,
enabled: true,
is_default: false,
description: '',
}) })
setDrawerOpen(true) setDrawerOpen(true)
} }
@ -92,6 +137,17 @@ export default function ModelConfigsPage() {
capability: config.capability, capability: config.capability,
endpoint_url: config.endpoint_url, endpoint_url: config.endpoint_url,
model_name: config.model_name, model_name: config.model_name,
vendor_name: config.vendor_name,
input_modalities: config.input_modalities,
output_modalities: config.output_modalities,
context_window: config.context_window,
max_output_tokens: config.max_output_tokens,
supports_streaming: config.supports_streaming,
supports_tool_calling: config.supports_tool_calling,
supports_structured_output: config.supports_structured_output,
supports_reasoning: config.supports_reasoning,
region: config.region,
documentation_url: config.documentation_url,
api_key: null, api_key: null,
clear_api_key: false, clear_api_key: false,
enabled: config.enabled, enabled: config.enabled,
@ -114,6 +170,9 @@ export default function ModelConfigsPage() {
...values, ...values,
api_key: values.api_key || null, api_key: values.api_key || null,
model_name: values.model_name || null, model_name: values.model_name || null,
vendor_name: values.vendor_name || '',
region: values.region || '',
documentation_url: values.documentation_url || null,
description: values.description || '', description: values.description || '',
} }
if (editing) { if (editing) {
@ -148,12 +207,21 @@ export default function ModelConfigsPage() {
const columns = [ const columns = [
{ {
title: '配置名称', dataIndex: 'name', key: 'name', width: 190, title: '配置名称', dataIndex: 'name', key: 'name', width: 220,
render: (value: string, record: ModelConfig) => ( render: (value: string, record: ModelConfig) => (
<Space size={6}> <div style={{ minWidth: 0 }}>
<span style={{ fontWeight: 500 }}>{value}</span> <Space size={6}>
{record.is_default && <Tag color="gold" style={{ margin: 0 }}></Tag>} <Tooltip title={record.description || undefined}>
</Space> <span style={{ fontWeight: 500 }}>{value}</span>
</Tooltip>
{record.is_default && <Tag color="gold" style={{ margin: 0 }}></Tag>}
</Space>
{(record.vendor_name || record.region) && (
<div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 3 }}>
{[record.vendor_name, record.region].filter(Boolean).join(' · ')}
</div>
)}
</div>
), ),
}, },
{ {
@ -164,8 +232,54 @@ export default function ModelConfigsPage() {
title: '能力', dataIndex: 'capability', key: 'capability', width: 110, title: '能力', dataIndex: 'capability', key: 'capability', width: 110,
render: (value: ModelCapability) => <Tag color={capabilityColors[value]}>{capabilityLabels[value]}</Tag>, render: (value: ModelCapability) => <Tag color={capabilityColors[value]}>{capabilityLabels[value]}</Tag>,
}, },
{ title: '模型', dataIndex: 'model_name', key: 'model_name', width: 180, render: (value: string | null) => value || '-' }, {
{ title: 'Endpoint', dataIndex: 'endpoint_url', key: 'endpoint_url', ellipsis: true }, title: '输入 / 输出', key: 'modalities', width: 230,
render: (_: unknown, record: ModelConfig) => (
<Space direction="vertical" size={3}>
<Space size={4} wrap>
<span style={{ color: '#8c8c8c', fontSize: 12, width: 28 }}></span>
{record.input_modalities.map((value) => (
<Tag key={`input-${value}`} color={modalityColors[value]} style={{ margin: 0 }}>
{modalityLabels[value]}
</Tag>
))}
</Space>
<Space size={4} wrap>
<span style={{ color: '#8c8c8c', fontSize: 12, width: 28 }}></span>
{record.output_modalities.map((value) => (
<Tag key={`output-${value}`} color={modalityColors[value]} style={{ margin: 0 }}>
{modalityLabels[value]}
</Tag>
))}
</Space>
</Space>
),
},
{
title: '模型', dataIndex: 'model_name', key: 'model_name', width: 180, ellipsis: true,
render: (value: string | null) => value || '-',
},
{
title: '规格', key: 'limits', width: 170,
render: (_: unknown, record: ModelConfig) => (
<div style={{ fontSize: 12, lineHeight: 1.7 }}>
<div><span style={{ color: '#8c8c8c' }}> </span>{formatTokens(record.context_window)}</div>
<div><span style={{ color: '#8c8c8c' }}> </span>{formatTokens(record.max_output_tokens)}</div>
</div>
),
},
{
title: '特性', key: 'features', width: 210,
render: (_: unknown, record: ModelConfig) => {
const enabledFeatures = featureLabels.filter(([key]) => record[key])
return enabledFeatures.length ? (
<Space size={[4, 4]} wrap>
{enabledFeatures.map(([key, label]) => <Tag key={key} style={{ margin: 0 }}>{label}</Tag>)}
</Space>
) : '-'
},
},
{ title: 'Endpoint', dataIndex: 'endpoint_url', key: 'endpoint_url', width: 260, ellipsis: true },
{ {
title: '凭据', dataIndex: 'has_api_key', key: 'has_api_key', width: 90, title: '凭据', dataIndex: 'has_api_key', key: 'has_api_key', width: 90,
render: (value: boolean) => value ? <Tag color="success"></Tag> : <Tag></Tag>, render: (value: boolean) => value ? <Tag color="success"></Tag> : <Tag></Tag>,
@ -241,22 +355,63 @@ export default function ModelConfigsPage() {
rowKey="id" rowKey="id"
loading={loading} loading={loading}
pagination={{ pageSize: 15, showSizeChanger: true, showTotal: (total) => `${total}` }} pagination={{ pageSize: 15, showSizeChanger: true, showTotal: (total) => `${total}` }}
scroll={{ y: 'calc(100vh - 200px)' }} scroll={{ x: 1800, y: 'calc(100vh - 200px)' }}
style={{ height: '100%' }} style={{ height: '100%' }}
/> />
</div> </div>
<Drawer <Drawer
title={editing ? '编辑模型配置' : '新增模型配置'} title={editing ? '编辑模型配置' : '新增模型配置'}
width={520} width="min(680px, 100vw)"
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={<Button type="primary" icon={<CheckCircleOutlined />} onClick={submit}></Button>} extra={<Button type="primary" icon={<CheckCircleOutlined />} onClick={submit}></Button>}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Divider orientation="left" plain style={{ marginTop: 0 }}></Divider>
<Form.Item name="name" label="配置名称" rules={[{ required: true, message: '请输入配置名称' }]}> <Form.Item name="name" label="配置名称" rules={[{ required: true, message: '请输入配置名称' }]}>
<Input placeholder="例如:评估对话模型" /> <Input placeholder="例如:评估对话模型" />
</Form.Item> </Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<Form.Item name="vendor_name" label="厂商 / 平台">
<Input placeholder="例如:阿里云百炼" />
</Form.Item>
<Form.Item name="region" label="部署区域">
<Input placeholder="例如:华北 2北京" />
</Form.Item>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<Form.Item name="input_modalities" label="输入模态" rules={[{ required: true, message: '请选择输入模态' }]}>
<Select mode="multiple" options={modalityOptions} />
</Form.Item>
<Form.Item name="output_modalities" label="输出模态" rules={[{ required: true, message: '请选择输出模态' }]}>
<Select mode="multiple" options={modalityOptions} />
</Form.Item>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<Form.Item name="context_window" label="上下文窗口Token">
<InputNumber min={1} precision={0} style={{ width: '100%' }} placeholder="例如128000" />
</Form.Item>
<Form.Item name="max_output_tokens" label="最大输出Token">
<InputNumber min={1} precision={0} style={{ width: '100%' }} placeholder="例如8192" />
</Form.Item>
</div>
<Form.Item name="documentation_url" label="官方文档地址" rules={[{ type: 'url', message: '请输入有效 URL' }]}>
<Input placeholder="https://..." />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={3} placeholder="该配置的使用范围或注意事项" />
</Form.Item>
<Divider orientation="left" plain></Divider>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 20px' }}>
<Form.Item name="supports_streaming" label="流式输出" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="supports_tool_calling" label="工具调用" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="supports_structured_output" label="结构化输出" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="supports_reasoning" label="推理模型" valuePropName="checked"><Switch /></Form.Item>
</div>
<Divider orientation="left" plain></Divider>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<Form.Item name="provider" label="协议" rules={[{ required: true }]}> <Form.Item name="provider" label="协议" rules={[{ required: true }]}>
<Select <Select
@ -288,9 +443,6 @@ export default function ModelConfigsPage() {
<Switch /> <Switch />
</Form.Item> </Form.Item>
)} )}
<Form.Item name="description" label="说明">
<Input.TextArea rows={3} placeholder="该配置的使用范围或注意事项" />
</Form.Item>
<Space size={24}> <Space size={24}>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item> <Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="is_default" label="设为该能力默认配置" valuePropName="checked"><Switch /></Form.Item> <Form.Item name="is_default" label="设为该能力默认配置" valuePropName="checked"><Switch /></Form.Item>

View File

@ -0,0 +1,65 @@
"""add model metadata
Revision ID: a64b2f8c9d10
Revises: 8e91c70a5d3b
Create Date: 2026-07-17
"""
from typing import Sequence, Union
import sqlalchemy as sa
import sqlmodel
from alembic import op
revision: str = "a64b2f8c9d10"
down_revision: Union[str, Sequence[str], None] = "8e91c70a5d3b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("model_configs", schema=None) as batch_op:
batch_op.add_column(
sa.Column("vendor_name", sqlmodel.sql.sqltypes.AutoString(), nullable=False, server_default="")
)
batch_op.add_column(
sa.Column(
"input_modalities",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
server_default='["text"]',
)
)
batch_op.add_column(
sa.Column(
"output_modalities",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
server_default='["text"]',
)
)
batch_op.add_column(sa.Column("context_window", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("max_output_tokens", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("supports_streaming", sa.Boolean(), nullable=False, server_default=sa.false()))
batch_op.add_column(sa.Column("supports_tool_calling", sa.Boolean(), nullable=False, server_default=sa.false()))
batch_op.add_column(
sa.Column("supports_structured_output", sa.Boolean(), nullable=False, server_default=sa.false())
)
batch_op.add_column(sa.Column("supports_reasoning", sa.Boolean(), nullable=False, server_default=sa.false()))
batch_op.add_column(sa.Column("region", sqlmodel.sql.sqltypes.AutoString(), nullable=False, server_default=""))
batch_op.add_column(sa.Column("documentation_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("model_configs", schema=None) as batch_op:
batch_op.drop_column("documentation_url")
batch_op.drop_column("region")
batch_op.drop_column("supports_reasoning")
batch_op.drop_column("supports_structured_output")
batch_op.drop_column("supports_tool_calling")
batch_op.drop_column("supports_streaming")
batch_op.drop_column("max_output_tokens")
batch_op.drop_column("context_window")
batch_op.drop_column("output_modalities")
batch_op.drop_column("input_modalities")
batch_op.drop_column("vendor_name")

View File

@ -57,13 +57,33 @@ def _payload(**overrides) -> dict:
def test_model_config_crud_never_returns_secret(model_client): def test_model_config_crud_never_returns_secret(model_client):
client, session = model_client client, session = model_client
created_response = client.post("/api/model-configs", json=_payload()) metadata = {
"vendor_name": "Example AI",
"input_modalities": ["text", "image"],
"output_modalities": ["text"],
"context_window": 128000,
"max_output_tokens": 8192,
"supports_streaming": True,
"supports_tool_calling": True,
"supports_structured_output": True,
"supports_reasoning": False,
"region": "cn-test-1",
"documentation_url": "https://models.example.com/docs",
}
created_response = client.post("/api/model-configs", json=_payload(**metadata))
assert created_response.status_code == 200 assert created_response.status_code == 200
created = created_response.json() created = created_response.json()
assert created["has_api_key"] is True assert created["has_api_key"] is True
assert "api_key" not in created assert "api_key" not in created
assert "api_key_encrypted" not in created assert "api_key_encrypted" not in created
assert "sk-private" not in created_response.text assert "sk-private" not in created_response.text
assert created["vendor_name"] == "Example AI"
assert created["input_modalities"] == ["text", "image"]
assert created["output_modalities"] == ["text"]
assert created["context_window"] == 128000
assert created["max_output_tokens"] == 8192
assert created["supports_tool_calling"] is True
assert created["region"] == "cn-test-1"
stored = session.get(ModelConfigDB, created["id"]) stored = session.get(ModelConfigDB, created["id"])
assert stored and stored.api_key_encrypted assert stored and stored.api_key_encrypted
@ -75,11 +95,12 @@ def test_model_config_crud_never_returns_secret(model_client):
updated_response = client.put( updated_response = client.put(
f"/api/model-configs/{created['id']}", f"/api/model-configs/{created['id']}",
json=_payload(api_key=None, clear_api_key=False, model_name="judge-model-v2"), json=_payload(api_key=None, clear_api_key=False, model_name="judge-model-v2", **metadata),
) )
assert updated_response.status_code == 200 assert updated_response.status_code == 200
assert updated_response.json()["has_api_key"] is True assert updated_response.json()["has_api_key"] is True
assert updated_response.json()["model_name"] == "judge-model-v2" assert updated_response.json()["model_name"] == "judge-model-v2"
assert updated_response.json()["input_modalities"] == ["text", "image"]
assert client.delete(f"/api/model-configs/{created['id']}").status_code == 200 assert client.delete(f"/api/model-configs/{created['id']}").status_code == 200
assert client.get(f"/api/model-configs/{created['id']}").status_code == 404 assert client.get(f"/api/model-configs/{created['id']}").status_code == 404
@ -158,3 +179,25 @@ def test_model_config_api_supports_mainstream_protocols_and_validates_capability
) )
assert mismatch.status_code == 400 assert mismatch.status_code == 400
assert "不支持 embedding 能力" in mismatch.json()["detail"] assert "不支持 embedding 能力" in mismatch.json()["detail"]
def test_model_config_metadata_defaults_and_validation(model_client):
client, _ = model_client
created = client.post("/api/model-configs", json=_payload(api_key=None)).json()
assert created["input_modalities"] == ["text"]
assert created["output_modalities"] == ["text"]
assert created["vendor_name"] == ""
assert created["context_window"] is None
assert created["supports_reasoning"] is False
invalid_modality = client.post(
"/api/model-configs",
json=_payload(name="invalid-modality", api_key=None, input_modalities=["document"]),
)
assert invalid_modality.status_code == 422
invalid_context = client.post(
"/api/model-configs",
json=_payload(name="invalid-context", api_key=None, context_window=0),
)
assert invalid_context.status_code == 422

View File

@ -138,3 +138,64 @@ def test_chat_only_protocols_accept_chat_and_reject_other_capabilities(db_sessio
is_default=False, is_default=False,
description="", description="",
) )
def test_model_metadata_validation_and_runtime_snapshot(db_session):
service = _service(db_session)
config = service.create(
name="多模态模型",
provider="openai_compatible",
capability="chat",
endpoint_url="https://models.example.com/v1/chat/completions",
model_name="vision-model",
api_key=None,
enabled=True,
is_default=False,
description="",
vendor_name="Example AI",
input_modalities=["text", "image", "image"],
output_modalities=["text"],
context_window=128000,
max_output_tokens=8192,
supports_streaming=True,
supports_tool_calling=True,
region="cn-test-1",
documentation_url="https://models.example.com/docs",
)
runtime = service.resolve(config.id)
snapshot = runtime.snapshot()
assert snapshot["vendor_name"] == "Example AI"
assert snapshot["input_modalities"] == ["text", "image"]
assert snapshot["context_window"] == 128000
assert snapshot["supports_tool_calling"] is True
assert snapshot["region"] == "cn-test-1"
assert "api_key" not in snapshot
with pytest.raises(ModelConfigError, match="不支持的模型模态"):
service.create(
name="非法模态",
provider="openai_compatible",
capability="chat",
endpoint_url="https://models.example.com/v1/chat/completions",
model_name="invalid-model",
api_key=None,
enabled=True,
is_default=False,
description="",
input_modalities=["document"],
)
with pytest.raises(ModelConfigError, match="官方文档地址"):
service.create(
name="非法文档",
provider="openai_compatible",
capability="chat",
endpoint_url="https://models.example.com/v1/chat/completions",
model_name="invalid-docs",
api_key=None,
enabled=True,
is_default=False,
description="",
documentation_url="not-a-url",
)

View File

@ -0,0 +1,51 @@
"""Verify model metadata migration preserves existing configurations."""
import importlib
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
def test_model_metadata_migration_adds_safe_defaults(tmp_path, monkeypatch):
engine = sa.create_engine(f"sqlite:///{tmp_path / 'model_metadata.db'}")
metadata = sa.MetaData()
legacy_table = sa.Table(
"model_configs",
metadata,
sa.Column("id", sa.String(), primary_key=True),
sa.Column("name", sa.String(), nullable=False),
)
metadata.create_all(engine)
with engine.begin() as connection:
connection.execute(legacy_table.insert().values(id="existing", name="现有模型"))
operations = Operations(MigrationContext.configure(connection))
migration = importlib.import_module("migrations.versions.a64b2f8c9d10_add_model_metadata")
monkeypatch.setattr(migration, "op", operations)
migration.upgrade()
columns = {column["name"] for column in sa.inspect(connection).get_columns("model_configs")}
assert {
"vendor_name",
"input_modalities",
"output_modalities",
"context_window",
"supports_tool_calling",
"documentation_url",
} <= columns
row = connection.execute(
sa.text(
"SELECT name, vendor_name, input_modalities, output_modalities, supports_streaming "
"FROM model_configs WHERE id = :id"
),
{"id": "existing"},
).mappings().one()
assert row == {
"name": "现有模型",
"vendor_name": "",
"input_modalities": '["text"]',
"output_modalities": '["text"]',
"supports_streaming": 0,
}