Skip to content

Commit 6156590

Browse files
added spend metrics
1 parent fd11159 commit 6156590

File tree

3 files changed

+192
-8
lines changed

3 files changed

+192
-8
lines changed

litellm/integrations/datadog/datadog_llm_obs.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,6 @@ def create_llm_obs_payload(
185185

186186
messages = standard_logging_payload["messages"]
187187
messages = self._ensure_string_content(messages=messages)
188-
response_obj = standard_logging_payload.get("response")
189188

190189
metadata = kwargs.get("litellm_params", {}).get("metadata", {})
191190

@@ -495,6 +494,12 @@ def _get_dd_llm_obs_payload_metadata(
495494
latency_metrics = self._get_latency_metrics(standard_logging_payload)
496495
_metadata.update({"latency_metrics": dict(latency_metrics)})
497496

497+
#########################################################
498+
# Add spend metrics to metadata
499+
#########################################################
500+
spend_metrics = self._get_spend_metrics(standard_logging_payload)
501+
_metadata.update({"spend_metrics": dict(spend_metrics)})
502+
498503
## extract tool calls and add to metadata
499504
tool_call_metadata = self._extract_tool_call_metadata(standard_logging_payload)
500505
_metadata.update(tool_call_metadata)
@@ -543,6 +548,47 @@ def _get_latency_metrics(
543548
)
544549

545550
return latency_metrics
551+
552+
def _get_spend_metrics(
553+
self, standard_logging_payload: StandardLoggingPayload
554+
) -> DDLLMObsSpendMetrics:
555+
"""
556+
Get the spend metrics from the standard logging payload
557+
"""
558+
spend_metrics: DDLLMObsSpendMetrics = DDLLMObsSpendMetrics()
559+
560+
# Get response cost for litellm_spend_metric
561+
response_cost = standard_logging_payload.get("response_cost", 0.0)
562+
if response_cost > 0:
563+
spend_metrics["litellm_spend_metric"] = response_cost
564+
565+
# Get budget information from metadata
566+
metadata = standard_logging_payload.get("metadata", {})
567+
568+
# API key max budget
569+
user_api_key_max_budget = metadata.get("user_api_key_max_budget")
570+
if user_api_key_max_budget is not None:
571+
spend_metrics["litellm_api_key_max_budget_metric"] = user_api_key_max_budget
572+
573+
# API key budget remaining hours
574+
user_api_key_budget_reset_at = metadata.get("user_api_key_budget_reset_at")
575+
if user_api_key_budget_reset_at is not None:
576+
try:
577+
from datetime import datetime
578+
if isinstance(user_api_key_budget_reset_at, str):
579+
# Parse ISO string if it's a string
580+
budget_reset_at = datetime.fromisoformat(user_api_key_budget_reset_at.replace('Z', '+00:00'))
581+
else:
582+
budget_reset_at = user_api_key_budget_reset_at
583+
584+
remaining_hours = (
585+
budget_reset_at - datetime.now(budget_reset_at.tzinfo)
586+
).total_seconds() / 3600
587+
spend_metrics["litellm_api_key_budget_remaining_hours_metric"] = max(0, remaining_hours)
588+
except Exception as e:
589+
verbose_logger.debug(f"Error calculating remaining hours for budget reset: {e}")
590+
591+
return spend_metrics
546592

547593
def _process_input_messages_preserving_tool_calls(
548594
self, messages: List[Any]

litellm/types/integrations/datadog_llm_obs.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,9 @@ class DDLLMObsLatencyMetrics(TypedDict, total=False):
8181
time_to_first_token_ms: float
8282
litellm_overhead_time_ms: float
8383
guardrail_overhead_time_ms: float
84+
85+
class DDLLMObsSpendMetrics(TypedDict, total=False):
86+
litellm_spend_metric: float
87+
litellm_api_key_max_budget_metric: float
88+
litellm_remaining_api_key_budget_metric: float
89+
litellm_api_key_budget_remaining_hours_metric: float

tests/test_litellm/integrations/datadog/test_datadog_llm_observability.py

Lines changed: 139 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -657,21 +657,109 @@ def test_guardrail_information_in_metadata(mock_env_vars):
657657
assert guardrail_info["guardrail_response"]["score"] == 0.1
658658

659659

660+
def create_standard_logging_payload_with_spend_metrics() -> StandardLoggingPayload:
661+
"""Create a StandardLoggingPayload object with spend metrics for testing"""
662+
from datetime import datetime, timezone
663+
664+
# Create a budget reset time 24 hours from now
665+
budget_reset_at = datetime.now(timezone.utc) + timedelta(hours=24)
666+
667+
return {
668+
"id": "test-request-id-spend",
669+
"trace_id": "test-trace-id-spend",
670+
"call_type": "completion",
671+
"stream": None,
672+
"response_cost": 0.15,
673+
"response_cost_failure_debug_info": None,
674+
"status": "success",
675+
"custom_llm_provider": "openai",
676+
"total_tokens": 30,
677+
"prompt_tokens": 10,
678+
"completion_tokens": 20,
679+
"startTime": 1234567890.0,
680+
"endTime": 1234567891.0,
681+
"completionStartTime": 1234567890.5,
682+
"response_time": 1.0,
683+
"model_map_information": {
684+
"model_map_key": "gpt-4",
685+
"model_map_value": None
686+
},
687+
"model": "gpt-4",
688+
"model_id": "model-123",
689+
"model_group": "openai-gpt",
690+
"api_base": "https://api.openai.com",
691+
"metadata": {
692+
"user_api_key_hash": "test_hash",
693+
"user_api_key_org_id": None,
694+
"user_api_key_alias": "test_alias",
695+
"user_api_key_team_id": "test_team",
696+
"user_api_key_user_id": "test_user",
697+
"user_api_key_team_alias": "test_team_alias",
698+
"user_api_key_user_email": None,
699+
"user_api_key_end_user_id": None,
700+
"user_api_key_request_route": None,
701+
"user_api_key_max_budget": 10.0, # $10 max budget
702+
"user_api_key_budget_reset_at": budget_reset_at.isoformat(),
703+
"spend_logs_metadata": None,
704+
"requester_ip_address": "127.0.0.1",
705+
"requester_metadata": None,
706+
"requester_custom_headers": None,
707+
"prompt_management_metadata": None,
708+
"mcp_tool_call_metadata": None,
709+
"vector_store_request_metadata": None,
710+
"applied_guardrails": None,
711+
"usage_object": None,
712+
"cold_storage_object_key": None,
713+
},
714+
"cache_hit": False,
715+
"cache_key": None,
716+
"saved_cache_cost": 0.0,
717+
"request_tags": [],
718+
"end_user": None,
719+
"requester_ip_address": "127.0.0.1",
720+
"messages": [{"role": "user", "content": "Hello, world!"}],
721+
"response": {"choices": [{"message": {"content": "Hi there!"}}]},
722+
"error_str": None,
723+
"error_information": None,
724+
"model_parameters": {"stream": False},
725+
"hidden_params": {
726+
"model_id": "model-123",
727+
"cache_key": None,
728+
"api_base": "https://api.openai.com",
729+
"response_cost": "0.15",
730+
"litellm_overhead_time_ms": None,
731+
"additional_headers": None,
732+
"batch_models": None,
733+
"litellm_model_name": None,
734+
"usage_object": None,
735+
},
736+
"guardrail_information": None,
737+
"standard_built_in_tools_params": None,
738+
} # type: ignore
739+
740+
660741
def create_standard_logging_payload_with_tool_calls() -> StandardLoggingPayload:
661742
"""Create a StandardLoggingPayload object with tool calls for testing"""
662743
return {
663744
"id": "test-request-id-tool-calls",
745+
"trace_id": "test-trace-id-tool-calls",
664746
"call_type": "completion",
747+
"stream": None,
665748
"response_cost": 0.05,
666749
"response_cost_failure_debug_info": None,
667750
"status": "success",
751+
"custom_llm_provider": "openai",
668752
"total_tokens": 50,
669753
"prompt_tokens": 20,
670754
"completion_tokens": 30,
671755
"startTime": 1234567890.0,
672756
"endTime": 1234567891.0,
673757
"completionStartTime": 1234567890.5,
674-
"model_map_information": {"model_map_key": "gpt-4", "model_map_value": None},
758+
"response_time": 1.0,
759+
"model_map_information": {
760+
"model_map_key": "gpt-4",
761+
"model_map_value": None
762+
},
675763
"model": "gpt-4",
676764
"model_id": "model-123",
677765
"model_group": "openai-gpt",
@@ -746,6 +834,7 @@ def create_standard_logging_payload_with_tool_calls() -> StandardLoggingPayload:
746834
]
747835
},
748836
"error_str": None,
837+
"error_information": None,
749838
"model_parameters": {"temperature": 0.7},
750839
"hidden_params": {
751840
"model_id": "model-123",
@@ -758,14 +847,9 @@ def create_standard_logging_payload_with_tool_calls() -> StandardLoggingPayload:
758847
"litellm_model_name": None,
759848
"usage_object": None,
760849
},
761-
"stream": None,
762-
"response_time": 1.0,
763-
"error_information": None,
764850
"guardrail_information": None,
765851
"standard_built_in_tools_params": None,
766-
"trace_id": "test-trace-id-tool-calls",
767-
"custom_llm_provider": "openai",
768-
}
852+
} # type: ignore
769853

770854

771855
class TestDataDogLLMObsLoggerToolCalls:
@@ -897,3 +981,51 @@ def test_tool_call_response_handling(self, mock_env_vars):
897981
assert len(output_tool_calls) == 1
898982
output_function_info = output_tool_calls[0].get("function", {})
899983
assert output_function_info.get("name") == "format_response"
984+
985+
986+
def test_spend_metrics_in_datadog_payload(mock_env_vars):
987+
"""Test that spend metrics are correctly included in DataDog LLM Observability payloads"""
988+
with patch(
989+
"litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client"
990+
), patch("asyncio.create_task"):
991+
logger = DataDogLLMObsLogger()
992+
993+
standard_payload = create_standard_logging_payload_with_spend_metrics()
994+
995+
kwargs = {
996+
"standard_logging_object": standard_payload,
997+
"litellm_params": {"metadata": {}},
998+
}
999+
1000+
start_time = datetime.now()
1001+
end_time = datetime.now()
1002+
1003+
payload = logger.create_llm_obs_payload(kwargs, start_time, end_time)
1004+
1005+
# Verify basic payload structure
1006+
assert payload.get("name") == "litellm_llm_call"
1007+
assert payload.get("status") == "ok"
1008+
1009+
# Verify spend metrics are included in metadata
1010+
meta = payload.get("meta", {})
1011+
assert meta is not None, "Meta section should exist in payload"
1012+
1013+
metadata = meta.get("metadata", {})
1014+
assert metadata is not None, "Metadata section should exist in meta"
1015+
1016+
spend_metrics = metadata.get("spend_metrics", {})
1017+
assert spend_metrics, "Spend metrics should exist in metadata"
1018+
1019+
# Check that all three spend metrics are present
1020+
assert "litellm_spend_metric" in spend_metrics
1021+
assert "litellm_api_key_max_budget_metric" in spend_metrics
1022+
assert "litellm_api_key_budget_remaining_hours_metric" in spend_metrics
1023+
1024+
# Verify the values are correct
1025+
assert spend_metrics["litellm_spend_metric"] == 0.15 # response_cost
1026+
assert spend_metrics["litellm_api_key_max_budget_metric"] == 10.0 # max budget
1027+
1028+
# Verify remaining hours is a reasonable value (should be close to 24 since we set it to 24 hours from now)
1029+
remaining_hours = spend_metrics["litellm_api_key_budget_remaining_hours_metric"]
1030+
assert isinstance(remaining_hours, (int, float))
1031+
assert 20 <= remaining_hours <= 25 # Should be close to 24 hours

0 commit comments

Comments
 (0)