mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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
This commit is contained in:
parent
658f50663d
commit
9b6742b6d8
11 changed files with 424 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']).",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -34,12 +34,16 @@ const buildSettingsResponse = (overrides?: Partial<Record<string, unknown>>) =>
|
|||
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(<UISettings />);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
checked={Boolean(values.forward_spend_logs_metadata_to_llm_api)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleForwardSpendLogsMetadata}
|
||||
ariaLabel={forwardSpendLogsMetadataProperty?.description ?? "Forward spend logs metadata to LLM API"}
|
||||
label="Forward spend logs metadata to LLM API"
|
||||
description={
|
||||
forwardSpendLogsMetadataProperty?.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."
|
||||
}
|
||||
/>
|
||||
{enableProjectsUIProperty && (
|
||||
<SettingRow
|
||||
checked={Boolean(values.enable_projects_ui)}
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -25658,6 +25658,11 @@ export interface components {
|
|||
* @description If True, forwards client headers (e.g. Authorization) to the LLM API. Required for Claude Code with Max subscription.
|
||||
*/
|
||||
forward_client_headers_to_llm_api?: boolean | null;
|
||||
/**
|
||||
* Forward Spend Logs Metadata To Llm Api
|
||||
* @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.
|
||||
*/
|
||||
forward_spend_logs_metadata_to_llm_api?: boolean | null;
|
||||
/**
|
||||
* Global Max Parallel Requests
|
||||
* @description global max parallel requests to allow for a proxy instance.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue