feat(models): support mainstream model protocols
This commit is contained in:
parent
457dfed252
commit
cc79d3a625
@ -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
|
||||
|
||||
import httpx
|
||||
|
||||
from agenteval.model_protocols import ProtocolAdapterError, get_protocol_adapter
|
||||
from agenteval.services.model_configs import ModelRuntimeConfig
|
||||
from agenteval.utils.llm import extract_content_from_llm_response
|
||||
|
||||
|
||||
class ModelGatewayError(RuntimeError):
|
||||
@ -13,22 +13,17 @@ class ModelGatewayError(RuntimeError):
|
||||
|
||||
|
||||
class ModelGateway:
|
||||
def __init__(self, timeout: float = 60.0):
|
||||
def __init__(self, timeout: float = 60.0, transport: httpx.AsyncBaseTransport | None = None):
|
||||
self.timeout = timeout
|
||||
|
||||
@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
|
||||
self.transport = transport
|
||||
|
||||
async def _post(self, config: ModelRuntimeConfig, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
adapter = self._adapter(config)
|
||||
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(
|
||||
config.endpoint_url,
|
||||
headers=self._headers(config),
|
||||
headers=adapter.headers(config.api_key),
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
@ -44,42 +39,41 @@ class ModelGateway:
|
||||
except Exception as 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(
|
||||
self,
|
||||
config: ModelRuntimeConfig,
|
||||
messages: list[dict[str, str]],
|
||||
temperature: float = 0.2,
|
||||
) -> str:
|
||||
payload = {
|
||||
"model": config.model_name,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
content = extract_content_from_llm_response(await self._post(config, payload))
|
||||
if not content:
|
||||
raise ModelGatewayError("模型接口返回内容为空")
|
||||
return content
|
||||
adapter = self._adapter(config)
|
||||
try:
|
||||
payload = adapter.chat_payload(config.model_name, messages, temperature)
|
||||
return adapter.parse_chat(await self._post(config, payload))
|
||||
except ProtocolAdapterError as exc:
|
||||
raise ModelGatewayError(str(exc)) from exc
|
||||
|
||||
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:
|
||||
rows = sorted(data["data"], key=lambda item: item.get("index", 0))
|
||||
return [row["embedding"] for row in rows]
|
||||
except (KeyError, TypeError) as exc:
|
||||
raise ModelGatewayError("Embedding 接口返回格式不正确") from exc
|
||||
payload = adapter.embedding_payload(config.model_name, inputs)
|
||||
return adapter.parse_embeddings(await self._post(config, payload))
|
||||
except ProtocolAdapterError as exc:
|
||||
raise ModelGatewayError(str(exc)) from exc
|
||||
|
||||
async def moderate(self, config: ModelRuntimeConfig, text: str) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"input": text}
|
||||
if config.model_name:
|
||||
payload["model"] = config.model_name
|
||||
data = await self._post(config, payload)
|
||||
adapter = self._adapter(config)
|
||||
try:
|
||||
result = data["results"][0]
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise ModelGatewayError("Moderation 接口返回格式不正确") from exc
|
||||
if not isinstance(result, dict):
|
||||
raise ModelGatewayError("Moderation 接口返回格式不正确")
|
||||
return result
|
||||
payload = adapter.moderation_payload(config.model_name, text)
|
||||
return adapter.parse_moderation(await self._post(config, payload))
|
||||
except ProtocolAdapterError as exc:
|
||||
raise ModelGatewayError(str(exc)) from exc
|
||||
|
||||
async def test_connection(self, config: ModelRuntimeConfig) -> str:
|
||||
if config.capability.value == "chat":
|
||||
|
||||
39
backend/agenteval/model_protocols/__init__.py
Normal file
39
backend/agenteval/model_protocols/__init__.py
Normal 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",
|
||||
]
|
||||
57
backend/agenteval/model_protocols/anthropic.py
Normal file
57
backend/agenteval/model_protocols/anthropic.py
Normal 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
|
||||
49
backend/agenteval/model_protocols/base.py
Normal file
49
backend/agenteval/model_protocols/base.py
Normal 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
|
||||
45
backend/agenteval/model_protocols/dashscope.py
Normal file
45
backend/agenteval/model_protocols/dashscope.py
Normal 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)
|
||||
56
backend/agenteval/model_protocols/gemini.py
Normal file
56
backend/agenteval/model_protocols/gemini.py
Normal 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
|
||||
61
backend/agenteval/model_protocols/openai.py
Normal file
61
backend/agenteval/model_protocols/openai.py
Normal 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
|
||||
@ -36,6 +36,13 @@ class ModelCapability(str, Enum):
|
||||
MODERATION = "moderation"
|
||||
|
||||
|
||||
class ModelProtocol(str, Enum):
|
||||
OPENAI_COMPATIBLE = "openai_compatible"
|
||||
ANTHROPIC = "anthropic"
|
||||
GOOGLE_GEMINI = "google_gemini"
|
||||
DASHSCOPE = "dashscope"
|
||||
|
||||
|
||||
class ModelPurpose(str, Enum):
|
||||
GENERATOR = "generator"
|
||||
JUDGE = "judge"
|
||||
|
||||
@ -8,6 +8,7 @@ from cryptography.fernet import Fernet, InvalidToken
|
||||
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.storage.db import ModelConfigDB, ScenarioDB
|
||||
from agenteval.storage.model_config_repository import ModelConfigRepository
|
||||
@ -94,11 +95,17 @@ class ModelConfigService:
|
||||
self.cipher = cipher or SecretCipher()
|
||||
|
||||
@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:
|
||||
capability_value = ModelCapability(capability)
|
||||
except ValueError as 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)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
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:
|
||||
if not name.strip():
|
||||
raise ModelConfigError("模型配置名称不能为空")
|
||||
if provider != "openai_compatible":
|
||||
raise ModelConfigError(f"不支持的模型协议: {provider}")
|
||||
try:
|
||||
get_protocol_adapter(provider)
|
||||
except ProtocolAdapterError as exc:
|
||||
raise ModelConfigError(str(exc)) from exc
|
||||
if is_default and not enabled:
|
||||
raise ModelConfigError("停用的模型配置不能设为默认")
|
||||
|
||||
@ -129,7 +138,7 @@ class ModelConfigService:
|
||||
) -> ModelConfigDB:
|
||||
name = name.strip()
|
||||
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):
|
||||
raise ModelConfigError("模型配置名称已存在")
|
||||
config = ModelConfigDB(
|
||||
@ -163,7 +172,7 @@ class ModelConfigService:
|
||||
config = self.require(config_id)
|
||||
name = name.strip()
|
||||
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)
|
||||
if references and not enabled:
|
||||
raise ModelConfigError("模型配置正在被场景引用,不能停用")
|
||||
|
||||
@ -4,13 +4,13 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agenteval.models import ModelCapability
|
||||
from agenteval.models import ModelCapability, ModelProtocol
|
||||
from agenteval.storage.db import ModelConfigDB, iso_utc
|
||||
|
||||
|
||||
class ModelConfigCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
provider: str = "openai_compatible"
|
||||
provider: ModelProtocol = ModelProtocol.OPENAI_COMPATIBLE
|
||||
capability: ModelCapability
|
||||
endpoint_url: str
|
||||
model_name: str | None = None
|
||||
@ -27,7 +27,7 @@ class ModelConfigUpdate(ModelConfigCreate):
|
||||
class ModelConfigResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
provider: str
|
||||
provider: ModelProtocol
|
||||
capability: ModelCapability
|
||||
endpoint_url: str
|
||||
model_name: str | None
|
||||
@ -43,7 +43,7 @@ class ModelConfigResponse(BaseModel):
|
||||
return cls(
|
||||
id=config.id or "",
|
||||
name=config.name,
|
||||
provider=config.provider,
|
||||
provider=ModelProtocol(config.provider),
|
||||
capability=ModelCapability(config.capability),
|
||||
endpoint_url=config.endpoint_url,
|
||||
model_name=config.model_name,
|
||||
|
||||
@ -41,11 +41,12 @@ export interface Scenario {
|
||||
}
|
||||
|
||||
export type ModelCapability = 'chat' | 'embedding' | 'moderation'
|
||||
export type ModelProtocol = 'openai_compatible' | 'anthropic' | 'google_gemini' | 'dashscope'
|
||||
|
||||
export interface ModelConfig {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
provider: ModelProtocol
|
||||
capability: ModelCapability
|
||||
endpoint_url: string
|
||||
model_name: string | null
|
||||
@ -59,7 +60,7 @@ export interface ModelConfig {
|
||||
|
||||
export interface ModelConfigPayload {
|
||||
name: string
|
||||
provider: string
|
||||
provider: ModelProtocol
|
||||
capability: ModelCapability
|
||||
endpoint_url: string
|
||||
model_name?: string | null
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
ApiOutlined, CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
modelConfigsApi, type ModelCapability, type ModelConfig, type ModelConfigPayload,
|
||||
modelConfigsApi, type ModelCapability, type ModelConfig, type ModelConfigPayload, type ModelProtocol,
|
||||
} from '../api'
|
||||
import PageWrapper from '../components/PageWrapper'
|
||||
import { formatDateTime } from '../utils/date'
|
||||
@ -24,6 +24,33 @@ const capabilityColors: Record<ModelCapability, string> = {
|
||||
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() {
|
||||
const [configs, setConfigs] = useState<ModelConfig[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@ -33,6 +60,8 @@ export default function ModelConfigsPage() {
|
||||
const [capability, setCapability] = useState<ModelCapability | undefined>()
|
||||
const [enabled, setEnabled] = useState<boolean | undefined>()
|
||||
const [form] = Form.useForm<ModelConfigPayload>()
|
||||
const selectedProtocol = Form.useWatch('provider', form) || 'openai_compatible'
|
||||
const selectedProtocolMeta = protocolOptions[selectedProtocol]
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
@ -72,6 +101,13 @@ export default function ModelConfigsPage() {
|
||||
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 values = await form.validateFields()
|
||||
const payload: ModelConfigPayload = {
|
||||
@ -120,6 +156,10 @@ export default function ModelConfigsPage() {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '协议', dataIndex: 'provider', key: 'provider', width: 150,
|
||||
render: (value: ModelProtocol) => <Tag>{protocolOptions[value]?.label || value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '能力', dataIndex: 'capability', key: 'capability', width: 110,
|
||||
render: (value: ModelCapability) => <Tag color={capabilityColors[value]}>{capabilityLabels[value]}</Tag>,
|
||||
@ -219,14 +259,19 @@ export default function ModelConfigsPage() {
|
||||
</Form.Item>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<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 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>
|
||||
</div>
|
||||
<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 shouldUpdate noStyle>
|
||||
{() => form.getFieldValue('capability') !== 'moderation' && (
|
||||
|
||||
@ -126,3 +126,35 @@ def test_scenario_rejects_capability_mismatch(model_client):
|
||||
)
|
||||
assert response.status_code == 400
|
||||
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"]
|
||||
|
||||
@ -108,3 +108,33 @@ def test_failed_binding_does_not_create_scenario(db_session):
|
||||
with pytest.raises(ModelConfigError):
|
||||
ScenarioRepository(db_session).create(scenario)
|
||||
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="",
|
||||
)
|
||||
|
||||
146
tests/unit/test_model_gateway.py
Normal file
146
tests/unit/test_model_gateway.py
Normal 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")
|
||||
Loading…
Reference in New Issue
Block a user