feat(models): support mainstream model protocols

This commit is contained in:
sinohqb 2026-07-17 20:58:27 +08:00
parent 457dfed252
commit cc79d3a625
15 changed files with 622 additions and 51 deletions

View File

@ -1,11 +1,11 @@
"""Shared OpenAI-compatible model transport used by evaluation features.""" """Shared multi-protocol model transport used by evaluation features."""
from typing import Any from typing import Any
import httpx import httpx
from agenteval.model_protocols import ProtocolAdapterError, get_protocol_adapter
from agenteval.services.model_configs import ModelRuntimeConfig from agenteval.services.model_configs import ModelRuntimeConfig
from agenteval.utils.llm import extract_content_from_llm_response
class ModelGatewayError(RuntimeError): class ModelGatewayError(RuntimeError):
@ -13,22 +13,17 @@ class ModelGatewayError(RuntimeError):
class ModelGateway: class ModelGateway:
def __init__(self, timeout: float = 60.0): def __init__(self, timeout: float = 60.0, transport: httpx.AsyncBaseTransport | None = None):
self.timeout = timeout self.timeout = timeout
self.transport = transport
@staticmethod
def _headers(config: ModelRuntimeConfig) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if config.api_key:
headers["Authorization"] = f"Bearer {config.api_key}"
return headers
async def _post(self, config: ModelRuntimeConfig, payload: dict[str, Any]) -> dict[str, Any]: async def _post(self, config: ModelRuntimeConfig, payload: dict[str, Any]) -> dict[str, Any]:
adapter = self._adapter(config)
try: try:
async with httpx.AsyncClient(timeout=self.timeout) as client: async with httpx.AsyncClient(timeout=self.timeout, transport=self.transport) as client:
response = await client.post( response = await client.post(
config.endpoint_url, config.endpoint_url,
headers=self._headers(config), headers=adapter.headers(config.api_key),
json=payload, json=payload,
) )
response.raise_for_status() response.raise_for_status()
@ -44,42 +39,41 @@ class ModelGateway:
except Exception as exc: except Exception as exc:
raise ModelGatewayError(f"模型接口调用失败: {exc}") from exc raise ModelGatewayError(f"模型接口调用失败: {exc}") from exc
@staticmethod
def _adapter(config: ModelRuntimeConfig):
try:
return get_protocol_adapter(config.provider)
except ProtocolAdapterError as exc:
raise ModelGatewayError(str(exc)) from exc
async def chat( async def chat(
self, self,
config: ModelRuntimeConfig, config: ModelRuntimeConfig,
messages: list[dict[str, str]], messages: list[dict[str, str]],
temperature: float = 0.2, temperature: float = 0.2,
) -> str: ) -> str:
payload = { adapter = self._adapter(config)
"model": config.model_name, try:
"messages": messages, payload = adapter.chat_payload(config.model_name, messages, temperature)
"temperature": temperature, return adapter.parse_chat(await self._post(config, payload))
} except ProtocolAdapterError as exc:
content = extract_content_from_llm_response(await self._post(config, payload)) raise ModelGatewayError(str(exc)) from exc
if not content:
raise ModelGatewayError("模型接口返回内容为空")
return content
async def embed(self, config: ModelRuntimeConfig, inputs: str | list[str]) -> list[list[float]]: async def embed(self, config: ModelRuntimeConfig, inputs: str | list[str]) -> list[list[float]]:
data = await self._post(config, {"model": config.model_name, "input": inputs}) adapter = self._adapter(config)
try: try:
rows = sorted(data["data"], key=lambda item: item.get("index", 0)) payload = adapter.embedding_payload(config.model_name, inputs)
return [row["embedding"] for row in rows] return adapter.parse_embeddings(await self._post(config, payload))
except (KeyError, TypeError) as exc: except ProtocolAdapterError as exc:
raise ModelGatewayError("Embedding 接口返回格式不正确") from exc raise ModelGatewayError(str(exc)) from exc
async def moderate(self, config: ModelRuntimeConfig, text: str) -> dict[str, Any]: async def moderate(self, config: ModelRuntimeConfig, text: str) -> dict[str, Any]:
payload: dict[str, Any] = {"input": text} adapter = self._adapter(config)
if config.model_name:
payload["model"] = config.model_name
data = await self._post(config, payload)
try: try:
result = data["results"][0] payload = adapter.moderation_payload(config.model_name, text)
except (KeyError, IndexError, TypeError) as exc: return adapter.parse_moderation(await self._post(config, payload))
raise ModelGatewayError("Moderation 接口返回格式不正确") from exc except ProtocolAdapterError as exc:
if not isinstance(result, dict): raise ModelGatewayError(str(exc)) from exc
raise ModelGatewayError("Moderation 接口返回格式不正确")
return result
async def test_connection(self, config: ModelRuntimeConfig) -> str: async def test_connection(self, config: ModelRuntimeConfig) -> str:
if config.capability.value == "chat": if config.capability.value == "chat":

View File

@ -0,0 +1,39 @@
"""Registry of supported external model protocols."""
from agenteval.models import ModelCapability, ModelProtocol
from .anthropic import AnthropicAdapter
from .base import ModelProtocolAdapter, ProtocolAdapterError
from .dashscope import DashScopeAdapter
from .gemini import GoogleGeminiAdapter
from .openai import OpenAICompatibleAdapter
_ADAPTERS: dict[ModelProtocol, ModelProtocolAdapter] = {
adapter.protocol: adapter
for adapter in (
OpenAICompatibleAdapter(),
AnthropicAdapter(),
GoogleGeminiAdapter(),
DashScopeAdapter(),
)
}
def get_protocol_adapter(provider: str | ModelProtocol) -> ModelProtocolAdapter:
try:
protocol = ModelProtocol(provider)
return _ADAPTERS[protocol]
except (ValueError, KeyError) as exc:
raise ProtocolAdapterError(f"不支持的模型协议: {provider}") from exc
def get_protocol_capabilities(provider: str | ModelProtocol) -> frozenset[ModelCapability]:
return get_protocol_adapter(provider).supported_capabilities
__all__ = [
"ModelProtocolAdapter",
"ProtocolAdapterError",
"get_protocol_adapter",
"get_protocol_capabilities",
]

View File

@ -0,0 +1,57 @@
"""Anthropic Messages API protocol."""
from typing import Any
from agenteval.models import ModelCapability, ModelProtocol
from .base import ModelProtocolAdapter, ProtocolAdapterError
class AnthropicAdapter(ModelProtocolAdapter):
protocol = ModelProtocol.ANTHROPIC
supported_capabilities = frozenset({ModelCapability.CHAT})
def headers(self, api_key: str | None) -> dict[str, str]:
headers = super().headers(api_key)
headers["anthropic-version"] = "2023-06-01"
if api_key:
headers["x-api-key"] = api_key
return headers
def chat_payload(
self,
model_name: str | None,
messages: list[dict[str, str]],
temperature: float,
) -> dict[str, Any]:
system_parts: list[str] = []
api_messages: list[dict[str, str]] = []
for message in messages:
role = message.get("role", "user")
content = message.get("content", "")
if role in {"system", "developer"}:
system_parts.append(content)
else:
api_messages.append({"role": "assistant" if role == "assistant" else "user", "content": content})
payload: dict[str, Any] = {
"model": self.require_model(model_name),
"max_tokens": 1024,
"messages": api_messages,
"temperature": temperature,
}
if system_parts:
payload["system"] = "\n\n".join(system_parts)
return payload
def parse_chat(self, data: dict[str, Any]) -> str:
blocks = data.get("content")
if not isinstance(blocks, list):
raise ProtocolAdapterError("Anthropic 接口返回格式不正确")
content = "\n".join(
str(block.get("text", ""))
for block in blocks
if isinstance(block, dict) and block.get("type") == "text" and block.get("text")
)
if not content:
raise ProtocolAdapterError("模型接口返回内容为空")
return content

View File

@ -0,0 +1,49 @@
"""Protocol adapter contract for external model APIs."""
from typing import Any
from agenteval.models import ModelCapability, ModelProtocol
class ProtocolAdapterError(ValueError):
pass
class ModelProtocolAdapter:
protocol: ModelProtocol
supported_capabilities: frozenset[ModelCapability] = frozenset()
def headers(self, api_key: str | None) -> dict[str, str]:
return {"Content-Type": "application/json"}
def chat_payload(
self,
model_name: str | None,
messages: list[dict[str, str]],
temperature: float,
) -> dict[str, Any]:
self._unsupported(ModelCapability.CHAT)
def parse_chat(self, data: dict[str, Any]) -> str:
self._unsupported(ModelCapability.CHAT)
def embedding_payload(self, model_name: str | None, inputs: str | list[str]) -> dict[str, Any]:
self._unsupported(ModelCapability.EMBEDDING)
def parse_embeddings(self, data: dict[str, Any]) -> list[list[float]]:
self._unsupported(ModelCapability.EMBEDDING)
def moderation_payload(self, model_name: str | None, text: str) -> dict[str, Any]:
self._unsupported(ModelCapability.MODERATION)
def parse_moderation(self, data: dict[str, Any]) -> dict[str, Any]:
self._unsupported(ModelCapability.MODERATION)
def _unsupported(self, capability: ModelCapability) -> None:
raise ProtocolAdapterError(f"{self.protocol.value} 协议不支持 {capability.value} 能力")
@staticmethod
def require_model(model_name: str | None) -> str:
if not model_name:
raise ProtocolAdapterError("模型名称不能为空")
return model_name

View File

@ -0,0 +1,45 @@
"""Alibaba Cloud Model Studio native DashScope protocol."""
from typing import Any
from agenteval.models import ModelCapability, ModelProtocol
from .base import ModelProtocolAdapter, ProtocolAdapterError
class DashScopeAdapter(ModelProtocolAdapter):
protocol = ModelProtocol.DASHSCOPE
supported_capabilities = frozenset({ModelCapability.CHAT})
def headers(self, api_key: str | None) -> dict[str, str]:
headers = super().headers(api_key)
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
def chat_payload(
self,
model_name: str | None,
messages: list[dict[str, str]],
temperature: float,
) -> dict[str, Any]:
return {
"model": self.require_model(model_name),
"input": {"messages": messages},
"parameters": {"result_format": "message", "temperature": temperature},
}
def parse_chat(self, data: dict[str, Any]) -> str:
try:
content = data["output"]["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise ProtocolAdapterError("DashScope 接口返回格式不正确") from exc
if isinstance(content, list):
content = "\n".join(
str(block.get("text") or block.get("content") or "")
for block in content
if isinstance(block, dict)
)
if not content:
raise ProtocolAdapterError("模型接口返回内容为空")
return str(content)

View File

@ -0,0 +1,56 @@
"""Google Gemini generateContent REST protocol."""
from typing import Any
from agenteval.models import ModelCapability, ModelProtocol
from .base import ModelProtocolAdapter, ProtocolAdapterError
class GoogleGeminiAdapter(ModelProtocolAdapter):
protocol = ModelProtocol.GOOGLE_GEMINI
supported_capabilities = frozenset({ModelCapability.CHAT})
def headers(self, api_key: str | None) -> dict[str, str]:
headers = super().headers(api_key)
if api_key:
headers["x-goog-api-key"] = api_key
return headers
def chat_payload(
self,
model_name: str | None,
messages: list[dict[str, str]],
temperature: float,
) -> dict[str, Any]:
self.require_model(model_name)
system_parts: list[dict[str, str]] = []
contents: list[dict[str, Any]] = []
for message in messages:
role = message.get("role", "user")
part = {"text": message.get("content", "")}
if role in {"system", "developer"}:
system_parts.append(part)
else:
contents.append({"role": "model" if role == "assistant" else "user", "parts": [part]})
payload: dict[str, Any] = {
"contents": contents,
"generationConfig": {"temperature": temperature},
}
if system_parts:
payload["systemInstruction"] = {"parts": system_parts}
return payload
def parse_chat(self, data: dict[str, Any]) -> str:
try:
parts = data["candidates"][0]["content"]["parts"]
except (KeyError, IndexError, TypeError) as exc:
raise ProtocolAdapterError("Gemini 接口返回格式不正确") from exc
content = "\n".join(
str(part.get("text", ""))
for part in parts
if isinstance(part, dict) and part.get("text")
)
if not content:
raise ProtocolAdapterError("模型接口返回内容为空")
return content

View File

@ -0,0 +1,61 @@
"""OpenAI-compatible chat, embedding, and moderation protocol."""
from typing import Any
from agenteval.models import ModelCapability, ModelProtocol
from agenteval.utils.llm import extract_content_from_llm_response
from .base import ModelProtocolAdapter, ProtocolAdapterError
class OpenAICompatibleAdapter(ModelProtocolAdapter):
protocol = ModelProtocol.OPENAI_COMPATIBLE
supported_capabilities = frozenset(ModelCapability)
def headers(self, api_key: str | None) -> dict[str, str]:
headers = super().headers(api_key)
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
def chat_payload(
self,
model_name: str | None,
messages: list[dict[str, str]],
temperature: float,
) -> dict[str, Any]:
return {"model": self.require_model(model_name), "messages": messages, "temperature": temperature}
def parse_chat(self, data: dict[str, Any]) -> str:
content = extract_content_from_llm_response(data)
if not content:
raise ProtocolAdapterError("模型接口返回内容为空")
return content
def embedding_payload(self, model_name: str | None, inputs: str | list[str]) -> dict[str, Any]:
return {"model": self.require_model(model_name), "input": inputs}
def parse_embeddings(self, data: dict[str, Any]) -> list[list[float]]:
try:
rows = sorted(data["data"], key=lambda item: item.get("index", 0))
vectors = [row["embedding"] for row in rows]
except (KeyError, TypeError) as exc:
raise ProtocolAdapterError("Embedding 接口返回格式不正确") from exc
if not all(isinstance(vector, list) for vector in vectors):
raise ProtocolAdapterError("Embedding 接口返回格式不正确")
return vectors
def moderation_payload(self, model_name: str | None, text: str) -> dict[str, Any]:
payload: dict[str, Any] = {"input": text}
if model_name:
payload["model"] = model_name
return payload
def parse_moderation(self, data: dict[str, Any]) -> dict[str, Any]:
try:
result = data["results"][0]
except (KeyError, IndexError, TypeError) as exc:
raise ProtocolAdapterError("Moderation 接口返回格式不正确") from exc
if not isinstance(result, dict):
raise ProtocolAdapterError("Moderation 接口返回格式不正确")
return result

View File

@ -36,6 +36,13 @@ class ModelCapability(str, Enum):
MODERATION = "moderation" MODERATION = "moderation"
class ModelProtocol(str, Enum):
OPENAI_COMPATIBLE = "openai_compatible"
ANTHROPIC = "anthropic"
GOOGLE_GEMINI = "google_gemini"
DASHSCOPE = "dashscope"
class ModelPurpose(str, Enum): class ModelPurpose(str, Enum):
GENERATOR = "generator" GENERATOR = "generator"
JUDGE = "judge" JUDGE = "judge"

View File

@ -8,6 +8,7 @@ from cryptography.fernet import Fernet, InvalidToken
from sqlmodel import Session 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.models import ModelCapability, ModelPurpose from agenteval.models import ModelCapability, 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
@ -94,11 +95,17 @@ class ModelConfigService:
self.cipher = cipher or SecretCipher() self.cipher = cipher or SecretCipher()
@staticmethod @staticmethod
def validate_fields(capability: str, endpoint_url: str, model_name: str | None) -> None: def validate_fields(provider: str, capability: str, endpoint_url: str, model_name: str | None) -> None:
try: try:
capability_value = ModelCapability(capability) capability_value = ModelCapability(capability)
except ValueError as exc: except ValueError as exc:
raise ModelConfigError(f"不支持的模型能力: {capability}") from exc raise ModelConfigError(f"不支持的模型能力: {capability}") from exc
try:
adapter = get_protocol_adapter(provider)
except ProtocolAdapterError as exc:
raise ModelConfigError(str(exc)) from exc
if capability_value not in adapter.supported_capabilities:
raise ModelConfigError(f"{provider} 协议不支持 {capability_value.value} 能力")
parsed = urlparse(endpoint_url) parsed = urlparse(endpoint_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc: if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ModelConfigError("Endpoint 必须是有效的 HTTP 或 HTTPS URL") raise ModelConfigError("Endpoint 必须是有效的 HTTP 或 HTTPS URL")
@ -109,8 +116,10 @@ class ModelConfigService:
def validate_common(name: str, provider: str, enabled: bool, is_default: bool) -> None: def validate_common(name: str, provider: str, enabled: bool, is_default: bool) -> None:
if not name.strip(): if not name.strip():
raise ModelConfigError("模型配置名称不能为空") raise ModelConfigError("模型配置名称不能为空")
if provider != "openai_compatible": try:
raise ModelConfigError(f"不支持的模型协议: {provider}") get_protocol_adapter(provider)
except ProtocolAdapterError as exc:
raise ModelConfigError(str(exc)) from exc
if is_default and not enabled: if is_default and not enabled:
raise ModelConfigError("停用的模型配置不能设为默认") raise ModelConfigError("停用的模型配置不能设为默认")
@ -129,7 +138,7 @@ class ModelConfigService:
) -> ModelConfigDB: ) -> ModelConfigDB:
name = name.strip() name = name.strip()
self.validate_common(name, provider, enabled, is_default) self.validate_common(name, provider, enabled, is_default)
self.validate_fields(capability, endpoint_url.strip(), model_name) self.validate_fields(provider, capability, endpoint_url.strip(), model_name)
if self.repo.get_by_name(name): if self.repo.get_by_name(name):
raise ModelConfigError("模型配置名称已存在") raise ModelConfigError("模型配置名称已存在")
config = ModelConfigDB( config = ModelConfigDB(
@ -163,7 +172,7 @@ class ModelConfigService:
config = self.require(config_id) config = self.require(config_id)
name = name.strip() name = name.strip()
self.validate_common(name, provider, enabled, is_default) self.validate_common(name, provider, enabled, is_default)
self.validate_fields(capability, endpoint_url.strip(), model_name) self.validate_fields(provider, capability, endpoint_url.strip(), model_name)
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("模型配置正在被场景引用,不能停用")

View File

@ -4,13 +4,13 @@ from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from agenteval.models import ModelCapability from agenteval.models import ModelCapability, ModelProtocol
from agenteval.storage.db import ModelConfigDB, iso_utc from agenteval.storage.db import ModelConfigDB, iso_utc
class ModelConfigCreate(BaseModel): class ModelConfigCreate(BaseModel):
name: str = Field(min_length=1, max_length=100) name: str = Field(min_length=1, max_length=100)
provider: str = "openai_compatible" provider: ModelProtocol = ModelProtocol.OPENAI_COMPATIBLE
capability: ModelCapability capability: ModelCapability
endpoint_url: str endpoint_url: str
model_name: str | None = None model_name: str | None = None
@ -27,7 +27,7 @@ class ModelConfigUpdate(ModelConfigCreate):
class ModelConfigResponse(BaseModel): class ModelConfigResponse(BaseModel):
id: str id: str
name: str name: str
provider: str provider: ModelProtocol
capability: ModelCapability capability: ModelCapability
endpoint_url: str endpoint_url: str
model_name: str | None model_name: str | None
@ -43,7 +43,7 @@ class ModelConfigResponse(BaseModel):
return cls( return cls(
id=config.id or "", id=config.id or "",
name=config.name, name=config.name,
provider=config.provider, provider=ModelProtocol(config.provider),
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,

View File

@ -41,11 +41,12 @@ 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 interface ModelConfig { export interface ModelConfig {
id: string id: string
name: string name: string
provider: string provider: ModelProtocol
capability: ModelCapability capability: ModelCapability
endpoint_url: string endpoint_url: string
model_name: string | null model_name: string | null
@ -59,7 +60,7 @@ export interface ModelConfig {
export interface ModelConfigPayload { export interface ModelConfigPayload {
name: string name: string
provider: string provider: ModelProtocol
capability: ModelCapability capability: ModelCapability
endpoint_url: string endpoint_url: string
model_name?: string | null model_name?: string | null

View File

@ -6,7 +6,7 @@ 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, modelConfigsApi, type ModelCapability, type ModelConfig, type ModelConfigPayload, 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 +24,33 @@ const capabilityColors: Record<ModelCapability, string> = {
moderation: 'orange', moderation: 'orange',
} }
const protocolOptions: Record<ModelProtocol, {
label: string
capabilities: ModelCapability[]
endpointPlaceholder: string
}> = {
openai_compatible: {
label: 'OpenAI 兼容',
capabilities: ['chat', 'embedding', 'moderation'],
endpointPlaceholder: 'https://api.example.com/v1/chat/completions',
},
anthropic: {
label: 'Anthropic Messages',
capabilities: ['chat'],
endpointPlaceholder: 'https://api.anthropic.com/v1/messages',
},
google_gemini: {
label: 'Google Gemini',
capabilities: ['chat'],
endpointPlaceholder: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent',
},
dashscope: {
label: 'DashScope 原生',
capabilities: ['chat'],
endpointPlaceholder: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation',
},
}
export default function ModelConfigsPage() { export default function ModelConfigsPage() {
const [configs, setConfigs] = useState<ModelConfig[]>([]) const [configs, setConfigs] = useState<ModelConfig[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -33,6 +60,8 @@ export default function ModelConfigsPage() {
const [capability, setCapability] = useState<ModelCapability | undefined>() const [capability, setCapability] = useState<ModelCapability | undefined>()
const [enabled, setEnabled] = useState<boolean | undefined>() const [enabled, setEnabled] = useState<boolean | undefined>()
const [form] = Form.useForm<ModelConfigPayload>() const [form] = Form.useForm<ModelConfigPayload>()
const selectedProtocol = Form.useWatch('provider', form) || 'openai_compatible'
const selectedProtocolMeta = protocolOptions[selectedProtocol]
const load = async () => { const load = async () => {
setLoading(true) setLoading(true)
@ -72,6 +101,13 @@ export default function ModelConfigsPage() {
setDrawerOpen(true) setDrawerOpen(true)
} }
const changeProtocol = (provider: ModelProtocol) => {
const supportedCapabilities = protocolOptions[provider].capabilities
if (!supportedCapabilities.includes(form.getFieldValue('capability'))) {
form.setFieldValue('capability', supportedCapabilities[0])
}
}
const submit = async () => { const submit = async () => {
const values = await form.validateFields() const values = await form.validateFields()
const payload: ModelConfigPayload = { const payload: ModelConfigPayload = {
@ -120,6 +156,10 @@ export default function ModelConfigsPage() {
</Space> </Space>
), ),
}, },
{
title: '协议', dataIndex: 'provider', key: 'provider', width: 150,
render: (value: ModelProtocol) => <Tag>{protocolOptions[value]?.label || value}</Tag>,
},
{ {
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>,
@ -219,14 +259,19 @@ export default function ModelConfigsPage() {
</Form.Item> </Form.Item>
<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 options={[{ value: 'openai_compatible', label: 'OpenAI Compatible' }]} /> <Select
onChange={changeProtocol}
options={Object.entries(protocolOptions).map(([value, option]) => ({ value, label: option.label }))}
/>
</Form.Item> </Form.Item>
<Form.Item name="capability" label="能力" rules={[{ required: true }]}> <Form.Item name="capability" label="能力" rules={[{ required: true }]}>
<Select options={Object.entries(capabilityLabels).map(([value, label]) => ({ value, label }))} /> <Select
options={selectedProtocolMeta.capabilities.map((value) => ({ value, label: capabilityLabels[value] }))}
/>
</Form.Item> </Form.Item>
</div> </div>
<Form.Item name="endpoint_url" label="完整 Endpoint URL" rules={[{ required: true, message: '请输入 Endpoint URL' }, { type: 'url', message: '请输入有效 URL' }]}> <Form.Item name="endpoint_url" label="完整 Endpoint URL" rules={[{ required: true, message: '请输入 Endpoint URL' }, { type: 'url', message: '请输入有效 URL' }]}>
<Input placeholder="https://api.example.com/v1/chat/completions" /> <Input placeholder={selectedProtocolMeta.endpointPlaceholder} />
</Form.Item> </Form.Item>
<Form.Item shouldUpdate noStyle> <Form.Item shouldUpdate noStyle>
{() => form.getFieldValue('capability') !== 'moderation' && ( {() => form.getFieldValue('capability') !== 'moderation' && (

View File

@ -126,3 +126,35 @@ def test_scenario_rejects_capability_mismatch(model_client):
) )
assert response.status_code == 400 assert response.status_code == 400
assert "不能用于 chat" in response.json()["detail"] assert "不能用于 chat" in response.json()["detail"]
def test_model_config_api_supports_mainstream_protocols_and_validates_capability(model_client):
client, _ = model_client
for provider in ("anthropic", "google_gemini", "dashscope"):
response = client.post(
"/api/model-configs",
json=_payload(
name=f"{provider}-chat",
provider=provider,
endpoint_url=f"https://models.example.com/{provider}",
api_key=None,
is_default=False,
),
)
assert response.status_code == 200
assert response.json()["provider"] == provider
mismatch = client.post(
"/api/model-configs",
json=_payload(
name="anthropic-embedding",
provider="anthropic",
capability="embedding",
endpoint_url="https://models.example.com/anthropic/embeddings",
api_key=None,
is_default=False,
),
)
assert mismatch.status_code == 400
assert "不支持 embedding 能力" in mismatch.json()["detail"]

View File

@ -108,3 +108,33 @@ def test_failed_binding_does_not_create_scenario(db_session):
with pytest.raises(ModelConfigError): with pytest.raises(ModelConfigError):
ScenarioRepository(db_session).create(scenario) ScenarioRepository(db_session).create(scenario)
assert ScenarioRepository(db_session).get(scenario.id) is None assert ScenarioRepository(db_session).get(scenario.id) is None
@pytest.mark.parametrize("provider", ["anthropic", "google_gemini", "dashscope"])
def test_chat_only_protocols_accept_chat_and_reject_other_capabilities(db_session, provider):
service = _service(db_session)
created = service.create(
name=f"{provider}-chat",
provider=provider,
capability="chat",
endpoint_url=f"https://models.example.com/{provider}",
model_name="chat-model",
api_key=None,
enabled=True,
is_default=False,
description="",
)
assert created.provider == provider
with pytest.raises(ModelConfigError, match="不支持 embedding 能力"):
service.create(
name=f"{provider}-embedding",
provider=provider,
capability="embedding",
endpoint_url=f"https://models.example.com/{provider}/embeddings",
model_name="embedding-model",
api_key=None,
enabled=True,
is_default=False,
description="",
)

View File

@ -0,0 +1,146 @@
"""Request and response contract tests for supported model protocols."""
import json
from datetime import datetime
import httpx
import pytest
from agenteval.model_gateway import ModelGateway, ModelGatewayError
from agenteval.models import ModelCapability
from agenteval.services.model_configs import ModelRuntimeConfig
def _config(provider: str, capability: ModelCapability = ModelCapability.CHAT) -> ModelRuntimeConfig:
return ModelRuntimeConfig(
id=f"{provider}-config",
name=provider,
provider=provider,
capability=capability,
endpoint_url=f"https://models.example.com/{provider}",
model_name="test-model",
api_key="test-key",
updated_at=datetime(2026, 7, 17),
)
def _gateway(handler) -> ModelGateway:
return ModelGateway(transport=httpx.MockTransport(handler))
async def test_openai_compatible_chat_embedding_and_moderation_contracts():
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
payload = json.loads(request.content)
if "messages" in payload:
return httpx.Response(200, json={"choices": [{"message": {"content": "chat reply"}}]})
if payload.get("input") == ["first", "second"]:
return httpx.Response(
200,
json={"data": [{"index": 1, "embedding": [2.0]}, {"index": 0, "embedding": [1.0]}]},
)
return httpx.Response(200, json={"results": [{"flagged": False}]})
gateway = _gateway(handler)
config = _config("openai_compatible")
assert await gateway.chat(config, [{"role": "user", "content": "hello"}], temperature=0.3) == "chat reply"
assert await gateway.embed(config, ["first", "second"]) == [[1.0], [2.0]]
assert await gateway.moderate(config, "safe text") == {"flagged": False}
assert all(request.headers["authorization"] == "Bearer test-key" for request in requests)
assert json.loads(requests[0].content) == {
"model": "test-model",
"messages": [{"role": "user", "content": "hello"}],
"temperature": 0.3,
}
async def test_anthropic_messages_contract():
def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["x-api-key"] == "test-key"
assert request.headers["anthropic-version"] == "2023-06-01"
assert "authorization" not in request.headers
assert json.loads(request.content) == {
"model": "test-model",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
],
"temperature": 0.1,
"system": "be concise",
}
return httpx.Response(
200,
json={"content": [{"type": "text", "text": "first"}, {"type": "text", "text": "second"}]},
)
result = await _gateway(handler).chat(
_config("anthropic"),
[
{"role": "system", "content": "be concise"},
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
],
temperature=0.1,
)
assert result == "first\nsecond"
async def test_google_gemini_generate_content_contract():
def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["x-goog-api-key"] == "test-key"
assert json.loads(request.content) == {
"contents": [
{"role": "user", "parts": [{"text": "hello"}]},
{"role": "model", "parts": [{"text": "hi"}]},
],
"generationConfig": {"temperature": 0.2},
"systemInstruction": {"parts": [{"text": "be concise"}]},
}
return httpx.Response(
200,
json={"candidates": [{"content": {"parts": [{"text": "Gemini reply"}]}}]},
)
result = await _gateway(handler).chat(
_config("google_gemini"),
[
{"role": "system", "content": "be concise"},
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
],
)
assert result == "Gemini reply"
async def test_dashscope_native_contract():
def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["authorization"] == "Bearer test-key"
assert json.loads(request.content) == {
"model": "test-model",
"input": {"messages": [{"role": "user", "content": "hello"}]},
"parameters": {"result_format": "message", "temperature": 0.4},
}
return httpx.Response(
200,
json={"output": {"choices": [{"message": {"content": "DashScope reply"}}]}},
)
result = await _gateway(handler).chat(
_config("dashscope"),
[{"role": "user", "content": "hello"}],
temperature=0.4,
)
assert result == "DashScope reply"
@pytest.mark.parametrize("provider", ["anthropic", "google_gemini", "dashscope"])
async def test_chat_only_protocols_reject_embedding(provider: str):
with pytest.raises(ModelGatewayError, match="不支持 embedding 能力"):
await _gateway(lambda _: httpx.Response(500)).embed(_config(provider), "hello")