diff --git a/AGENTS.md b/AGENTS.md index 50dac69..0347ae9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 实时状态。 diff --git a/backend/agenteval/models.py b/backend/agenteval/models.py index fe53cdc..4c008cb 100644 --- a/backend/agenteval/models.py +++ b/backend/agenteval/models.py @@ -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" diff --git a/backend/agenteval/services/model_configs.py b/backend/agenteval/services/model_configs.py index 68146c4..d0db5ff 100644 --- a/backend/agenteval/services/model_configs.py +++ b/backend/agenteval/services/model_configs.py @@ -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: diff --git a/backend/agenteval/storage/db.py b/backend/agenteval/storage/db.py index a59c092..e8c921f 100644 --- a/backend/agenteval/storage/db.py +++ b/backend/agenteval/storage/db.py @@ -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.""" diff --git a/backend/agenteval/web/model_config_schemas.py b/backend/agenteval/web/model_config_schemas.py index 1cad5bc..91f8b9f 100644 --- a/backend/agenteval/web/model_config_schemas.py +++ b/backend/agenteval/web/model_config_schemas.py @@ -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, diff --git a/frontend/web/src/api.ts b/frontend/web/src/api.ts index 8d3ac73..75707d6 100644 --- a/frontend/web/src/api.ts +++ b/frontend/web/src/api.ts @@ -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 diff --git a/frontend/web/src/pages/ModelConfigs.tsx b/frontend/web/src/pages/ModelConfigs.tsx index 53fe762..031e43d 100644 --- a/frontend/web/src/pages/ModelConfigs.tsx +++ b/frontend/web/src/pages/ModelConfigs.tsx @@ -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 = { moderation: 'orange', } +const modalityLabels: Record = { + text: '文本', + image: '图像', + audio: '音频', + video: '视频', +} + +const modalityColors: Record = { + 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 ( - - {value} - {record.is_default && 默认} - +
+ + + {value} + + {record.is_default && 默认} + + {(record.vendor_name || record.region) && ( +
+ {[record.vendor_name, record.region].filter(Boolean).join(' · ')} +
+ )} +
), }, { @@ -164,8 +232,54 @@ export default function ModelConfigsPage() { title: '能力', dataIndex: 'capability', key: 'capability', width: 110, render: (value: ModelCapability) => {capabilityLabels[value]}, }, - { 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) => ( + + + 输入 + {record.input_modalities.map((value) => ( + + {modalityLabels[value]} + + ))} + + + 输出 + {record.output_modalities.map((value) => ( + + {modalityLabels[value]} + + ))} + + + ), + }, + { + 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) => ( +
+
上下文 {formatTokens(record.context_window)}
+
最大输出 {formatTokens(record.max_output_tokens)}
+
+ ), + }, + { + title: '特性', key: 'features', width: 210, + render: (_: unknown, record: ModelConfig) => { + const enabledFeatures = featureLabels.filter(([key]) => record[key]) + return enabledFeatures.length ? ( + + {enabledFeatures.map(([key, label]) => {label})} + + ) : '-' + }, + }, + { 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 ? 已配置 : , @@ -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%' }} /> setDrawerOpen(false)} extra={} >
+ 基础信息 +
+ + + + + + +
+
+ + + +
+
+ + + + + + +
+ + + + + + + + 模型特性 +
+ + + + +
+ + 连接设置