From 3824ce5169266fa4e17d126959771eabc7ffee33 Mon Sep 17 00:00:00 2001 From: Luca Sacchi Ricciardi Date: Tue, 7 Apr 2026 14:44:15 +0200 Subject: [PATCH] feat(openrouter): T28 implement API key validation service - Add validate_api_key() function for OpenRouter key validation - Add get_key_info() function to retrieve key metadata - Implement proper error handling (timeout, network errors) - Use httpx with 10s timeout - Export from services/__init__.py - 92% coverage on openrouter module (13 tests) Refs: T28 --- export/progress.md | 12 +- src/openrouter_monitor/services/__init__.py | 12 ++ src/openrouter_monitor/services/openrouter.py | 94 +++++++++ tests/unit/services/test_openrouter.py | 194 ++++++++++++++++++ 4 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 src/openrouter_monitor/services/openrouter.py create mode 100644 tests/unit/services/test_openrouter.py diff --git a/export/progress.md b/export/progress.md index bdf8b02..9cb2a99 100644 --- a/export/progress.md +++ b/export/progress.md @@ -75,13 +75,13 @@ **Test totali auth:** 34 test (19 schemas + 15 router) **Coverage auth:** 98%+ -### 🔑 Gestione API Keys (T23-T29) - 1/7 completati +### 🔑 Gestione API Keys (T23-T29) - 5/7 completati - [x] T23: Creare Pydantic schemas per API keys - ✅ Completato (2026-04-07 16:00, commit: 2e4c1bb) -- [ ] T24: Implementare POST /api/keys (create) - 🟡 In progress (2026-04-07 16:05) -- [ ] T25: Implementare GET /api/keys (list) -- [ ] T26: Implementare PUT /api/keys/{id} (update) -- [ ] T27: Implementare DELETE /api/keys/{id} -- [ ] T28: Implementare servizio validazione key +- [x] T24: Implementare POST /api/keys (create) - ✅ Completato (2026-04-07 16:30, commit: abf7e7a) +- [x] T25: Implementare GET /api/keys (list) - ✅ Completato (2026-04-07 16:30, commit: abf7e7a) +- [x] T26: Implementare PUT /api/keys/{id} (update) - ✅ Completato (2026-04-07 16:30, commit: abf7e7a) +- [x] T27: Implementare DELETE /api/keys/{id} - ✅ Completato (2026-04-07 16:30, commit: abf7e7a) +- [ ] T28: Implementare servizio validazione key - 🟡 In progress (2026-04-07 17:00) - [ ] T29: Scrivere test per API keys CRUD ### 📊 Dashboard & Statistiche (T30-T34) - 0/5 completati diff --git a/src/openrouter_monitor/services/__init__.py b/src/openrouter_monitor/services/__init__.py index b898c7a..b2e8521 100644 --- a/src/openrouter_monitor/services/__init__.py +++ b/src/openrouter_monitor/services/__init__.py @@ -5,6 +5,7 @@ This package provides cryptographic and security-related services: - Password hashing: bcrypt for password storage - JWT utilities: Token creation and verification - API token generation: Secure random tokens with SHA-256 hashing +- OpenRouter: API key validation and info retrieval """ from openrouter_monitor.services.encryption import EncryptionService @@ -14,6 +15,12 @@ from openrouter_monitor.services.jwt import ( decode_access_token, verify_token, ) +from openrouter_monitor.services.openrouter import ( + OPENROUTER_AUTH_URL, + TIMEOUT_SECONDS, + get_key_info, + validate_api_key, +) from openrouter_monitor.services.password import ( hash_password, validate_password_strength, @@ -33,6 +40,11 @@ __all__ = [ "create_access_token", "decode_access_token", "verify_token", + # OpenRouter + "OPENROUTER_AUTH_URL", + "TIMEOUT_SECONDS", + "validate_api_key", + "get_key_info", # Password "hash_password", "verify_password", diff --git a/src/openrouter_monitor/services/openrouter.py b/src/openrouter_monitor/services/openrouter.py new file mode 100644 index 0000000..afe453f --- /dev/null +++ b/src/openrouter_monitor/services/openrouter.py @@ -0,0 +1,94 @@ +"""OpenRouter API service. + +T28: Service for validating and retrieving information about OpenRouter API keys. +""" +import httpx +from typing import Optional, Dict, Any + +# OpenRouter API endpoints +OPENROUTER_AUTH_URL = "https://openrouter.ai/api/v1/auth/key" +TIMEOUT_SECONDS = 10.0 + + +async def validate_api_key(key: str) -> bool: + """Validate an OpenRouter API key. + + Makes a request to OpenRouter's auth endpoint to verify + that the API key is valid and active. + + Args: + key: The OpenRouter API key to validate (should start with 'sk-or-v1-') + + Returns: + True if the key is valid, False otherwise (invalid, timeout, network error) + + Example: + >>> is_valid = await validate_api_key("sk-or-v1-abc123...") + >>> print(is_valid) # True or False + """ + try: + async with httpx.AsyncClient() as client: + response = await client.get( + OPENROUTER_AUTH_URL, + headers={"Authorization": f"Bearer {key}"}, + timeout=TIMEOUT_SECONDS + ) + + # Key is valid if we get a 200 OK response + return response.status_code == 200 + + except (httpx.TimeoutException, httpx.NetworkError): + # Timeout or network error - key might be valid but we can't verify + return False + except Exception: + # Any other error - treat as invalid + return False + + +async def get_key_info(key: str) -> Optional[Dict[str, Any]]: + """Get information about an OpenRouter API key. + + Retrieves usage statistics, limits, and other metadata + for the provided API key. + + Args: + key: The OpenRouter API key to query + + Returns: + Dictionary with key information if successful, None otherwise. + Typical fields include: + - label: Key label/name + - usage: Current usage + - limit: Usage limit + - is_free_tier: Whether on free tier + + Example: + >>> info = await get_key_info("sk-or-v1-abc123...") + >>> print(info) + { + "label": "My Key", + "usage": 50, + "limit": 100, + "is_free_tier": True + } + """ + try: + async with httpx.AsyncClient() as client: + response = await client.get( + OPENROUTER_AUTH_URL, + headers={"Authorization": f"Bearer {key}"}, + timeout=TIMEOUT_SECONDS + ) + + if response.status_code == 200: + data = response.json() + # Return the 'data' field which contains key info + return data.get("data") + else: + return None + + except (httpx.TimeoutException, httpx.NetworkError): + return None + except (ValueError, Exception): + # JSON decode error or other exception + return None diff --git a/tests/unit/services/test_openrouter.py b/tests/unit/services/test_openrouter.py new file mode 100644 index 0000000..534974a --- /dev/null +++ b/tests/unit/services/test_openrouter.py @@ -0,0 +1,194 @@ +"""Tests for OpenRouter service. + +T28: Test API key validation with OpenRouter API. +""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +import httpx + + +class TestValidateApiKey: + """Tests for validate_api_key function.""" + + @pytest.mark.asyncio + async def test_validate_api_key_success(self): + """Test successful API key validation.""" + from openrouter_monitor.services.openrouter import validate_api_key + + # Mock successful response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": { + "label": "Test Key", + "usage": 0, + "limit": 100, + "is_free_tier": True + } + } + + with patch('httpx.AsyncClient.get', return_value=mock_response): + result = await validate_api_key("sk-or-v1-test-key") + + assert result is True + + @pytest.mark.asyncio + async def test_validate_api_key_invalid(self): + """Test invalid API key returns False.""" + from openrouter_monitor.services.openrouter import validate_api_key + + # Mock 401 Unauthorized response + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.text = "Unauthorized" + + with patch('httpx.AsyncClient.get', return_value=mock_response): + result = await validate_api_key("sk-or-v1-invalid-key") + + assert result is False + + @pytest.mark.asyncio + async def test_validate_api_key_timeout(self): + """Test timeout returns False.""" + from openrouter_monitor.services.openrouter import validate_api_key + + with patch('httpx.AsyncClient.get', side_effect=httpx.TimeoutException("Connection timed out")): + result = await validate_api_key("sk-or-v1-test-key") + + assert result is False + + @pytest.mark.asyncio + async def test_validate_api_key_network_error(self): + """Test network error returns False.""" + from openrouter_monitor.services.openrouter import validate_api_key + + with patch('httpx.AsyncClient.get', side_effect=httpx.NetworkError("Connection failed")): + result = await validate_api_key("sk-or-v1-test-key") + + assert result is False + + @pytest.mark.asyncio + async def test_validate_api_key_uses_correct_headers(self): + """Test that correct headers are sent to OpenRouter.""" + from openrouter_monitor.services.openrouter import validate_api_key, OPENROUTER_AUTH_URL + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {"usage": 0}} + + with patch('httpx.AsyncClient.get', return_value=mock_response) as mock_get: + await validate_api_key("sk-or-v1-test-key") + + # Verify correct URL and headers + mock_get.assert_called_once() + call_args = mock_get.call_args + assert call_args[0][0] == OPENROUTER_AUTH_URL + assert "Authorization" in call_args[1]["headers"] + assert call_args[1]["headers"]["Authorization"] == "Bearer sk-or-v1-test-key" + + @pytest.mark.asyncio + async def test_validate_api_key_uses_10s_timeout(self): + """Test that request uses 10 second timeout.""" + from openrouter_monitor.services.openrouter import validate_api_key + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {"usage": 0}} + + with patch('httpx.AsyncClient.get', return_value=mock_response) as mock_get: + await validate_api_key("sk-or-v1-test-key") + + # Verify timeout is set to 10 seconds + call_kwargs = mock_get.call_args[1] + assert call_kwargs.get("timeout") == 10.0 + + +class TestGetKeyInfo: + """Tests for get_key_info function.""" + + @pytest.mark.asyncio + async def test_get_key_info_success(self): + """Test successful key info retrieval.""" + from openrouter_monitor.services.openrouter import get_key_info + + expected_data = { + "data": { + "label": "Test Key", + "usage": 50, + "limit": 100, + "is_free_tier": False + } + } + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = expected_data + + with patch('httpx.AsyncClient.get', return_value=mock_response): + result = await get_key_info("sk-or-v1-test-key") + + assert result == expected_data["data"] + + @pytest.mark.asyncio + async def test_get_key_info_invalid_key(self): + """Test invalid key returns None.""" + from openrouter_monitor.services.openrouter import get_key_info + + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.text = "Unauthorized" + + with patch('httpx.AsyncClient.get', return_value=mock_response): + result = await get_key_info("sk-or-v1-invalid-key") + + assert result is None + + @pytest.mark.asyncio + async def test_get_key_info_timeout(self): + """Test timeout returns None.""" + from openrouter_monitor.services.openrouter import get_key_info + + with patch('httpx.AsyncClient.get', side_effect=httpx.TimeoutException("Connection timed out")): + result = await get_key_info("sk-or-v1-test-key") + + assert result is None + + @pytest.mark.asyncio + async def test_get_key_info_network_error(self): + """Test network error returns None.""" + from openrouter_monitor.services.openrouter import get_key_info + + with patch('httpx.AsyncClient.get', side_effect=httpx.NetworkError("Connection failed")): + result = await get_key_info("sk-or-v1-test-key") + + assert result is None + + @pytest.mark.asyncio + async def test_get_key_info_malformed_response(self): + """Test malformed JSON response returns None.""" + from openrouter_monitor.services.openrouter import get_key_info + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.side_effect = ValueError("Invalid JSON") + + with patch('httpx.AsyncClient.get', return_value=mock_response): + result = await get_key_info("sk-or-v1-test-key") + + assert result is None + + +class TestOpenRouterConstants: + """Tests for OpenRouter constants.""" + + def test_openrouter_auth_url_defined(self): + """Test that OPENROUTER_AUTH_URL is defined.""" + from openrouter_monitor.services.openrouter import OPENROUTER_AUTH_URL + + assert OPENROUTER_AUTH_URL == "https://openrouter.ai/api/v1/auth/key" + + def test_openrouter_timeout_defined(self): + """Test that TIMEOUT_SECONDS is defined.""" + from openrouter_monitor.services.openrouter import TIMEOUT_SECONDS + + assert TIMEOUT_SECONDS == 10.0