From 096ba84f00636bf3e317e1f8fac42a4c3f3834ce Mon Sep 17 00:00:00 2001 From: yucheng Date: Wed, 23 Sep 2026 08:00:26 +0000 Subject: [PATCH] fix(batches): map anthropic batch lines through the anthropic message transformer Anthropic result lines carry a message-shaped body, so ModelResponse(**body) produced empty choices and a usage key that blocked the batch-rate usage attach. Extract the existing AnthropicResponse -> ModelResponse conversion used for /v1/messages logging into anthropic_message_to_model_response and reuse it for anthropic line items. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/batches/batch_line_item_logging.py | 10 ++++-- litellm/litellm_core_utils/litellm_logging.py | 33 +++++++++++-------- .../batches/test_batch_line_item_logging.py | 6 ++++ 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/litellm/batches/batch_line_item_logging.py b/litellm/batches/batch_line_item_logging.py index 911a4d478ec..b91ef3dccd3 100644 --- a/litellm/batches/batch_line_item_logging.py +++ b/litellm/batches/batch_line_item_logging.py @@ -121,11 +121,17 @@ def _line_model( _BatchLineResult: TypeAlias = "ModelResponse | EmbeddingResponse | ResponsesAPIResponse" -def _line_result(call_type: str, response_body: Mapping[str, object]) -> _BatchLineResult: +def _line_result( + call_type: str, custom_llm_provider: _BatchLineProvider, response_body: Mapping[str, object] +) -> _BatchLineResult: if call_type == "aembedding": return EmbeddingResponse(**response_body) # pyright: ignore[reportArgumentType] # provider output bodies are dicts expanded as response ctor kwargs if call_type == "aresponses": return ResponsesAPIResponse(**response_body) # pyright: ignore[reportArgumentType] # same as above + if custom_llm_provider == "anthropic": + from litellm.litellm_core_utils.litellm_logging import anthropic_message_to_model_response + + return anthropic_message_to_model_response(response_body, None) return ModelResponse(**response_body) # pyright: ignore[reportArgumentType] # same as above @@ -235,7 +241,7 @@ async def _emit_line_event( stats: Final = _safe_output_line_stats(entry, custom_llm_provider, model_name, model_info) try: - result: Final = _line_result(call_type, response_body) + result: Final = _line_result(call_type, custom_llm_provider, response_body) except Exception: # noqa: BLE001 # one unparseable line must not drop the rest of the batch's line events verbose_logger.warning( "batch output line could not be reconstructed as a %s response, skipping it. custom_id=%s", diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 06c8218eea5..11b1d4725f7 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -535,6 +535,24 @@ def _provider_response_id(source: object) -> str | None: return candidate if isinstance(candidate, str) and candidate else None +def anthropic_message_to_model_response(result: Mapping[str, object], speed: str | None) -> ModelResponse: + import httpx + + from litellm.types.llms.anthropic import AnthropicResponse + + pydantic_result: Final = AnthropicResponse.model_validate(result) + return litellm.AnthropicConfig().transform_parsed_response( + completion_response=pydantic_result.model_dump(), + raw_response=httpx.Response( + status_code=200, + headers={}, + ), + model_response=litellm.ModelResponse(id=_provider_response_id(result)), + json_mode=None, + speed=speed, + ) + + def mask_api_base_credentials(api_base: str) -> str: if "key=" not in api_base: return api_base @@ -4237,19 +4255,8 @@ class Logging(LiteLLMLoggingBaseClass): litellm_params={}, ) else: - from litellm.types.llms.anthropic import AnthropicResponse - - pydantic_result: Final = AnthropicResponse.model_validate(result) - import httpx - - result = litellm.AnthropicConfig().transform_parsed_response( - completion_response=pydantic_result.model_dump(), - raw_response=httpx.Response( - status_code=200, - headers={}, - ), - model_response=litellm.ModelResponse(id=provider_response_id), - json_mode=None, + result = anthropic_message_to_model_response( + cast(Mapping[str, object], result), speed=self.optional_params.get("speed") if self.optional_params else None, ) return result diff --git a/tests/test_litellm/batches/test_batch_line_item_logging.py b/tests/test_litellm/batches/test_batch_line_item_logging.py index fe03e348ce0..62c6bd359f2 100644 --- a/tests/test_litellm/batches/test_batch_line_item_logging.py +++ b/tests/test_litellm/batches/test_batch_line_item_logging.py @@ -368,6 +368,8 @@ ANTHROPIC_OUTPUT_JSONL = json.dumps( "role": "assistant", "content": [{"type": "text", "text": "hello b2"}], "model": "claude-3", + "stop_reason": "end_turn", + "stop_sequence": None, "usage": {"input_tokens": 1, "output_tokens": 2}, }, }, @@ -482,3 +484,7 @@ async def test_line_items_anthropic_shapes(recorder): line = next(e for e in recorder.success_events if _hidden(e).get("batch_custom_id") == "b2") assert _hidden(line)["batch_line_status_code"] == 200 assert line["litellm_params"]["batch_parent_id"] == batch.id + payload = _payload(line) + assert payload["response"]["choices"][0]["message"]["content"] == "hello b2" + assert payload["prompt_tokens"] == 1 + assert payload["completion_tokens"] == 2