Skip to content

AnthropicLlm leaves error_code empty when Claude refuses or hits max_tokens with no content #7660

Description

@chrikrah

🔴 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

  1. Install google-adk at 1def14b and anthropic 1.15.0.
  2. 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

  1. added
    models[Component] This issue is related to model support
    on Oct 22, 2026
  2. llalitkumarrr commented on Oct 27, 2026

    @llalitkumarrr
    Collaborator

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

models[Component] This issue is related to model support

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions