diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index ed49ca2caa9..7835045f39e 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2585,6 +2585,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): None, description="If True, stores request messages and responses in spend logs. Default is False.", ) + store_responses_in_spend_logs: bool | None = Field( + None, + description="Controls response content storage in spend logs independently. When unset, follows store_prompts_in_spend_logs for backward compatibility.", + ) disable_auto_add_proxy_admin_to_teams: bool | None = Field( None, description="By default, the user calling /team/new is automatically added to the new team as a team admin. If True, proxy admins are no longer auto-added; members explicitly listed in members_with_roles are unaffected. Default is False.", diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7dad7abd210..3ef3806c7a4 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -6546,6 +6546,21 @@ class ProxyConfig: # For other types, convert to bool general_settings["store_prompts_in_spend_logs"] = bool(value) + if "store_responses_in_spend_logs" in _general_settings: + response_storage_value: Final = ( + general_settings.get("store_responses_in_spend_logs") + if "store_responses_in_spend_logs" in self._yaml_general_settings_keys + else _general_settings["store_responses_in_spend_logs"] + ) + if response_storage_value is None: + general_settings["store_responses_in_spend_logs"] = None + elif isinstance(response_storage_value, bool): + general_settings["store_responses_in_spend_logs"] = response_storage_value + elif isinstance(response_storage_value, str): + general_settings["store_responses_in_spend_logs"] = response_storage_value.lower() == "true" + else: + general_settings["store_responses_in_spend_logs"] = bool(response_storage_value) + if "disable_auto_add_proxy_admin_to_teams" in _general_settings: value = _general_settings["disable_auto_add_proxy_admin_to_teams"] if isinstance(value, str): @@ -16061,6 +16076,7 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro "pass_through_endpoints": "PydanticModel", "store_model_in_db": "Boolean", "store_prompts_in_spend_logs": "Boolean", + "store_responses_in_spend_logs": "Boolean", "maximum_spend_logs_retention_period": "String", "maximum_health_check_retention_period": "String", "maximum_spend_logs_cleanup_batch_size": "Integer", diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 52261d2c305..0fdcd3b8534 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -1169,7 +1169,7 @@ 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_responses_in_spend_logs(): response_obj: Any = payload.get("response") if response_obj is None: return "{}" @@ -1236,6 +1236,18 @@ def _should_store_prompts_and_responses_in_spend_logs() -> bool: return get_secret_bool("STORE_PROMPTS_IN_SPEND_LOGS") is True +def _should_store_responses_in_spend_logs() -> bool: + from litellm.proxy.proxy_server import general_settings + + store_responses_value: Final = general_settings.get("store_responses_in_spend_logs") + if store_responses_value is not None: + if isinstance(store_responses_value, str): + return store_responses_value.lower() == "true" + return store_responses_value is True + + return _should_store_prompts_and_responses_in_spend_logs() + + def _get_status_for_spend_log( metadata: dict, ) -> Literal["success", "failure"]: diff --git a/tests/proxy_unit_tests/test_proxy_config_unit_test.py b/tests/proxy_unit_tests/test_proxy_config_unit_test.py index 81648dc1158..909acfdceb7 100644 --- a/tests/proxy_unit_tests/test_proxy_config_unit_test.py +++ b/tests/proxy_unit_tests/test_proxy_config_unit_test.py @@ -353,3 +353,40 @@ class TestYamlStorePromptsDbOverride: """_yaml_general_settings_keys should be empty on init.""" proxy_config = ProxyConfig() assert proxy_config._yaml_general_settings_keys == set() + + @pytest.mark.asyncio + async def test_response_storage_yaml_value_takes_precedence_over_db(self): + proxy_config = self._make_proxy_config_with_yaml_keys({"store_responses_in_spend_logs"}) + test_general_settings = {"store_responses_in_spend_logs": False} + + with mock.patch("litellm.proxy.proxy_server.general_settings", test_general_settings): + await proxy_config._update_general_settings( + db_general_settings={"store_responses_in_spend_logs": True}, + ) + + assert test_general_settings["store_responses_in_spend_logs"] is False + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("db_value", "expected"), + [ + (None, None), + (True, True), + ("TRUE", True), + (0, False), + ], + ) + async def test_response_storage_db_value_is_normalized_when_yaml_omits_key( + self, + db_value, + expected, + ): + proxy_config = self._make_proxy_config_with_yaml_keys({"master_key"}) + test_general_settings = {"master_key": "sk-test"} + + with mock.patch("litellm.proxy.proxy_server.general_settings", test_general_settings): + await proxy_config._update_general_settings( + db_general_settings={"store_responses_in_spend_logs": db_value}, + ) + + assert test_general_settings["store_responses_in_spend_logs"] is expected diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index 29d199ebc6f..34545ee5ad4 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -29,6 +29,7 @@ from litellm.proxy.spend_tracking.spend_tracking_utils import ( _sanitize_guardrail_information_for_spend_logs, _sanitize_request_body_for_spend_logs_payload, _should_store_prompts_and_responses_in_spend_logs, + _should_store_responses_in_spend_logs, get_logging_payload, get_spend_logs_id, ) @@ -1440,6 +1441,74 @@ def test_should_store_prompts_and_responses_in_spend_logs_case_insensitive_strin ), "Expected False (from env var) when key missing, got True" +@pytest.mark.parametrize( + ("settings", "expected"), + [ + ({"store_prompts_in_spend_logs": True}, True), + ({"store_prompts_in_spend_logs": False}, False), + ( + { + "store_prompts_in_spend_logs": True, + "store_responses_in_spend_logs": False, + }, + False, + ), + ( + { + "store_prompts_in_spend_logs": False, + "store_responses_in_spend_logs": True, + }, + True, + ), + ( + { + "store_prompts_in_spend_logs": True, + "store_responses_in_spend_logs": "FALSE", + }, + False, + ), + ], +) +def test_should_store_responses_in_spend_logs( + settings, + expected, +): + with patch("litellm.proxy.proxy_server.general_settings", settings): + assert _should_store_responses_in_spend_logs() is expected + + +def test_spend_logs_can_store_request_without_response(): + settings = { + "store_prompts_in_spend_logs": True, + "store_responses_in_spend_logs": False, + } + kwargs = { + "litellm_params": { + "proxy_server_request": { + "body": { + "model": "gpt-5-mini", + "messages": [{"role": "user", "content": "Hello!"}], + } + } + } + } + payload = cast( + StandardLoggingPayload, + {"response": {"role": "assistant", "content": "Hi there!"}}, + ) + + with patch("litellm.proxy.proxy_server.general_settings", settings): + request_result = _get_proxy_server_request_for_spend_logs_payload( + metadata={}, + litellm_params=kwargs["litellm_params"], + kwargs=kwargs, + ) + response_result = _get_response_for_spend_logs_payload(payload=payload, kwargs=kwargs) + + assert json.loads(request_result)["messages"] == [{"role": "user", "content": "Hello!"}] + assert response_result == "{}" + + def test_get_spend_logs_metadata_guardrail_info_fallback_from_metadata(): """ When standard_logging_payload is None (e.g. guardrail blocks before LLM call), diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 24ed24512dc..b827abe93f1 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -25152,6 +25152,11 @@ export interface components { * @description If True, stores request messages and responses in spend logs. Default is False. */ store_prompts_in_spend_logs?: boolean | null; + /** + * Store Responses In Spend Logs + * @description Controls response content storage in spend logs independently. When unset, follows store_prompts_in_spend_logs for backward compatibility. + */ + store_responses_in_spend_logs?: boolean | null; /** * Supported Db Objects * @description Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map', 'tools', 'config_overrides'. If not set, all objects are loaded (default behavior).