fix(llm): Anthropic 结构化输出记真实 usage——修计费记 0 成本(CR-H3)

This commit is contained in:
Yaojia Wang
2026-07-08 10:21:18 +02:00
parent c69e613c97
commit cb8fddfc3b
2 changed files with 54 additions and 5 deletions

View File

@@ -164,6 +164,51 @@ async def test_gemini_structured_output() -> None:
assert "config" in models.last_kwargs assert "config" in models.last_kwargs
async def test_anthropic_structured_output_threads_usage() -> None:
# 结构化输出必须记真实 usageCR-H3instructor 的 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: async def test_anthropic_stream_yields_text_then_usage() -> None:
class _StreamEvent: class _StreamEvent:
def __init__(self, **kw: Any) -> None: def __init__(self, **kw: Any) -> None:

View File

@@ -46,7 +46,9 @@ class AnthropicClient(Protocol):
class StructuredAnthropic(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: def _is_transient(exc: Exception) -> bool:
@@ -115,7 +117,7 @@ class AnthropicAdapter:
# 注入客户端是 `AnthropicClient` Protocol保留测试替身缝 # 注入客户端是 `AnthropicClient` Protocol保留测试替身缝
# 但 instructor.from_anthropic 只重载真实 SDK 的具体类型; # 但 instructor.from_anthropic 只重载真实 SDK 的具体类型;
# 本适配器异步 → 转 `AsyncAnthropic` 选中 AsyncInstructor 重载, # 本适配器异步 → 转 `AsyncAnthropic` 选中 AsyncInstructor 重载,
# 返回的 AsyncInstructor 鸭子匹配 StructuredAnthropic。 # 返回的 AsyncInstructor 鸭子匹配 StructuredAnthropic(含 create_with_completion
async_client = cast("AsyncAnthropic", self._client) async_client = cast("AsyncAnthropic", self._client)
self._structured_client = cast( self._structured_client = cast(
"StructuredAnthropic", instructor.from_anthropic(async_client) "StructuredAnthropic", instructor.from_anthropic(async_client)
@@ -156,9 +158,11 @@ class AnthropicAdapter:
system = _system_blocks(req) system = _system_blocks(req)
if system: if system:
kwargs["system"] = system kwargs["system"] = system
parsed = await self._structured().create(response_model=req.output_schema, **kwargs) parsed, raw = await self._structured().create_with_completion(
# instructor.from_anthropic 默认不回 raw usage按名隐藏usage 经流/text 路径覆盖。 response_model=req.output_schema, **kwargs
usage = ProviderUsage(input_tokens=0, output_tokens=0) )
# 从 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) return ProviderResult(text=parsed.model_dump_json(), usage=usage, parsed=parsed)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]: async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]: