Repository navigation
AnthropicLlm leaves error_code empty when Claude refuses or hits max_tokens with no content #7660
Description
🔴 Required Information
Describe the Bug
A non-streaming Claude refusal can come back with stop_reason: "refusal" and no content blocks. AnthropicLlm returns that turn with error_code=None, where LlmResponse.create gives Gemini's empty turn SAFETY, and LiteLlm sets error_code for every non-STOP reason (src/google/adk/models/lite_llm.py:3155). The models guide says errors are "reported in-band through error_code and error_message" (docs/guides/models/llm_registry/index.md:146). A mocked max_tokens turn with content: [] behaves the same.
Steps to Reproduce
- Install
google-adkat1def14bandanthropic1.15.0. - Run the script below (mock transport, no key).
Expected Behavior
error_code=SAFETY, as in src/google/adk/models/llm_response.py:245.
Observed Behavior
$ python -I claude_refusal_error_code.py
claude refusal, no content : 1 event(s) [(<FinishReason.SAFETY: 'SAFETY'>, None, False)]
claude max_tokens, no content: 1 event(s) [(<FinishReason.MAX_TOKENS: 'MAX_TOKENS'>, None, False)]
gemini SAFETY LlmResponse.create: error_code=SAFETY
gemini MAX_TOKENS LlmResponse.create: error_code=MAX_TOKENS
# with a local patch applying the LlmResponse.create rule in message_to_generate_content_response
# and in the streamed final response, both Claude rows read 'SAFETY' and 'MAX_TOKENS'
$ python -m pytest -q tests/unittests/models/test_anthropic_llm.py tests/unittests/plugins/test_reflect_retry_model_plugin.py
1 failed, 200 passed # same on 1def14b and with the patch
# the failure is test_anthropic_client_created_from_sdk_credential_provider, which reads local credentials
# not run: the streaming path against a mock stream
Environment Details
- ADK Library Version (pip show google-adk): 2.12.0, main at
1def14b - Desktop OS: Linux (Ubuntu 24.04)
- Python Version (python -V): Python 3.12
Model Information
- Are you using LiteLLM: No
- Which model is being used:
claude-sonnet-4-5(mocked)
🟡 Optional Information
Regression
N/A, not checked.
Minimal Reproduction Code
import asyncio, httpx2, anthropic
from google.adk import Agent
from google.adk.models.anthropic_llm import AnthropicLlm
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import InMemoryRunner
from google.genai import types
def client_for(stop_reason, content):
def respond(request):
return httpx2.Response(200, json={"id": "msg_1", "type": "message", "role": "assistant",
"model": "claude-sonnet-4-5", "content": content, "stop_reason": stop_reason,
"stop_sequence": None, "usage": {"input_tokens": 40, "output_tokens": 12}})
return anthropic.AsyncAnthropic(api_key="x", http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(respond)))
async def run(label, stop_reason, content):
agent = Agent(name="a", model=AnthropicLlm(model="claude-sonnet-4-5", client=client_for(stop_reason, content)))
runner = InMemoryRunner(agent=agent, app_name="app")
s = await runner.session_service.create_session(app_name="app", user_id="u")
events = [e async for e in runner.run_async(user_id="u", session_id=s.id,
new_message=types.Content(role="user", parts=[types.Part(text="hi")]))]
print(f"{label}: {len(events)} event(s)", [(e.finish_reason, e.error_code, bool(e.content and e.content.parts)) for e in events])
async def main():
await run("claude refusal, no content ", "refusal", [])
await run("claude max_tokens, no content", "max_tokens", [])
for fr in (types.FinishReason.SAFETY, types.FinishReason.MAX_TOKENS):
r = LlmResponse.create(types.GenerateContentResponse(candidates=[types.Candidate(finish_reason=fr)]))
print(f"gemini {fr.name:10} LlmResponse.create: error_code={r.error_code}")
asyncio.run(main())Additional Context
ReflectAndRetryModelPlugin(on_model_errors=[SAFETY]) never fires on Claude for this reason. The two precedents split on a refusal that still returns text: LiteLlm flags it, create() does not. I'd follow create() and flag only the empty turn, because a refusal with text is still a usable answer. That rule misses a real max_tokens turn that spent its budget on thinking, since the thinking block becomes a thought part. Open #7558 by @donggyun112 already sets error_code=MAX_TOKENS when the last block is a cut-off tool_use, in the same return this fix would edit, so the empty case could fold into it. #7584 maps model_context_window_exceeded only.
How often has this issue occurred?
- Always (100%)
@GWeale, should a Claude refusal that still returns text set error_code as LiteLlm does, or only an empty turn as LlmResponse.create does?
Activity
llalitkumarrr commentedon Oct 27, 2026
Hello @chrikrah,
Could you please take a look at #7679? It should resolve your issue. Kindly review it and share your feedback.
Hello @emme1t,
Could you please sign the CLA? It is currently not signed and we cannot process your PR without it.