# SPDX-License-Identifier: Apache-2.0 # Standard from typing import Any, Dict, List, Optional from unittest.mock import MagicMock import hashlib import json # Third Party from fastapi.testclient import TestClient import pytest import torch # First Party from lmcache.utils import compress_slot_mapping from lmcache.v1.internal_api_server.api_server import app class TestKVCacheCheckAPI: """Test suite for the /kvcache/check API endpoint.""" @pytest.fixture(autouse=True) def reset_adapter(self): """Reset adapter before each test.""" yield app.state.lmcache_adapter = None @pytest.fixture def mock_kv_caches(self) -> Dict[str, torch.Tensor]: """Create mock kv_caches tensors for testing.""" return { "layer_0": torch.randn(4, 4, 8, 64), "layer_1": torch.randn(4, 4, 8, 64), } @pytest.fixture def mock_lmcache_adapter(self, mock_kv_caches): """Create a mock LMCacheConnectorV1Impl adapter.""" adapter = MagicMock() adapter.kv_caches = mock_kv_caches adapter.kvcaches = mock_kv_caches # API uses kvcaches (no underscore) # Mock lmcache_engine for record_slot tests mock_engine = MagicMock() mock_engine.kvcache_check_log_enabled = False adapter.lmcache_engine = mock_engine def compute_checksums( slot_indices: List[int], chunk_size: Optional[int] = None ) -> Optional[Dict[str, Any]]: if not adapter.kv_caches: return None layer_checksums: Dict[str, str] = {} chunk_checksums: Dict[str, List[str]] = {} for layer_name, kv_tensor in adapter.kv_caches.items(): slot_tensor = torch.tensor( slot_indices, dtype=torch.long, device=kv_tensor.device ) kv_at_slots = kv_tensor.view(-1, *kv_tensor.shape[2:])[slot_tensor] tensor_bytes = kv_at_slots.detach().cpu().contiguous().numpy().tobytes() layer_checksums[layer_name] = hashlib.md5(tensor_bytes).hexdigest() if chunk_size and chunk_size > 0: num_slots = len(slot_indices) chunk_checksum_list: List[str] = [] for i in range((num_slots + chunk_size - 1) // chunk_size): chunk_data = kv_at_slots[i * chunk_size : (i + 1) * chunk_size] chunk_bytes = ( chunk_data.detach().cpu().contiguous().numpy().tobytes() ) chunk_checksum_list.append(hashlib.md5(chunk_bytes).hexdigest()) chunk_checksums[layer_name] = chunk_checksum_list result: Dict[str, Any] = {"layer_checksums": layer_checksums} if chunk_size: result["chunk_checksums"] = chunk_checksums result["chunk_size"] = chunk_size result["num_chunks"] = ( (len(slot_indices) + chunk_size - 1) // chunk_size if slot_indices else 0 ) return result adapter.compute_kvcache_checksums = compute_checksums return adapter @pytest.fixture def client_with_adapter(self, mock_lmcache_adapter): """Create a test client with mocked adapter.""" app.state.lmcache_adapter = mock_lmcache_adapter client = TestClient(app) yield client client.close() # ========================================================================== # Tests for /kvcache/check endpoint # ========================================================================== def test_kvcache_check_success_layer_only(self, client_with_adapter): """Test successful kvcache check with layer-level checksums.""" response = client_with_adapter.get( "/cache/kvcache/check?slot_mapping=0,1,2,3&chunk_size=2" ) assert response.status_code == 200 data = json.loads(response.text) assert data["status"] == "success" assert "chunk_checksums" in data @pytest.mark.parametrize( "url,expected_error", [ ("/cache/kvcache/check?slot_mapping=a,b,c", "Invalid slot_mapping format"), ( "/cache/kvcache/check?slot_mapping=0,1&chunk_size=0", "Invalid chunk_size", ), ( "/cache/kvcache/check?slot_mapping=0,1&chunk_size=-1", "Invalid chunk_size", ), ], ) def test_kvcache_check_invalid_params( self, client_with_adapter, url, expected_error ): """Test kvcache check with various invalid parameters.""" response = client_with_adapter.get(url) assert response.status_code == 400 assert json.loads(response.text)["error"] == expected_error @pytest.mark.parametrize( "slots,chunk_size,expected_chunks", [ ("0,1,2,3,4,5,6,7", 4, 2), ("0,1,2,3,4,5,6", 3, 3), ("0,1,2,3", 4, 1), ("0,1,2,3", 1, 4), ], ) def test_kvcache_check_with_chunk_size( self, client_with_adapter, slots, chunk_size, expected_chunks ): """Test kvcache check with various chunk sizes.""" response = client_with_adapter.get( f"/cache/kvcache/check?slot_mapping={slots}&chunk_size={chunk_size}" ) assert response.status_code == 200 data = json.loads(response.text) assert data["num_chunks"] == expected_chunks def test_kvcache_check_consistency(self, client_with_adapter): """Test that checksums are consistent across calls.""" url = "/cache/kvcache/check?slot_mapping=0,1,2,3&chunk_size=2" data1 = json.loads(client_with_adapter.get(url).text) data2 = json.loads(client_with_adapter.get(url).text) assert data1["chunk_checksums"] == data2["chunk_checksums"] # ========================================================================== # Tests for adapter unavailable scenarios # ========================================================================== @pytest.mark.parametrize( "method,url", [ ("get", "/cache/kvcache/check?slot_mapping=0,1,2,3&chunk_size=2"), ("get", "/cache/kvcache/info"), ("post", "/cache/kvcache/record_slot?enabled=true"), ], ) def test_no_adapter(self, method, url): """Test endpoints when adapter is not available.""" app.state.lmcache_adapter = None with TestClient(app) as client: response = getattr(client, method)(url) assert response.status_code == 503 assert json.loads(response.text)["error"] == "LMCache adapter unavailable" def test_kvcache_check_empty_kv_caches(self): """Test kvcache check when kv_caches is empty.""" adapter = MagicMock() adapter.kv_caches = {} adapter.kvcaches = {} adapter.compute_kvcache_checksums = MagicMock(return_value=None) app.state.lmcache_adapter = adapter with TestClient(app) as client: response = client.get( "/cache/kvcache/check?slot_mapping=0,1,2,3&chunk_size=2" ) assert response.status_code == 404 # ========================================================================== # Tests for /cache/kvcache/info endpoint # ========================================================================== def test_kvcache_info_success(self, client_with_adapter): """Test successful kvcache info retrieval.""" response = client_with_adapter.get("/cache/kvcache/info") assert response.status_code == 200 data = json.loads(response.text) assert data["num_layers"] == 2 assert "layer_0" in data["layers"] def test_kvcache_info_empty(self): """Test kvcache info when kvcaches is empty.""" adapter = MagicMock() adapter.kvcaches = {} app.state.lmcache_adapter = adapter with TestClient(app) as client: response = client.get("/cache/kvcache/info") assert response.status_code == 404 # ========================================================================== # Tests for /cache/kvcache/record_slot endpoint # ========================================================================== @pytest.mark.parametrize( "enabled_param,expected_value", [("true", True), ("false", False), ("TRUE", True), ("False", False)], ) def test_kvcache_record_slot_toggle( self, client_with_adapter, mock_lmcache_adapter, enabled_param, expected_value ): """Test enabling/disabling KVCache Check logging.""" response = client_with_adapter.post( f"/cache/kvcache/record_slot?enabled={enabled_param}" ) assert response.status_code == 200 assert ( mock_lmcache_adapter.lmcache_engine.kvcache_check_log_enabled == expected_value ) def test_kvcache_record_slot_invalid(self, client_with_adapter): """Test with invalid enabled parameter.""" response = client_with_adapter.post( "/cache/kvcache/record_slot?enabled=invalid" ) assert response.status_code == 400 class TestCompressSlotMapping: """Test suite for the compress_slot_mapping function.""" @pytest.mark.parametrize( "input_slots,expected", [ ([1, 2, 3, 4, 5], [[1, 5]]), ([1, 2, 3, 4, 5, 9, 10, 11, 12], [[1, 5], [9, 12]]), # No consecutive elements, no compression ([1, 3, 5, 7], [1, 3, 5, 7]), # 5 is single, not compressed ([1, 2, 3, 5, 7, 8, 9], [[1, 3], 5, [7, 9]]), ([], []), ([42], [42]), # Single element, not compressed # Mixed order ([5, 3, 1, 2, 4, 9, 10, 11, 13], [5, 3, 1, 2, 4, [9, 11], 13]), ([0, 1, 2, 100, 101, 102], [[0, 2], [100, 102]]), ([1, 2], [1, 2]), # Two elements, not compressed ([1, 2, 3], [[1, 3]]), # Three elements, compressed ], ) def test_compress_slot_mapping(self, input_slots, expected): """Test compression of slot mappings.""" assert compress_slot_mapping(input_slots) == expected