import json import os from unittest.mock import AsyncMock, MagicMock, patch import pytest import litellm from litellm.integrations.openmeter import OpenMeterLogger class TestOpenMeterIntegration: """Test suite for OpenMeter integration""" def setup_method(self): """Set up test environment""" # Set required environment variables os.environ["OPENMETER_API_KEY"] = "test-api-key" os.environ["OPENMETER_API_ENDPOINT"] = "https://test.openmeter.com" def teardown_method(self): """Clean up test environment""" # Clean up environment variables os.environ.pop("OPENMETER_API_KEY", None) os.environ.pop("OPENMETER_API_ENDPOINT", None) os.environ.pop("OPENMETER_EVENT_TYPE", None) os.environ.pop("OPENMETER_TRUST_REQUEST_USER", None) def test_openmeter_logger_initialization(self): """Test that OpenMeterLogger initializes correctly with required env vars""" logger = OpenMeterLogger() assert logger is not None def test_openmeter_logger_missing_api_key(self): """Test that OpenMeterLogger raises exception when API key is missing""" os.environ.pop("OPENMETER_API_KEY", None) with pytest.raises(Exception, match="Missing keys.*OPENMETER_API_KEY"): OpenMeterLogger() def test_common_logic_with_string_user(self): """Test that _common_logic correctly handles string user parameter""" logger = OpenMeterLogger() kwargs = { "user": "test-user-123", "model": "gpt-3.5-turbo", "response_cost": 0.001, "litellm_call_id": "test-call-id", } # Mock response object response_obj = { "id": "test-response-id", "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, } result = logger._common_logic(kwargs, response_obj) # Verify subject is a string, not a tuple assert isinstance(result["subject"], str) assert result["subject"] == "test-user-123" assert result["data"]["model"] == "gpt-3.5-turbo" assert result["data"]["cost"] == 0.001 def test_common_logic_with_integer_user(self): """Test that _common_logic correctly converts integer user to string""" logger = OpenMeterLogger() kwargs = { "user": 12345, # Integer user ID "model": "gpt-4", "response_cost": 0.002, "litellm_call_id": "test-call-id-2", } response_obj = { "id": "test-response-id-2", "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}, } result = logger._common_logic(kwargs, response_obj) # Verify subject is converted to string assert isinstance(result["subject"], str) assert result["subject"] == "12345" def test_common_logic_missing_user(self): """Test that _common_logic raises exception when user is missing""" logger = OpenMeterLogger() kwargs = { "model": "gpt-3.5-turbo", "response_cost": 0.001, "litellm_call_id": "test-call-id", } response_obj = {"id": "test-response-id"} with pytest.raises(Exception, match="OpenMeter: user is required"): logger._common_logic(kwargs, response_obj) def test_common_logic_none_user(self): """Test that _common_logic raises exception when user is None""" logger = OpenMeterLogger() kwargs = { "user": None, "model": "gpt-3.5-turbo", "response_cost": 0.001, "litellm_call_id": "test-call-id", } response_obj = {"id": "test-response-id"} with pytest.raises(Exception, match="OpenMeter: user is required"): logger._common_logic(kwargs, response_obj) def test_common_logic_empty_string_user(self): """Test that _common_logic correctly handles an empty string user""" logger = OpenMeterLogger() kwargs = { "user": "", "model": "gpt-3.5-turbo", "response_cost": 0.001, "litellm_call_id": "test-call-id", } response_obj = {"id": "test-response-id"} result = logger._common_logic(kwargs, response_obj) assert isinstance(result["subject"], str) assert result["subject"] == "" @patch("litellm.integrations.openmeter.HTTPHandler") def test_log_success_event(self, mock_http_handler): """Test synchronous log_success_event method""" mock_post = MagicMock() mock_http_handler.return_value.post = mock_post logger = OpenMeterLogger() kwargs = { "user": "test-user", "model": "gpt-3.5-turbo", "response_cost": 0.001, "litellm_call_id": "test-call-id", } response_obj = { "id": "test-response-id", "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, } logger.log_success_event(kwargs, response_obj, None, None) # Verify HTTP call was made mock_post.assert_called_once() # Verify the data structure call_args = mock_post.call_args data = json.loads(call_args[1]["data"]) assert data["subject"] == "test-user" assert isinstance(data["subject"], str) assert data["data"]["model"] == "gpt-3.5-turbo" @patch("litellm.integrations.openmeter.get_async_httpx_client") @pytest.mark.asyncio async def test_async_log_success_event(self, mock_get_client): """Test asynchronous log_success_event method""" mock_post = AsyncMock() mock_client = MagicMock() mock_client.post = mock_post mock_get_client.return_value = mock_client logger = OpenMeterLogger() kwargs = { "user": "async-test-user", "model": "gpt-4", "response_cost": 0.002, "litellm_call_id": "async-test-call-id", } response_obj = { "id": "async-test-response-id", "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}, } await logger.async_log_success_event(kwargs, response_obj, None, None) # Verify async HTTP call was made mock_post.assert_called_once() # Verify the data structure call_args = mock_post.call_args data = json.loads(call_args[1]["data"]) assert data["subject"] == "async-test-user" assert isinstance(data["subject"], str) assert data["data"]["model"] == "gpt-4" def test_cloudevents_structure(self): """Test that the CloudEvents structure is correct""" logger = OpenMeterLogger() kwargs = { "user": "cloudevents-test-user", "model": "gpt-3.5-turbo", "response_cost": 0.001, "litellm_call_id": "cloudevents-test-call-id", } response_data = { "id": "cloudevents-test-response-id", "usage": {"prompt_tokens": 15, "completion_tokens": 8, "total_tokens": 23}, } response_obj = litellm.ModelResponse(**response_data) result = logger._common_logic(kwargs, response_obj) # Verify CloudEvents required fields assert result["specversion"] == "1.0" assert result["type"] == "litellm_tokens" # default value assert result["id"] == "cloudevents-test-response-id" assert result["source"] == "litellm-proxy" assert "time" in result assert isinstance(result["subject"], str) assert result["subject"] == "cloudevents-test-user" # Verify data structure assert "data" in result assert result["data"]["model"] == "gpt-3.5-turbo" assert result["data"]["cost"] == 0.001 assert result["data"]["prompt_tokens"] == 15 assert result["data"]["completion_tokens"] == 8 assert result["data"]["total_tokens"] == 23 def test_custom_event_type(self): """Test that custom event type is used when set""" os.environ["OPENMETER_EVENT_TYPE"] = "custom_event_type" logger = OpenMeterLogger() kwargs = { "user": "custom-event-user", "model": "gpt-4", "response_cost": 0.003, "litellm_call_id": "custom-event-call-id", } response_obj = { "id": "custom-event-response-id", "usage": {"prompt_tokens": 25, "completion_tokens": 12, "total_tokens": 37}, } result = logger._common_logic(kwargs, response_obj) assert result["type"] == "custom_event_type" def test_common_logic_user_from_token_user_id(self): """Test that _common_logic uses user_api_key_user_id when no user provided""" logger = OpenMeterLogger() kwargs = { "model": "gpt-3.5-turbo", "response_cost": 0.001, "litellm_call_id": "test-call-id", "litellm_params": {"metadata": {"user_api_key_user_id": "token-user-123"}}, # No "user" parameter - should use token user_id } response_obj = { "id": "test-response-id", "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, } result = logger._common_logic(kwargs, response_obj) # Verify user was set from token user_id assert isinstance(result["subject"], str) assert result["subject"] == "token-user-123" assert result["data"]["model"] == "gpt-3.5-turbo" def test_common_logic_direct_user_takes_priority_over_token(self): """Test that direct user parameter takes priority over token user_id""" logger = OpenMeterLogger() kwargs = { "user": "direct-user-456", # Direct user should take priority "model": "gpt-4", "response_cost": 0.002, "litellm_call_id": "test-call-id", "litellm_params": { "metadata": { "user_api_key_user_id": "token-user-123" # This should be ignored } }, } response_obj = { "id": "test-response-id", "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}, } result = logger._common_logic(kwargs, response_obj) # Verify direct user takes priority assert isinstance(result["subject"], str) assert result["subject"] == "direct-user-456" assert result["subject"] != "token-user-123" def test_common_logic_missing_user_and_token_user_id(self): """Test that exception is raised when neither user nor token user_id available""" logger = OpenMeterLogger() kwargs = { "model": "gpt-3.5-turbo", "response_cost": 0.001, "litellm_call_id": "test-call-id", "litellm_params": { "metadata": { # No user_api_key_user_id } }, # No "user" parameter } response_obj = {"id": "test-response-id"} with pytest.raises(Exception, match="OpenMeter: user is required"): logger._common_logic(kwargs, response_obj) def test_common_logic_token_user_id_none(self): """Test that exception is raised when token user_id is None""" logger = OpenMeterLogger() kwargs = { "model": "gpt-3.5-turbo", "response_cost": 0.001, "litellm_call_id": "test-call-id", "litellm_params": { "metadata": {"user_api_key_user_id": None} # Explicitly None }, } response_obj = {"id": "test-response-id"} with pytest.raises(Exception, match="OpenMeter: user is required"): logger._common_logic(kwargs, response_obj) def test_common_logic_no_metadata(self): """Test that exception is raised when no metadata is available""" logger = OpenMeterLogger() kwargs = { "model": "gpt-3.5-turbo", "response_cost": 0.001, "litellm_call_id": "test-call-id", # No litellm_params at all } response_obj = {"id": "test-response-id"} with pytest.raises(Exception, match="OpenMeter: user is required"): logger._common_logic(kwargs, response_obj) def test_common_logic_integer_token_user_id(self): """Test that integer token user_id is converted to string""" logger = OpenMeterLogger() kwargs = { "model": "gpt-4", "response_cost": 0.003, "litellm_call_id": "test-call-id", "litellm_params": { "metadata": {"user_api_key_user_id": 12345} # Integer user_id }, } response_obj = { "id": "test-response-id", "usage": {"prompt_tokens": 25, "completion_tokens": 12, "total_tokens": 37}, } result = logger._common_logic(kwargs, response_obj) # Verify integer user_id is converted to string assert isinstance(result["subject"], str) assert result["subject"] == "12345" def test_common_logic_trust_request_user_false_ignores_request_user(self): """OPENMETER_TRUST_REQUEST_USER=false makes the key-bound user_id win over a request-supplied `user` (forge-attribution mitigation).""" os.environ["OPENMETER_TRUST_REQUEST_USER"] = "false" logger = OpenMeterLogger() kwargs = { "user": "forged-by-client", "model": "gpt-4", "response_cost": 0.002, "litellm_call_id": "test-call-id", "litellm_params": { "metadata": {"user_api_key_user_id": "real-tenant-id"} }, } response_obj = { "id": "test-response-id", "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}, } result = logger._common_logic(kwargs, response_obj) assert result["subject"] == "real-tenant-id" assert result["subject"] != "forged-by-client" def test_common_logic_trust_request_user_false_still_raises_without_key_user(self): """OPENMETER_TRUST_REQUEST_USER=false still raises when no user_api_key_user_id is available — the request `user` is not a fallback in this mode.""" os.environ["OPENMETER_TRUST_REQUEST_USER"] = "false" logger = OpenMeterLogger() kwargs = { "user": "would-have-worked-without-the-flag", "model": "gpt-3.5-turbo", "response_cost": 0.001, "litellm_call_id": "test-call-id", } response_obj = {"id": "test-response-id"} with pytest.raises(Exception, match="OpenMeter: user is required"): logger._common_logic(kwargs, response_obj) def test_common_logic_trust_request_user_default_preserves_behavior(self): """Default (unset OPENMETER_TRUST_REQUEST_USER) keeps request `user` taking priority — backward compatibility.""" # OPENMETER_TRUST_REQUEST_USER intentionally unset logger = OpenMeterLogger() kwargs = { "user": "request-user", "model": "gpt-4", "response_cost": 0.002, "litellm_call_id": "test-call-id", "litellm_params": { "metadata": {"user_api_key_user_id": "key-user"} }, } response_obj = { "id": "test-response-id", "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}, } result = logger._common_logic(kwargs, response_obj) assert result["subject"] == "request-user" @patch("litellm.integrations.openmeter.HTTPHandler") def test_integration_token_user_id_scenario(self, mock_http_handler): """Integration test simulating the exact scenario that was failing""" mock_post = MagicMock() mock_http_handler.return_value.post = mock_post logger = OpenMeterLogger() # Simulate the exact scenario: request with token that has user_id but no direct user param kwargs = { "model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "Hello"}], "response_cost": 0.001, "litellm_call_id": "test-integration-call-id", "litellm_params": { "metadata": { "user_api_key_user_id": "user123-from-token", "user_api_key": "hashed-key-abc", "user_api_key_metadata": {}, } }, # No "user" parameter - this was causing "OpenMeter: user is required" error } response_obj = { "id": "chatcmpl-test123", "usage": {"prompt_tokens": 15, "completion_tokens": 10, "total_tokens": 25}, } # This should NOT raise "OpenMeter: user is required" anymore logger.log_success_event(kwargs, response_obj, None, None) # Verify HTTP call was made mock_post.assert_called_once() # Verify the data structure contains user from token call_args = mock_post.call_args data = json.loads(call_args[1]["data"]) assert data["subject"] == "user123-from-token" assert isinstance(data["subject"], str) assert data["data"]["model"] == "gpt-3.5-turbo"