From f1fec326408b8afa743915a1f10430c86a88398f Mon Sep 17 00:00:00 2001 From: ly-wang19 Date: Mon, 29 Jun 2026 16:44:34 +0800 Subject: [PATCH 1/4] fix(bedrock/converse): drop toolSpec.strict for Opus 4.7/4.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bedrock Converse routes Claude Opus 4.7/4.8 through an Anthropic-compatible validator that maps toolSpec to the native tool shape and rejects the extra `strict` key with `tools.N.custom.strict: Extra inputs are not permitted`, even though Anthropic's native API accepts `strict` as a top-level tool field for the same models. Sonnet 4.5/4.6 and Opus <=4.6 accept `toolSpec.strict` unchanged. The existing gate `get_bedrock_base_model(model).startswith("anthropic")` (introduced in #29814 to forward `strict` for Claude on Bedrock Converse) is too broad and regressed Opus 4.7/4.8 callers — see #31582. Replace the inline check with a small `bedrock_converse_supports_strict_tools` helper that excludes the Opus 4.7/4.8 family from strict forwarding. All other Anthropic models on Bedrock keep the existing behavior. Closes #31582. --- .../prompt_templates/factory.py | 9 ++-- litellm/llms/bedrock/common_utils.py | 34 ++++++++++++ ...llm_core_utils_prompt_templates_factory.py | 52 +++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index c2448430387c..c1635158d3be 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -5035,15 +5035,18 @@ def _bedrock_tools_pt(tools: List, model: Optional[str] = None) -> List[BedrockT ] """ from litellm.llms.bedrock.common_utils import ( - get_bedrock_base_model, + bedrock_converse_supports_strict_tools, normalize_json_schema_custom_types_to_object, ) from litellm.litellm_core_utils.prompt_templates.common_utils import unpack_defs _valid_json_schema_root_types = frozenset(("array", "boolean", "integer", "null", "number", "object", "string")) # Only Claude on Bedrock honours strict tool schemas; other families - # (Nova, Llama, GPT-OSS) reject the strict field outright. - supports_strict_tools = bool(model and get_bedrock_base_model(model).startswith("anthropic")) + # (Nova, Llama, GPT-OSS) reject the strict field outright. Opus 4.7/4.8 + # also reject `strict` on Bedrock Converse (see #31582) — their validator + # maps toolSpec to the native Anthropic tool shape, which has no strict + # field, even though Anthropic's native API accepts it as a top-level key. + supports_strict_tools = bool(model and bedrock_converse_supports_strict_tools(model)) tool_block_list: List[BedrockToolBlock] = [] for tool_idx, tool in enumerate(tools): # Check if tool is already a BedrockToolBlock (e.g., systemTool for Nova grounding) diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 467e1050c993..db8666eda336 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -718,6 +718,40 @@ def is_claude_4_5_on_bedrock(model: str) -> bool: return any(pattern in model_lower for pattern in claude_4_5_patterns) +# Bedrock Converse routes Claude Opus 4.7/4.8 through an Anthropic-compatible +# validator that maps toolSpec to the native tool shape and rejects the extra +# ``strict`` key (``tools.N.custom.strict: Extra inputs are not permitted``). +# Sonnet 4.5/4.6 and Opus ≤4.6 accept ``toolSpec.strict``. See #31582. +_BEDROCK_CONVERSE_STRICT_REJECTED_OPUS_PATTERNS = ( + "claude-opus-4-7", + "claude_opus_4_7", + "claude-opus-4.7", + "claude_opus_4.7", + "claude-opus-4-8", + "claude_opus_4_8", + "claude-opus-4.8", + "claude_opus_4.8", +) + + +def bedrock_converse_supports_strict_tools(model: str) -> bool: + """ + Whether ``toolSpec.strict`` can be forwarded to Bedrock Converse for ``model``. + + Returns ``True`` only for Anthropic models that are NOT in the + Opus 4.7/4.8 family — those route through a stricter validator on the + Bedrock side that rejects the ``strict`` key on ``toolSpec`` even though + Anthropic's native API accepts it as a top-level tool field. + """ + base = get_bedrock_base_model(model) + if not base.startswith("anthropic"): + return False + base_lower = base.lower() + return not any( + p in base_lower for p in _BEDROCK_CONVERSE_STRICT_REJECTED_OPUS_PATTERNS + ) + + def normalize_bedrock_opus_output_config_effort(model: str, output_config: Any) -> None: """ Normalize Anthropic ``output_config.effort`` values for Bedrock Opus ids. diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 864f685e7c97..180691d91cc7 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -1027,6 +1027,58 @@ def test_bedrock_tools_pt_strict_parameter(): assert "additionalProperties" not in result[0]["toolSpec"]["inputSchema"]["json"] +def test_bedrock_tools_pt_strict_dropped_for_opus_47_48(): + """Regression for #31582. + + Bedrock Converse routes Claude Opus 4.7/4.8 through an Anthropic-compatible + validator that rejects ``toolSpec.strict`` (``tools.N.custom.strict: Extra + inputs are not permitted``), even though Anthropic's native API accepts + ``strict`` as a top-level tool field for these models. Sonnet 4.5/4.6 and + Opus <=4.6 accept ``toolSpec.strict`` and keep the prior behavior. + """ + tools_with_strict = [ + { + "type": "function", + "function": { + "name": "get_weather", + "strict": True, + "description": "Get the weather for a city", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"}, + "unit": {"type": "string", "enum": ["celsius"]}, + }, + "required": ["city", "unit"], + "additionalProperties": False, + }, + }, + } + ] + + # Opus 4.7 / 4.8 on Bedrock Converse: strict must be dropped. + for model_id in ( + "bedrock/us.anthropic.claude-opus-4-7", + "bedrock/us.anthropic.claude-opus-4-8", + "anthropic.claude-opus-4-7-v1:0", + "anthropic.claude_opus_4_8-v1:0", + ): + result = _bedrock_tools_pt(tools_with_strict, model=model_id) + assert "strict" not in result[0]["toolSpec"], ( + f"strict leaked into toolSpec for {model_id}: {result[0]['toolSpec']}" + ) + + # Sonnet 4.5 / Opus 4.6 keep the existing behavior (strict forwarded). + for model_id in ( + "anthropic.claude-sonnet-4-5-20250929-v1:0", + "bedrock/us.anthropic.claude-opus-4-6", + ): + result = _bedrock_tools_pt(tools_with_strict, model=model_id) + assert result[0]["toolSpec"]["strict"] is True, ( + f"strict missing for {model_id}: {result[0]['toolSpec']}" + ) + + def test_bedrock_image_processor_content_type_fallback_url_extension(): """ Test that _post_call_image_processing falls back to URL extension From 377631235a2b8c2063dee732e152ea76325ba868 Mon Sep 17 00:00:00 2001 From: ly-wang19 Date: Mon, 29 Jun 2026 17:21:11 +0800 Subject: [PATCH 2/4] fix(bedrock/converse): move strict-tools regression to a clean test file The original regression test was added to test_litellm_core_utils_prompt_templates_factory.py, which has pre-existing ruff-format violations throughout (multi-line asserts that fit on one line). The lint workflow runs `ruff format --check` on changed files only, so touching that file surfaces those pre-existing violations and fails CI for unrelated reasons. Move the #31582 regression coverage into a new dedicated test file so the format check stays green. Also collapses the helper's `not any(...)` onto a single line to satisfy ruff format. Covers: #31582 --- litellm/llms/bedrock/common_utils.py | 4 +- ...edrock_converse_strict_tools_opus_47_48.py | 88 +++++++++++++++++++ ...llm_core_utils_prompt_templates_factory.py | 52 ----------- 3 files changed, 89 insertions(+), 55 deletions(-) create mode 100644 tests/test_litellm/litellm_core_utils/prompt_templates/test_bedrock_converse_strict_tools_opus_47_48.py diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index db8666eda336..ec4e355b3ecd 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -747,9 +747,7 @@ def bedrock_converse_supports_strict_tools(model: str) -> bool: if not base.startswith("anthropic"): return False base_lower = base.lower() - return not any( - p in base_lower for p in _BEDROCK_CONVERSE_STRICT_REJECTED_OPUS_PATTERNS - ) + return not any(p in base_lower for p in _BEDROCK_CONVERSE_STRICT_REJECTED_OPUS_PATTERNS) def normalize_bedrock_opus_output_config_effort(model: str, output_config: Any) -> None: diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_bedrock_converse_strict_tools_opus_47_48.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_bedrock_converse_strict_tools_opus_47_48.py new file mode 100644 index 000000000000..7b7a0260a882 --- /dev/null +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_bedrock_converse_strict_tools_opus_47_48.py @@ -0,0 +1,88 @@ +"""Regression tests for Bedrock Converse ``toolSpec.strict`` forwarding. + +Bedrock Converse routes Claude Opus 4.7/4.8 through an Anthropic-compatible +validator that rejects ``toolSpec.strict`` even though Anthropic's native API +accepts ``strict`` as a top-level tool field for the same models. See +BerriAI/litellm#31582. +""" + +import pytest + +from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_tools_pt +from litellm.llms.bedrock.common_utils import bedrock_converse_supports_strict_tools + + +_STRICT_TOOL = [ + { + "type": "function", + "function": { + "name": "get_weather", + "strict": True, + "description": "Get the weather for a city", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"}, + "unit": {"type": "string", "enum": ["celsius"]}, + }, + "required": ["city", "unit"], + "additionalProperties": False, + }, + }, + } +] + + +@pytest.mark.parametrize( + "model_id", + [ + "bedrock/us.anthropic.claude-opus-4-7", + "bedrock/us.anthropic.claude-opus-4-8", + "anthropic.claude-opus-4-7-v1:0", + "anthropic.claude_opus_4_8-v1:0", + "bedrock/us.anthropic.claude-opus-4.7", + "bedrock/us.anthropic.claude_opus_4_8-v1:0", + ], +) +def test_bedrock_tools_pt_strict_dropped_for_opus_47_48(model_id: str) -> None: + """Opus 4.7/4.8 on Bedrock Converse reject toolSpec.strict — must be dropped.""" + result = _bedrock_tools_pt(_STRICT_TOOL, model=model_id) + assert "strict" not in result[0]["toolSpec"], f"strict leaked into toolSpec for {model_id}: {result[0]['toolSpec']}" + + +@pytest.mark.parametrize( + "model_id", + [ + "anthropic.claude-sonnet-4-5-20250929-v1:0", + "bedrock/us.anthropic.claude-sonnet-4-6", + "bedrock/us.anthropic.claude-opus-4-6", + "bedrock/us.anthropic.claude-opus-4-5", + ], +) +def test_bedrock_tools_pt_strict_kept_for_other_anthropic(model_id: str) -> None: + """Sonnet 4.5/4.6 and Opus <=4.6 accept toolSpec.strict — keep forwarding it.""" + result = _bedrock_tools_pt(_STRICT_TOOL, model=model_id) + assert result[0]["toolSpec"]["strict"] is True, f"strict missing for {model_id}: {result[0]['toolSpec']}" + + +@pytest.mark.parametrize( + "model_id", + [ + "us.amazon.nova-micro-v1:0", + "meta.llama3-2-11b-instruct-v1:0", + ], +) +def test_bedrock_tools_pt_strict_dropped_for_non_anthropic(model_id: str) -> None: + """Non-Anthropic Bedrock families reject toolSpec.strict — must be dropped.""" + result = _bedrock_tools_pt(_STRICT_TOOL, model=model_id) + assert "strict" not in result[0]["toolSpec"] + + +def test_bedrock_converse_supports_strict_tools_helper() -> None: + """Direct check for the gate helper used by factory.py.""" + assert bedrock_converse_supports_strict_tools("bedrock/us.anthropic.claude-opus-4-7") is False + assert bedrock_converse_supports_strict_tools("bedrock/us.anthropic.claude-opus-4-8") is False + assert bedrock_converse_supports_strict_tools("anthropic.claude-sonnet-4-5-20250929-v1:0") is True + assert bedrock_converse_supports_strict_tools("bedrock/us.anthropic.claude-opus-4-6") is True + assert bedrock_converse_supports_strict_tools("us.amazon.nova-micro-v1:0") is False + assert bedrock_converse_supports_strict_tools("") is False diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 180691d91cc7..864f685e7c97 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -1027,58 +1027,6 @@ def test_bedrock_tools_pt_strict_parameter(): assert "additionalProperties" not in result[0]["toolSpec"]["inputSchema"]["json"] -def test_bedrock_tools_pt_strict_dropped_for_opus_47_48(): - """Regression for #31582. - - Bedrock Converse routes Claude Opus 4.7/4.8 through an Anthropic-compatible - validator that rejects ``toolSpec.strict`` (``tools.N.custom.strict: Extra - inputs are not permitted``), even though Anthropic's native API accepts - ``strict`` as a top-level tool field for these models. Sonnet 4.5/4.6 and - Opus <=4.6 accept ``toolSpec.strict`` and keep the prior behavior. - """ - tools_with_strict = [ - { - "type": "function", - "function": { - "name": "get_weather", - "strict": True, - "description": "Get the weather for a city", - "parameters": { - "type": "object", - "properties": { - "city": {"type": "string"}, - "unit": {"type": "string", "enum": ["celsius"]}, - }, - "required": ["city", "unit"], - "additionalProperties": False, - }, - }, - } - ] - - # Opus 4.7 / 4.8 on Bedrock Converse: strict must be dropped. - for model_id in ( - "bedrock/us.anthropic.claude-opus-4-7", - "bedrock/us.anthropic.claude-opus-4-8", - "anthropic.claude-opus-4-7-v1:0", - "anthropic.claude_opus_4_8-v1:0", - ): - result = _bedrock_tools_pt(tools_with_strict, model=model_id) - assert "strict" not in result[0]["toolSpec"], ( - f"strict leaked into toolSpec for {model_id}: {result[0]['toolSpec']}" - ) - - # Sonnet 4.5 / Opus 4.6 keep the existing behavior (strict forwarded). - for model_id in ( - "anthropic.claude-sonnet-4-5-20250929-v1:0", - "bedrock/us.anthropic.claude-opus-4-6", - ): - result = _bedrock_tools_pt(tools_with_strict, model=model_id) - assert result[0]["toolSpec"]["strict"] is True, ( - f"strict missing for {model_id}: {result[0]['toolSpec']}" - ) - - def test_bedrock_image_processor_content_type_fallback_url_extension(): """ Test that _post_call_image_processing falls back to URL extension From 3652798fad397b2abb8382fb95a227873c3f7434 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:45:44 +0000 Subject: [PATCH 3/4] refactor(bedrock/converse): drive strict-tools gate from model cost map Replace the hardcoded Opus 4.7/4.8 pattern list with a bedrock_converse_supports_strict_tools flag on the affected entries in model_prices_and_context_window.json, resolved via get_model_info with a local cost map fallback, so future models with the same restriction only need a JSON update --- litellm/llms/bedrock/common_utils.py | 55 +++++++++----- ...odel_prices_and_context_window_backup.json | 11 +++ .../credential_migration.py | 73 ++++--------------- litellm/types/utils.py | 1 + litellm/utils.py | 1 + model_prices_and_context_window.json | 11 +++ ...edrock_converse_strict_tools_opus_47_48.py | 25 ++++++- tests/test_litellm/test_utils.py | 1 + 8 files changed, 98 insertions(+), 80 deletions(-) diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index ec4e355b3ecd..df432a4d7e35 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -4,9 +4,11 @@ Common utilities used across bedrock chat/embedding/image generation """ +import contextlib import functools import json import os +import re from typing import ( TYPE_CHECKING, Any, @@ -718,36 +720,49 @@ def is_claude_4_5_on_bedrock(model: str) -> bool: return any(pattern in model_lower for pattern in claude_4_5_patterns) -# Bedrock Converse routes Claude Opus 4.7/4.8 through an Anthropic-compatible -# validator that maps toolSpec to the native tool shape and rejects the extra -# ``strict`` key (``tools.N.custom.strict: Extra inputs are not permitted``). -# Sonnet 4.5/4.6 and Opus ≤4.6 accept ``toolSpec.strict``. See #31582. -_BEDROCK_CONVERSE_STRICT_REJECTED_OPUS_PATTERNS = ( - "claude-opus-4-7", - "claude_opus_4_7", - "claude-opus-4.7", - "claude_opus_4.7", - "claude-opus-4-8", - "claude_opus_4_8", - "claude-opus-4.8", - "claude_opus_4.8", -) +_BEDROCK_MODEL_VERSION_SUFFIX_RE = re.compile(r"-v\d+(?::\d+)?$") def bedrock_converse_supports_strict_tools(model: str) -> bool: """ Whether ``toolSpec.strict`` can be forwarded to Bedrock Converse for ``model``. - Returns ``True`` only for Anthropic models that are NOT in the - Opus 4.7/4.8 family — those route through a stricter validator on the - Bedrock side that rejects the ``strict`` key on ``toolSpec`` even though - Anthropic's native API accepts it as a top-level tool field. + Non-Anthropic Bedrock families (Nova, Llama, GPT-OSS) reject the field + outright. Anthropic models forward it unless their entry in + ``model_prices_and_context_window.json`` sets + ``bedrock_converse_supports_strict_tools: false`` — Bedrock routes those + (Opus 4.7/4.8, see #31582) through a stricter validator that rejects the + ``strict`` key on ``toolSpec`` even though Anthropic's native API accepts + it as a top-level tool field. """ base = get_bedrock_base_model(model) if not base.startswith("anthropic"): return False - base_lower = base.lower() - return not any(p in base_lower for p in _BEDROCK_CONVERSE_STRICT_REJECTED_OPUS_PATTERNS) + flag = _get_bedrock_converse_strict_tools_flag(base) + return flag if flag is not None else True + + +def _get_bedrock_converse_strict_tools_flag(base_model: str) -> Optional[bool]: + candidates = dict.fromkeys((base_model, _BEDROCK_MODEL_VERSION_SUFFIX_RE.sub("", base_model))) + for candidate in candidates: + with contextlib.suppress(Exception): + model_info = get_cached_model_info()( + model=candidate, + custom_llm_provider="bedrock", + ) + + flag = model_info.get("bedrock_converse_supports_strict_tools") + if isinstance(flag, bool): + return flag + + model_cost_key = model_info.get("key") + if isinstance(model_cost_key, str): + local_flag = ( + _get_local_model_cost_map().get(model_cost_key, {}).get("bedrock_converse_supports_strict_tools") + ) + if isinstance(local_flag, bool): + return local_flag + return None def normalize_bedrock_opus_output_config_effort(model: str, output_config: Any) -> None: diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 8dfd3d0036a0..63296613126d 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1154,6 +1154,7 @@ "bedrock_output_config_effort_ceiling": "max" }, "anthropic.claude-opus-4-7": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, @@ -1203,6 +1204,7 @@ "supports_output_config": true }, "global.anthropic.claude-opus-4-7": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, @@ -1237,6 +1239,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "us.anthropic.claude-opus-4-7": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, @@ -1271,6 +1274,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "eu.anthropic.claude-opus-4-7": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, @@ -1305,6 +1309,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "au.anthropic.claude-opus-4-7": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, @@ -1471,6 +1476,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "anthropic.claude-opus-4-8": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, @@ -1505,6 +1511,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "global.anthropic.claude-opus-4-8": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, @@ -1539,6 +1546,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "us.anthropic.claude-opus-4-8": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, @@ -1573,6 +1581,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "eu.anthropic.claude-opus-4-8": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, @@ -1607,6 +1616,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "au.anthropic.claude-opus-4-8": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, @@ -1641,6 +1651,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "jp.anthropic.claude-opus-4-7": { + "bedrock_converse_supports_strict_tools": false, "cache_creation_input_token_cost": 6.875e-06, "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 5.5e-06, diff --git a/litellm/proxy/management_endpoints/credential_migration.py b/litellm/proxy/management_endpoints/credential_migration.py index 4d51295f8dc3..6f79a39c8832 100644 --- a/litellm/proxy/management_endpoints/credential_migration.py +++ b/litellm/proxy/management_endpoints/credential_migration.py @@ -130,9 +130,7 @@ def classify_value(value: object, key: str = "scan") -> ValueClass: return "plaintext" if value.startswith(_V2_GCM_PREFIX): return "migrated" - decrypted = decrypt_value_helper( - value=value, key=key, exception_type="debug", return_original_value=False - ) + decrypted = decrypt_value_helper(value=value, key=key, exception_type="debug", return_original_value=False) if decrypted is None: # Did not decrypt under nacl and has no v2 marker: legacy plaintext. return "plaintext" @@ -151,9 +149,7 @@ def reencrypt_value(value: object, key: str = "migrate") -> object: return value if value.startswith(_V2_GCM_PREFIX): return value # idempotent: already migrated - decrypted = decrypt_value_helper( - value=value, key=key, exception_type="debug", return_original_value=False - ) + decrypted = decrypt_value_helper(value=value, key=key, exception_type="debug", return_original_value=False) if decrypted is None: # Either legacy plaintext (no ciphertext to migrate) or corrupt. Either # way, do not overwrite — preserve the value as stored. @@ -161,9 +157,7 @@ def reencrypt_value(value: object, key: str = "migrate") -> object: return encrypt_value_helper(decrypted) -def reencrypt_selective_dict( - data: dict[str, object], sensitive_keys: list[str] -) -> dict[str, object]: +def reencrypt_selective_dict(data: dict[str, object], sensitive_keys: list[str]) -> dict[str, object]: """Return a copy of ``data`` with only ``sensitive_keys`` re-encrypted. Non-sensitive fields (e.g. ``base_url``, ``connection_id``) are left as-is. @@ -212,9 +206,7 @@ async def _migrate_config_settings_row( dict with selected sensitive fields (vantage_settings / cloudzero_settings). """ report = LocationReport(location=param_name) - record = await prisma_client.db.litellm_config.find_unique( - where={"param_name": param_name} - ) + record = await prisma_client.db.litellm_config.find_unique(where={"param_name": param_name}) if record is None or record.param_value is None: return report @@ -266,9 +258,7 @@ async def _migrate_sso_config(prisma_client: object, dry_run: bool) -> LocationR every present string field. """ report = LocationReport(location="sso_config") - record = await prisma_client.db.litellm_ssoconfig.find_unique( - where={"id": "sso_config"} - ) + record = await prisma_client.db.litellm_ssoconfig.find_unique(where={"id": "sso_config"}) if record is None or record.sso_settings is None: return report @@ -344,9 +334,7 @@ async def _migrate_callback_vars_table( rows = await table.find_many() for row in rows or []: metadata = getattr(row, "metadata", None) - if not isinstance(metadata, dict) or ( - "logging" not in metadata and "callback_settings" not in metadata - ): + if not isinstance(metadata, dict) or ("logging" not in metadata and "callback_settings" not in metadata): continue # Classify every callback-var value directly (strip the litellm_enc:: @@ -534,9 +522,7 @@ async def _scan_config_env_vars(prisma_client: object) -> LocationReport: """Scan the ``environment_variables`` config row (``param_value`` dict).""" report = LocationReport(location="config_environment_variables") try: - record = await prisma_client.db.litellm_config.find_unique( - where={"param_name": "environment_variables"} - ) + record = await prisma_client.db.litellm_config.find_unique(where={"param_name": "environment_variables"}) except Exception as e: # pragma: no cover - defensive verbose_proxy_logger.debug("scan: config env vars unavailable: %s", str(e)) return report @@ -557,11 +543,7 @@ async def _scan_covered_tables(prisma_client: object) -> list[LocationReport]: """Read-only classification of every rotation-covered table. No writes.""" reports: list[LocationReport] = [] for location, db_attr, json_cols, scalar_cols in _COVERED_TABLE_SPECS: - reports.append( - await _scan_one_table( - prisma_client, location, db_attr, json_cols, scalar_cols - ) - ) + reports.append(await _scan_one_table(prisma_client, location, db_attr, json_cols, scalar_cols)) reports.append(await _scan_config_env_vars(prisma_client)) return reports @@ -575,9 +557,7 @@ async def _scan_covered_tables(prisma_client: object) -> list[LocationReport]: _CLOUDZERO_SENSITIVE = ["api_key"] -async def _migrate_covered_tables( - prisma_client: object, user_api_key_dict: object -) -> list[LocationReport]: +async def _migrate_covered_tables(prisma_client: object, user_api_key_dict: object) -> list[LocationReport]: """Re-encrypt the tables already covered by ``_rotate_master_key`` (model table, credentials, MCP credential/env tables, config environment_variables) by running that orchestrator in *same-key* mode. With the AES gate on, the @@ -597,8 +577,7 @@ async def _migrate_covered_tables( current_key = _get_salt_key() if current_key is None: raise RuntimeError( - "Cannot migrate covered tables: no salt key / master key is set. " - "Set LITELLM_SALT_KEY before migrating." + "Cannot migrate covered tables: no salt key / master key is set. Set LITELLM_SALT_KEY before migrating." ) await _rotate_master_key( prisma_client=cast("PrismaClient", prisma_client), @@ -648,19 +627,9 @@ async def migrate_encryption( # Net-new walkers (items 3, 4, 11, 12, 13). report.add(await _migrate_callback_vars_table(prisma_client, "team", dry_run)) - report.add( - await _migrate_callback_vars_table(prisma_client, "verification_token", dry_run) - ) - report.add( - await _migrate_config_settings_row( - prisma_client, "vantage_settings", _VANTAGE_SENSITIVE, dry_run - ) - ) - report.add( - await _migrate_config_settings_row( - prisma_client, "cloudzero_settings", _CLOUDZERO_SENSITIVE, dry_run - ) - ) + report.add(await _migrate_callback_vars_table(prisma_client, "verification_token", dry_run)) + report.add(await _migrate_config_settings_row(prisma_client, "vantage_settings", _VANTAGE_SENSITIVE, dry_run)) + report.add(await _migrate_config_settings_row(prisma_client, "cloudzero_settings", _CLOUDZERO_SENSITIVE, dry_run)) report.add(await _migrate_sso_config(prisma_client, dry_run)) return report @@ -683,20 +652,10 @@ async def check_encryption(prisma_client: object) -> MigrationReport: # Net-new walker locations, in dry-run (read-only) mode. report.add(await _migrate_callback_vars_table(prisma_client, "team", dry_run=True)) + report.add(await _migrate_callback_vars_table(prisma_client, "verification_token", dry_run=True)) + report.add(await _migrate_config_settings_row(prisma_client, "vantage_settings", _VANTAGE_SENSITIVE, dry_run=True)) report.add( - await _migrate_callback_vars_table( - prisma_client, "verification_token", dry_run=True - ) - ) - report.add( - await _migrate_config_settings_row( - prisma_client, "vantage_settings", _VANTAGE_SENSITIVE, dry_run=True - ) - ) - report.add( - await _migrate_config_settings_row( - prisma_client, "cloudzero_settings", _CLOUDZERO_SENSITIVE, dry_run=True - ) + await _migrate_config_settings_row(prisma_client, "cloudzero_settings", _CLOUDZERO_SENSITIVE, dry_run=True) ) report.add(await _migrate_sso_config(prisma_client, dry_run=True)) return report diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 4f0c0c21bcec..b3a8d4faa7d8 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -152,6 +152,7 @@ class ProviderSpecificModelInfo(TypedDict, total=False): supports_output_config: Optional[bool] supports_image_size: Optional[bool] bedrock_output_config_effort_ceiling: Optional[Literal["low", "medium", "high", "max", "xhigh"]] + bedrock_converse_supports_strict_tools: Optional[bool] class SearchContextCostPerQuery(TypedDict, total=False): diff --git a/litellm/utils.py b/litellm/utils.py index 45ce5332f1d0..b33d073a870d 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5458,6 +5458,7 @@ def _get_model_info_helper( supports_xhigh_reasoning_effort=_model_info.get("supports_xhigh_reasoning_effort", None), supports_max_reasoning_effort=_model_info.get("supports_max_reasoning_effort", None), bedrock_output_config_effort_ceiling=_model_info.get("bedrock_output_config_effort_ceiling", None), + bedrock_converse_supports_strict_tools=_model_info.get("bedrock_converse_supports_strict_tools", None), supports_computer_use=_model_info.get("supports_computer_use", None), search_context_cost_per_query=_model_info.get("search_context_cost_per_query", None), web_search_billing_unit=_model_info.get("web_search_billing_unit", None), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 6ab6f1bda468..80fd3a3ded34 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1154,6 +1154,7 @@ "bedrock_output_config_effort_ceiling": "max" }, "anthropic.claude-opus-4-7": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, @@ -1203,6 +1204,7 @@ "supports_output_config": true }, "global.anthropic.claude-opus-4-7": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, @@ -1237,6 +1239,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "us.anthropic.claude-opus-4-7": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, @@ -1271,6 +1274,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "eu.anthropic.claude-opus-4-7": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, @@ -1305,6 +1309,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "au.anthropic.claude-opus-4-7": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, @@ -1471,6 +1476,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "anthropic.claude-opus-4-8": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, @@ -1505,6 +1511,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "global.anthropic.claude-opus-4-8": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, @@ -1539,6 +1546,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "us.anthropic.claude-opus-4-8": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, @@ -1573,6 +1581,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "eu.anthropic.claude-opus-4-8": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, @@ -1607,6 +1616,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "au.anthropic.claude-opus-4-8": { + "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, @@ -1641,6 +1651,7 @@ "bedrock_output_config_effort_ceiling": "xhigh" }, "jp.anthropic.claude-opus-4-7": { + "bedrock_converse_supports_strict_tools": false, "cache_creation_input_token_cost": 6.875e-06, "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 5.5e-06, diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_bedrock_converse_strict_tools_opus_47_48.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_bedrock_converse_strict_tools_opus_47_48.py index 7b7a0260a882..26096c49468d 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_bedrock_converse_strict_tools_opus_47_48.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_bedrock_converse_strict_tools_opus_47_48.py @@ -38,10 +38,11 @@ [ "bedrock/us.anthropic.claude-opus-4-7", "bedrock/us.anthropic.claude-opus-4-8", + "anthropic.claude-opus-4-7", + "anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7-v1:0", - "anthropic.claude_opus_4_8-v1:0", - "bedrock/us.anthropic.claude-opus-4.7", - "bedrock/us.anthropic.claude_opus_4_8-v1:0", + "bedrock/eu.anthropic.claude-opus-4-8-v1:0", + "bedrock/global.anthropic.claude-opus-4-7", ], ) def test_bedrock_tools_pt_strict_dropped_for_opus_47_48(model_id: str) -> None: @@ -86,3 +87,21 @@ def test_bedrock_converse_supports_strict_tools_helper() -> None: assert bedrock_converse_supports_strict_tools("bedrock/us.anthropic.claude-opus-4-6") is True assert bedrock_converse_supports_strict_tools("us.amazon.nova-micro-v1:0") is False assert bedrock_converse_supports_strict_tools("") is False + + +@pytest.mark.parametrize( + "cost_map_key", + [ + "anthropic.claude-opus-4-7", + "us.anthropic.claude-opus-4-7", + "anthropic.claude-opus-4-8", + "us.anthropic.claude-opus-4-8", + ], +) +def test_strict_tools_flag_set_in_model_cost_map(cost_map_key: str) -> None: + """The gate is driven by ``bedrock_converse_supports_strict_tools: false`` in + ``model_prices_and_context_window.json``, not hardcoded model patterns.""" + from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap + + cost_map = GetModelCostMap.load_local_model_cost_map() + assert cost_map[cost_map_key]["bedrock_converse_supports_strict_tools"] is False diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 6f9f26bf6dd3..5f7c0bebdf18 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -858,6 +858,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "type": "string", "enum": ["low", "medium", "high", "max", "xhigh"], }, + "bedrock_converse_supports_strict_tools": {"type": "boolean"}, "tpm": {"type": "number"}, "provider_specific_entry": {"type": "object"}, "supported_endpoints": { From 6bd1f9a5342614e5586fffd83af4512e8a2651e0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:54:52 +0000 Subject: [PATCH 4/4] chore: revert unrelated credential_migration.py reformat --- .../credential_migration.py | 73 +++++++++++++++---- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/management_endpoints/credential_migration.py b/litellm/proxy/management_endpoints/credential_migration.py index 6f79a39c8832..4d51295f8dc3 100644 --- a/litellm/proxy/management_endpoints/credential_migration.py +++ b/litellm/proxy/management_endpoints/credential_migration.py @@ -130,7 +130,9 @@ def classify_value(value: object, key: str = "scan") -> ValueClass: return "plaintext" if value.startswith(_V2_GCM_PREFIX): return "migrated" - decrypted = decrypt_value_helper(value=value, key=key, exception_type="debug", return_original_value=False) + decrypted = decrypt_value_helper( + value=value, key=key, exception_type="debug", return_original_value=False + ) if decrypted is None: # Did not decrypt under nacl and has no v2 marker: legacy plaintext. return "plaintext" @@ -149,7 +151,9 @@ def reencrypt_value(value: object, key: str = "migrate") -> object: return value if value.startswith(_V2_GCM_PREFIX): return value # idempotent: already migrated - decrypted = decrypt_value_helper(value=value, key=key, exception_type="debug", return_original_value=False) + decrypted = decrypt_value_helper( + value=value, key=key, exception_type="debug", return_original_value=False + ) if decrypted is None: # Either legacy plaintext (no ciphertext to migrate) or corrupt. Either # way, do not overwrite — preserve the value as stored. @@ -157,7 +161,9 @@ def reencrypt_value(value: object, key: str = "migrate") -> object: return encrypt_value_helper(decrypted) -def reencrypt_selective_dict(data: dict[str, object], sensitive_keys: list[str]) -> dict[str, object]: +def reencrypt_selective_dict( + data: dict[str, object], sensitive_keys: list[str] +) -> dict[str, object]: """Return a copy of ``data`` with only ``sensitive_keys`` re-encrypted. Non-sensitive fields (e.g. ``base_url``, ``connection_id``) are left as-is. @@ -206,7 +212,9 @@ async def _migrate_config_settings_row( dict with selected sensitive fields (vantage_settings / cloudzero_settings). """ report = LocationReport(location=param_name) - record = await prisma_client.db.litellm_config.find_unique(where={"param_name": param_name}) + record = await prisma_client.db.litellm_config.find_unique( + where={"param_name": param_name} + ) if record is None or record.param_value is None: return report @@ -258,7 +266,9 @@ async def _migrate_sso_config(prisma_client: object, dry_run: bool) -> LocationR every present string field. """ report = LocationReport(location="sso_config") - record = await prisma_client.db.litellm_ssoconfig.find_unique(where={"id": "sso_config"}) + record = await prisma_client.db.litellm_ssoconfig.find_unique( + where={"id": "sso_config"} + ) if record is None or record.sso_settings is None: return report @@ -334,7 +344,9 @@ async def _migrate_callback_vars_table( rows = await table.find_many() for row in rows or []: metadata = getattr(row, "metadata", None) - if not isinstance(metadata, dict) or ("logging" not in metadata and "callback_settings" not in metadata): + if not isinstance(metadata, dict) or ( + "logging" not in metadata and "callback_settings" not in metadata + ): continue # Classify every callback-var value directly (strip the litellm_enc:: @@ -522,7 +534,9 @@ async def _scan_config_env_vars(prisma_client: object) -> LocationReport: """Scan the ``environment_variables`` config row (``param_value`` dict).""" report = LocationReport(location="config_environment_variables") try: - record = await prisma_client.db.litellm_config.find_unique(where={"param_name": "environment_variables"}) + record = await prisma_client.db.litellm_config.find_unique( + where={"param_name": "environment_variables"} + ) except Exception as e: # pragma: no cover - defensive verbose_proxy_logger.debug("scan: config env vars unavailable: %s", str(e)) return report @@ -543,7 +557,11 @@ async def _scan_covered_tables(prisma_client: object) -> list[LocationReport]: """Read-only classification of every rotation-covered table. No writes.""" reports: list[LocationReport] = [] for location, db_attr, json_cols, scalar_cols in _COVERED_TABLE_SPECS: - reports.append(await _scan_one_table(prisma_client, location, db_attr, json_cols, scalar_cols)) + reports.append( + await _scan_one_table( + prisma_client, location, db_attr, json_cols, scalar_cols + ) + ) reports.append(await _scan_config_env_vars(prisma_client)) return reports @@ -557,7 +575,9 @@ async def _scan_covered_tables(prisma_client: object) -> list[LocationReport]: _CLOUDZERO_SENSITIVE = ["api_key"] -async def _migrate_covered_tables(prisma_client: object, user_api_key_dict: object) -> list[LocationReport]: +async def _migrate_covered_tables( + prisma_client: object, user_api_key_dict: object +) -> list[LocationReport]: """Re-encrypt the tables already covered by ``_rotate_master_key`` (model table, credentials, MCP credential/env tables, config environment_variables) by running that orchestrator in *same-key* mode. With the AES gate on, the @@ -577,7 +597,8 @@ async def _migrate_covered_tables(prisma_client: object, user_api_key_dict: obje current_key = _get_salt_key() if current_key is None: raise RuntimeError( - "Cannot migrate covered tables: no salt key / master key is set. Set LITELLM_SALT_KEY before migrating." + "Cannot migrate covered tables: no salt key / master key is set. " + "Set LITELLM_SALT_KEY before migrating." ) await _rotate_master_key( prisma_client=cast("PrismaClient", prisma_client), @@ -627,9 +648,19 @@ async def migrate_encryption( # Net-new walkers (items 3, 4, 11, 12, 13). report.add(await _migrate_callback_vars_table(prisma_client, "team", dry_run)) - report.add(await _migrate_callback_vars_table(prisma_client, "verification_token", dry_run)) - report.add(await _migrate_config_settings_row(prisma_client, "vantage_settings", _VANTAGE_SENSITIVE, dry_run)) - report.add(await _migrate_config_settings_row(prisma_client, "cloudzero_settings", _CLOUDZERO_SENSITIVE, dry_run)) + report.add( + await _migrate_callback_vars_table(prisma_client, "verification_token", dry_run) + ) + report.add( + await _migrate_config_settings_row( + prisma_client, "vantage_settings", _VANTAGE_SENSITIVE, dry_run + ) + ) + report.add( + await _migrate_config_settings_row( + prisma_client, "cloudzero_settings", _CLOUDZERO_SENSITIVE, dry_run + ) + ) report.add(await _migrate_sso_config(prisma_client, dry_run)) return report @@ -652,10 +683,20 @@ async def check_encryption(prisma_client: object) -> MigrationReport: # Net-new walker locations, in dry-run (read-only) mode. report.add(await _migrate_callback_vars_table(prisma_client, "team", dry_run=True)) - report.add(await _migrate_callback_vars_table(prisma_client, "verification_token", dry_run=True)) - report.add(await _migrate_config_settings_row(prisma_client, "vantage_settings", _VANTAGE_SENSITIVE, dry_run=True)) report.add( - await _migrate_config_settings_row(prisma_client, "cloudzero_settings", _CLOUDZERO_SENSITIVE, dry_run=True) + await _migrate_callback_vars_table( + prisma_client, "verification_token", dry_run=True + ) + ) + report.add( + await _migrate_config_settings_row( + prisma_client, "vantage_settings", _VANTAGE_SENSITIVE, dry_run=True + ) + ) + report.add( + await _migrate_config_settings_row( + prisma_client, "cloudzero_settings", _CLOUDZERO_SENSITIVE, dry_run=True + ) ) report.add(await _migrate_sso_config(prisma_client, dry_run=True)) return report