Skip to content

Commit 4c862b9

Browse files
sebastienci-yliu
authored andcommitted
feat(adk): add Anthropic effort config handling, thinking parameter propagation, and conflict mitigation
Key changes: - Reasoning Effort Support for anthropic model: Introduces AnthropicGenerateContentConfig to allow users to set the effort field directly ("low", "medium", "high", "xhigh", "max") for Anthropic model effort level. - the standard thinking_config.thinking_level was not passed to anthropic effort due to mapping inconsistencies between the 4-level standard enum and Anthropic's 5 effort levels. It will raise a warning. - Conflict Mitigation: Automatically excludes sampling parameters (temperature, top_p, top_k) when thinking or reasoning effort is enabled, avoiding Anthropic API 400 errors. Emits a warning log if these parameters were provided but ignored. - Unified Parameter Propagation: Propagates core GenerateContentConfig parameters (temperature, top_p, top_k, stop_sequences, and max_output_tokens [mapped to max_tokens]) to the Anthropic client using the new _build_anthropic_kwargs helper. - Type Annotation Updates: Adds ThinkingConfigAdaptiveParam to the thinking parameter type annotations across streaming and non-streaming interfaces. Note for callers: - This is a behavior change for existing Anthropic model callers. Previously, temperature, top_p, top_k, stop_sequences, and max_output_tokens were ignored (dropped) for Claude. They are now correctly propagated to the Anthropic client, and max_output_tokens will now correctly override the default max_tokens (8192). Fixes: #5393 Closes: #5513 Co-authored-by: Yi Liu <yiliuly@google.com> COPYBARA_INTEGRATE_REVIEW=#5513 from sebastienc:feat/anthropic-generation-config 0276484 PiperOrigin-RevId: 940074181
1 parent 81f9f2e commit 4c862b9

3 files changed

Lines changed: 508 additions & 38 deletions

File tree

src/google/adk/models/__init__.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
if TYPE_CHECKING:
2828
from google.adk.labs.openai import OpenAILlm
2929

30+
from .anthropic_llm import AnthropicGenerateContentConfig
3031
from .anthropic_llm import Claude
3132
from .apigee_llm import ApigeeLlm
3233
from .gemma_llm import Gemma
@@ -35,6 +36,7 @@
3536
from .lite_llm import LiteLlm
3637

3738
__all__ = [
39+
'AnthropicGenerateContentConfig',
3840
'ApigeeLlm',
3941
'BaseLlm',
4042
'Claude',
@@ -97,18 +99,27 @@
9799
LLMRegistry._register_lazy(_patterns, _target_module, _name)
98100

99101

102+
_OTHER_LAZY_IMPORTS: dict[str, str] = {
103+
'AnthropicGenerateContentConfig': 'anthropic_llm',
104+
}
105+
106+
100107
def __getattr__(name: str):
101108
if name in _LAZY_PROVIDERS:
102109
module_name = _LAZY_PROVIDERS[name][1]
103-
try:
104-
if module_name.startswith('google.adk.'):
105-
module = importlib.import_module(module_name)
106-
else:
107-
module = importlib.import_module(f'{__name__}.{module_name}')
108-
except ImportError as e:
109-
raise ImportError(
110-
f'`{name}` requires an optional dependency that is not installed.'
111-
' Install with: pip install google-adk[extensions]'
112-
) from e
113-
return getattr(module, name)
114-
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
110+
elif name in _OTHER_LAZY_IMPORTS:
111+
module_name = _OTHER_LAZY_IMPORTS[name]
112+
else:
113+
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
114+
115+
try:
116+
if module_name.startswith('google.adk.'):
117+
module = importlib.import_module(module_name)
118+
else:
119+
module = importlib.import_module(f'{__name__}.{module_name}')
120+
except ImportError as e:
121+
raise ImportError(
122+
f'`{name}` requires an optional dependency that is not installed.'
123+
' Install with: pip install google-adk[extensions]'
124+
) from e
125+
return getattr(module, name)

src/google/adk/models/anthropic_llm.py

Lines changed: 202 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from typing import Optional
3232
from typing import TYPE_CHECKING
3333
from typing import Union
34+
import warnings
3435

3536
from anthropic import AsyncAnthropic
3637
from anthropic import AsyncAnthropicVertex
@@ -39,16 +40,19 @@
3940
from anthropic import types as anthropic_types
4041
from google.genai import types
4142
from pydantic import BaseModel
43+
from pydantic import Field
44+
from pydantic import model_validator
4245
from typing_extensions import override
4346

4447
from ..utils._google_client_headers import get_tracking_headers
4548
from .base_llm import BaseLlm
49+
from .interactions_utils import extract_system_instruction
4650
from .llm_response import LlmResponse
4751

4852
if TYPE_CHECKING:
4953
from .llm_request import LlmRequest
5054

51-
__all__ = ["AnthropicLlm", "Claude"]
55+
__all__ = ["AnthropicLlm", "Claude", "AnthropicGenerateContentConfig"]
5256

5357
logger = logging.getLogger("google_adk." + __name__)
5458

@@ -98,6 +102,12 @@ def _build_anthropic_thinking_param(
98102
caller gets the canonical error message). Rejected by Claude Opus 4.7
99103
-- callers targeting 4.7+ must use a negative value (adaptive) or
100104
``0`` (disabled).
105+
106+
Args:
107+
config: Optional GenerateContentConfig object.
108+
109+
Returns:
110+
Mapped thinking parameter or NotGiven.
101111
"""
102112
if not config or not config.thinking_config:
103113
return NOT_GIVEN
@@ -130,6 +140,85 @@ def _build_anthropic_thinking_param(
130140
)
131141

132142

143+
class AnthropicGenerateContentConfig(types.GenerateContentConfig):
144+
"""Configuration options for Anthropic Claude content generation.
145+
146+
This specialized configuration class is the recommended way to
147+
configure reasoning and extended thinking for newer Claude models.
148+
149+
Attributes:
150+
effort: The reasoning effort level for adaptive extended thinking. Set
151+
directly to guide the reasoning depth ("low", "medium", "high", "xhigh",
152+
"max"). This is the preferred alternative to the deprecated manual
153+
`thinking_budget` on newer Claude models.
154+
"""
155+
156+
effort: Optional[Literal["low", "medium", "high", "xhigh", "max"]] = Field(
157+
default=None,
158+
description=(
159+
"Configures the Claude-specific reasoning effort level for adaptive"
160+
" extended thinking. This is the recommended, future-proof way to"
161+
" control reasoning depth on newer Claude models."
162+
),
163+
)
164+
165+
@model_validator(mode="after")
166+
def validate_no_thinking_level(self) -> "AnthropicGenerateContentConfig":
167+
"""Ensures thinking_level is not configured on Anthropic-specific config."""
168+
169+
if self.thinking_config and self.thinking_config.thinking_level is not None:
170+
raise ValueError(
171+
"thinking_level is not supported in AnthropicGenerateContentConfig. "
172+
"Use the `effort` field directly to configure reasoning effort."
173+
)
174+
return self
175+
176+
177+
def _build_effort_param(
178+
config: Optional[types.GenerateContentConfig],
179+
) -> Optional[str]:
180+
"""Extracts Anthropic's effort parameter from the configuration.
181+
182+
To configure a specific reasoning effort level for Anthropic models,
183+
callers must use ``google.adk.models.AnthropicGenerateContentConfig`` and
184+
set the ``effort`` field directly.
185+
Using the standard ``thinking_config.thinking_level`` is explicitly
186+
unsupported because the standard `ThinkingLevel` enum (4 levels) cannot map
187+
consistently to Anthropic's 5 effort levels
188+
("low", "medium", "high", "xhigh", "max").
189+
190+
Any attempt to set `thinking_level` will not be passed to the model and will
191+
log a warning.
192+
193+
If `effort` is not set, we return `None`.
194+
If `effort` and `thinking_level` are both set, `effort` takes precedence.
195+
196+
Args:
197+
config: Optional GenerateContentConfig object.
198+
199+
Returns:
200+
The effort level string (e.g., "xhigh") if specified via
201+
AnthropicGenerateContentConfig, or None.
202+
"""
203+
if not config:
204+
return None
205+
206+
if isinstance(config, AnthropicGenerateContentConfig) and config.effort:
207+
return config.effort
208+
209+
# If effort is not set, but thinking_level is, log a warning and ignore it.
210+
if config.thinking_config and config.thinking_config.thinking_level:
211+
warnings.warn(
212+
"Standard thinking_config.thinking_level is not supported for Anthropic"
213+
" models and will be ignored. Use AnthropicGenerateContentConfig and"
214+
" set the `effort` field directly to configure reasoning effort.",
215+
category=UserWarning,
216+
stacklevel=4,
217+
)
218+
219+
return None
220+
221+
133222
class ClaudeRequest(BaseModel):
134223
system_instruction: str
135224
messages: Iterable[anthropic_types.MessageParam]
@@ -509,6 +598,14 @@ def function_declaration_to_tool_param(
509598
class AnthropicLlm(BaseLlm):
510599
"""Integration with Claude models via the Anthropic API.
511600
601+
Note:
602+
Anthropic Claude supports 5 distinct effort levels ("low", "medium",
603+
"high", "xhigh", "max") while the standard `ThinkingLevel` enum defines 4
604+
levels (MINIMAL, LOW, MEDIUM, HIGH), the standard
605+
`thinking_config.thinking_level` is not supported for Anthropic models.
606+
To configure thinking effort, user must use `AnthropicGenerateContentConfig`
607+
and set its `effort` field directly (e.g., `effort="xhigh"`).
608+
512609
Attributes:
513610
model: The name of the Claude model.
514611
max_tokens: The maximum number of tokens to generate.
@@ -534,11 +631,86 @@ def _resolve_model_name(self, model: Optional[str]) -> str:
534631
return match.group(1)
535632
return model
536633

634+
def _build_anthropic_kwargs(
635+
self,
636+
llm_request: LlmRequest,
637+
messages: list[anthropic_types.MessageParam],
638+
tools: Union[Iterable[anthropic_types.ToolUnionParam], NotGiven],
639+
tool_choice: Union[anthropic_types.ToolChoiceParam, NotGiven],
640+
thinking: Union[
641+
anthropic_types.ThinkingConfigEnabledParam,
642+
anthropic_types.ThinkingConfigDisabledParam,
643+
anthropic_types.ThinkingConfigAdaptiveParam,
644+
NotGiven,
645+
],
646+
) -> dict[str, Any]:
647+
system = NOT_GIVEN
648+
if llm_request.config:
649+
system_str = extract_system_instruction(llm_request.config)
650+
if system_str:
651+
system = system_str
652+
653+
model_to_use = self._resolve_model_name(llm_request.model)
654+
kwargs = {
655+
"model": model_to_use,
656+
"system": system,
657+
"messages": messages,
658+
"tools": tools,
659+
"tool_choice": tool_choice,
660+
"thinking": thinking,
661+
}
662+
663+
effort = _build_effort_param(llm_request.config)
664+
if effort:
665+
kwargs["output_config"] = {"effort": effort}
666+
667+
# Determine if thinking is enabled to avoid parameter conflicts.
668+
thinking_enabled = False
669+
if thinking is not NOT_GIVEN and thinking is not None:
670+
if isinstance(thinking, dict):
671+
thinking_enabled = thinking.get("type") in ["enabled", "adaptive"]
672+
673+
exclude_sampling = thinking_enabled or (effort is not None)
674+
675+
if llm_request.config:
676+
# Models released after Claude Opus 4.6 do not support setting
677+
# temperature, top_k, or top_p when thinking is enabled or effort is set.
678+
if not exclude_sampling:
679+
if llm_request.config.temperature is not None:
680+
kwargs["temperature"] = llm_request.config.temperature
681+
if llm_request.config.top_p is not None:
682+
kwargs["top_p"] = llm_request.config.top_p
683+
if llm_request.config.top_k is not None:
684+
kwargs["top_k"] = int(llm_request.config.top_k)
685+
else:
686+
if (
687+
llm_request.config.temperature is not None
688+
or llm_request.config.top_p is not None
689+
or llm_request.config.top_k is not None
690+
):
691+
warnings.warn(
692+
"Sampling parameters (temperature, top_p, top_k) are ignored "
693+
"because thinking/effort is enabled.",
694+
category=UserWarning,
695+
stacklevel=3,
696+
)
697+
698+
if llm_request.config.stop_sequences:
699+
kwargs["stop_sequences"] = llm_request.config.stop_sequences
700+
701+
if llm_request.config.max_output_tokens is not None:
702+
kwargs["max_tokens"] = llm_request.config.max_output_tokens
703+
else:
704+
kwargs["max_tokens"] = self.max_tokens
705+
else:
706+
kwargs["max_tokens"] = self.max_tokens
707+
708+
return kwargs
709+
537710
@override
538711
async def generate_content_async(
539712
self, llm_request: LlmRequest, stream: bool = False
540713
) -> AsyncGenerator[LlmResponse, None]:
541-
model_to_use = self._resolve_model_name(llm_request.model)
542714
sanitizer = _ToolUseIdSanitizer()
543715
messages = [
544716
_content_to_message_param(content, sanitizer)
@@ -560,55 +732,51 @@ async def generate_content_async(
560732
else NOT_GIVEN
561733
)
562734
thinking = _build_anthropic_thinking_param(llm_request.config)
563-
system = NOT_GIVEN
564-
if llm_request.config.system_instruction is not None:
565-
system = llm_request.config.system_instruction
566735

567736
if not stream:
568-
message = await self._anthropic_client.messages.create(
569-
model=model_to_use,
570-
system=system,
571-
messages=messages,
572-
tools=tools,
573-
tool_choice=tool_choice,
574-
max_tokens=self.max_tokens,
575-
thinking=thinking,
737+
kwargs = self._build_anthropic_kwargs(
738+
llm_request, messages, tools, tool_choice, thinking
576739
)
740+
message = await self._anthropic_client.messages.create(**kwargs)
577741
yield message_to_generate_content_response(message)
578742
else:
579743
async for response in self._generate_content_streaming(
580-
llm_request, messages, system, tools, tool_choice, thinking
744+
llm_request, messages, tools, tool_choice, thinking
581745
):
582746
yield response
583747

584748
async def _generate_content_streaming(
585749
self,
586750
llm_request: LlmRequest,
587751
messages: list[anthropic_types.MessageParam],
588-
system: Union[str, types.Content, NotGiven],
589752
tools: Union[Iterable[anthropic_types.ToolUnionParam], NotGiven],
590753
tool_choice: Union[anthropic_types.ToolChoiceParam, NotGiven],
591754
thinking: Union[
592755
anthropic_types.ThinkingConfigEnabledParam,
593756
anthropic_types.ThinkingConfigDisabledParam,
757+
anthropic_types.ThinkingConfigAdaptiveParam,
594758
NotGiven,
595759
] = NOT_GIVEN,
596760
) -> AsyncGenerator[LlmResponse, None]:
597761
"""Handles streaming responses from Anthropic models.
598762
599-
Yields partial LlmResponse objects as content arrives, followed by
600-
a final aggregated LlmResponse with all content.
763+
Args:
764+
llm_request: LlmRequest containing configurations and contents.
765+
messages: List of formatted Anthropic messages.
766+
tools: Optional tool configurations.
767+
tool_choice: Optional tool choice setting.
768+
thinking: Optional thinking details.
769+
770+
Yields:
771+
Partial LlmResponse objects as content arrives, followed by
772+
a final aggregated LlmResponse with all content.
601773
"""
602-
model_to_use = self._resolve_model_name(llm_request.model)
774+
kwargs = self._build_anthropic_kwargs(
775+
llm_request, messages, tools, tool_choice, thinking
776+
)
603777
raw_stream = await self._anthropic_client.messages.create(
604-
model=model_to_use,
605-
system=system,
606-
messages=messages,
607-
tools=tools,
608-
tool_choice=tool_choice,
609-
max_tokens=self.max_tokens,
610778
stream=True,
611-
thinking=thinking,
779+
**kwargs,
612780
)
613781

614782
# Track content blocks being built during streaming.
@@ -730,6 +898,15 @@ def _anthropic_client(self) -> AsyncAnthropic:
730898
class Claude(AnthropicLlm):
731899
"""Integration with Claude models served from Vertex AI.
732900
901+
Note:
902+
Because Anthropic Claude supports 5 distinct effort levels ("low", "medium",
903+
"high", "xhigh", "max") while the standard `ThinkingLevel` enum defines 4
904+
levels (MINIMAL, LOW, MEDIUM, HIGH), the standard
905+
`thinking_config.thinking_level` is not supported for Anthropic models.
906+
907+
To configure thinking effort, user must use `AnthropicGenerateContentConfig`
908+
and set its `effort` field directly (e.g., `effort="xhigh"`).
909+
733910
Attributes:
734911
model: The name of the Claude model.
735912
max_tokens: The maximum number of tokens to generate.

0 commit comments

Comments
 (0)