feat(proxy): let a key reference a callback_settings definition by name

A callback_settings entry with a callback_type other than generic_api is a
named definition. A key's logging entry naming it runs that backend with the
definition's values, which the config loader resolved through the secret
manager at boot, so the key stores no credential.

A reference entry carries the name and event type only, and is the only entry
on the key for its backend, so no key-side value can merge with the
definition's credentials. Only a proxy admin may point a key at a definition
or change the logging list of a key that holds one, on /key/generate,
/key/update, /key/{key}/regenerate and the metadata of /user/new. Boot fails
on a definition named after a callback, with an unknown callback_type, a
field no callback accepts, or a secret the secret manager cannot provide,
naming the field and never its value
This commit is contained in:
Jesus Velarde 2026-11-06 09:35:11 -08:00
parent bbc7de1ccb
commit e44e42dfe4
10 changed files with 862 additions and 9 deletions

View file

@ -6,10 +6,14 @@ per-integration checks here.
"""
import math
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Callable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, cast # noqa: TID251 # preserve arbitrary keys from validated callback mappings
from typing import Final, TypeAlias, cast # noqa: TID251 # preserve arbitrary keys from validated callback mappings
from pydantic import TypeAdapter
import litellm
from litellm.types.utils import CAPTURE_MESSAGE_CONTENT_VAR, EXCLUDED_SERVICES_VAR
_NEWRELIC_CALLBACK: Final = "newrelic"
@ -21,6 +25,148 @@ _ARIZE_OTLP_PROTOCOL_VAR: Final = "arize_otlp_protocol"
_ARIZE_SAMPLING_RATE_VARS: Final[frozenset[str]] = frozenset(
{"arize_success_sampling_rate", "arize_error_sampling_rate"}
)
_GENERIC_API_CALLBACK_TYPE: Final = "generic_api"
_CALLBACK_TYPE_FIELD: Final = "callback_type"
_SECRET_REFERENCE_PREFIX: Final = "os.environ/"
_STRING_KEYED_MAPPING: Final = TypeAdapter(Mapping[str, object])
LoggingEntryKey: TypeAlias = tuple[str | None, str | None, tuple[tuple[str, str], ...]]
@dataclass(frozen=True, slots=True)
class CallbackDefinition:
"""A ``callback_settings`` entry a key's ``logging`` entry may reference by name.
``backend`` is the callback it runs on and ``callback_vars`` the values the config
loader resolved at boot, secrets included, so the key stores only the name.
"""
name: str
backend: str
callback_vars: Mapping[str, str]
def callback_definition(callback_name: object) -> CallbackDefinition | None:
"""The definition ``callback_name`` references, or None when it is a plain callback name."""
if not isinstance(callback_name, str):
return None
return _parsed_definition(callback_name, litellm.callback_settings.get(callback_name))
def _parsed_definition(name: str, raw: object) -> CallbackDefinition | None:
if not isinstance(raw, Mapping):
return None
definition: Final = _STRING_KEYED_MAPPING.validate_python(raw)
backend: Final = definition.get(_CALLBACK_TYPE_FIELD)
if not isinstance(backend, str) or backend == _GENERIC_API_CALLBACK_TYPE:
return None
return CallbackDefinition(
name=name,
backend=backend,
callback_vars=MappingProxyType(
{
key: "" if value is None else str(value)
for key, value in definition.items()
if key != _CALLBACK_TYPE_FIELD
}
),
)
def callback_backend_name(callback_name: str) -> str:
"""The callback a ``logging`` entry runs on: its definition's backend for a reference, else its own name."""
definition: Final = callback_definition(callback_name)
return callback_name if definition is None else definition.backend
def callback_definitions_error(callback_settings: Mapping[str, object]) -> str | None:
"""Why a ``callback_settings`` definition could never serve a key, checked at config load.
A typo in a field or secret name would otherwise surface only on the first request
of a key that already depends on the definition. Errors name the field, never its value.
"""
definitions: Final = (
definition
for name, raw in callback_settings.items()
if (definition := _parsed_definition(name, raw)) is not None
)
return next((error for definition in definitions if (error := _definition_error(definition)) is not None), None)
def _definition_error(definition: CallbackDefinition) -> str | None:
known_callbacks: Final = litellm._known_custom_logger_compatible_callbacks # pyright: ignore[reportPrivateUsage] # the registry of callback names has no public accessor
if definition.name in known_callbacks:
return (
f"callback_settings.{definition.name} is named after the {definition.name} callback, "
"so a key could not tell them apart"
)
if definition.backend not in known_callbacks:
return f"callback_settings.{definition.name}.callback_type {definition.backend!r} is not a known callback"
unresolved: Final = next(
(
key
for key, value in definition.callback_vars.items()
if value == "" or value.startswith(_SECRET_REFERENCE_PREFIX)
),
None,
)
if unresolved is not None:
return (
f"callback_settings.{definition.name}.{unresolved} is empty or could not be resolved "
"from the secret manager"
)
return _definition_backend_error(definition)
def _definition_backend_error(definition: CallbackDefinition) -> str | None:
from pydantic import ValidationError
from litellm.proxy._types import AddTeamCallback
try:
validated: Final = AddTeamCallback.model_validate(
{"callback_name": definition.backend, "callback_vars": dict(definition.callback_vars)}
)
except ValidationError as e:
return f"callback_settings.{definition.name}: " + "; ".join(error["msg"] for error in e.errors())
error: Final = callback_config_error(definition.backend, validated.callback_vars)
return None if error is None else f"callback_settings.{definition.name}: {error}"
def callback_reference_error(logging_entries: object) -> str | None:
"""A ``callback_settings`` reference is a name only, and the only entry for its backend.
Every entry's vars merge into one map per backend at request time, so a value on the
reference or on a sibling naming the same backend could point the definition's
credentials somewhere else.
"""
entries: Final = stored_callback_entries(logging_entries)
backends: Final = tuple(None if name is None else callback_backend_name(name) for name, _ in entries)
return next((error for error in _reference_errors(entries, backends) if error is not None), None)
def _reference_errors(
entries: Sequence[tuple[str | None, Mapping[str, str]]], backends: Sequence[str | None]
) -> Iterator[str | None]:
for (name, callback_vars), backend in zip(entries, backends, strict=True):
if name is None or callback_definition(name) is None:
continue
if callback_vars:
yield f"callback_settings.{name} is referenced by name only, so the entry may not carry callback_vars"
elif backends.count(backend) > 1:
yield (
f"callback_settings.{name} already provides the {backend} callback for this key, "
f"so no other {backend} entry may sit next to it"
)
def logging_entry_keys(logging_entries: object) -> tuple[LoggingEntryKey, ...]:
"""Every ``logging`` entry as a comparable value: name, event type and sorted vars, so no field changes unnoticed."""
entries: Final = _object_sequence(logging_entries) or ()
return tuple(
(_entry_callback_name(entry), _entry_callback_type(entry), tuple(sorted(_entry_callback_vars(entry).items())))
for entry in entries
)
def callback_config_error(
@ -279,6 +425,9 @@ def logging_metadata_config_error(metadata: Mapping[str, object] | None) -> str
entries: Final = _object_sequence(metadata.get("logging"))
if entries is None:
return None
reference_error: Final = callback_reference_error(entries)
if reference_error is not None:
return reference_error
entry_error: Final = next(
(error for entry in entries if (error := _logging_entry_error(entry)) is not None),
None,
@ -318,6 +467,14 @@ def _entry_callback_name(entry: object) -> str | None:
return None
def _entry_callback_type(entry: object) -> str | None:
match entry:
case {"callback_type": str() as callback_type}:
return callback_type
case _:
return None
def _conflicting_entry_setting_error(
entry: tuple[str | None, Mapping[str, str]], earlier: Sequence[tuple[str | None, Mapping[str, str]]]
) -> str | None:

View file

@ -77,6 +77,7 @@ from litellm.proxy._types import (
)
from litellm.proxy.auth.auth_utils import get_request_route
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.common_utils.callback_config_validation import callback_backend_name, callback_definition
from litellm.proxy.common_utils.callback_utils import (
decrypt_callback_vars,
get_metadata_variable_name_from_kwargs,
@ -1243,6 +1244,69 @@ def get_validated_callback_metadata(item: dict, *, source: str) -> AddTeamCallba
_get_validated_callback_metadata: Final = get_validated_callback_metadata
_STRING_KEYED_MAPPING: Final = TypeAdapter(Mapping[str, object])
def resolved_key_callbacks(logging_entries: object, *, source: str) -> tuple[AddTeamCallback, ...]:
"""A key's ``logging`` entries as the backends see them, each ``callback_settings`` reference swapped for its definition.
A reference is honoured only while no other entry on the key names its backend: every
entry's vars merge into one map per backend, so a sibling could point the definition's
credentials at its own host. The reference's own ``callback_vars`` are never read.
"""
if not isinstance(logging_entries, Sequence) or isinstance(logging_entries, (str, bytes)):
return ()
entries: Final = tuple(
_STRING_KEYED_MAPPING.validate_python(item)
for item in _OBJECT_LIST_ADAPTER.validate_python(logging_entries)
if isinstance(item, Mapping)
)
backends: Final = tuple(_entry_backend(entry) for entry in entries)
return tuple(
callback
for entry, backend in zip(entries, backends, strict=True)
if (callback := _resolved_key_callback(entry, source=source, shared_backend=backends.count(backend) > 1))
is not None
)
def _entry_backend(entry: Mapping[str, object]) -> str | None:
callback_name: Final = entry.get("callback_name")
return callback_backend_name(callback_name) if isinstance(callback_name, str) else None
def _resolved_key_callback(entry: Mapping[str, object], *, source: str, shared_backend: bool) -> AddTeamCallback | None:
definition: Final = callback_definition(entry.get("callback_name"))
if definition is None:
return get_validated_callback_metadata(item=dict(entry), source=source)
if shared_backend:
verbose_proxy_logger.warning(
"Ignoring %s callback metadata naming callback_settings.%s: another entry on this key names the %s callback",
source,
_sanitize_for_log(definition.name),
_sanitize_for_log(definition.backend),
)
return None
reference: Final = get_validated_callback_metadata(item={**entry, "callback_vars": {}}, source=source)
if reference is None:
return None
return reference.model_copy(
update={"callback_name": definition.backend, "callback_vars": dict(definition.callback_vars)}
)
def _validated_callbacks(logging_entries: Iterable[object], *, source: str) -> tuple[AddTeamCallback, ...]:
return tuple(
callback
for item in logging_entries
if (
callback := get_validated_callback_metadata(
item=dict(_STRING_KEYED_MAPPING.validate_python(item)), source=source
)
)
is not None
)
class KeyAndTeamLoggingSettings:
"""
@ -1276,10 +1340,7 @@ def get_dynamic_logging_metadata(
# Key-based callbacks
#########################################################################################
if key_dynamic_logging_settings is not None:
for item in key_dynamic_logging_settings:
callback = get_validated_callback_metadata(item=item, source="key-level")
if callback is None:
continue
for callback in resolved_key_callbacks(key_dynamic_logging_settings, source="key-level"):
callback_settings_obj = convert_key_logging_metadata_to_callback(
data=callback,
team_callback_settings_obj=callback_settings_obj,
@ -1443,8 +1504,11 @@ def resolve_tenant_otel_destinations(
disabled: Final = _dynamically_disabled_backends(user_api_key_dict, request_headers)
callbacks: Final = tuple(
callback
for item in entries
if (callback := get_validated_callback_metadata(item=item, source="otel-destination")) is not None
for callback in (
resolved_key_callbacks(entries, source="otel-destination")
if key_entries is not None
else _validated_callbacks(entries, source="otel-destination")
)
if callback.callback_name.lower() not in disabled
)
return tuple(

View file

@ -76,6 +76,8 @@ from litellm.proxy.management_endpoints.key_management_endpoints import ( # noq
check_permissions_caller_permission,
generate_key_helper_fn,
prepare_metadata_fields,
raise_on_invalid_key_logging_config,
raise_on_unauthorized_callback_reference,
)
from litellm.proxy.management_helpers.bulk_user_deletion import delete_users_from_current_teams
from litellm.proxy.management_helpers.object_permission_utils import ( # noqa: F401 # legacy module exports
@ -594,6 +596,11 @@ async def new_user(
if data.auto_create_key and isinstance(user_api_key_dict, UserAPIKeyAuth):
enforce_batch_limits_are_admin_only(data, None, user_api_key_dict, "key")
raise_on_invalid_key_logging_config(data.metadata) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # request models declare `metadata` as bare dict
raise_on_unauthorized_callback_reference(
data.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # request models declare `metadata` as bare dict
user_api_key_dict,
)
check_permissions_caller_permission(
data=data,

View file

@ -67,7 +67,11 @@ from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
evict_and_broadcast,
publish_auth_cache_invalidation,
)
from litellm.proxy.common_utils.callback_config_validation import logging_metadata_config_error
from litellm.proxy.common_utils.callback_config_validation import (
callback_definition,
logging_entry_keys,
logging_metadata_config_error,
)
from litellm.proxy.common_utils.callback_utils import (
decrypt_callback_vars,
encrypt_callback_vars,
@ -433,6 +437,7 @@ _KEY_METADATA_REQUEST_FIELDS: Final = frozenset(
_DECODED_JSON: Final = TypeAdapter(object)
_STRING_KEYED_MAPPING: Final = TypeAdapter(Mapping[str, object])
def _decode_json_string_column(column: str, value: object) -> object:
@ -933,12 +938,49 @@ def raise_on_invalid_key_logging_config(metadata: Mapping[str, object] | None) -
raise HTTPException(status_code=400, detail={"error": error})
def raise_on_unauthorized_callback_reference(
metadata: Mapping[str, object] | None,
user_api_key_dict: UserAPIKeyAuth,
*,
existing_metadata: Mapping[str, object] | None = None,
) -> None:
"""Only a proxy admin may point a key at a ``callback_settings`` definition or change the logging of a key holding one.
The definition carries the operator's credentials, so the team admins and members who
can otherwise edit key metadata may only send such a key's ``logging`` list back unchanged.
"""
if metadata is None or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
return
requested: Final = logging_entry_keys(metadata.get("logging"))
stored: Final = logging_entry_keys(_stored_logging_entries(existing_metadata))
if requested == stored or not any(callback_definition(name) is not None for name, _, _ in (*requested, *stored)):
return
raise HTTPException(
status_code=403,
detail={
"error": (
"Only a proxy admin can point a key at a callback_settings definition "
"or change the logging entries of a key that holds one"
)
},
)
def _stored_logging_entries(existing_metadata: Mapping[str, object] | None) -> object:
"""The stored ``logging`` list with its callback vars decrypted, since ciphertext never equals the plaintext sent back."""
if existing_metadata is None:
return None
decrypted: Final = _DECODED_JSON.validate_python(decrypt_callback_vars(existing_metadata))
return _STRING_KEYED_MAPPING.validate_python(decrypted).get("logging") if isinstance(decrypted, Mapping) else None
def common_key_access_checks(
user_api_key_dict: UserAPIKeyAuth,
data: GenerateKeyRequest | UpdateKeyRequest,
llm_router: Router | None,
premium_user: bool,
user_id: str | None = None,
existing_metadata: Mapping[str, object] | None = None,
) -> Literal[True]:
"""
Check if user is allowed to make a key request, for this key
@ -965,6 +1007,11 @@ def common_key_access_checks(
llm_router=llm_router,
premium_user=premium_user,
)
raise_on_unauthorized_callback_reference(
data.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # request models declare `metadata` as bare dict
user_api_key_dict,
existing_metadata=existing_metadata,
)
return True
@ -3773,6 +3820,7 @@ async def _validate_update_key_data(
user_id=user_api_key_dict.user_id if acting_as_team_admin else existing_key_row.user_id,
llm_router=llm_router,
premium_user=premium_user,
existing_metadata=existing_key_row.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # LiteLLM_VerificationToken.metadata is a bare dict
)
await TeamMemberPermissionChecks.can_team_member_execute_key_management_endpoint(
@ -6652,6 +6700,11 @@ async def regenerate_key_fn(
_key_in_db.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # LiteLLM_VerificationToken.metadata is a bare dict
user_api_key_dict,
)
raise_on_unauthorized_callback_reference(
None if data is None else data.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # request models declare `metadata` as bare dict
user_api_key_dict,
existing_metadata=_key_in_db.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # LiteLLM_VerificationToken.metadata is a bare dict
)
# check if user has permission to regenerate key
await TeamMemberPermissionChecks.can_team_member_execute_key_management_endpoint(

View file

@ -168,6 +168,7 @@ from litellm.proxy._types import (
TransformRequestBody,
UserAPIKeyAuth,
)
from litellm.proxy.common_utils.callback_config_validation import callback_definitions_error
from litellm.proxy.common_utils.callback_utils import (
is_sensitive_callback_key,
normalize_callback_names,
@ -5862,6 +5863,16 @@ def _configured_db_callbacks(litellm_settings: Mapping[str, object], setting_key
return tuple((setting_key, callback) for callback in callbacks if isinstance(callback, str))
_CALLBACK_SETTINGS: Final = TypeAdapter(Mapping[str, object])
def _check_callback_definitions(callback_settings: Mapping[str, object]) -> None:
"""Refuse to boot on a ``callback_settings`` definition a key could never use."""
definition_error: Final = callback_definitions_error(callback_settings)
if definition_error is not None:
raise ValueError(definition_error)
class ProxyConfig:
"""
Abstraction class on top of config loading/updating logic. Gives us one place to control all config updating logic.
@ -6785,6 +6796,7 @@ class ProxyConfig:
## Callback settings
callback_settings: Final = config.get("callback_settings", {})
if callback_settings:
_check_callback_definitions(_CALLBACK_SETTINGS.validate_python(callback_settings))
litellm.callback_settings = callback_settings
## LITELLM MODULE SETTINGS (e.g. litellm.drop_params=True,..)

View file

@ -3,11 +3,16 @@ from typing import Final
import pytest
import litellm
from litellm.proxy.common_utils.callback_config_validation import (
callback_backend_name,
callback_config_error,
callback_definition,
callback_definitions_error,
conflicting_callback_var_error,
conflicting_span_scope_error,
cross_entry_family_error,
logging_entry_keys,
logging_metadata_config_error,
)
@ -499,3 +504,158 @@ def test_key_logging_entries_compare_excluded_services_by_parsed_value(
def test_a_failure_only_entry_rejects_excluded_services() -> None:
error: Final = callback_config_error("langfuse_otel", {"excluded_services": "redis"}, "failure")
assert error is not None and "excluded_services" in error and "success_and_failure" in error
_ARIZE_DEFINITION: Final = {
"callback_type": "arize",
"arize_api_key": "resolved-space-a-key",
"arize_space_id": "space-a",
}
@pytest.fixture
def arize_space_a(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "callback_settings", {"arize_space_a": dict(_ARIZE_DEFINITION)})
@pytest.mark.parametrize(
("callback_settings", "named_fields"),
[
({"arize": {"callback_type": "arize", "arize_api_key": "k"}}, ("callback_settings.arize", "named after")),
(
{"space_a": {"callback_type": "not_a_callback"}},
("callback_settings.space_a.callback_type", "not_a_callback"),
),
({"space_a": {"callback_type": "arize", "arize_api_key": None}}, ("callback_settings.space_a.arize_api_key",)),
({"space_a": {"callback_type": "arize", "arize_api_key": ""}}, ("callback_settings.space_a.arize_api_key",)),
(
{"space_a": {"callback_type": "arize", "arize_space_id": "os.environ/UNSET"}},
("callback_settings.space_a.arize_space_id", "secret manager"),
),
({"space_a": {"callback_type": "arize", "arize_api_kye": "k"}}, ("callback_settings.space_a", "arize_api_kye")),
(
{"space_a": {"callback_type": "arize", "arize_otlp_protocol": "carrier-pigeon"}},
("callback_settings.space_a", "arize_otlp_protocol"),
),
],
)
def test_a_definition_a_key_could_never_use_fails_config_load(
callback_settings: dict[str, object], named_fields: tuple[str, ...]
) -> None:
error: Final = callback_definitions_error(callback_settings)
assert error is not None
for fragment in named_fields:
assert fragment in error
def test_a_definition_error_names_the_field_and_never_its_value() -> None:
error: Final = callback_definitions_error(
{
"space_a": {
"callback_type": "arize",
"arize_otlp_protocol": "carrier-pigeon",
"arize_api_key": "sk-super-secret",
}
}
)
assert error is not None and "arize_otlp_protocol" in error
assert "sk-super-secret" not in error
@pytest.mark.usefixtures("otel_v2_on")
def test_callback_settings_without_a_referenceable_definition_pass_config_load() -> None:
assert (
callback_definitions_error(
{
"otel": {"message_logging": False, "excluded_services": ["postgres"]},
"my_api": {"callback_type": "generic_api", "url": "os.environ/UNSET"},
"space_a": {
"callback_type": "arize",
"arize_api_key": "k",
"arize_space_id": 12345,
"excluded_services": "postgres,redis",
},
}
)
is None
)
@pytest.mark.usefixtures("arize_space_a")
def test_a_reference_resolves_to_its_definition_and_a_plain_name_does_not() -> None:
definition: Final = callback_definition("arize_space_a")
assert definition is not None
assert (definition.backend, dict(definition.callback_vars)) == (
"arize",
{"arize_api_key": "resolved-space-a-key", "arize_space_id": "space-a"},
)
assert callback_definition("arize") is None
assert callback_definition(None) is None
assert (callback_backend_name("arize_space_a"), callback_backend_name("langfuse")) == ("arize", "langfuse")
@pytest.mark.usefixtures("arize_space_a")
def test_a_reference_entry_may_not_carry_callback_vars() -> None:
error: Final = logging_metadata_config_error(
{
"logging": [
{
"callback_name": "arize_space_a",
"callback_type": "success",
"callback_vars": {"arize_space_id": "attacker-space"},
}
]
}
)
assert error is not None and "arize_space_a" in error and "callback_vars" in error
@pytest.mark.usefixtures("arize_space_a")
def test_a_reference_is_the_only_entry_for_its_backend() -> None:
sibling: Final = {"callback_name": "arize", "callback_type": "success", "callback_vars": {"arize_space_id": "x"}}
reference: Final = {"callback_name": "arize_space_a", "callback_type": "success"}
error: Final = logging_metadata_config_error({"logging": [reference, sibling]})
assert error is not None and "arize_space_a" in error and "arize" in error
assert logging_metadata_config_error({"logging": [sibling]}) is None
@pytest.mark.usefixtures("arize_space_a")
def test_a_reference_sits_next_to_entries_for_other_backends() -> None:
assert (
logging_metadata_config_error(
{
"logging": [
{"callback_name": "arize_space_a", "callback_type": "success"},
{"callback_name": "arize_space_a", "callback_type": "failure", "callback_vars": {}},
{"callback_name": "langfuse", "callback_vars": {"langfuse_public_key": "pk"}},
]
}
)
is not None
), "two references to one definition still share its backend"
assert (
logging_metadata_config_error(
{
"logging": [
{"callback_name": "arize_space_a", "callback_type": "success"},
{"callback_name": "langfuse", "callback_vars": {"langfuse_public_key": "pk"}},
]
}
)
is None
)
def test_logging_entry_keys_change_with_every_field_a_caller_could_edit() -> None:
entry: Final = {"callback_name": "arize_space_a", "callback_type": "success", "callback_vars": {"b": "2", "a": "1"}}
reordered: Final = {
"callback_type": "success",
"callback_vars": {"a": "1", "b": "2"},
"callback_name": "arize_space_a",
}
assert logging_entry_keys([entry]) == logging_entry_keys([reordered])
assert logging_entry_keys([entry]) != logging_entry_keys([{**entry, "callback_type": "failure"}])
assert logging_entry_keys([entry]) != logging_entry_keys([{**entry, "callback_vars": {"a": "1"}}])
assert logging_entry_keys([entry]) != logging_entry_keys([{**entry, "callback_name": "arize"}])
assert logging_entry_keys([entry]) != logging_entry_keys([entry, entry])
assert logging_entry_keys(None) == logging_entry_keys("not-a-list") == ()

View file

@ -16,6 +16,8 @@ from fastapi.testclient import TestClient
from pydantic import ValidationError
from pytest_mock import MockerFixture
import litellm
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import (
LiteLLM_UserTable,
@ -5496,3 +5498,42 @@ async def test_fips_user_update_hashes_off_the_event_loop(pbkdf2_on_event_loop:
assert pbkdf2_on_event_loop == [False]
assert data["password"].startswith("pbkdf2:")
@pytest.mark.asyncio
async def test_new_user_cannot_hand_its_auto_created_key_a_callback_reference_for_a_non_admin(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``/user/new`` writes ``metadata`` onto the key it auto-creates, so it runs the same admin gate as ``/key/generate``."""
from litellm.proxy import proxy_server
from litellm.proxy.utils import PrismaClient
monkeypatch.setattr(
litellm,
"callback_settings",
{"arize_space_a": {"callback_type": "arize", "arize_api_key": "k", "arize_space_id": "s"}},
)
table: Final = SimpleNamespace(
find_first=AsyncMock(return_value=None),
count=AsyncMock(return_value=0),
create=AsyncMock(),
upsert=AsyncMock(),
)
mocker.patch("prisma.Prisma", return_value=SimpleNamespace(litellm_usertable=table))
database: Final = PrismaClient(
database_url="postgresql://unused:unused@localhost/unused",
proxy_logging_obj=proxy_server.proxy_logging_obj,
)
mocker.patch("litellm.proxy.proxy_server.prisma_client", database)
reference: Final = {"logging": [{"callback_name": "arize_space_a", "callback_type": "success"}]}
with pytest.raises(ProxyException) as exc:
await new_user(
data=NewUserRequest(user_email="bob@example.test", metadata=reference, send_invite_email=False),
user_api_key_dict=UserAPIKeyAuth(user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER),
)
assert str(exc.value.code) == "403"
assert "callback_settings" in exc.value.message
table.create.assert_not_awaited()
table.upsert.assert_not_awaited()

View file

@ -50,6 +50,7 @@ from litellm.proxy.auth.auth_checks import (
jwt_key_mapping_cache_key,
)
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth
from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import AuthCacheInvalidationSubscriber
from litellm.proxy.common_utils.user_api_key_cache import (
UserApiKeyCache,
@ -87,6 +88,7 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
_validate_max_budget,
_validate_reset_spend_value,
_validate_update_key_data,
raise_on_unauthorized_callback_reference,
can_modify_verification_token,
check_org_key_model_specific_limits,
check_team_key_model_specific_limits,
@ -24243,3 +24245,187 @@ async def test_key_aliases_answers_503_when_the_database_is_unreachable(monkeypa
)
assert (exc_info.value.code, exc_info.value.type) == ("503", "no_db_connection")
_REFERENCE_ENTRY: Final = {"callback_name": "arize_space_a", "callback_type": "success"}
_LANGFUSE_ENTRY: Final = {
"callback_name": "langfuse",
"callback_type": "success",
"callback_vars": {"langfuse_public_key": "pk", "langfuse_secret_key": "sk-plain"},
}
@pytest.fixture
def arize_space_a(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
litellm,
"callback_settings",
{"arize_space_a": {"callback_type": "arize", "arize_api_key": "k", "arize_space_id": "s"}},
)
def _non_admin() -> UserAPIKeyAuth:
return UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, api_key="sk-alice", user_id="alice")
@pytest.mark.usefixtures("arize_space_a")
class TestCallbackReferenceWriteGate:
def test_a_proxy_admin_may_point_a_key_at_a_definition(self) -> None:
admin: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin")
assert raise_on_unauthorized_callback_reference({"logging": [_REFERENCE_ENTRY]}, admin) is None
assert (
raise_on_unauthorized_callback_reference(
{"logging": []}, admin, existing_metadata={"logging": [_REFERENCE_ENTRY]}
)
is None
)
@pytest.mark.parametrize(
"metadata",
[
{"logging": [_REFERENCE_ENTRY]},
{"logging": [_LANGFUSE_ENTRY, _REFERENCE_ENTRY]},
{"logging": [{**_REFERENCE_ENTRY, "callback_vars": {}}]},
],
)
def test_a_non_admin_cannot_point_a_key_at_a_definition(self, metadata: Mapping[str, object]) -> None:
with pytest.raises(HTTPException) as exc:
raise_on_unauthorized_callback_reference(metadata, _non_admin())
assert exc.value.status_code == 403
assert "callback_settings" in str(exc.value.detail)
@pytest.mark.parametrize(
"metadata",
[
{"note": "edited"},
{"logging": []},
{"logging": [{**_REFERENCE_ENTRY, "callback_type": "failure"}]},
{"logging": [{**_REFERENCE_ENTRY, "callback_name": "arize"}]},
{"logging": [_REFERENCE_ENTRY, _LANGFUSE_ENTRY]},
{"logging": [{**_LANGFUSE_ENTRY, "callback_vars": {"langfuse_host": "https://attacker.example"}}]},
],
)
def test_a_non_admin_cannot_change_the_logging_of_a_key_holding_a_reference(
self, metadata: Mapping[str, object]
) -> None:
with pytest.raises(HTTPException) as exc:
raise_on_unauthorized_callback_reference(
metadata, _non_admin(), existing_metadata={"logging": [_REFERENCE_ENTRY]}
)
assert exc.value.status_code == 403
def test_a_non_admin_may_edit_the_rest_of_the_metadata_when_the_logging_is_sent_back_unchanged(self) -> None:
stored: Final = encrypt_callback_vars({"note": "original", "logging": [_REFERENCE_ENTRY, _LANGFUSE_ENTRY]})
assert (
raise_on_unauthorized_callback_reference(
{"note": "edited", "logging": [_REFERENCE_ENTRY, _LANGFUSE_ENTRY]},
_non_admin(),
existing_metadata=stored,
)
is None
)
@pytest.mark.parametrize("existing_metadata", [None, {}, {"logging": [_LANGFUSE_ENTRY]}])
def test_a_key_without_a_reference_keeps_the_logging_rules_it_had(
self, existing_metadata: Mapping[str, object] | None
) -> None:
changed: Final = [
{**_LANGFUSE_ENTRY, "callback_type": "failure"},
{"callback_name": "datadog", "callback_vars": {}},
]
assert (
raise_on_unauthorized_callback_reference(
{"logging": changed}, _non_admin(), existing_metadata=existing_metadata
)
is None
)
assert (
raise_on_unauthorized_callback_reference(
None, _non_admin(), existing_metadata={"logging": [_REFERENCE_ENTRY]}
)
is None
)
@pytest.mark.asyncio
@pytest.mark.usefixtures("arize_space_a")
async def test_generate_key_with_a_callback_reference_is_admin_only() -> None:
with patch("litellm.proxy.proxy_server.prisma_client", AsyncMock()):
with pytest.raises(HTTPException) as exc:
await _common_key_generation_helper(
data=GenerateKeyRequest(metadata={"logging": [_REFERENCE_ENTRY]}),
user_api_key_dict=_non_admin(),
litellm_changed_by=None,
team_table=None,
)
assert exc.value.status_code == 403
assert "callback_settings" in str(exc.value.detail)
@pytest.mark.asyncio
@pytest.mark.usefixtures("arize_space_a")
async def test_generate_key_rejects_callback_vars_on_a_reference_for_everyone() -> None:
with patch("litellm.proxy.proxy_server.prisma_client", AsyncMock()):
with pytest.raises(HTTPException) as exc:
await _common_key_generation_helper(
data=GenerateKeyRequest(
metadata={"logging": [{**_REFERENCE_ENTRY, "callback_vars": {"arize_space_id": "mine"}}]}
),
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"),
litellm_changed_by=None,
team_table=None,
)
assert exc.value.status_code == 400
assert "callback_vars" in str(exc.value.detail)
@pytest.mark.asyncio
@pytest.mark.usefixtures("arize_space_a")
async def test_update_key_compares_the_sent_logging_against_the_stored_row() -> None:
existing_key_row: Final = MagicMock()
existing_key_row.user_id = "alice"
existing_key_row.token = "hashed_token"
existing_key_row.team_id = None
existing_key_row.metadata = {"logging": [_REFERENCE_ENTRY]}
with pytest.raises(HTTPException) as exc:
await _validate_update_key_data(
data=UpdateKeyRequest(key="sk-test-key", metadata={"note": "edited"}),
existing_key_row=existing_key_row,
user_api_key_dict=_non_admin(),
llm_router=None,
premium_user=False,
prisma_client=AsyncMock(),
user_api_key_cache=MagicMock(),
)
assert exc.value.status_code == 403
assert "callback_settings" in str(exc.value.detail)
@pytest.mark.asyncio
@pytest.mark.usefixtures("arize_space_a")
async def test_regenerate_key_cannot_drop_a_reference_for_a_non_admin(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy.management_endpoints.key_management_endpoints import regenerate_key_fn
key_row: Final = MagicMock()
key_row.token = "hashed_token"
key_row.user_id = "alice"
key_row.team_id = None
key_row.metadata = {"logging": [_REFERENCE_ENTRY]}
prisma: Final = MagicMock()
prisma.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=key_row)
prisma.db.litellm_verificationtoken.update = AsyncMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma)
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-master")
with pytest.raises(ProxyException) as exc:
await regenerate_key_fn(
key=None,
data=RegenerateKeyRequest(key="sk-test", metadata={"note": "edited"}),
user_api_key_dict=_non_admin(),
litellm_changed_by=None,
)
assert str(exc.value.code) == "403"
assert "callback_settings" in str(exc.value.message)
prisma.db.litellm_verificationtoken.update.assert_not_awaited()

View file

@ -6593,3 +6593,68 @@ async def test_proxy_config_validates_advertised_mcp_versions_at_load(tmp_path,
return
with pytest.raises(ValidationError):
await ProxyConfig().load_config(router=None, config_file_path=str(config))
_CALLBACK_DEFINITION_CONFIG: Final = """
model_list: []
callback_settings:
arize_space_a:
callback_type: arize
arize_api_key: os.environ/QA_ARIZE_SPACE_A_KEY
arize_space_id: os.environ/QA_ARIZE_SPACE_A_SPACE
"""
@pytest.fixture
def config_without_db(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False)
monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False)
monkeypatch.setattr(litellm, "callback_settings", {})
@pytest.mark.asyncio
@pytest.mark.usefixtures("config_without_db")
async def test_a_callback_definition_resolves_its_secrets_at_boot(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("QA_ARIZE_SPACE_A_KEY", "space-a-key-v1")
monkeypatch.setenv("QA_ARIZE_SPACE_A_SPACE", "space-a")
config_file: Final = tmp_path / "callbacks.yaml"
config_file.write_text(_CALLBACK_DEFINITION_CONFIG)
await ProxyConfig().load_config(router=None, config_file_path=str(config_file))
assert litellm.callback_settings["arize_space_a"] == {
"callback_type": "arize",
"arize_api_key": "space-a-key-v1",
"arize_space_id": "space-a",
}
@pytest.mark.asyncio
@pytest.mark.usefixtures("config_without_db")
async def test_a_callback_definition_whose_secret_is_missing_fails_boot_naming_the_field(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("QA_ARIZE_SPACE_A_KEY", "space-a-key-v1")
monkeypatch.delenv("QA_ARIZE_SPACE_A_SPACE", raising=False)
config_file: Final = tmp_path / "callbacks.yaml"
config_file.write_text(_CALLBACK_DEFINITION_CONFIG)
with pytest.raises(ValueError, match=r"callback_settings\.arize_space_a\.arize_space_id"):
await ProxyConfig().load_config(router=None, config_file_path=str(config_file))
assert litellm.callback_settings == {}
@pytest.mark.asyncio
@pytest.mark.usefixtures("config_without_db")
async def test_a_callback_definition_with_a_misspelled_field_fails_boot(tmp_path: Path) -> None:
config_file: Final = tmp_path / "callbacks.yaml"
config_file.write_text(
"model_list: []\ncallback_settings:\n arize_space_a:\n callback_type: arize\n arize_api_kye: k\n"
)
with pytest.raises(ValueError, match="arize_api_kye"):
await ProxyConfig().load_config(router=None, config_file_path=str(config_file))

View file

@ -11443,3 +11443,111 @@ def test_caller_tags_reach_litellm_metadata_whether_metadata_is_a_dict_or_a_json
LiteLLMProxyRequestSetup.merge_caller_body_tags(request_data=data)
assert set(data["litellm_metadata"]["tags"]) == {"key-tag", "client-tag"}
assert data["metadata"] == caller_metadata
_REFERENCE_ENTRY: Final = {"callback_name": "arize_space_a", "callback_type": "success"}
_ARIZE_SPACE_A: Final = {
"callback_type": "arize",
"arize_api_key": "resolved-space-a-key",
"arize_space_id": "space-a",
"arize_otlp_protocol": "http/protobuf",
"excluded_services": "postgres,redis",
}
@pytest.fixture
def arize_space_a(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "callback_settings", {"arize_space_a": dict(_ARIZE_SPACE_A)})
@pytest.fixture
def otel_v2_on(monkeypatch: pytest.MonkeyPatch):
from litellm.integrations.otel.model.config import is_otel_v2_enabled
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
monkeypatch.setenv("ARIZE_ENDPOINT", "https://arize.internal.example/v1")
monkeypatch.delenv("ARIZE_HTTP_ENDPOINT", raising=False)
is_otel_v2_enabled.cache_clear()
yield
is_otel_v2_enabled.cache_clear()
@pytest.mark.usefixtures("arize_space_a")
def test_a_key_logging_entry_naming_a_definition_runs_its_backend_with_the_definition_vars() -> None:
auth: Final = UserAPIKeyAuth(api_key="hashed-key", metadata={"logging": [_REFERENCE_ENTRY]}, team_metadata={})
result: Final = get_dynamic_logging_metadata(user_api_key_dict=auth, proxy_config=MagicMock())
assert result is not None
assert result.success_callback == ["arize"]
assert not result.failure_callback
assert result.callback_vars == {
key: value for key, value in _ARIZE_SPACE_A.items() if key not in ("callback_type", "excluded_services")
}, "excluded_services is a destination-only var, which the destination test below covers"
@pytest.mark.usefixtures("arize_space_a")
def test_the_vars_stored_on_a_reference_entry_never_reach_the_backend() -> None:
auth: Final = UserAPIKeyAuth(
api_key="hashed-key",
metadata={"logging": [{**_REFERENCE_ENTRY, "callback_vars": {"arize_space_id": "attacker-space"}}]},
team_metadata={},
)
result: Final = get_dynamic_logging_metadata(user_api_key_dict=auth, proxy_config=MagicMock())
assert result is not None and result.callback_vars is not None
assert result.callback_vars["arize_space_id"] == "space-a"
@pytest.mark.usefixtures("arize_space_a")
def test_a_reference_is_skipped_when_another_entry_on_the_key_names_its_backend() -> None:
"""A stored inline entry for the same backend would merge its host or space with the definition's
credentials, so the definition's secrets stay out of the request instead."""
sibling: Final = {
"callback_name": "arize",
"callback_type": "success",
"callback_vars": {"arize_space_id": "attacker-space", "arize_api_key": "attacker-key"},
}
auth: Final = UserAPIKeyAuth(
api_key="hashed-key", metadata={"logging": [_REFERENCE_ENTRY, sibling]}, team_metadata={}
)
result: Final = get_dynamic_logging_metadata(user_api_key_dict=auth, proxy_config=MagicMock())
assert result is not None
assert result.callback_vars == sibling["callback_vars"]
@pytest.mark.usefixtures("arize_space_a")
def test_a_team_logging_entry_naming_a_definition_is_not_resolved() -> None:
auth: Final = UserAPIKeyAuth(api_key="hashed-key", metadata={}, team_metadata={"logging": [_REFERENCE_ENTRY]})
assert get_dynamic_logging_metadata(user_api_key_dict=auth, proxy_config=MagicMock()) is None
@pytest.mark.usefixtures("arize_space_a", "otel_v2_on")
def test_a_reference_resolves_to_the_same_otel_destination_as_the_inline_entry() -> None:
from litellm.proxy.litellm_pre_call_utils import resolve_tenant_otel_destinations
inline: Final = {
"callback_name": "arize",
"callback_type": "success",
"callback_vars": {key: value for key, value in _ARIZE_SPACE_A.items() if key != "callback_type"},
}
referencing_key: Final = UserAPIKeyAuth(
api_key="hashed-key", team_id="team-a", metadata={"logging": [_REFERENCE_ENTRY]}, team_metadata={}
)
inline_key: Final = UserAPIKeyAuth(
api_key="other-key", team_id="team-a", metadata={"logging": [inline]}, team_metadata={}
)
plain_key_on_the_same_team: Final = UserAPIKeyAuth(
api_key="plain-key", team_id="team-a", metadata={}, team_metadata={}
)
resolved: Final = resolve_tenant_otel_destinations(referencing_key)
assert resolved == resolve_tenant_otel_destinations(inline_key)
assert len(resolved) == 1
assert resolved[0].headers["api_key"] == "resolved-space-a-key"
assert resolve_tenant_otel_destinations(plain_key_on_the_same_team) == ()