diff --git a/adapters/llm.py b/adapters/llm.py new file mode 100644 index 0000000..db49991 --- /dev/null +++ b/adapters/llm.py @@ -0,0 +1,574 @@ +"""GovernedLLMClient — 四层治理栈统一 LLM 调用网关。 + +组合熔断器、Redis 缓存、指数退避重试、流式 SSE 消费 + 三层活性看门狗, +并在每次调用后自动写入遥测。provider 差异处理覆盖 DeepSeek(reasoning_content) +和 Qwen( 标签剥离)。 + +治理栈五层(ARCHITECTURE.md §5): + 1. 硬超时 — asyncio.wait_for + StreamLivenessTimeout + 2. 指数退避重试 — max_retries + base_delay + max_delay + 3. 熔断器 — CircuitBreaker(连续 N 失败开路,冷却后半开探针) + 4. Redis 响应缓存 — content-addressed hash(model + messages) + 5. 遥测 — TelemetryRecorder(每次调用必录) +""" + +from __future__ import annotations + +import asyncio +import json +import re +import time +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +import httpx +from loguru import logger + +from adapters.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts +from core.types import LLMResponse + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from adapters.breaker import CircuitBreaker + + +class CircuitOpenError(Exception): + """熔断器已开启,拒绝调用。""" + + +class _SseAnomaly(Exception): # noqa: N818 — 参考 CHSAnalyzer2 命名 + """SSE 协议异常(malformed_json 等)。""" + + def __init__(self, kind: str) -> None: + self.kind = kind + super().__init__(f"SSE 协议异常: {kind}") + + +# ── SSE 解析(模块级纯函数) ──────────────────────────────────────── + + +def _sse_data_payload(raw: str) -> str | None: + """提取一行 SSE 的 data 段;ping/空行/非 data 行返回 None。 + + 参数: + raw: 原始 SSE 行文本。 + + 返回: + data 段 payload 字符串,或 None(应被跳过的行)。 + """ + line = raw.strip() + if not line or line.startswith(":") or not line.startswith("data:"): + return None + return line[len("data:"):].strip() + + +def _sse_delta(chunk: dict, usage_sink: dict) -> tuple[bool, str] | None: + """旁路收集 usage 并提取一帧增量 token。 + + 返回 (True, content) 或 (False, reasoning_content),无增量则 None。 + 优先 content(正式输出);否则取 reasoning_content(thinking 模型的思考 token)。 + + 参数: + chunk: 一帧解析后的 SSE JSON。 + usage_sink: 旁路字典;含 usage 的帧写入 usage_sink["usage"]。 + + 返回: + (is_content, text) 或 None。 + """ + if chunk.get("usage"): + usage_sink["usage"] = chunk["usage"] + choices = chunk.get("choices") or [] + if not choices: + return None + delta = choices[0].get("delta") or {} + content = delta.get("content") + if content: + return (True, content) + reasoning = delta.get("reasoning_content") + if reasoning: + return (False, reasoning) + return None + + +async def _iter_sse_deltas( + lines: AsyncIterator[str], usage_sink: dict +) -> AsyncIterator[tuple[bool, str]]: + """OpenAI 兼容 SSE 行流 → 逐帧产出 (是否 content, 文本)。 + + usage 旁路写入 usage_sink。内部消化 ping/空行/[DONE]/usage-only 帧。 + + 参数: + lines: 底层 SSE 文本行异步迭代器。 + usage_sink: 旁路字典。 + + 产出: + (is_content, text)。 + + 异常: + _SseAnomaly: data 段 JSON 解析失败。 + """ + async for raw in lines: + data = _sse_data_payload(raw) + if data is None: + continue + if data == "[DONE]": + usage_sink["done"] = True + return + try: + chunk = json.loads(data) + except json.JSONDecodeError as exc: + raise _SseAnomaly("malformed_json") from exc + delta = _sse_delta(chunk, usage_sink) + if delta is not None: + yield delta + + +# ── Provider 差异处理 ──────────────────────────────────────────────── + + +def _build_thinking_body(provider: str) -> dict: + """构造 thinking/reasoning 启用参数(provider 差异处理)。 + + 参数: + provider: provider 标识(如 "deepseek"、"qwen" 等)。 + + 返回: + 合并进请求体的字典片段。 + """ + provider_lower = provider.lower() + if "deepseek" in provider_lower: + return {"thinking": {"type": "enabled"}} + if "qwen" in provider_lower: + return {"enable_thinking": True} + return {} + + +_THINK_PATTERN = re.compile(r"(.*?)", re.DOTALL) + + +def _strip_qwen_thinking(content: str) -> tuple[str, str]: + """剥离 Qwen 模型 ... 标签,拆分为 (正文, 思考内容)。 + + 参数: + content: 含有可能的 标签的模型原始输出。 + + 返回: + (stripped_content, thinking_text)。无 think 标签时 thinking 为空字符串。 + """ + match = _THINK_PATTERN.search(content) + if match is None: + return content, "" + thinking = match.group(1).strip() + stripped = _THINK_PATTERN.sub("", content).strip() + return stripped, thinking + + +# ── 瞬时错误判定 ───────────────────────────────────────────────────── + +_TRANSIENT_STATUS_CODES = frozenset({429, 500, 502, 503, 504}) +_FATAL_STATUS_CODES = frozenset({401, 403}) + + +def _is_transient_error(exc: Exception) -> bool: + """判断异常是否为瞬时可重试错误。 + + 参数: + exc: 捕获的异常。 + + 返回: + True 表示可重试,False 表示不可重试。 + """ + if isinstance(exc, (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout)): + return True + if isinstance(exc, httpx.HTTPStatusError): + return exc.response.status_code in _TRANSIENT_STATUS_CODES + return isinstance(exc, (StreamLivenessTimeout, _SseAnomaly)) + + +def _is_fatal_auth_error(exc: Exception) -> bool: + """判断异常是否为不可恢复的认证/授权错误。 + + 参数: + exc: 捕获的异常。 + + 返回: + True 表示应立即 force_open 熔断器。 + """ + if isinstance(exc, httpx.HTTPStatusError): + return exc.response.status_code in _FATAL_STATUS_CODES + return False + + +# ── GovernedLLMClient ──────────────────────────────────────────────── + + +class GovernedLLMClient: + """四层治理栈统一 LLM 调用网关,实现 LLMProvider Protocol。 + + 构造函数接收所有组件(依赖注入),不从全局状态获取任何配置。 + + 参数: + model: 模型名称。 + base_url: API 基础 URL(如 http://localhost:8000/v1)。 + api_key: API 密钥。 + provider: provider 标识(如 "deepseek"、"qwen")。 + thinking: 是否启用 thinking/reasoning 模式。 + breaker: 熔断器实例。 + cache: Redis 响应缓存实例。 + telemetry: 遥测记录器实例。 + timeout_s: 总超时秒数。 + ttft_timeout_s: 首 token 超时秒数。 + inter_token_timeout_s: token 间超时秒数。 + max_retries: 最大重试次数。 + retry_base_delay_s: 重试基础延迟秒数。 + retry_max_delay_s: 重试最大延迟秒数。 + """ + + def __init__( + self, + *, + model: str, + base_url: str, + api_key: str, + provider: str, + thinking: bool, + breaker: CircuitBreaker, + cache: Any, + telemetry: Any, + timeout_s: float, + ttft_timeout_s: float, + inter_token_timeout_s: float, + max_retries: int, + retry_base_delay_s: float, + retry_max_delay_s: float, + ) -> None: + self._model = model + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._provider = provider + self._thinking = thinking + self._breaker = breaker + self._cache = cache + self._telemetry = telemetry + self._timeout_s = timeout_s + self._ttft_timeout_s = ttft_timeout_s + self._inter_token_timeout_s = inter_token_timeout_s + self._max_retries = max_retries + self._retry_base_delay_s = retry_base_delay_s + self._retry_max_delay_s = retry_max_delay_s + self._http = httpx.AsyncClient( + base_url=self._base_url, + headers={ + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + }, + timeout=httpx.Timeout(timeout_s), + ) + + async def chat( + self, + messages: list[dict[str, Any]], + *, + session_id: str | None = None, + parent_call_id: str | None = None, + ) -> LLMResponse: + """发起 LLM 调用,经四层治理栈:熔断 → 缓存 → 重试+流式 → 遥测。 + + 参数: + messages: OpenAI 格式消息列表。 + session_id: 会话 ID(传递到遥测)。 + parent_call_id: 父调用 ID(传递到遥测)。 + + 返回: + LLMResponse 统一响应。 + + 异常: + CircuitOpenError: 熔断器开路。 + httpx.HTTPStatusError: 不可恢复的 HTTP 错误(401/403)。 + """ + started = time.monotonic() + call_id = str(uuid4()) + + # ① 熔断器检查 + if self._breaker.is_open(self._provider, time.monotonic()): + raise CircuitOpenError( + f"熔断器已开启,拒绝调用 provider={self._provider}" + ) + + # ② 缓存查询 + cached = await self._cache.get(self._model, messages) + if cached is not None: + response = LLMResponse( + content=cached.content, + thinking=cached.thinking, + model=cached.model, + provider=cached.provider, + prompt_tokens=cached.prompt_tokens, + completion_tokens=cached.completion_tokens, + latency_ms=0, + ttft_ms=None, + max_inter_token_ms=None, + cache_hit=True, + call_id=call_id, + ) + await self._telemetry.record_llm_call( + call_id=call_id, + parent_call_id=parent_call_id, + session_id=session_id, + model_name=self._model, + provider=self._provider, + messages=json.dumps(messages, ensure_ascii=False), + response=response.content, + thinking=response.thinking, + prompt_tokens=response.prompt_tokens, + completion_tokens=response.completion_tokens, + latency_ms=0, + ttft_ms=None, + max_inter_token_ms=None, + cache_hit=True, + error=None, + ) + return response + + # ③ 重试循环 + 流式消费 + last_exc: Exception | None = None + for attempt in range(self._max_retries): + attempt_start = time.monotonic() + try: + sse_lines = await self._stream_request(messages) + content, thinking_text, ttft_ms, max_itoken_ms, usage = ( + await self._consume_stream(sse_lines) + ) + + # 熔断器记成功 + self._breaker.record_success(self._provider) + + # provider 差异:Qwen think 标签剥离 + if "qwen" in self._provider.lower() and "" in content: + content, qwen_thinking = _strip_qwen_thinking(content) + if qwen_thinking: + thinking_text = qwen_thinking + + latency_ms = int((time.monotonic() - started) * 1000) + prompt_tokens = usage.get("prompt_tokens", 0) + completion_tokens = usage.get("completion_tokens", 0) + + response = LLMResponse( + content=content, + thinking=thinking_text, + model=self._model, + provider=self._provider, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + latency_ms=latency_ms, + ttft_ms=ttft_ms, + max_inter_token_ms=max_itoken_ms, + cache_hit=False, + call_id=call_id, + ) + + # ④ 写缓存 + await self._cache.set(self._model, messages, response) + + # ⑤ 遥测 + await self._telemetry.record_llm_call( + call_id=call_id, + parent_call_id=parent_call_id, + session_id=session_id, + model_name=self._model, + provider=self._provider, + messages=json.dumps(messages, ensure_ascii=False), + response=content, + thinking=thinking_text, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + latency_ms=latency_ms, + ttft_ms=ttft_ms, + max_inter_token_ms=max_itoken_ms, + cache_hit=False, + error=None, + ) + + return response + + except Exception as exc: + last_exc = exc + attempt_latency = int((time.monotonic() - attempt_start) * 1000) + + # 致命错误(401/403)→ force_open + 遥测 + 立即抛出 + if _is_fatal_auth_error(exc): + self._breaker.force_open(self._provider, time.monotonic()) + await self._telemetry.record_llm_call( + call_id=call_id, + parent_call_id=parent_call_id, + session_id=session_id, + model_name=self._model, + provider=self._provider, + messages=json.dumps(messages, ensure_ascii=False), + response="", + thinking="", + prompt_tokens=0, + completion_tokens=0, + latency_ms=attempt_latency, + ttft_ms=None, + max_inter_token_ms=None, + cache_hit=False, + error=str(exc), + ) + raise + + # 瞬时错误 → 记失败 + 遥测 + 退避重试 + if _is_transient_error(exc): + self._breaker.record_failure(self._provider, time.monotonic()) + await self._telemetry.record_llm_call( + call_id=str(uuid4()), # 每次重试用新 call_id + parent_call_id=parent_call_id, + session_id=session_id, + model_name=self._model, + provider=self._provider, + messages=json.dumps(messages, ensure_ascii=False), + response="", + thinking="", + prompt_tokens=0, + completion_tokens=0, + latency_ms=attempt_latency, + ttft_ms=None, + max_inter_token_ms=None, + cache_hit=False, + error=str(exc), + ) + logger.warning( + "llm.call.transient_error", + extra={ + "provider": self._provider, + "attempt": attempt + 1, + "max_retries": self._max_retries, + "error": str(exc), + }, + ) + if attempt < self._max_retries - 1: + delay = min( + self._retry_base_delay_s * (2 ** attempt), + self._retry_max_delay_s, + ) + if delay > 0: + await asyncio.sleep(delay) + continue + + # 非瞬时、非致命 → 记遥测后直接抛出 + await self._telemetry.record_llm_call( + call_id=call_id, + parent_call_id=parent_call_id, + session_id=session_id, + model_name=self._model, + provider=self._provider, + messages=json.dumps(messages, ensure_ascii=False), + response="", + thinking="", + prompt_tokens=0, + completion_tokens=0, + latency_ms=attempt_latency, + ttft_ms=None, + max_inter_token_ms=None, + cache_hit=False, + error=str(exc), + ) + raise + + # 所有重试耗尽 + assert last_exc is not None + raise last_exc + + async def _stream_request( + self, messages: list[dict[str, Any]] + ) -> list[str]: + """发起流式 HTTP 请求并收集全部 SSE 行。 + + 此方法为主要的 HTTP 交互点,测试通过 patch 替换以注入假 SSE 行。 + + 参数: + messages: OpenAI 格式消息列表。 + + 返回: + SSE 文本行列表。 + + 异常: + httpx.HTTPStatusError: HTTP 状态码错误。 + httpx.ConnectError: 连接错误。 + """ + payload: dict[str, Any] = { + "model": self._model, + "messages": messages, + "stream": True, + "stream_options": {"include_usage": True}, + } + if self._thinking: + payload.update(_build_thinking_body(self._provider)) + + collected_lines: list[str] = [] + async with self._http.stream( + "POST", "/chat/completions", json=payload + ) as resp: + if resp.status_code >= 400: + await resp.aread() + resp.raise_for_status() + async for line in resp.aiter_lines(): + collected_lines.append(line) + return collected_lines + + async def _consume_stream( + self, sse_lines: list[str] + ) -> tuple[str, str, float | None, float | None, dict]: + """消费 SSE 行序列,累积 content/thinking,测量 TTFT 和 max_inter_token_ms。 + + 参数: + sse_lines: SSE 文本行列表。 + + 返回: + (content, thinking, ttft_ms, max_inter_token_ms, usage_dict)。 + """ + usage_sink: dict = {} + + async def _lines_iter() -> AsyncIterator[str]: + for line in sse_lines: + yield line + + raw_deltas = _iter_sse_deltas(_lines_iter(), usage_sink) + guarded = stream_with_liveness_timeouts( + raw_deltas, + ttft_s=self._ttft_timeout_s, + inter_token_s=self._inter_token_timeout_s, + total_s=self._timeout_s, + ) + + content_parts: list[str] = [] + thinking_parts: list[str] = [] + ttft_ms: float | None = None + max_inter_token_ms: float | None = None + stream_start = time.monotonic() + last_token_time = stream_start + + async for is_content, text in guarded: + now = time.monotonic() + if ttft_ms is None: + ttft_ms = (now - stream_start) * 1000 + else: + gap_ms = (now - last_token_time) * 1000 + if max_inter_token_ms is None or gap_ms > max_inter_token_ms: + max_inter_token_ms = gap_ms + last_token_time = now + + if is_content: + content_parts.append(text) + else: + thinking_parts.append(text) + + content = "".join(content_parts) + thinking = "".join(thinking_parts) + usage = usage_sink.get("usage", {}) + return content, thinking, ttft_ms, max_inter_token_ms, usage + + async def close(self) -> None: + """关闭底层 HTTP 客户端。""" + await self._http.aclose() diff --git a/tests/unit/test_governed_llm.py b/tests/unit/test_governed_llm.py new file mode 100644 index 0000000..2970654 --- /dev/null +++ b/tests/unit/test_governed_llm.py @@ -0,0 +1,306 @@ +"""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"