From 9b6742b6d842b25c6817b6d87c17ca235bcca4e2 Mon Sep 17 00:00:00 2001 From: Yucheng He Date: Thu, 3 Sep 2026 00:51:53 -0700 Subject: [PATCH] feat(proxy): forward spend logs metadata to an upstream LiteLLM proxy In a proxy-to-proxy setup the upstream proxy recorded only its own key's metadata, so per-user attribution set on a downstream virtual key never reached the upstream SpendLogs. `forward_client_headers_to_llm_api` only relays headers the caller sent, and `spend_logs_metadata` resolved from the downstream key or team is never a caller header, so it had no way out. Adds `general_settings.forward_spend_logs_metadata_to_llm_api` (off by default, with an Admin UI toggle). When on, the downstream emits the request's resolved `spend_logs_metadata` as `x-litellm-spend-logs-metadata` on the outbound call, which the upstream already reads into its own SpendLogs. The emit runs after the key and team merges, so both proxies log the same values, and caller-supplied values still win over key and team ones. Values above 4KB once encoded are dropped with a warning rather than sent, since an oversized header would make the upstream reject the request. Resolves LIT-3371 --- litellm/constants.py | 8 + litellm/proxy/_types.py | 9 + litellm/proxy/litellm_pre_call_utils.py | 85 ++++++- litellm/proxy/proxy_server.py | 1 + .../proxy_setting_endpoints.py | 12 + litellm/responses/utils.py | 3 +- .../proxy/test_litellm_pre_call_utils.py | 212 ++++++++++++++++++ tests/test_litellm/proxy/test_proxy_server.py | 33 +++ .../UISettings/UISettings.test.tsx | 32 +++ .../AdminSettings/UISettings/UISettings.tsx | 26 +++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 5 + 11 files changed, 424 insertions(+), 2 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index ef9329b9dfc..a1786d6e214 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1514,6 +1514,14 @@ ALLOWED_VERTEX_AI_PASSTHROUGH_HEADERS: Final = { # Works for all LLM pass-through endpoints (Vertex AI, Anthropic, Bedrock, etc.) PASS_THROUGH_HEADER_PREFIX: Final = "x-pass-" +# Request header an upstream LiteLLM proxy reads to populate SpendLogs `spend_logs_metadata`. +SPEND_LOGS_METADATA_HEADER_NAME: Final = "x-litellm-spend-logs-metadata" +# Cap on the JSON-encoded `x-litellm-spend-logs-metadata` value a downstream proxy emits. +# Kept well under the 8KB per-header limit common to uvicorn/nginx/ALB so an oversized +# key or team `spend_logs_metadata` is dropped with a warning instead of making the +# upstream reject the whole request. +MAX_SPEND_LOGS_METADATA_HEADER_BYTES: Final = 4096 + BASE_MCP_ROUTE: Final = "/mcp" BATCH_STATUS_POLL_INTERVAL_SECONDS: Final = int(os.getenv("BATCH_STATUS_POLL_INTERVAL_SECONDS", 3600)) # 1 hour diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 849e54c65aa..4f055e4c1e6 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2704,6 +2704,15 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): None, description="If True, forwards client headers (e.g. Authorization) to the LLM API. Required for Claude Code with Max subscription.", ) + forward_spend_logs_metadata_to_llm_api: bool | None = Field( + None, + description=( + "If True, sends the request's resolved spend_logs_metadata (key, team and caller values merged) to the " + "LLM API as the x-litellm-spend-logs-metadata header, so an upstream LiteLLM proxy records it in its own " + "SpendLogs. Proxy-wide: only enable it when the configured upstreams are LiteLLM proxies you trust with " + "those identifiers. Default is False." + ), + ) mcp_required_fields: list[str] | None = Field( None, description="List of MCP server fields that must be filled in for a submission to pass standards checks (e.g. ['description', 'source_url', 'alias']).", diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 1d440448c2f..c5243685622 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -21,10 +21,12 @@ from litellm.constants import ( CONSUMED_REQUEST_TAGS_METADATA_KEY, INTERNAL_CALL_ORIGIN_METADATA_KEY, LITELLM_PROXY_MASTER_KEY_ALIAS, + MAX_SPEND_LOGS_METADATA_HEADER_BYTES, OTEL_SERVICE_NAME_METADATA_KEYS, PRE_CALL_EXECUTED_GUARDRAILS_KEY, SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY, SESSION_ID_GENERATED_METADATA_KEY, + SPEND_LOGS_METADATA_HEADER_NAME, ) from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.litellm_core_utils.initialize_dynamic_callback_params import ( @@ -1092,7 +1094,7 @@ class LiteLLMProxyRequestSetup: """ from litellm.litellm_core_utils.safe_json_loads import safe_json_loads - spend_logs_metadata_header: Final = headers.get("x-litellm-spend-logs-metadata", None) + spend_logs_metadata_header: Final = headers.get(SPEND_LOGS_METADATA_HEADER_NAME, None) if spend_logs_metadata_header is not None: return safe_json_loads(spend_logs_metadata_header) return None @@ -1213,6 +1215,79 @@ class LiteLLMProxyRequestSetup: return returned_headers + @staticmethod + def add_spend_logs_metadata_to_llm_call_headers( + data: MutableMapping[str, object], # mutable-ok: this helper writes the outbound header into it + _metadata_variable_name: str, + general_settings: Mapping[str, object] | None, + ) -> None: + """ + Emit the request's resolved ``spend_logs_metadata`` as the + ``x-litellm-spend-logs-metadata`` header on the outbound LLM call. + + Proxy-to-proxy attribution: an upstream LiteLLM proxy reads that header in + ``_get_spend_logs_metadata_from_request_headers`` and stores the values in its + own SpendLogs row. ``forward_client_headers_to_llm_api`` only relays headers + the client itself sent, so ``spend_logs_metadata`` the downstream resolved from + the virtual key or the team never reached the upstream. + + Must run after every key/team ``spend_logs_metadata`` merge so the header + carries the same values the downstream writes to its own SpendLogs. The + resolved dict already merges caller, key and team values with key/team losing + to the caller, so the upstream sees one deterministic namespace. + + Opt-in via ``general_settings.forward_spend_logs_metadata_to_llm_api``: the + values are customer identifiers and the header is sent to every configured + provider, not only to LiteLLM upstreams. + """ + if not general_settings or general_settings.get("forward_spend_logs_metadata_to_llm_api") is not True: + return + + metadata: Final = data.get(_metadata_variable_name) + if not isinstance(metadata, dict): + return + spend_logs_metadata: Final = metadata.get("spend_logs_metadata") + if not isinstance(spend_logs_metadata, dict) or not spend_logs_metadata: + return + + try: + encoded: Final = json.dumps(spend_logs_metadata) + except (TypeError, ValueError): + verbose_proxy_logger.warning( + "spend_logs_metadata is not JSON-serializable, not forwarding it to the LLM API" + ) + return + encoded_size: Final = len(encoded.encode("utf-8")) + if encoded_size > MAX_SPEND_LOGS_METADATA_HEADER_BYTES: + verbose_proxy_logger.warning( + "spend_logs_metadata is %s bytes, above the %s byte header limit, not forwarding it to the LLM API", + encoded_size, + MAX_SPEND_LOGS_METADATA_HEADER_BYTES, + ) + return + + existing_headers: Final = data.get("headers") + if isinstance(existing_headers, dict): + existing_headers[SPEND_LOGS_METADATA_HEADER_NAME] = encoded + else: + # Every provider reads `headers or litellm.headers`, replacing rather than + # merging, so creating this dict from scratch would drop the operator's + # `litellm_settings.headers` from every request the flag applies to. + emitted: Final = dict(litellm.headers or {}) + emitted[SPEND_LOGS_METADATA_HEADER_NAME] = encoded + data["headers"] = emitted # rebind-ok: emitting this header is what this helper is for + + # `extra_headers` beats `headers` in every provider handler, so a caller that put + # this header in the request body would otherwise overwrite the proxy's resolved + # value and forge the attribution the upstream records. The proxy owns this header; + # a caller contributes through the `x-litellm-spend-logs-metadata` request header, + # which is merged above and loses to nothing. + caller_extra_headers: Final = data.get("extra_headers") + if isinstance(caller_extra_headers, dict): + for key in caller_extra_headers: + if isinstance(key, str) and key.lower() == SPEND_LOGS_METADATA_HEADER_NAME: + caller_extra_headers[key] = encoded + @staticmethod def add_headers_to_llm_call_by_model_group(data: dict, headers: dict, user_api_key_dict: UserAPIKeyAuth) -> dict: """ @@ -2267,6 +2342,14 @@ async def add_litellm_data_to_request( user_api_key_dict=user_api_key_dict, ) + # Runs after the key/team spend_logs_metadata merges above so the forwarded header + # carries the same values this proxy writes to its own SpendLogs. + LiteLLMProxyRequestSetup.add_spend_logs_metadata_to_llm_call_headers( + data=data, + _metadata_variable_name=_metadata_variable_name, + general_settings=general_settings, + ) + verbose_proxy_logger.debug("[PROXY] returned data from litellm_pre_call_utils: %s", data) # Team/Project credential overrides from model_config diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 27132c90e05..16c7ea0af80 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -16512,6 +16512,7 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro "mcp_xff_num_trusted_hops": "Integer", "always_include_stream_usage": "Boolean", "forward_client_headers_to_llm_api": "Boolean", + "forward_spend_logs_metadata_to_llm_api": "Boolean", "mcp_required_fields": "List", "cancel_on_disconnect": "Boolean", "disable_auto_add_proxy_admin_to_teams": "Boolean", diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index c12d071dd36..bbf0382cd4a 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -264,6 +264,16 @@ class UISettings(BaseModel): ), ) + forward_spend_logs_metadata_to_llm_api: bool = Field( + default=False, + description=( + "Sends the request's resolved spend_logs_metadata (key, team and caller values merged) to the upstream " + "LLM as the x-litellm-spend-logs-metadata header, so an upstream LiteLLM proxy records it in its own " + "SpendLogs. Use for proxy-to-proxy setups where per-user attribution has to survive the hop. Proxy-wide, " + "so enable it only when the configured upstreams are LiteLLM proxies you trust with those identifiers." + ), + ) + disable_agents_for_internal_users: bool = Field( default=False, description="If true, internal users cannot access agent management endpoints or the Agents page in the UI.", @@ -317,6 +327,7 @@ ALLOWED_UI_SETTINGS_FIELDS: Final = { "require_auth_for_public_ai_hub", "allow_public_health_readiness_details", "forward_client_headers_to_llm_api", + "forward_spend_logs_metadata_to_llm_api", "forward_llm_provider_auth_headers", "disable_agents_for_internal_users", "allow_agents_for_team_admins", @@ -354,6 +365,7 @@ def _derived_ui_setting_value(key: str) -> object: _RUNTIME_GENERAL_SETTINGS_FLAGS: Final = [ "allow_public_health_readiness_details", "forward_client_headers_to_llm_api", + "forward_spend_logs_metadata_to_llm_api", "forward_llm_provider_auth_headers", "disable_agents_for_internal_users", "allow_agents_for_team_admins", diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 39675faf735..1e53cfa8665 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -141,7 +141,8 @@ class ResponsesAPIRequestUtils: ) -> dict[str, object] | None: """ Merge headers forwarded by the proxy (`headers` kwarg, set when - `forward_client_headers_to_llm_api` is enabled) into `extra_headers`. + `forward_client_headers_to_llm_api` or `forward_spend_logs_metadata_to_llm_api` + is enabled) into `extra_headers`. `extra_headers` wins on conflicts, since it is set explicitly by the caller. Header names are compared case-insensitively, as HTTP defines them. diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 8366e5546a9..85fbde911e2 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -14,6 +14,7 @@ from pydantic import ValidationError as PydanticValidationError from starlette.datastructures import Headers import litellm +from litellm.constants import MAX_SPEND_LOGS_METADATA_HEADER_BYTES, SPEND_LOGS_METADATA_HEADER_NAME from litellm.proxy._types import AddTeamCallback, ProxyException, TeamCallbackMetadata, UserAPIKeyAuth from litellm.proxy.litellm_pre_call_utils import ( KeyAndTeamLoggingSettings, @@ -7895,3 +7896,214 @@ async def test_missing_session_id_unknown_value_is_ignored(): ) assert "session_id" not in updated["metadata"] + + +def _spend_logs_metadata_request(header_value: str | None = None) -> MagicMock: + request = _request_for("/v1/chat/completions") + headers = {"Content-Type": "application/json"} + if header_value is not None: + headers[SPEND_LOGS_METADATA_HEADER_NAME] = header_value + request.headers = headers + return request + + +def _proxy_chain_auth( + metadata: dict | None = None, + team_metadata: dict | None = None, +) -> UserAPIKeyAuth: + """Downstream virtual key carrying per-user attribution, plus a team-level default.""" + return UserAPIKeyAuth( + api_key="hashed-key", + metadata=( + metadata + if metadata is not None + else {"spend_logs_metadata": {"user_id": "U0099887", "username": "jdoe"}} + ), + team_metadata=( + team_metadata + if team_metadata is not None + else {"spend_logs_metadata": {"cost_center": "CC-42", "user_id": "team-default"}} + ), + ) + + +@pytest.mark.asyncio +async def test_forward_spend_logs_metadata_covers_litellm_metadata_routes(): + """ + `/v1/responses` and `/v1/messages` keep request metadata under `litellm_metadata`, not + `metadata`, so reading the slot by name is what makes the feature work on them at all. + """ + request = _spend_logs_metadata_request() + request.scope = {"path": "/v1/responses"} + request.url.path = "/v1/responses" + + updated = await add_litellm_data_to_request( + data={"model": "gpt-4o", "input": "hi"}, + request=request, + user_api_key_dict=_proxy_chain_auth(), + proxy_config=MagicMock(), + general_settings={"forward_spend_logs_metadata_to_llm_api": True}, + ) + + assert "litellm_metadata" in updated, "route should resolve to the litellm_metadata slot" + forwarded = json.loads(updated["headers"][SPEND_LOGS_METADATA_HEADER_NAME]) + assert forwarded == updated["litellm_metadata"]["spend_logs_metadata"] + assert forwarded["user_id"] == "U0099887" + + +@pytest.mark.asyncio +async def test_forward_spend_logs_metadata_overrides_caller_supplied_extra_headers(): + """ + `extra_headers` in the request body wins over `headers` in every provider handler, so a + caller could otherwise forge the attribution the upstream proxy records for them. + """ + updated = await add_litellm_data_to_request( + data={ + "model": "gpt-4o", + "messages": [], + "extra_headers": {SPEND_LOGS_METADATA_HEADER_NAME: json.dumps({"user_id": "forged"})}, + }, + request=_spend_logs_metadata_request(), + user_api_key_dict=_proxy_chain_auth(), + proxy_config=MagicMock(), + general_settings={"forward_spend_logs_metadata_to_llm_api": True}, + ) + + resolved = json.loads(updated["headers"][SPEND_LOGS_METADATA_HEADER_NAME]) + assert resolved["user_id"] == "U0099887" + forged = json.loads(updated["extra_headers"][SPEND_LOGS_METADATA_HEADER_NAME]) + assert forged == resolved, "the proxy's resolved value must win over the request body" + + +@pytest.mark.asyncio +async def test_forward_spend_logs_metadata_keeps_globally_configured_headers(): + """ + Providers read `headers or litellm.headers`, replacing rather than merging, so creating + `data["headers"]` here would silently drop `litellm_settings.headers` from the call. + """ + original = litellm.headers + litellm.headers = {"x-org-routing": "eu"} + try: + updated = await add_litellm_data_to_request( + data={"model": "gpt-4o", "messages": []}, + request=_spend_logs_metadata_request(), + user_api_key_dict=_proxy_chain_auth(), + proxy_config=MagicMock(), + general_settings={"forward_spend_logs_metadata_to_llm_api": True}, + ) + finally: + litellm.headers = original + + assert updated["headers"]["x-org-routing"] == "eu" + assert json.loads(updated["headers"][SPEND_LOGS_METADATA_HEADER_NAME])["user_id"] == "U0099887" + + +@pytest.mark.asyncio +async def test_forward_spend_logs_metadata_emits_resolved_key_and_team_values(): + """ + LIT-3371: proxy-to-proxy attribution. `forward_client_headers_to_llm_api` only relays + headers the caller sent, so `spend_logs_metadata` resolved from the downstream virtual + key or its team never reached the upstream proxy. The opt-in must emit the fully + resolved dict, which means it has to run after the key AND team merges. + """ + updated = await add_litellm_data_to_request( + data={"model": "gpt-4o", "messages": []}, + request=_spend_logs_metadata_request(header_value=json.dumps({"request_id": "req-1"})), + user_api_key_dict=_proxy_chain_auth(), + proxy_config=MagicMock(), + general_settings={"forward_spend_logs_metadata_to_llm_api": True}, + ) + + forwarded = json.loads(updated["headers"][SPEND_LOGS_METADATA_HEADER_NAME]) + assert forwarded == { + "request_id": "req-1", + "user_id": "U0099887", + "username": "jdoe", + "cost_center": "CC-42", + } + # the header must carry exactly what this proxy logs for itself + assert forwarded == updated["metadata"]["spend_logs_metadata"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "general_settings", + [ + {}, + {"forward_spend_logs_metadata_to_llm_api": False}, + {"forward_client_headers_to_llm_api": True}, + ], +) +async def test_forward_spend_logs_metadata_is_opt_in(general_settings: dict[str, object]): + """The values are customer identifiers, so nothing is emitted without the explicit opt-in.""" + updated = await add_litellm_data_to_request( + data={"model": "gpt-4o", "messages": []}, + request=_spend_logs_metadata_request(), + user_api_key_dict=_proxy_chain_auth(), + proxy_config=MagicMock(), + general_settings=general_settings, + ) + + assert SPEND_LOGS_METADATA_HEADER_NAME not in updated.get("headers", {}) + assert updated["metadata"]["spend_logs_metadata"]["user_id"] == "U0099887" + + +@pytest.mark.asyncio +async def test_forward_spend_logs_metadata_keeps_other_forwarded_headers(): + """The emitted header is merged into the forwarded-header map, never replacing it.""" + request = _spend_logs_metadata_request() + request.headers = {"Content-Type": "application/json", "x-custom-trace": "trace-1"} + + updated = await add_litellm_data_to_request( + data={"model": "gpt-4o", "messages": []}, + request=request, + user_api_key_dict=_proxy_chain_auth(), + proxy_config=MagicMock(), + general_settings={ + "forward_client_headers_to_llm_api": True, + "forward_spend_logs_metadata_to_llm_api": True, + }, + ) + + assert updated["headers"]["x-custom-trace"] == "trace-1" + assert json.loads(updated["headers"][SPEND_LOGS_METADATA_HEADER_NAME])["user_id"] == "U0099887" + + +@pytest.mark.asyncio +async def test_forward_spend_logs_metadata_skips_oversized_value(): + """ + An oversized header makes the upstream reject the whole request, so drop the header + rather than the traffic. Everything else about the request is unchanged. + """ + oversized = {"blob": "x" * (MAX_SPEND_LOGS_METADATA_HEADER_BYTES + 1)} + request = _spend_logs_metadata_request() + request.headers = {"Content-Type": "application/json", "x-custom-trace": "trace-1"} + + updated = await add_litellm_data_to_request( + data={"model": "gpt-4o", "messages": []}, + request=request, + user_api_key_dict=_proxy_chain_auth(metadata={"spend_logs_metadata": oversized}, team_metadata={}), + proxy_config=MagicMock(), + general_settings={ + "forward_client_headers_to_llm_api": True, + "forward_spend_logs_metadata_to_llm_api": True, + }, + ) + + assert SPEND_LOGS_METADATA_HEADER_NAME not in updated["headers"] + assert updated["headers"]["x-custom-trace"] == "trace-1" + assert updated["metadata"]["spend_logs_metadata"] == oversized + + +@pytest.mark.asyncio +async def test_forward_spend_logs_metadata_noop_without_values(): + """A key with no spend_logs_metadata must not add an empty header.""" + updated = await add_litellm_data_to_request( + data={"model": "gpt-4o", "messages": []}, + request=_spend_logs_metadata_request(), + user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"), + proxy_config=MagicMock(), + general_settings={"forward_spend_logs_metadata_to_llm_api": True}, + ) + + assert SPEND_LOGS_METADATA_HEADER_NAME not in updated.get("headers", {}) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 4ed6a468371..d2d8b823180 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -9821,6 +9821,39 @@ def test_get_config_list_includes_apply_user_budget_to_team_keys(monkeypatch): app.dependency_overrides.clear() +def test_get_config_list_includes_forward_spend_logs_metadata_to_llm_api(monkeypatch): + """Resolves LIT-3371: the proxy-to-proxy opt-in must be discoverable via + /config/list so it renders as a Boolean toggle on the Admin UI General + Settings table. This needs both the ConfigGeneralSettings field and the + allowed_args entry; missing either silently hides it from the UI.""" + import types + from unittest.mock import AsyncMock, MagicMock + + from fastapi.testclient import TestClient + + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.proxy_server import app + + mock_prisma = MagicMock() + mock_config_table = MagicMock() + mock_config_table.find_first = AsyncMock(return_value=None) + mock_prisma.db = types.SimpleNamespace(litellm_config=mock_config_table) + monkeypatch.setattr(ps, "prisma_client", mock_prisma) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + client = TestClient(app) + resp = client.get("/config/list", params={"config_type": "general_settings"}) + assert resp.status_code == 200, resp.text + fields = {item["field_name"]: item for item in resp.json()} + assert "forward_spend_logs_metadata_to_llm_api" in fields + assert fields["forward_spend_logs_metadata_to_llm_api"]["field_type"] == "Boolean" + finally: + app.dependency_overrides.clear() + + def test_get_config_list_includes_budget_exceeded_throttle_percentage(monkeypatch): """The throttle fraction is a litellm_settings scalar surfaced on the General Settings table as a Float field so it sits with the other global limits; it diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx index c2834e65498..f24638405cd 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx @@ -34,12 +34,16 @@ const buildSettingsResponse = (overrides?: Partial>) => require_auth_for_public_ai_hub: { description: "Require authentication for public AI Hub", }, + forward_spend_logs_metadata_to_llm_api: { + description: "Forward spend logs metadata to LLM API", + }, }, }, values: { disable_model_add_for_internal_users: false, disable_team_admin_delete_team_user: false, require_auth_for_public_ai_hub: false, + forward_spend_logs_metadata_to_llm_api: false, }, }, isLoading: false, @@ -155,4 +159,32 @@ describe("UISettings", () => { ); expect(toast.success).toHaveBeenCalledWith("UI settings updated successfully"); }); + it("should toggle forward spend logs metadata setting and call update", () => { + const mutateMock = vi.fn((_settings, options) => { + options?.onSuccess?.(); + }); + + mockUseUpdateUISettings.mockReturnValue({ + mutate: mutateMock, + isPending: false, + error: null, + }); + + render(); + + const toggle = screen.getByRole("switch", { name: "Forward spend logs metadata to LLM API" }); + + act(() => { + fireEvent.click(toggle); + }); + + expect(mutateMock).toHaveBeenCalledWith( + { forward_spend_logs_metadata_to_llm_api: true }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + expect(toast.success).toHaveBeenCalledWith("UI settings updated successfully"); + }); }); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index 612ca05d083..42e1c775f5a 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -56,6 +56,7 @@ export default function UISettings() { const requireAuthForPublicAIHubProperty = schema?.properties?.require_auth_for_public_ai_hub; const forwardClientHeadersProperty = schema?.properties?.forward_client_headers_to_llm_api; const forwardLLMProviderAuthHeadersProperty = schema?.properties?.forward_llm_provider_auth_headers; + const forwardSpendLogsMetadataProperty = schema?.properties?.forward_spend_logs_metadata_to_llm_api; const enableProjectsUIProperty = schema?.properties?.enable_projects_ui; const enableChatUIProperty = schema?.properties?.enable_chat_ui; const enabledPagesProperty = schema?.properties?.enabled_ui_pages_internal_users; @@ -138,6 +139,20 @@ export default function UISettings() { ); }; + const handleToggleForwardSpendLogsMetadata = (checked: boolean) => { + updateSettings( + { forward_spend_logs_metadata_to_llm_api: checked }, + { + onSuccess: () => { + toast.success("UI settings updated successfully"); + }, + onError: (error) => { + toast.fromError(error); + }, + }, + ); + }; + const handleToggleEnableProjectsUI = (checked: boolean) => { updateSettings( { enable_projects_ui: checked }, @@ -341,6 +356,17 @@ export default function UISettings() { "Forwards provider auth headers (x-api-key, x-goog-api-key, api-key, ocp-apim-subscription-key) to the upstream LLM, overriding any deployment-configured key for that request. Enable for Claude Code BYOK (clients bring their own API key). Independent of the client-headers toggle — enable only the one(s) you need." } /> + {enableProjectsUIProperty && (