fix(proxy)!: log and bill Responses-style bodies sent to /cursor/chat/completions (#30277)

* fix(proxy): restore spend logging for /cursor/chat/completions (#30126)

The /cursor/chat/completions route processes requests through the Responses API pipeline, but _get_metadata_variable_name did not treat it as a litellm_metadata route. Proxy auth metadata therefore landed in the user-facing metadata param, so the spend tracking callback saw user_api_key=None and silently skipped the SpendLogs write. Add the route to LITELLM_METADATA_ROUTES and cover it with regression tests.

* test(proxy): pin provider metadata and key billing on /cursor/chat/completions

A Responses-format body on the Cursor route now sends OpenAI only the metadata the client sent and writes a spend row that bills the key. The chat-body branch, which this fix also moves onto litellm_metadata, still bills the key with its team and request tags. The scripted chat reply gets a unique id per test, since a shared id let only the first spend row per database land

* fix(proxy): pick the Cursor route's metadata field from the body shape

A Responses-style body on /cursor/chat/completions keeps proxy metadata in
litellm_metadata, the same as /v1/responses, while a Chat Completions body
keeps metadata, the same as /v1/chat/completions. Adding the whole route to
the internal-metadata list let a client's chat-body tags replace the key's
tags in spend logs and skip tag budgets.

* fix(proxy): keep get_metadata_variable_name callable with only the request

* fix(proxy): check client tags against tag budgets and read agent limits from litellm_metadata on Responses bodies

A Responses-style body on /cursor/chat/completions now keeps its proxy fields in litellm_metadata, so the pre-call tag budget check and the v3 agent rate limiter stopped seeing the client's metadata.tags and the x-litellm-agent-id value. The pre-auth tag check now merges the caller's metadata.tags into the selected metadata field, sharing one helper with request setup, and the limiter reads agent_id from either field

* fix(proxy): check client tags sent in a JSON-string metadata against tag budgets on Responses bodies

* fix(proxy): answer 422, not 500, when a budget rejects a Responses body whose metadata is a JSON string

* fix(proxy): keep key guardrails, metadata type errors and failure-row client details on Responses-style bodies

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
Filip Sinitsin 2026-10-30 07:18:15 +00:00 • committed by GitHub
parent cfb77ecc3a
commit e81dd348a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 482 additions and 91 deletions

View file

@ -9,6 +9,7 @@ from types import MappingProxyType
from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, Optional, cast, get_args
import httpx
from pydantic import TypeAdapter
from litellm._internal_context import with_service_target
from litellm._logging import verbose_logger
@ -218,6 +219,15 @@ def _request_is_streaming(data: object, event_type: GuardrailEventHooks | None =
)
_REQUEST_FIELDS: Final = TypeAdapter(Mapping[str, object])
def _guardrails_listed_in(metadata: object) -> list[str | dict[str, DynamicGuardrailParams]]:
if not isinstance(metadata, dict):
return []
return metadata.get("guardrails") or []
class CustomGuardrail(CustomLogger):
# If True, during_call runs async_moderation_hook instead of the unified apply_guardrail path.
use_native_during_call_hook: ClassVar[bool] = False
@ -791,28 +801,22 @@ class CustomGuardrail(CustomLogger):
return True
raise
def get_guardrail_from_metadata(self, data: dict) -> list[str] | list[dict[str, DynamicGuardrailParams]]:
def get_guardrail_from_metadata(self, data: dict) -> list[str | dict[str, DynamicGuardrailParams]]:
"""
Returns the guardrail(s) to be run from the metadata or root
Returns the guardrail(s) to be run from the root, else from both metadata fields together
"""
if "guardrails" in data:
return data["guardrails"]
# Check both metadata locations. For regular endpoints move_guardrails_to_metadata
# writes to "metadata"; for thread/assistant endpoints it writes to
# "litellm_metadata". We check the one that actually contains the "guardrails"
# key so that a non-empty litellm_metadata without guardrails does not shadow
# the merged list stored in metadata (which would cause team guardrails to be
# silently skipped while default_on=True policy guardrails still fire).
for meta_key in ("metadata", "litellm_metadata"):
meta = data.get(meta_key) or {}
if isinstance(meta, dict) and "guardrails" in meta:
return meta.get("guardrails") or []
return []
request_fields: Final = _REQUEST_FIELDS.validate_python(data)
return [
*_guardrails_listed_in(request_fields.get("metadata")),
*_guardrails_listed_in(request_fields.get("litellm_metadata")),
]
def _guardrail_is_in_requested_guardrails(
self,
requested_guardrails: list[str] | list[dict[str, DynamicGuardrailParams]],
requested_guardrails: Sequence[str | dict[str, DynamicGuardrailParams]],
) -> bool:
for _guardrail in requested_guardrails:
if isinstance(_guardrail, dict):

View file

@ -446,7 +446,7 @@ def _request_has_trace_id(request_body: Mapping[str, object], request: Request,
from litellm.proxy.litellm_pre_call_utils import (
_trace_id_from_traceparent, # pyright: ignore[reportPrivateUsage] # reuse W3C traceparent parser
get_chain_id_from_headers,
metadata_variable_name_for_route,
metadata_variable_name_for_request,
)
if get_chain_id_from_headers(dict(request.headers)):
@ -460,7 +460,7 @@ def _request_has_trace_id(request_body: Mapping[str, object], request: Request,
traceparent: Final[str | None] = request.headers.get("traceparent")
if isinstance(traceparent, str) and _trace_id_from_traceparent(traceparent):
return True
metadata_variable_name: Final = metadata_variable_name_for_route(get_request_route(request))
metadata_variable_name: Final = metadata_variable_name_for_request(get_request_route(request), request_body)
selected_metadata: Final[object] = request_body.get(metadata_variable_name)
if isinstance(selected_metadata, dict):
selected_metadata_dict: Final = _TRACE_ID_METADATA_ADAPTER.validate_python(selected_metadata)
@ -1367,11 +1367,15 @@ async def common_checks(
if not skip_all_budget_checks:
# Key metadata.tags are injected into request_body here so the tag budget
# check can read them; this mutation must run before the gathered checks.
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup, metadata_variable_name_for_request
if valid_token is not None:
for tag_target in LiteLLMProxyRequestSetup.pre_auth_tag_merge_targets(request, request_body):
LiteLLMProxyRequestSetup.pre_seed_litellm_metadata_for_route(request_data=tag_target, route=route)
LiteLLMProxyRequestSetup.merge_caller_body_tags(
request_data=tag_target,
metadata_variable_name=metadata_variable_name_for_request(route, tag_target),
)
LiteLLMProxyRequestSetup.apply_key_tags_pre_auth(request_data=tag_target, user_api_key_dict=valid_token)
LiteLLMProxyRequestSetup.apply_authenticated_baggage_tags(request, request_body)

View file

@ -2401,9 +2401,10 @@ class ProxyBaseLLMRequestProcessing:
version=version,
proxy_config=proxy_config,
)
request_fields: Final = _REQUEST_FIELDS.validate_python(self.data)
metadata_variable_name: Final = get_metadata_variable_name(request, request_fields)
if logging_auth is not None:
metadata_variable_name: Final = get_metadata_variable_name(request)
request_metadata: Final = self.data.get(metadata_variable_name)
request_metadata: Final = request_fields.get(metadata_variable_name)
if isinstance(request_metadata, dict):
self.data[metadata_variable_name] = {
**TypeAdapter(dict[str, object]).validate_python(request_metadata),
@ -2431,12 +2432,11 @@ class ProxyBaseLLMRequestProcessing:
# Store queue time in metadata after add_litellm_data_to_request to ensure it's preserved
if queue_time_seconds is not None:
_metadata_variable_name: Final = get_metadata_variable_name(request)
if _metadata_variable_name not in self.data:
self.data[_metadata_variable_name] = {}
if not isinstance(self.data[_metadata_variable_name], dict):
self.data[_metadata_variable_name] = {}
self.data[_metadata_variable_name]["queue_time_seconds"] = queue_time_seconds
if metadata_variable_name not in self.data:
self.data[metadata_variable_name] = {}
if not isinstance(self.data[metadata_variable_name], dict):
self.data[metadata_variable_name] = {}
self.data[metadata_variable_name]["queue_time_seconds"] = queue_time_seconds
if isinstance(model, str):
reject_url_valued_destination("model", model)

View file

@ -3312,7 +3312,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
if key_agent_id:
return key_agent_id
metadata: Final = data.get("metadata") or {}
return metadata.get("agent_id")
litellm_metadata: Final = data.get("litellm_metadata") or {}
return metadata.get("agent_id") or litellm_metadata.get("agent_id")
def _get_session_id_from_data(self, data: dict) -> str | None:
"""Extract session_id from request metadata or litellm_session_id."""

View file

@ -37,7 +37,7 @@ from litellm.proxy.db.db_spend_update_writer import (
get_llm_router,
)
from litellm.proxy.hooks.search_free_tier import apply_search_free_tier
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup, metadata_variable_name_for_route
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup, metadata_variable_name_for_request
from litellm.proxy.route_llm_request import ProxyModelNotFoundError
from litellm.proxy.spend_tracking.spend_counter_batch import post_call_counter_keys, spend_counter_batch_scope
from litellm.proxy.spend_tracking.spend_event import (
@ -137,19 +137,34 @@ _CAPTURED_IDENTITY_CALL_TYPES: Final[frozenset[str]] = frozenset(
_OBJECT_MAPPING: Final = TypeAdapter(ObjectMapping)
def _proxy_stamped_used_client_oauth_token(
request_data: Mapping[str, object], request_route: str | None
) -> bool | None:
def _proxy_stamped_metadata(request_data: Mapping[str, object], request_route: str | None) -> Mapping[str, object]:
proxy_bucket: Final = (
get_metadata_variable_name_from_kwargs(request_data)
if request_route is None
else metadata_variable_name_for_route(request_route)
else metadata_variable_name_for_request(request_route, request_data)
)
proxy_metadata: Final = request_data.get(proxy_bucket)
stamped: Final = proxy_metadata.get("used_client_oauth_token") if isinstance(proxy_metadata, dict) else None
return _str_keyed_fields(request_data.get(proxy_bucket))
def _proxy_stamped_used_client_oauth_token(
request_data: Mapping[str, object], request_route: str | None
) -> bool | None:
stamped: Final = _proxy_stamped_metadata(request_data, request_route).get("used_client_oauth_token")
return stamped if isinstance(stamped, bool) else None
_PROXY_STAMPED_REQUEST_FIELDS: Final = ("requester_ip_address", "user_agent")
def _proxy_stamped_request_fields(
request_data: Mapping[str, object], request_route: str | None
) -> Mapping[str, object]:
if request_route is None:
return {}
proxy_metadata: Final = _proxy_stamped_metadata(request_data, request_route)
return {field: proxy_metadata[field] for field in _PROXY_STAMPED_REQUEST_FIELDS if field in proxy_metadata}
def _str_keyed_fields(value: object) -> Mapping[str, object]:
return value if is_str_keyed_mapping(value) else {}
@ -455,7 +470,13 @@ class _ProxyDBLogger(CustomLogger):
_metadata["used_client_oauth_token"] = _proxy_stamped_used_client_oauth_token(request_fields, request_route)
existing_metadata: Final[dict] = request_data.get("metadata", None) or {}
existing_metadata.update({**_routed_metadata_fallback(request_fields), **_metadata})
existing_metadata.update(
{
**_routed_metadata_fallback(request_fields),
**_proxy_stamped_request_fields(request_fields, request_route),
**_metadata,
}
)
litellm_metadata_bucket: Final = request_data.get("litellm_metadata")
litellm_metadata: Final[ObjectMapping] = (

View file

@ -379,6 +379,8 @@ LITELLM_METADATA_ROUTES: Final = (
"interactions",
)
CURSOR_CHAT_COMPLETIONS_ROUTE: Final = "/cursor/chat/completions"
LITELLM_TRACE_CONTROL_METADATA_FIELDS: Final = frozenset(
{
"mask_input",
@ -636,7 +638,7 @@ def _has_tag_budget(tag: "LiteLLM_TagTable | None") -> bool:
return tag.litellm_budget_table.max_budget is not None
def _normalized_metadata_slot(
def normalized_metadata_slot(
request_data: MutableMapping[str, object], metadata_variable_name: str
) -> dict[str, object]:
"""Return the request's metadata slot as a dict, normalising it in place first.
@ -653,6 +655,12 @@ def _normalized_metadata_slot(
return normalized
def _holds_metadata_object(value: object) -> bool:
if value is None or isinstance(value, dict):
return True
return isinstance(value, str) and isinstance(_DECODED_JSON.validate_python(safe_json_loads(value)), dict)
def _metadata_object_or_error(field: str, value: object) -> Mapping[str, object] | ProxyException:
if value is None:
return {}
@ -863,7 +871,7 @@ def _strip_router_reserved_metadata(
)
def get_metadata_variable_name(request: Request) -> str:
def get_metadata_variable_name(request: Request, data: Mapping[str, object] | None = None) -> str:
"""
Helper to return what the "metadata" field should be called in the request data
@ -874,7 +882,10 @@ def get_metadata_variable_name(request: Request) -> str:
# Inline imports — auth_utils/route_checks participate in a proxy import cycle.
from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415
return metadata_variable_name_for_route(get_request_route(request))
route: Final = get_request_route(request)
if data is None:
return metadata_variable_name_for_route(route)
return metadata_variable_name_for_request(route, data)
_get_metadata_variable_name: Final = get_metadata_variable_name
@ -890,6 +901,25 @@ def metadata_variable_name_for_route(route: str) -> Literal["metadata", "litellm
return "metadata"
def is_chat_completions_body(data: Mapping[str, object]) -> bool:
messages: Final = data.get("messages")
if isinstance(messages, list) and messages:
return True
return "messages" in data and "input" not in data
def is_cursor_responses_request(route: str, body: Mapping[str, object]) -> bool:
return CURSOR_CHAT_COMPLETIONS_ROUTE in route and not is_chat_completions_body(body)
def metadata_variable_name_for_request(
route: str, body: Mapping[str, object]
) -> Literal["metadata", "litellm_metadata"]:
if is_cursor_responses_request(route, body):
return "litellm_metadata"
return metadata_variable_name_for_route(route)
def _promoted_trace_control_fields(
requester_metadata: Mapping[str, object],
litellm_metadata: Mapping[str, object],
@ -2309,7 +2339,9 @@ class LiteLLMProxyRequestSetup:
``_tag_max_budget_check`` resolve to ``litellm_metadata``, leaving
header tags invisible to per-tag budget enforcement.
"""
if any(metadata_route in route for metadata_route in LITELLM_METADATA_ROUTES):
if any(metadata_route in route for metadata_route in LITELLM_METADATA_ROUTES) or is_cursor_responses_request(
route, request_data
):
request_data.setdefault("litellm_metadata", {})
@staticmethod
@ -2380,7 +2412,7 @@ class LiteLLMProxyRequestSetup:
return
tags: Final = _STRING_TAGS_ADAPTER.validate_python(raw_tags)
slot: Final = metadata_key if metadata_key is not None else get_metadata_variable_name_from_kwargs(request_data)
metadata: Final = _normalized_metadata_slot(request_data, slot)
metadata: Final = normalized_metadata_slot(request_data, slot)
existing: Final = metadata.get("tags")
if existing and not isinstance(existing, list):
return
@ -2393,6 +2425,34 @@ class LiteLLMProxyRequestSetup:
tags=tuple(tag for tag in tags if tag not in sent_tags)
)
@staticmethod
def merge_caller_body_tags(
request_data: MutableMapping[str, object], # mutable-ok: the request body the tag merges write into
metadata_variable_name: str | None = None,
) -> None:
slot: Final = (
metadata_variable_name
if metadata_variable_name is not None
else get_metadata_variable_name_from_kwargs(request_data)
)
if slot == "metadata" or not _holds_metadata_object(request_data.get(slot)):
return
raw_caller_metadata: Final = request_data.get("metadata")
caller_metadata: Final = (
_DECODED_JSON.validate_python(safe_json_loads(raw_caller_metadata))
if isinstance(raw_caller_metadata, str)
else raw_caller_metadata
)
caller_tags: Final = caller_metadata.get("tags") if isinstance(caller_metadata, dict) else None
if not isinstance(caller_tags, list) or not caller_tags:
return
metadata: Final = normalized_metadata_slot(request_data, slot)
existing_tags: Final = metadata.get("tags")
metadata["tags"] = LiteLLMProxyRequestSetup.merge_tags(
request_tags=existing_tags if isinstance(existing_tags, list) else None,
tags_to_add=caller_tags,
)
@staticmethod
def apply_key_tags_pre_auth(
request_data: dict,
@ -2408,7 +2468,7 @@ class LiteLLMProxyRequestSetup:
return
_metadata_variable_name: Final = get_metadata_variable_name_from_kwargs(request_data)
metadata: Final = _normalized_metadata_slot(request_data, _metadata_variable_name)
metadata: Final = normalized_metadata_slot(request_data, _metadata_variable_name)
existing_tags: Final = metadata.get("tags")
metadata["tags"] = LiteLLMProxyRequestSetup.merge_tags(
@ -2460,7 +2520,7 @@ class LiteLLMProxyRequestSetup:
# from (litellm_metadata vs metadata) so the merged tags are visible
# to _tag_max_budget_check.
_metadata_variable_name: Final = get_metadata_variable_name_from_kwargs(request_data)
metadata: Final = _normalized_metadata_slot(request_data, _metadata_variable_name)
metadata: Final = normalized_metadata_slot(request_data, _metadata_variable_name)
existing_tags: Final = metadata.get("tags")
metadata["tags"] = LiteLLMProxyRequestSetup.merge_tags(
@ -2637,7 +2697,7 @@ async def add_litellm_data_to_request(
}
safe_add_api_version_from_query_params(data, request)
_metadata_variable_name: Final = get_metadata_variable_name(request)
_metadata_variable_name: Final = get_metadata_variable_name(request, _REQUEST_FIELDS.validate_python(data))
if data.get(_metadata_variable_name, None) is None:
data[_metadata_variable_name] = {}
@ -2974,17 +3034,7 @@ async def add_litellm_data_to_request(
tags_to_add=tags,
)
_caller_body_metadata: Final = data.get("metadata") if _metadata_variable_name != "metadata" else None
_caller_body_tags: Final = (
_caller_body_metadata.get("tags")
if isinstance(_caller_body_metadata, dict) and isinstance(_caller_body_metadata.get("tags"), list)
else None
)
if _caller_body_tags:
data[_metadata_variable_name]["tags"] = LiteLLMProxyRequestSetup.merge_tags( # rebind-ok: matches file idiom
request_tags=data[_metadata_variable_name].get("tags"),
tags_to_add=_caller_body_tags,
)
LiteLLMProxyRequestSetup.merge_caller_body_tags(request_data=data, metadata_variable_name=_metadata_variable_name)
# caller_tags: the tags the request's headers and body carry (x-litellm-tags, body "tags", and body
# "metadata.tags" / "litellm_metadata.tags" read before the mapped tags are added below),

View file

@ -79,6 +79,7 @@ from litellm.proxy.hooks.responses_id_security import ResponsesIDSecurity, decry
from litellm.proxy.litellm_pre_call_utils import (
_CLIENT_PRICING_METADATA_FIELDS, # pyright: ignore[reportPrivateUsage] # the HTTP path's own strip list
_UNTRUSTED_METADATA_CONTROL_FIELDS, # pyright: ignore[reportPrivateUsage] # the HTTP path's own strip list
is_chat_completions_body,
)
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import (
request_tags_from_request_data,
@ -213,13 +214,6 @@ def _normalize_tool_dialect(
return {**data, **{key: value for key, value in replaceable if key in data}}
def _is_chat_completions_body(data: Mapping[str, object]) -> bool:
messages: Final = data.get("messages")
if isinstance(messages, list) and messages:
return True
return "messages" in data and "input" not in data
_CURSOR_THINKING_SEPARATOR: Final = "-thinking-"
_CURSOR_FAST_SUFFIX: Final = "-fast"
_CURSOR_THINKING_LEVELS: Final[frozenset[str]] = frozenset(get_args(REASONING_EFFORT))
@ -261,7 +255,7 @@ def _resolve_cursor_model_variant(
resolved: Final = {**data, "model": variant.base_model}
if variant.reasoning_effort is None:
return resolved
if _is_chat_completions_body(data):
if is_chat_completions_body(data):
if "reasoning_effort" in data:
return resolved
return {**resolved, "reasoning_effort": variant.reasoning_effort}
@ -646,7 +640,7 @@ async def cursor_chat_completions(
raw_body: Final = await read_request_body(request=request)
if _is_chat_completions_body(raw_body):
if is_chat_completions_body(raw_body):
# Genuine chat completions body (Cursor sends these for models whose BYOK it
# already fixed); delegate so behavior matches /chat/completions exactly.
# Keyed on messages CONTENT, not key presence: Cursor can send a null or

View file

@ -250,7 +250,11 @@ from litellm.proxy.hooks.sensitive_data_routing import ( # noqa: F401, RUF100
PROXY_SensitiveDataRoutingHandler,
_PROXY_SensitiveDataRoutingHandler, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
)
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup, add_guardrails_from_auth_metadata
from litellm.proxy.litellm_pre_call_utils import (
LiteLLMProxyRequestSetup,
add_guardrails_from_auth_metadata,
normalized_metadata_slot,
)
from litellm.proxy.management_helpers.key_settings_audit import with_settings_updated_at
from litellm.proxy.policy_engine.pipeline_executor import (
PipelineExecutor,
@ -3973,9 +3977,7 @@ 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})
if "metadata" not in request_data:
request_data["metadata"] = {}
request_data["metadata"].update(user_api_key_logged_metadata)
normalized_metadata_slot(request_data, "metadata").update(user_api_key_logged_metadata)
if litellm_logging_obj is not None:
## UPDATE LOGGING INPUT

View file

@ -3,11 +3,14 @@ from __future__ import annotations
import json
import os
import uuid
from collections.abc import Mapping
from hashlib import sha256
from importlib.resources import files
from typing import Final
import httpx
import pytest
from integration._support.client import Gateway, Scenario
from integration._support.client import Gateway, Scenario, eventually, list_value, object_value
from integration._support.responses_vendor import same_response
from integration._support.wire import Reply, Request, Wire, wire_server
from integration._support.worker_readiness import wait_for_worker_readiness
@ -38,6 +41,7 @@ _CHAT_SUFFIXES: Final[tuple[tuple[str, str], ...]] = (("", ""),) + tuple(
(f"-thinking-{level}", level) for level in _CHAT_THINKING_LEVELS
)
_PROMPT: Final = "Inspect the requested change."
_REQUEST_TAG: Final = "cursor-integration"
_INPUT: Final = [{"role": "user", "content": [{"type": "input_text", "text": _PROMPT}]}]
_CUSTOM_TOOL: Final = {
"type": "custom",
@ -107,16 +111,30 @@ def _stream(identity: str) -> tuple[bytes, ...]:
) + (b"data: [DONE]\n\n",)
def _chat_completion(stream: bool) -> bytes | tuple[bytes, ...]:
def _chat_completion(stream: bool, identity: str) -> bytes | tuple[bytes, ...]:
if stream:
return (
b'data: {"id":"chat_cursor_1","object":"chat.completion.chunk","created":1,"model":"gpt-5","choices":[{"index":0,"delta":{"content":"Patch inspected."},"finish_reason":null}]}\n\n',
b'data: {"id":"chat_cursor_1","object":"chat.completion.chunk","created":1,"model":"gpt-5","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
b"data: [DONE]\n\n",
)
return tuple(
f"data: {json.dumps(chunk)}\n\n".encode()
for chunk in (
{
"id": identity,
"object": "chat.completion.chunk",
"created": 1,
"model": _MODEL,
"choices": [{"index": 0, "delta": {"content": "Patch inspected."}, "finish_reason": None}],
},
{
"id": identity,
"object": "chat.completion.chunk",
"created": 1,
"model": _MODEL,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
},
)
) + (b"data: [DONE]\n\n",)
return json.dumps(
{
"id": "chat_cursor_1",
"id": identity,
"object": "chat.completion",
"created": 1,
"model": _MODEL,
@ -133,11 +151,12 @@ def _chat_completion(stream: bool) -> bytes | tuple[bytes, ...]:
).encode()
def _deployment(scenario: Scenario, wire: Wire) -> str:
def _deployment(scenario: Scenario, wire: Wire, **pricing: JsonValue) -> str:
model: Final = scenario.model(
model=f"openai/{_MODEL}",
api_key=_API_KEY,
api_base=f"{wire.url}/v1",
**pricing,
)
wait_for_worker_readiness(scenario.gateway, model, 1, _PROXY_WORKERS)
return model
@ -167,7 +186,7 @@ def test_cursor_responses_input_uses_responses_wire_and_chat_output(
assert request.method == "POST"
assert request.headers["authorization"] == f"Bearer {_API_KEY}", dict(request.headers)
if request.target == "/v1/chat/completions":
completion: Final = _chat_completion(stream)
completion: Final = _chat_completion(stream, raw_id)
return Reply(
content_type="text/event-stream" if stream else "application/json",
chunks=completion if isinstance(completion, tuple) else None,
@ -175,7 +194,7 @@ def test_cursor_responses_input_uses_responses_wire_and_chat_output(
)
assert request.target == "/v1/responses", request.target
body: Final = _JSON_OBJECT.validate_json(request.body)
assert {key: value for key, value in body.items() if key != "metadata"} == {
assert body == {
"model": _MODEL,
"input": _INPUT,
**reasoning,
@ -259,41 +278,69 @@ def test_cursor_responses_input_uses_responses_wire_and_chat_output(
assert [request.target for request in drain_contract_requests(wire)] == ["/v1/responses"]
def test_cursor_responses_body_carries_no_proxy_metadata(gateway: Gateway) -> None:
pytest.skip(
"BUG: /cursor/chat/completions forwards proxy-internal metadata (key hash, team, user, client ip, user agent) "
"to the provider in the Responses body"
)
@pytest.mark.parametrize(
"client_metadata", ({}, {"cursor_conversation": "integration"}), ids=("no_client_metadata", "client_metadata")
)
@pytest.mark.parametrize("stream", (False, True))
def test_cursor_responses_body_sends_only_client_metadata_and_bills_the_key(
gateway: Gateway,
stream: bool,
client_metadata: Mapping[str, JsonValue],
) -> None:
raw_id: Final = f"resp_cursor_metadata_{uuid.uuid4().hex}"
alias: Final = f"integration-cursor-{uuid.uuid4().hex}"
sent_metadata: Final = {"metadata": client_metadata} if client_metadata else {}
def respond(request: Request) -> Reply:
if request.method == "GET" and request.target == "/v1/models":
return model_discovery_reply(_MODEL)
assert request.method == "POST" and request.target == "/v1/responses", request.target
if stream:
return Reply(content_type="text/event-stream", chunks=_stream(raw_id))
return Reply(body=json.dumps(_response(raw_id)).encode())
with wire_server(respond) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, wire)
key: Final = scenario.key(key_alias=alias)
hashed_key: Final = sha256(key.encode()).hexdigest()
response: Final = gateway.request(
"POST",
"/cursor/chat/completions",
{
"model": f"{model}-thinking-high",
"model": model,
"input": _INPUT,
"messages": [],
"tools": [_CUSTOM_TOOL],
"stream": stream,
**sent_metadata,
},
key=key,
)
assert response.status_code == 200, response.text
requests: Final = drain_contract_requests(wire)
assert len(requests) == 1, requests
body: Final = _JSON_OBJECT.validate_json(requests[0].body)
assert body == {
assert _JSON_OBJECT.validate_json(requests[0].body) == {
"model": _MODEL,
"input": _INPUT,
"reasoning": {"effort": "high"},
"tools": [_CUSTOM_TOOL],
"stream": stream,
**sent_metadata,
}, requests[0].body
(row,) = eventually(
lambda: gateway.rows(
'SELECT call_type, spend, metadata FROM "LiteLLM_SpendLogs" WHERE api_key = %s', (hashed_key,)
),
lambda rows: len(rows) == 1,
seconds=70,
)
assert row["call_type"] == "aresponses", row
assert object_value(row["metadata"])["user_api_key_alias"] == alias, row
assert isinstance(row["spend"], float) and row["spend"] > 0, row
eventually(
lambda: gateway.rows('SELECT spend FROM "LiteLLM_VerificationToken" WHERE token = %s', (hashed_key,)),
lambda tokens: len(tokens) == 1 and tokens[0]["spend"] == pytest.approx(row["spend"]),
seconds=70,
)
def test_cursor_stream_reports_usage_when_the_client_asks_for_it(gateway: Gateway) -> None:
@ -341,10 +388,12 @@ def test_cursor_messages_body_uses_chat_completions_wire(
suffix: str,
reasoning_effort: str,
) -> None:
chat_id: Final = f"chat_cursor_{uuid.uuid4().hex}"
def respond(request: Request) -> Reply:
if request.method == "GET" and request.target == "/v1/models":
return model_discovery_reply(_MODEL)
completion: Final = _chat_completion(stream)
completion: Final = _chat_completion(stream, chat_id)
return Reply(
content_type="text/event-stream" if stream else "application/json",
chunks=completion if isinstance(completion, tuple) else None,
@ -367,6 +416,7 @@ def test_cursor_messages_body_uses_chat_completions_wire(
"stream": stream,
},
key=key,
headers={"x-litellm-tags": _REQUEST_TAG},
)
assert response.status_code == 200, response.text
if stream:
@ -393,7 +443,7 @@ def test_cursor_messages_body_uses_chat_completions_wire(
payload: Final = _JSON_OBJECT.validate_json(response.content)
identity: Final = payload.get("id")
created: Final = payload.get("created")
assert isinstance(identity, str) and same_response(identity, "chat_cursor_1"), response.text
assert isinstance(identity, str) and same_response(identity, chat_id), response.text
assert isinstance(created, int) and created > 0, response.text
assert payload == {
"id": identity,
@ -456,3 +506,72 @@ def test_cursor_messages_body_uses_chat_completions_wire(
**({"stream_options": {"include_usage": True}} if stream else {}),
**({"reasoning_effort": reasoning_effort} if reasoning_effort else {}),
}, requests[0].body
(row,) = eventually(
lambda: gateway.rows(
'SELECT call_type, team_id, request_tags FROM "LiteLLM_SpendLogs" WHERE api_key = %s',
(sha256(key.encode()).hexdigest(),),
),
lambda rows: len(rows) == 1,
seconds=70,
)
assert (row["call_type"], row["team_id"]) == ("acompletion", team), row
assert _REQUEST_TAG in list_value(row["request_tags"]), row
@pytest.mark.parametrize(
("body_shape", "metadata_as_json_string"), (("messages", False), ("input", False), ("input", True))
)
def test_cursor_body_keeps_client_and_key_tags_and_enforces_the_client_tag_budget(
gateway: Gateway, body_shape: str, metadata_as_json_string: bool
) -> None:
chat_id: Final = f"chat_cursor_{uuid.uuid4().hex}"
response_id: Final = f"resp_cursor_tags_{uuid.uuid4().hex}"
budget_tag: Final = f"cursor-tag-budget-{uuid.uuid4().hex}"
key_tag: Final = f"cursor-key-tag-{uuid.uuid4().hex}"
client_metadata: Final = {"tags": [budget_tag]}
def respond(request: Request) -> Reply:
if request.method == "GET" and request.target == "/v1/models":
return model_discovery_reply(_MODEL)
if request.target == "/v1/responses":
return Reply(body=json.dumps(_response(response_id)).encode())
completion: Final = _chat_completion(False, chat_id)
return Reply(body=completion if isinstance(completion, bytes) else b"")
def delete_tag() -> None:
gateway.post("/tag/delete", {"name": budget_tag})
with wire_server(respond) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, wire, input_cost_per_token=0.01, output_cost_per_token=0.01)
key: Final = scenario.key(models=[model], metadata={"tags": [key_tag]})
gateway.post("/tag/new", {"name": budget_tag, "max_budget": 0.0001})
scenario.cleanups.callback(delete_tag)
def tagged_chat(text: str) -> httpx.Response:
return gateway.request(
"POST",
"/cursor/chat/completions",
{
"model": model,
body_shape: [{"role": "user", "content": text}],
"metadata": json.dumps(client_metadata) if metadata_as_json_string else client_metadata,
},
key=key,
)
first: Final = tagged_chat("first tagged chat")
assert first.status_code == 200, first.text
(row,) = eventually(
lambda: gateway.rows(
'SELECT request_tags FROM "LiteLLM_SpendLogs" WHERE api_key = %s',
(sha256(key.encode()).hexdigest(),),
),
lambda rows: len(rows) == 1,
seconds=70,
)
assert {budget_tag, key_tag} <= set(list_value(row["request_tags"])), row
blocked: Final = eventually(
lambda: tagged_chat("tag budget probe"), lambda response: response.status_code != 200, seconds=70
)
assert blocked.status_code == 422, blocked.text
assert "budget" in blocked.text.lower(), blocked.text

View file

@ -3699,6 +3699,20 @@ def test_get_guardrail_from_metadata() -> None:
) == [{"test-guardrail": {"extra_body": {"key": "value"}}}]
@pytest.mark.parametrize(
"caller_metadata",
[{"guardrails": []}, {"guardrails": ["not-a-guardrail"]}, {"guardrails": None}],
)
def test_caller_metadata_guardrails_never_hide_the_proxy_guardrails_in_litellm_metadata(caller_metadata: dict) -> None:
guardrail: Final = CustomGuardrail(
guardrail_name="key-guardrail", event_hook=GuardrailEventHooks.pre_call, default_on=False
)
request_data: Final = {"metadata": caller_metadata, "litellm_metadata": {"guardrails": ["key-guardrail"]}}
assert guardrail.should_run_guardrail(request_data, GuardrailEventHooks.pre_call) is True
def test_guardrail_is_in_requested_guardrails() -> None:
guardrail: Final = CustomGuardrail(guardrail_name="test-guardrail")

View file

@ -2591,7 +2591,8 @@ async def test_agent_session_rate_limit_skipped_without_session_id():
@pytest.mark.asyncio
async def test_agent_rate_limit_from_metadata_agent_id():
@pytest.mark.parametrize("metadata_field", ("metadata", "litellm_metadata"))
async def test_agent_rate_limit_from_metadata_agent_id(metadata_field):
"""
Test that agent rate limits work when agent_id comes from
request metadata (header) rather than from the API key.
@ -2639,7 +2640,7 @@ async def test_agent_rate_limit_from_metadata_agent_id():
cache=local_cache,
data={
"model": "gpt-4",
"metadata": {"agent_id": _agent_id},
metadata_field: {"agent_id": _agent_id},
},
call_type="",
)

View file

@ -623,6 +623,56 @@ async def test_async_post_call_failure_hook_reads_used_client_oauth_token_from_t
assert json.loads(payload["metadata"])["used_client_oauth_token"] is expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
"request_route, body",
[
(
"/cursor/chat/completions",
{
"input": "Hello",
"metadata": {"requester_ip_address": "203.0.113.9", "user_agent": "spoofed"},
"litellm_metadata": {"requester_ip_address": "127.0.0.1", "user_agent": "Cursor/1.0"},
},
),
(
"/v1/chat/completions",
{
"messages": [{"role": "user", "content": "Hello"}],
"metadata": {"requester_ip_address": "127.0.0.1", "user_agent": "Cursor/1.0"},
"litellm_metadata": {"requester_ip_address": "203.0.113.9", "user_agent": "spoofed"},
},
),
],
)
async def test_async_post_call_failure_hook_logs_the_proxy_stamped_ip_and_user_agent(request_route: str, body: dict):
request_data = {
"model": "gpt-4o",
"proxy_server_request": {"request_id": "test_request_id"},
**body,
}
with patch(
"litellm.proxy.db.db_spend_update_writer.DBSpendUpdateWriter.update_database",
new_callable=AsyncMock,
) as mock_update_database:
await ProxyDBLogger().async_post_call_failure_hook(
request_data=request_data,
original_exception=Exception("invalid api key"),
user_api_key_dict=UserAPIKeyAuth(api_key="test_api_key", request_route=request_route),
)
payload = get_logging_payload(
kwargs=mock_update_database.call_args[1]["kwargs"],
response_obj={},
start_time=datetime.now(),
end_time=datetime.now(),
)
assert payload["requester_ip_address"] == "127.0.0.1"
assert json.loads(payload["metadata"])["user_agent"] == "Cursor/1.0"
@pytest.mark.asyncio
async def test_async_post_call_failure_hook_bills_guardrail_cost_on_blocked_request():
"""LIT-5651: a request blocked by a guardrail never reaches the LLM, but the

View file

@ -3938,7 +3938,7 @@ class TestCursorInputArmFlattening:
class TestChatCompletionsBodyDetection:
def test_routing_matrix(self):
from litellm.proxy.response_api_endpoints.endpoints import _is_chat_completions_body
from litellm.proxy.litellm_pre_call_utils import is_chat_completions_body as _is_chat_completions_body
assert _is_chat_completions_body({"messages": [{"role": "user", "content": "hi"}]}) is True
assert _is_chat_completions_body({"messages": [{"role": "user", "content": "hi"}], "input": []}) is True

View file

@ -9711,6 +9711,40 @@ async def test_saved_weights_override_caller_input_and_preserve_key_precedence(
assert logging.pre_call_hook.call_args.kwargs["data"].get("_router_weights") == expected
def _hook_data(**kwargs: object) -> object:
return kwargs["data"]
@pytest.mark.asyncio
@pytest.mark.parametrize("prompt, proxy_field, client_field", [
({"input": "hi"}, "litellm_metadata", "metadata"),
({"messages": [{"role": "user", "content": "hi"}]}, "metadata", "litellm_metadata"),
])
async def test_cursor_route_writes_proxy_fields_only_to_the_field_its_body_shape_picks(
prompt: dict[str, str | list[dict[str, str]]],
proxy_field: str,
client_field: str,
) -> None:
from litellm.proxy import proxy_server
logging: Final = MagicMock(spec=ProxyLogging)
logging.pre_call_hook = AsyncMock(side_effect=_hook_data)
processor: Final = ProxyBaseLLMRequestProcessing(data={"model": "gpt-x", "metadata": {"client": "cursor"}, **prompt})
data, _ = await processor.common_processing_pre_call_logic(
request=Request({"type": "http", "method": "POST", "path": "/cursor/chat/completions", "headers": []}),
general_settings={},
user_api_key_dict=ProxyUserAPIKeyAuth(api_key="hash"),
proxy_logging_obj=logging,
proxy_config=proxy_server.ProxyConfig(),
route_type="acompletion",
logging_auth=ProxyUserAPIKeyAuth(api_key="logging-hash", key_alias="logging-alias"),
)
assert data[proxy_field]["user_api_key_alias"] == "logging-alias"
assert "queue_time_seconds" in data[proxy_field]
assert "user_api_key_alias" not in data.get(client_field, {})
assert "queue_time_seconds" not in data.get(client_field, {})
assert data["metadata"]["client"] == "cursor"
class TestPerRequestModelGroupAlias:
"""``router_settings.model_group_alias`` on a key or team has to be resolved
by the proxy: the Router resolves aliases from its own shared instance

View file

@ -121,6 +121,23 @@ class TestGetMetadataVariableName:
request = self._make_request("/chat/completions")
assert get_metadata_variable_name(request) == "metadata"
@pytest.mark.parametrize(
"body, expected",
[
({"messages": [{"role": "user", "content": "hi"}]}, "metadata"),
({"messages": []}, "metadata"),
({"input": [{"role": "user", "content": "hi"}]}, "litellm_metadata"),
({"input": "hi", "messages": []}, "litellm_metadata"),
],
)
def test_cursor_route_picks_the_metadata_field_by_body_shape(self, body, expected):
request = self._make_request("/cursor/chat/completions")
assert get_metadata_variable_name(request, body) == expected
def test_cursor_route_without_a_body_keeps_the_route_answer(self):
request = self._make_request("/cursor/chat/completions")
assert get_metadata_variable_name(request) == "metadata"
def test_returns_metadata_for_completions(self):
request = self._make_request("/v1/completions")
assert get_metadata_variable_name(request) == "metadata"
@ -681,6 +698,45 @@ async def test_add_litellm_data_to_request_rejects_non_object_metadata(field, va
assert field not in data
@pytest.mark.asyncio
@pytest.mark.parametrize("route", ["/v1/chat/completions", "/v1/responses", "/cursor/chat/completions"])
@pytest.mark.parametrize("value, received_type", [("junk", "a string"), (5, "an integer"), ([1], "an array")])
async def test_invalid_litellm_metadata_answers_400_when_metadata_carries_tags(route, value, received_type):
from litellm.proxy.auth.auth_checks import common_checks
from litellm.proxy.utils import ProxyLogging
data = {"model": "gpt-4o", "input": "hi", "metadata": {"tags": ["client-tag"]}, "litellm_metadata": value}
request = _batches_request_mock()
await common_checks(
request_body=data,
team_object=None,
user_object=None,
end_user_object=None,
global_proxy_spend=None,
general_settings={},
route=route,
llm_router=None,
proxy_logging_obj=ProxyLogging(user_api_key_cache=None),
valid_token=UserAPIKeyAuth(api_key="hashed-key"),
request=request,
)
with pytest.raises(ProxyException) as exc_info:
await add_litellm_data_to_request(
data=data,
request=request,
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert exc_info.value.code == "400"
assert exc_info.value.message == (
f"Invalid type for 'litellm_metadata': expected an object, but got {received_type} instead."
)
@pytest.mark.asyncio
async def test_add_litellm_data_to_request_removes_every_invalid_metadata_field_before_raising():
"""When both fields are invalid, the raise for the first must not leave the
@ -11098,3 +11154,15 @@ def test_mapped_baggage_tags_keep_non_string_client_tags_as_sent():
data: Final[dict[str, object]] = {"metadata": {"tags": [1, {"team": "x"}, "client-tag", "client-tag"]}}
LiteLLMProxyRequestSetup.apply_authenticated_baggage_tags(request, data)
assert data["metadata"]["tags"] == [1, {"team": "x"}, "client-tag", "client-tag", "tenant:a"]
@pytest.mark.parametrize("caller_metadata", ({"tags": ["client-tag"]}, json.dumps({"tags": ["client-tag"]})))
def test_caller_tags_reach_litellm_metadata_whether_metadata_is_a_dict_or_a_json_string(caller_metadata):
data: Final[dict[str, object]] = {
"input": "hi",
"metadata": caller_metadata,
"litellm_metadata": {"tags": ["key-tag"]},
}
LiteLLMProxyRequestSetup.merge_caller_body_tags(request_data=data)
assert set(data["litellm_metadata"]["tags"]) == {"key-tag", "client-tag"}
assert data["metadata"] == caller_metadata

View file

@ -4,6 +4,7 @@ and ``_handle_logging_proxy_only_error``."""
from __future__ import annotations
import asyncio
import json
from collections.abc import Callable
from datetime import datetime
from types import MappingProxyType
@ -1192,3 +1193,31 @@ async def test_handle_logging_proxy_only_path_flags_requests_that_died_before_re
blocked_by_guardrail.get(REJECTED_BEFORE_REQUEST_SETUP_KEY) is REJECTED_BEFORE_REQUEST_SETUP,
blocked_by_guardrail["litellm_call_id"],
) == (True, "client-supplied", False, "call-1")
@pytest.mark.asyncio
async def test_auth_failure_on_a_responses_body_with_a_json_string_metadata_is_logged_against_the_key(
proxy_logging, monkeypatch
):
from litellm.proxy import proxy_server
recorded: list[dict] = []
class _RecordingLogger(CustomLogger):
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
recorded.append(kwargs)
monkeypatch.setattr(proxy_server, "llm_router", None)
monkeypatch.setattr(litellm, "callbacks", [_RecordingLogger()])
proxy_logging.alert_types = []
await proxy_logging.post_call_failure_hook(
request_data={"model": "internal-model", "input": "hi", "metadata": json.dumps({"tags": ["checkout"]})},
original_exception=litellm.BudgetExceededError(current_cost=1.0, max_budget=0.5),
user_api_key_dict=UserAPIKeyAuth(api_key="sk-hashed", key_alias="checkout-key", request_route="/v1/responses"),
error_type=ProxyErrorTypes.auth_error,
route="/v1/responses",
)
assert len(recorded) == 1
assert recorded[0]["standard_logging_object"]["metadata"]["user_api_key_alias"] == "checkout-key"