mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-21 08:26:34 +00:00
Merge branch 'litellm_internal_staging' into shin_agent_oss_staging_05_09_2026
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# LiteLLM main module: public completion, embedding, streaming, and moderation entrypoints.
|
||||
#
|
||||
# +-----------------------------------------------+
|
||||
# | |
|
||||
# | Give Feedback / Get Help |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user