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
|
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:
|
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:
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
Reference in New Issue
Block a user