mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge pull request #32552 from BerriAI/litellm_backport_1_91_x_0708
chore(release): backport #32256, #32405, #32524 to stable/1.91.x and cut 1.91.1
This commit is contained in:
commit
cdc8c72c97
10 changed files with 333 additions and 81 deletions
|
|
@ -50,6 +50,7 @@ from litellm.integrations.otel.model.semconv import (
|
|||
GenAIProvider,
|
||||
JsonRpc,
|
||||
LiteLLM,
|
||||
LiteLLMError,
|
||||
MCPMethod,
|
||||
Metric,
|
||||
Network,
|
||||
|
|
@ -85,6 +86,7 @@ __all__ = [
|
|||
"HTTP",
|
||||
"JsonRpc",
|
||||
"LiteLLM",
|
||||
"LiteLLMError",
|
||||
"MCP",
|
||||
"MCPMethod",
|
||||
"Metric",
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ from litellm.integrations.otel.model.payloads import (
|
|||
LLMCallSpanData,
|
||||
MCPToolCallSpanData,
|
||||
ServiceSpanData,
|
||||
SpanError,
|
||||
)
|
||||
from litellm.integrations.otel.plumbing.providers import to_otel_span_kind
|
||||
from litellm.integrations.otel.model.semconv import Error, ExceptionEvent
|
||||
from litellm.integrations.otel.model.semconv import Error, ExceptionEvent, LiteLLMError
|
||||
from litellm.integrations.otel.model.spans import (
|
||||
SPAN_REGISTRY,
|
||||
SpanRole,
|
||||
|
|
@ -46,6 +47,27 @@ _NAME_BUILDERS: dict[SpanRole, Callable[..., str]] = {
|
|||
_DEDUP_CACHE_MAX = 10_000
|
||||
|
||||
|
||||
def _stamp_otel_error_attributes(span: Span, error_type: str, resolved_message: str) -> None:
|
||||
"""Stamp the OTel-semconv error attributes (``error.type`` + ``error.message``).
|
||||
``error_type`` and ``resolved_message`` are ``finish_span``'s already-computed
|
||||
fallback chains, so the pair on the status, event, and attributes stays in
|
||||
lockstep."""
|
||||
span.set_attribute(Error.TYPE, error_type)
|
||||
span.set_attribute(Error.MESSAGE, resolved_message)
|
||||
|
||||
|
||||
def _stamp_litellm_error_attributes(span: Span, error: SpanError) -> None:
|
||||
"""Stamp litellm-specific error detail attributes. Emitted only when the
|
||||
corresponding field is populated so guardrail-shape errors carrying only a
|
||||
message aren't polluted with empty detail keys."""
|
||||
if error.code:
|
||||
span.set_attribute(LiteLLMError.CODE, error.code)
|
||||
if error.stack_trace:
|
||||
span.set_attribute(LiteLLMError.STACK_TRACE, error.stack_trace)
|
||||
if error.llm_provider:
|
||||
span.set_attribute(LiteLLMError.LLM_PROVIDER, error.llm_provider)
|
||||
|
||||
|
||||
class SpanEmitter:
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -175,12 +197,13 @@ class SpanEmitter:
|
|||
if error and (error.error_type or error.message):
|
||||
error_type = error.error_type or "error"
|
||||
message = error.message or error.error_type or "error"
|
||||
span.set_attribute(Error.TYPE, error_type)
|
||||
_stamp_otel_error_attributes(span, error_type, message)
|
||||
_stamp_litellm_error_attributes(span, error)
|
||||
span.set_status(Status(StatusCode.ERROR, message))
|
||||
# Carry the full message on the standard ``exception`` event so backends
|
||||
# map it as full text under ``exception.message``. Setting it as a bare
|
||||
# string attribute instead lets backends like Elasticsearch dynamic-map
|
||||
# it to a ``keyword`` capped at 1024 chars, truncating the message.
|
||||
# Also emit the semconv ``exception`` event so backends that
|
||||
# dynamic-map unknown string span attrs to ``keyword`` (e.g.
|
||||
# Elasticsearch with a 1024-char ``ignore_above``) still see the
|
||||
# full untruncated message on the recognized event field.
|
||||
span.add_event(
|
||||
ExceptionEvent.NAME,
|
||||
{ExceptionEvent.TYPE: error_type, ExceptionEvent.MESSAGE: message},
|
||||
|
|
|
|||
|
|
@ -139,6 +139,9 @@ class LLMCost:
|
|||
class SpanError:
|
||||
error_type: str | None = None
|
||||
message: str | None = None
|
||||
code: str | None = None
|
||||
stack_trace: str | None = None
|
||||
llm_provider: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -528,6 +531,9 @@ def _parse_error(payload: "StandardLoggingPayload") -> SpanError | None:
|
|||
return SpanError(
|
||||
error_type=as_str(info.get("error_class")) or as_str(info.get("error_code")),
|
||||
message=as_str(info.get("error_message")) or as_str(payload.get("error_str")),
|
||||
code=as_str(info.get("error_code")),
|
||||
stack_trace=as_str(info.get("traceback")),
|
||||
llm_provider=as_str(info.get("llm_provider")),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -143,7 +143,27 @@ class Client:
|
|||
|
||||
|
||||
class Error:
|
||||
"""OTel-defined error attribute keys, from the semconv ``error.*`` registry.
|
||||
``MESSAGE`` is marked *Deprecated* upstream in favor of domain-specific
|
||||
error message keys plus ``exception.message`` on the exception event, but
|
||||
is still defined and stamped by litellm's v1 integration; keeping it here
|
||||
for byte-for-byte parity."""
|
||||
|
||||
TYPE: Final = "error.type"
|
||||
MESSAGE: Final = "error.message"
|
||||
|
||||
|
||||
class LiteLLMError:
|
||||
"""LiteLLM-specific error attribute keys. Emitted under the ``error.*``
|
||||
namespace (not ``litellm.*``) for byte-for-byte compat with the v1
|
||||
integration in ``opentelemetry.py``; consumers reading these keys on v1
|
||||
spans read the same keys on v2 spans. OTel semconv does not define any of
|
||||
these three, and per its extension rules a namespace may carry additional
|
||||
vendor keys as long as they don't collide with defined names."""
|
||||
|
||||
CODE: Final = "error.code"
|
||||
STACK_TRACE: Final = "error.stack_trace"
|
||||
LLM_PROVIDER: Final = "error.llm_provider"
|
||||
|
||||
|
||||
class ExceptionEvent:
|
||||
|
|
|
|||
|
|
@ -1074,33 +1074,6 @@ _OPENAPI_HTTP_METHODS = {
|
|||
# `_SSO_SENSITIVE_FIELDS` / `_CACHE_SENSITIVE_FIELDS` constants in the SSO
|
||||
# and cache endpoint files.
|
||||
_ALERTING_SENSITIVE_VARS: Set[str] = {"SLACK_WEBHOOK_URL", "SMTP_PASSWORD"}
|
||||
_DB_LITELLM_PARAM_ENV_REF_KEYS = frozenset(
|
||||
{
|
||||
"api_key",
|
||||
"client_secret",
|
||||
"vertex_credentials",
|
||||
"vertex_ai_credentials",
|
||||
"aws_access_key_id",
|
||||
"aws_secret_access_key",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _db_model_is_team_scoped(model: object) -> bool:
|
||||
model_info = getattr(model, "model_info", None)
|
||||
if isinstance(model_info, BaseModel):
|
||||
return getattr(model_info, "team_id", None) is not None
|
||||
if isinstance(model_info, str):
|
||||
try:
|
||||
model_info = json.loads(model_info)
|
||||
except (TypeError, ValueError):
|
||||
model_info = None
|
||||
if isinstance(model_info, dict) and model_info.get("team_id") is not None:
|
||||
return True
|
||||
if getattr(model_info, "team_id", None) is not None:
|
||||
return True
|
||||
model_name = getattr(model, "model_name", None)
|
||||
return isinstance(model_name, str) and model_name.startswith("model_name_")
|
||||
|
||||
|
||||
def _strip_operation_id_method_suffix(operation_id: str) -> str:
|
||||
|
|
@ -4903,17 +4876,12 @@ class ProxyConfig:
|
|||
deleted_deployments += 1
|
||||
return deleted_deployments
|
||||
|
||||
def _resolve_db_litellm_param(self, key: str, value: object, resolve_env_refs: bool = True) -> object:
|
||||
def _resolve_db_litellm_param(self, key: str, value: object) -> object:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
decrypted_value = decrypt_value_helper(value=value, key=key, return_original_value=True)
|
||||
if (
|
||||
resolve_env_refs
|
||||
and key in _DB_LITELLM_PARAM_ENV_REF_KEYS
|
||||
and isinstance(decrypted_value, str)
|
||||
and decrypted_value.startswith("os.environ/")
|
||||
):
|
||||
if isinstance(decrypted_value, str) and decrypted_value.startswith("os.environ/"):
|
||||
return get_secret(decrypted_value)
|
||||
return decrypted_value
|
||||
|
||||
|
|
@ -4934,13 +4902,10 @@ class ProxyConfig:
|
|||
## ADD MODEL LOGIC
|
||||
for m in db_models:
|
||||
_litellm_params = m.litellm_params
|
||||
resolve_env_refs = not _db_model_is_team_scoped(m)
|
||||
if isinstance(_litellm_params, dict):
|
||||
# decrypt values
|
||||
for k, v in _litellm_params.items():
|
||||
_litellm_params[k] = self._resolve_db_litellm_param(
|
||||
key=k, value=v, resolve_env_refs=resolve_env_refs
|
||||
)
|
||||
_litellm_params[k] = self._resolve_db_litellm_param(key=k, value=v)
|
||||
_litellm_params = LiteLLM_Params(**_litellm_params)
|
||||
|
||||
else:
|
||||
|
|
@ -4966,15 +4931,12 @@ class ProxyConfig:
|
|||
_model_list: list = []
|
||||
for m in new_models:
|
||||
_litellm_params = m.litellm_params
|
||||
resolve_env_refs = not _db_model_is_team_scoped(m)
|
||||
if isinstance(_litellm_params, BaseModel):
|
||||
_litellm_params = _litellm_params.model_dump()
|
||||
if isinstance(_litellm_params, dict):
|
||||
# decrypt values
|
||||
for k, v in _litellm_params.items():
|
||||
_litellm_params[k] = self._resolve_db_litellm_param(
|
||||
key=k, value=v, resolve_env_refs=resolve_env_refs
|
||||
)
|
||||
_litellm_params[k] = self._resolve_db_litellm_param(key=k, value=v)
|
||||
_litellm_params = LiteLLM_Params(**_litellm_params)
|
||||
else:
|
||||
verbose_proxy_logger.error(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm"
|
||||
version = "1.91.0"
|
||||
version = "1.91.1"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10, <3.14"
|
||||
|
|
@ -269,7 +269,7 @@ source-exclude = [
|
|||
profile = "black"
|
||||
|
||||
[tool.commitizen]
|
||||
version = "1.91.0"
|
||||
version = "1.91.1"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -579,13 +579,10 @@ def _exception_event(span):
|
|||
|
||||
|
||||
def test_error_message_recorded_as_full_exception_event_untruncated():
|
||||
"""Regression for the Elasticsearch keyword/ignore_above:1024 truncation.
|
||||
|
||||
A long error message must survive intact on the standard ``exception``
|
||||
event under ``exception.message`` — not get dropped onto a bare string
|
||||
attribute that backends dynamic-map to a 1024-char ``keyword``. The SDK
|
||||
must not truncate it either, so a 5000-char message stays 5000 chars.
|
||||
"""
|
||||
"""The ``exception`` event carries the full untruncated message under
|
||||
``exception.message`` so backends that dynamic-map unknown string span
|
||||
attrs to ``keyword`` (e.g. Elasticsearch with a 1024-char ``ignore_above``)
|
||||
still see it in full via the semconv-recognized event field."""
|
||||
from litellm.integrations.otel.model.semconv import Error, ExceptionEvent
|
||||
|
||||
long_message = "boom: " + "x" * 5000
|
||||
|
|
@ -596,13 +593,108 @@ def test_error_message_recorded_as_full_exception_event_untruncated():
|
|||
assert len(event.attributes[ExceptionEvent.MESSAGE]) == len(long_message) > 1024
|
||||
assert event.attributes[ExceptionEvent.TYPE] == "litellm.APIError"
|
||||
|
||||
# error.type stays a low-cardinality attribute; the message does NOT become a
|
||||
# bare string attribute (which is what got truncated).
|
||||
# error.type stays a low-cardinality attribute; the exception EVENT field
|
||||
# ``exception.message`` never becomes a bare string attribute.
|
||||
assert span.attributes[Error.TYPE] == "litellm.APIError"
|
||||
assert ExceptionEvent.MESSAGE not in span.attributes
|
||||
assert span.status.description == long_message
|
||||
|
||||
|
||||
def test_error_details_stamped_as_span_attributes_for_labels_ingest():
|
||||
"""OTel-defined keys and litellm-specific detail keys both ride span
|
||||
attributes so backends that flatten attrs into label indexes (Elastic APM
|
||||
``labels.*``, Datadog span tags) render them. The exception event with the
|
||||
full untruncated message stays alongside — both places, matching v1's
|
||||
shape."""
|
||||
from litellm.integrations.otel.model.semconv import Error, ExceptionEvent, LiteLLMError
|
||||
from litellm.integrations.otel.emitter import SpanEmitter
|
||||
|
||||
cfg = OpenTelemetryV2Config(exporter="in_memory")
|
||||
provider, exporter = providers.in_memory_provider(cfg)
|
||||
engine = SpanEmitter(providers.get_tracer(provider, "t"), cfg)
|
||||
data = LLMCallSpanData(
|
||||
operation=GenAIOperation.CHAT,
|
||||
provider="openai",
|
||||
request_model="gpt-4o",
|
||||
response_model=None,
|
||||
response_id=None,
|
||||
request_params=LLMRequestParams(),
|
||||
usage=LLMUsage(),
|
||||
finish_reasons=(),
|
||||
error=SpanError(
|
||||
error_type="litellm.BadRequestError",
|
||||
message="400: violated moderation policy",
|
||||
code="400",
|
||||
stack_trace="File proxy_server.py line 8570 ...",
|
||||
llm_provider="openai",
|
||||
),
|
||||
response_cost=None,
|
||||
server=None,
|
||||
identity=RequestIdentity(call_id=None),
|
||||
)
|
||||
engine.emit(SpanRole.LLM_CALL, data)
|
||||
(span,) = exporter.get_finished_spans()
|
||||
|
||||
# OTel-defined keys (from the ``error.*`` semconv registry).
|
||||
assert span.attributes[Error.TYPE] == "litellm.BadRequestError"
|
||||
assert span.attributes[Error.MESSAGE] == "400: violated moderation policy"
|
||||
# LiteLLM-specific detail keys — vendor-namespaced under ``error.*``
|
||||
# for v1-parity, not defined by OTel semconv.
|
||||
assert span.attributes[LiteLLMError.CODE] == "400"
|
||||
assert span.attributes[LiteLLMError.STACK_TRACE] == "File proxy_server.py line 8570 ..."
|
||||
assert span.attributes[LiteLLMError.LLM_PROVIDER] == "openai"
|
||||
|
||||
# The exception event carries the same message on the span too.
|
||||
event = _exception_event(span)
|
||||
assert event.attributes[ExceptionEvent.MESSAGE] == "400: violated moderation policy"
|
||||
|
||||
|
||||
def test_error_details_omitted_when_span_error_carries_only_message():
|
||||
"""A guardrail-shape error (message only, no code/traceback/provider) must
|
||||
not pollute the span with empty-string detail attributes. Only the keys
|
||||
that carry real data land."""
|
||||
from litellm.integrations.otel.model.semconv import Error, LiteLLMError
|
||||
|
||||
span = _emit_error_span("guardrail rejected", error_type="ContentFilter")
|
||||
|
||||
assert span.attributes[Error.TYPE] == "ContentFilter"
|
||||
assert span.attributes[Error.MESSAGE] == "guardrail rejected"
|
||||
# LiteLLM-specific detail keys aren't stamped when the SpanError doesn't
|
||||
# carry them.
|
||||
assert LiteLLMError.CODE not in span.attributes
|
||||
assert LiteLLMError.STACK_TRACE not in span.attributes
|
||||
assert LiteLLMError.LLM_PROVIDER not in span.attributes
|
||||
|
||||
|
||||
def test_v2_error_attribute_keys_match_v1_error_attributes_byte_for_byte():
|
||||
"""v1 (``opentelemetry.py``) and v2 (``otel/`` package) stamp identical
|
||||
span-attribute keys so consumers reading ``labels.error_message`` don't
|
||||
care which integration produced the span. Renaming either side is a
|
||||
breaking change for downstream dashboards; this test locks the vocabulary."""
|
||||
from litellm.integrations._types.open_inference import ErrorAttributes
|
||||
from litellm.integrations.otel.model.semconv import Error, LiteLLMError
|
||||
|
||||
assert Error.TYPE == ErrorAttributes.ERROR_TYPE
|
||||
assert Error.MESSAGE == ErrorAttributes.ERROR_MESSAGE
|
||||
assert LiteLLMError.CODE == ErrorAttributes.ERROR_CODE
|
||||
assert LiteLLMError.STACK_TRACE == ErrorAttributes.ERROR_STACK_TRACE
|
||||
assert LiteLLMError.LLM_PROVIDER == ErrorAttributes.ERROR_LLM_PROVIDER
|
||||
|
||||
|
||||
def test_error_message_falls_back_to_error_type_when_message_absent():
|
||||
"""A ``SpanError(error_type=..., message=None)`` still renders on the span:
|
||||
the resolved message is the error_type, and it lands on ``error.message``,
|
||||
the exception event, and the span-status description in lockstep so a
|
||||
single-source-of-truth view isn't inconsistent."""
|
||||
from litellm.integrations.otel.model.semconv import Error, ExceptionEvent
|
||||
|
||||
span = _emit_error_span(message=None, error_type="RateLimitError")
|
||||
|
||||
assert span.attributes[Error.MESSAGE] == "RateLimitError"
|
||||
assert _exception_event(span).attributes[ExceptionEvent.MESSAGE] == "RateLimitError"
|
||||
assert span.status.description == "RateLimitError"
|
||||
|
||||
|
||||
def test_success_span_records_no_exception_event():
|
||||
from litellm.integrations.otel.emitter import SpanEmitter
|
||||
from litellm.integrations.otel.model.semconv import ExceptionEvent
|
||||
|
|
|
|||
|
|
@ -131,11 +131,13 @@ def _all_constants(cls):
|
|||
|
||||
|
||||
def test_attribute_keys_are_unique_across_namespaces():
|
||||
from litellm.integrations.otel import MCP, Client, JsonRpc, Network
|
||||
from litellm.integrations.otel import MCP, Client, JsonRpc, LiteLLMError, Network
|
||||
|
||||
# prefixes are allowed to be substrings; exact keys must not collide.
|
||||
# ``LiteLLMError`` shares the ``error.*`` prefix with ``Error`` by design
|
||||
# (v1-parity); the assert below is the guarantee they never overlap.
|
||||
exact = set()
|
||||
for cls in (GenAI, Error, Server, HTTP, DB, MCP, JsonRpc, Network, Client):
|
||||
for cls in (GenAI, Error, LiteLLMError, Server, HTTP, DB, MCP, JsonRpc, Network, Client):
|
||||
for key in _all_constants(cls):
|
||||
assert key not in exact, f"duplicate attribute key {key}"
|
||||
exact.add(key)
|
||||
|
|
@ -329,6 +331,47 @@ def test_llm_call_adapter_failure_path():
|
|||
assert data.error.message == "429 slow down"
|
||||
|
||||
|
||||
def test_llm_call_adapter_carries_error_detail_fields():
|
||||
"""``_parse_error`` threads the full detail set from ``error_information``
|
||||
(``error_code``, ``traceback``, ``llm_provider``) onto ``SpanError`` so the
|
||||
emitter can stamp them as span attributes."""
|
||||
payload = _sample_payload(
|
||||
status="failure",
|
||||
error_information={
|
||||
"error_class": "BadRequestError",
|
||||
"error_message": "400 violated moderation policy",
|
||||
"error_code": "400",
|
||||
"traceback": "File proxy_server.py line 8570 ...",
|
||||
"llm_provider": "openai",
|
||||
},
|
||||
)
|
||||
data = LLMCallSpanData.from_standard_logging_payload(payload)
|
||||
assert data.error is not None
|
||||
assert data.error.error_type == "BadRequestError"
|
||||
assert data.error.message == "400 violated moderation policy"
|
||||
assert data.error.code == "400"
|
||||
assert data.error.stack_trace == "File proxy_server.py line 8570 ..."
|
||||
assert data.error.llm_provider == "openai"
|
||||
|
||||
|
||||
def test_llm_call_adapter_error_details_default_to_none_when_absent():
|
||||
"""Guardrail-shape payloads carry only ``error_class`` + ``error_message``.
|
||||
The detail fields must stay ``None`` so the emitter's ``if error.code:``
|
||||
guards skip stamping empty attributes."""
|
||||
payload = _sample_payload(
|
||||
status="failure",
|
||||
error_information={
|
||||
"error_class": "ContentFilter",
|
||||
"error_message": "guardrail rejected",
|
||||
},
|
||||
)
|
||||
data = LLMCallSpanData.from_standard_logging_payload(payload)
|
||||
assert data.error is not None
|
||||
assert data.error.code is None
|
||||
assert data.error.stack_trace is None
|
||||
assert data.error.llm_provider is None
|
||||
|
||||
|
||||
def test_adapter_is_resilient_to_minimal_payload():
|
||||
data = LLMCallSpanData.from_standard_logging_payload({})
|
||||
assert data.request_model == ""
|
||||
|
|
|
|||
|
|
@ -888,6 +888,11 @@ def test_ProxyConfig__add_deployment_invalid_litellm_params_skips(monkeypatch):
|
|||
|
||||
|
||||
def test_ProxyConfig__add_deployment_resolves_env_refs_after_db_decrypt(monkeypatch):
|
||||
"""Every ``os.environ/`` value on an admin-scoped DB row resolves at
|
||||
load time, regardless of the field name. Replaces the earlier
|
||||
behavior where only fields in ``_DB_LITELLM_PARAM_ENV_REF_KEYS``
|
||||
resolved: the whitelist has been removed so the resolver applies to
|
||||
every string field."""
|
||||
monkeypatch.setenv("LITELLM_DB_MODEL_API_KEY", "resolved-secret")
|
||||
monkeypatch.setenv("LITELLM_MASTER_KEY", "master-secret")
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -915,19 +920,21 @@ def test_ProxyConfig__add_deployment_resolves_env_refs_after_db_decrypt(monkeypa
|
|||
|
||||
assert added == 1
|
||||
assert deployment.litellm_params.api_key == "resolved-secret"
|
||||
assert deployment.litellm_params.api_base == "os.environ/LITELLM_MASTER_KEY"
|
||||
assert deployment.litellm_params.api_base == "master-secret"
|
||||
|
||||
|
||||
def test_ProxyConfig__add_deployment_keeps_team_env_refs_literal(monkeypatch):
|
||||
def fail_on_call(secret_name, *args, **kwargs):
|
||||
raise AssertionError("team DB models should not resolve env refs")
|
||||
|
||||
def test_ProxyConfig__add_deployment_resolves_team_env_refs(monkeypatch):
|
||||
"""Team-scoped DB rows now resolve ``os.environ/`` refs the same way
|
||||
admin rows do. The prior team-scoped short-circuit and the
|
||||
field-by-field whitelist have both been removed; the write-side team
|
||||
auth check in ``ModelManagementAuthChecks.can_user_make_model_call``
|
||||
remains the single trust boundary. A literal (non-``os.environ/``)
|
||||
value still passes through unchanged."""
|
||||
monkeypatch.setenv("LITELLM_MASTER_KEY", "master-secret")
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.decrypt_value_helper",
|
||||
lambda value, key, return_original_value: value,
|
||||
)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.get_secret", fail_on_call)
|
||||
fake_router = MagicMock()
|
||||
fake_router.upsert_deployment = MagicMock(return_value=True)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", fake_router)
|
||||
|
|
@ -939,7 +946,7 @@ def test_ProxyConfig__add_deployment_keeps_team_env_refs_literal(monkeypatch):
|
|||
litellm_params={
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"api_key": "os.environ/LITELLM_MASTER_KEY",
|
||||
"api_base": "https://attacker.example",
|
||||
"api_base": "https://team.example",
|
||||
},
|
||||
blocked=False,
|
||||
)
|
||||
|
|
@ -948,8 +955,8 @@ def test_ProxyConfig__add_deployment_keeps_team_env_refs_literal(monkeypatch):
|
|||
deployment = fake_router.upsert_deployment.call_args.kwargs["deployment"]
|
||||
|
||||
assert added == 1
|
||||
assert deployment.litellm_params.api_key == "os.environ/LITELLM_MASTER_KEY"
|
||||
assert deployment.litellm_params.api_base == "https://attacker.example"
|
||||
assert deployment.litellm_params.api_key == "master-secret"
|
||||
assert deployment.litellm_params.api_base == "https://team.example"
|
||||
|
||||
|
||||
def test_ProxyConfig__resolve_db_litellm_param_skips_non_string_values(monkeypatch):
|
||||
|
|
@ -965,6 +972,100 @@ def test_ProxyConfig__resolve_db_litellm_param_skips_non_string_values(monkeypat
|
|||
assert pc._resolve_db_litellm_param(key="tpm", value=100) == 100
|
||||
|
||||
|
||||
def test_ProxyConfig__add_deployment_resolves_env_refs_for_aws_bedrock_auth_params(
|
||||
monkeypatch,
|
||||
):
|
||||
"""Regression: DB-stored Bedrock/SageMaker auth params like
|
||||
``aws_role_name: os.environ/BEDROCK_ASSUME_ROLE_ARN`` must resolve at
|
||||
DB-load time. PR #30867 removed request-time expansion in
|
||||
``BaseAWSLLM.get_credentials``; without DB-load resolution the literal
|
||||
string reaches STS and fails with ``ValidationError: ... is invalid``."""
|
||||
aws_env = {
|
||||
"aws_session_token": ("BEDROCK_SESSION_TOKEN", "resolved-session-token"),
|
||||
"aws_region_name": ("BEDROCK_REGION", "us-east-1"),
|
||||
"aws_session_name": ("BEDROCK_SESSION_NAME", "resolved-session"),
|
||||
"aws_profile_name": ("BEDROCK_PROFILE", "resolved-profile"),
|
||||
"aws_role_name": (
|
||||
"BEDROCK_ASSUME_ROLE_ARN",
|
||||
"arn:aws:iam::123456789012:role/resolved",
|
||||
),
|
||||
"aws_web_identity_token": ("BEDROCK_WEB_IDENTITY_TOKEN", "resolved-token"),
|
||||
"aws_sts_endpoint": (
|
||||
"BEDROCK_STS_ENDPOINT",
|
||||
"https://sts.us-east-1.amazonaws.com",
|
||||
),
|
||||
"aws_external_id": ("BEDROCK_EXTERNAL_ID", "resolved-external-id"),
|
||||
"aws_bedrock_runtime_endpoint": (
|
||||
"BEDROCK_RUNTIME_ENDPOINT",
|
||||
"https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
),
|
||||
"aws_bedrock_project_id": ("BEDROCK_PROJECT_ID", "resolved-project-id"),
|
||||
"aws_batch_role_arn": (
|
||||
"BEDROCK_BATCH_ROLE_ARN",
|
||||
"arn:aws:iam::123456789012:role/batch",
|
||||
),
|
||||
"aws_workspace_id": ("BEDROCK_WORKSPACE_ID", "resolved-workspace-id"),
|
||||
}
|
||||
for _, (env_name, env_value) in aws_env.items():
|
||||
monkeypatch.setenv(env_name, env_value)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.decrypt_value_helper",
|
||||
lambda value, key, return_original_value: value,
|
||||
)
|
||||
fake_router = MagicMock()
|
||||
fake_router.upsert_deployment = MagicMock(return_value=True)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", fake_router)
|
||||
pc = ProxyConfig()
|
||||
litellm_params: Dict[str, Any] = {"model": "bedrock/anthropic.claude-v2"}
|
||||
for key, (env_name, _) in aws_env.items():
|
||||
litellm_params[key] = f"os.environ/{env_name}"
|
||||
db_model = SimpleNamespace(
|
||||
model_id="model-1",
|
||||
model_name="bedrock-model",
|
||||
model_info={"id": "model-1"},
|
||||
litellm_params=litellm_params,
|
||||
blocked=False,
|
||||
)
|
||||
|
||||
added = pc._add_deployment(db_models=[db_model])
|
||||
deployment = fake_router.upsert_deployment.call_args.kwargs["deployment"]
|
||||
|
||||
assert added == 1
|
||||
for key, (_, expected) in aws_env.items():
|
||||
assert getattr(deployment.litellm_params, key) == expected, key
|
||||
|
||||
|
||||
def test_ProxyConfig__add_deployment_resolves_env_refs_on_arbitrary_field(monkeypatch):
|
||||
"""A made-up field name that was never on the removed whitelist still
|
||||
resolves ``os.environ/`` refs. Pins the "no whitelist" invariant:
|
||||
the resolver applies to every string field, not a curated list."""
|
||||
monkeypatch.setenv("SOME_CUSTOM_ENV", "resolved-custom-value")
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.decrypt_value_helper",
|
||||
lambda value, key, return_original_value: value,
|
||||
)
|
||||
fake_router = MagicMock()
|
||||
fake_router.upsert_deployment = MagicMock(return_value=True)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", fake_router)
|
||||
pc = ProxyConfig()
|
||||
db_model = SimpleNamespace(
|
||||
model_id="model-1",
|
||||
model_name="custom-field-model",
|
||||
model_info={"id": "model-1"},
|
||||
litellm_params={
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"some_future_field": "os.environ/SOME_CUSTOM_ENV",
|
||||
},
|
||||
blocked=False,
|
||||
)
|
||||
|
||||
added = pc._add_deployment(db_models=[db_model])
|
||||
deployment = fake_router.upsert_deployment.call_args.kwargs["deployment"]
|
||||
|
||||
assert added == 1
|
||||
assert deployment.litellm_params.some_future_field == "resolved-custom-value"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ProxyConfig.decrypt_model_list_from_db
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1000,6 +1101,9 @@ def test_ProxyConfig_decrypt_model_list_from_db_returns_decrypted(monkeypatch):
|
|||
def test_ProxyConfig_decrypt_model_list_from_db_resolves_env_refs_after_db_decrypt(
|
||||
monkeypatch,
|
||||
):
|
||||
"""Path B (feeding /v2/model/info fallback and /model/info fallback)
|
||||
resolves every ``os.environ/`` field on admin-scoped rows, mirroring
|
||||
path A. Both paths now share the same universal-resolution shape."""
|
||||
monkeypatch.setenv("LITELLM_DB_MODEL_API_KEY", "resolved-secret")
|
||||
monkeypatch.setenv("LITELLM_MASTER_KEY", "master-secret")
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -1026,15 +1130,16 @@ def test_ProxyConfig_decrypt_model_list_from_db_resolves_env_refs_after_db_decry
|
|||
out = pc.decrypt_model_list_from_db(new_models=[m])
|
||||
|
||||
assert out[0]["litellm_params"]["api_key"] == "resolved-secret"
|
||||
assert out[0]["litellm_params"]["api_base"] == "os.environ/LITELLM_MASTER_KEY"
|
||||
assert out[0]["litellm_params"]["api_base"] == "master-secret"
|
||||
|
||||
|
||||
def test_ProxyConfig_decrypt_model_list_from_db_keeps_team_env_refs_literal_after_db_decrypt(
|
||||
def test_ProxyConfig_decrypt_model_list_from_db_resolves_team_env_refs_after_db_decrypt(
|
||||
monkeypatch,
|
||||
):
|
||||
def fail_on_call(secret_name, *args, **kwargs):
|
||||
raise AssertionError("team DB models should not resolve env refs")
|
||||
|
||||
"""Team-scoped rows on path B resolve ``os.environ/`` refs just like
|
||||
admin rows do. Pairs with
|
||||
``test_ProxyConfig__add_deployment_resolves_team_env_refs`` on path
|
||||
A — both paths now agree on the trust model."""
|
||||
monkeypatch.setenv("LITELLM_MASTER_KEY", "master-secret")
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.decrypt_value_helper",
|
||||
|
|
@ -1042,7 +1147,6 @@ def test_ProxyConfig_decrypt_model_list_from_db_keeps_team_env_refs_literal_afte
|
|||
"os.environ/LITELLM_MASTER_KEY" if key == "api_key" else value
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.get_secret", fail_on_call)
|
||||
pc = ProxyConfig()
|
||||
m = SimpleNamespace(
|
||||
model_id="model-1",
|
||||
|
|
@ -1050,7 +1154,7 @@ def test_ProxyConfig_decrypt_model_list_from_db_keeps_team_env_refs_literal_afte
|
|||
model_info={"id": "model-1", "team_id": "team-1"},
|
||||
litellm_params={
|
||||
"api_key": "encrypted-env-ref",
|
||||
"api_base": "https://attacker.example",
|
||||
"api_base": "https://team.example",
|
||||
"model": "openai/gpt-4o-mini",
|
||||
},
|
||||
blocked=False,
|
||||
|
|
@ -1058,8 +1162,8 @@ def test_ProxyConfig_decrypt_model_list_from_db_keeps_team_env_refs_literal_afte
|
|||
|
||||
out = pc.decrypt_model_list_from_db(new_models=[m])
|
||||
|
||||
assert out[0]["litellm_params"]["api_key"] == "os.environ/LITELLM_MASTER_KEY"
|
||||
assert out[0]["litellm_params"]["api_base"] == "https://attacker.example"
|
||||
assert out[0]["litellm_params"]["api_key"] == "master-secret"
|
||||
assert out[0]["litellm_params"]["api_base"] == "https://team.example"
|
||||
|
||||
|
||||
def test_ProxyConfig_decrypt_model_list_from_db_invalid_params_skips():
|
||||
|
|
|
|||
4
uv.lock
generated
4
uv.lock
generated
|
|
@ -9,7 +9,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-06-23T00:31:52.495979Z"
|
||||
exclude-newer = "2026-07-05T22:43:25.371327Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[manifest]
|
||||
|
|
@ -3232,7 +3232,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.91.0"
|
||||
version = "1.91.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue