fix(adapters,vertex): pass output_config through to backends that accept it

Resolves the silent strip of Anthropic Structured Outputs across the
Vertex AI Claude transformation paths and the Anthropic-adapter
re-merge. Consolidates and supersedes four stalled community PRs
addressing overlapping aspects of the same root bug:

- #23475 (Vertex AI Claude blanket-strip removal)
- #23396 (Vertex AI Claude conditional passthrough)
- #23706 (Anthropic adapter exclude output_config from non-Anthropic
  backends)
- #22727 (Anthropic adapter strip output_config for non-Anthropic
  backends)

Closes / addresses: #23380 (Vertex AI Claude output_config drop),
related: #26423, #25079, #24549, #25971, #25957, #26163, #24856.

What was broken
---------------
* Vertex AI Claude paths called ``data.pop("output_config")`` and
  ``data.pop("output_format")`` unconditionally even when Vertex
  accepted those fields. Callers asking for Structured Outputs got a
  200 with prose and never knew the schema constraints had been
  silently dropped (often masked for months by permissive fallback
  parsers).
* The ``/v1/messages`` -> ``/chat/completions`` adapter
  (``LiteLLMMessagesToCompletionTransformationHandler``) re-merged the
  raw Anthropic-shaped ``output_config`` into ``completion_kwargs``
  AFTER the translator already mapped its meaningful parts to
  ``response_format`` / ``reasoning_effort``. Non-Anthropic backends
  (Azure OpenAI, Fireworks, Bedrock Nova, etc.) then 400'd with
  "Extra inputs are not permitted".

Approach
--------
Vertex AI Claude (chat-completion + experimental_pass_through paths):
  Replace the unconditional pop with a sanitizer
  ``_sanitize_vertex_anthropic_output_params`` that strips only the
  Vertex-unsupported keys (today: ``effort``) from ``output_config``
  while forwarding ``format`` and the legacy top-level
  ``output_format``. Defensive: non-dict ``output_config`` values are
  dropped to avoid sending malformed payloads downstream.
  Greptile P1 from PR #23396 addressed: when ``output_config`` carries
  both ``format`` and ``effort``, the prior conditional pass-through
  forwarded ``effort`` and reproduced the 400. The new helper filters
  per-key.

Anthropic ``/v1/messages`` adapter:
  Add ``output_config`` to a named module-level constant
  ``ANTHROPIC_ONLY_REQUEST_KEYS`` and wire it into ``excluded_keys`` so
  the post-translation re-merge skips re-adding the raw key. This
  fixes the 400 on non-Anthropic backends and avoids the conflicting
  duplicate (``response_format`` + raw ``output_config``) on
  Anthropic-family backends.
  Greptile P2 from PR #23706 addressed: the constant gives reviewers
  one grep target instead of an inline literal that silently grows.
  Greptile P2 from PR #22727 addressed: ``extra_kwargs or {}`` is
  replaced with explicit ``is None`` checks so empty-dict callers no
  longer skip the fallback path.

Tests
-----
* tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/
  test_vertex_ai_partner_models_anthropic_transformation.py:
  - 5 new/updated cases plus a direct unit test for
    ``_sanitize_vertex_anthropic_output_params``.
  - Updated ``test_vertex_ai_claude_sonnet_4_5_structured_output_fix``
    so its mock-injected ``output_format`` is asserted to FLOW THROUGH
    (the original test asserted the now-buggy strip behavior).
* tests/test_litellm/llms/anthropic/experimental_pass_through/
  adapters/test_handler_output_config_passthrough.py (new):
  - Constant export sanity, output_config strip with ``effort`` only,
    output_config strip with ``format`` only, regression guard that
    unrelated extras still flow, explicit-empty-dict path, and the
    ``extra_kwargs=None`` no-crash path.

Test-quality fixes incorporated from Greptile review on the
superseded PRs:
* No ``inspect.getsource`` source-text assertions (PR #24114 / #23475).
* ``sys.path`` insertion is anchored to ``__file__`` (PR #23706).
* Assertion messages are positional, not tuple (PR #24114-class bug).
* No ``or {}`` masking explicit empty dicts in helper signatures
  (PR #22727).

Verified locally: 26/26 pass with this commit. The new tests
fail (or fail to import) on ``main`` without it.

Out of scope
------------
* The ``max_tokens`` capping logic from PR #22727 — independent
  concern, deserves its own PR with a focused test plan.
* Architectural rework of the ``excluded_keys`` mechanism (Greptile
  P2 on PR #23706 noted point-fix growth). The named constant gives
  maintainers a clear place to extend; a registry-based approach
  would be a follow-up.

Co-Authored-By: netbrah <netbrah>
Co-Authored-By: s-zx <s-zx>
Co-Authored-By: invoicepulse <invoicepulse>
Co-Authored-By: cfdude <cfdude>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darien Kindlund 2026-04-24 11:48:39 -04:00
parent 7b47dffefd
commit b9e46cbdb7
5 changed files with 431 additions and 78 deletions

View file

@ -27,6 +27,16 @@ from litellm.utils import get_model_info
if TYPE_CHECKING:
pass
# Anthropic-only fields that the translator above already maps into the
# OpenAI-format completion_kwargs (output_config → reasoning_effort /
# response_format, etc.). They must be filtered out of the raw
# extra_kwargs re-merge below or non-Anthropic backends reject the call
# with 400 "Extra inputs are not permitted". Add new entries here when
# extending AnthropicMessagesRequestOptionalParams with another Anthropic-
# specific key.
ANTHROPIC_ONLY_REQUEST_KEYS: frozenset[str] = frozenset({"output_config"})
########################################################
# init adapter
ANTHROPIC_ADAPTER = AnthropicAdapter()
@ -202,8 +212,12 @@ class LiteLLMMessagesToCompletionTransformationHandler:
request_data["output_format"] = output_format
# Extract output_config from extra_kwargs so the translator can use it
# (e.g. output_config.effort for adaptive thinking → reasoning_effort)
extra_kwargs = extra_kwargs or {}
# (e.g. output_config.effort for adaptive thinking → reasoning_effort,
# output_config.format → response_format for structured outputs).
# Use explicit None check rather than `or {}` so an explicit empty dict
# caller-passed argument is preserved (matters for tests that drive
# the fallback inference path).
extra_kwargs = extra_kwargs if extra_kwargs is not None else {}
if "output_config" in extra_kwargs:
request_data["output_config"] = extra_kwargs["output_config"]
@ -225,8 +239,22 @@ class LiteLLMMessagesToCompletionTransformationHandler:
"include_usage": True,
}
excluded_keys = {"anthropic_messages"}
extra_kwargs = extra_kwargs or {}
# Keys that must NOT be forwarded as raw extras into the OpenAI-format
# ``completion_kwargs`` after translation. The translator above has
# already consumed the meaningful parts of these inputs (e.g.
# ``output_config.format`` → ``response_format``, ``output_config.effort``
# → ``reasoning_effort`` for non-Claude targets). Re-adding the raw
# Anthropic-shaped key here causes 400 "Extra inputs are not permitted"
# on non-Anthropic backends (Azure OpenAI, Fireworks, Bedrock Nova,
# etc.) and is silently lossy on Anthropic-family targets, which would
# see the translated key ``response_format`` AND a duplicate, conflicting
# ``output_config``.
#
# Maintainability: when adding a new Anthropic-only request param to
# ``AnthropicMessagesRequestOptionalParams``, also extend
# ``ANTHROPIC_ONLY_REQUEST_KEYS`` here so it doesn't silently leak.
excluded_keys = ANTHROPIC_ONLY_REQUEST_KEYS | {"anthropic_messages"}
extra_kwargs = extra_kwargs if extra_kwargs is not None else {}
for key, value in extra_kwargs.items():
if (
key == "litellm_logging_obj"

View file

@ -13,6 +13,7 @@ from litellm.types.llms.vertex_ai import VertexPartnerProvider
from litellm.types.router import GenericLiteLLMParams
from ....vertex_llm_base import VertexBase
from ..transformation import _sanitize_vertex_anthropic_output_params
class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, VertexBase):
@ -158,12 +159,10 @@ class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, Vert
"model", None
) # do not pass model in request body to vertex ai
anthropic_messages_request.pop(
"output_format", None
) # do not pass output_format in request body to vertex ai - vertex ai does not support output_format as yet
anthropic_messages_request.pop(
"output_config", None
) # do not pass output_config in request body to vertex ai - vertex ai does not support output_config
# Vertex AI Claude accepts ``output_config.format`` (structured outputs)
# and ``output_format``, but rejects ``output_config.effort`` with 400
# "Extra inputs are not permitted". Sanitize in place so the supported
# bits flow through.
_sanitize_vertex_anthropic_output_params(anthropic_messages_request)
return anthropic_messages_request

View file

@ -11,6 +11,45 @@ from litellm.types.utils import ModelResponse
from ....anthropic.chat.transformation import AnthropicConfig
# Keys inside ``output_config`` that Vertex AI Claude does not accept.
# Today only ``effort`` triggers "Extra inputs are not permitted"; add new
# entries here as Vertex parity drifts. Keep this list narrow — anything
# Vertex DOES accept (e.g. ``format`` for structured outputs) must be
# preserved so callers can rely on Anthropic-native features.
_VERTEX_UNSUPPORTED_OUTPUT_CONFIG_KEYS = frozenset({"effort"})
def _sanitize_vertex_anthropic_output_params(data: dict) -> None:
"""
Strip Vertex-unsupported keys from ``output_config`` / ``output_format``
in-place; forward whatever remains.
Behavior:
* ``output_config`` containing only unsupported keys (e.g. ``effort``
alone) is removed entirely so the request body has no empty dict.
* ``output_config`` containing a mix of supported + unsupported keys has
the unsupported subset filtered out and the rest forwarded.
* ``output_config`` that is supported in full passes through unchanged.
* ``output_format`` is forwarded as-is (Vertex AI Claude accepts it).
* Non-dict values for ``output_config`` are dropped to avoid sending
malformed payloads downstream.
"""
output_config = data.get("output_config")
if output_config is None:
return
if not isinstance(output_config, dict):
data.pop("output_config", None)
return
sanitized = {
k: v
for k, v in output_config.items()
if k not in _VERTEX_UNSUPPORTED_OUTPUT_CONFIG_KEYS
}
if sanitized:
data["output_config"] = sanitized
else:
data.pop("output_config", None)
class VertexAIError(Exception):
def __init__(self, status_code, message):
@ -105,11 +144,12 @@ class VertexAIAnthropicConfig(AnthropicConfig):
data.pop("model", None) # vertex anthropic doesn't accept 'model' parameter
# VertexAI doesn't support output_format parameter, remove it if present
data.pop("output_format", None)
# VertexAI doesn't support output_config parameter, remove it if present
data.pop("output_config", None)
# Vertex AI Claude accepts ``output_config.format`` (structured outputs /
# JSON Schema) but NOT ``output_config.effort`` — sending ``effort`` to
# Vertex returns 400 "Extra inputs are not permitted". Sanitize in place:
# forward the structured-output bits, drop the unsupported keys.
# Same treatment for the legacy top-level ``output_format`` field.
_sanitize_vertex_anthropic_output_params(data)
tools = optional_params.get("tools")
tool_search_used = self.is_tool_search_used(tools)

View file

@ -0,0 +1,167 @@
"""
Regression tests for output_config passthrough through the Anthropic
``/v1/messages`` ``/chat/completions`` adapter.
Background what was broken:
* When a client sent ``output_config`` to ``/v1/messages`` and the request
was routed to a non-Anthropic backend (Azure OpenAI, Fireworks, Bedrock
Nova, etc.), the adapter forwarded the raw Anthropic-shaped ``output_config``
field as-is into the OpenAI-format ``completion_kwargs``. The non-Anthropic
backend then rejected the request with 400 "Extra inputs are not permitted".
* The translator above the re-merge already extracts the meaningful parts of
``output_config`` (``format`` ``response_format``, ``effort``
``reasoning_effort`` for non-Claude targets), so re-adding the raw key was
always either redundant (Anthropic-family) or harmful (non-Anthropic).
Tests cover (consolidating PRs #23706 and #22727):
1. ``output_config`` is excluded from the post-translation re-merge.
2. ``ANTHROPIC_ONLY_REQUEST_KEYS`` constant is exported and contains
``output_config`` so future maintainers know where to extend it.
3. The translator-extracted fields (``response_format`` / ``reasoning_effort``)
are still present after the strip the strip removes only the raw
Anthropic-shaped duplicate.
4. Helper-level coverage for empty ``extra_kwargs`` (PR #22727 Greptile P2 —
the original ``or {}`` pattern silently substituted a default and prevented
the fallback inference path from being exercised).
"""
import os
import sys
from unittest.mock import MagicMock, patch
import pytest
# Anchor sys.path to this file's location — not the working-directory-relative
# pattern Greptile flagged on PR #23706. Resolves correctly regardless of
# where pytest is invoked from.
sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../../.."))
)
from litellm.llms.anthropic.experimental_pass_through.adapters.handler import (
ANTHROPIC_ONLY_REQUEST_KEYS,
LiteLLMMessagesToCompletionTransformationHandler,
)
MESSAGES = [{"role": "user", "content": "hello"}]
def _call_prepare(extra_kwargs, model="gpt-4o", **overrides):
"""
Drive ``_prepare_completion_kwargs`` with the minimum scaffolding needed.
Uses an explicit-None check on ``extra_kwargs`` so callers can test the
falsy-empty-dict path. The fallback ``or {}`` pattern PR #22727 used here
masked the no-extra-kwargs case from ever exercising the test's intent.
"""
return LiteLLMMessagesToCompletionTransformationHandler._prepare_completion_kwargs(
max_tokens=overrides.get("max_tokens", 1024),
messages=overrides.get("messages", MESSAGES),
model=model,
metadata=None,
stop_sequences=None,
stream=False,
system=None,
temperature=None,
thinking=None,
tool_choice=None,
tools=None,
top_k=None,
top_p=None,
output_format=None,
extra_kwargs=extra_kwargs,
)
class TestAnthropicOnlyRequestKeysExport:
"""The exclusion list must be a public, named constant for maintainability —
Greptile P2 on PR #23706: ``excluded_keys`` was silently growing as a
point-fix pattern. A named module-level constant gives reviewers a single
grep target when extending Anthropic-only fields."""
def test_constant_exposed(self):
assert isinstance(ANTHROPIC_ONLY_REQUEST_KEYS, frozenset)
def test_contains_output_config(self):
assert "output_config" in ANTHROPIC_ONLY_REQUEST_KEYS
class TestOutputConfigStrippedFromCompletionKwargs:
"""``output_config`` must not survive the post-translation re-merge into
``completion_kwargs`` regardless of the target provider the translator
has already consumed its meaningful parts."""
def test_output_config_with_effort_is_stripped(self):
extra_kwargs = {
"custom_llm_provider": "azure",
"output_config": {"effort": "high"},
}
result = _call_prepare(extra_kwargs=extra_kwargs)
# Returns (completion_kwargs, original_messages, ...) — first element
# is the dict we care about.
completion_kwargs = result[0] if isinstance(result, tuple) else result
assert "output_config" not in completion_kwargs, (
"Raw output_config must not be forwarded — non-Anthropic backends "
"reject it with 400 'Extra inputs are not permitted'"
)
def test_output_config_with_format_is_stripped_format_already_translated(self):
"""Even when ``output_config`` carries useful structured-output info,
the raw key must be excluded the translator above has already mapped
``output_config.format`` to ``response_format`` (the OpenAI-shaped key
the downstream backend understands)."""
extra_kwargs = {
"custom_llm_provider": "azure",
"output_config": {
"format": {"type": "json_schema", "schema": {"type": "object"}}
},
}
result = _call_prepare(extra_kwargs=extra_kwargs)
completion_kwargs = result[0] if isinstance(result, tuple) else result
assert "output_config" not in completion_kwargs
def test_other_extra_kwargs_still_passed_through(self):
"""Regression guard: the strip must be narrow. Unrelated fields like
``api_key`` / ``timeout`` continue to flow through."""
extra_kwargs = {
"custom_llm_provider": "azure",
"output_config": {"effort": "high"},
"timeout": 30,
"user": "end-user-123",
}
result = _call_prepare(extra_kwargs=extra_kwargs)
completion_kwargs = result[0] if isinstance(result, tuple) else result
assert "output_config" not in completion_kwargs
assert completion_kwargs.get("timeout") == 30
assert completion_kwargs.get("user") == "end-user-123"
class TestEmptyExtraKwargsPath:
"""Greptile P2 on PR #22727: ``extra_kwargs or {default}`` substitutes a
default for an explicitly-passed empty dict, hiding the no-extra-kwargs
path. The new explicit-None pattern lets ``extra_kwargs={}`` reach the
code under test as written."""
def test_explicit_empty_dict_does_not_substitute_default(self):
# Explicit empty dict must be honored — not silently replaced with a
# default that adds back a custom_llm_provider this test wants absent.
result = _call_prepare(extra_kwargs={})
completion_kwargs = result[0] if isinstance(result, tuple) else result
# No output_config because nothing supplied it.
assert "output_config" not in completion_kwargs
def test_none_extra_kwargs_handled_safely(self):
"""The signature documents ``extra_kwargs: Optional[Dict] = None``;
passing None must not crash with KeyError or AttributeError."""
result = _call_prepare(extra_kwargs=None)
# Just exercising the path; assert no exception and we get back a
# dict-like result.
completion_kwargs = result[0] if isinstance(result, tuple) else result
assert isinstance(completion_kwargs, dict)

View file

@ -203,12 +203,15 @@ def test_vertex_ai_anthropic_structured_output_header_not_added():
def test_vertex_ai_claude_sonnet_4_5_structured_output_fix():
"""
Test fix for issue #18625: Claude Sonnet 4.5 on VertexAI should use tool-based
structured outputs instead of output_format parameter.
structured outputs when ``response_format`` is supplied via the OpenAI-compat
interface (``map_openai_params``).
This test verifies that:
1. Claude Sonnet 4.5 uses tool-based structured outputs on VertexAI
2. output_format parameter is removed from the final request
3. The fix prevents "Extra inputs are not permitted" error
1. Claude Sonnet 4.5 uses tool-based structured outputs when ``response_format``
is given to the OpenAI-compat path (the path that triggered #18625).
2. ``output_format`` is forwarded to Vertex AI when present Vertex now
accepts the field; the prior blanket-strip behavior was the silent drop
of Anthropic Structured Outputs that this PR fixes.
"""
config = VertexAIAnthropicConfig()
@ -294,11 +297,15 @@ def test_vertex_ai_claude_sonnet_4_5_structured_output_fix():
headers={},
)
# Verify that output_format was removed (fixes the "Extra inputs are not permitted" error)
# output_format is now forwarded to Vertex (Vertex parity has shifted —
# it accepts the field and uses it to enforce the JSON schema). The
# prior behavior silently stripped it, hiding Structured Outputs from
# callers who explicitly requested them.
assert "output_format" in final_data
assert final_data["output_format"]["type"] == "json_schema"
assert (
"output_format" not in final_data
), "output_format should be removed for VertexAI"
assert "model" not in final_data, "model should be removed for VertexAI"
"model" not in final_data
), "model is still stripped (Vertex routes by URL)"
assert "tools" in final_data, "tools should still be present"
assert "tool_choice" in final_data, "tool_choice should still be present"
@ -491,28 +498,22 @@ def test_vertex_ai_partner_models_anthropic_remove_prompt_caching_scope_beta_hea
), "Header should be removed if no supported values remain"
def test_vertex_ai_anthropic_output_config_dropped():
def test_vertex_ai_anthropic_output_config_effort_only_dropped():
"""
Test that output_config parameter is dropped from Vertex AI Anthropic requests.
Vertex AI does not support the output_config parameter (used for effort settings
in Anthropic API). This test ensures it's properly removed to prevent
"Extra inputs are not permitted" errors.
``output_config`` containing only ``effort`` (an Anthropic-only key Vertex
rejects with "Extra inputs are not permitted") is dropped entirely so the
request body has no empty dict.
"""
config = VertexAIAnthropicConfig()
messages = [{"role": "user", "content": "What is 2+2?"}]
headers = {}
headers: dict = {}
# Simulate optional_params with output_config that would be passed in
optional_params = {
"max_tokens": 1024,
"output_config": {
"effort": "high" # This is Anthropic-specific and not supported by Vertex AI
},
"output_config": {"effort": "high"},
}
# Call transform_request which should drop output_config
result = config.transform_request(
model="claude-3-5-sonnet-20241022",
messages=messages,
@ -521,54 +522,144 @@ def test_vertex_ai_anthropic_output_config_dropped():
headers=headers,
)
# Verify output_config was removed
assert (
"output_config" not in result
), "output_config should be dropped from Vertex AI Anthropic requests"
# Verify other parameters are preserved
assert result["max_tokens"] == 1024, "max_tokens should be preserved"
assert "messages" in result, "messages should be present"
), "output_config containing only effort must be dropped"
assert result["max_tokens"] == 1024
assert "messages" in result
def test_vertex_ai_anthropic_output_format_and_output_config_both_dropped():
def test_vertex_ai_anthropic_output_config_format_passes_through():
"""
Test that both output_format and output_config are dropped from Vertex AI requests.
This ensures that even if both parameters somehow make it to the transform_request,
they are properly cleaned up before sending to Vertex AI.
``output_config`` containing structured-output ``format`` is FORWARDED to
Vertex AI Claude Vertex now accepts it and uses it for JSON Schema
enforcement. Previously the entire field was being silently stripped, so
Anthropic Structured Outputs never engaged on Vertex even when callers
requested it.
"""
config = VertexAIAnthropicConfig()
messages = [{"role": "user", "content": "Return a person object."}]
output_config = {
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
},
}
}
optional_params = {"max_tokens": 1024, "output_config": output_config}
result = config.transform_request(
model="claude-3-5-sonnet-20241022",
messages=messages,
optional_params=optional_params,
litellm_params={},
headers={},
)
assert result["output_config"] == output_config
def test_vertex_ai_anthropic_output_config_format_plus_effort_strips_only_effort():
"""
Greptile P1 on PR #23396: when ``output_config`` contains BOTH ``format``
and ``effort``, the prior conditional-passthrough logic forwarded the
full dict including the unsupported ``effort`` key, reproducing the
400 error the fix was meant to resolve. Only ``effort`` (and any future
Vertex-unsupported keys) should be filtered; ``format`` must survive.
"""
config = VertexAIAnthropicConfig()
messages = [{"role": "user", "content": "Return a person object."}]
output_config = {
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"additionalProperties": False,
"properties": {"name": {"type": "string"}},
},
},
"effort": "high",
}
optional_params = {"max_tokens": 1024, "output_config": output_config}
result = config.transform_request(
model="claude-3-5-sonnet-20241022",
messages=messages,
optional_params=optional_params,
litellm_params={},
headers={},
)
assert "output_config" in result
assert (
"effort" not in result["output_config"]
), "effort must be stripped — Vertex returns 400 on unknown keys"
assert result["output_config"]["format"] == output_config["format"]
def test_vertex_ai_anthropic_output_config_non_dict_dropped():
"""Defensive: if ``output_config`` is somehow not a dict, drop it rather
than forwarding malformed data downstream."""
config = VertexAIAnthropicConfig()
messages = [{"role": "user", "content": "hi"}]
optional_params = {"max_tokens": 64, "output_config": "not-a-dict"}
result = config.transform_request(
model="claude-3-5-sonnet-20241022",
messages=messages,
optional_params=optional_params,
litellm_params={},
headers={},
)
assert "output_config" not in result
def test_vertex_ai_anthropic_output_format_preserved_output_config_effort_dropped():
"""
When the request carries both ``output_format`` (top-level structured
outputs) AND an ``output_config`` whose only useful key for Vertex is
``effort``: ``output_format`` must be forwarded (Vertex accepts it),
while ``output_config`` is dropped because Vertex returns 400 on
``effort``. This replaces the old "drop both" behavior, which was the
silent strip the bug report flagged.
"""
config = VertexAIAnthropicConfig()
messages = [{"role": "user", "content": "Extract structured data"}]
headers = {}
output_format = {
"type": "json_schema",
"json_schema": {
"name": "data",
"schema": {
"type": "object",
"properties": {"result": {"type": "string"}},
},
},
}
optional_params = {
"max_tokens": 2048,
"output_format": {
"type": "json_schema",
"json_schema": {
"name": "data",
"schema": {
"type": "object",
"properties": {"result": {"type": "string"}},
},
},
},
"output_format": output_format,
"output_config": {"effort": "high"},
}
# Simulate parent class creating test_data with both parameters
# (as if the parent transform_request added them)
test_data = {
"model": "claude-3-5-sonnet-20241022",
"messages": messages,
"max_tokens": 2048,
"output_format": optional_params["output_format"],
"output_config": optional_params["output_config"],
"output_format": output_format,
"output_config": {"effort": "high"},
}
# Mock the parent transform_request to return data with both parameters
original_transform = config.__class__.__bases__[0].transform_request
def mock_transform_request(
@ -584,22 +675,50 @@ def test_vertex_ai_anthropic_output_format_and_output_config_both_dropped():
messages=messages,
optional_params=optional_params,
litellm_params={},
headers=headers,
headers={},
)
# Verify both were removed
assert (
"output_format" not in result
), "output_format should be dropped from Vertex AI requests"
assert (
"output_config" not in result
), "output_config should be dropped from Vertex AI requests"
# Verify essential params are preserved
assert result["max_tokens"] == 2048, "max_tokens should be preserved"
assert "messages" in result, "messages should be present"
assert "model" not in result, "model should also be dropped for Vertex AI"
# output_format flows through unchanged — Vertex AI Claude accepts it.
assert result["output_format"] == output_format
# output_config containing only ``effort`` is dropped to avoid the
# 400 "Extra inputs are not permitted" the silent strip used to mask.
assert "output_config" not in result
assert result["max_tokens"] == 2048
assert "model" not in result, "model is still stripped (Vertex routes by URL)"
finally:
# Restore original method
config.__class__.__bases__[0].transform_request = original_transform
def test_sanitize_vertex_anthropic_output_params_unit():
"""Direct unit coverage for the helper itself (used by both Vertex
Anthropic transformation paths). Mirrors the integration assertions
above without going through the full ``transform_request`` stack."""
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.transformation import (
_sanitize_vertex_anthropic_output_params,
)
# No-op when output_config absent.
data: dict = {"max_tokens": 8}
_sanitize_vertex_anthropic_output_params(data)
assert data == {"max_tokens": 8}
# Effort-only → dropped entirely.
data = {"output_config": {"effort": "high"}}
_sanitize_vertex_anthropic_output_params(data)
assert "output_config" not in data
# Format-only → preserved unchanged.
fmt = {"format": {"type": "json_schema", "schema": {"type": "object"}}}
data = {"output_config": dict(fmt)}
_sanitize_vertex_anthropic_output_params(data)
assert data["output_config"] == fmt
# Mixed → effort filtered, format kept.
data = {"output_config": {"format": fmt["format"], "effort": "high"}}
_sanitize_vertex_anthropic_output_params(data)
assert data["output_config"] == fmt
# Non-dict → dropped defensively.
data = {"output_config": "garbage"}
_sanitize_vertex_anthropic_output_params(data)
assert "output_config" not in data