mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
fix(bedrock): adapt native output backports to release interfaces
This commit is contained in:
parent
857b6bdc7d
commit
c335e81186
21 changed files with 511 additions and 837 deletions
|
|
@ -1,3 +1,5 @@
|
|||
from pydantic import TypeAdapter
|
||||
|
||||
"""
|
||||
Common utility functions used for translating messages across providers
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1377,6 +1377,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,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -108,18 +110,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
|
||||
|
|
|
|||
|
|
@ -934,6 +934,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/", "nova-2/", "nova/"]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,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
|
||||
|
||||
import litellm
|
||||
import litellm.litellm_core_utils
|
||||
|
|
@ -2338,7 +2339,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 = (
|
||||
|
|
@ -2348,7 +2351,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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1882,23 +1882,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
|
||||
|
|
@ -1907,7 +1950,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"}},
|
||||
|
|
@ -1924,4 +1971,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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
import pytest
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
import respx
|
||||
from httpx import Headers
|
||||
|
||||
import litellm
|
||||
from litellm.constants import (
|
||||
|
|
@ -1340,7 +1342,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"),
|
||||
[
|
||||
|
|
@ -1353,7 +1354,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(
|
||||
|
|
@ -1379,6 +1386,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:
|
||||
|
|
@ -6521,6 +6529,9 @@ def test_eager_input_streaming_reaches_anthropic_request_tools():
|
|||
assert result["tools"][0]["name"] == "write_file"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -6531,3 +6542,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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import asyncio
|
||||
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
|
||||
|
||||
|
|
@ -601,7 +601,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)
|
||||
|
||||
|
|
@ -638,6 +637,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 Opus 4.7)."""
|
||||
|
|
@ -1094,6 +1094,9 @@ def test_bedrock_chat_invoke_eager_input_streaming_beta_not_duplicated_with_clie
|
|||
assert result["anthropic_beta"] == [FINE_GRAINED_TOOL_STREAMING_BETA]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
_CITY_SCHEMA: Final = {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
|
|
@ -1154,4 +1157,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
|
|
@ -32,9 +32,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 = []
|
||||
|
|
@ -122,28 +120,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):
|
||||
"""
|
||||
|
|
@ -216,10 +206,7 @@ class TestBedrockFilesTransformation:
|
|||
config = BedrockFilesConfig()
|
||||
|
||||
# 1x1 transparent PNG
|
||||
img_b64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
|
||||
"2mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="
|
||||
)
|
||||
img_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="
|
||||
|
||||
openai_jsonl_content = [
|
||||
{
|
||||
|
|
@ -235,9 +222,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},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -275,16 +260,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"
|
||||
|
|
@ -369,9 +348,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):
|
||||
|
|
@ -403,9 +380,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")
|
||||
|
|
@ -460,9 +435,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):
|
||||
"""
|
||||
|
|
@ -516,9 +491,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
|
||||
|
|
@ -1113,9 +1088,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."""
|
||||
|
|
@ -1185,7 +1160,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
|
||||
|
|
@ -1196,9 +1170,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 (
|
||||
|
|
@ -1207,9 +1181,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
|
||||
|
|
@ -1219,13 +1193,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
|
||||
|
|
@ -1235,19 +1205,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."""
|
||||
|
|
@ -1258,9 +1222,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(
|
||||
|
|
@ -1270,9 +1232,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
|
||||
|
|
@ -1280,9 +1240,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"
|
||||
)
|
||||
|
|
@ -1294,87 +1252,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."""
|
||||
|
|
@ -1382,28 +1302,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):
|
||||
|
|
@ -1443,7 +1353,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
|
||||
|
|
@ -1481,9 +1390,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."""
|
||||
|
|
@ -1493,9 +1400,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`"):
|
||||
|
|
@ -1523,22 +1428,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
|
||||
)
|
||||
|
||||
|
|
@ -1805,9 +1704,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(
|
||||
|
|
@ -1852,9 +1749,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:
|
||||
|
|
@ -1935,7 +1830,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"],
|
||||
|
|
@ -2095,16 +1993,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)
|
||||
|
|
@ -2132,15 +2038,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:
|
||||
|
|
@ -2188,11 +2101,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"
|
||||
|
|
@ -2215,9 +2124,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},
|
||||
|
|
@ -2235,16 +2142,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
|
||||
|
|
@ -2260,7 +2164,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 (
|
||||
|
|
@ -2270,9 +2173,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
|
||||
|
|
@ -2309,9 +2210,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
|
||||
|
|
@ -2328,19 +2227,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
|
||||
|
|
@ -2351,17 +2243,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
|
||||
|
|
@ -2372,19 +2259,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
|
||||
|
|
@ -2397,18 +2277,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."""
|
||||
|
|
@ -2434,9 +2309,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},
|
||||
|
|
@ -2476,9 +2349,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 (
|
||||
|
|
@ -2486,9 +2360,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"},
|
||||
|
|
@ -2554,9 +2426,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,
|
||||
|
|
@ -2584,9 +2454,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,
|
||||
|
|
@ -2595,11 +2463,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"}'
|
||||
|
||||
|
||||
|
|
@ -2633,9 +2497,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,
|
||||
|
|
@ -2643,12 +2505,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,
|
||||
|
|
@ -2659,12 +2517,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
|
||||
|
|
@ -2698,9 +2552,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,
|
||||
|
|
@ -2714,9 +2566,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,
|
||||
)
|
||||
|
|
@ -3047,7 +2897,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
|
||||
|
|
@ -3063,7 +2912,6 @@ class TestBedrockFileDeletionTransformation:
|
|||
|
||||
assert rejection.value.status_code == 400
|
||||
|
||||
|
||||
def test_transform_delete_file_response_echoes_the_deleted_id(self):
|
||||
import httpx
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -329,9 +309,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]
|
||||
|
|
@ -362,42 +340,19 @@ def test_route_prefix_matched_as_path_segment_not_substring():
|
|||
)
|
||||
|
||||
# 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(
|
||||
|
|
@ -447,16 +402,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):
|
||||
|
|
@ -955,7 +904,9 @@ def test_bedrock_get_error_class_audit_covers_every_surface():
|
|||
_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 = (
|
||||
|
|
@ -1021,4 +972,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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
from collections.abc import Callable, Iterator
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
|
|
@ -32,6 +33,7 @@ from litellm._logging import (
|
|||
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.router import CredentialLiteLLMParams, GenericLiteLLMParams
|
||||
|
|
@ -59,12 +61,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,
|
||||
|
|
@ -2974,6 +2983,9 @@ class TestAdditionalDropParamsForNonOpenAIProviders:
|
|||
assert result.get("custom_param") == "value"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TestDropParamsWithPromptCacheKey:
|
||||
"""
|
||||
Test that drop_params: true correctly drops prompt_cache_key for non-OpenAI providers.
|
||||
|
|
@ -3891,6 +3903,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
|
||||
|
|
@ -4275,6 +4290,9 @@ async def test_converted_chat_stream_hook_skips_unhandled_wrappers(
|
|||
assert wrapper.completion_stream is completion_stream
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_wrapper_async_leaves_success_deployment_hook_off_requested_fake_stream(
|
||||
|
|
@ -4349,6 +4367,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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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..."},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue