feat(models): add model capability metadata
This commit is contained in:
parent
b3c6c1fa7a
commit
affbf60945
@ -115,6 +115,10 @@ SQLite Storage(Repository 模式)
|
||||
|
||||
`/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 实时状态。
|
||||
|
||||
@ -43,6 +43,13 @@ class ModelProtocol(str, Enum):
|
||||
DASHSCOPE = "dashscope"
|
||||
|
||||
|
||||
class ModelModality(str, Enum):
|
||||
TEXT = "text"
|
||||
IMAGE = "image"
|
||||
AUDIO = "audio"
|
||||
VIDEO = "video"
|
||||
|
||||
|
||||
class ModelPurpose(str, Enum):
|
||||
GENERATOR = "generator"
|
||||
JUDGE = "judge"
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
@ -9,7 +10,7 @@ from sqlmodel import Session
|
||||
|
||||
from agenteval.config import get_settings
|
||||
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.model_config_repository import ModelConfigRepository
|
||||
|
||||
@ -49,8 +50,19 @@ class ModelRuntimeConfig:
|
||||
model_name: str | None
|
||||
api_key: str | 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 {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
@ -58,6 +70,17 @@ class ModelRuntimeConfig:
|
||||
"capability": self.capability.value,
|
||||
"endpoint_url": self.endpoint_url,
|
||||
"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,
|
||||
}
|
||||
|
||||
@ -123,6 +146,36 @@ class ModelConfigService:
|
||||
if is_default and not enabled:
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
@ -135,10 +188,24 @@ class ModelConfigService:
|
||||
enabled: bool,
|
||||
is_default: bool,
|
||||
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:
|
||||
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_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):
|
||||
raise ModelConfigError("模型配置名称已存在")
|
||||
config = ModelConfigDB(
|
||||
@ -147,11 +214,21 @@ class ModelConfigService:
|
||||
capability=capability,
|
||||
endpoint_url=endpoint_url.strip(),
|
||||
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),
|
||||
enabled=enabled,
|
||||
is_default=is_default,
|
||||
description=description.strip(),
|
||||
)
|
||||
config.set_modalities(input_modalities, output_modalities)
|
||||
return self.repo.create(config)
|
||||
|
||||
def update(
|
||||
@ -168,11 +245,25 @@ class ModelConfigService:
|
||||
enabled: bool,
|
||||
is_default: bool,
|
||||
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:
|
||||
config = self.require(config_id)
|
||||
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_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)
|
||||
if references and not enabled:
|
||||
raise ModelConfigError("模型配置正在被场景引用,不能停用")
|
||||
@ -190,6 +281,16 @@ class ModelConfigService:
|
||||
config.capability = capability
|
||||
config.endpoint_url = endpoint_url.strip()
|
||||
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.is_default = is_default
|
||||
config.description = description.strip()
|
||||
@ -227,6 +328,17 @@ class ModelConfigService:
|
||||
model_name=config.model_name,
|
||||
api_key=self.cipher.decrypt(config.api_key_encrypted),
|
||||
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:
|
||||
|
||||
@ -120,6 +120,17 @@ class ModelConfigDB(SQLModel, table=True):
|
||||
capability: str = Field(index=True)
|
||||
endpoint_url: str
|
||||
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
|
||||
enabled: bool = True
|
||||
is_default: bool = False
|
||||
@ -127,6 +138,16 @@ class ModelConfigDB(SQLModel, table=True):
|
||||
created_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):
|
||||
"""Bind one model configuration to a purpose within a scenario."""
|
||||
|
||||
@ -4,7 +4,7 @@ from datetime import datetime
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -14,6 +14,17 @@ class ModelConfigCreate(BaseModel):
|
||||
capability: ModelCapability
|
||||
endpoint_url: str
|
||||
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
|
||||
enabled: bool = True
|
||||
is_default: bool = False
|
||||
@ -31,6 +42,17 @@ class ModelConfigResponse(BaseModel):
|
||||
capability: ModelCapability
|
||||
endpoint_url: str
|
||||
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
|
||||
enabled: bool
|
||||
is_default: bool
|
||||
@ -47,6 +69,17 @@ class ModelConfigResponse(BaseModel):
|
||||
capability=ModelCapability(config.capability),
|
||||
endpoint_url=config.endpoint_url,
|
||||
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),
|
||||
enabled=config.enabled,
|
||||
is_default=config.is_default,
|
||||
|
||||
@ -42,6 +42,7 @@ export interface Scenario {
|
||||
|
||||
export type ModelCapability = 'chat' | 'embedding' | 'moderation'
|
||||
export type ModelProtocol = 'openai_compatible' | 'anthropic' | 'google_gemini' | 'dashscope'
|
||||
export type ModelModality = 'text' | 'image' | 'audio' | 'video'
|
||||
|
||||
export interface ModelConfig {
|
||||
id: string
|
||||
@ -50,6 +51,17 @@ export interface ModelConfig {
|
||||
capability: ModelCapability
|
||||
endpoint_url: string
|
||||
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
|
||||
enabled: boolean
|
||||
is_default: boolean
|
||||
@ -64,6 +76,17 @@ export interface ModelConfigPayload {
|
||||
capability: ModelCapability
|
||||
endpoint_url: string
|
||||
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
|
||||
clear_api_key?: boolean
|
||||
enabled: boolean
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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'
|
||||
import {
|
||||
ApiOutlined, CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
modelConfigsApi, type ModelCapability, type ModelConfig, type ModelConfigPayload, type ModelProtocol,
|
||||
modelConfigsApi, type ModelCapability, type ModelConfig, type ModelConfigPayload, type ModelModality,
|
||||
type ModelProtocol,
|
||||
} from '../api'
|
||||
import PageWrapper from '../components/PageWrapper'
|
||||
import { formatDateTime } from '../utils/date'
|
||||
@ -24,6 +26,34 @@ const capabilityColors: Record<ModelCapability, string> = {
|
||||
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, {
|
||||
label: string
|
||||
capabilities: ModelCapability[]
|
||||
@ -79,7 +109,22 @@ export default function ModelConfigsPage() {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
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)
|
||||
}
|
||||
@ -92,6 +137,17 @@ export default function ModelConfigsPage() {
|
||||
capability: config.capability,
|
||||
endpoint_url: config.endpoint_url,
|
||||
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,
|
||||
clear_api_key: false,
|
||||
enabled: config.enabled,
|
||||
@ -114,6 +170,9 @@ export default function ModelConfigsPage() {
|
||||
...values,
|
||||
api_key: values.api_key || null,
|
||||
model_name: values.model_name || null,
|
||||
vendor_name: values.vendor_name || '',
|
||||
region: values.region || '',
|
||||
documentation_url: values.documentation_url || null,
|
||||
description: values.description || '',
|
||||
}
|
||||
if (editing) {
|
||||
@ -148,12 +207,21 @@ export default function ModelConfigsPage() {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '配置名称', dataIndex: 'name', key: 'name', width: 190,
|
||||
title: '配置名称', dataIndex: 'name', key: 'name', width: 220,
|
||||
render: (value: string, record: ModelConfig) => (
|
||||
<Space size={6}>
|
||||
<span style={{ fontWeight: 500 }}>{value}</span>
|
||||
{record.is_default && <Tag color="gold" style={{ margin: 0 }}>默认</Tag>}
|
||||
</Space>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Space size={6}>
|
||||
<Tooltip title={record.description || undefined}>
|
||||
<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,
|
||||
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,
|
||||
render: (value: boolean) => value ? <Tag color="success">已配置</Tag> : <Tag>无</Tag>,
|
||||
@ -241,22 +355,63 @@ export default function ModelConfigsPage() {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 15, showSizeChanger: true, showTotal: (total) => `共 ${total} 个` }}
|
||||
scroll={{ y: 'calc(100vh - 200px)' }}
|
||||
scroll={{ x: 1800, y: 'calc(100vh - 200px)' }}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
title={editing ? '编辑模型配置' : '新增模型配置'}
|
||||
width={520}
|
||||
width="min(680px, 100vw)"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Button type="primary" icon={<CheckCircleOutlined />} onClick={submit}>保存</Button>}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Divider orientation="left" plain style={{ marginTop: 0 }}>基础信息</Divider>
|
||||
<Form.Item name="name" label="配置名称" rules={[{ required: true, message: '请输入配置名称' }]}>
|
||||
<Input placeholder="例如:评估对话模型" />
|
||||
</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 }}>
|
||||
<Form.Item name="provider" label="协议" rules={[{ required: true }]}>
|
||||
<Select
|
||||
@ -288,9 +443,6 @@ export default function ModelConfigsPage() {
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input.TextArea rows={3} placeholder="该配置的使用范围或注意事项" />
|
||||
</Form.Item>
|
||||
<Space size={24}>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
|
||||
<Form.Item name="is_default" label="设为该能力默认配置" valuePropName="checked"><Switch /></Form.Item>
|
||||
|
||||
65
migrations/versions/a64b2f8c9d10_add_model_metadata.py
Normal file
65
migrations/versions/a64b2f8c9d10_add_model_metadata.py
Normal 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")
|
||||
@ -57,13 +57,33 @@ def _payload(**overrides) -> dict:
|
||||
def test_model_config_crud_never_returns_secret(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
|
||||
created = created_response.json()
|
||||
assert created["has_api_key"] is True
|
||||
assert "api_key" not in created
|
||||
assert "api_key_encrypted" not in created
|
||||
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"])
|
||||
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(
|
||||
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.json()["has_api_key"] is True
|
||||
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.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 "不支持 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
|
||||
|
||||
@ -138,3 +138,64 @@ def test_chat_only_protocols_accept_chat_and_reject_other_capabilities(db_sessio
|
||||
is_default=False,
|
||||
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",
|
||||
)
|
||||
|
||||
51
tests/unit/test_model_metadata_migration.py
Normal file
51
tests/unit/test_model_metadata_migration.py
Normal 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,
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user