diff --git a/packages/llm_gateway/tests/test_new_adapters.py b/packages/llm_gateway/tests/test_new_adapters.py index 04fd623..ad15d03 100644 --- a/packages/llm_gateway/tests/test_new_adapters.py +++ b/packages/llm_gateway/tests/test_new_adapters.py @@ -164,6 +164,51 @@ async def test_gemini_structured_output() -> None: assert "config" in models.last_kwargs +async def test_anthropic_structured_output_threads_usage() -> None: + # 结构化输出必须记真实 usage(CR-H3):instructor 的 create_with_completion 回 + # (parsed, raw),raw.usage 带真实 token 数;网关据此记账(§4.8)。 + class _RawUsage: + input_tokens = 120 + output_tokens = 80 + cache_read_input_tokens = 30 + + class _Raw: + usage = _RawUsage() + + class _FakeStructured: + def __init__(self) -> None: + self.last_kwargs: dict[str, Any] = {} + + async def create_with_completion( + self, *, response_model: type[BaseModel], **kwargs: Any + ) -> tuple[BaseModel, Any]: + self.last_kwargs = kwargs + return response_model(score=7), _Raw() + + # 修前路径调 `.create`(仅回 parsed)——保留它,令 RED 是值不符(0!=120) + # 而非 AttributeError,坐实旧代码硬编码零 usage 的缺陷。 + async def create(self, *, response_model: type[BaseModel], **kwargs: Any) -> BaseModel: + return response_model(score=7) + + fake = _FakeStructured() + client = _FakeAnthropicClient(_FakeAnthropicMessages("", 0, 0, 0)) + adapter = AnthropicAdapter("anthropic", client, structured_client=fake) + req = LlmRequest( + tier="analyst", + input="打分", + output_schema=Out, + scope=Scope(user_id=uuid.UUID(int=1)), + ) + + result = await adapter.complete(req, "claude-3-5-sonnet") + + assert isinstance(result.parsed, Out) + assert result.parsed.score == 7 + assert result.usage.input_tokens == 120 + assert result.usage.output_tokens == 80 + assert result.usage.cache_read_tokens == 30 + + async def test_anthropic_stream_yields_text_then_usage() -> None: class _StreamEvent: def __init__(self, **kw: Any) -> None: diff --git a/packages/llm_gateway/ww_llm_gateway/adapters/anthropic.py b/packages/llm_gateway/ww_llm_gateway/adapters/anthropic.py index 5064b0a..5d12ede 100644 --- a/packages/llm_gateway/ww_llm_gateway/adapters/anthropic.py +++ b/packages/llm_gateway/ww_llm_gateway/adapters/anthropic.py @@ -46,7 +46,9 @@ class AnthropicClient(Protocol): class StructuredAnthropic(Protocol): - async def create(self, *, response_model: type[BaseModel], **kwargs: Any) -> BaseModel: ... + async def create_with_completion( + self, *, response_model: type[BaseModel], **kwargs: Any + ) -> tuple[BaseModel, Any]: ... def _is_transient(exc: Exception) -> bool: @@ -115,7 +117,7 @@ class AnthropicAdapter: # 注入客户端是 `AnthropicClient` Protocol(保留测试替身缝), # 但 instructor.from_anthropic 只重载真实 SDK 的具体类型; # 本适配器异步 → 转 `AsyncAnthropic` 选中 AsyncInstructor 重载, - # 返回的 AsyncInstructor 鸭子匹配 StructuredAnthropic。 + # 返回的 AsyncInstructor 鸭子匹配 StructuredAnthropic(含 create_with_completion)。 async_client = cast("AsyncAnthropic", self._client) self._structured_client = cast( "StructuredAnthropic", instructor.from_anthropic(async_client) @@ -156,9 +158,11 @@ class AnthropicAdapter: system = _system_blocks(req) if system: kwargs["system"] = system - parsed = await self._structured().create(response_model=req.output_schema, **kwargs) - # instructor.from_anthropic 默认不回 raw usage(按名隐藏);usage 经流/text 路径覆盖。 - usage = ProviderUsage(input_tokens=0, output_tokens=0) + parsed, raw = await self._structured().create_with_completion( + response_model=req.output_schema, **kwargs + ) + # 从 raw completion 提取真实 usage(记账真源,§4.8);不再硬编码零(CR-H3)。 + usage = _usage_from(getattr(raw, "usage", None)) return ProviderResult(text=parsed.model_dump_json(), usage=usage, parsed=parsed) async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]: