fix(llm): Anthropic 结构化输出记真实 usage——修计费记 0 成本(CR-H3)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user