Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@
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()
Expand Down Expand Up @@ -202,8 +212,12 @@ def _prepare_completion_kwargs(
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"]

Expand All @@ -225,8 +239,22 @@ def _prepare_completion_kwargs(
"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 {}
Comment thread
dkindlund marked this conversation as resolved.
Resolved
for key, value in extra_kwargs.items():
if (
key == "litellm_logging_obj"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from litellm.types.router import GenericLiteLLMParams

from ....vertex_llm_base import VertexBase
from ..transformation import _sanitize_vertex_anthropic_output_params
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed


class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, VertexBase):
Expand Down Expand Up @@ -158,12 +159,10 @@
"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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,45 @@

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):
Expand Down Expand Up @@ -105,11 +144,12 @@ def transform_request(

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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
Comment thread
dkindlund marked this conversation as resolved.
Resolved
"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)
Loading
Loading