From 6fec38dabdab473835c62afb8049730558afe9be Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:31:28 -0700 Subject: [PATCH 1/8] fix(anthropic/passthrough): drop incompatible temperature when downgrading adaptive thinking for pre-4.6 models (#33244) * fix(anthropic/passthrough): drop temperature and cap thinking budget when downgrading adaptive thinking for pre-4.6 models * test(anthropic/passthrough): use sufficient max_tokens for reasoning_effort thinking mapping * fix(anthropic/passthrough): drop incompatible temperature when downgrading adaptive thinking for pre-4.6 models Narrow the fix to the temperature reconciliation; the reasoning_effort budget cap is reverted because the live translation grid relies on budget_tokens >= max_tokens to reject unsupported effort tiers (xhigh/max) on budget-mode models, so capping turned those 400s into 200s. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> (cherry picked from commit 71dffc1e9a6a921920c3d89119bb30ce7a6fd52b) --- .../messages/transformation.py | 36 +++++++++ .../test_anthropic_messages_effort.py | 76 +++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 39713e0f003..d57a34de55b 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -386,6 +386,36 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): return thinking return {**thinking, "budget_tokens": max_tokens - 1} + @staticmethod + def _drop_incompatible_temperature_for_thinking( + model: str, optional_params: dict, custom_llm_provider: str + ) -> None: + """Anthropic rejects any ``temperature`` other than 1 while extended thinking + is enabled ("temperature may only be set to 1 when thinking is enabled"). + + Clients like Claude Code send ``thinking``/``output_config.effort`` together + with a pinned ``temperature`` (e.g. the safety classifier uses ``temperature=0`` + for determinism). When the request lands on a non-adaptive model, the effort + interface is reshaped above into legacy ``thinking={type: enabled}`` (or kept + as ``output_config.effort`` on Opus 4.5), and the leftover ``temperature`` would + 400. Preserving the thinking the caller asked for wins over an unhonorable + sampling value (Anthropic forces ``temperature=1`` under thinking regardless), + so drop it and let the API default apply. + + Adaptive models (4.6+) own this natively and are left untouched. + """ + if AnthropicModelInfo._is_adaptive_thinking_model(model, custom_llm_provider): + return + temperature = optional_params.get("temperature") + if temperature is None or temperature == 1: + return + thinking = optional_params.get("thinking") + output_config = optional_params.get("output_config") + thinking_enabled = isinstance(thinking, dict) and thinking.get("type") == "enabled" + effort_enabled = isinstance(output_config, dict) and output_config.get("effort") is not None + if thinking_enabled or effort_enabled: + optional_params.pop("temperature", None) + def transform_anthropic_messages_request( self, model: str, @@ -426,6 +456,12 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): custom_llm_provider=self._resolved_provider, ) + self._drop_incompatible_temperature_for_thinking( + model=model, + optional_params=anthropic_messages_optional_request_params, + custom_llm_provider=self._resolved_provider, + ) + system_param = anthropic_messages_optional_request_params.get("system") if self.should_strip_billing_metadata() and system_param is not None: filtered_system = self._filter_billing_headers_from_system(system_param) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_effort.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_effort.py index 06d3effcfbb..5254808e315 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_effort.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_effort.py @@ -2,6 +2,7 @@ import pytest from litellm.constants import ( DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET, DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, ) @@ -174,6 +175,81 @@ def test_unrecognized_effort_raises_clean_400(): assert exc_info.value.status_code == 400 +def test_pinned_temperature_dropped_when_adaptive_downgraded_to_enabled(): + """Regression (#33203): Claude Code's safety classifier sends adaptive thinking + + temperature=0 to Haiku 4.5. The adaptive interface is downgraded to legacy enabled + thinking, but Anthropic rejects "temperature may only be set to 1 when thinking is + enabled". The pinned temperature must be dropped so the request succeeds while the + downgraded thinking is preserved.""" + params = _claude_code_payload(effort="medium") + params["temperature"] = 0 + result = _transform("claude-haiku-4-5", params) + + assert result["thinking"] == { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, + } + assert "temperature" not in result + + +def test_temperature_one_preserved_with_enabled_thinking(): + """temperature=1 is compatible with extended thinking, so it must be kept.""" + params = _claude_code_payload(effort="medium") + params["temperature"] = 1 + result = _transform("claude-haiku-4-5", params) + + assert result["thinking"]["type"] == "enabled" + assert result["temperature"] == 1 + + +def test_pinned_temperature_preserved_when_thinking_dropped(): + """When thinking is dropped entirely (non-reasoning model), there is no thinking + conflict, so a pinned temperature must survive untouched.""" + params = _claude_code_payload(effort="medium") + params["temperature"] = 0 + result = _transform("claude-3-5-haiku-latest", params) + + assert "thinking" not in result + assert result["temperature"] == 0 + + +def test_pinned_temperature_preserved_for_adaptive_model(): + """Adaptive models (4.6+) own the thinking/temperature relationship natively, so + the passthrough must not strip a pinned temperature for them.""" + params = _claude_code_payload(effort="high") + params["temperature"] = 0 + result = _transform("claude-sonnet-4-6", params) + + assert result["thinking"] == {"type": "adaptive"} + assert result["temperature"] == 0 + + +def test_pinned_temperature_dropped_for_opus_4_5_effort(): + """Opus 4.5 keeps native output_config.effort (extended thinking), which is equally + incompatible with a pinned non-1 temperature, so the temperature must be dropped.""" + params = _claude_code_payload(effort="medium") + params["temperature"] = 0 + result = _transform("claude-opus-4-5", params) + + assert result["output_config"] == {"effort": "medium"} + assert "temperature" not in result + + +def test_reasoning_effort_with_pinned_temperature_drops_temperature(): + """The reasoning_effort alias synthesizes legacy enabled thinking on a non-adaptive + model; a co-pinned non-1 temperature must be dropped to avoid the Anthropic 400.""" + result = _transform( + "claude-haiku-4-5", + {"max_tokens": 8192, "reasoning_effort": "low", "temperature": 0}, + ) + + assert result["thinking"] == { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET, + } + assert "temperature" not in result + + def test_non_adaptive_request_without_effort_is_untouched(): """A non-adaptive model receiving a request with no adaptive interface (no effort, no adaptive thinking) must pass through untouched.""" From e4d9010a93d9f7b35840b0aecca549c0415b9fa8 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 16 Jul 2026 15:04:55 -0700 Subject: [PATCH 2/8] fix(docker): restore litellm-proxy-extras source dir in runtime images (#33592) * fix(docker): restore litellm-proxy-extras source dir in runtime images #30243 narrowed the runtime stage to an allowlist COPY, which dropped /app/litellm-proxy-extras from the published images. Downstream migration jobs point prisma migrate deploy at that path; with the schema gone (or a schema with no adjacent migrations dir, where prisma exits 0 without applying anything) those jobs went green while never migrating the database. Restore the folder in all three runtime stages and assert in image-scan that the schema and a non-empty migrations dir ship at the source path * chore(ci): drop image-scan migration-assets assertion (cherry picked from commit 111d447e1b603878ccfed645654981c97ecfe250) --- Dockerfile | 1 + docker/Dockerfile.database | 1 + docker/Dockerfile.non_root | 1 + 3 files changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index bc0e6a5ca6f..581d1808f0a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -114,6 +114,7 @@ COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/pr # working directory on sys.path; litellm/proxy/hooks resolves # enterprise.enterprise_hooks from it) COPY --from=builder /app/enterprise /app/enterprise +COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras # Prisma binaries live in $HOME/.cache (default prisma-python location), # which is /root/.cache here. Copy only the Prisma subdirs — copying the # whole /root/.cache drags in the uv build cache (~660 MB, includes a diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database index 4564ee403fe..868b6682276 100644 --- a/docker/Dockerfile.database +++ b/docker/Dockerfile.database @@ -111,6 +111,7 @@ COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/pr # working directory on sys.path; litellm/proxy/hooks resolves # enterprise.enterprise_hooks from it) COPY --from=builder /app/enterprise /app/enterprise +COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras # Prisma binaries live in $HOME/.cache (default prisma-python location), # which is /root/.cache here. Copy them from the builder so they survive # deployments that volume-mount /app/.cache (e.g. readOnlyRootFilesystem diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index 1883e87be60..839f5da565c 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -137,6 +137,7 @@ COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/pr # working directory on sys.path; litellm/proxy/hooks resolves # enterprise.enterprise_hooks from it) COPY --from=builder /app/enterprise /app/enterprise +COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras COPY --from=builder /app/.cache /app/.cache COPY --from=builder /var/lib/litellm/ui /var/lib/litellm/ui COPY --from=builder /var/lib/litellm/assets /var/lib/litellm/assets From 8c47c61b3284847f924b2647ca2709a0554802a4 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:06:41 -0700 Subject: [PATCH 3/8] fix(model_armor): restore reference attachments via skip_unscannable_attachments and remove the attachment count cap (#33554) * fix(model_armor): add skip_unscannable_attachments to allow reference-only attachments through * fix(model_armor): wire skip_unscannable_attachments through guardrail config * fix(model_armor): make max_file_attachments configurable and scan overflow instead of dropping * fix(model_armor): remove the per-request attachment count cap and scan all attachments --------- Co-authored-by: yucheng (cherry picked from commit 0d7b0f708b645aa01054dca4dc60a4e11fc06e56) --- .../guardrail_hooks/model_armor/__init__.py | 1 + .../model_armor/file_scanning.py | 4 - .../model_armor/model_armor.py | 30 ++-- litellm/types/guardrails.py | 8 + .../guardrail_hooks/test_model_armor.py | 159 ++++++++++++++++-- 5 files changed, 168 insertions(+), 34 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/model_armor/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/model_armor/__init__.py index 7398e8defea..5e62ab96f0c 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/model_armor/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/model_armor/__init__.py @@ -26,6 +26,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" mask_request_content=litellm_params.mask_request_content, mask_response_content=litellm_params.mask_response_content, fail_on_error=litellm_params.fail_on_error, + skip_unscannable_attachments=litellm_params.skip_unscannable_attachments, ) litellm.logging_callback_manager.add_litellm_callback(_model_armor_callback) diff --git a/litellm/proxy/guardrails/guardrail_hooks/model_armor/file_scanning.py b/litellm/proxy/guardrails/guardrail_hooks/model_armor/file_scanning.py index 0bc6e67eb35..b879f0d29c7 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/model_armor/file_scanning.py +++ b/litellm/proxy/guardrails/guardrail_hooks/model_armor/file_scanning.py @@ -25,10 +25,6 @@ from litellm.types.llms.openai import AllMessageValues MODEL_ARMOR_MAX_FILE_SIZE_BYTES = 4 * 1024 * 1024 -# Hard cap on how many attachments a single request may submit to Model Armor, to bound -# per-request fan-out (latency and quota). -MAX_FILE_ATTACHMENTS_PER_REQUEST = 10 - _REMOTE_URI_SCHEMES = ("gs://", "http://", "https://") ModelArmorByteDataType = Literal["PDF", "WORD_DOCUMENT", "EXCEL_DOCUMENT", "POWERPOINT_DOCUMENT", "CSV", "TXT"] diff --git a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py index bebd9b28745..69d1f3273ac 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py +++ b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py @@ -32,7 +32,6 @@ from litellm.llms.custom_httpx.http_handler import ( from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.guardrails.guardrail_hooks.model_armor.file_scanning import ( - MAX_FILE_ATTACHMENTS_PER_REQUEST, MODEL_ARMOR_MAX_FILE_SIZE_BYTES, plan_file_scans, ) @@ -371,10 +370,14 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): Each attachment is sent through the byte API and a MATCH_FOUND raises a 400 before the request reaches the LLM. File scanning does not support masking (Model Armor returns - findings, not a sanitized document), so it only blocks. Anything the guardrail cannot - scan - a file_id or remote URL reference with no inline bytes, a document over the 4 MB - byte limit, or more attachments than the per-request cap - is a guardrail failure and - blocks unless the operator has opted into fail-open via fail_on_error=False. + findings, not a sanitized document), so it only blocks. A file_id or remote URL reference + with no inline bytes and a document over the 4 MB byte limit are guardrail failures that + block unless the operator has opted into fail-open via fail_on_error=False. + + skip_unscannable_attachments decouples reference-only attachments from fail_on_error: when + enabled, attachments Model Armor cannot scan (file_id, gs://, or http(s) references with no + inline bytes, and inline content whose base64 will not decode) pass through instead of + blocking, while fail_on_error still governs real Model Armor API errors. """ from litellm.proxy.common_utils.callback_utils import ( _get_or_create_proxy_metadata_bucket, @@ -383,7 +386,14 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): plan = plan_file_scans(messages) attachments = plan.attachments - unscannable_references = plan.unscannable_count + skip_unscannable = bool(self.optional_params.get("skip_unscannable_attachments", False)) + if skip_unscannable and plan.unscannable_count > 0: + verbose_proxy_logger.warning( + "Model Armor: allowing %d unscannable attachment(s) through because " + "skip_unscannable_attachments is enabled", + plan.unscannable_count, + ) + unscannable_references = 0 if skip_unscannable else plan.unscannable_count if not attachments and unscannable_references == 0: return @@ -403,14 +413,6 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): metadata["_model_armor_status"] = "blocked" raise self._unscannable_block_error(reason) - if len(attachments) > MAX_FILE_ATTACHMENTS_PER_REQUEST: - reason = f"{len(attachments)} attachments exceed the per-request scan limit of {MAX_FILE_ATTACHMENTS_PER_REQUEST}" - verbose_proxy_logger.warning("Model Armor: %s", reason) - if fail_on_error: - metadata["_model_armor_status"] = "blocked" - raise self._unscannable_block_error(reason) - attachments = attachments[:MAX_FILE_ATTACHMENTS_PER_REQUEST] - for attachment in attachments: if len(attachment.file_bytes) > MODEL_ARMOR_MAX_FILE_SIZE_BYTES: reason = ( diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 8d7d7311fad..2a6ef292cdd 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -687,6 +687,14 @@ class BaseLitellmParams(ContentFilterConfigModel): # works for new and patch up "so only a valid guardrail response can block or modify it." ), ) + skip_unscannable_attachments: Optional[bool] = Field( + default=False, + description=( + "Implemented by guardrail='model_armor'. When True, attachment references that carry no " + "inline bytes (file_id, gs://, or http(s) URLs) pass through unscanned instead of blocking, " + "while fail_on_error still governs real Model Armor API errors. Default False blocks them." + ), + ) additional_provider_specific_params: Optional[Dict[str, Any]] = Field( default=None, diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py index 64df9ee7ab5..4565c6c170d 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py @@ -2205,21 +2205,20 @@ async def test_pre_call_file_id_reference_skipped_when_fail_open(): @pytest.mark.asyncio -async def test_pre_call_blocks_when_attachment_count_exceeds_cap(): - """More attachments than the per-request cap fail closed by default to bound scan fan-out.""" - from litellm.proxy.guardrails.guardrail_hooks.model_armor.file_scanning import ( - MAX_FILE_ATTACHMENTS_PER_REQUEST, - ) - - guardrail = _make_guardrail() - pdf_b64 = base64.b64encode(PDF_BYTES).decode("utf-8") - block = { - "type": "file", - "file": {"file_data": f"data:application/pdf;base64,{pdf_b64}"}, - } +async def test_pre_call_file_id_reference_passthrough_when_skip_unscannable_enabled(): + """skip_unscannable_attachments lets a file_id reference through even with fail_on_error=True.""" + guardrail = _make_guardrail(skip_unscannable_attachments=True) request_data = { "model": "gpt-4", - "messages": [{"role": "user", "content": [block] * (MAX_FILE_ATTACHMENTS_PER_REQUEST + 1)}], + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "summarize this"}, + {"type": "file", "file": {"file_id": "file-abc123"}}, + ], + } + ], "metadata": {"guardrails": ["model-armor-test"]}, } @@ -2227,8 +2226,109 @@ async def test_pre_call_blocks_when_attachment_count_exceeds_cap(): guardrail.async_handler, "post", AsyncMock(return_value=_armor_response(blocked=False)), + ) as mock_post: + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), + cache=MagicMock(spec=DualCache), + data=request_data, + call_type="completion", + ) + + assert _byte_items_sent(mock_post) == [] + assert _text_payloads_sent(mock_post) == ["summarize this"] + + +@pytest.mark.asyncio +async def test_pre_call_gs_uri_reference_passthrough_when_skip_unscannable_enabled(): + """A gs:// document reference passes through when skip_unscannable_attachments is enabled.""" + guardrail = _make_guardrail(skip_unscannable_attachments=True) + request_data = { + "model": "gpt-4", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "file", + "file": {"file_data": "gs://my-bucket/report.pdf", "filename": "report.pdf"}, + } + ], + } + ], + "metadata": {"guardrails": ["model-armor-test"]}, + } + + with patch.object( + guardrail.async_handler, + "post", + AsyncMock(return_value=_armor_response(blocked=False)), + ) as mock_post: + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), + cache=MagicMock(spec=DualCache), + data=request_data, + call_type="completion", + ) + + assert _byte_items_sent(mock_post) == [] + + +def test_initialize_guardrail_forwards_skip_unscannable_attachments(): + """skip_unscannable_attachments configured in litellm_params reaches the guardrail instance.""" + from litellm.proxy.guardrails.guardrail_hooks.model_armor import initialize_guardrail + from litellm.types.guardrails import Guardrail, LitellmParams + + litellm_params = LitellmParams( + guardrail="model_armor", + mode="pre_call", + template_id="demo-template", + project_id="demo-project", + skip_unscannable_attachments=True, + ) + guardrail = initialize_guardrail( + litellm_params=litellm_params, + guardrail=Guardrail(guardrail_name="model-armor-config-test"), + ) + + assert guardrail.optional_params.get("skip_unscannable_attachments") is True + + +def test_initialize_guardrail_skip_unscannable_defaults_false(): + """A config that omits skip_unscannable_attachments keeps the secure default (block).""" + from litellm.proxy.guardrails.guardrail_hooks.model_armor import initialize_guardrail + from litellm.types.guardrails import Guardrail, LitellmParams + + litellm_params = LitellmParams( + guardrail="model_armor", + mode="pre_call", + template_id="demo-template", + project_id="demo-project", + ) + guardrail = initialize_guardrail( + litellm_params=litellm_params, + guardrail=Guardrail(guardrail_name="model-armor-config-default"), + ) + + assert guardrail.optional_params.get("skip_unscannable_attachments") is False + + +@pytest.mark.asyncio +async def test_skip_unscannable_still_fails_closed_on_api_error(): + """skip_unscannable_attachments only affects references; a real API error still fails closed.""" + guardrail = _make_guardrail(skip_unscannable_attachments=True, fail_on_error=True) + pdf_b64 = base64.b64encode(PDF_BYTES).decode("utf-8") + request_data = { + "model": "gpt-4", + "messages": [_file_message(pdf_b64)], + "metadata": {"guardrails": ["model-armor-test"]}, + } + + with patch.object( + guardrail.async_handler, + "post", + AsyncMock(side_effect=Exception("model armor upstream 500")), ): - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(Exception) as exc_info: await guardrail.async_pre_call_hook( user_api_key_dict=UserAPIKeyAuth(), cache=MagicMock(spec=DualCache), @@ -2236,8 +2336,35 @@ async def test_pre_call_blocks_when_attachment_count_exceeds_cap(): call_type="completion", ) - assert exc_info.value.status_code == 400 - assert "per-request scan limit" in str(exc_info.value.detail) + assert "model armor upstream 500" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_pre_call_scans_every_attachment_without_a_count_cap(): + """There is no per-request attachment cap: every scannable attachment is submitted to Model Armor.""" + guardrail = _make_guardrail() + pdf_b64 = base64.b64encode(PDF_BYTES).decode("utf-8") + block = { + "type": "file", + "file": {"file_data": f"data:application/pdf;base64,{pdf_b64}"}, + } + count = 25 + request_data = { + "model": "gpt-4", + "messages": [{"role": "user", "content": [block] * count}], + "metadata": {"guardrails": ["model-armor-test"]}, + } + + mock_post = AsyncMock(return_value=_armor_response(blocked=False)) + with patch.object(guardrail.async_handler, "post", mock_post): + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), + cache=MagicMock(spec=DualCache), + data=request_data, + call_type="completion", + ) + + assert len(_byte_items_sent(mock_post)) == count @pytest.mark.asyncio From 438c8b6bab95e1bf2c109c69cce5e1a6837fc87b Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 18 Jul 2026 14:52:40 -0700 Subject: [PATCH 4/8] fix(docker): bake prisma CLI and engines at a fixed path so fresh-DB migrations work for any uid offline (#33853) * fix(docker): bake prisma CLI and engines at a fixed path so fresh-DB migrations work for any uid offline The runtime image shipped the prisma CLI and engines under /root/.cache, the default HOME-derived prisma-python cache location. Any deployment whose runtime HOME is not /root (kubernetes runAsUser, docker --user, HOME overrides) missed that cache on a fresh database, fell back to a nodeenv Node download that crashes on Wolfi (libatomic.so.1), and started the proxy with zero tables while every DB-backed endpoint returned 500 The bake now lives at /opt/prisma, a path no HOME resolution or cache volume mount can shadow. The builder records the engine paths there at generate time, and the runtime stage pins PRISMA_BINARY_CACHE_DIR, PRISMA_CLI_PATH, PRISMA_CLI_QUERY_ENGINE_TYPE=binary and PRISMA_OFFLINE_MODE so both litellm-proxy-extras and prisma-python resolve the baked CLI and engines directly. prisma migrate deploy on a fresh database now needs no npm and no network access for any runtime uid, including readOnlyRootFilesystem deployments Verified against live containers: fresh and existing databases as root, uid 12345, HOME overridden, on an internal-only docker network, and with a read-only root filesystem all migrate and serve /team/new successfully Fixes #33650, #24554 * chore(docker): fail the image build if the baked prisma CLI layout drifts Asserts the baked CLI shim is executable and its entrypoint exists in the runtime stage after the COPY and chmod, so a layout change in a future prisma-python release breaks the image build loudly instead of silently degrading the migration path at container startup (cherry picked from commit 567ebcb3e9b0d7f817ee920007662444dc9046ad) --- Dockerfile | 28 ++++++++++++++++++---------- docker/Dockerfile.database | 30 ++++++++++++++++++++---------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/Dockerfile b/Dockerfile index 581d1808f0a..9977ebb82d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,7 +86,9 @@ RUN uv sync --frozen --no-default-groups --no-editable \ --extra semantic-router \ --python python3 -RUN prisma generate --schema=./schema.prisma +RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ + npm_config_cache=/root/.npm \ + prisma generate --schema=./schema.prisma RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \ sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh @@ -100,7 +102,11 @@ USER root RUN apk add --no-cache bash openssl tzdata nodejs python3 libsndfile WORKDIR /app -ENV PATH="/app/.venv/bin:${PATH}" +ENV PATH="/app/.venv/bin:${PATH}" \ + PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ + PRISMA_CLI_PATH=/opt/prisma/binaries/node_modules/.bin/prisma \ + PRISMA_CLI_QUERY_ENGINE_TYPE=binary \ + PRISMA_OFFLINE_MODE=true # Copy only what runtime needs. The application is installed inside the venv; # the rest of the builder's /app is source and build metadata that must not @@ -115,16 +121,18 @@ COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/pr # enterprise.enterprise_hooks from it) COPY --from=builder /app/enterprise /app/enterprise COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras -# Prisma binaries live in $HOME/.cache (default prisma-python location), -# which is /root/.cache here. Copy only the Prisma subdirs — copying the -# whole /root/.cache drags in the uv build cache (~660 MB, includes a -# setuptools wheel that surfaces as a CVE finding even though it's not -# on the runtime sys.path). -COPY --from=builder /root/.cache/prisma /root/.cache/prisma -COPY --from=builder /root/.cache/prisma-python /root/.cache/prisma-python +# Prisma CLI + engines are baked under /opt/prisma, a fixed path every +# runtime uid can read and that no cache volume mount shadows. The paths are +# pinned via PRISMA_BINARY_CACHE_DIR / PRISMA_CLI_PATH and recorded into the +# generated client at build time, so `prisma migrate deploy` on a fresh +# database needs no npm and no network access (#33650, #24554). +COPY --from=builder /opt/prisma /opt/prisma RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \ - find /app/.venv -type d -path "*/tornado/test" -delete + find /app/.venv -type d -path "*/tornado/test" -delete && \ + chmod -R a+rX /opt/prisma && \ + test -x /opt/prisma/binaries/node_modules/.bin/prisma && \ + test -f /opt/prisma/binaries/node_modules/prisma/build/index.js EXPOSE 4000/tcp diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database index 868b6682276..34c9c606991 100644 --- a/docker/Dockerfile.database +++ b/docker/Dockerfile.database @@ -84,7 +84,9 @@ RUN uv sync --frozen --no-default-groups --no-editable \ --extra semantic-router \ --python python3 -RUN prisma generate --schema=./schema.prisma +RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ + npm_config_cache=/root/.npm \ + prisma generate --schema=./schema.prisma RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \ sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh @@ -97,7 +99,11 @@ USER root RUN apk add --no-cache bash openssl tzdata nodejs python3 libsndfile WORKDIR /app -ENV PATH="/app/.venv/bin:${PATH}" +ENV PATH="/app/.venv/bin:${PATH}" \ + PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ + PRISMA_CLI_PATH=/opt/prisma/binaries/node_modules/.bin/prisma \ + PRISMA_CLI_QUERY_ENGINE_TYPE=binary \ + PRISMA_OFFLINE_MODE=true # Copy only what runtime needs. The application is installed inside the venv; # the rest of the builder's /app is source and build metadata that must not @@ -112,16 +118,20 @@ COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/pr # enterprise.enterprise_hooks from it) COPY --from=builder /app/enterprise /app/enterprise COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras -# Prisma binaries live in $HOME/.cache (default prisma-python location), -# which is /root/.cache here. Copy them from the builder so they survive -# deployments that volume-mount /app/.cache (e.g. readOnlyRootFilesystem -# + emptyDir) — otherwise the mount would shadow the baked-in query engine. -# Only the Prisma subdirs: the whole /root/.cache drags in the uv build cache. -COPY --from=builder /root/.cache/prisma /root/.cache/prisma -COPY --from=builder /root/.cache/prisma-python /root/.cache/prisma-python +# Prisma CLI + engines are baked under /opt/prisma, a fixed path every +# runtime uid can read and that no cache volume mount shadows (unlike +# /app/.cache or $HOME/.cache in readOnlyRootFilesystem + emptyDir setups). +# The paths are pinned via PRISMA_BINARY_CACHE_DIR / PRISMA_CLI_PATH and +# recorded into the generated client at build time, so `prisma migrate +# deploy` on a fresh database needs no npm and no network access +# (#33650, #24554). +COPY --from=builder /opt/prisma /opt/prisma RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \ - find /app/.venv -type d -path "*/tornado/test" -delete + find /app/.venv -type d -path "*/tornado/test" -delete && \ + chmod -R a+rX /opt/prisma && \ + test -x /opt/prisma/binaries/node_modules/.bin/prisma && \ + test -f /opt/prisma/binaries/node_modules/prisma/build/index.js EXPOSE 4000/tcp From f49a449763de7bbeea1df6651689f9a7bfa5b419 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 18 Jul 2026 16:56:36 -0700 Subject: [PATCH 5/8] chore(deps): bump mcp to 1.28.1 --- uv.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index dd55a4f31b8..821a8dd2988 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-01T21:29:59.740039Z" +exclude-newer = "2026-07-15T23:56:27.774599Z" exclude-newer-span = "P3D" [manifest] @@ -3911,7 +3911,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.26.0" +version = "1.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3929,9 +3929,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] [[package]] From 8d29c790451958799619337292f9f09d606214b0 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 18 Jul 2026 16:56:36 -0700 Subject: [PATCH 6/8] chore(deps): bump soupsieve to 2.8.4 --- uv.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index 821a8dd2988..7ac2948ac15 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-15T23:56:27.774599Z" +exclude-newer = "2026-07-15T23:56:36.210851Z" exclude-newer-span = "P3D" [manifest] @@ -7076,11 +7076,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.3" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] From 2d324beaa63de2195d11ac84797b1cd10889b874 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 18 Jul 2026 17:00:57 -0700 Subject: [PATCH 7/8] =?UTF-8?q?bump:=20version=201.92.0=20=E2=86=92=201.92?= =?UTF-8?q?.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d1f22224c79..54c80ac982c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm" -version = "1.92.0" +version = "1.92.1" description = "Library to easily interface with LLM API providers" readme = "README.md" requires-python = ">=3.10, <3.14" @@ -279,7 +279,7 @@ members = ["enterprise", "litellm-proxy-extras"] profile = "black" [tool.commitizen] -version = "1.92.0" +version = "1.92.1" version_files = [ "pyproject.toml:^version", ] From e7d3f9307b7088d1123fbf78062db6e683d66693 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 18 Jul 2026 17:01:05 -0700 Subject: [PATCH 8/8] chore: refresh uv.lock for 1.92.1 --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 7ac2948ac15..cf6ef1f21bc 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-15T23:56:36.210851Z" +exclude-newer = "2026-07-16T00:01:05.072281Z" exclude-newer-span = "P3D" [manifest] @@ -3274,7 +3274,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.92.0" +version = "1.92.1" source = { editable = "." } dependencies = [ { name = "aiohttp" },