fix(bedrock): adapt native output backports to release interfaces

This commit is contained in:
Joshua Valluru 2026-10-20 17:41:23 -07:00
parent 3bd1854175
commit e7d432a328
22 changed files with 530 additions and 858 deletions

View file

@ -1,3 +1,5 @@
from pydantic import TypeAdapter
"""
Common utility functions used for translating messages across providers
"""

View file

@ -1382,6 +1382,11 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
return None
return AnthropicConfig.filter_anthropic_output_schema(dict(json_schema))
def uses_native_structured_output(self, model: str) -> bool:
return AnthropicConfig._supports_model_capability(
model, "supports_native_structured_output", self._resolved_provider
)
def uses_native_structured_output_for_schema(
self,
model: str,

View file

@ -52,6 +52,7 @@ from litellm.llms.anthropic.chat.transformation import (
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
from litellm.llms.base_llm.chat.transformation import REQUEST_PARAMS, BaseConfig, BaseLLMException
from litellm.llms.bedrock.common_utils import (
bedrock_capability_model,
bedrock_chat_uses_native_structured_output,
bedrock_cleaned_output_schema,
bedrock_native_output_format,

View file

@ -1,6 +1,8 @@
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Final
import httpx
from pydantic import TypeAdapter
from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
@ -107,18 +109,7 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
model: str,
drop_params: bool,
) -> dict:
# Force tool-based structured outputs for Bedrock Invoke
# (similar to VertexAI fix in #19201) unless the model map advertises
# native structured output
from litellm.utils import supports_native_structured_output
original_model: Final = model
if "response_format" in non_default_params and not supports_native_structured_output(
model=model, custom_llm_provider="bedrock"
):
# Use a model name that forces tool-based approach
model = "claude-3-sonnet-20240229"
# Clamp ``reasoning_effort`` to the Bedrock effort ceiling before the
# parent mapping converts it to ``output_config.effort`` and the
# downstream effort gate runs. Mirrors the converse path's

View file

@ -954,6 +954,12 @@ def is_bedrock_application_inference_profile_arn(model: str) -> bool:
return ":application-inference-profile/" in model
def bedrock_capability_model(model: str, base_model: object) -> str:
if isinstance(base_model, str) and base_model and is_bedrock_application_inference_profile_arn(model):
return strip_bedrock_routing_prefix(base_model)
return model
def strip_bedrock_routing_prefix(model: str) -> str:
"""Strip LiteLLM routing prefixes from model name."""
for prefix in ["bedrock/", "converse/", "invoke/", "openai/", "mantle/", "nova-2/", "nova/"]:

View file

@ -35,8 +35,8 @@ from litellm.llms.bedrock.common_utils import (
bedrock_supports_tool_search,
ensure_bedrock_anthropic_messages_tool_names,
get_anthropic_beta_from_headers,
is_claude_4_5_on_bedrock,
is_bedrock_native_output_rejection,
is_claude_4_5_on_bedrock,
normalize_bedrock_opus_output_config_effort,
normalize_custom_field_on_tools,
normalize_tool_input_schema_types_for_bedrock_invoke,

View file

@ -26,6 +26,7 @@ import httpx
from httpx import USE_CLIENT_DEFAULT
from httpx._types import FileContent
from openai.types.file_deleted import FileDeleted
from pydantic import TypeAdapter
from typing_extensions import ReadOnly
import litellm
@ -2185,7 +2186,9 @@ class BaseLLMHTTPHandler:
native_output_fallback_params: Final = anthropic_messages_provider_config.native_output_fallback_litellm_params(
model=model,
anthropic_messages_optional_request_params=_OPTIONAL_PARAMS.validate_python(anthropic_messages_optional_request_params),
anthropic_messages_optional_request_params=_OPTIONAL_PARAMS.validate_python(
anthropic_messages_optional_request_params
),
litellm_params=litellm_params,
)
rebuild_without_native_output: Final = (
@ -2195,7 +2198,9 @@ class BaseLLMHTTPHandler:
anthropic_messages_provider_config.transform_anthropic_messages_request,
model=model,
messages=copy.deepcopy(_JSON_OBJECTS.validate_python(messages)),
anthropic_messages_optional_request_params=copy.deepcopy(_JSON_OBJECT.validate_python(anthropic_messages_optional_request_params)),
anthropic_messages_optional_request_params=copy.deepcopy(
_JSON_OBJECT.validate_python(anthropic_messages_optional_request_params)
),
litellm_params=native_output_fallback_params,
headers=_JSON_OBJECT.validate_python(headers),
)

View file

@ -24,11 +24,11 @@ import pytest
import yaml
from integration._support.client import Gateway, Scenario, eventually, list_value, object_value, string_value
from integration._support.database import read_rows
from integration._support.ports import allocate_port
from integration._support.process import OwnedProxy, graceful_stop_seconds, owned_proxy_process
from integration._support.upstream import aws_event_stream_frame
from integration._support.vertex import service_account_json
from integration._support.wire import Reply, Request, Wire, wire_server
from integration._support.ports import allocate_port
from pydantic import JsonValue, TypeAdapter
_JSON_TOOL: Final = "json_tool_call"

View file

@ -10,7 +10,9 @@ from pydantic import JsonValue, TypeAdapter
import litellm
from litellm import ModelResponse, Router
from litellm.utils import _invalidate_model_cost_lowercase_map # pyright: ignore[reportPrivateUsage] # required cache invalidation
from litellm.utils import (
_invalidate_model_cost_lowercase_map, # pyright: ignore[reportPrivateUsage] # required cache invalidation
)
_OPUS_4_8: Final = "us.anthropic.claude-opus-4-8"
_JSON_TOOL: Final = "json_tool_call"

View file

@ -239,7 +239,9 @@ def isolate_litellm_globals() -> Iterator[None]:
original_callbacks: Final = {name: list(getattr(litellm, name) or []) for name in CALLBACK_LISTS}
original_reset: Final = {name: getattr(litellm, name) for name in RESET_TO_NONE_GLOBALS}
original_restored: Final = {name: getattr(litellm, name) for name in RESTORED_GLOBALS if hasattr(litellm, name)}
original_clients: Final = {name: litellm.__dict__[name] for name in MODULE_LEVEL_CLIENTS if name in litellm.__dict__}
original_clients: Final = {
name: litellm.__dict__[name] for name in MODULE_LEVEL_CLIENTS if name in litellm.__dict__
}
original_loggers: Final = {
logger: (logger.level, logger.disabled, logger.propagate, list(logger.handlers), list(logger.filters))
for logger in ALL_LOGGERS

View file

@ -1988,23 +1988,66 @@ def _object_with(properties: Mapping[str, object], **defs: Mapping[str, object])
("schema", "unresolvable"),
[
(_object_with({"a": {"type": "string"}}), False),
(_object_with({"a": {"$ref": "#/$defs/A"}, "b": {"$ref": "#/$defs/A"}}, **{"$defs": {"A": {"type": "string"}}}), False),
(_object_with({"a": {"$ref": "#/definitions/A"}}, definitions={"A": {"$ref": "#/definitions/B"}, "B": {"type": "integer"}}), False),
(_object_with({"a": {"$ref": "/$defs/A"}}, **{"$defs": {"A": {"items": {"$ref": "/$defs/B"}}, "B": {"type": "string"}}}), False),
(
_object_with(
{"a": {"$ref": "#/$defs/A"}, "b": {"$ref": "#/$defs/A"}}, **{"$defs": {"A": {"type": "string"}}}
),
False,
),
(
_object_with(
{"a": {"$ref": "#/definitions/A"}},
definitions={"A": {"$ref": "#/definitions/B"}, "B": {"type": "integer"}},
),
False,
),
(
_object_with(
{"a": {"$ref": "/$defs/A"}},
**{"$defs": {"A": {"items": {"$ref": "/$defs/B"}}, "B": {"type": "string"}}},
),
False,
),
(_object_with({"a": {"$ref": "#/$defs/Missing"}}, **{"$defs": {"A": {"type": "string"}}}), True),
(_object_with({"a": {"$ref": "other.json#/A"}}), True),
(_object_with({"parent": {"$ref": "#"}}), True),
(_object_with({"a": {"$ref": "#/$defs/A"}}, **{"$defs": {"A": {"type": "array", "items": {"$ref": "#/$defs/A"}}}}), True),
(_object_with({"a": {"$ref": "/$defs/A"}}, **{"$defs": {"A": {"type": "array", "items": {"$ref": "/$defs/A"}}}}), True),
(
_object_with(
{"a": {"$ref": "#/$defs/A"}}, **{"$defs": {"A": {"type": "array", "items": {"$ref": "#/$defs/A"}}}}
),
True,
),
(
_object_with(
{"a": {"$ref": "/$defs/A"}}, **{"$defs": {"A": {"type": "array", "items": {"$ref": "/$defs/A"}}}}
),
True,
),
(
_object_with(
{"a": {"$ref": "#/$defs/A"}},
**{"$defs": {"A": {"properties": {"b": {"$ref": "#/$defs/B"}}}, "B": {"properties": {"a": {"$ref": "#/$defs/A"}}}}},
**{
"$defs": {
"A": {"properties": {"b": {"$ref": "#/$defs/B"}}},
"B": {"properties": {"a": {"$ref": "#/$defs/A"}}},
}
},
),
True,
),
],
ids=["no-refs", "shared-def", "chained-definitions", "root-path-defs", "missing-def", "external", "root", "self-cycle", "root-path-self-cycle", "mutual-cycle"],
ids=[
"no-refs",
"shared-def",
"chained-definitions",
"root-path-defs",
"missing-def",
"external",
"root",
"self-cycle",
"root-path-self-cycle",
"mutual-cycle",
],
)
def test_schema_has_unresolvable_ref_flags_refs_that_cannot_be_inlined(schema, unresolvable):
assert schema_has_unresolvable_ref(schema) is unresolvable
@ -2013,7 +2056,11 @@ def test_schema_has_unresolvable_ref_flags_refs_that_cannot_be_inlined(schema, u
@pytest.mark.parametrize(
("schema", "properties", "branches"),
[
(_object_with({"a": {"type": "string"}, "b": {"type": "string"}, "c": {"type": "string"}}, required=["a"]), 3, 8),
(
_object_with({"a": {"type": "string"}, "b": {"type": "string"}, "c": {"type": "string"}}, required=["a"]),
3,
8,
),
(
_object_with(
{"x": {"$ref": "#/$defs/Pair"}, "y": {"$ref": "#/$defs/Pair"}},
@ -2030,4 +2077,3 @@ def test_schema_has_unresolvable_ref_flags_refs_that_cannot_be_inlined(schema, u
def test_schema_size_counts_every_written_object_once(schema, properties, branches):
assert schema_property_count(schema) == properties
assert schema_grammar_branch_count(schema) == branches

View file

@ -8,15 +8,19 @@ from unittest.mock import MagicMock, patch
import pytest
import litellm
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.litellm_core_utils.prompt_templates.common_utils import get_completion_messages
from litellm.litellm_core_utils.prompt_templates.factory import (
BEDROCK_DOCUMENT_PLACEHOLDER_TEXT,
THOUGHT_SIGNATURE_SEPARATOR,
BedrockConverseMessagesProcessor,
BedrockImageProcessor,
_bedrock_converse_messages_pt,
_bedrock_tools_pt,
_rename_duplicate_bedrock_document_names,
_convert_to_bedrock_tool_call_invoke,
_convert_to_bedrock_tool_call_result,
_rename_duplicate_bedrock_document_names,
_sanitize_anthropic_tool_use_id,
anthropic_messages_pt,
convert_to_anthropic_tool_result,
convert_to_gemini_tool_call_result,
@ -73,6 +77,9 @@ def test_ollama_pt_consecutive_user_messages():
assert result["prompt"] == expected_prompt
@pytest.mark.asyncio
async def test_anthropic_bedrock_thinking_blocks_with_none_content():
"""
@ -2008,8 +2015,8 @@ def test_bedrock_tools_unpack_defs_no_oom_with_nested_refs():
This test creates a schema with multiple nested $defs that reference each other
to verify the fix prevents memory explosion while still correctly resolving refs.
"""
import sys
import copy
import sys
from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_tools_pt

View file

@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
import httpx
import pytest
import respx
from httpx import Headers
import litellm
from litellm.constants import (
@ -1331,7 +1332,6 @@ def test_dict_fields_take_the_json_tool_on_a_native_structured_output_model(fiel
assert ([tool["name"] for tool in optional_params.get("tools", [])] == ["json_tool_call"]) is not native
@pytest.mark.parametrize(
("model", "thinking_params"),
[
@ -1344,7 +1344,13 @@ def test_dict_fields_take_the_json_tool_on_a_native_structured_output_model(fiel
{"tools": [{"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}]},
),
],
ids=["adaptive-thinking", "reasoning-effort", "reasoning-effort-none", "model-without-forced-tools", "caller-tools"],
ids=[
"adaptive-thinking",
"reasoning-effort",
"reasoning-effort-none",
"model-without-forced-tools",
"caller-tools",
],
)
def test_dict_fields_keep_native_output_when_the_json_tool_would_not_be_forced(model, thinking_params):
optional_params: Final = AnthropicConfig().map_openai_params(
@ -1370,6 +1376,7 @@ def test_dict_fields_keep_native_output_when_the_json_tool_would_not_be_forced(m
assert "output_format" in optional_params
assert "json_tool_call" not in [tool["name"] for tool in optional_params.get("tools", [])]
def test_native_structured_output_uses_bundled_capability_when_remote_map_lags(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@ -6868,6 +6875,9 @@ def test_chat_dummy_tool_result_for_an_orphaned_tool_call_replays_a_byte_identic
assert requests[0]["messages"][2]["content"][0]["type"] == "tool_result"
def test_json_mode_keeps_the_thinking_that_preceded_the_json_tool_call():
thinking_block: Final = {"type": "thinking", "thinking": "The user wants a greeting.", "signature": "sig-1"}
body: Final = _json_mode_message_body("tool_use", None)
@ -6878,3 +6888,27 @@ def test_json_mode_keeps_the_thinking_that_preceded_the_json_tool_call():
assert message.reasoning_content == thinking_block["thinking"]
assert [block["signature"] for block in message.thinking_blocks] == [thinking_block["signature"]]
def _json_mode_message_body(stop_reason: str, stop_details: dict | None) -> dict:
return {
"id": "msg_json_mode",
"type": "message",
"role": "assistant",
"model": "claude-fable-5",
"stop_reason": stop_reason,
"stop_details": stop_details,
"usage": {"input_tokens": 12, "output_tokens": 8},
"content": [
{"type": "tool_use", "id": "toolu_json", "name": RESPONSE_FORMAT_TOOL_NAME, "input": {"answer": "Hello"}}
],
}
def _transform_json_mode_body(body: dict) -> litellm.ModelResponse:
return AnthropicConfig().transform_parsed_response(
completion_response=body,
raw_response=httpx.Response(status_code=200, headers={}),
model_response=litellm.ModelResponse(),
json_mode=True,
prefix_prompt=None,
)

View file

@ -5,6 +5,7 @@ Related to: https://platform.claude.com/docs/en/build-with-claude/structured-out
"""
import pytest
from litellm.llms.anthropic.chat.transformation import AnthropicConfig

View file

@ -1,10 +1,11 @@
import asyncio
import base64
import copy
import importlib
import json
import uuid
from types import SimpleNamespace
from collections.abc import Mapping
from types import SimpleNamespace
from typing import Final
from unittest.mock import patch
@ -674,7 +675,6 @@ def test_shared_defs_reach_native_output_once_instead_of_inlined_per_use(set_nat
assert "tools" not in result
class Relabel(BaseModel):
model_config = ConfigDict(frozen=True)
@ -711,6 +711,7 @@ def test_nested_pydantic_response_format_reaches_native_output_with_refs_that_re
assert item_ref.removeprefix("#/$defs/") in sent["$defs"]
assert "tools" not in result
def test_output_config_format_forwarded_for_bedrock_chat_invoke_request(local_model_cost_map):
"""Bedrock Invoke chat path forwards ``output_config.format`` alongside effort
for models with native structured-output support (Claude Sonnet 4.6)."""
@ -1266,6 +1267,9 @@ def test_chat_flagged_model_replays_a_byte_identical_prefix_around_a_mid_convers
assert [m["role"] for m in requests[2]["messages"]] == ["user", "assistant", "user", "system", "assistant", "user"]
_CITY_SCHEMA: Final = {
"type": "object",
"properties": {"city": {"type": "string"}},
@ -1326,4 +1330,3 @@ def test_invoke_uses_opt_in_native_output_is_false_when_native_output_did_not_co
assert not AmazonAnthropicClaudeConfig().uses_opt_in_native_output(
model, optional_params, {"model_info": model_info}
)

File diff suppressed because it is too large Load diff

View file

@ -34,9 +34,7 @@ class TestBedrockFilesTransformation:
transformation = BedrockJsonlFilesTransformation()
# Load input JSONL file
input_file_path = os.path.join(
os.path.dirname(__file__), "input_batch_completions.jsonl"
)
input_file_path = os.path.join(os.path.dirname(__file__), "input_batch_completions.jsonl")
# Read and parse the JSONL content
openai_jsonl_content = []
@ -124,28 +122,20 @@ class TestBedrockFilesTransformation:
model_input = record["modelInput"]
# Must have inferenceConfig with maxTokens, NOT top-level max_tokens
assert (
"inferenceConfig" in model_input
), "Nova modelInput must contain inferenceConfig"
assert "inferenceConfig" in model_input, "Nova modelInput must contain inferenceConfig"
assert model_input["inferenceConfig"]["maxTokens"] == 50
assert model_input["inferenceConfig"]["temperature"] == 0.7
assert (
"max_tokens" not in model_input
), "max_tokens must NOT be at the top level for Nova"
assert (
"temperature" not in model_input
), "temperature must NOT be at the top level for Nova"
assert "max_tokens" not in model_input, "max_tokens must NOT be at the top level for Nova"
assert "temperature" not in model_input, "temperature must NOT be at the top level for Nova"
# Must have messages
assert "messages" in model_input
# Nova Pro rejects empty additionalModelRequestFields / system — they must be absent
assert (
"additionalModelRequestFields" not in model_input
), "Nova: empty additionalModelRequestFields must be omitted, not serialized as {}"
assert (
"system" not in model_input
), "Nova: empty system must be omitted, not serialized as []"
assert "additionalModelRequestFields" not in model_input, (
"Nova: empty additionalModelRequestFields must be omitted, not serialized as {}"
)
assert "system" not in model_input, "Nova: empty system must be omitted, not serialized as []"
def test_nova_batch_jsonl_omits_empty_converse_fields(self):
"""
@ -218,10 +208,7 @@ class TestBedrockFilesTransformation:
config = BedrockFilesConfig()
# 1x1 transparent PNG
img_b64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
"2mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="
)
img_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="
openai_jsonl_content = [
{
@ -237,9 +224,7 @@ class TestBedrockFilesTransformation:
{"type": "text", "text": "Describe this image."},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64," + img_b64
},
"image_url": {"url": "data:image/png;base64," + img_b64},
},
],
}
@ -277,16 +262,10 @@ class TestBedrockFilesTransformation:
# Verify Converse image format
assert "format" in block["image"], "Image block must have format field"
assert "source" in block["image"], "Image block must have source field"
assert (
"bytes" in block["image"]["source"]
), "Image source must have bytes field"
assert "bytes" in block["image"]["source"], "Image source must have bytes field"
# Must NOT have OpenAI-style image_url
assert (
"image_url" not in block
), "image_url must not appear in Converse format"
assert (
block.get("type") != "image_url"
), "type=image_url must not appear in Converse format"
assert "image_url" not in block, "image_url must not appear in Converse format"
assert block.get("type") != "image_url", "type=image_url must not appear in Converse format"
assert has_text, "Should have a text content block"
assert has_image, "Should have an image content block"
@ -371,9 +350,7 @@ class TestBedrockFilesTransformation:
)
assert "us-gov-west-1" in url, f"Expected us-gov-west-1 in URL but got: {url}"
assert (
"us-west-2" not in url
), f"us-west-2 must not appear when s3_region_name is set, got: {url}"
assert "us-west-2" not in url, f"us-west-2 must not appear when s3_region_name is set, got: {url}"
assert "litellm-batch-352026" in url
def test_get_complete_file_url_sanitizes_untrusted_filename(self):
@ -405,9 +382,7 @@ class TestBedrockFilesTransformation:
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig
config = BedrockFilesConfig()
object_name = config._get_s3_object_name_from_batch_jsonl(
[{"body": {"model": "bedrock/../../secret:model"}}]
)
object_name = config._get_s3_object_name_from_batch_jsonl([{"body": {"model": "bedrock/../../secret:model"}}])
assert object_name.startswith("litellm-bedrock-files-")
assert object_name.endswith(".jsonl")
@ -462,9 +437,9 @@ class TestBedrockFilesTransformation:
litellm_params=litellm_params,
)
assert (
captured_optional_params.get("aws_region_name") == "us-gov-west-1"
), "s3_region_name must be forwarded as aws_region_name for SigV4 signing"
assert captured_optional_params.get("aws_region_name") == "us-gov-west-1", (
"s3_region_name must be forwarded as aws_region_name for SigV4 signing"
)
def test_s3_region_name_wins_over_aws_region_name_for_signing(self):
"""
@ -518,9 +493,9 @@ class TestBedrockFilesTransformation:
litellm_params=litellm_params,
)
assert (
captured_optional_params.get("aws_region_name") == "us-gov-west-1"
), "s3_region_name must override aws_region_name for SigV4 signing"
assert captured_optional_params.get("aws_region_name") == "us-gov-west-1", (
"s3_region_name must override aws_region_name for SigV4 signing"
)
def _signed_upload_request(self, litellm_params: dict) -> dict:
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig
@ -1115,9 +1090,9 @@ class TestBedrockFilesEmbeddingTransformation:
}
]
)
assert result[0]["modelInput"] == {
"inputText": "Hi"
}, f"model id {model_id} did not route to Titan v2 embedding path"
assert result[0]["modelInput"] == {"inputText": "Hi"}, (
f"model id {model_id} did not route to Titan v2 embedding path"
)
def test_pretokenized_input_list_of_ints_raises(self):
"""`input: List[int]` (pre-tokenized) is rejected, not silently mis-shaped."""
@ -1187,7 +1162,6 @@ class TestBedrockFilesEmbeddingTransformation:
assert "messages" in result[0]["modelInput"]
assert "inputText" not in result[0]["modelInput"]
def test_titan_v2_marker_boundary_rejects_lookalikes(self):
"""The marker must end at `:`, `/`, or end-of-string to avoid false positives."""
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig
@ -1198,9 +1172,9 @@ class TestBedrockFilesEmbeddingTransformation:
"bedrock/amazon.titan-embed-text-v2-experimental:0",
"bedrock/amazon.titan-embed-text-v2foo",
):
assert not BedrockFilesConfig._is_titan_v2_embed_model(
model
), f"{model} unexpectedly matched the Titan v2 marker"
assert not BedrockFilesConfig._is_titan_v2_embed_model(model), (
f"{model} unexpectedly matched the Titan v2 marker"
)
# Real Titan v2 ids that MUST match
for model in (
@ -1209,9 +1183,9 @@ class TestBedrockFilesEmbeddingTransformation:
"us.amazon.titan-embed-text-v2:0",
"arn:aws:bedrock:us-east-1:123:foundation-model/amazon.titan-embed-text-v2:0",
):
assert BedrockFilesConfig._is_titan_v2_embed_model(
model
), f"{model} unexpectedly missed the Titan v2 marker"
assert BedrockFilesConfig._is_titan_v2_embed_model(model), (
f"{model} unexpectedly missed the Titan v2 marker"
)
def test_titan_v2_accepted_when_registry_schema_field_matches(self, mocker):
"""Registry-driven happy path: nested
@ -1221,13 +1195,9 @@ class TestBedrockFilesEmbeddingTransformation:
mocker.patch(
"litellm.get_model_info",
return_value={
"provider_specific_entry": {"bedrock_invocation_schema": "titan_v2"}
},
)
assert BedrockFilesConfig._is_titan_v2_embed_model(
"amazon.titan-embed-text-v2:0"
return_value={"provider_specific_entry": {"bedrock_invocation_schema": "titan_v2"}},
)
assert BedrockFilesConfig._is_titan_v2_embed_model("amazon.titan-embed-text-v2:0")
def test_titan_v2_rejected_when_registry_schema_field_differs(self, mocker):
"""Registry resolves with a different schema value (e.g. a hypothetical
@ -1237,19 +1207,13 @@ class TestBedrockFilesEmbeddingTransformation:
mocker.patch(
"litellm.get_model_info",
return_value={
"provider_specific_entry": {"bedrock_invocation_schema": "cohere_v3"}
},
return_value={"provider_specific_entry": {"bedrock_invocation_schema": "cohere_v3"}},
)
# Even though the model id looks like Titan v2, the registry says
# otherwise and we trust it.
assert not BedrockFilesConfig._is_titan_v2_embed_model(
"amazon.titan-embed-text-v2:0"
)
assert not BedrockFilesConfig._is_titan_v2_embed_model("amazon.titan-embed-text-v2:0")
def test_titan_v2_falls_back_to_marker_when_registry_lacks_schema_field(
self, mocker
):
def test_titan_v2_falls_back_to_marker_when_registry_lacks_schema_field(self, mocker):
"""Registry resolves but the entry has no
`provider_specific_entry.bedrock_invocation_schema` field yet (e.g.
a stale local registry) -> fall through to substring."""
@ -1260,9 +1224,7 @@ class TestBedrockFilesEmbeddingTransformation:
"litellm.get_model_info",
return_value={"mode": "embedding"},
)
assert BedrockFilesConfig._is_titan_v2_embed_model(
"amazon.titan-embed-text-v2:0"
)
assert BedrockFilesConfig._is_titan_v2_embed_model("amazon.titan-embed-text-v2:0")
# provider_specific_entry present but missing the schema key
mocker.patch(
@ -1272,9 +1234,7 @@ class TestBedrockFilesEmbeddingTransformation:
"provider_specific_entry": {"unrelated": "value"},
},
)
assert BedrockFilesConfig._is_titan_v2_embed_model(
"amazon.titan-embed-text-v2:0"
)
assert BedrockFilesConfig._is_titan_v2_embed_model("amazon.titan-embed-text-v2:0")
def test_titan_v2_accepted_when_registry_silent(self, mocker):
"""Marker-only match is fine for ids the registry can't resolve
@ -1282,9 +1242,7 @@ class TestBedrockFilesEmbeddingTransformation:
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig
mocker.patch("litellm.get_model_info", side_effect=Exception("not mapped"))
assert BedrockFilesConfig._is_titan_v2_embed_model(
"us.amazon.titan-embed-text-v2:0"
)
assert BedrockFilesConfig._is_titan_v2_embed_model("us.amazon.titan-embed-text-v2:0")
assert BedrockFilesConfig._is_titan_v2_embed_model(
"arn:aws:bedrock:us-east-1:123:foundation-model/amazon.titan-embed-text-v2:0"
)
@ -1296,87 +1254,49 @@ class TestBedrockFilesEmbeddingTransformation:
# Happy path: returns the nested field's string value
mocker.patch(
"litellm.get_model_info",
return_value={
"provider_specific_entry": {"bedrock_invocation_schema": "titan_v2"}
},
)
assert (
BedrockFilesConfig._lookup_provider_specific_field(
"anything", "bedrock_invocation_schema"
)
== "titan_v2"
return_value={"provider_specific_entry": {"bedrock_invocation_schema": "titan_v2"}},
)
assert BedrockFilesConfig._lookup_provider_specific_field("anything", "bedrock_invocation_schema") == "titan_v2"
# Registry raises -> None
mocker.patch("litellm.get_model_info", side_effect=Exception("not mapped"))
assert (
BedrockFilesConfig._lookup_provider_specific_field("anything", "any")
is None
)
assert BedrockFilesConfig._lookup_provider_specific_field("anything", "any") is None
# Registry returns non-dict -> None
mocker.patch("litellm.get_model_info", return_value="not a dict")
assert (
BedrockFilesConfig._lookup_provider_specific_field("anything", "any")
is None
)
assert BedrockFilesConfig._lookup_provider_specific_field("anything", "any") is None
# Registry returns dict without provider_specific_entry -> None
mocker.patch("litellm.get_model_info", return_value={"mode": "embedding"})
assert (
BedrockFilesConfig._lookup_provider_specific_field(
"anything", "bedrock_invocation_schema"
)
is None
)
assert BedrockFilesConfig._lookup_provider_specific_field("anything", "bedrock_invocation_schema") is None
# provider_specific_entry exists but isn't a dict -> None
mocker.patch(
"litellm.get_model_info",
return_value={"provider_specific_entry": "not a dict"},
)
assert (
BedrockFilesConfig._lookup_provider_specific_field(
"anything", "bedrock_invocation_schema"
)
is None
)
assert BedrockFilesConfig._lookup_provider_specific_field("anything", "bedrock_invocation_schema") is None
# provider_specific_entry dict missing the requested field -> None
mocker.patch(
"litellm.get_model_info",
return_value={"provider_specific_entry": {"unrelated": "x"}},
)
assert (
BedrockFilesConfig._lookup_provider_specific_field(
"anything", "bedrock_invocation_schema"
)
is None
)
assert BedrockFilesConfig._lookup_provider_specific_field("anything", "bedrock_invocation_schema") is None
# Non-string nested value -> None
mocker.patch(
"litellm.get_model_info",
return_value={"provider_specific_entry": {"bedrock_invocation_schema": 42}},
)
assert (
BedrockFilesConfig._lookup_provider_specific_field(
"anything", "bedrock_invocation_schema"
)
is None
)
assert BedrockFilesConfig._lookup_provider_specific_field("anything", "bedrock_invocation_schema") is None
# Empty-string nested value -> None
mocker.patch(
"litellm.get_model_info",
return_value={"provider_specific_entry": {"bedrock_invocation_schema": ""}},
)
assert (
BedrockFilesConfig._lookup_provider_specific_field(
"anything", "bedrock_invocation_schema"
)
is None
)
assert BedrockFilesConfig._lookup_provider_specific_field("anything", "bedrock_invocation_schema") is None
def test_classify_batch_record_helper(self):
"""Helper classifies by `url` first, then by body shape."""
@ -1384,28 +1304,18 @@ class TestBedrockFilesEmbeddingTransformation:
from litellm.types.llms.bedrock import BedrockBatchRecordKind
assert (
BedrockFilesConfig._classify_batch_record(
{"url": "/v1/embeddings", "body": {"input": "x"}}
)
BedrockFilesConfig._classify_batch_record({"url": "/v1/embeddings", "body": {"input": "x"}})
is BedrockBatchRecordKind.EMBEDDING
)
# body-only fallback
assert (
BedrockFilesConfig._classify_batch_record({"body": {"input": "x"}})
is BedrockBatchRecordKind.EMBEDDING
)
assert BedrockFilesConfig._classify_batch_record({"body": {"input": "x"}}) is BedrockBatchRecordKind.EMBEDDING
# chat shape
assert (
BedrockFilesConfig._classify_batch_record(
{"url": "/v1/chat/completions", "body": {"messages": []}}
)
BedrockFilesConfig._classify_batch_record({"url": "/v1/chat/completions", "body": {"messages": []}})
is BedrockBatchRecordKind.CHAT
)
# ambiguous body without any recognized key is treated as chat
assert (
BedrockFilesConfig._classify_batch_record({"body": {}})
is BedrockBatchRecordKind.CHAT
)
assert BedrockFilesConfig._classify_batch_record({"body": {}}) is BedrockBatchRecordKind.CHAT
@pytest.mark.parametrize("body", ["not a mapping", ["messages"], 7, None], ids=["str", "list", "int", "missing"])
def test_classify_batch_record_falls_back_to_chat_for_non_mapping_body(self, body):
@ -1445,7 +1355,6 @@ class TestBedrockFilesEmbeddingTransformation:
transformer, corrupting the modelInput.
"""
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig
from litellm.types.llms.bedrock import BedrockBatchRecordKind
# Direct helper assertion
@ -1483,9 +1392,7 @@ class TestBedrockFilesEmbeddingTransformation:
)
model_input = result[0]["modelInput"]
assert (
"inputText" not in model_input
), "explicit chat URL must not produce an embedding-shaped modelInput"
assert "inputText" not in model_input, "explicit chat URL must not produce an embedding-shaped modelInput"
def test_coerce_embedding_input_helper_isolated(self):
"""Direct coverage of the extracted input-normalization helper."""
@ -1495,9 +1402,7 @@ class TestBedrockFilesEmbeddingTransformation:
# Happy paths
assert BedrockFilesConfig._coerce_embedding_input_to_string("hello") == "hello"
assert (
BedrockFilesConfig._coerce_embedding_input_to_string(["hello"]) == "hello"
)
assert BedrockFilesConfig._coerce_embedding_input_to_string(["hello"]) == "hello"
# Error paths
with pytest.raises(ValueError, match="missing required `input`"):
@ -1525,22 +1430,16 @@ class TestBedrockFilesEmbeddingTransformation:
# /v1/completions (legacy completions endpoint)
assert (
BedrockFilesConfig._classify_batch_record(
{"url": "/v1/completions", "body": {"input": "x"}}
)
BedrockFilesConfig._classify_batch_record({"url": "/v1/completions", "body": {"input": "x"}})
is BedrockBatchRecordKind.TEXT_COMPLETION
)
assert (
BedrockFilesConfig._classify_batch_record(
{"url": "/v1/responses", "body": {"input": "x"}}
)
BedrockFilesConfig._classify_batch_record({"url": "/v1/responses", "body": {"input": "x"}})
is BedrockBatchRecordKind.RESPONSES
)
# Arbitrary unknown url - caller's explicit signal still wins
assert (
BedrockFilesConfig._classify_batch_record(
{"url": "/v1/moderations", "body": {"input": "x"}}
)
BedrockFilesConfig._classify_batch_record({"url": "/v1/moderations", "body": {"input": "x"}})
is BedrockBatchRecordKind.CHAT
)
@ -1692,7 +1591,10 @@ class TestBedrockBatchNonChatEndpointRecords:
assert model_input["messages"][0]["content"] == [{"type": "text", "text": "Weather in Paris?"}]
tool = model_input["tools"][0]
function = tool.get("function", tool)
assert (function["name"], function.get("parameters", function.get("input_schema"))) == ("get_weather", parameters)
assert (function["name"], function.get("parameters", function.get("input_schema"))) == (
"get_weather",
parameters,
)
@pytest.mark.parametrize(
("url", "body"),
@ -1850,9 +1752,7 @@ class TestBedrockBatchNonChatEndpointRecords:
"embedding",
]
for record in result[:3]:
assert record["modelInput"]["messages"] == [
{"role": "user", "content": [{"type": "text", "text": "hi"}]}
]
assert record["modelInput"]["messages"] == [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
assert result[3]["modelInput"] == {"inputText": "hi"}
@pytest.mark.parametrize(
@ -1897,9 +1797,7 @@ class TestBedrockBatchNonChatEndpointRecords:
}
)
assert model_input["messages"] == [
{"role": "user", "content": [{"type": "text", "text": "from messages"}]}
]
assert model_input["messages"] == [{"role": "user", "content": [{"type": "text", "text": "from messages"}]}]
class TestBedrockBatchAnthropicRowParams:
@ -1980,7 +1878,10 @@ class TestBedrockBatchAnthropicRowParams:
@pytest.mark.parametrize(
("url", "body"),
[
("/v1/chat/completions", {"messages": [{"role": "user", "content": "17 * 23?"}], "reasoning_effort": "low"}),
(
"/v1/chat/completions",
{"messages": [{"role": "user", "content": "17 * 23?"}], "reasoning_effort": "low"},
),
("/v1/responses", {"input": "17 * 23?", "reasoning": {"effort": "low"}}),
],
ids=["chat", "responses"],
@ -2140,16 +2041,24 @@ class TestBedrockFileDeletion:
with respx.mock, closing(HTTPHandler()) as client:
route: Final = respx.delete(self.URL).mock(return_value=httpx.Response(204))
deleted: Final = litellm.file_delete(
file_id=self.S3_URI, custom_llm_provider="bedrock", client=client,
aws_access_key_id="AKIAEXAMPLE", aws_secret_access_key="test-secret", aws_region_name="us-west-2",
file_id=self.S3_URI,
custom_llm_provider="bedrock",
client=client,
aws_access_key_id="AKIAEXAMPLE",
aws_secret_access_key="test-secret",
aws_region_name="us-west-2",
)
assert route.call_count == 1
request: Final = route.calls[0].request
assert request.content == b""
signed: Final = AWSRequest(method="DELETE", url=self.URL, headers={
"X-Amz-Date": request.headers["X-Amz-Date"],
"X-Amz-Content-SHA256": request.headers["X-Amz-Content-SHA256"],
})
signed: Final = AWSRequest(
method="DELETE",
url=self.URL,
headers={
"X-Amz-Date": request.headers["X-Amz-Date"],
"X-Amz-Content-SHA256": request.headers["X-Amz-Content-SHA256"],
},
)
signed.context["timestamp"] = request.headers["X-Amz-Date"]
auth: Final = S3SigV4Auth(Credentials("AKIAEXAMPLE", "test-secret"), "s3", "us-west-2")
signature: Final = auth.signature(auth.string_to_sign(signed, auth.canonical_request(signed)), signed)
@ -2177,15 +2086,22 @@ class TestBedrockFileDeletion:
with pytest.raises(BedrockError, match="AccessDenied"):
await litellm.afile_delete(
file_id=self.S3_URI, custom_llm_provider="bedrock", client=client,
aws_access_key_id="AKIAEXAMPLE", aws_secret_access_key="test-secret", aws_region_name="us-west-2",
file_id=self.S3_URI,
custom_llm_provider="bedrock",
client=client,
aws_access_key_id="AKIAEXAMPLE",
aws_secret_access_key="test-secret",
aws_region_name="us-west-2",
)
assert route.call_count == 1
@pytest.mark.parametrize("file_id, message", [
("s3://other-bucket/litellm-bedrock-files-model-abc.jsonl", "configured storage bucket"),
("s3://my-bucket/private/data.jsonl", "LiteLLM-managed"),
])
@pytest.mark.parametrize(
"file_id, message",
[
("s3://other-bucket/litellm-bedrock-files-model-abc.jsonl", "configured storage bucket"),
("s3://my-bucket/private/data.jsonl", "LiteLLM-managed"),
],
)
def test_delete_rejects_untrusted_objects_before_signing(
self, file_id: str, message: str, monkeypatch: pytest.MonkeyPatch
) -> None:
@ -2233,11 +2149,7 @@ class TestBedrockFileContentTransformation:
assert params == {}
signed_headers = litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM]
content_hashes = {
value
for name, value in signed_headers.items()
if name.lower() == "x-amz-content-sha256"
}
content_hashes = {value for name, value in signed_headers.items() if name.lower() == "x-amz-content-sha256"}
assert content_hashes == {hashlib.sha256(b"").hexdigest()}, (
"GET has no payload, so the content hash must be the empty-body hash."
" The header name is matched case-insensitively because botocore picks"
@ -2260,9 +2172,7 @@ class TestBedrockFileContentTransformation:
unified_file_id = SpecialEnums.LITELLM_MANAGED_FILE_COMPLETE_STR.value.format(
"application/json", "unified-id", "", self.S3_URI, "model-id"
)
encoded_file_id = (
base64.urlsafe_b64encode(unified_file_id.encode()).decode().rstrip("=")
)
encoded_file_id = base64.urlsafe_b64encode(unified_file_id.encode()).decode().rstrip("=")
url, _ = BedrockFilesConfig().transform_file_content_request(
file_content_request={"file_id": encoded_file_id},
@ -2280,16 +2190,13 @@ class TestBedrockFileContentTransformation:
with pytest.raises(BedrockError, match="configured storage bucket") as rejection:
BedrockFilesConfig().transform_file_content_request(
file_content_request={
"file_id": "s3://other-bucket/litellm-batch-outputs/job/x.jsonl.out"
},
file_content_request={"file_id": "s3://other-bucket/litellm-batch-outputs/job/x.jsonl.out"},
optional_params={},
litellm_params=self._litellm_params(),
)
assert rejection.value.status_code == 400
def test_transform_file_content_request_rejects_unmanaged_key(self, monkeypatch):
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig
@ -2305,7 +2212,6 @@ class TestBedrockFileContentTransformation:
assert rejection.value.status_code == 400
def test_extract_s3_uri_rejects_non_managed_file_id(self):
"""A file id that is neither an s3:// URI nor a unified id must be rejected."""
from litellm.llms.bedrock.files.transformation import (
@ -2315,9 +2221,7 @@ class TestBedrockFileContentTransformation:
with pytest.raises(ValueError, match="managed LiteLLM S3 file id"):
extract_s3_uri_from_file_id("file-1234567890")
def test_transform_file_content_request_requires_configured_bucket(
self, monkeypatch
):
def test_transform_file_content_request_requires_configured_bucket(self, monkeypatch):
"""Without a server-configured bucket (env or snapshot), the request must fail
before any S3 call rather than guessing a bucket from the file id."""
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig
@ -2354,9 +2258,7 @@ class TestBedrockFileContentTransformation:
from litellm.types.router import CredentialLiteLLMParams
snapshot = CredentialLiteLLMParams(**deployment_litellm_params).model_dump(
exclude_none=True
)
snapshot = CredentialLiteLLMParams(**deployment_litellm_params).model_dump(exclude_none=True)
params = self._litellm_params()
params["_litellm_internal_model_credentials"] = MappingProxyType(snapshot)
return params
@ -2373,19 +2275,12 @@ class TestBedrockFileContentTransformation:
monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False)
url, _ = BedrockFilesConfig().transform_file_content_request(
file_content_request={
"file_id": "s3://out-bucket/litellm-batch-outputs/job/in.jsonl.out"
},
file_content_request={"file_id": "s3://out-bucket/litellm-batch-outputs/job/in.jsonl.out"},
optional_params={},
litellm_params=self._trusted(
s3_bucket_name="in-bucket", s3_output_bucket_name="out-bucket"
),
litellm_params=self._trusted(s3_bucket_name="in-bucket", s3_output_bucket_name="out-bucket"),
)
assert (
url
== "https://s3.us-west-2.amazonaws.com/out-bucket/litellm-batch-outputs/job/in.jsonl.out"
)
assert url == "https://s3.us-west-2.amazonaws.com/out-bucket/litellm-batch-outputs/job/in.jsonl.out"
def test_output_bucket_falls_back_to_env(self, monkeypatch):
"""The output bucket resolves from AWS_S3_OUTPUT_BUCKET_NAME when not in
@ -2396,17 +2291,12 @@ class TestBedrockFileContentTransformation:
monkeypatch.setenv("AWS_S3_OUTPUT_BUCKET_NAME", "env-out-bucket")
url, _ = BedrockFilesConfig().transform_file_content_request(
file_content_request={
"file_id": "s3://env-out-bucket/litellm-batch-outputs/job/in.jsonl.out"
},
file_content_request={"file_id": "s3://env-out-bucket/litellm-batch-outputs/job/in.jsonl.out"},
optional_params={},
litellm_params=self._litellm_params(),
)
assert (
url
== "https://s3.us-west-2.amazonaws.com/env-out-bucket/litellm-batch-outputs/job/in.jsonl.out"
)
assert url == "https://s3.us-west-2.amazonaws.com/env-out-bucket/litellm-batch-outputs/job/in.jsonl.out"
def test_input_bucket_still_validates_when_output_bucket_set(self, monkeypatch):
"""Adding output-bucket support must not break retrieval of input-bucket
@ -2417,19 +2307,12 @@ class TestBedrockFileContentTransformation:
monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False)
url, _ = BedrockFilesConfig().transform_file_content_request(
file_content_request={
"file_id": "s3://in-bucket/litellm-batch-outputs/job/in.jsonl.out"
},
file_content_request={"file_id": "s3://in-bucket/litellm-batch-outputs/job/in.jsonl.out"},
optional_params={},
litellm_params=self._trusted(
s3_bucket_name="in-bucket", s3_output_bucket_name="out-bucket"
),
litellm_params=self._trusted(s3_bucket_name="in-bucket", s3_output_bucket_name="out-bucket"),
)
assert (
url
== "https://s3.us-west-2.amazonaws.com/in-bucket/litellm-batch-outputs/job/in.jsonl.out"
)
assert url == "https://s3.us-west-2.amazonaws.com/in-bucket/litellm-batch-outputs/job/in.jsonl.out"
def test_rejects_bucket_outside_input_and_output(self, monkeypatch):
"""A file id whose bucket is neither the input nor the output bucket is
@ -2442,18 +2325,13 @@ class TestBedrockFileContentTransformation:
with pytest.raises(BedrockError, match="configured storage bucket") as rejection:
BedrockFilesConfig().transform_file_content_request(
file_content_request={
"file_id": "s3://other-bucket/litellm-batch-outputs/job/x.jsonl.out"
},
file_content_request={"file_id": "s3://other-bucket/litellm-batch-outputs/job/x.jsonl.out"},
optional_params={},
litellm_params=self._trusted(
s3_bucket_name="in-bucket", s3_output_bucket_name="out-bucket"
),
litellm_params=self._trusted(s3_bucket_name="in-bucket", s3_output_bucket_name="out-bucket"),
)
assert rejection.value.status_code == 400
def test_sign_request_without_botocore_raises_helpful_error(self, monkeypatch):
"""A missing botocore must surface an actionable 'install boto3' error
rather than a raw import failure."""
@ -2479,9 +2357,7 @@ class TestBedrockFileContentTransformation:
monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False)
litellm_params = self._litellm_params()
litellm_params["_litellm_internal_model_credentials"] = MappingProxyType(
{"s3_bucket_name": "my-bucket"}
)
litellm_params["_litellm_internal_model_credentials"] = MappingProxyType({"s3_bucket_name": "my-bucket"})
url, _ = BedrockFilesConfig().transform_file_content_request(
file_content_request={"file_id": self.S3_URI},
@ -2521,9 +2397,10 @@ class TestBedrockFileContentTransformation:
s3_endpoint_url="https://bucket.vpce-abc.s3.us-east-1.vpce.amazonaws.com",
)
assert BedrockFilesConfig()._s3_request_target(
optional_params={}, litellm_params=lp
).endpoint_url == "https://bucket.vpce-abc.s3.us-east-1.vpce.amazonaws.com"
assert (
BedrockFilesConfig()._s3_request_target(optional_params={}, litellm_params=lp).endpoint_url
== "https://bucket.vpce-abc.s3.us-east-1.vpce.amazonaws.com"
)
def test_validate_environment_merges_and_pops_signed_get_headers(self):
from litellm.llms.bedrock.files.transformation import (
@ -2531,9 +2408,7 @@ class TestBedrockFileContentTransformation:
BedrockFilesConfig,
)
litellm_params = {
S3_SIGNED_REQUEST_HEADERS_PARAM: {"Authorization": "AWS4-HMAC-SHA256 test"}
}
litellm_params = {S3_SIGNED_REQUEST_HEADERS_PARAM: {"Authorization": "AWS4-HMAC-SHA256 test"}}
headers = BedrockFilesConfig().validate_environment(
headers={"x-custom": "kept"},
@ -2599,9 +2474,7 @@ class TestBedrockFileContentTransformation:
monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket")
with respx.mock:
route = respx.get(self.EXPECTED_URL).mock(
return_value=httpx.Response(200, content=b'{"recordId": "x"}')
)
route = respx.get(self.EXPECTED_URL).mock(return_value=httpx.Response(200, content=b'{"recordId": "x"}'))
response = litellm.file_content(
file_id=self.S3_URI,
@ -2629,9 +2502,7 @@ class TestBedrockFileContentTransformation:
litellm.in_memory_llm_clients_cache.flush_cache()
with respx.mock:
route = respx.get(self.EXPECTED_URL).mock(
return_value=httpx.Response(200, content=b'{"recordId": "x"}')
)
route = respx.get(self.EXPECTED_URL).mock(return_value=httpx.Response(200, content=b'{"recordId": "x"}'))
response = await litellm.afile_content(
file_id=self.S3_URI,
@ -2640,11 +2511,7 @@ class TestBedrockFileContentTransformation:
)
assert route.called
assert (
route.calls[0]
.request.headers["Authorization"]
.startswith("AWS4-HMAC-SHA256")
)
assert route.calls[0].request.headers["Authorization"].startswith("AWS4-HMAC-SHA256")
assert response.content == b'{"recordId": "x"}'
@ -2678,9 +2545,7 @@ class TestBedrockFilesS3SignatureEncoding:
headers: Mapping[str, str],
) -> str:
sent = {name.lower(): value for name, value in headers.items()}
signed_names = (
sent["authorization"].split("SignedHeaders=")[1].split(",")[0].split(";")
)
signed_names = sent["authorization"].split("SignedHeaders=")[1].split(",")[0].split(";")
request = AWSRequest(
method=method,
url=url,
@ -2688,12 +2553,8 @@ class TestBedrockFilesS3SignatureEncoding:
headers={name: sent[name] for name in signed_names if name in sent},
)
request.context["timestamp"] = sent["x-amz-date"]
signer = signer_cls(
Credentials(self.ACCESS_KEY, self.SECRET_KEY), "s3", self.REGION
)
return signer.signature(
signer.string_to_sign(request, signer.canonical_request(request)), request
)
signer = signer_cls(Credentials(self.ACCESS_KEY, self.SECRET_KEY), "s3", self.REGION)
return signer.signature(signer.string_to_sign(request, signer.canonical_request(request)), request)
def _assert_signed_the_way_s3_reads_it(
self,
@ -2704,12 +2565,8 @@ class TestBedrockFilesS3SignatureEncoding:
) -> None:
assert "%20" in url, "the object key must reach the wire percent-encoded"
sent_signature = headers["Authorization"].split("Signature=")[1].strip()
assert sent_signature == self._signature_under(
S3SigV4Auth, method, url, body, headers
)
assert sent_signature != self._signature_under(
SigV4Auth, method, url, body, headers
)
assert sent_signature == self._signature_under(S3SigV4Auth, method, url, body, headers)
assert sent_signature != self._signature_under(SigV4Auth, method, url, body, headers)
def test_create_file_signs_spaced_object_key_the_way_s3_does(self) -> None:
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig
@ -2743,9 +2600,7 @@ class TestBedrockFilesS3SignatureEncoding:
headers=signed["headers"],
)
def test_file_content_signs_spaced_object_key_the_way_s3_does(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
def test_file_content_signs_spaced_object_key_the_way_s3_does(self, monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.llms.bedrock.files.transformation import (
S3_SIGNED_REQUEST_HEADERS_PARAM,
BedrockFilesConfig,
@ -2759,9 +2614,7 @@ class TestBedrockFilesS3SignatureEncoding:
}
url, _ = BedrockFilesConfig().transform_file_content_request(
file_content_request={
"file_id": "s3://my-bucket/LLM AI Projects/litellm-bedrock-files-model-abc.jsonl"
},
file_content_request={"file_id": "s3://my-bucket/LLM AI Projects/litellm-bedrock-files-model-abc.jsonl"},
optional_params={},
litellm_params=litellm_params,
)
@ -3092,7 +2945,6 @@ class TestBedrockFileDeletionTransformation:
assert rejection.value.status_code == 400
def test_transform_delete_file_request_rejects_unmanaged_key(self, monkeypatch):
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig
@ -3108,7 +2960,6 @@ class TestBedrockFileDeletionTransformation:
assert rejection.value.status_code == 400
def test_transform_delete_file_response_echoes_the_deleted_id(self):
import httpx

View file

@ -15,6 +15,7 @@ from unittest.mock import Mock
import httpx
import pytest
from pydantic import TypeAdapter
# Ensure the project root is on the import path so `litellm` can be imported when
# tests are executed from any working directory.
@ -25,6 +26,7 @@ from litellm.llms.bedrock.common_utils import (
normalize_custom_field_on_tools,
normalize_tool_input_schema_types_for_bedrock_invoke,
)
import litellm
from litellm.constants import (
BEDROCK_MIN_THINKING_BUDGET_TOKENS,
DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET,
@ -3781,3 +3783,5 @@ async def test_bedrock_messages_400_is_not_retried_unless_it_rejects_opt_in_nati
assert isinstance(raised, litellm.BadRequestError)
assert len(bedrock.bodies) == 1
REASONING_BODY_ADAPTER: Final = TypeAdapter(dict[str, object])

View file

@ -1,9 +1,15 @@
import asyncio, importlib, litellm, litellm.litellm_core_utils.get_model_cost_map as bedrock_govcloud_model_cost_map, pytest
import asyncio
import importlib
from collections.abc import Iterator
from typing import Final
from unittest.mock import Mock, patch
import pytest
from litellm.llms.bedrock.common_utils import(
import litellm
import litellm.litellm_core_utils.get_model_cost_map as bedrock_govcloud_model_cost_map
from litellm import completion
from litellm.llms.bedrock.common_utils import (
AmazonBedrockGlobalConfig,
BedrockModelInfo,
bedrock_native_output_is_opt_in,
@ -16,11 +22,8 @@ from litellm.llms.bedrock.common_utils import(
strip_bedrock_throughput_suffix,
without_bedrock_native_output_opt_in,
)
from collections.abc import Iterator
from litellm import completion
from litellm.llms.bedrock.count_tokens.bedrock_token_counter import BedrockTokenCounter
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
from unittest.mock import Mock, patch
# --------------------------------------------------------------------------- #
# get_bedrock_response_stream_shape lazy-load tests #
@ -48,9 +51,7 @@ def test_bedrock_response_stream_shape_lazy_loads_once():
import litellm.llms.bedrock.common_utils as mod
sentinel = MagicMock()
with patch.object(
mod, "_load_bedrock_response_stream_shape", return_value=sentinel
) as mock_load:
with patch.object(mod, "_load_bedrock_response_stream_shape", return_value=sentinel) as mock_load:
assert mod.get_bedrock_response_stream_shape() is sentinel
assert mod.get_bedrock_response_stream_shape() is sentinel
mock_load.assert_called_once()
@ -97,9 +98,7 @@ def test_bedrock_response_stream_shape_is_structure_shape():
from litellm.llms.bedrock.common_utils import get_bedrock_response_stream_shape
loaded_shape = get_bedrock_response_stream_shape()
assert (
loaded_shape is not None
), "get_bedrock_response_stream_shape() is None — botocore may not be installed"
assert loaded_shape is not None, "get_bedrock_response_stream_shape() is None — botocore may not be installed"
shape: StructureShape = loaded_shape
assert isinstance(shape, StructureShape)
assert shape.name == "ResponseStream"
@ -164,9 +163,7 @@ def test_deepseek_cris():
Test that DeepSeek models with cross-region inference prefix use converse route
"""
bedrock_model_info = BedrockModelInfo
bedrock_route = bedrock_model_info.get_bedrock_route(
model="bedrock/us.deepseek.r1-v1:0"
)
bedrock_route = bedrock_model_info.get_bedrock_route(model="bedrock/us.deepseek.r1-v1:0")
assert bedrock_route == "converse"
@ -239,27 +236,19 @@ def test_govcloud_cross_region_inference_prefix():
bedrock_model_info = BedrockModelInfo
# Test us-gov prefix is stripped correctly for Claude models
base_model = bedrock_model_info.get_base_model(
model="bedrock/us-gov.anthropic.claude-haiku-4-5-20251001-v1:0"
)
base_model = bedrock_model_info.get_base_model(model="bedrock/us-gov.anthropic.claude-haiku-4-5-20251001-v1:0")
assert base_model == "anthropic.claude-haiku-4-5-20251001-v1:0"
# Test us-gov prefix is stripped correctly for different Claude versions
base_model = bedrock_model_info.get_base_model(
model="bedrock/us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0"
)
base_model = bedrock_model_info.get_base_model(model="bedrock/us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0")
assert base_model == "anthropic.claude-sonnet-4-5-20250929-v1:0"
# Test us-gov prefix is stripped correctly for Haiku models
base_model = bedrock_model_info.get_base_model(
model="bedrock/us-gov.anthropic.claude-3-haiku-20240307-v1:0"
)
base_model = bedrock_model_info.get_base_model(model="bedrock/us-gov.anthropic.claude-3-haiku-20240307-v1:0")
assert base_model == "anthropic.claude-3-haiku-20240307-v1:0"
# Test us-gov prefix is stripped correctly for Meta models
base_model = bedrock_model_info.get_base_model(
model="bedrock/us-gov.meta.llama3-8b-instruct-v1:0"
)
base_model = bedrock_model_info.get_base_model(model="bedrock/us-gov.meta.llama3-8b-instruct-v1:0")
assert base_model == "meta.llama3-8b-instruct-v1:0"
@ -273,23 +262,14 @@ def test_context_window_suffix_stripped_for_cost_lookup():
"""
from litellm.llms.bedrock.common_utils import get_bedrock_base_model
assert (
get_bedrock_base_model("us.anthropic.claude-opus-4-6-v1[1m]")
== "anthropic.claude-opus-4-6-v1"
)
assert (
get_bedrock_base_model("us.anthropic.claude-sonnet-4-6[1m]")
== "anthropic.claude-sonnet-4-6"
)
assert get_bedrock_base_model("us.anthropic.claude-opus-4-6-v1[1m]") == "anthropic.claude-opus-4-6-v1"
assert get_bedrock_base_model("us.anthropic.claude-sonnet-4-6[1m]") == "anthropic.claude-sonnet-4-6"
assert (
get_bedrock_base_model("global.anthropic.claude-opus-4-5-20251101-v1:0[1m]")
== "anthropic.claude-opus-4-5-20251101-v1:0"
)
# Ensure models without suffix are unaffected
assert (
get_bedrock_base_model("us.anthropic.claude-opus-4-6-v1")
== "anthropic.claude-opus-4-6-v1"
)
assert get_bedrock_base_model("us.anthropic.claude-opus-4-6-v1") == "anthropic.claude-opus-4-6-v1"
# Ensure :51k throughput suffix still works
assert (
get_bedrock_base_model("anthropic.claude-3-5-sonnet-20241022-v2:0:51k")
@ -339,9 +319,7 @@ def test_output_config_effort_normalization_uses_model_info_ceiling(monkeypatch)
("us.anthropic.claude-opus-4-7", "xhigh"),
],
)
def test_bundled_bedrock_opus_model_info_declares_output_config_effort_ceiling(
model, expected_ceiling
):
def test_bundled_bedrock_opus_model_info_declares_output_config_effort_ceiling(model, expected_ceiling):
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
model_info = GetModelCostMap.load_local_model_cost_map()[model]
@ -360,54 +338,24 @@ def test_route_prefix_matched_as_path_segment_not_substring():
or a ``/`` boundary.
"""
# The bedrock_mantle/ provider prefix must NOT be read as the mantle/ route.
assert (
BedrockModelInfo.get_bedrock_route("bedrock_mantle/openai.gpt-5.5") != "mantle"
)
assert (
BedrockModelInfo.get_bedrock_route("bedrock_mantle/openai.gpt-5.4") == "converse"
)
assert (
BedrockModelInfo._explicit_mantle_route("bedrock_mantle/openai.gpt-5.5")
is False
)
assert BedrockModelInfo.get_bedrock_route("bedrock_mantle/openai.gpt-5.5") != "mantle"
assert BedrockModelInfo.get_bedrock_route("bedrock_mantle/openai.gpt-5.4") == "converse"
assert BedrockModelInfo._explicit_mantle_route("bedrock_mantle/openai.gpt-5.5") is False
# A genuine mantle route still resolves, via the startswith branch...
assert (
BedrockModelInfo.get_bedrock_route("mantle/anthropic.claude-mythos-preview")
== "mantle"
)
assert BedrockModelInfo.get_bedrock_route("mantle/anthropic.claude-mythos-preview") == "mantle"
# ...and via the mid-path "/mantle/" branch (after the bedrock/ provider prefix).
assert (
BedrockModelInfo.get_bedrock_route(
"bedrock/mantle/anthropic.claude-mythos-preview"
)
== "mantle"
)
assert BedrockModelInfo.get_bedrock_route("bedrock/mantle/anthropic.claude-mythos-preview") == "mantle"
def test_model_has_route_prefix_exercises_both_branches():
"""``_model_has_route_prefix`` matches on ``startswith`` or a ``/`` boundary only."""
# startswith branch
assert (
BedrockModelInfo._model_has_route_prefix(
"mantle/anthropic.claude-mythos-preview", "mantle/"
)
is True
)
assert BedrockModelInfo._model_has_route_prefix("mantle/anthropic.claude-mythos-preview", "mantle/") is True
# f"/{prefix}" boundary branch
assert (
BedrockModelInfo._model_has_route_prefix(
"bedrock/mantle/anthropic.claude-mythos-preview", "mantle/"
)
is True
)
assert BedrockModelInfo._model_has_route_prefix("bedrock/mantle/anthropic.claude-mythos-preview", "mantle/") is True
# neither branch: the token only appears glued to another segment
assert (
BedrockModelInfo._model_has_route_prefix(
"bedrock_mantle/openai.gpt-5.5", "mantle/"
)
is False
)
assert BedrockModelInfo._model_has_route_prefix("bedrock_mantle/openai.gpt-5.5", "mantle/") is False
@pytest.mark.parametrize(
@ -457,16 +405,10 @@ def test_explicit_invoke_route_does_not_match_async_invoke():
"""
async_invoke_model = "async_invoke/twelvelabs.marengo-embed-2-7-v1:0"
assert BedrockModelInfo._explicit_invoke_route(async_invoke_model) is False
assert (
BedrockModelInfo._explicit_invoke_route(f"bedrock/{async_invoke_model}")
is False
)
assert BedrockModelInfo._explicit_invoke_route(f"bedrock/{async_invoke_model}") is False
# ...while async_invoke/ is still detected as its own route.
assert BedrockModelInfo._explicit_async_invoke_route(async_invoke_model) is True
assert (
BedrockModelInfo._explicit_async_invoke_route(f"bedrock/{async_invoke_model}")
is True
)
assert BedrockModelInfo._explicit_async_invoke_route(f"bedrock/{async_invoke_model}") is True
def test_capability_lookups_fall_back_to_base_model_when_regional_entry_lacks_field(monkeypatch):
@ -1010,7 +952,9 @@ def test_unmapped_openai_family_model_routes_to_converse():
_FORMAT_REJECTION: Final = "The model returned the following errors: output_config.format: Extra inputs are not permitted"
_COMPILE_REJECTION: Final = "The model returned the following errors: compilation exceeded limits: Schema is too complex."
_COMPILE_REJECTION: Final = (
"The model returned the following errors: compilation exceeded limits: Schema is too complex."
)
_SCHEMA_PATTERN_REJECTION: Final = (
@ -1076,4 +1020,3 @@ def test_without_bedrock_native_output_opt_in_turns_the_opt_in_off_and_keeps_the
assert turned_off["api_base"] == "https://bedrock.example"
assert deployment_model_info(turned_off)["id"] == "deployment-1"
assert litellm_params["model_info"] == {"id": "deployment-1"}

View file

@ -2,11 +2,14 @@ import asyncio
import base64
import contextlib
import contextvars
import copy
import importlib
import io
import json
import logging
import os
import queue
import re
import threading
from collections.abc import Callable, Iterator, Mapping
from concurrent.futures import Future, ThreadPoolExecutor
@ -36,6 +39,7 @@ from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.get_litellm_params import get_litellm_params
from litellm.litellm_core_utils.thread_pool_executor import executor as logging_executor
from litellm.llms.base_llm.audio_transcription.transformation import BaseAudioTranscriptionConfig
from litellm.llms.base_llm.base_model_iterator import MockResponseIterator
from litellm.proxy.utils import is_valid_api_key
from litellm.types.integrations.custom_logger import HEADROOM_CONVERTED_STREAM_KEY
@ -70,12 +74,19 @@ from litellm.utils import (
_is_streaming_request,
_run_success_deployment_hook_on_converted_chat_stream,
_snapshot_exception_for_hook,
add_custom_logger_callback_to_specific_event,
async_post_call_failure_deployment_hook,
async_post_call_success_deployment_hook,
calculate_max_parallel_requests,
check_valid_key,
client,
filter_out_litellm_params,
get_applied_guardrails,
get_llm_provider,
get_non_default_completion_params,
get_optional_params_embeddings,
get_optional_params_image_gen,
get_potential_model_names,
get_prompt_cache_min_tokens,
is_cached_message,
is_prompt_caching_valid_prompt,
@ -3135,9 +3146,7 @@ class TestExtraBodyCannotOverrideModel:
}
]
untouched = litellm.get_optional_params(
model="my-vllm-model", custom_llm_provider="hosted_vllm", tools=tools()
)
untouched = litellm.get_optional_params(model="my-vllm-model", custom_llm_provider="hosted_vllm", tools=tools())
assert untouched["tools"][0]["function"]["custom_marker"] == "LEAK", untouched
result = litellm.get_optional_params(
@ -4106,6 +4115,9 @@ def test_is_prompt_caching_valid_prompt_explicit_min_token_count_overrides_model
)
def test_custom_logger_guards_ignore_subclass_instances(monkeypatch: pytest.MonkeyPatch) -> None:
"""Regression LIT-4392: the success/failure existence guards used isinstance, so a user
subclass of a built-in logger already promoted into the callback lists made the guard
@ -4517,7 +4529,13 @@ _SUCCESS_RESPONSES_BY_CALL_TYPE: Final = (
pytest.param(EmbeddingResponse(model="text-embedding-3-small"), CallTypes.aembedding, id="embedding"),
pytest.param(
ResponsesAPIResponse(
id="resp_abc", created_at=1, output=[], parallel_tool_calls=False, tool_choice="auto", tools=[], model="gpt-5.6"
id="resp_abc",
created_at=1,
output=[],
parallel_tool_calls=False,
tool_choice="auto",
tools=[],
model="gpt-5.6",
),
CallTypes.aresponses,
id="responses",
@ -4545,7 +4563,9 @@ async def test_success_deployment_hook_raising_keeps_response_and_runs_later_hoo
assert result is response
assert second_hook.seen_responses == (response,)
failure_logs: Final = tuple(r for r in caplog.records if "async_post_call_success_deployment_hook error" in r.message)
failure_logs: Final = tuple(
r for r in caplog.records if "async_post_call_success_deployment_hook error" in r.message
)
assert len(failure_logs) == 1
assert "_ChatShapedSuccessDeploymentHook" in failure_logs[0].message
assert str(call_type) in failure_logs[0].message
@ -4669,6 +4689,9 @@ async def test_wrapper_async_logs_converted_responses_stream_with_standard_loggi
assert success_kwargs["stream"] is True
@pytest.mark.asyncio
async def test_wrapper_async_replays_cached_converted_chat_stream_as_stream(
monkeypatch: pytest.MonkeyPatch,

View file

@ -1,8 +1,10 @@
import json
import os
import re
from collections.abc import Collection, Iterator, Mapping
from pathlib import Path
from typing import Final, Literal, cast
from unittest.mock import MagicMock, patch
import httpx
import pytest
@ -13,8 +15,6 @@ from litellm.llms.bedrock.common_utils import BedrockModelInfo
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.types.utils import ModelInfoBase
from litellm.utils import _invalidate_model_cost_lowercase_map
import os
from unittest.mock import MagicMock, patch
@pytest.fixture(autouse=True)
@ -169,9 +169,7 @@ def test_model_info_bedrock_converse(monkeypatch):
except FileNotFoundError:
pytest.skip("whitelisted_bedrock_models.txt not found")
_enforce_bedrock_converse_models(
model_cost=litellm.model_cost, whitelist_models=whitelist_models
)
_enforce_bedrock_converse_models(model_cost=litellm.model_cost, whitelist_models=whitelist_models)
@pytest.mark.flaky(retries=6, delay=2)
@ -195,9 +193,7 @@ def test_model_info_bedrock_converse_enforcement(monkeypatch):
# Check for unwhitelisted models
with pytest.raises(AssertionError, match=r"fake\.bedrock-chat-model"):
_enforce_bedrock_converse_models(
model_cost=litellm.model_cost, whitelist_models=whitelist_models
)
_enforce_bedrock_converse_models(model_cost=litellm.model_cost, whitelist_models=whitelist_models)
except FileNotFoundError as e:
pytest.skip("whitelisted_bedrock_models.txt not found")
@ -256,11 +252,7 @@ def test_get_model_info_bedrock_models():
k = k.replace(f"{commitment}/", "")
base_model = BedrockModelInfo.get_base_model(k)
# get_base_model() returns model id without "bedrock/" prefix; cost map keys use "bedrock/<model>"
base_model_key = (
base_model
if base_model in litellm.model_cost
else f"bedrock/{base_model}"
)
base_model_key = base_model if base_model in litellm.model_cost else f"bedrock/{base_model}"
if base_model_key not in litellm.model_cost:
continue
base_model_info = litellm.model_cost[base_model_key]
@ -268,15 +260,13 @@ def test_get_model_info_bedrock_models():
if "invoke/" in k:
continue
if base_model_key.startswith("supports_"):
assert (
base_model_key in v
), f"{base_model_key} is not in model cost map for {k}"
assert base_model_key in v, f"{base_model_key} is not in model cost map for {k}"
if _govcloud_may_lag(k, base_model_key):
assert base_model_value or not v[base_model_key], f"{base_model_key} is on {k} but not its base"
continue
assert (
v[base_model_key] == base_model_value
), f"{base_model_key} is not equal to {base_model_value} for model {k}"
assert v[base_model_key] == base_model_value, (
f"{base_model_key} is not equal to {base_model_value} for model {k}"
)
def _cross_region_base_model_key(
@ -299,10 +289,7 @@ def _cross_region_profiles(
(model_key, model_info, base_model_key)
for model_key, model_info in model_cost.items()
if str(model_info.get("litellm_provider", "")).startswith("bedrock")
and (
base_model_key := _cross_region_base_model_key(model_key, prefixes, model_cost)
)
is not None
and (base_model_key := _cross_region_base_model_key(model_key, prefixes, model_cost)) is not None
)
@ -336,9 +323,7 @@ def test_get_model_info_bedrock_cross_region_capability_parity():
if _govcloud_may_lag(k, cap):
assert base_value or not v[cap], f"{cap} is on {k} but not on {base_model_key}"
continue
assert (
v[cap] == base_value
), f"{cap} is {v[cap]} on {k} but {base_value} on {base_model_key}"
assert v[cap] == base_value, f"{cap} is {v[cap]} on {k} but {base_value} on {base_model_key}"
assert checked > 0, "no cross-region bedrock profiles found - the filter is inert"
@ -367,9 +352,7 @@ def test_get_model_info_bedrock_priced_cross_region_profile_has_priced_base():
base = litellm.model_cost[base_model_key]
for cost_key in ("input_cost_per_token", "output_cost_per_token"):
if (v.get(cost_key) or 0) > 0:
assert (
base.get(cost_key) or 0
) > 0, f"{k} charges {cost_key} but its base {base_model_key} is free"
assert (base.get(cost_key) or 0) > 0, f"{k} charges {cost_key} but its base {base_model_key} is free"
assert checked > 0, "no cross-region bedrock profiles found - the filter is inert"
@ -401,23 +384,17 @@ def test_get_model_info_case_insensitive_lookup(monkeypatch):
)
# Test 1: Exact case should work
info = litellm.get_model_info(
model="Qwen/Qwen3-Next-80B-A3B-Thinking", custom_llm_provider="together_ai"
)
info = litellm.get_model_info(model="Qwen/Qwen3-Next-80B-A3B-Thinking", custom_llm_provider="together_ai")
assert info is not None
assert info["supports_function_calling"] is True
# Test 2: Lowercase should also work (case-insensitive lookup)
info_lower = litellm.get_model_info(
model="qwen/qwen3-next-80b-a3b-thinking", custom_llm_provider="together_ai"
)
info_lower = litellm.get_model_info(model="qwen/qwen3-next-80b-a3b-thinking", custom_llm_provider="together_ai")
assert info_lower is not None
assert info_lower["supports_function_calling"] is True
# Test 3: Mixed case should also work
info_mixed = litellm.get_model_info(
model="QWEN/qwen3-NEXT-80b-a3b-thinking", custom_llm_provider="together_ai"
)
info_mixed = litellm.get_model_info(model="QWEN/qwen3-NEXT-80b-a3b-thinking", custom_llm_provider="together_ai")
assert info_mixed is not None
assert info_mixed["supports_function_calling"] is True
@ -445,21 +422,14 @@ def test_get_model_info_case_insensitive_supports_function_calling(monkeypatch):
from litellm.utils import supports_function_calling
# Exact case
assert (
supports_function_calling("TestModel-ABC", custom_llm_provider="test_provider")
is True
)
assert supports_function_calling("TestModel-ABC", custom_llm_provider="test_provider") is True
# Lowercase (should now work with case-insensitive lookup)
assert (
supports_function_calling("testmodel-abc", custom_llm_provider="test_provider")
is True
)
assert supports_function_calling("testmodel-abc", custom_llm_provider="test_provider") is True
def test_get_model_info_custom_model_router():
from litellm import Router
from litellm import get_model_info
from litellm import Router, get_model_info
litellm.turn_on_debug()
@ -517,6 +487,4 @@ def test_get_model_info_custom_provider():
# Get registered model info
from litellm import get_model_info
get_model_info(
model="my-custom-llm/my-fake-model"
) # 💥 "Exception: This model isn't mapped yet." in v1.56.10
get_model_info(model="my-custom-llm/my-fake-model") # 💥 "Exception: This model isn't mapped yet." in v1.56.10

View file

@ -1,7 +1,7 @@
"""
Tests for litellm.types.completion module
This test suite validates the CompletionRequest model and its compatibility with
This test suite validates the CompletionRequest model and its compatibility with
OpenAI ChatCompletion API message formats.
Usage:
@ -102,9 +102,7 @@ def test_completion_request_multimodal_content():
{"type": "text", "text": "What's in this image?"},
{
"type": "image_url",
"image_url": {
"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD..."
},
"image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD..."},
},
],
}