All checks were successful
CI / test (pull_request) Successful in 3m55s
4.1 新增三个评估场景(急诊分诊、慢病管理、健康咨询),各 3 个用例,
全部使用无模型绑定依赖的规则;急诊场景编码 <20s 延迟验收标准
4.2 ModelGateway 由每次请求新建 httpx.AsyncClient 改为单实例共享客户端
(复用 TCP/TLS 连接),引擎与模型连通性测试端点负责关闭;
tutu 通道已具备同等优化,无需改动
4.3 Reports.tsx 单次报告顶部新增上线评估横幅:go/no-go/conditional
三态 banner + 各验收标准达标情况标签
版本号升至 1.3.1(v1.3.1-final)。
门禁:pytest tests/unit 709 passed;ruff 全绿;
前端 tsc --noEmit + vitest 232 passed。
附带修复 RunList 测试时区缺陷:started_at 用 UTC 日期构造,
本地 00:00-08:00 之间会被默认"今天"过滤器排除导致误报失败。
130 lines
5.2 KiB
Python
130 lines
5.2 KiB
Python
"""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
|
||
|
||
|
||
class ModelGatewayError(RuntimeError):
|
||
pass
|
||
|
||
|
||
class ModelGateway:
|
||
def __init__(self, timeout: float = 60.0, transport: httpx.AsyncBaseTransport | None = None):
|
||
self.timeout = timeout
|
||
self.transport = transport
|
||
# 评测侧 LLM 调用的累计 token 用量(引擎结束时写入 run summary)
|
||
self.total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||
self._client: httpx.AsyncClient | None = None
|
||
|
||
async def _get_client(self) -> httpx.AsyncClient:
|
||
# 单实例复用客户端,省去每次 LLM 调用的 TCP/TLS 握手
|
||
if self._client is None or self._client.is_closed:
|
||
self._client = httpx.AsyncClient(timeout=self.timeout, transport=self.transport)
|
||
return self._client
|
||
|
||
async def close(self) -> None:
|
||
if self._client is not None and not self._client.is_closed:
|
||
await self._client.aclose()
|
||
self._client = None
|
||
|
||
async def _post(self, config: ModelRuntimeConfig, payload: dict[str, Any]) -> dict[str, Any]:
|
||
adapter = self._adapter(config)
|
||
try:
|
||
client = await self._get_client()
|
||
response = await client.post(
|
||
config.endpoint_url,
|
||
headers=adapter.headers(config.api_key),
|
||
json=payload,
|
||
)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
if not isinstance(data, dict):
|
||
raise ModelGatewayError("模型接口返回格式不是 JSON 对象")
|
||
return data
|
||
except ModelGatewayError:
|
||
raise
|
||
except httpx.HTTPStatusError as exc:
|
||
detail = exc.response.text[:300]
|
||
raise ModelGatewayError(f"模型接口返回 HTTP {exc.response.status_code}: {detail}") from exc
|
||
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:
|
||
content, _ = await self.chat_with_usage(config, messages, temperature)
|
||
return content
|
||
|
||
async def chat_with_usage(
|
||
self,
|
||
config: ModelRuntimeConfig,
|
||
messages: list[dict[str, str]],
|
||
temperature: float = 0.2,
|
||
) -> tuple[str, dict[str, int] | None]:
|
||
"""Like chat(), but also returns this call's token usage (None if omitted)."""
|
||
adapter = self._adapter(config)
|
||
try:
|
||
payload = adapter.chat_payload(config.model_name, messages, temperature)
|
||
data = await self._post(config, payload)
|
||
usage = adapter.parse_usage(data)
|
||
self._accumulate(usage)
|
||
return adapter.parse_chat(data), usage
|
||
except ProtocolAdapterError as exc:
|
||
raise ModelGatewayError(str(exc)) from exc
|
||
|
||
async def embed(self, config: ModelRuntimeConfig, inputs: str | list[str]) -> list[list[float]]:
|
||
vectors, _ = await self.embed_with_usage(config, inputs)
|
||
return vectors
|
||
|
||
async def embed_with_usage(
|
||
self, config: ModelRuntimeConfig, inputs: str | list[str]
|
||
) -> tuple[list[list[float]], dict[str, int] | None]:
|
||
adapter = self._adapter(config)
|
||
try:
|
||
payload = adapter.embedding_payload(config.model_name, inputs)
|
||
data = await self._post(config, payload)
|
||
usage = adapter.parse_usage(data)
|
||
self._accumulate(usage)
|
||
return adapter.parse_embeddings(data), usage
|
||
except ProtocolAdapterError as exc:
|
||
raise ModelGatewayError(str(exc)) from exc
|
||
|
||
def _accumulate(self, usage: dict[str, int] | None) -> None:
|
||
if not usage:
|
||
return
|
||
for key in self.total_usage:
|
||
self.total_usage[key] += int(usage.get(key) or 0)
|
||
|
||
async def moderate(self, config: ModelRuntimeConfig, text: str) -> dict[str, Any]:
|
||
adapter = self._adapter(config)
|
||
try:
|
||
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":
|
||
await self.chat(config, [{"role": "user", "content": "回复 OK"}], temperature=0)
|
||
elif config.capability.value == "embedding":
|
||
vectors = await self.embed(config, "connection test")
|
||
if not vectors or not vectors[0]:
|
||
raise ModelGatewayError("Embedding 接口未返回向量")
|
||
else:
|
||
await self.moderate(config, "connection test")
|
||
return "连接成功"
|