Merge branch 'litellm_internal_staging' into shin_agent_oss_staging_05_09_2026

This commit is contained in:
Sameer Kankute
2026-05-11 11:35:00 +05:30
committed by GitHub
21 changed files with 1491 additions and 195 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+157 -10
View File
@@ -1771,17 +1771,41 @@ class OpenTelemetry(CustomLogger):
value=safe_dumps(transformed_messages),
)
if kwargs.get("system_instructions"):
transformed_system_instructions = (
self._transform_messages_to_otel_semantic_conventions(
kwargs.get("system_instructions")
# Coalesce the different kwarg names that carry the system
# prompt depending on the call path:
# - "system_instructions" — Vertex AI Gemini chat-completion
# - "instructions" — OpenAI Responses API
# - "system" — Anthropic Messages API
# Use `is not None` rather than truthiness to avoid falsy
# values (e.g. []) falling through to the wrong kwarg.
system_instructions = (
kwargs.get("system_instructions")
if kwargs.get("system_instructions") is not None
else (
kwargs.get("instructions")
if kwargs.get("instructions") is not None
else kwargs.get("system")
)
)
if system_instructions:
if isinstance(system_instructions, str):
# Plain text system prompt — no transformation needed
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value,
value=system_instructions,
)
else:
transformed_system_instructions = (
self._transform_messages_to_otel_semantic_conventions(
system_instructions
)
)
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value,
value=safe_dumps(transformed_system_instructions),
)
)
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value,
value=safe_dumps(transformed_system_instructions),
)
self.safe_set_attribute(
span=span,
@@ -1840,6 +1864,57 @@ class OpenTelemetry(CustomLogger):
value=value,
)
elif response_obj.get("output"):
# Responses API: ResponsesAPIResponse has an "output"
# list instead of "choices". Each item with
# type="message" contains a "content" list of
# OutputText objects (type="output_text").
output_items = response_obj.get("output")
output_messages = self._transform_responses_api_output_to_otel(
output_items
)
if output_messages:
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_OUTPUT_MESSAGES.value,
value=safe_dumps(output_messages),
)
# Emit per-tool-call span attributes (parity with
# the choices branch that calls _tool_calls_kv_pair).
# Convert Responses API function_call items to the
# ChatCompletionMessageToolCall format expected by
# _tool_calls_kv_pair.
tool_calls = []
for out_item in output_items:
item_d = self._to_dict(out_item)
if item_d and item_d.get("type") == "function_call":
tool_calls.append(
{
"function": {
"name": item_d.get("name", ""),
"arguments": item_d.get("arguments", ""),
}
}
)
if tool_calls:
kv_pairs = OpenTelemetry._tool_calls_kv_pair(tool_calls) # type: ignore
for key, value in kv_pairs.items():
self.safe_set_attribute(
span=span,
key=key,
value=value,
)
# Extract finish reason from ResponsesAPIResponse.status
status = response_obj.get("status")
if status:
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value,
value=safe_dumps([status]),
)
except Exception as e:
self.handle_callback_failure(
callback_name=self.callback_name or "opentelemetry"
@@ -1935,6 +2010,78 @@ class OpenTelemetry(CustomLogger):
transformed.append(transformed_msg)
return transformed
@staticmethod
def _to_dict(obj) -> Optional[dict]:
"""Normalize an object to a plain dict.
Handles three forms that appear in practice:
1. Plain ``dict`` — returned as-is.
2. LiteLLM's ``BaseLiteLLMOpenAIResponseObject`` — exposes a
``.get()`` method that delegates to ``__dict__``.
3. Raw Pydantic v2 models from the ``openai`` SDK (e.g.
``ResponseOutputMessage``, ``ResponseOutputText``) — these do
**not** have ``.get()`` but do have ``.model_dump()``.
Returns ``None`` for anything else so callers can skip it.
"""
if isinstance(obj, dict):
return obj
if hasattr(obj, "get"):
# BaseLiteLLMOpenAIResponseObject duck-type
return obj # type: ignore[return-value]
if hasattr(obj, "model_dump"):
# Raw Pydantic v2 model (e.g. openai SDK types)
return obj.model_dump() # type: ignore[union-attr]
return None
def _transform_responses_api_output_to_otel(self, output: List) -> List[dict]:
"""
Transform Responses API output items into OTEL GenAI 1.38 format.
The Responses API returns output as a list of items, each with a
``type`` field. Message items (``type="message"``) contain a
``content`` list of ``OutputText`` objects with ``type="output_text"``
and ``text`` fields.
Items may be plain dicts, LiteLLM wrapper objects (with ``.get()``),
or raw Pydantic v2 models from the ``openai`` SDK (with
``.model_dump()``). We normalize each item to a dict via
``_to_dict`` before processing.
This method converts them to the same ``{"role": ..., "parts": [...]}``
format used by ``_transform_choices_to_otel_semantic_conventions``.
"""
transformed = []
for raw_item in output:
item = self._to_dict(raw_item)
if item is None:
continue
if item.get("type") == "message":
role = item.get("role", "assistant")
parts = []
for raw_content in item.get("content", []):
content = self._to_dict(raw_content)
if content is None:
continue
if content.get("type") == "output_text":
text = content.get("text", "")
if text:
parts.append({"type": "text", "content": text})
if parts:
transformed.append({"role": role, "parts": parts})
elif item.get("type") == "function_call":
# Surface tool calls from Responses API output
part: dict = {
"type": "tool_call",
"name": item.get("name", ""),
"arguments": item.get("arguments", ""),
}
if item.get("call_id"):
part["id"] = item["call_id"]
transformed.append({"role": "assistant", "parts": [part]})
return transformed
def set_raw_request_attributes(self, span: Span, kwargs, response_obj):
try:
# Only set provider-specific raw payload attributes on this span.
@@ -156,5 +156,17 @@ class OVHCloudAudioTranscriptionConfig(BaseAudioTranscriptionConfig):
text = response_json.get("text") or response_json.get("transcript") or ""
response = TranscriptionResponse(text=text)
# OVHCloud field migration (deadline: 2026-05-11):
# `duration` is replaced by `seconds` in STT responses.
# Prefer `seconds`, fall back to `duration`, normalize to `duration`
# so downstream consumers see a consistent key.
duration = (
response_json["seconds"]
if "seconds" in response_json and response_json["seconds"] is not None
else response_json.get("duration")
)
if duration is not None:
response_json["duration"] = duration
response._hidden_params = response_json
return response
+11 -4
View File
@@ -13,6 +13,7 @@ from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig
from litellm.llms.ovhcloud.utils import OVHCloudException
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.types.llms.openai import AllMessageValues
@@ -98,10 +99,16 @@ class OVHCloudChatCompletionStreamingHandler(BaseModelResponseIterator):
new_choices = []
for choice in chunk["choices"]:
if "delta" in choice and "reasoning" in choice["delta"]:
choice["delta"]["reasoning_content"] = choice["delta"].get(
"reasoning"
)
if "delta" in choice:
delta = choice["delta"]
# OVHCloud field migration (deadline: 2026-05-11):
# `reasoning_content` is replaced by `reasoning`.
# Normalise to `reasoning_content` so downstream consumers
# see a consistent key during the transition window.
reasoning_new = delta.get("reasoning")
reasoning_legacy = delta.get("reasoning_content")
if reasoning_new is not None and reasoning_legacy is None:
delta["reasoning_content"] = reasoning_new
new_choices.append(choice)
return ModelResponseStream(
+2
View File
@@ -1,3 +1,5 @@
# LiteLLM main module: public completion, embedding, streaming, and moderation entrypoints.
#
# +-----------------------------------------------+
# | |
# | Give Feedback / Get Help |
+1 -1
View File
@@ -689,7 +689,7 @@ class LiteLLMRoutes(enum.Enum):
+ compliance_check_routes
)
internal_user_view_only_routes = spend_tracking_routes
internal_user_view_only_routes = spend_tracking_routes + compliance_check_routes
self_managed_routes = [
"/team/member_add",
+27 -21
View File
@@ -1131,10 +1131,12 @@ class DBSpendUpdateWriter:
timeout=timedelta(seconds=60)
) as transaction:
async with transaction.batch_() as batcher:
for (
user_id,
response_cost,
) in user_list_transactions.items():
# Sort by ID for consistent lock ordering across pods to prevent deadlocks.
# batch_() issues statements sequentially within the tx, so iteration
# order = lock acquisition order.
for user_id, response_cost in sorted(
user_list_transactions.items()
):
batcher.litellm_usertable.update_many(
where={"user_id": user_id},
data={"spend": {"increment": response_cost}},
@@ -1186,10 +1188,10 @@ class DBSpendUpdateWriter:
timeout=timedelta(seconds=60)
) as transaction:
async with transaction.batch_() as batcher:
for (
token,
response_cost,
) in key_list_transactions.items():
# Sort by token for consistent lock ordering across pods to prevent deadlocks.
for token, response_cost in sorted(
key_list_transactions.items()
):
batcher.litellm_verificationtoken.update_many( # 'update_many' prevents error from being raised if no row exists
where={"token": token},
data={
@@ -1230,10 +1232,10 @@ class DBSpendUpdateWriter:
timeout=timedelta(seconds=60)
) as transaction:
async with transaction.batch_() as batcher:
for (
team_id,
response_cost,
) in team_list_transactions.items():
# Sort by team_id for consistent lock ordering across pods to prevent deadlocks.
for team_id, response_cost in sorted(
team_list_transactions.items()
):
verbose_proxy_logger.debug(
"Updating spend for team id={} by {}".format(
team_id, response_cost
@@ -1288,10 +1290,11 @@ class DBSpendUpdateWriter:
timeout=timedelta(seconds=60)
) as transaction:
async with transaction.batch_() as batcher:
for (
key,
response_cost,
) in team_member_list_transactions.items():
# Sort by composite key for consistent lock ordering across pods to prevent deadlocks.
# Key format "team_id::<v>::user_id::<v>" makes the string sort equivalent to sorting by (team_id, user_id).
for key, response_cost in sorted(
team_member_list_transactions.items()
):
# key is "team_id::<value>::user_id::<value>"
team_id = key.split("::")[1]
user_id = key.split("::")[3]
@@ -1348,10 +1351,10 @@ class DBSpendUpdateWriter:
timeout=timedelta(seconds=60)
) as transaction:
async with transaction.batch_() as batcher:
for (
org_id,
response_cost,
) in org_list_transactions.items():
# Sort by org_id for consistent lock ordering across pods to prevent deadlocks.
for org_id, response_cost in sorted(
org_list_transactions.items()
):
batcher.litellm_organizationtable.update_many( # 'update_many' prevents error from being raised if no row exists
where={"organization_id": org_id},
data={"spend": {"increment": response_cost}},
@@ -1439,7 +1442,10 @@ class DBSpendUpdateWriter:
timeout=timedelta(seconds=60)
) as transaction:
async with transaction.batch_() as batcher:
for entity_id, response_cost in transactions.items():
# Sort by entity_id for consistent lock ordering across pods to prevent deadlocks.
for entity_id, response_cost in sorted(
transactions.items()
):
verbose_proxy_logger.debug(
f"Updating spend for {entity_name} {where_field}={entity_id} by {response_cost}"
)
+51 -37
View File
@@ -1061,6 +1061,52 @@ vertex_live_passthrough_vertex_base = VertexBase()
from fastapi.routing import APIWebSocketRoute
def _inject_websocket_stubs_into_openapi_schema(
openapi_schema: dict, websocket_routes: list
) -> dict:
"""
Add a synthetic GET stub for each WebSocket route so it appears in Swagger UI.
Merges into any existing path entry rather than replacing it a WebSocket route
that shares its path with an HTTP route must not erase the HTTP operation. If
a "get" operation is already documented on the path, the WebSocket stub is
skipped to preserve the real GET.
"""
for route in websocket_routes:
base_path = route.path.split("{")[0].rstrip("?")
parameters = []
try:
if hasattr(route, "dependant") and route.dependant is not None:
# Handle both FastAPI <0.120 and >=0.120
query_params = getattr(route.dependant, "query_params", [])
if query_params:
for param in query_params:
parameters.append(
{
"name": param.name,
"in": "query",
"required": param.required,
"schema": {"type": "string"},
}
)
except (AttributeError, TypeError):
pass
path_entry = openapi_schema["paths"].setdefault(base_path, {})
if "get" not in path_entry:
path_entry["get"] = {
"summary": f"WebSocket: {route.name or base_path}",
"description": "WebSocket connection endpoint",
"operationId": f"websocket_{route.name or base_path.replace('/', '_')}",
"parameters": parameters,
"responses": {"101": {"description": "WebSocket Protocol Switched"}},
"tags": ["WebSocket"],
}
return openapi_schema
def get_openapi_schema():
if app.openapi_schema:
return app.openapi_schema
@@ -1083,43 +1129,11 @@ def get_openapi_schema():
route for route in app.routes if isinstance(route, APIWebSocketRoute)
]
# Add each WebSocket route to the schema
for route in websocket_routes:
# Get the base path without query parameters
base_path = route.path.split("{")[0].rstrip("?")
# Extract parameters from the route
parameters = []
try:
if hasattr(route, "dependant") and route.dependant is not None:
# Handle both FastAPI <0.120 and >=0.120
query_params = getattr(route.dependant, "query_params", [])
if query_params:
for param in query_params:
parameters.append(
{
"name": param.name,
"in": "query",
"required": param.required,
"schema": {
"type": "string"
}, # You can make this more specific if needed
}
)
except (AttributeError, TypeError):
# If we can't access query_params, continue without them
pass
openapi_schema["paths"][base_path] = {
"get": {
"summary": f"WebSocket: {route.name or base_path}",
"description": "WebSocket connection endpoint",
"operationId": f"websocket_{route.name or base_path.replace('/', '_')}",
"parameters": parameters,
"responses": {"101": {"description": "WebSocket Protocol Switched"}},
"tags": ["WebSocket"],
}
}
# Add a synthetic GET stub for each so they render in Swagger UI,
# without clobbering existing HTTP operations on the same path.
openapi_schema = _inject_websocket_stubs_into_openapi_schema(
openapi_schema, websocket_routes
)
# Add LLM API request schema bodies for documentation
from litellm.proxy.common_utils.custom_openapi_spec import CustomOpenAPISpec
+4 -4
View File
@@ -4977,10 +4977,10 @@ class ProxyUpdateSpend:
timeout=timedelta(seconds=60)
) as transaction:
async with transaction.batch_() as batcher:
for (
end_user_id,
response_cost,
) in end_user_list_transactions.items():
# Sort by end_user_id for consistent lock ordering across pods to prevent deadlocks.
for end_user_id, response_cost in sorted(
end_user_list_transactions.items()
):
if litellm.max_end_user_budget is not None:
pass
batcher.litellm_endusertable.upsert(
+2
View File
@@ -1,3 +1,5 @@
"""Utility helpers for LiteLLM core request handling and provider support."""
# from __future__ import annotations must be the first non-comment statement
from __future__ import annotations
+1 -1
View File
@@ -11,7 +11,7 @@ import litellm
def test_completion_openrouter_reasoning_content():
litellm._turn_on_debug()
resp = litellm.completion(
model="openrouter/anthropic/claude-3.7-sonnet",
model="openrouter/anthropic/claude-sonnet-4",
messages=[{"role": "user", "content": "Hello world"}],
reasoning={"effort": "high"},
)
@@ -3159,3 +3159,630 @@ class TestResponseIdFallback(unittest.TestCase):
otel.set_attributes(mock_span, kwargs, response_obj)
mock_span.set_attribute.assert_any_call("litellm.call_id", call_id)
class TestOpenTelemetryResponsesAPI(unittest.TestCase):
"""
Tests for Responses API (/v1/responses) OTel span attributes.
The Responses API uses ``output`` (list of output items) instead of
``choices``, ``instructions`` instead of ``system_instructions``, and
``status`` instead of per-choice ``finish_reason``.
See: https://github.com/BerriAI/litellm/issues/25840
"""
def _base_kwargs(self, **overrides):
"""Return minimal kwargs for set_attributes with Responses API defaults."""
kwargs = {
"model": "gpt-4o",
"messages": [{"role": "user", "content": "What is 2+2?"}],
"optional_params": {},
"litellm_params": {"custom_llm_provider": "openai"},
"standard_logging_object": {
"id": "resp_abc123",
"call_type": "responses",
"metadata": {},
},
}
kwargs.update(overrides)
return kwargs
def _responses_api_response_obj(self, text="The answer is 4.", status="completed"):
"""Return a dict mimicking ResponsesAPIResponse with a message output."""
return {
"id": "resp_abc123",
"model": "gpt-4o",
"status": status,
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": text,
}
],
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30,
},
}
def _get_attr(self, mock_span, attr_name):
"""Extract the value set for a specific attribute name, or None."""
calls = [
call
for call in mock_span.set_attribute.call_args_list
if call[0][0] == attr_name
]
if not calls:
return None
return calls[0][0][1]
# ------------------------------------------------------------------
# gen_ai.output.messages
# ------------------------------------------------------------------
def test_output_messages_populated_for_responses_api(self):
"""gen_ai.output.messages must be set when response has output items."""
otel = OpenTelemetry()
mock_span = MagicMock()
kwargs = self._base_kwargs()
response_obj = self._responses_api_response_obj(text="The answer is 4.")
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
raw = self._get_attr(mock_span, "gen_ai.output.messages")
self.assertIsNotNone(raw, "gen_ai.output.messages should be set")
parsed = json.loads(raw)
self.assertIsInstance(parsed, list)
self.assertEqual(len(parsed), 1)
self.assertEqual(parsed[0]["role"], "assistant")
self.assertIn("parts", parsed[0])
self.assertEqual(parsed[0]["parts"][0]["type"], "text")
self.assertEqual(parsed[0]["parts"][0]["content"], "The answer is 4.")
def test_output_messages_with_multiple_content_items(self):
"""Multiple output_text items in a single message should all appear as parts."""
otel = OpenTelemetry()
mock_span = MagicMock()
response_obj = {
"id": "resp_multi",
"model": "gpt-4o",
"status": "completed",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{"type": "output_text", "text": "First paragraph."},
{"type": "output_text", "text": "Second paragraph."},
],
}
],
}
otel.set_attributes(
span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj
)
raw = self._get_attr(mock_span, "gen_ai.output.messages")
parsed = json.loads(raw)
self.assertEqual(len(parsed[0]["parts"]), 2)
self.assertEqual(parsed[0]["parts"][0]["content"], "First paragraph.")
self.assertEqual(parsed[0]["parts"][1]["content"], "Second paragraph.")
def test_output_messages_with_function_call(self):
"""function_call output items should appear as tool_call parts."""
otel = OpenTelemetry()
mock_span = MagicMock()
response_obj = {
"id": "resp_fc",
"model": "gpt-4o",
"status": "completed",
"output": [
{
"type": "function_call",
"name": "get_weather",
"call_id": "call_abc",
"arguments": '{"location": "SF"}',
}
],
}
otel.set_attributes(
span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj
)
raw = self._get_attr(mock_span, "gen_ai.output.messages")
parsed = json.loads(raw)
self.assertEqual(len(parsed), 1)
self.assertEqual(parsed[0]["role"], "assistant")
self.assertEqual(parsed[0]["parts"][0]["type"], "tool_call")
self.assertEqual(parsed[0]["parts"][0]["name"], "get_weather")
self.assertEqual(parsed[0]["parts"][0]["arguments"], '{"location": "SF"}')
self.assertEqual(parsed[0]["parts"][0]["id"], "call_abc")
def test_output_messages_mixed_message_and_function_call(self):
"""Mixed output with both message and function_call items."""
otel = OpenTelemetry()
mock_span = MagicMock()
response_obj = {
"id": "resp_mixed",
"model": "gpt-4o",
"status": "completed",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{"type": "output_text", "text": "Let me check the weather."},
],
},
{
"type": "function_call",
"name": "get_weather",
"call_id": "call_xyz",
"arguments": "{}",
},
],
}
otel.set_attributes(
span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj
)
raw = self._get_attr(mock_span, "gen_ai.output.messages")
parsed = json.loads(raw)
self.assertEqual(len(parsed), 2)
self.assertEqual(parsed[0]["role"], "assistant")
self.assertEqual(parsed[0]["parts"][0]["content"], "Let me check the weather.")
self.assertEqual(parsed[1]["parts"][0]["type"], "tool_call")
def test_output_messages_empty_text_skipped(self):
"""Output items with empty text should not produce parts."""
otel = OpenTelemetry()
mock_span = MagicMock()
response_obj = {
"id": "resp_empty",
"model": "gpt-4o",
"status": "completed",
"output": [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": ""}],
}
],
}
otel.set_attributes(
span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj
)
# No output messages should be set since the text is empty
raw = self._get_attr(mock_span, "gen_ai.output.messages")
self.assertIsNone(raw, "Empty output text should not produce gen_ai.output.messages")
def test_choices_still_work(self):
"""Existing choices-based responses must still work (no regression)."""
otel = OpenTelemetry()
mock_span = MagicMock()
kwargs = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"optional_params": {},
"litellm_params": {"custom_llm_provider": "openai"},
"standard_logging_object": {
"id": "test-id",
"call_type": "completion",
"metadata": {},
},
}
response_obj = {
"id": "chatcmpl-123",
"model": "gpt-4",
"choices": [
{
"finish_reason": "stop",
"message": {"role": "assistant", "content": "Hi there!"},
}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15},
}
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
raw = self._get_attr(mock_span, "gen_ai.output.messages")
parsed = json.loads(raw)
self.assertEqual(parsed[0]["parts"][0]["content"], "Hi there!")
self.assertEqual(parsed[0]["finish_reason"], "stop")
# ------------------------------------------------------------------
# gen_ai.response.finish_reasons
# ------------------------------------------------------------------
def test_finish_reasons_from_status(self):
"""gen_ai.response.finish_reasons should use ResponsesAPIResponse.status."""
otel = OpenTelemetry()
mock_span = MagicMock()
otel.set_attributes(
span=mock_span,
kwargs=self._base_kwargs(),
response_obj=self._responses_api_response_obj(status="completed"),
)
raw = self._get_attr(mock_span, "gen_ai.response.finish_reasons")
self.assertIsNotNone(raw)
parsed = json.loads(raw)
self.assertEqual(parsed, ["completed"])
def test_finish_reasons_incomplete_status(self):
"""Non-completed status values should still be captured."""
otel = OpenTelemetry()
mock_span = MagicMock()
otel.set_attributes(
span=mock_span,
kwargs=self._base_kwargs(),
response_obj=self._responses_api_response_obj(status="incomplete"),
)
raw = self._get_attr(mock_span, "gen_ai.response.finish_reasons")
parsed = json.loads(raw)
self.assertEqual(parsed, ["incomplete"])
# ------------------------------------------------------------------
# gen_ai.system_instructions
# ------------------------------------------------------------------
def test_system_instructions_from_instructions_kwarg(self):
"""Responses API passes system prompt as kwargs['instructions']."""
otel = OpenTelemetry()
mock_span = MagicMock()
kwargs = self._base_kwargs(instructions="You are a math tutor.")
response_obj = self._responses_api_response_obj()
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
value = self._get_attr(mock_span, "gen_ai.system_instructions")
self.assertEqual(value, "You are a math tutor.")
def test_system_instructions_from_system_kwarg(self):
"""Anthropic Messages API passes system prompt as kwargs['system']."""
otel = OpenTelemetry()
mock_span = MagicMock()
kwargs = self._base_kwargs(system="You are a helpful assistant.")
response_obj = self._responses_api_response_obj()
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
value = self._get_attr(mock_span, "gen_ai.system_instructions")
self.assertEqual(value, "You are a helpful assistant.")
def test_system_instructions_from_system_instructions_kwarg(self):
"""Vertex AI Gemini path uses kwargs['system_instructions'] (existing behavior)."""
otel = OpenTelemetry()
mock_span = MagicMock()
kwargs = self._base_kwargs(
system_instructions=[{"role": "system", "content": "Be concise."}]
)
response_obj = self._responses_api_response_obj()
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
raw = self._get_attr(mock_span, "gen_ai.system_instructions")
self.assertIsNotNone(raw)
parsed = json.loads(raw)
self.assertEqual(parsed[0]["role"], "system")
self.assertIn("parts", parsed[0])
def test_system_instructions_precedence(self):
"""system_instructions takes precedence over instructions and system."""
otel = OpenTelemetry()
mock_span = MagicMock()
kwargs = self._base_kwargs(
system_instructions="From Gemini",
instructions="From Responses API",
system="From Anthropic",
)
response_obj = self._responses_api_response_obj()
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
# system_instructions (string) should win — it's checked first
value = self._get_attr(mock_span, "gen_ai.system_instructions")
self.assertEqual(value, "From Gemini")
def test_no_system_instructions_when_absent(self):
"""No gen_ai.system_instructions attr when none of the kwargs are set."""
otel = OpenTelemetry()
mock_span = MagicMock()
kwargs = self._base_kwargs()
response_obj = self._responses_api_response_obj()
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
value = self._get_attr(mock_span, "gen_ai.system_instructions")
self.assertIsNone(value)
class TestTransformResponsesAPIOutput(unittest.TestCase):
"""
Unit tests for _transform_responses_api_output_to_otel.
"""
def test_message_with_output_text(self):
otel = OpenTelemetry()
output = [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hello!"}],
}
]
result = otel._transform_responses_api_output_to_otel(output)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["role"], "assistant")
self.assertEqual(result[0]["parts"], [{"type": "text", "content": "Hello!"}])
def test_function_call_item(self):
otel = OpenTelemetry()
output = [
{
"type": "function_call",
"name": "search",
"call_id": "call_1",
"arguments": '{"q": "test"}',
}
]
result = otel._transform_responses_api_output_to_otel(output)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["role"], "assistant")
self.assertEqual(result[0]["parts"][0]["type"], "tool_call")
self.assertEqual(result[0]["parts"][0]["name"], "search")
self.assertEqual(result[0]["parts"][0]["id"], "call_1")
def test_function_call_without_call_id(self):
otel = OpenTelemetry()
output = [
{
"type": "function_call",
"name": "search",
"arguments": "{}",
}
]
result = otel._transform_responses_api_output_to_otel(output)
self.assertNotIn("id", result[0]["parts"][0])
def test_unknown_type_ignored(self):
otel = OpenTelemetry()
output = [{"type": "reasoning", "content": "thinking..."}]
result = otel._transform_responses_api_output_to_otel(output)
self.assertEqual(result, [])
def test_non_dict_items_ignored(self):
otel = OpenTelemetry()
output = ["not a dict", 42, None]
result = otel._transform_responses_api_output_to_otel(output)
self.assertEqual(result, [])
def test_empty_output(self):
otel = OpenTelemetry()
result = otel._transform_responses_api_output_to_otel([])
self.assertEqual(result, [])
def test_message_with_empty_text_skipped(self):
otel = OpenTelemetry()
output = [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": ""}],
}
]
result = otel._transform_responses_api_output_to_otel(output)
self.assertEqual(result, [])
def test_message_default_role(self):
"""Messages without explicit role should default to assistant."""
otel = OpenTelemetry()
output = [
{
"type": "message",
"content": [{"type": "output_text", "text": "Hi"}],
}
]
result = otel._transform_responses_api_output_to_otel(output)
self.assertEqual(result[0]["role"], "assistant")
def test_pydantic_like_objects_accepted(self):
"""Items with .get() but not isinstance(dict) should be accepted."""
class FakeOutputItem:
"""Mimics BaseLiteLLMOpenAIResponseObject duck-typing."""
def __init__(self, data):
self._data = data
def get(self, key, default=None):
return self._data.get(key, default)
class FakeContent:
def __init__(self, data):
self._data = data
def get(self, key, default=None):
return self._data.get(key, default)
otel = OpenTelemetry()
output = [
FakeOutputItem(
{
"type": "message",
"role": "assistant",
"content": [
FakeContent({"type": "output_text", "text": "Pydantic works!"}),
],
}
)
]
result = otel._transform_responses_api_output_to_otel(output)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["parts"][0]["content"], "Pydantic works!")
class TestSystemInstructionsPrecedence(unittest.TestCase):
"""Tests for the is-not-None precedence in system_instructions coalescing."""
def _get_attr(self, mock_span, attr_name):
calls = [
call
for call in mock_span.set_attribute.call_args_list
if call[0][0] == attr_name
]
if not calls:
return None
return calls[0][0][1]
def _base_kwargs(self, **overrides):
kwargs = {
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Hi"}],
"optional_params": {},
"litellm_params": {"custom_llm_provider": "openai"},
"standard_logging_object": {
"id": "test-id",
"call_type": "responses",
"metadata": {},
},
}
kwargs.update(overrides)
return kwargs
def test_empty_list_system_instructions_does_not_fallthrough(self):
"""An empty list for system_instructions should NOT fall through to instructions."""
otel = OpenTelemetry()
mock_span = MagicMock()
kwargs = self._base_kwargs(
system_instructions=[],
instructions="Should not be used",
)
response_obj = {"id": "r1", "model": "gpt-4o"}
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
# system_instructions is [] (falsy but not None), so it wins.
# Since it's an empty list, no attribute should be set (nothing to transform).
value = self._get_attr(mock_span, "gen_ai.system_instructions")
# The empty list is truthy for `is not None` but produces empty
# transformed output — the attribute should NOT contain "Should not be used".
if value is not None:
self.assertNotIn("Should not be used", str(value))
class TestResponsesAPIToolCallSpanAttributes(unittest.TestCase):
"""Tests for per-tool-call span attributes on Responses API function_call items."""
def _base_kwargs(self):
return {
"model": "gpt-4o",
"messages": [{"role": "user", "content": "What is the weather?"}],
"optional_params": {},
"litellm_params": {"custom_llm_provider": "openai"},
"standard_logging_object": {
"id": "resp_tc",
"call_type": "responses",
"metadata": {},
},
}
def test_per_tool_call_attributes_emitted(self):
"""function_call output items should produce per-tool-call span attributes."""
otel = OpenTelemetry()
mock_span = MagicMock()
response_obj = {
"id": "resp_tc",
"model": "gpt-4o",
"status": "completed",
"output": [
{
"type": "function_call",
"name": "get_weather",
"call_id": "call_abc",
"arguments": '{"location": "SF"}',
}
],
}
otel.set_attributes(span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj)
# Verify per-tool-call attributes were set (same format as choices branch)
attr_names = [call[0][0] for call in mock_span.set_attribute.call_args_list]
tool_call_attrs = [a for a in attr_names if "function_call" in a]
self.assertTrue(len(tool_call_attrs) > 0, "Per-tool-call span attributes should be emitted")
# Verify the name attribute specifically
mock_span.set_attribute.assert_any_call(
"gen_ai.completion.0.function_call.name", "get_weather"
)
mock_span.set_attribute.assert_any_call(
"gen_ai.completion.0.function_call.arguments", '{"location": "SF"}'
)
def test_multiple_tool_calls_indexed(self):
"""Multiple function_call items should be indexed correctly."""
otel = OpenTelemetry()
mock_span = MagicMock()
response_obj = {
"id": "resp_tc2",
"model": "gpt-4o",
"status": "completed",
"output": [
{
"type": "function_call",
"name": "get_weather",
"call_id": "call_1",
"arguments": "{}",
},
{
"type": "function_call",
"name": "get_time",
"call_id": "call_2",
"arguments": "{}",
},
],
}
otel.set_attributes(span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj)
mock_span.set_attribute.assert_any_call(
"gen_ai.completion.0.function_call.name", "get_weather"
)
mock_span.set_attribute.assert_any_call(
"gen_ai.completion.1.function_call.name", "get_time"
)
@@ -54,3 +54,61 @@ def test_ovhcloud_audio_transcription_config_installed():
assert config is not None
assert isinstance(config, BaseAudioTranscriptionConfig)
class TestOVHCloudDurationFieldMigration:
"""Tests for OVHCloud duration -> seconds field migration."""
def test_seconds_field_mapped_to_duration(self):
"""New `seconds` field should be normalized to `duration`."""
from litellm.llms.ovhcloud.audio_transcription.transformation import (
OVHCloudAudioTranscriptionConfig,
)
from unittest.mock import MagicMock
config = OVHCloudAudioTranscriptionConfig()
mock_response = MagicMock()
mock_response.json.return_value = {
"text": "Hello world",
"seconds": 3.14,
}
result = config.transform_audio_transcription_response(mock_response)
assert result.text == "Hello world"
assert result._hidden_params["duration"] == 3.14
def test_legacy_duration_field_still_works(self):
"""Legacy `duration` field should still be accepted."""
from litellm.llms.ovhcloud.audio_transcription.transformation import (
OVHCloudAudioTranscriptionConfig,
)
from unittest.mock import MagicMock
config = OVHCloudAudioTranscriptionConfig()
mock_response = MagicMock()
mock_response.json.return_value = {
"text": "Hello world",
"duration": 2.71,
}
result = config.transform_audio_transcription_response(mock_response)
assert result.text == "Hello world"
assert result._hidden_params["duration"] == 2.71
def test_seconds_zero_mapped_to_duration(self):
"""seconds=0.0 must not be treated as falsy and lost."""
from litellm.llms.ovhcloud.audio_transcription.transformation import (
OVHCloudAudioTranscriptionConfig,
)
from unittest.mock import MagicMock
config = OVHCloudAudioTranscriptionConfig()
mock_response = MagicMock()
mock_response.json.return_value = {"text": "silence", "seconds": 0.0}
result = config.transform_audio_transcription_response(mock_response)
assert result._hidden_params["duration"] == 0.0
@@ -292,3 +292,78 @@ def test_ovhcloud_with_custom_base_url():
if __name__ == "__main__":
pytest.main([__file__, "-v"])
class TestOVHCloudReasoningFieldMigration:
"""Tests for OVHCloud reasoning_content -> reasoning field migration."""
def test_streaming_new_reasoning_field(self):
"""New `reasoning` field should be mapped to `reasoning_content`."""
handler = OVHCloudChatCompletionStreamingHandler(
streaming_response=iter([]),
sync_stream=True,
)
chunk = {
"id": "test-id",
"created": 1234567890,
"model": "test-model",
"choices": [
{
"delta": {
"role": "assistant",
"reasoning": "Let me think...",
},
"index": 0,
}
],
}
result = handler.chunk_parser(chunk)
assert result.choices[0]["delta"]["reasoning_content"] == "Let me think..."
def test_streaming_legacy_reasoning_content_unchanged(self):
"""Legacy `reasoning_content` field should pass through untouched."""
handler = OVHCloudChatCompletionStreamingHandler(
streaming_response=iter([]),
sync_stream=True,
)
chunk = {
"id": "test-id",
"created": 1234567890,
"model": "test-model",
"choices": [
{
"delta": {
"role": "assistant",
"reasoning_content": "Already correct field.",
},
"index": 0,
}
],
}
result = handler.chunk_parser(chunk)
assert result.choices[0]["delta"]["reasoning_content"] == "Already correct field."
def test_streaming_both_fields_legacy_wins(self):
"""When both fields present, existing `reasoning_content` is not overwritten."""
handler = OVHCloudChatCompletionStreamingHandler(
streaming_response=iter([]),
sync_stream=True,
)
chunk = {
"id": "test-id",
"created": 1234567890,
"model": "test-model",
"choices": [
{
"delta": {
"reasoning": "new field",
"reasoning_content": "legacy field",
},
"index": 0,
}
],
}
result = handler.chunk_parser(chunk)
assert result.choices[0]["delta"]["reasoning_content"] == "legacy field"
@@ -53,14 +53,20 @@ def test_non_admin_config_update_route_rejected():
assert "Your role=internal_user" in str(exc_info.value)
@pytest.mark.parametrize(
"role",
[
LitellmUserRoles.INTERNAL_USER.value,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
],
)
@pytest.mark.parametrize(
"route",
["/compliance/eu-ai-act", "/compliance/gdpr"],
)
def test_compliance_routes_open_to_internal_user(route):
def test_compliance_routes_open_to_non_admin_roles(role, route):
"""Compliance routes are stateless validators on caller-supplied log data
- non-admin internal_user roles can call them."""
role = LitellmUserRoles.INTERNAL_USER.value
both non-admin internal_user roles can call them."""
user_obj = LiteLLM_UserTable(
user_id="test_user",
user_email="test@example.com",
@@ -80,56 +86,6 @@ def test_compliance_routes_open_to_internal_user(route):
)
def test_health_test_connection_route_delegates_internal_user_auth_to_endpoint():
"""Team model test-connection requests are authorized by the endpoint."""
role = LitellmUserRoles.INTERNAL_USER.value
user_obj = LiteLLM_UserTable(
user_id="test_user",
user_email="test@example.com",
user_role=role,
)
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=role)
request = MagicMock(spec=Request)
request.query_params = {}
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=role,
route="/health/test_connection",
request=request,
valid_token=valid_token,
request_data={},
)
@pytest.mark.parametrize(
"route",
["/compliance/eu-ai-act", "/compliance/gdpr"],
)
def test_compliance_routes_blocked_for_internal_user_view_only(route):
"""Deprecated internal_user_viewer role must not gain compliance route access."""
role = LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value
user_obj = LiteLLM_UserTable(
user_id="test_user",
user_email="test@example.com",
user_role=role,
)
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=role)
request = MagicMock(spec=Request)
request.query_params = {}
with pytest.raises(Exception) as exc_info:
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=role,
route=route,
request=request,
valid_token=valid_token,
request_data={},
)
assert "Only proxy admin can be used" in str(exc_info.value)
def test_proxy_admin_viewer_config_update_route_rejected():
"""Test that proxy admin viewer users are rejected when trying to call /config/update"""
@@ -1513,3 +1513,146 @@ async def test_commit_spend_updates_uses_pipeline():
mock_redis_update_buffer.get_all_daily_end_user_spend_update_transactions_from_redis_buffer.assert_not_called()
mock_redis_update_buffer.get_all_daily_agent_spend_update_transactions_from_redis_buffer.assert_not_called()
mock_redis_update_buffer.get_all_daily_tag_spend_update_transactions_from_redis_buffer.assert_not_called()
@pytest.mark.parametrize(
"bucket_name,input_dict,table_attr,method_name,where_key,expected_order",
[
pytest.param(
"user_list_transactions",
{"user_c": 0.1, "user_a": 0.2, "user_b": 0.3},
"litellm_usertable",
"update_many",
"user_id",
["user_a", "user_b", "user_c"],
id="user",
),
pytest.param(
"key_list_transactions",
{"tok_c": 0.1, "tok_a": 0.2, "tok_b": 0.3},
"litellm_verificationtoken",
"update_many",
"token",
["tok_a", "tok_b", "tok_c"],
id="key",
),
pytest.param(
"team_list_transactions",
{"team_c": 0.1, "team_a": 0.2, "team_b": 0.3},
"litellm_teamtable",
"update_many",
"team_id",
["team_a", "team_b", "team_c"],
id="team",
),
pytest.param(
"team_member_list_transactions",
{
"team_id::team_c::user_id::user_x": 0.1,
"team_id::team_a::user_id::user_x": 0.2,
"team_id::team_b::user_id::user_x": 0.3,
},
"litellm_teammembership",
"update_many",
"team_id",
["team_a", "team_b", "team_c"],
id="team_member",
),
pytest.param(
"org_list_transactions",
{"org_c": 0.1, "org_a": 0.2, "org_b": 0.3},
"litellm_organizationtable",
"update_many",
"organization_id",
["org_a", "org_b", "org_c"],
id="org",
),
pytest.param(
"end_user_list_transactions",
{"eu_c": 0.1, "eu_a": 0.2, "eu_b": 0.3},
"litellm_endusertable",
"upsert",
"user_id",
["eu_a", "eu_b", "eu_c"],
id="end_user",
),
pytest.param(
"tag_list_transactions",
{"prod": 0.1, "customer-x": 0.2, "test": 0.3},
"litellm_tagtable",
"update_many",
"tag_name",
["customer-x", "prod", "test"],
id="tag",
),
pytest.param(
"agent_list_transactions",
{"agent_c": 0.1, "agent_a": 0.2, "agent_b": 0.3},
"litellm_agentstable",
"update_many",
"agent_id",
["agent_a", "agent_b", "agent_c"],
id="agent",
),
],
)
@pytest.mark.asyncio
async def test_commit_spend_updates_iterates_in_sorted_order(
bucket_name, input_dict, table_attr, method_name, where_key, expected_order
):
"""
Every spend-bucket code path in _commit_spend_updates_to_db must iterate
in sorted order so concurrent pods acquire row locks in the same order
and avoid PostgreSQL deadlocks. Covers the 5 direct loops (user/key/team/
team_member/org), the end_user path in ProxyUpdateSpend.update_end_user_spend,
and the shared _update_entity_spend_in_db helper (tag, agent).
"""
db_writer = DBSpendUpdateWriter()
captured_where_values = []
def capture(*, where, data):
captured_where_values.append(where[where_key])
mock_batcher = MagicMock()
table_mock = MagicMock()
setattr(table_mock, method_name, MagicMock(side_effect=capture))
setattr(mock_batcher, table_attr, table_mock)
mock_transaction = AsyncMock()
mock_transaction.__aenter__ = AsyncMock(return_value=mock_transaction)
mock_transaction.__aexit__ = AsyncMock(return_value=False)
mock_transaction.batch_ = MagicMock(
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_batcher),
__aexit__=AsyncMock(return_value=False),
)
)
mock_prisma_client = MagicMock()
mock_prisma_client.db = MagicMock()
mock_prisma_client.db.tx = MagicMock(return_value=mock_transaction)
mock_proxy_logging = MagicMock()
mock_proxy_logging.call_details = {}
buckets = {
"user_list_transactions": {},
"end_user_list_transactions": {},
"key_list_transactions": {},
"team_list_transactions": {},
"team_member_list_transactions": {},
"org_list_transactions": {},
"tag_list_transactions": {},
"agent_list_transactions": {},
}
buckets[bucket_name] = input_dict
await db_writer._commit_spend_updates_to_db(
prisma_client=mock_prisma_client,
n_retry_times=3,
proxy_logging_obj=mock_proxy_logging,
db_spend_update_transactions=buckets,
)
assert captured_where_values == expected_order
@@ -140,3 +140,110 @@ class TestCredentialEndpointsOpenAPISchema:
assert (
"credential_name" in sig.parameters
), "get_credential_by_name must have a credential_name parameter"
class TestWebSocketStubInjection:
"""
Regression test for the v1.82.3 bug where adding a WebSocket route on a path
that already had an HTTP route silently dropped the HTTP operation from the
OpenAPI schema.
Related case: 2026-05-05-madhu-swagger-responses-missing
"""
def _make_fake_ws_route(self, path: str, name: str = "fake_ws"):
"""Minimal stand-in for fastapi.routing.APIWebSocketRoute for the helper's purposes."""
from types import SimpleNamespace
return SimpleNamespace(path=path, name=name, dependant=None)
def test_websocket_stub_does_not_clobber_existing_post(self):
"""
When a WebSocket route shares its path with an existing POST operation,
the POST must survive the WebSocket stub is added alongside, not on top.
"""
from litellm.proxy.proxy_server import (
_inject_websocket_stubs_into_openapi_schema,
)
schema = {
"paths": {
"/v1/responses": {
"post": {"summary": "responses_api", "operationId": "responses_api"}
}
}
}
ws_routes = [self._make_fake_ws_route("/v1/responses", name="responses_ws")]
result = _inject_websocket_stubs_into_openapi_schema(schema, ws_routes)
assert (
"post" in result["paths"]["/v1/responses"]
), "POST operation must be preserved when a WebSocket route shares the path"
assert (
result["paths"]["/v1/responses"]["post"]["operationId"] == "responses_api"
)
assert (
"get" in result["paths"]["/v1/responses"]
), "WebSocket stub should also be added under 'get'"
assert result["paths"]["/v1/responses"]["get"]["tags"] == ["WebSocket"]
def test_websocket_stub_added_when_path_is_new(self):
"""
When a WebSocket route's path is not already in the schema, the stub
creates a fresh entry preserving the original behavior for WebSocket-only
paths.
"""
from litellm.proxy.proxy_server import (
_inject_websocket_stubs_into_openapi_schema,
)
schema = {"paths": {}}
ws_routes = [self._make_fake_ws_route("/ws_only", name="ws_only")]
result = _inject_websocket_stubs_into_openapi_schema(schema, ws_routes)
assert "/ws_only" in result["paths"]
assert "get" in result["paths"]["/ws_only"]
assert result["paths"]["/ws_only"]["get"]["tags"] == ["WebSocket"]
def test_websocket_stub_skipped_when_existing_get(self):
"""
If a real GET is already documented on the path, the WebSocket stub is
skipped a real operation always wins over the synthetic stub. This
closes the same trap for future GET-vs-WebSocket collisions.
"""
from litellm.proxy.proxy_server import (
_inject_websocket_stubs_into_openapi_schema,
)
schema = {
"paths": {
"/health": {
"get": {"summary": "health_check", "operationId": "real_get"}
}
}
}
ws_routes = [self._make_fake_ws_route("/health", name="health_ws")]
result = _inject_websocket_stubs_into_openapi_schema(schema, ws_routes)
assert (
result["paths"]["/health"]["get"]["operationId"] == "real_get"
), "Real GET must take precedence over WebSocket stub"
def test_responses_post_routes_registered_on_router(self):
"""
Sanity check: the three POST routes for the responses API are still wired
on the responses router. Guards against accidental removal at the source.
"""
from litellm.proxy.response_api_endpoints.endpoints import router
post_paths = {
route.path
for route in router.routes
if hasattr(route, "methods")
and "POST" in (route.methods or set())
and route.path in {"/v1/responses", "/responses", "/openai/v1/responses"}
}
assert post_paths == {"/v1/responses", "/responses", "/openai/v1/responses"}
@@ -0,0 +1,13 @@
from pathlib import Path
def test_main_py_starts_with_brief_file_description():
repo_root = Path(__file__).resolve().parents[2]
main_py = repo_root / "litellm" / "main.py"
first_two_lines = main_py.read_text(encoding="utf-8").splitlines()[:2]
assert any(
"LiteLLM main module" in line and "entrypoints" in line
for line in first_two_lines
)
@@ -0,0 +1,11 @@
import ast
from pathlib import Path
def test_utils_module_has_docstring():
utils_path = Path(__file__).parents[2] / "litellm" / "utils.py"
module = ast.parse(utils_path.read_text())
assert ast.get_docstring(module) == (
"Utility helpers for LiteLLM core request handling and provider support."
)
@@ -158,8 +158,8 @@ describe("KeyEditView", () => {
const { getByText } = renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => { }}
onSubmit={async () => { }}
onCancel={() => {}}
onSubmit={async () => {}}
accessToken={""}
userID={""}
userRole={""}
@@ -176,8 +176,8 @@ describe("KeyEditView", () => {
const { getByText } = renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => { }}
onSubmit={async () => { }}
onCancel={() => {}}
onSubmit={async () => {}}
accessToken={""}
userID={""}
userRole={""}
@@ -194,8 +194,8 @@ describe("KeyEditView", () => {
const { getByLabelText } = renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => { }}
onSubmit={async () => { }}
onCancel={() => {}}
onSubmit={async () => {}}
accessToken={""}
userID={""}
userRole={""}
@@ -219,7 +219,7 @@ describe("KeyEditView", () => {
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={onCancelMock}
onSubmit={async () => { }}
onSubmit={async () => {}}
accessToken={""}
userID={""}
userRole={""}
@@ -241,8 +241,8 @@ describe("KeyEditView", () => {
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => { }}
onSubmit={async () => { }}
onCancel={() => {}}
onSubmit={async () => {}}
accessToken={""}
userID={""}
userRole={""}
@@ -259,8 +259,8 @@ describe("KeyEditView", () => {
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => { }}
onSubmit={async () => { }}
onCancel={() => {}}
onSubmit={async () => {}}
accessToken={""}
userID={""}
userRole={""}
@@ -277,8 +277,8 @@ describe("KeyEditView", () => {
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => { }}
onSubmit={async () => { }}
onCancel={() => {}}
onSubmit={async () => {}}
accessToken={""}
userID={""}
userRole={""}
@@ -295,8 +295,8 @@ describe("KeyEditView", () => {
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => { }}
onSubmit={async () => { }}
onCancel={() => {}}
onSubmit={async () => {}}
accessToken={""}
userID={""}
userRole={""}
@@ -314,7 +314,7 @@ describe("KeyEditView", () => {
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => { }}
onCancel={() => {}}
onSubmit={onSubmitMock}
accessToken={"test-token"}
userID={"test-user"}
@@ -344,8 +344,8 @@ describe("KeyEditView", () => {
renderWithProviders(
<KeyEditView
keyData={keyDataWithManagementRoutes}
onCancel={() => { }}
onSubmit={async () => { }}
onCancel={() => {}}
onSubmit={async () => {}}
accessToken={""}
userID={""}
userRole={""}
@@ -367,8 +367,8 @@ describe("KeyEditView", () => {
renderWithProviders(
<KeyEditView
keyData={keyDataWithInfoRoutes}
onCancel={() => { }}
onSubmit={async () => { }}
onCancel={() => {}}
onSubmit={async () => {}}
accessToken={""}
userID={""}
userRole={""}
@@ -385,8 +385,8 @@ describe("KeyEditView", () => {
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => { }}
onSubmit={async () => { }}
onCancel={() => {}}
onSubmit={async () => {}}
accessToken={"test-token"}
userID={""}
userRole={""}
@@ -404,7 +404,7 @@ describe("KeyEditView", () => {
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => { }}
onCancel={() => {}}
onSubmit={onSubmitMock}
accessToken={"test-token"}
userID={"test-user"}
@@ -434,10 +434,14 @@ describe("KeyEditView", () => {
it("should handle empty allowed routes string on submit", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
const keyDataWithRoutes = {
...MOCK_KEY_DATA,
allowed_routes: ["llm_api_routes"],
};
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => { }}
keyData={keyDataWithRoutes}
onCancel={() => {}}
onSubmit={onSubmitMock}
accessToken={"test-token"}
userID={"test-user"}
@@ -463,6 +467,101 @@ describe("KeyEditView", () => {
});
});
it("should omit allowed_routes from submit when value is unchanged", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
const aiApisKeyData = {
...MOCK_KEY_DATA,
allowed_routes: ["llm_api_routes"],
};
renderWithProviders(
<KeyEditView
keyData={aiApisKeyData}
onCancel={() => {}}
onSubmit={onSubmitMock}
accessToken={"test-token"}
userID={"test-user"}
userRole={"admin"}
premiumUser={false}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
});
const submitButton = screen.getByRole("button", { name: /save changes/i });
await userEvent.click(submitButton);
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
const callArgs = onSubmitMock.mock.calls[0][0];
expect("allowed_routes" in callArgs).toBe(false);
});
});
it("should omit allowed_routes from submit when keyData.allowed_routes is null and form is untouched", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
const keyDataNullRoutes = {
...MOCK_KEY_DATA,
allowed_routes: null as unknown as string[],
};
renderWithProviders(
<KeyEditView
keyData={keyDataNullRoutes}
onCancel={() => {}}
onSubmit={onSubmitMock}
accessToken={"test-token"}
userID={"test-user"}
userRole={"admin"}
premiumUser={false}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
});
const submitButton = screen.getByRole("button", { name: /save changes/i });
await userEvent.click(submitButton);
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
const callArgs = onSubmitMock.mock.calls[0][0];
expect("allowed_routes" in callArgs).toBe(false);
});
});
it("should omit allowed_routes from submit when server returned routes in a different order", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
const keyDataReordered = {
...MOCK_KEY_DATA,
allowed_routes: ["beta_routes", "alpha_routes"],
};
renderWithProviders(
<KeyEditView
keyData={keyDataReordered}
onCancel={() => {}}
onSubmit={onSubmitMock}
accessToken={"test-token"}
userID={"test-user"}
userRole={"admin"}
premiumUser={false}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
});
const submitButton = screen.getByRole("button", { name: /save changes/i });
await userEvent.click(submitButton);
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
const callArgs = onSubmitMock.mock.calls[0][0];
expect("allowed_routes" in callArgs).toBe(false);
});
});
it("should pass access_group_ids to onSubmit when saving key with access groups", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
@@ -554,7 +653,7 @@ describe("KeyEditView", () => {
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => { }}
onCancel={() => {}}
onSubmit={onSubmitMock}
accessToken={"test-token"}
userID={"test-user"}
@@ -576,10 +675,13 @@ describe("KeyEditView", () => {
});
// Wait for the cancel button to actually be disabled (state update may take a moment)
await waitFor(() => {
const cancelButton = screen.getByRole("button", { name: /cancel/i });
expect(cancelButton).toBeDisabled();
}, { timeout: 3000 });
await waitFor(
() => {
const cancelButton = screen.getByRole("button", { name: /cancel/i });
expect(cancelButton).toBeDisabled();
},
{ timeout: 3000 },
);
// Clean up: resolve the promise to allow the form to complete
if (resolveSubmit) {
@@ -78,7 +78,6 @@ const getKeyTypeFromRoutes = (allowedRoutes: string[] | null | undefined): strin
return "default";
};
export function KeyEditView({
keyData,
onCancel,
@@ -106,7 +105,7 @@ export function KeyEditView({
const [neverExpire, setNeverExpire] = useState<boolean>(!keyData.expires);
const [isKeySaving, setIsKeySaving] = useState(false);
const [budgetLimits, setBudgetLimits] = useState<BudgetWindowEntry[]>(
Array.isArray(keyData.budget_limits) ? keyData.budget_limits : []
Array.isArray(keyData.budget_limits) ? keyData.budget_limits : [],
);
const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations();
const { data: projects } = useProjects();
@@ -116,9 +115,7 @@ export function KeyEditView({
const projectDisplay = (() => {
if (!keyData.project_id) return null;
const project = projects?.find((p) => p.project_id === keyData.project_id);
return project?.project_alias
? `${project.project_alias} (${keyData.project_id})`
: keyData.project_id;
return project?.project_alias ? `${project.project_alias} (${keyData.project_id})` : keyData.project_id;
})();
useEffect(() => {
@@ -198,9 +195,10 @@ export function KeyEditView({
access_group_ids: keyData.access_group_ids || [],
auto_rotate: keyData.auto_rotate || false,
...(keyData.rotation_interval && { rotation_interval: keyData.rotation_interval }),
allowed_routes: Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0
? keyData.allowed_routes.join(", ")
: "",
allowed_routes:
Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0
? keyData.allowed_routes.join(", ")
: "",
};
useEffect(() => {
@@ -226,9 +224,10 @@ export function KeyEditView({
access_group_ids: keyData.access_group_ids || [],
auto_rotate: keyData.auto_rotate || false,
...(keyData.rotation_interval && { rotation_interval: keyData.rotation_interval }),
allowed_routes: Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0
? keyData.allowed_routes.join(", ")
: "",
allowed_routes:
Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0
? keyData.allowed_routes.join(", ")
: "",
});
}, [keyData, form]);
@@ -275,12 +274,25 @@ export function KeyEditView({
}
// If it's already an array (shouldn't happen, but handle it), keep as is
// Backend rejects non-empty allowed_routes from non-admins, so re-sending
// an unchanged value 403s a team admin. Set compare tolerates reorder.
const originalRoutesSet = new Set<string>(Array.isArray(keyData.allowed_routes) ? keyData.allowed_routes : []);
const submittedRoutesSet = new Set<string>(Array.isArray(values.allowed_routes) ? values.allowed_routes : []);
const allowedRoutesUnchanged =
originalRoutesSet.size === submittedRoutesSet.size &&
[...submittedRoutesSet].every((r) => originalRoutesSet.has(r));
if (allowedRoutesUnchanged) {
delete values.allowed_routes;
}
if (neverExpire) {
values.duration = null;
}
// Include multi-window budget limits (filter out incomplete entries)
const validWindows = budgetLimits.filter((w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined);
const validWindows = budgetLimits.filter(
(w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined,
);
values.budget_limits = validWindows.length > 0 ? validWindows : undefined;
await onSubmit(values);
@@ -305,9 +317,13 @@ export function KeyEditView({
{({ getFieldValue, setFieldValue }) => {
const allowedRoutesValue = getFieldValue("allowed_routes") || "";
// Convert string to array for checking
const allowedRoutes = typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== ""
? allowedRoutesValue.split(",").map((r: string) => r.trim()).filter((r: string) => r.length > 0)
: [];
const allowedRoutes =
typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== ""
? allowedRoutesValue
.split(",")
.map((r: string) => r.trim())
.filter((r: string) => r.length > 0)
: [];
const isDisabled = allowedRoutes.includes("management_routes") || allowedRoutes.includes("info_routes");
const models = getFieldValue("models") || [];
@@ -348,9 +364,13 @@ export function KeyEditView({
{({ getFieldValue, setFieldValue }) => {
const allowedRoutesValue = getFieldValue("allowed_routes") || "";
// Convert string to array for getKeyTypeFromRoutes
const allowedRoutes = typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== ""
? allowedRoutesValue.split(",").map((r: string) => r.trim()).filter((r: string) => r.length > 0)
: [];
const allowedRoutes =
typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== ""
? allowedRoutesValue
.split(",")
.map((r: string) => r.trim())
.filter((r: string) => r.length > 0)
: [];
const keyTypeValue = getKeyTypeFromRoutes(allowedRoutes);
return (
@@ -415,9 +435,7 @@ export function KeyEditView({
}
name="allowed_routes"
>
<Input
placeholder="Enter allowed routes (comma-separated). Special values: llm_api_routes, management_routes. Examples: llm_api_routes, /chat/completions, /keys/*. Leave empty to allow all routes"
/>
<Input placeholder="Enter allowed routes (comma-separated). Special values: llm_api_routes, management_routes. Examples: llm_api_routes, /chat/completions, /keys/*. Leave empty to allow all routes" />
</Form.Item>
<Form.Item label="Max Budget (USD)" name="max_budget">
@@ -442,10 +460,7 @@ export function KeyEditView({
</span>
}
>
<BudgetWindowsEditor
value={budgetLimits}
onChange={setBudgetLimits}
/>
<BudgetWindowsEditor value={budgetLimits} onChange={setBudgetLimits} />
</Form.Item>
<Form.Item label="TPM Limit" name="tpm_limit">
@@ -579,7 +594,7 @@ export function KeyEditView({
!premiumUser
? "Premium feature - Upgrade to set allowed pass through routes by key"
: Array.isArray(keyData.metadata?.allowed_passthrough_routes) &&
keyData.metadata.allowed_passthrough_routes.length > 0
keyData.metadata.allowed_passthrough_routes.length > 0
? `Current: ${keyData.metadata.allowed_passthrough_routes.join(", ")}`
: "Select or enter allowed pass through routes"
}
@@ -690,14 +705,13 @@ export function KeyEditView({
return team.team_alias?.toLowerCase().includes(input.toLowerCase()) ?? false;
}}
>
{(selectedOrganizationId
? teams?.filter((t) => t.organization_id === selectedOrganizationId)
: teams
)?.map((team) => (
<Select.Option key={team.team_id} value={team.team_id}>
{`${team.team_alias} (${team.team_id})`}
</Select.Option>
))}
{(selectedOrganizationId ? teams?.filter((t) => t.organization_id === selectedOrganizationId) : teams)?.map(
(team) => (
<Select.Option key={team.team_id} value={team.team_id}>
{`${team.team_alias} (${team.team_id})`}
</Select.Option>
),
)}
</Select>
</Form.Item>
{enableProjectsUI && hasProject && (