"""T2.1 结构化输出接线单测:output_schema → instructor → LlmResponse.parsed。 注入 fake instructor client(返回 (parsed, raw_completion)),不联网。 覆盖:① 带 schema → parsed 是实例且字段正确 + 仍记 1 条 ledger; ② 无 schema → parsed is None 且纯文本路径不变。 """ from __future__ import annotations import uuid from types import SimpleNamespace from typing import Any, cast from openai import AsyncOpenAI from pydantic import BaseModel from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter from ww_llm_gateway.gateway import Gateway from ww_llm_gateway.routing import Route from ww_llm_gateway.types import LlmRequest, Scope, Tier class _Out(BaseModel): label: str score: int def _req(**kw: Any) -> LlmRequest: kw.setdefault("tier", "analyst") return LlmRequest(scope=Scope(user_id=uuid.UUID(int=1)), **kw) def _route(_tier: Tier) -> Route: return Route(provider="deepseek", model="deepseek-chat") class _FakeStructured: """模拟 instructor.AsyncInstructor:create_with_completion 返回 (parsed, raw)。""" def __init__(self, parsed: BaseModel, raw: Any) -> None: self._parsed = parsed self._raw = raw self.calls = 0 self.last_response_model: Any = None async def create_with_completion( self, *, messages: Any, response_model: Any, **kw: Any ) -> tuple[BaseModel, Any]: self.calls += 1 self.last_response_model = response_model return self._parsed, self._raw def _raw_with_usage() -> Any: return SimpleNamespace( usage=SimpleNamespace( prompt_tokens=42, completion_tokens=7, prompt_tokens_details=SimpleNamespace(cached_tokens=5), ) ) def _client() -> AsyncOpenAI: return cast(AsyncOpenAI, SimpleNamespace(chat=SimpleNamespace(completions=None))) async def test_complete_with_schema_returns_parsed_instance() -> None: structured = _FakeStructured(_Out(label="ok", score=9), _raw_with_usage()) adapter = OpenAICompatAdapter("deepseek", _client(), structured_client=structured) result = await adapter.complete(_req(input="x", output_schema=_Out), "deepseek-chat") assert isinstance(result.parsed, _Out) assert result.parsed.label == "ok" assert result.parsed.score == 9 assert structured.last_response_model is _Out # usage 从 raw completion 提取 assert result.usage.input_tokens == 42 assert result.usage.output_tokens == 7 assert result.usage.cache_read_tokens == 5 async def test_complete_without_schema_keeps_parsed_none() -> None: response = SimpleNamespace( choices=[SimpleNamespace(message=SimpleNamespace(content="纯文本"))], usage=SimpleNamespace(prompt_tokens=3, completion_tokens=2, prompt_tokens_details=None), ) class _Completions: async def create(self, **kw: Any) -> Any: return response client = cast( AsyncOpenAI, SimpleNamespace(chat=SimpleNamespace(completions=_Completions())), ) adapter = OpenAICompatAdapter("deepseek", client) result = await adapter.complete(_req(input="x"), "deepseek-chat") assert result.parsed is None assert result.text == "纯文本" class _FakeLedger: def __init__(self) -> None: self.records: list[Any] = [] async def record(self, scope: Any, usage: Any) -> None: self.records.append(usage) async def test_gateway_run_passes_parsed_through_and_records_once() -> None: structured = _FakeStructured(_Out(label="hit", score=1), _raw_with_usage()) adapter = OpenAICompatAdapter("deepseek", _client(), structured_client=structured) ledger = _FakeLedger() gw = Gateway({"deepseek": adapter}, ledger, resolver=_route) resp = await gw.run(_req(input="x", output_schema=_Out)) assert isinstance(resp.parsed, _Out) assert resp.parsed.label == "hit" assert len(ledger.records) == 1