InferRoute / tests /test_advanced_features.py
Ypeng12's picture
feat: implement streaming request dedup, cache-affinity trie routing, speculative cascade, vegas concurrency limiter, web UI playground, and multi-tenant wallet billing
80db329
Raw
History Blame Contribute Delete
12.4 kB
"""
Unit and integration tests for advanced gateway features:
1. Streaming Request Deduplication
2. KV-Cache Affinity Routing
3. Speculative / Cascade Routing
4. Adaptive Rate Limiting
"""
import asyncio
import json
import time
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi.testclient import TestClient
from inferroute.main import app
from inferroute.rate_limiter import AdaptiveConcurrencyLimiter
from inferroute.router_trie import PrefixTrieRouter
BASE_HEADERS = {"Authorization": "Bearer sk-inferroute-demo"}
CHAT_BODY = {
"model": "edge/auto",
"messages": [{"role": "user", "content": "Explain KV caching in vLLM in detail."}],
"stream": False,
}
@pytest.fixture(scope="module")
def client():
return TestClient(app)
def _mock_redis():
"""A clean mock Redis client for routing and cache states."""
r = AsyncMock()
# Lock mock
r.set.return_value = True
r.get.return_value = None
r.delete.return_value = 1
r.exists.return_value = 0
# Pub/sub mock
pubsub_mock = AsyncMock()
pubsub_mock.subscribe = AsyncMock()
pubsub_mock.unsubscribe = AsyncMock()
pubsub_mock.aclose = AsyncMock()
# Simulate a stream sequence for Wait Stream Dedup:
# We yield two chunks, then a done message
chunks = [
{"type": "message", "data": json.dumps({"index": 0, "chunk": {"choices": [{"delta": {"content": "Hello"}}]}})},
{"type": "message", "data": json.dumps({"index": 1, "chunk": {"choices": [{"delta": {"content": " world"}}]}})},
{"type": "message", "data": json.dumps({"index": "done", "final_index": 2})}
]
async def get_message_mock(*args, **kwargs):
if chunks:
return chunks.pop(0)
await asyncio.sleep(0.1)
return None
pubsub_mock.get_message.side_effect = get_message_mock
# Make pubsub a synchronous MagicMock so it does not return a coroutine
r.pubsub = MagicMock(return_value=pubsub_mock)
r.lrange.return_value = []
# Make pipeline a synchronous MagicMock so it does not return a coroutine
r.pipeline = MagicMock(return_value=r)
r.execute.return_value = [True, True]
# Set mock key behaviors
keys = {}
async def set_side_effect(key, val, ex=None, nx=False):
if nx and key in keys:
return None
keys[key] = val
return True
async def get_side_effect(key):
return keys.get(key)
async def delete_side_effect(key):
return keys.pop(key, None)
r.set.side_effect = set_side_effect
r.get.side_effect = get_side_effect
r.delete.side_effect = delete_side_effect
return r
# ── Test 1: KV-Cache Affinity Routing ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_kv_cache_affinity_scoring():
"""
Verify that registering a prefix tree affinity cache in Redis correctly
gives a score bonus to the target host during routing.
"""
redis = _mock_redis()
trie = PrefixTrieRouter(redis)
# 1. Register prompt prefix to 'vllm' host (must be long enough for PREFIX_LENGTHS)
prompt_text = "This is a very long prompt prefix. " * 150 # 5000+ characters
await trie.register_host_prefix("vllm", prompt_text)
# Verify that Redis SMEMBERS was called for prefix hashes
assert redis.sadd.call_count > 0
# Set mock SMEMBERS to return our registered host
redis.smembers.return_value = [b"vllm"]
# Query affinity hosts
affinity_hosts = await trie.get_affinity_hosts(prompt_text)
assert "vllm" in affinity_hosts
# 2. Test routing with warm affinity
from inferroute.router import Router
router = Router(redis)
req = {
"model": "edge/auto",
"messages": [{"role": "user", "content": prompt_text}],
"routing": {"policy": "latency"}
}
with patch("inferroute.router.get_redis_client", return_value=redis), \
patch("inferroute.circuit_breaker.get_circuit_breaker") as mock_gcb:
cb = AsyncMock()
cb.allow_request.return_value = True
mock_gcb.return_value = cb
decision = await router.choose_backend(req)
# Should prefer 'vllm' due to large warm cache bonus (250ms equivalent)
assert decision.primary == "vllm"
# ── Test 2: Speculative Cascaded Validation ──────────────────────────────────
@patch("inferroute.adapters.vllm.VLLMAdapter.generate")
@patch("inferroute.adapters.openai.OpenAIAdapter.generate")
def test_speculative_cascade_on_loop_failure(mock_openai_gen, mock_vllm_gen, client):
"""
Scenario: Speculative routing is active. Primary backend (vllm) returns
a repetitive generation loop (e.g. 'hello hello hello hello hello').
The speculative quality validator detects the loop, discards the output,
and automatically upgrades/cascades to the fallback backend (openai).
"""
mock_vllm_gen.return_value = {
"id": "vllm-loop-001",
"object": "chat.completion",
"model": "llama-3-8b",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hello " * 20}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 5, "completion_tokens": 20, "total_tokens": 25, "estimated_cost_usd": 0.0},
"timing": {"ttft_ms": 50.0, "latency_ms": 100.0},
}
# 2. Premium backend returns high quality fallback response
mock_openai_gen.return_value = {
"id": "openai-cascade-001",
"object": "chat.completion",
"model": "gpt-4o-mini",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "Hello! How can I help you today?"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 5, "completion_tokens": 8, "total_tokens": 13, "estimated_cost_usd": 0.0002},
"timing": {"ttft_ms": 150.0, "latency_ms": 200.0},
}
redis = _mock_redis()
with patch("inferroute.auth.redis_client", redis), \
patch("inferroute.auth.get_redis_client", return_value=redis), \
patch("inferroute.cache.get_redis_client", return_value=redis), \
patch("inferroute.router.get_redis_client", return_value=redis), \
patch("inferroute.circuit_breaker.get_circuit_breaker") as gcb, \
patch("inferroute.main.async_session"):
# Configure gcb to return OPEN for 'ollama' so it doesn't choose ollama over vllm
def gcb_side_effect(backend):
mock_cb = AsyncMock()
if backend == "ollama":
mock_cb.allow_request.return_value = False
else:
mock_cb.allow_request.return_value = True
mock_cb.record_success = AsyncMock()
mock_cb.record_failure = AsyncMock()
mock_cb.get_status.return_value = {"state": "CLOSED"}
return mock_cb
gcb.side_effect = gcb_side_effect
body = CHAT_BODY.copy()
body["routing"] = {"policy": "speculative"}
resp = client.post("/v1/chat/completions", json=body, headers=BASE_HEADERS)
assert resp.status_code == 200
data = resp.json()
# Verify it automatically upgraded to OpenAI because vLLM output failed loop quality check
assert data["choices"][0]["message"]["content"] == "Hello! How can I help you today?"
assert data["route"]["selected_backend"] == "openai"
assert data["route"]["fallback_count"] == 1
# ── Test 3: Adaptive Concurrency Limiting ────────────────────────────────────
@pytest.mark.asyncio
async def test_adaptive_concurrency_limiting_backpressure():
"""
Verify that AdaptiveConcurrencyLimiter properly implements limit boundaries,
rejects requests when limit is reached, and adapts dynamically under load.
"""
limiter = AdaptiveConcurrencyLimiter(initial_limit=10, min_limit=2, max_limit=20, alpha=2, beta=5)
# Establish a fast baseline RTT
await limiter.release(latency_ms=100.0)
assert limiter.min_latency_ms == 100.0
# 1. Acquire up to limit
for _ in range(10):
assert await limiter.acquire() is True
# 2. Next acquisition must be rejected (limit reached)
assert await limiter.acquire() is False
# 3. Release slots with high RTT (indicates queue forming)
# The limiter should dynamically adjust the concurrency limit down
for _ in range(5):
await limiter.release(latency_ms=1000.0)
# concurr limit should decrease due to high latency queue estimates
assert limiter.limit < 10
# ── Test 4: Streaming Request Deduplication ───────────────────────────────────
def test_streaming_deduplication_sharing(client):
"""
Verify that concurrent streaming requests share the active in-flight stream
via wait_for_stream_dedup instead of invoking the backend multiple times.
"""
redis = _mock_redis()
# Set try_acquire_dedup_lock to return False (so client 2 is a waiter)
redis.set.side_effect = lambda key, val, ex=None, nx=False: (None if nx else True)
with patch("inferroute.auth.redis_client", redis), \
patch("inferroute.auth.get_redis_client", return_value=redis), \
patch("inferroute.cache.get_redis_client", return_value=redis), \
patch("inferroute.router.get_redis_client", return_value=redis), \
patch("inferroute.circuit_breaker.get_circuit_breaker") as gcb:
cb = MagicMock()
cb.allow_request = AsyncMock(return_value=True)
gcb.return_value = cb
body = CHAT_BODY.copy()
body["stream"] = True
# Simulate joining an active stream
# This will trigger client 2 paths
with patch("inferroute.cache.CacheLayer.try_acquire_dedup_lock", return_value=False):
resp = client.post("/v1/chat/completions", json=body, headers=BASE_HEADERS)
assert resp.status_code == 200
lines = list(resp.iter_lines())
assert len(lines) > 0
# Verify stream yields chunks and DONE
assert any("data: " in line for line in lines)
assert any("DONE" in line for line in lines)
# ── Test 5: Multi-Tenant Billing & Wallet ─────────────────────────────────────
def test_billing_and_wallet_flow(client):
"""
Verify get_balance, recharge_wallet, and 402 Payment Required block.
"""
redis = _mock_redis()
# 1. Mock DB Session for balance lookup
mock_session = AsyncMock()
mock_wallet = MagicMock()
mock_wallet.tenant_id = "acme_corp"
mock_wallet.balance_usd = 15.0
# We mock execute() scalar result
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = mock_wallet
mock_session.execute.return_value = execute_result
with patch("inferroute.auth.redis_client", redis), \
patch("inferroute.auth.get_redis_client", return_value=redis), \
patch("inferroute.auth.async_session") as mock_sess_auth, \
patch("inferroute.main.async_session") as mock_sess_main:
mock_sess_auth.return_value.__aenter__.return_value = mock_session
mock_sess_main.return_value.__aenter__.return_value = mock_session
# Test balance retrieval
resp = client.get("/v1/billing/balance", headers=BASE_HEADERS)
assert resp.status_code == 200
assert resp.json()["balance_usd"] == 15.0
# Test recharge
mock_wallet.balance_usd = 15.0 # Reset
resp = client.post("/v1/billing/recharge?amount=10.0", headers=BASE_HEADERS)
assert resp.status_code == 200
assert resp.json()["new_balance_usd"] == 25.0
# Test 402 Payment Required block
mock_wallet.balance_usd = 0.0 # Dry wallet
body = CHAT_BODY.copy()
resp = client.post("/v1/chat/completions", json=body, headers=BASE_HEADERS)
assert resp.status_code == 402
assert "Payment Required" in resp.json()["detail"]