"""GovernedLLMClient 单元测试 — 四层治理栈核心路径验证。""" from __future__ import annotations import json import time from typing import Any from unittest.mock import patch import httpx import pytest from adapters.breaker import CircuitBreaker from adapters.llm import CircuitOpenError, GovernedLLMClient from core.types import LLMResponse # ── 辅助类 ────────────────────────────────────────────────────────── class _FakeRedisCache: """内存字典替身,模拟 RedisResponseCache 的 get/set 接口。""" def __init__(self) -> None: self._store: dict[str, LLMResponse] = {} async def get( self, model: str, messages: list[dict[str, str]] ) -> LLMResponse | None: key = f"{model}:{json.dumps(messages, sort_keys=True)}" return self._store.get(key) async def set( self, model: str, messages: list[dict[str, str]], response: LLMResponse, ) -> None: key = f"{model}:{json.dumps(messages, sort_keys=True)}" self._store[key] = response class _FakeTelemetry: """记录所有 record_llm_call 调用的替身。""" def __init__(self) -> None: self.calls: list[dict[str, Any]] = [] async def record_llm_call(self, **kwargs: Any) -> None: self.calls.append(kwargs) # ── 工具函数 ───────────────────────────────────────────────────────── async def _async_line_iter(lines: list[str]): """将字符串列表转为异步行迭代器。""" for line in lines: yield line def _make_sse_lines( content_chunks: list[str], *, reasoning_chunks: list[str] | None = None, model: str = "test-model", usage: dict[str, int] | None = None, ) -> list[str]: """构造 SSE 文本行序列,模拟 OpenAI 兼容流式响应。""" lines: list[str] = [] # 先产出 reasoning 帧 for chunk in reasoning_chunks or []: frame = { "choices": [{"delta": {"reasoning_content": chunk}}], "model": model, } lines.append(f"data: {json.dumps(frame)}\n") # 再产出 content 帧 for chunk in content_chunks: frame = { "choices": [{"delta": {"content": chunk}}], "model": model, } lines.append(f"data: {json.dumps(frame)}\n") # usage 帧 if usage is None: usage = {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} frame_usage = {"choices": [], "usage": usage, "model": model} lines.append(f"data: {json.dumps(frame_usage)}\n") # 终止标记 lines.append("data: [DONE]\n") return lines def _build_client( *, breaker: CircuitBreaker | None = None, cache: _FakeRedisCache | None = None, telemetry: _FakeTelemetry | None = None, provider: str = "test-provider", model: str = "test-model", thinking: bool = False, ) -> GovernedLLMClient: """构造标准测试客户端。""" return GovernedLLMClient( model=model, base_url="http://localhost:8000/v1", api_key="test-key", provider=provider, thinking=thinking, breaker=breaker or CircuitBreaker(fail_threshold=3, cooldown_s=60), cache=cache or _FakeRedisCache(), telemetry=telemetry or _FakeTelemetry(), timeout_s=30.0, ttft_timeout_s=10.0, inter_token_timeout_s=5.0, max_retries=3, retry_base_delay_s=0.0, # 测试不等待 retry_max_delay_s=0.0, ) # ── 测试用例 ───────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_circuit_open_raises(): """熔断器开启 → 直接抛 CircuitOpenError,不发请求。""" breaker = CircuitBreaker(fail_threshold=1, cooldown_s=600) breaker.force_open("test-provider", time.monotonic()) client = _build_client(breaker=breaker) with pytest.raises(CircuitOpenError): await client.chat([{"role": "user", "content": "hello"}]) @pytest.mark.asyncio async def test_cache_hit_returns_cached_and_records_telemetry(): """缓存命中 → 返回缓存响应 + 新 call_id + 遥测记录 cache_hit=True。""" cache = _FakeRedisCache() telemetry = _FakeTelemetry() client = _build_client(cache=cache, telemetry=telemetry) # 预填缓存 cached_resp = LLMResponse( content="cached answer", thinking="", model="test-model", provider="test-provider", prompt_tokens=10, completion_tokens=5, latency_ms=0, ttft_ms=None, max_inter_token_ms=None, cache_hit=True, call_id="old-id", ) messages = [{"role": "user", "content": "hello"}] await cache.set("test-model", messages, cached_resp) result = await client.chat(messages) assert result.content == "cached answer" assert result.cache_hit is True # 每次调用都生成新 call_id assert result.call_id != "old-id" # 遥测已记录 assert len(telemetry.calls) == 1 assert telemetry.calls[0]["cache_hit"] is True @pytest.mark.asyncio async def test_successful_streaming_call(): """正常流式调用 → 正确累积 content + thinking + 采集 ttft_ms + tokens。""" telemetry = _FakeTelemetry() client = _build_client(telemetry=telemetry) messages = [{"role": "user", "content": "hello"}] sse_lines = _make_sse_lines( ["Hello", " world"], reasoning_chunks=["Let me", " think"], usage={"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}, ) async def fake_stream_request(_messages: Any) -> list[str]: return sse_lines with patch.object(client, "_stream_request", side_effect=fake_stream_request): result = await client.chat(messages) assert result.content == "Hello world" assert result.thinking == "Let me think" assert result.prompt_tokens == 20 assert result.completion_tokens == 10 assert result.ttft_ms is not None assert result.ttft_ms >= 0 assert result.cache_hit is False # 遥测已记录 assert len(telemetry.calls) == 1 assert telemetry.calls[0]["cache_hit"] is False assert telemetry.calls[0]["error"] is None @pytest.mark.asyncio async def test_transient_error_retries_and_records_telemetry(): """ConnectError 重试 2 次后成功 → 结果正确 + 遥测记录成功。""" telemetry = _FakeTelemetry() client = _build_client(telemetry=telemetry) messages = [{"role": "user", "content": "hello"}] sse_lines = _make_sse_lines(["OK"]) call_count = 0 async def flaky_stream(_messages: Any) -> list[str]: nonlocal call_count call_count += 1 if call_count <= 2: raise httpx.ConnectError("connection refused") return sse_lines with patch.object(client, "_stream_request", side_effect=flaky_stream): result = await client.chat(messages) assert result.content == "OK" assert call_count == 3 # 前 2 次失败也应记遥测 error_calls = [c for c in telemetry.calls if c.get("error") is not None] assert len(error_calls) == 2 # 最后 1 次成功也记了遥测 success_calls = [c for c in telemetry.calls if c.get("error") is None] assert len(success_calls) == 1 @pytest.mark.asyncio async def test_fatal_error_force_opens_breaker(): """401 → force_open 熔断器 + 遥测记录 error。""" breaker = CircuitBreaker(fail_threshold=3, cooldown_s=60) telemetry = _FakeTelemetry() client = _build_client(breaker=breaker, telemetry=telemetry) messages = [{"role": "user", "content": "hello"}] mock_response = httpx.Response( status_code=401, request=httpx.Request("POST", "http://localhost:8000/v1/chat/completions"), ) async def auth_fail(_messages: Any) -> list[str]: raise httpx.HTTPStatusError( "Unauthorized", request=mock_response.request, response=mock_response ) with patch.object(client, "_stream_request", side_effect=auth_fail), \ pytest.raises(httpx.HTTPStatusError): await client.chat(messages) # 熔断器已被 force_open assert breaker.is_open("test-provider", time.monotonic()) # 遥测已记录 error assert len(telemetry.calls) == 1 assert telemetry.calls[0]["error"] is not None @pytest.mark.asyncio async def test_qwen_thinking_stripped(): """Qwen 模型 标签被正确剥离到 thinking 字段。""" from adapters.llm import _strip_qwen_thinking content_with_think = "这是思考过程这是正式输出" content, thinking = _strip_qwen_thinking(content_with_think) assert content == "这是正式输出" assert thinking == "这是思考过程" # 无 think 标签时原样返回 plain = "纯文本输出" content2, thinking2 = _strip_qwen_thinking(plain) assert content2 == "纯文本输出" assert thinking2 == "" @pytest.mark.asyncio async def test_parent_call_id_forwarded_to_telemetry(): """parent_call_id 和 session_id 正确传递到遥测记录。""" telemetry = _FakeTelemetry() client = _build_client(telemetry=telemetry) messages = [{"role": "user", "content": "hello"}] sse_lines = _make_sse_lines(["result"]) async def fake_stream(_messages: Any) -> list[str]: return sse_lines with patch.object(client, "_stream_request", side_effect=fake_stream): await client.chat( messages, session_id="sess-42", parent_call_id="parent-99" ) assert len(telemetry.calls) == 1 assert telemetry.calls[0]["session_id"] == "sess-42" assert telemetry.calls[0]["parent_call_id"] == "parent-99"