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") # --------------------------------------------------------------------------- # `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"] @pytest.mark.asyncio async def test_resolve_warns_when_multiple_embedding_endpoints_declared( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """声明 `embedding` 能力的 active 端点超过一个时必须告警(配置守卫,只告警不改行为)。 为什么要守:Milvus 集合的向量维度是固定的,索引侧与查询侧必须用**同一个**模型。 两个 embedding 端点共存时,`ModelDispatchService.embed` 只试前 `max(1, max_attempts)` (默认 2)个端点,实际用哪个取决于表的行顺序 —— 一旦索引与查询落到不同模型, `COSINE` 相似度整体失真却**不会报任何错**,表现为“越答越差”的哑故障。 """ from app.service import model_gateway rows = [ _endpoint("embedding-primary", ["embedding"]), _endpoint("embedding-shadow", ["embedding"]), _endpoint("chat-primary", ["chat"]), ] monkeypatch.setattr(model_gateway, "SessionFactory", lambda: _FakeScalarSession(rows)) with caplog.at_level("WARNING"): resolved = await model_gateway.DatabaseModelEndpointResolver().resolve( agent_type="customer_service", task_type="embedding" ) # 只告警不改行为:仍返回全部声明 embedding 的端点,且顺序与表行顺序一致。 assert [e.endpoint_code for e in resolved] == ["embedding-primary", "embedding-shadow"] warnings = [r for r in caplog.records if r.levelname == "WARNING"] assert warnings, "多 embedding 端点时必须留下 WARNING 痕迹" assert "embedding-shadow" in warnings[-1].getMessage() @pytest.mark.asyncio async def test_resolve_is_silent_with_single_embedding_endpoint( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """恰有一个 embedding 端点(现网配置)时不得产生告警噪音。""" from app.service import model_gateway rows = [_endpoint("embedding-primary", ["embedding"]), _endpoint("chat-primary", ["chat"])] monkeypatch.setattr(model_gateway, "SessionFactory", lambda: _FakeScalarSession(rows)) with caplog.at_level("WARNING"): resolved = await model_gateway.DatabaseModelEndpointResolver().resolve( agent_type="customer_service", task_type="embedding" ) assert [e.endpoint_code for e in resolved] == ["embedding-primary"] assert [r for r in caplog.records if r.levelname == "WARNING"] == []