2026-09-09 21:55:37 +08:00
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from app.core.errors import RecoverableAgentError
|
|
|
|
|
from app.service.model_gateway import (
|
|
|
|
|
ModelDispatchService,
|
|
|
|
|
ModelGenerationService,
|
|
|
|
|
OpenAICompatibleGateway,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Gateway:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.calls: list[str] = []
|
|
|
|
|
|
|
|
|
|
async def generate(self, *, endpoint_code: str, prompt: str, timeout_ms: int) -> str:
|
|
|
|
|
self.calls.append(endpoint_code)
|
|
|
|
|
if endpoint_code == "primary":
|
|
|
|
|
raise TimeoutError("timeout")
|
|
|
|
|
return "fallback answer"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Endpoint:
|
|
|
|
|
def __init__(self, code: str) -> None:
|
|
|
|
|
self.endpoint_code = code
|
|
|
|
|
self.timeout_ms = 1000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_model_dispatch_uses_declared_fallback_order() -> None:
|
|
|
|
|
gateway = Gateway()
|
|
|
|
|
result = await ModelDispatchService(gateway).generate(
|
|
|
|
|
[Endpoint("primary"), Endpoint("fallback")], "hello"
|
|
|
|
|
)
|
|
|
|
|
assert result.text == "fallback answer"
|
|
|
|
|
assert result.degraded is True
|
|
|
|
|
assert gateway.calls == ["primary", "fallback"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_openai_compatible_gateway_uses_secret_ref_and_parses_text(monkeypatch) -> None:
|
|
|
|
|
monkeypatch.setenv("TEST_MODEL_KEY", "secret-value")
|
|
|
|
|
requests: list[httpx.Request] = []
|
|
|
|
|
|
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
requests.append(request)
|
|
|
|
|
return httpx.Response(200, json={"choices": [{"message": {"content": "answer"}}]})
|
|
|
|
|
|
|
|
|
|
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
|
|
|
endpoint = SimpleNamespace(endpoint_code="primary", base_url="https://model.test/v1",
|
|
|
|
|
model_name="test-model", secret_ref="env:TEST_MODEL_KEY")
|
|
|
|
|
gateway = OpenAICompatibleGateway({"primary": endpoint}, client=client)
|
|
|
|
|
try:
|
|
|
|
|
assert await gateway.generate(
|
|
|
|
|
endpoint_code="primary", prompt="hello", timeout_ms=1000
|
|
|
|
|
) == "answer"
|
|
|
|
|
assert str(requests[0].url) == "https://model.test/v1/chat/completions"
|
|
|
|
|
assert requests[0].headers["authorization"] == "Bearer secret-value"
|
|
|
|
|
assert requests[0].content.find(b"secret-value") == -1
|
|
|
|
|
finally:
|
|
|
|
|
await client.aclose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gateway_missing_secret_and_empty_route_fail_closed(monkeypatch) -> None:
|
|
|
|
|
monkeypatch.delenv("MISSING_MODEL_KEY", raising=False)
|
|
|
|
|
endpoint = SimpleNamespace(endpoint_code="primary", base_url="https://model.test",
|
|
|
|
|
model_name="test-model", secret_ref="env:MISSING_MODEL_KEY")
|
|
|
|
|
with pytest.raises(RecoverableAgentError, match="密钥未配置"):
|
|
|
|
|
await OpenAICompatibleGateway({"primary": endpoint}).generate(
|
|
|
|
|
endpoint_code="primary", prompt="hello", timeout_ms=1000)
|
|
|
|
|
with pytest.raises(RecoverableAgentError, match="没有可用"):
|
|
|
|
|
await ModelGenerationService(ModelDispatchService(Gateway())).generate([], "hello")
|
2026-09-11 14:37:20 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# `DatabaseModelEndpointResolver` 按 task_type 过滤能力(补上被漏掉的契约兑现)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeScalarSession:
|
|
|
|
|
"""只实现 `scalars()`:解析器只用它取 active 端点列表。"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, rows: list[object]) -> None:
|
|
|
|
|
self._rows = rows
|
|
|
|
|
|
|
|
|
|
async def scalars(self, _statement: object) -> list[object]:
|
|
|
|
|
return self._rows
|
|
|
|
|
|
|
|
|
|
async def __aenter__(self) -> "_FakeScalarSession":
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
async def __aexit__(self, *args: object) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _endpoint(code: str, capabilities: list[str] | None) -> SimpleNamespace:
|
|
|
|
|
return SimpleNamespace(endpoint_code=code, capabilities=capabilities)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("task_type", "expected"),
|
|
|
|
|
[
|
|
|
|
|
# 向量化只能走声明 embedding 的端点:交给 chat 端点会打到 /chat/completions 上。
|
|
|
|
|
("embedding", ["embedding-primary"]),
|
|
|
|
|
# 生成与意图分类都走 chat 端点:交给 embedding 端点会 404。
|
|
|
|
|
("chat", ["chat-primary"]),
|
|
|
|
|
("intent_classification", ["chat-primary"]),
|
|
|
|
|
# 未知任务类型无法判断该要哪种能力 → 不过滤(返回全部 active)。
|
|
|
|
|
("something_new", ["embedding-primary", "chat-primary"]),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
async def test_resolve_filters_endpoints_by_task_type_capability(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, task_type: str, expected: list[str]
|
|
|
|
|
) -> None:
|
|
|
|
|
"""必须按 `task_type` 过滤能力。
|
|
|
|
|
|
|
|
|
|
不过滤的后果:`ModelDispatchService` 的 `generate`/`embed` 只取前
|
|
|
|
|
`max(1, max_attempts)`(默认 2)个端点,错叫一个就吃掉一次尝试机会 ——
|
|
|
|
|
端点一多会直接耗尽尝试而失败(原实现 `del agent_type, task_type` 即此缺陷)。
|
|
|
|
|
"""
|
|
|
|
|
from app.service import model_gateway
|
|
|
|
|
|
|
|
|
|
rows = [_endpoint("embedding-primary", ["embedding"]), _endpoint("chat-primary", ["chat"])]
|
|
|
|
|
monkeypatch.setattr(model_gateway, "SessionFactory", lambda: _FakeScalarSession(rows))
|
|
|
|
|
|
|
|
|
|
resolved = await model_gateway.DatabaseModelEndpointResolver().resolve(
|
|
|
|
|
agent_type="customer_service", task_type=task_type
|
|
|
|
|
)
|
|
|
|
|
assert [e.endpoint_code for e in resolved] == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_resolve_skips_endpoints_without_declared_capabilities(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""`capabilities` 为 NULL/空 的端点不得被任何筛选选中(不能裸奔到错误的网关方法上)。"""
|
|
|
|
|
from app.service import model_gateway
|
|
|
|
|
|
|
|
|
|
rows = [_endpoint("broken", None), _endpoint("empty", []), _endpoint("chat-primary", ["chat"])]
|
|
|
|
|
monkeypatch.setattr(model_gateway, "SessionFactory", lambda: _FakeScalarSession(rows))
|
|
|
|
|
|
|
|
|
|
resolved = await model_gateway.DatabaseModelEndpointResolver().resolve(
|
|
|
|
|
agent_type="customer_service", task_type="chat"
|
|
|
|
|
)
|
|
|
|
|
assert [e.endpoint_code for e in resolved] == ["chat-primary"]
|