Skip to content

Commit e56b11c

Browse files
added tests
1 parent fc7de7a commit e56b11c

File tree

3 files changed

+82
-123
lines changed

3 files changed

+82
-123
lines changed

litellm/integrations/datadog/datadog_llm_obs.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -148,17 +148,19 @@ async def async_send_batch(self):
148148
),
149149
),
150150
}
151-
151+
152152
# serialize datetime objects - for budget reset time in spend metrics
153153
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
154-
154+
155155
try:
156156
verbose_logger.debug("payload %s", safe_dumps(payload))
157157
except Exception as debug_error:
158-
verbose_logger.debug("payload serialization failed: %s", str(debug_error))
159-
158+
verbose_logger.debug(
159+
"payload serialization failed: %s", str(debug_error)
160+
)
161+
160162
json_payload = safe_dumps(payload)
161-
163+
162164
response = await self.async_client.post(
163165
url=self.intake_url,
164166
content=json_payload,
@@ -331,6 +333,7 @@ def _get_response_messages(
331333
if isinstance(response_obj, str):
332334
try:
333335
import ast
336+
334337
response_obj = ast.literal_eval(response_obj)
335338
except (ValueError, SyntaxError):
336339
try:
@@ -557,21 +560,23 @@ def _get_latency_metrics(
557560
)
558561

559562
return latency_metrics
560-
563+
561564
def _get_spend_metrics(
562-
self, standard_logging_payload: StandardLoggingPayload
565+
self, standard_logging_payload: StandardLoggingPayload
563566
) -> DDLLMObsSpendMetrics:
564567
"""
565568
Get the spend metrics from the standard logging payload
566569
"""
567570
spend_metrics: DDLLMObsSpendMetrics = DDLLMObsSpendMetrics()
568571

569572
# send response cost
570-
spend_metrics["response_cost"] = standard_logging_payload.get("response_cost", 0.0)
573+
spend_metrics["response_cost"] = standard_logging_payload.get(
574+
"response_cost", 0.0
575+
)
571576

572577
# Get budget information from metadata
573578
metadata = standard_logging_payload.get("metadata", {})
574-
579+
575580
# API key max budget
576581
user_api_key_max_budget = metadata.get("user_api_key_max_budget")
577582
if user_api_key_max_budget is not None:
@@ -583,7 +588,9 @@ def _get_spend_metrics(
583588
try:
584589
spend_metrics["user_api_key_spend"] = float(user_api_key_spend)
585590
except (ValueError, TypeError):
586-
verbose_logger.debug(f"Invalid user_api_key_spend value: {user_api_key_spend}")
591+
verbose_logger.debug(
592+
f"Invalid user_api_key_spend value: {user_api_key_spend}"
593+
)
587594

588595
# API key budget reset datetime
589596
user_api_key_budget_reset_at = metadata.get("user_api_key_budget_reset_at")
@@ -594,7 +601,7 @@ def _get_spend_metrics(
594601
budget_reset_at = None
595602
if isinstance(user_api_key_budget_reset_at, str):
596603
# Handle ISO format strings that might have 'Z' suffix
597-
iso_string = user_api_key_budget_reset_at.replace('Z', '+00:00')
604+
iso_string = user_api_key_budget_reset_at.replace("Z", "+00:00")
598605
budget_reset_at = datetime.fromisoformat(iso_string)
599606
elif isinstance(user_api_key_budget_reset_at, datetime):
600607
budget_reset_at = user_api_key_budget_reset_at
@@ -608,9 +615,11 @@ def _get_spend_metrics(
608615
# This prevents circular reference issues and ensures proper timezone representation
609616
iso_string = budget_reset_at.isoformat()
610617
spend_metrics["user_api_key_budget_reset_at"] = iso_string
611-
618+
612619
# Debug logging to verify the conversion
613-
verbose_logger.debug(f"Converted budget_reset_at to ISO format: {iso_string}")
620+
verbose_logger.debug(
621+
f"Converted budget_reset_at to ISO format: {iso_string}"
622+
)
614623
except Exception as e:
615624
verbose_logger.debug(f"Error processing budget reset datetime: {e}")
616625
verbose_logger.debug(f"Original value: {user_api_key_budget_reset_at}")

litellm/types/integrations/datadog_llm_obs.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,9 @@ class DDLLMObsLatencyMetrics(TypedDict, total=False):
8282
litellm_overhead_time_ms: float
8383
guardrail_overhead_time_ms: float
8484

85+
8586
class DDLLMObsSpendMetrics(TypedDict, total=False):
8687
response_cost: float
8788
user_api_key_spend: float
8889
user_api_key_max_budget: float
89-
user_api_key_budget_reset_at: str
90+
user_api_key_budget_reset_at: str

tests/test_litellm/integrations/datadog/test_datadog_llm_observability.py

Lines changed: 58 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import asyncio
22
import os
33
import sys
4-
from datetime import datetime, timedelta
4+
from datetime import datetime, timedelta, timezone
55
from typing import Optional
66
from unittest.mock import Mock, patch, MagicMock
77

@@ -901,89 +901,12 @@ def test_tool_call_response_handling(self, mock_env_vars):
901901
output_function_info = output_tool_calls[0].get("function", {})
902902
assert output_function_info.get("name") == "format_response"
903903

904-
905-
def create_standard_logging_payload() -> StandardLoggingPayload:
906-
"""Create a standard logging payload for testing"""
907-
return {
908-
"id": "test_id",
909-
"trace_id": "test_trace_id",
910-
"call_type": "completion",
911-
"stream": False,
912-
"response_cost": 0.1,
913-
"response_cost_failure_debug_info": None,
914-
"status": "success",
915-
"custom_llm_provider": None,
916-
"total_tokens": 30,
917-
"prompt_tokens": 20,
918-
"completion_tokens": 10,
919-
"startTime": 1234567890.0,
920-
"endTime": 1234567891.0,
921-
"completionStartTime": 1234567890.5,
922-
"response_time": 1.0,
923-
"model_map_information": {
924-
"model_map_key": "gpt-3.5-turbo",
925-
"model_map_value": None
926-
},
927-
"model": "gpt-3.5-turbo",
928-
"model_id": "model-123",
929-
"model_group": "openai-gpt",
930-
"api_base": "https://api.openai.com",
931-
"metadata": {
932-
"user_api_key_hash": "test_hash",
933-
"user_api_key_org_id": None,
934-
"user_api_key_alias": "test_alias",
935-
"user_api_key_team_id": "test_team",
936-
"user_api_key_user_id": "test_user",
937-
"user_api_key_team_alias": "test_team_alias",
938-
"user_api_key_end_user_id": None,
939-
"user_api_key_request_route": None,
940-
"user_api_key_max_budget": None,
941-
"user_api_key_budget_reset_at": None,
942-
"user_api_key_user_email": None,
943-
"spend_logs_metadata": None,
944-
"requester_ip_address": "127.0.0.1",
945-
"requester_metadata": None,
946-
"requester_custom_headers": None,
947-
"prompt_management_metadata": None,
948-
"mcp_tool_call_metadata": None,
949-
"vector_store_request_metadata": None,
950-
"applied_guardrails": None,
951-
"usage_object": None,
952-
"cold_storage_object_key": None,
953-
},
954-
"cache_hit": False,
955-
"cache_key": None,
956-
"saved_cache_cost": 0.0,
957-
"request_tags": [],
958-
"end_user": None,
959-
"requester_ip_address": "127.0.0.1",
960-
"messages": [{"role": "user", "content": "Hello, world!"}],
961-
"response": {"choices": [{"message": {"content": "Hi there!"}}]},
962-
"error_str": None,
963-
"model_parameters": {"stream": True},
964-
"hidden_params": {
965-
"model_id": "model-123",
966-
"cache_key": None,
967-
"api_base": "https://api.openai.com",
968-
"response_cost": "0.1",
969-
"additional_headers": None,
970-
"litellm_overhead_time_ms": None,
971-
"batch_models": None,
972-
"litellm_model_name": None,
973-
"usage_object": None,
974-
},
975-
"error_information": None,
976-
"guardrail_information": None,
977-
"standard_built_in_tools_params": None,
978-
} # type: ignore
979-
980-
981904
def create_standard_logging_payload_with_spend_metrics() -> StandardLoggingPayload:
982905
"""Create a StandardLoggingPayload object with spend metrics for testing"""
983906
from datetime import datetime, timezone
984907

985-
# Create a budget reset time 24 hours from now
986-
budget_reset_at = datetime.now(timezone.utc) + timedelta(hours=24)
908+
# Create a budget reset time 10 days from now (using "10d" format)
909+
budget_reset_at = datetime.now(timezone.utc) + timedelta(days=10)
987910

988911
return {
989912
"id": "test-request-id-spend",
@@ -1019,8 +942,9 @@ def create_standard_logging_payload_with_spend_metrics() -> StandardLoggingPaylo
1019942
"user_api_key_user_email": None,
1020943
"user_api_key_end_user_id": None,
1021944
"user_api_key_request_route": None,
945+
"user_api_key_spend": 0.67,
1022946
"user_api_key_max_budget": 10.0, # $10 max budget
1023-
"user_api_key_budget_reset_at": budget_reset_at.isoformat(),
947+
"user_api_key_budget_reset_at": budget_reset_at.isoformat(), # ISO format: 2025-09-26T...
1024948
"spend_logs_metadata": None,
1025949
"requester_ip_address": "127.0.0.1",
1026950
"requester_metadata": None,
@@ -1064,23 +988,32 @@ async def test_datadog_llm_obs_spend_metrics(mock_env_vars):
1064988
"""Test that budget metrics are properly extracted and logged"""
1065989
datadog_llm_obs_logger = DataDogLLMObsLogger()
1066990

1067-
# Create a standard logging payload with budget metadata
1068-
payload = create_standard_logging_payload()
991+
# Create a standard logging payload with spend metrics
992+
payload = create_standard_logging_payload_with_spend_metrics()
1069993

1070-
# Add budget information to metadata
1071-
payload["metadata"]["user_api_key_max_budget"] = 10.0
1072-
payload["metadata"]["user_api_key_budget_reset_at"] = "2025-09-15T00:00:00+00:00"
994+
# Show the budget reset time in ISO format
995+
budget_reset_iso = payload["metadata"]["user_api_key_budget_reset_at"]
996+
print(f"Budget reset time (ISO format): {budget_reset_iso}")
997+
from datetime import datetime, timezone
998+
print(f"Current time: {datetime.now(timezone.utc).isoformat()}")
1073999

10741000
# Test the _get_spend_metrics method
10751001
spend_metrics = datadog_llm_obs_logger._get_spend_metrics(payload)
10761002

10771003
# Verify budget metrics are present
1078-
assert "litellm_api_key_max_budget_metric" in spend_metrics
1079-
assert spend_metrics["litellm_api_key_max_budget_metric"] == 10.0
1080-
1081-
assert "litellm_api_key_budget_remaining_hours_metric" in spend_metrics
1082-
# The remaining hours should be calculated based on the reset time
1083-
assert spend_metrics["litellm_api_key_budget_remaining_hours_metric"] >= 0
1004+
assert "user_api_key_max_budget" in spend_metrics
1005+
assert spend_metrics["user_api_key_max_budget"] == 10.0
1006+
1007+
assert "user_api_key_budget_reset_at" in spend_metrics
1008+
# The budget reset should be a datetime string in ISO format
1009+
budget_reset = spend_metrics["user_api_key_budget_reset_at"]
1010+
assert isinstance(budget_reset, str)
1011+
print(f"Budget reset datetime: {budget_reset}")
1012+
# Should be close to 10 days from now
1013+
budget_reset_dt = datetime.fromisoformat(budget_reset.replace('Z', '+00:00'))
1014+
now = datetime.now(timezone.utc)
1015+
time_diff = (budget_reset_dt - now).total_seconds() / 86400 # days
1016+
assert 9.5 <= time_diff <= 10.5 # Should be close to 10 days
10841017

10851018
print(f"Spend metrics: {spend_metrics}")
10861019

@@ -1091,25 +1024,30 @@ async def test_datadog_llm_obs_spend_metrics_no_budget(mock_env_vars):
10911024
datadog_llm_obs_logger = DataDogLLMObsLogger()
10921025

10931026
# Create a standard logging payload without budget metadata
1094-
payload = create_standard_logging_payload()
1027+
payload = create_standard_logging_payload_with_spend_metrics()
1028+
1029+
# Remove budget-related metadata to test no-budget scenario
1030+
payload["metadata"].pop("user_api_key_max_budget", None)
1031+
payload["metadata"].pop("user_api_key_budget_reset_at", None)
10951032

10961033
# Test the _get_spend_metrics method
10971034
spend_metrics = datadog_llm_obs_logger._get_spend_metrics(payload)
10981035

10991036
# Verify only response cost is present
1100-
assert "litellm_spend_metric" in spend_metrics
1101-
assert spend_metrics["litellm_spend_metric"] == 0.1
1037+
assert "response_cost" in spend_metrics
1038+
assert spend_metrics["response_cost"] == 0.15
11021039

11031040
# Budget metrics should not be present
1104-
assert "litellm_api_key_max_budget_metric" not in spend_metrics
1105-
assert "litellm_api_key_budget_remaining_hours_metric" not in spend_metrics
1041+
assert "user_api_key_max_budget" not in spend_metrics
1042+
assert "user_api_key_budget_reset_at" not in spend_metrics
11061043

11071044
print(f"Spend metrics (no budget): {spend_metrics}")
11081045

11091046

11101047
@pytest.mark.asyncio
11111048
async def test_spend_metrics_in_datadog_payload(mock_env_vars):
11121049
"""Test that spend metrics are correctly included in DataDog LLM Observability payloads"""
1050+
from datetime import datetime
11131051
datadog_llm_obs_logger = DataDogLLMObsLogger()
11141052

11151053
standard_payload = create_standard_logging_payload_with_spend_metrics()
@@ -1138,17 +1076,28 @@ async def test_spend_metrics_in_datadog_payload(mock_env_vars):
11381076
spend_metrics = metadata.get("spend_metrics", {})
11391077
assert spend_metrics, "Spend metrics should exist in metadata"
11401078

1141-
# Check that all three spend metrics are present
1142-
assert "litellm_spend_metric" in spend_metrics
1143-
assert "litellm_api_key_max_budget_metric" in spend_metrics
1144-
assert "litellm_api_key_budget_remaining_hours_metric" in spend_metrics
1079+
# Check that all metrics are present
1080+
assert "response_cost" in spend_metrics
1081+
assert "user_api_key_spend" in spend_metrics
1082+
assert "user_api_key_max_budget" in spend_metrics
1083+
assert "user_api_key_budget_reset_at" in spend_metrics
11451084

11461085
# Verify the values are correct
1147-
assert spend_metrics["litellm_spend_metric"] == 0.15 # response_cost
1148-
assert spend_metrics["litellm_api_key_max_budget_metric"] == 10.0 # max budget
1149-
1150-
# Verify remaining hours is a reasonable value (should be close to 24 since we set it to 24 hours from now)
1151-
remaining_hours = spend_metrics["litellm_api_key_budget_remaining_hours_metric"]
1152-
assert isinstance(remaining_hours, (int, float))
1153-
assert 20 <= remaining_hours <= 25 # Should be close to 24 hours
1154-
1086+
assert spend_metrics["response_cost"] == 0.15 # response_cost
1087+
assert spend_metrics["user_api_key_spend"] == 0.67 # lol
1088+
assert spend_metrics["user_api_key_max_budget"] == 10.0 # max budget
1089+
1090+
# Verify budget reset is a datetime string in ISO format
1091+
budget_reset = spend_metrics["user_api_key_budget_reset_at"]
1092+
assert isinstance(budget_reset, str)
1093+
print(f"Budget reset in payload: {budget_reset}") # In StandardLoggingUserAPIKeyMetadata
1094+
user_api_key_budget_reset_at: Optional[str] = None
1095+
1096+
# In DDLLMObsSpendMetrics
1097+
user_api_key_budget_reset_at: str
1098+
# Should be close to 10 days from now
1099+
from datetime import datetime, timezone
1100+
budget_reset_dt = datetime.fromisoformat(budget_reset.replace('Z', '+00:00'))
1101+
now = datetime.now(timezone.utc)
1102+
time_diff = (budget_reset_dt - now).total_seconds() / 86400 # days
1103+
assert 9.5 <= time_diff <= 10.5 # Should be close to 10 days

0 commit comments

Comments
 (0)