mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge pull request #33892 from BerriAI/litellm_backport_1_92_x_bp-192x-0718
chore(release): backport #33244, #33592, #33554, #33853 to stable/1.92.x and cut 1.92.1
This commit is contained in:
commit
96baca0a7e
12 changed files with 331 additions and 64 deletions
29
Dockerfile
29
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
|
||||
|
|
@ -114,16 +120,19 @@ 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
|
||||
# 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
|
||||
COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras
|
||||
# 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -111,16 +117,21 @@ 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
|
||||
# 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
|
||||
COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras
|
||||
# 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
uv.lock
generated
16
uv.lock
generated
|
|
@ -9,7 +9,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-07-01T21:29:59.740039Z"
|
||||
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" },
|
||||
|
|
@ -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]]
|
||||
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue