-
-
Notifications
You must be signed in to change notification settings - Fork 11.2k
fix(anthropic-adapter): strip output_config for non-Anthropic backends #22727
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
68c891c
0b65e0a
19e85ea
ea8ac80
fd2f476
3647cac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,162 @@ | ||||||
| """ | ||||||
| Unit tests for LiteLLMMessagesToCompletionTransformationHandler._prepare_completion_kwargs. | ||||||
|
|
||||||
| Covers: | ||||||
| - max_tokens capping: prevents HTTP 400 from providers with strict output token limits | ||||||
| (e.g. Amazon Nova Pro: 10,000 tokens). | ||||||
| - output_config stripping: prevents HTTP 400 "extraneous key" errors from non-Anthropic | ||||||
| backends that don't understand Anthropic-specific parameters. | ||||||
| """ | ||||||
|
Comment on lines
+1
to
+9
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing tests for The PR title and description focus on stripping
Context Used: Rule from |
||||||
| import os | ||||||
| import sys | ||||||
| from unittest.mock import MagicMock, patch | ||||||
|
|
||||||
| import pytest | ||||||
|
|
||||||
| sys.path.insert(0, os.path.abspath("../../../../..")) | ||||||
|
|
||||||
| from litellm.llms.anthropic.experimental_pass_through.adapters.handler import ( | ||||||
| LiteLLMMessagesToCompletionTransformationHandler, | ||||||
| ) | ||||||
|
|
||||||
|
|
||||||
| MESSAGES = [{"role": "user", "content": "hello"}] | ||||||
| MODEL = "converse/us.amazon.nova-pro-v1:0" | ||||||
| PROVIDER = "bedrock" | ||||||
| MODEL_MAX_OUTPUT = 10_000 | ||||||
|
|
||||||
|
|
||||||
| def _call(max_tokens, extra_kwargs=None): | ||||||
| """Helper: call _prepare_completion_kwargs and return the resolved max_tokens.""" | ||||||
| kwargs, _ = LiteLLMMessagesToCompletionTransformationHandler._prepare_completion_kwargs( | ||||||
| max_tokens=max_tokens, | ||||||
| messages=MESSAGES, | ||||||
| model=MODEL, | ||||||
| extra_kwargs=extra_kwargs or {"custom_llm_provider": PROVIDER}, | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Helper masks empty dict argument
Use an explicit
Suggested change
|
||||||
| ) | ||||||
| return kwargs["max_tokens"] | ||||||
|
|
||||||
|
|
||||||
| class TestMaxTokensCapping: | ||||||
| def test_caps_when_exceeds_limit(self): | ||||||
| """max_tokens above the model limit is silently capped to max_output_tokens.""" | ||||||
| with patch("litellm.get_model_info") as mock_info: | ||||||
| mock_info.return_value = {"max_output_tokens": MODEL_MAX_OUTPUT} | ||||||
| result = _call(max_tokens=16_000) | ||||||
| assert result == MODEL_MAX_OUTPUT | ||||||
|
|
||||||
| def test_unchanged_when_within_limit(self): | ||||||
| """max_tokens at or below the model limit is left unchanged.""" | ||||||
| with patch("litellm.get_model_info") as mock_info: | ||||||
| mock_info.return_value = {"max_output_tokens": MODEL_MAX_OUTPUT} | ||||||
| result = _call(max_tokens=8_000) | ||||||
| assert result == 8_000 | ||||||
|
|
||||||
| def test_unchanged_when_equal_to_limit(self): | ||||||
| """max_tokens exactly equal to the model limit is left unchanged.""" | ||||||
| with patch("litellm.get_model_info") as mock_info: | ||||||
| mock_info.return_value = {"max_output_tokens": MODEL_MAX_OUTPUT} | ||||||
| result = _call(max_tokens=MODEL_MAX_OUTPUT) | ||||||
| assert result == MODEL_MAX_OUTPUT | ||||||
|
|
||||||
| def test_fallback_infers_provider_when_not_in_extra_kwargs(self): | ||||||
| """When custom_llm_provider is absent from extra_kwargs, max_tokens is still | ||||||
| capped correctly by inferring the provider from the model string.""" | ||||||
| with patch("litellm.utils.get_llm_provider") as mock_provider, \ | ||||||
| patch("litellm.get_model_info") as mock_info: | ||||||
| mock_provider.return_value = (MODEL, PROVIDER, None, None) | ||||||
| mock_info.return_value = {"max_output_tokens": MODEL_MAX_OUTPUT} | ||||||
|
|
||||||
| result = _call(max_tokens=16_000, extra_kwargs={}) | ||||||
|
|
||||||
| assert result == MODEL_MAX_OUTPUT | ||||||
|
|
||||||
| def test_resilient_when_get_model_info_raises(self): | ||||||
| """If get_model_info raises, max_tokens is passed through unchanged.""" | ||||||
| with patch("litellm.get_model_info", side_effect=Exception("model not found")), \ | ||||||
| patch("litellm.utils.get_llm_provider", side_effect=Exception("no provider")): | ||||||
| result = _call(max_tokens=16_000) | ||||||
| assert result == 16_000 | ||||||
|
|
||||||
| def test_no_cap_when_max_output_tokens_missing(self): | ||||||
| """If model_info has no max_output_tokens key, max_tokens is unchanged.""" | ||||||
| with patch("litellm.get_model_info") as mock_info: | ||||||
| mock_info.return_value = {} | ||||||
| result = _call(max_tokens=16_000) | ||||||
| assert result == 16_000 | ||||||
|
|
||||||
| def test_no_cap_when_max_output_tokens_is_none(self): | ||||||
| """Explicit None max_output_tokens does not trigger capping.""" | ||||||
| with patch("litellm.get_model_info") as mock_info: | ||||||
| mock_info.return_value = {"max_output_tokens": None} | ||||||
| result = _call(max_tokens=16_000) | ||||||
| assert result == 16_000 | ||||||
|
|
||||||
| def test_explicit_provider_used_before_inference(self): | ||||||
| """When custom_llm_provider is present, get_llm_provider is not called.""" | ||||||
| with patch("litellm.utils.get_llm_provider") as mock_provider, \ | ||||||
| patch("litellm.get_model_info") as mock_info: | ||||||
| mock_info.return_value = {"max_output_tokens": MODEL_MAX_OUTPUT} | ||||||
| _call(max_tokens=16_000, extra_kwargs={"custom_llm_provider": PROVIDER}) | ||||||
| mock_provider.assert_not_called() | ||||||
|
|
||||||
|
|
||||||
| def _call_with_output_config(extra_kwargs=None): | ||||||
| """Helper: call _prepare_completion_kwargs with output_config in extra_kwargs | ||||||
| and return the full completion kwargs dict.""" | ||||||
| kwargs, _ = LiteLLMMessagesToCompletionTransformationHandler._prepare_completion_kwargs( | ||||||
| max_tokens=1024, | ||||||
| messages=MESSAGES, | ||||||
| model=MODEL, | ||||||
| extra_kwargs={ | ||||||
| "output_config": {"type": "text"}, | ||||||
| **(extra_kwargs or {"custom_llm_provider": PROVIDER}), | ||||||
| }, | ||||||
| ) | ||||||
| return kwargs | ||||||
|
|
||||||
|
|
||||||
| class TestOutputConfigStripping: | ||||||
| def test_stripped_for_bedrock_non_claude(self): | ||||||
| """output_config is stripped when targeting a non-Anthropic Bedrock model.""" | ||||||
| with patch("litellm.get_model_info", return_value={}): | ||||||
| kwargs = _call_with_output_config( | ||||||
| extra_kwargs={"custom_llm_provider": "bedrock"} | ||||||
| ) | ||||||
| assert "output_config" not in kwargs | ||||||
|
|
||||||
| def test_stripped_for_non_anthropic_provider(self): | ||||||
| """output_config is stripped for any non-Anthropic provider.""" | ||||||
| with patch("litellm.get_model_info", return_value={}): | ||||||
| kwargs = _call_with_output_config( | ||||||
| extra_kwargs={"custom_llm_provider": "openai"} | ||||||
| ) | ||||||
| assert "output_config" not in kwargs | ||||||
|
|
||||||
| def test_passed_through_for_anthropic_provider(self): | ||||||
| """output_config is preserved when targeting the Anthropic provider directly.""" | ||||||
| with patch("litellm.get_model_info", return_value={}): | ||||||
| kwargs = _call_with_output_config( | ||||||
| extra_kwargs={"custom_llm_provider": "anthropic"} | ||||||
| ) | ||||||
| assert "output_config" in kwargs | ||||||
|
|
||||||
| def test_passed_through_for_bedrock_claude(self): | ||||||
| """output_config is preserved when targeting an Anthropic Claude model on Bedrock.""" | ||||||
| with patch("litellm.get_model_info", return_value={}): | ||||||
| kwargs, _ = LiteLLMMessagesToCompletionTransformationHandler._prepare_completion_kwargs( | ||||||
| max_tokens=1024, | ||||||
| messages=MESSAGES, | ||||||
| model="converse/us.anthropic.claude-sonnet-4-20250514-v1:0", | ||||||
| extra_kwargs={ | ||||||
| "output_config": {"type": "text"}, | ||||||
| "custom_llm_provider": "bedrock", | ||||||
| }, | ||||||
| ) | ||||||
| assert "output_config" in kwargs | ||||||
|
|
||||||
| def test_stripped_when_no_provider_specified(self): | ||||||
| """output_config is stripped when no provider is given (defaults to non-Anthropic).""" | ||||||
| with patch("litellm.get_model_info", return_value={}): | ||||||
| kwargs = _call_with_output_config(extra_kwargs={}) | ||||||
| assert "output_config" not in kwargs | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing Vertex AI Claude detection
The
_is_anthropic_claudecheck only covers the"anthropic"and"bedrock"providers, but Vertex AI also hosts Anthropic Claude models (e.g.vertex_ai/claude-sonnet-4). For those,custom_llm_providerwould be"vertex_ai"and the model string would contain"claude"but not"anthropic.claude". This meansoutput_configwould be incorrectly stripped for Vertex AI Claude models.There is already an existing helper
LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model()in the adjacenttransformation.py(line 653) that correctly handles all three providers by checking for"anthropic"or"claude"in the model string. Consider reusing that helper, or extending this check to cover Vertex AI: