feat(spend-logs): turn off prompt storage per key or team (#50135)

* Add per-key and team prompt storage restrictions for spend logs

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Apply spend-log prompt restrictions to passthrough requests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Apply team prompt storage restrictions to queued requests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(spend-logs): honor tenant prompt storage in the collector and on "false" strings

The spend-event collector decided whether to ship request and response
bodies from the global setting alone, so an opted-out key or team still
sent prompts through the collector transport. It now uses the same
key/team check as the spend log writer.

A key or team setting of the string "false" was ignored and prompts kept
being stored. It now counts as false, the same way the global setting
parses strings.

Splits the 42-case tenant storage test into one test for the policy and
one for the pass-through logging contexts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(spend-logs): carry key/team prompt storage into failure, batch and side-call rows

Failure rows, executed batch rows, semantic index embeddings, websearch
interception calls and errors rejected before request setup built their
logging metadata without the key and team metadata, so a restricted key
or team still had prompts stored on those rows. They now stamp it through
one helper, LiteLLMProxyRequestSetup.get_tenant_logging_metadata.

The dashboard toggle now shows a saved string "false" as off, matching
the server, and its label and help text come from the translation file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Krrish Dholakia <krrishdholakia@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krrish Dholakia 2026-11-06 19:50:47 -08:00 • committed by GitHub
parent a3bf1f4399
commit b3c4a1c3e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 629 additions and 33 deletions

View file

@ -1702,6 +1702,7 @@ class WebSearchInterceptionLogger(CustomLogger):
)
return {
**user_api_key_metadata,
**LiteLLMProxyRequestSetup.get_tenant_logging_metadata(user_api_key_auth),
**parent_correlation.as_search_metadata(),
"model_group": search_tool_name,
"user_api_key": LiteLLMProxyRequestSetup.get_logged_api_key(user_api_key_auth),

View file

@ -658,6 +658,7 @@ class LiteLLMExecutedBatchRunner:
def _row_metadata(self, run: _BatchRun) -> dict[str, object]: # mutable-ok: router updates metadata in place
return {
**LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(run.user_api_key_dict),
**LiteLLMProxyRequestSetup.get_tenant_logging_metadata(run.user_api_key_dict),
"user_api_key": LiteLLMProxyRequestSetup.get_logged_api_key(run.user_api_key_dict),
"user_api_end_user_max_budget": run.user_api_key_dict.end_user_max_budget,
"tags": list(run.request_tags),

View file

@ -83,6 +83,7 @@ def embedding_spend_metadata(user_api_key_dict: UserAPIKeyAuth) -> dict[str, obj
return {
**LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(user_api_key_dict),
**LiteLLMProxyRequestSetup.get_tenant_logging_metadata(user_api_key_dict),
"user_api_key": LiteLLMProxyRequestSetup.get_logged_api_key(user_api_key_dict),
}

View file

@ -67,7 +67,7 @@ from litellm.proxy.spend_tracking.spend_tracking_utils import ( # noqa: F401 #
deployment_params_provider,
get_request_model_access_groups,
sanitize_error_information_for_spend_logs, # pyright: ignore[reportUnusedImport] # backwards-compatible package export
should_store_prompts_and_responses_in_spend_logs,
should_store_prompts_for_call,
)
from litellm.proxy.utils import (
REJECTED_BEFORE_REQUEST_SETUP,
@ -425,7 +425,7 @@ class _ProxyDBLogger(CustomLogger):
response_obj,
start_time,
end_time,
store_bodies=should_store_prompts_and_responses_in_spend_logs(),
store_bodies=should_store_prompts_for_call(kwargs),
)
if isinstance(event, SpendEventBuildError):
verbose_proxy_logger.warning("collector: tracking cost in-process, event not buildable: %s", event.reason)
@ -489,6 +489,7 @@ class _ProxyDBLogger(CustomLogger):
LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(user_api_key_dict=user_api_key_dict)
)
_metadata["user_api_key"] = LiteLLMProxyRequestSetup.get_logged_api_key(user_api_key_dict)
_metadata.update(LiteLLMProxyRequestSetup.get_tenant_logging_metadata(user_api_key_dict))
_metadata["status"] = "failure"
_error_information = StandardLoggingPayloadSetup.get_error_information(
original_exception=original_exception,

View file

@ -23,6 +23,7 @@ from fastapi import HTTPException, Request, status
from pydantic import TypeAdapter
from pydantic import ValidationError as PydanticValidationError
from starlette.datastructures import Headers
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_logger, verbose_proxy_logger
@ -1643,7 +1644,19 @@ def redact_credential_headers(headers: Mapping[str, str]) -> Mapping[str, str]:
}
class TenantLoggingMetadata(TypedDict):
user_api_key_metadata: ReadOnly[Mapping[str, object] | None]
user_api_key_team_metadata: ReadOnly[Mapping[str, object] | None]
class LiteLLMProxyRequestSetup:
@staticmethod
def get_tenant_logging_metadata(user_api_key_dict: UserAPIKeyAuth) -> TenantLoggingMetadata:
return {
"user_api_key_metadata": strip_callback_config(user_api_key_dict.metadata),
"user_api_key_team_metadata": strip_callback_config(user_api_key_dict.team_metadata),
}
@staticmethod
def _get_timeout_from_request(headers: dict) -> float | None:
"""

View file

@ -301,7 +301,11 @@ def get_passthrough_router_request_metadata(
)
if request is not None:
LiteLLMProxyRequestSetup.apply_authenticated_baggage_tags(request, request_data)
return _METADATA_ADAPTER.validate_python(request_data["litellm_metadata"])
metadata: Final = _METADATA_ADAPTER.validate_python(request_data["litellm_metadata"])
return {
**metadata,
**LiteLLMProxyRequestSetup.get_tenant_logging_metadata(user_api_key_dict),
}
async def llm_passthrough_factory_proxy_route(

View file

@ -114,6 +114,7 @@ from litellm.proxy.common_request_processing import (
open_sse_before_first_byte,
resolve_litellm_call_id,
)
from litellm.proxy.common_utils.callback_utils import strip_callback_config
from litellm.proxy.common_utils.error_body_call_id import JSON_OBJECT, error_body_call_id, with_call_id
from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # legacy module exports
_read_request_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
@ -730,6 +731,8 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
_metadata["litellm_parent_otel_span"] = user_api_key_dict.parent_otel_span
_metadata["user_api_key_budget_reservation"] = user_api_key_dict.budget_reservation
_metadata[MODEL_ACCESS_GROUP_METADATA_KEY] = user_api_key_dict.matched_model_access_groups
_metadata["user_api_key_metadata"] = _metadata["user_api_key_auth_metadata"]
_metadata["user_api_key_team_metadata"] = strip_callback_config(user_api_key_dict.team_metadata)
# The per-model budget counters are keyed off these. get_sanitized_user_information_from_key
# returns StandardLoggingUserAPIKeyMetadata, which carries no budget field, so without this
# the post-call increment finds nothing and every passthrough request goes untracked and

View file

@ -173,7 +173,6 @@ from litellm.proxy.common_utils.callback_utils import (
is_sensitive_callback_key,
normalize_callback_names,
process_callback,
strip_callback_config,
)
from litellm.proxy.common_utils.realtime_utils import ( # noqa: F401, RUF100 # legacy module exports
_realtime_request_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
@ -19265,7 +19264,7 @@ async def async_queue_request(
logged_api_key: Final = LiteLLMProxyRequestSetup.get_logged_api_key(user_api_key_dict)
data["metadata"]["user_api_key"] = logged_api_key
data["metadata"]["user_api_key_hash"] = logged_api_key
data["metadata"]["user_api_key_metadata"] = strip_callback_config(user_api_key_dict.metadata)
data["metadata"].update(LiteLLMProxyRequestSetup.get_tenant_logging_metadata(user_api_key_dict))
_headers: Final = safe_get_request_headers(request).copy()
_headers.pop("authorization", None) # do not store the original `sk-..` api key in the db
data["metadata"]["headers"] = _headers

View file

@ -50,6 +50,7 @@ from litellm.litellm_core_utils.classifier_logging import (
without_classifier_audit,
)
from litellm.litellm_core_utils.core_helpers import (
as_str_mapping,
get_litellm_metadata_from_kwargs,
proxy_stamped_kubernetes_pod_routing,
proxy_stamped_used_client_oauth_token,
@ -87,6 +88,7 @@ from litellm.responses.litellm_completion_transformation.compaction_items import
summary_only_compaction_content,
with_compaction_summary,
)
from litellm.secret_managers.main import str_to_bool
from litellm.types.router import DeploymentTypedDict, LiteLLM_Params, SilentExperimentFlag
from litellm.types.utils import (
PROMPT_CARRYING_GUARDRAIL_FIELDS,
@ -113,6 +115,12 @@ if TYPE_CHECKING:
from litellm.router import Router
_TENANT_PROMPT_STORAGE_FIELDS: Final = ("user_api_key_metadata", "user_api_key_team_metadata")
_SPEND_LOGS_PRIVACY_METADATA_ADAPTER: Final[TypeAdapter[Mapping[str, object] | None]] = TypeAdapter(
Mapping[str, object] | None
)
def _get_max_string_length_prompt_in_db() -> int:
"""
Resolve prompt truncation threshold at runtime so values loaded later via
@ -336,9 +344,11 @@ def _get_spend_logs_metadata(
clean_metadata["batch_failed_requests"] = batch_failed_requests
clean_metadata["mcp_tool_call_metadata"] = mcp_tool_call_metadata
clean_metadata["vector_store_request_metadata"] = _get_vector_store_request_for_spend_logs_payload(
vector_store_request_metadata
vector_store_request_metadata, metadata=_SPEND_LOGS_PRIVACY_METADATA_ADAPTER.validate_python(metadata)
)
clean_metadata["guardrail_information"] = _sanitize_guardrail_information_for_spend_logs(
guardrail_information, metadata=_SPEND_LOGS_PRIVACY_METADATA_ADAPTER.validate_python(metadata)
)
clean_metadata["guardrail_information"] = _sanitize_guardrail_information_for_spend_logs(guardrail_information)
clean_metadata["usage_object"] = usage_object
clean_metadata["model_map_information"] = model_map_information
clean_metadata["cold_storage_object_key"] = cold_storage_object_key
@ -906,7 +916,9 @@ def get_logging_payload(
if drop_requester_ip_address:
clean_metadata["requester_ip_address"] = None
clean_metadata["error_information"] = sanitize_error_information_for_spend_logs(
clean_metadata["error_information"], original_exception=failure_exception
clean_metadata["error_information"],
original_exception=failure_exception,
metadata=_SPEND_LOGS_PRIVACY_METADATA_ADAPTER.validate_python(metadata),
)
if litellm.cache is None:
@ -1248,7 +1260,7 @@ def _get_messages_for_spend_logs_payload(
standard_logging_payload: StandardLoggingPayload | None,
metadata: dict | None = None,
) -> str:
if should_store_prompts_and_responses_in_spend_logs():
if should_store_prompts_and_responses_in_spend_logs(_SPEND_LOGS_PRIVACY_METADATA_ADAPTER.validate_python(metadata)):
if standard_logging_payload is not None:
call_type: Final = standard_logging_payload.get("call_type", "")
if call_type == "_arealtime":
@ -1291,8 +1303,8 @@ def stores_latest_turn_only() -> bool:
return general_settings_view().get("spend_logs_prompt_storage") == _LATEST_TURN_STORAGE
def stores_full_prompts_in_spend_logs() -> bool:
return should_store_prompts_and_responses_in_spend_logs() and not stores_latest_turn_only()
def stores_full_prompts_in_spend_logs(metadata: Mapping[str, object] | None = None) -> bool:
return should_store_prompts_and_responses_in_spend_logs(metadata) and not stores_latest_turn_only()
def _block_type(block: object) -> str | None:
@ -1651,6 +1663,7 @@ def _redact_prompt_leaks_in_error_string(text: str) -> str:
def _sanitize_guardrail_information_for_spend_logs(
guardrail_information: list[StandardLoggingGuardrailInformation] | None,
metadata: Mapping[str, object] | None = None,
) -> list[StandardLoggingGuardrailInformation] | None:
"""
When ``store_prompts_in_spend_logs`` is False, redact prompt-carrying fields
@ -1671,7 +1684,7 @@ def _sanitize_guardrail_information_for_spend_logs(
here to match OTEL's defensive read pattern; otherwise iteration would
yield the dict's keys and crash the whole spend-log write.
"""
if guardrail_information is None or stores_full_prompts_in_spend_logs():
if guardrail_information is None or stores_full_prompts_in_spend_logs(metadata):
return guardrail_information
entries: Final = [guardrail_information] if isinstance(guardrail_information, dict) else guardrail_information
return [_redact_prompt_fields_in_guardrail_entry(entry) for entry in entries if isinstance(entry, dict)]
@ -1721,6 +1734,7 @@ def _redact_prompt_fields_in_guardrail_entry(
def sanitize_error_information_for_spend_logs(
error_information: StandardLoggingPayloadErrorInformation | None,
original_exception: BaseException | None = None,
metadata: Mapping[str, object] | None = None,
) -> StandardLoggingPayloadErrorInformation | None:
"""
Sanitize ``error_information`` before it lands in ``LiteLLM_SpendLogs.metadata``.
@ -1749,7 +1763,7 @@ def sanitize_error_information_for_spend_logs(
)
sanitized = cast(dict, {**persisted})
if not stores_full_prompts_in_spend_logs():
if not stores_full_prompts_in_spend_logs(metadata):
for field in ("error_message", "traceback"):
value = sanitized.get(field)
if isinstance(value, str):
@ -2057,7 +2071,7 @@ def _get_proxy_server_request_for_spend_logs_payload(
If turn_off_message_logging is enabled, redact messages in the request body.
"""
if should_store_prompts_and_responses_in_spend_logs():
if should_store_prompts_and_responses_in_spend_logs(_SPEND_LOGS_PRIVACY_METADATA_ADAPTER.validate_python(metadata)):
_proxy_server_request: Final = cast(dict | None, litellm_params.get("proxy_server_request", EMPTY_MAPPING))
if _proxy_server_request is not None:
_request_body = _proxy_server_request.get("body", EMPTY_MAPPING) or EMPTY_MAPPING
@ -2098,11 +2112,12 @@ def _get_proxy_server_request_for_spend_logs_payload(
def _get_vector_store_request_for_spend_logs_payload(
vector_store_request_metadata: list[StandardLoggingVectorStoreRequest] | None,
metadata: Mapping[str, object] | None = None,
) -> list[StandardLoggingVectorStoreRequest] | None:
"""
If user does not want to store prompts and responses, then remove the content from the vector store request metadata
"""
if stores_full_prompts_in_spend_logs():
if stores_full_prompts_in_spend_logs(metadata):
return vector_store_request_metadata
if vector_store_request_metadata is None:
@ -2136,7 +2151,9 @@ def _get_response_for_spend_logs_payload(
) -> str:
if payload is None:
return "{}"
if should_store_prompts_and_responses_in_spend_logs():
if should_store_prompts_and_responses_in_spend_logs(
_SPEND_LOGS_PRIVACY_METADATA_ADAPTER.validate_python(get_litellm_metadata_from_kwargs(kwargs or {}))
):
response_obj: object = payload.get("response")
if response_obj is None:
return "{}"
@ -2186,10 +2203,27 @@ def _get_response_for_spend_logs_payload(
return "{}"
def should_store_prompts_and_responses_in_spend_logs() -> bool:
def _disables_prompt_storage(tenant_metadata: object) -> bool:
settings: Final = as_str_mapping(tenant_metadata)
value: Final = settings.get("store_prompts_in_spend_logs") if settings is not None else None
return value is False or (isinstance(value, str) and str_to_bool(value) is False)
def should_store_prompts_for_call(kwargs: Mapping[str, object]) -> bool:
return should_store_prompts_and_responses_in_spend_logs(
_SPEND_LOGS_PRIVACY_METADATA_ADAPTER.validate_python(get_litellm_metadata_from_kwargs(dict(kwargs)))
)
def should_store_prompts_and_responses_in_spend_logs(metadata: Mapping[str, object] | None = None) -> bool:
from litellm.proxy.proxy_server import general_settings
from litellm.secret_managers.main import get_secret_bool
if metadata is not None and any(
_disables_prompt_storage(metadata.get(field)) for field in _TENANT_PROMPT_STORAGE_FIELDS
):
return False
# Check general_settings (from DB or proxy_config.yaml)
store_prompts_value: Final = general_settings.get("store_prompts_in_spend_logs")

View file

@ -4258,7 +4258,12 @@ class ProxyLogging:
)
request_data["litellm_logging_obj"] = litellm_logging_obj # rebind-ok: lifted then popped by the caller
request_data.update({REJECTED_BEFORE_REQUEST_SETUP_KEY: REJECTED_BEFORE_REQUEST_SETUP})
normalized_metadata_slot(request_data, "metadata").update(user_api_key_logged_metadata)
normalized_metadata_slot(request_data, "metadata").update(
{
**user_api_key_logged_metadata,
**LiteLLMProxyRequestSetup.get_tenant_logging_metadata(user_api_key_dict),
}
)
if litellm_logging_obj is not None:
## UPDATE LOGGING INPUT

View file

@ -726,6 +726,33 @@ async def test_create_dispatches_each_row_with_the_key_team_and_project_tags_as_
assert inherited == [("key-tag", "shared", "&region:eu", "project-tag")] * 2
@pytest.mark.parametrize(
("key_metadata", "team_metadata", "stored"),
[
({"store_prompts_in_spend_logs": False}, {}, False),
({}, {"store_prompts_in_spend_logs": False}, False),
({}, {}, True),
],
)
async def test_create_dispatches_rows_that_keep_the_key_and_team_prompt_storage_policy(
monkeypatch: pytest.MonkeyPatch, key_metadata: dict[str, object], team_metadata: dict[str, object], stored: bool
) -> None:
from litellm.proxy import proxy_server
from litellm.proxy.spend_tracking.spend_tracking_utils import should_store_prompts_and_responses_in_spend_logs
monkeypatch.setattr(proxy_server, "general_settings", {"store_prompts_in_spend_logs": True})
harness: Final = make_runner(
user=UserAPIKeyAuth(api_key="sk-batch-key", metadata=key_metadata, team_metadata=team_metadata)
)
await harness.create_and_finish()
decisions: Final = [
should_store_prompts_and_responses_in_spend_logs(call.kwargs["metadata"])
for call in harness.router.acompletion.await_args_list
]
assert decisions == [stored] * 2
async def test_create_dispatches_cli_session_rows_under_the_per_user_alias_not_the_login_token() -> None:
session = UserAPIKeyAuth(
api_key="cli-session-Qm7xJ2kP9sLw4vT1nR8yAa",

View file

@ -498,6 +498,50 @@ async def test_async_post_call_failure_hook_honors_the_redaction_header_on_a_lit
assert stored_request["litellm_metadata"]["headers"] == request_headers
@pytest.mark.asyncio
@pytest.mark.parametrize(
("key_metadata", "team_metadata", "code_word_is_stored"),
[
({"store_prompts_in_spend_logs": False}, {}, False),
({}, {"store_prompts_in_spend_logs": False}, False),
({"store_prompts_in_spend_logs": True}, {"store_prompts_in_spend_logs": False}, False),
({}, {}, True),
],
)
async def test_async_post_call_failure_hook_honors_key_and_team_prompt_storage(
monkeypatch: pytest.MonkeyPatch,
key_metadata: dict[str, object],
team_metadata: dict[str, object],
code_word_is_stored: bool,
):
monkeypatch.setenv("STORE_PROMPTS_IN_SPEND_LOGS", "true")
writer: Final = MagicMock(spec=DBSpendUpdateWriter)
writer.update_database = AsyncMock()
request_input: Final = "The code word is VERMILION-42. Reply OK."
await ProxyDBLogger(spend_writer=lambda: writer).async_post_call_failure_hook(
request_data={
"model": "gpt-5.6",
"input": request_input,
"litellm_metadata": {},
"proxy_server_request": {
"url": "http://localhost/v1/responses",
"body": {"model": "gpt-5.6", "input": request_input},
},
},
original_exception=Exception("temperature must be at most 2"),
user_api_key_dict=UserAPIKeyAuth(api_key="test_api_key", metadata=key_metadata, team_metadata=team_metadata),
)
row: Final = get_logging_payload(
kwargs=writer.update_database.call_args.kwargs["kwargs"],
response_obj={},
start_time=datetime.now(),
end_time=datetime.now(),
)
assert ("VERMILION-42" in row["proxy_server_request"]) is code_word_is_stored
@pytest.mark.asyncio
@pytest.mark.parametrize(
"used_client_oauth_token, custom_llm_provider, expected",
@ -3398,6 +3442,43 @@ async def test_async_log_success_event_hands_the_sidecar_a_compact_event_and_ski
assert event.response_cost == 0.0125
@pytest.mark.asyncio
@pytest.mark.parametrize(
("tenant_metadata", "stored"),
[
({"user_api_key_metadata": {"store_prompts_in_spend_logs": False}}, False),
({"user_api_key_team_metadata": {"store_prompts_in_spend_logs": False}}, False),
({"user_api_key_team_metadata": {"store_prompts_in_spend_logs": "false"}}, False),
({"user_api_key_metadata": {}, "user_api_key_team_metadata": {}}, True),
],
)
async def test_spend_event_leaves_out_bodies_for_tenants_that_turned_off_prompt_storage(
socket_path: Path, monkeypatch: pytest.MonkeyPatch, tenant_metadata: dict[str, object], stored: bool
):
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "general_settings", {"store_prompts_in_spend_logs": True})
handler: Final = _RecordingHandler()
consumer: Final = SpendEventConsumer(handler)
address: Final = UnixAddress(path=str(socket_path))
server: Final = await consumer.serve(address)
producer: Final = SpendEventProducer(
address=address, on_unavailable="fallback", buffer_size=10, connect_timeout=1.0, fallback=_no_fallback
)
kwargs: Final[dict[str, object]] = _offload_kwargs()
kwargs["litellm_params"]["metadata"].update(tenant_metadata)
await ProxyDBLogger(producer).async_log_success_event(kwargs, _offload_response(), datetime.now(), datetime.now())
await producer.close(drain_timeout=5.0)
server.close()
assert await consumer.drain(timeout=5.0) == 0
event: Final = decode_spend_event(handler.lines[0])
assert not isinstance(event, SpendEventDecodeError)
assert (event.litellm_params["proxy_server_request"] is not None) is stored
assert ("messages" in event.standard_logging_object) is stored
def _completed_batch() -> LiteLLMBatch:
return LiteLLMBatch(
id="batch_abc",

View file

@ -8,14 +8,19 @@ from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractContextManager
from datetime import datetime, timezone
from queue import SimpleQueue
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import litellm
import pytest
from fastapi.testclient import TestClient
from litellm import Router
from litellm.integrations.custom_logger import CustomLogger
from litellm.proxy import proxy_server
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
from .conftest import normalize # type: ignore[import-not-found]
@ -133,6 +138,65 @@ def _queue_router(
return router
class _QueueRequestLogger(CustomLogger):
def __init__(self) -> None:
super().__init__()
self.requests: Final = SimpleQueue[dict[str, object]]()
def log_pre_api_call(self, model: str, messages: object, kwargs: dict[str, object]) -> None:
self.requests.put(kwargs)
@pytest.mark.parametrize("storage_mode", [None, "latest_turn"])
@pytest.mark.parametrize(
("key_setting", "team_setting", "stored"),
[(None, False, False), (True, False, False), (False, None, False), (None, None, True), (True, True, True)],
)
def test_queued_requests_apply_authenticated_prompt_storage_policy(
client: TestClient,
auth_as: Callable[..., AbstractContextManager[None]],
monkeypatch: pytest.MonkeyPatch,
storage_mode: str | None,
key_setting: bool | None,
team_setting: bool | None,
stored: bool,
) -> None:
_queue_router(monkeypatch, default_priority=0)
logger: Final = _QueueRequestLogger()
monkeypatch.setattr(litellm, "callbacks", [logger])
monkeypatch.setattr(
proxy_server,
"general_settings",
{"store_prompts_in_spend_logs": True, "spend_logs_prompt_storage": storage_mode},
)
with auth_as(
metadata={} if key_setting is None else {"store_prompts_in_spend_logs": key_setting},
team_metadata={} if team_setting is None else {"store_prompts_in_spend_logs": team_setting},
):
response: Final = client.post(
"/queue/chat/completions",
json={
"model": "gpt-4",
"messages": [{"role": "user", "content": "queue-private-prompt"}],
"metadata": {
"user_api_key_metadata": {"store_prompts_in_spend_logs": not stored},
"user_api_key_team_metadata": {"store_prompts_in_spend_logs": not stored},
},
},
)
assert response.status_code == 200, response.text
assert response.json()["choices"][0]["message"]["content"] == "queued"
now: Final = datetime(2026, 1, 1, tzinfo=timezone.utc)
payload: Final = get_logging_payload(
kwargs=logger.requests.get_nowait(),
response_obj=litellm.ModelResponse.model_validate(response.json()),
start_time=now,
end_time=now,
)
assert ("queue-private-prompt" in payload["proxy_server_request"]) is stored
assert ("queued" in payload["response"]) is stored
@pytest.mark.parametrize(
("default_priority", "priority_fields", "expected_priority"),
[

View file

@ -8,6 +8,7 @@ from typing import Any, Final, cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import Request
from pydantic import TypeAdapter
from typing_extensions import NotRequired, ReadOnly, TypedDict
@ -32,10 +33,14 @@ from litellm.constants import (
MANAGED_BACKGROUND_RESPONSE_METADATA_KEY,
)
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.ocr.transformation import OCRResponse, OCRUsageInfo
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.proxy._types import SpendLogsMetadata, SpendLogsMetadataFields, SpendLogsPayload, UserAPIKeyAuth
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import get_passthrough_router_request_metadata
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import HttpPassThroughEndpointHelpers
from litellm.types.passthrough_endpoints.pass_through_endpoints import PassthroughStandardLoggingPayload
from litellm.proxy.route_llm_request import ProxyModelNotFoundError
from litellm.proxy.spend_tracking.spend_tracking_utils import (
_extract_usage_for_ocr_call,
@ -211,6 +216,178 @@ def test_classifier_audit_spend_storage_obeys_privacy_and_truncation(monkeypatch
assert audit["classifier_input"]["system"] == "rubric" * 1000
_TENANT_PROMPT: Final = "tenant-private-prompt"
_TENANT_RESPONSE: Final = "tenant-private-response"
def _tenant_settings(setting: bool | str | None) -> dict[str, object]:
return {} if setting is None else {"store_prompts_in_spend_logs": setting}
def _spend_log_for(litellm_params: Mapping[str, object]) -> tuple[SpendLogsPayload, dict[str, object]]:
now: Final = datetime.datetime(2026, 1, 1, tzinfo=timezone.utc)
messages: Final = [{"role": "user", "content": _TENANT_PROMPT}]
kwargs: Final[dict[str, object]] = {
"model": "gpt-4o-mini",
"litellm_params": {**litellm_params, "proxy_server_request": {"body": {"messages": messages}}},
"standard_logging_object": {
"call_type": "_arealtime",
"model_map_information": None,
"messages": messages,
"response": {"choices": [{"message": {"content": _TENANT_RESPONSE}}]},
"metadata": {},
},
}
response: Final = litellm.ModelResponse(
id="tenant-storage",
choices=[{"message": {"role": "assistant", "content": _TENANT_RESPONSE}}],
usage=litellm.Usage(prompt_tokens=3, completion_tokens=2, total_tokens=5),
)
return get_logging_payload(kwargs=kwargs, response_obj=response, start_time=now, end_time=now), kwargs
def _stores_tenant_content(payload: SpendLogsPayload) -> tuple[bool, bool, bool]:
return (
_TENANT_PROMPT in payload["messages"],
_TENANT_PROMPT in payload["proxy_server_request"],
_TENANT_RESPONSE in payload["response"],
)
@pytest.mark.parametrize("storage_mode", [None, "latest_turn"])
@pytest.mark.parametrize(
("key_setting", "team_setting", "global_store", "stored"),
[
(False, None, True, False),
(None, False, True, False),
(True, False, True, False),
(False, True, True, False),
(None, "false", True, False),
(None, None, True, True),
(True, True, True, True),
(True, True, False, False),
],
)
def test_tenant_prompt_storage_policy_decides_what_spend_logs_keep(
monkeypatch: pytest.MonkeyPatch,
key_setting: bool | str | None,
team_setting: bool | str | None,
global_store: bool,
stored: bool,
storage_mode: str | None,
) -> None:
from litellm.proxy import proxy_server
monkeypatch.setattr(
proxy_server,
"general_settings",
{"store_prompts_in_spend_logs": global_store, "spend_logs_prompt_storage": storage_mode},
)
monkeypatch.delenv("STORE_PROMPTS_IN_SPEND_LOGS", raising=False)
payload, kwargs = _spend_log_for(
{
"metadata": {
"user_api_key_metadata": _tenant_settings(key_setting),
"user_api_key_team_metadata": _tenant_settings(team_setting),
"store_prompts_in_spend_logs": True,
"headers": {"litellm-disable-message-redaction": "true"},
}
}
)
assert _stores_tenant_content(payload) == (stored, stored, stored)
assert kwargs["standard_logging_object"]["messages"] == [{"role": "user", "content": _TENANT_PROMPT}]
assert kwargs["standard_logging_object"]["response"] == {"choices": [{"message": {"content": _TENANT_RESPONSE}}]}
def _passthrough_logging_params(auth: UserAPIKeyAuth, request: Request) -> Mapping[str, object]:
context: Final = HttpPassThroughEndpointHelpers.init_kwargs_for_pass_through_endpoint(
request=request,
user_api_key_dict=auth,
passthrough_logging_payload=PassthroughStandardLoggingPayload(url="https://api.openai.com/v1/chat/completions"),
logging_obj=LiteLLMLoggingObj(
model="test-model",
messages=[],
stream=False,
call_type="pass_through_endpoint",
start_time=datetime.datetime(2026, 1, 1, tzinfo=timezone.utc),
litellm_call_id="tenant-storage",
function_id="tenant-storage",
),
_parsed_body={
"messages": [{"role": "user", "content": _TENANT_PROMPT}],
"metadata": {
"user_api_key_metadata": {"store_prompts_in_spend_logs": True},
"user_api_key_team_metadata": {"store_prompts_in_spend_logs": True},
},
},
)
return TypeAdapter[Mapping[str, object]](Mapping[str, object]).validate_python(context["litellm_params"])
def _router_passthrough_logging_params(auth: UserAPIKeyAuth, request: Request) -> Mapping[str, object]:
return {"litellm_metadata": dict(get_passthrough_router_request_metadata(auth, request))}
@pytest.mark.parametrize("logging_params_for", [_passthrough_logging_params, _router_passthrough_logging_params])
@pytest.mark.parametrize(
("key_setting", "team_setting", "stored"),
[(False, None, False), (None, False, False), (True, False, False), (None, None, True)],
)
def test_passthrough_spend_logs_follow_the_authenticated_tenant_storage_policy(
monkeypatch: pytest.MonkeyPatch,
logging_params_for: Callable[[UserAPIKeyAuth, Request], Mapping[str, object]],
key_setting: bool | None,
team_setting: bool | None,
stored: bool,
) -> None:
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "general_settings", {"store_prompts_in_spend_logs": True})
monkeypatch.delenv("STORE_PROMPTS_IN_SPEND_LOGS", raising=False)
auth: Final = UserAPIKeyAuth(
api_key="test-storage-key", metadata=_tenant_settings(key_setting), team_metadata=_tenant_settings(team_setting)
)
request: Final = Request(
{"type": "http", "method": "POST", "path": "/openai/v1/chat/completions", "headers": [], "query_string": b""}
)
payload, _ = _spend_log_for(logging_params_for(auth, request))
assert _stores_tenant_content(payload) == (stored, stored, stored)
@pytest.mark.parametrize("tenant_field", ["user_api_key_metadata", "user_api_key_team_metadata"])
def test_tenant_prompt_restriction_redacts_ancillary_fields_without_mutating_callbacks(
monkeypatch: pytest.MonkeyPatch, tenant_field: str
) -> None:
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "general_settings", {"store_prompts_in_spend_logs": True})
metadata: Final = {tenant_field: {"store_prompts_in_spend_logs": False}}
guardrails: Final = [{"guardrail_name": "test", "guardrail_request": "private-guardrail"}]
vectors: Final = [
{"vector_store_search_response": {"data": [{"content": [{"text": "private-vector", "type": "text"}]}]}}
]
error: Final = {
"error_message": 'bad request {"messages": [{"role": "user", "content": "private-error"}]}',
"error_class": "BadRequestError",
}
cleaned: Final = _get_spend_logs_metadata(
metadata=metadata, guardrail_information=guardrails, vector_store_request_metadata=vectors
)
assert "private-guardrail" not in json.dumps(cleaned["guardrail_information"])
assert "private-vector" not in json.dumps(cleaned["vector_store_request_metadata"])
assert guardrails == [{"guardrail_name": "test", "guardrail_request": "private-guardrail"}]
assert vectors[0]["vector_store_search_response"]["data"][0]["content"][0]["text"] == "private-vector"
assert "private-error" not in json.dumps(sanitize_error_information_for_spend_logs(error, metadata=metadata))
assert "private-error" in error["error_message"]
def test_request_body_metadata_cannot_disable_spend_prompt_storage(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "general_settings", {"store_prompts_in_spend_logs": True})
assert should_store_prompts_and_responses_in_spend_logs({"store_prompts_in_spend_logs": False}) is True
def test_get_logging_payload_maps_openai_cached_tokens_to_cache_read_input_tokens():
additional_usage_values = _get_additional_usage_values_for_usage(
litellm.Usage(

View file

@ -47,6 +47,20 @@ afterEach(() => {
});
describe("Teams creation integration", () => {
it("sends the prompt storage restriction in team metadata", async () => {
renderWithProviders(<Teams accessToken="sk-session-test" userID="test-admin" userRole="Admin" premiumUser />);
fireEvent.click(screen.getAllByRole("button", { name: /create team/i })[0]);
fireEvent.change(await screen.findByLabelText(/team name/i), { target: { value: "Private Team" } });
fireEvent.click(screen.getByRole("switch", { name: "Store prompts in spend logs" }));
fireEvent.click(screen.getAllByRole("button", { name: /create team/i }).at(-1)!);
await waitFor(() =>
expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).includes("/team/new"))).toBe(true),
);
const request = vi.mocked(fetch).mock.calls.find(([url]) => String(url).includes("/team/new"));
const payload = JSON.parse(String(request?.[1]?.body)) as { metadata: Record<string, unknown> };
expect(payload.metadata.store_prompts_in_spend_logs).toBe(false);
});
it("sends a configured default member per-model budget", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(<Teams accessToken="sk-session-test" userID="test-admin" userRole="Admin" premiumUser />);

View file

@ -71,6 +71,7 @@ import DeleteResourceModal from "./common_components/DeleteResourceModal";
import { teamCreateCall } from "./networking";
import { normalizeTeamModelSelection } from "./team/teamModelAccess";
import { ModelSelect } from "./ModelSelect/ModelSelect";
import { StorePromptsToggle } from "./common_components/StorePromptsToggle";
const SUPPRESSED_BY_DESCRIPTION = "";
@ -877,6 +878,19 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
</FormField>
<Field>
<FieldLabel>{t("teams.page.create.fields.metadata.label")}</FieldLabel>
<FormField control={form.control} name="metadata">
{({ value, onChange }) => (
<StorePromptsToggle
value={metadataPairsToObject(value).store_prompts_in_spend_logs}
onChange={(store) =>
onChange([
...(value ?? []).filter((pair) => pair.key !== "store_prompts_in_spend_logs"),
{ key: "store_prompts_in_spend_logs", value: JSON.stringify(store) },
])
}
/>
)}
</FormField>
<MetadataKeyValueFields
control={form.control}
getValues={form.getValues}

View file

@ -0,0 +1,34 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { StorePromptsMetadataToggle } from "./StorePromptsToggle";
describe("StorePromptsMetadataToggle", () => {
it("leaves an unset default untouched and writes false while preserving metadata", () => {
const onChange = vi.fn();
render(<StorePromptsMetadataToggle value={'{"note":"keep"}'} onChange={onChange} />);
expect(onChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("switch", { name: "Store prompts in spend logs" }));
expect(JSON.parse(onChange.mock.calls[0][0])).toEqual({ note: "keep", store_prompts_in_spend_logs: false });
});
it("can remove an existing restriction", () => {
const onChange = vi.fn();
render(<StorePromptsMetadataToggle value={'{"store_prompts_in_spend_logs":false}'} onChange={onChange} />);
expect(screen.getByRole("switch")).not.toBeChecked();
fireEvent.click(screen.getByRole("switch"));
expect(JSON.parse(onChange.mock.calls[0][0])).toEqual({ store_prompts_in_spend_logs: true });
});
it("shows a saved string false restriction as off", () => {
render(<StorePromptsMetadataToggle value={'{"store_prompts_in_spend_logs":"False"}'} onChange={vi.fn()} />);
expect(screen.getByRole("switch", { name: "Store prompts in spend logs" })).not.toBeChecked();
});
it("does not overwrite invalid metadata", () => {
const onChange = vi.fn();
render(<StorePromptsMetadataToggle value="{" onChange={onChange} />);
expect(screen.getByRole("switch")).toHaveAttribute("aria-disabled", "true");
fireEvent.click(screen.getByRole("switch"));
expect(onChange).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,54 @@
import { useId } from "react";
import { useTranslation } from "react-i18next";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Switch } from "@/components/ui/switch";
interface StorePromptsToggleProps {
value?: unknown;
onChange: (value: boolean) => void;
disabled?: boolean;
}
function disablesPromptStorage(value: unknown): boolean {
return value === false || (typeof value === "string" && value.trim().toLowerCase() === "false");
}
export function StorePromptsToggle({ value, onChange, disabled }: StorePromptsToggleProps) {
const id = useId();
const { t } = useTranslation();
return (
<Field>
<FieldLabel htmlFor={id}>{t("storePromptsToggle.label")}</FieldLabel>
<Switch id={id} checked={!disablesPromptStorage(value)} onCheckedChange={onChange} disabled={disabled} />
<FieldDescription>{t("storePromptsToggle.description")}</FieldDescription>
</Field>
);
}
export function StorePromptsMetadataToggle({
value,
onChange,
}: {
value: string | undefined;
onChange: (value: string) => void;
}) {
const metadata = readMetadata(value);
return (
<StorePromptsToggle
value={metadata?.store_prompts_in_spend_logs}
disabled={metadata === null}
onChange={(store) => onChange(JSON.stringify({ ...metadata, store_prompts_in_spend_logs: store }, null, 2))}
/>
);
}
function readMetadata(value: string | undefined): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(value || "{}");
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}

View file

@ -22,6 +22,13 @@ const baseInput: KeyCreateInput = {
const build = (formValues: Record<string, unknown>, overrides: Partial<KeyCreateInput> = {}): KeyPayloadResult =>
buildKeyCreatePayload({ ...baseInput, ...overrides, formValues });
it.each([false, true])("preserves the prompt storage restriction %s in key metadata", (store) => {
const result = build({ metadata: JSON.stringify({ note: "keep", store_prompts_in_spend_logs: store }) });
expect(result.kind).toBe("ok");
if (result.kind !== "ok") throw new Error(result.kind);
expect(JSON.parse(String(result.payload.metadata))).toEqual({ note: "keep", store_prompts_in_spend_logs: store });
});
const payloadOf = (result: KeyPayloadResult): Record<string, unknown> => {
expect(result.kind).toBe("ok");
if (result.kind !== "ok") throw new Error("unreachable");

View file

@ -37,6 +37,7 @@ import BudgetDurationDropdown from "../common_components/budget_duration_dropdow
import SchemaFormFields from "../common_components/check_openapi_schema";
import KeyLifecycleSettings from "../common_components/KeyLifecycleSettings";
import ModelAliasManager from "../common_components/ModelAliasManager";
import { StorePromptsMetadataToggle } from "../common_components/StorePromptsToggle";
import {
MountedFormField,
MountedFormProvider,
@ -1571,12 +1572,18 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
className="mt-4"
>
{(control) => (
<Textarea
{...control}
value={(control.value as string | undefined) ?? ""}
rows={4}
placeholder={t("createKey.metadata.placeholder")}
/>
<>
<StorePromptsMetadataToggle
value={control.value as string | undefined}
onChange={control.onChange}
/>
<Textarea
{...control}
value={(control.value as string | undefined) ?? ""}
rows={4}
placeholder={t("createKey.metadata.placeholder")}
/>
</>
)}
</MountedFormField>
<MountedFormField

View file

@ -832,6 +832,21 @@ describe("TeamInfoView - model settings", () => {
seedDefaultMocks();
});
it("submits the prompt storage restriction in team metadata", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: [] }));
renderWithProviders(<TeamInfoView {...props} userModels={["gpt-4"]} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
fireEvent.click(screen.getByRole("switch", { name: "Store prompts in spend logs" }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1]).toEqual(
expect.objectContaining({ metadata: expect.objectContaining({ store_prompts_in_spend_logs: false }) }),
);
});
afterEach(() => {
vi.clearAllMocks();
});

View file

@ -114,6 +114,7 @@ import SearchToolSelector from "../search_tools/SearchToolSelector";
import SkillSelector from "../skills/SkillSelector";
import EditLoggingSettings from "./EditLoggingSettings";
import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_components/RouterSettingsAccordion";
import { StorePromptsToggle } from "../common_components/StorePromptsToggle";
import MemberModal from "./EditMembership";
import MemberPermissions from "./member_permissions";
import MyUserTab from "./MyUserTab";
@ -1976,6 +1977,19 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<Field>
<FieldLabel>{t("teams.teamInfo.editForm.metadata")}</FieldLabel>
<FormField control={form.control} name="metadata">
{({ value, onChange }) => (
<StorePromptsToggle
value={metadataPairsToObject(value).store_prompts_in_spend_logs}
onChange={(store) =>
onChange([
...(value ?? []).filter((pair) => pair.key !== "store_prompts_in_spend_logs"),
{ key: "store_prompts_in_spend_logs", value: JSON.stringify(store) },
])
}
/>
)}
</FormField>
<MetadataKeyValueFields
control={form.control}
getValues={form.getValues}

View file

@ -11,6 +11,7 @@ import { FormField } from "@/components/shared/form/FormField";
import { toast } from "@/lib/toast";
import AgentSelector from "../agent_management/AgentSelector";
import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem";
import { StorePromptsMetadataToggle } from "../common_components/StorePromptsToggle";
import NumericalInput from "../shared/numerical_input";
import SkillSelector from "../skills/SkillSelector";
import { moveTagsOutOfMetadataJson } from "./keyEditFieldNormalizers";
@ -195,15 +196,18 @@ export const KeyMetadataField = ({ form }: { form: KeyEditForm }) => {
description={t("keyDetail.keyEditViewControls.metadata.description")}
>
{(field) => (
<Textarea
{...field}
value={(field.value as string | undefined) ?? ""}
rows={10}
onBlur={() => {
field.onBlur();
moveMetadataTagsToTagsField(form, t);
}}
/>
<>
<StorePromptsMetadataToggle value={field.value as string | undefined} onChange={field.onChange} />
<Textarea
{...field}
value={(field.value as string | undefined) ?? ""}
rows={10}
onBlur={() => {
field.onBlur();
moveMetadataTagsToTagsField(form, t);
}}
/>
</>
)}
</FormField>
);

View file

@ -358,6 +358,24 @@ describe("KeyEditView", () => {
});
});
it("submits the spend prompt storage restriction in metadata", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => {}}
onSubmit={onSubmit}
accessToken=""
userID=""
userRole="Admin"
/>,
);
fireEvent.click(screen.getByRole("switch", { name: "Store prompts in spend logs" }));
fireEvent.click(screen.getByText("Save Changes"));
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
expect(JSON.parse(onSubmit.mock.calls[0][0].metadata).store_prompts_in_spend_logs).toBe(false);
});
it("should render tags", async () => {
renderWithProviders(
<KeyEditView

View file

@ -3081,6 +3081,10 @@
"empty": "No teams found",
"loading": "Loading teams…"
},
"storePromptsToggle": {
"label": "Store prompts in spend logs",
"description": "Turn off to exclude prompts and responses from spend logs. Global and team restrictions still apply."
},
"accessGroupSelector": {
"placeholder": "Select access groups",
"label": "Access Group",