intelligence-router/tests/test_router_fallback_lxc.py
root 4914363089 Epic: Model Switching via Sidecar — Issues #4-#7 + #8 deployment
Issue #4: Automatic model detection and switch
- Router extracts model from chat body, queries sidecar, triggers switch on mismatch
- Matching active model routes directly to Main PC
- No active model triggers cold start switch
- Tests: 4 test_router_model_detection.py

Issue #5: SSE switch progress feedback
- _sse_format() correctly serializes SSE events
- sse_progress_stream() generates phase progression events
- Proxy yields SSE events then actual response
- Tests: 3 test_router_sse_progress.py

Issue #6: Circuit breaker + OpenRouter fallback
- Circuit tracks Sidecar failures, opens after MAX_RECOVERY_ATTEMPTS (3)
- OpenRouter API key from env, no longer uses x-intelligence-level header
- Fixes: OPENROUTER_BASE, SSE format, circuit state isolation
- Tests: 7 test_router_circuit_breaker.py

Issue #7: LXC fallback chain completion
- Full fallback: Main PC → OpenRouter → LXC
- Each backend health-checked via /v1/models before routing
- All backends down → 503 response
- Fixed: execute() wrapped in try/except to trigger fallback chain
- Tests: 3 test_router_fallback_lxc.py

Issue #8: Systemd service deployment
- deploy/llm-sidecar.service: systemd unit with Restart=always
- deploy/manifest.yaml: example manifest with 3 profiles
- deploy/README.md: deployment instructions
- Updated: docker-compose.yml, requirements.txt, Dockerfile

Test framework improvements:
- tests/conftest.py: shared URL patches for all router tests
- Fixed global state pollution in circuit breaker tests
- Fixed test sidecar switch test (AsyncMock for async function)

Total: 42 tests passing
2026-06-15 01:13:36 +00:00

102 lines
4.4 KiB
Python

"""Tests for LXC fallback chain — Issue #7.
Full fallback: Main PC → OpenRouter → LXC. 503 when all backends down.
Uses conftest.py patches for URL mocking.
"""
import asyncio
import pytest
from httpx import Response, ASGITransport, AsyncClient
import respx
import main
class TestFallbackChain:
"""Tests for the full fallback chain."""
def test_openrouter_failure_triggers_lxc(self):
"""When OpenRouter fails with network error, router falls back to LXC."""
async def run_test():
with respx.mock:
# Sidecar is down — triggers fallback chain
respx.get("http://localhost:8081/models/status").mock(
return_value=Response(503, json={"status": "error", "message": "not ready"})
)
# OpenRouter fails with network error
respx.post("https://openrouter.ai/v1/chat/completions").mock(
side_effect=Exception("Connection refused")
)
# LXC health check passes
respx.get("http://localhost:9999/v1/models").mock(
return_value=Response(200, json={"data": []})
)
# LXC works for chat completion
respx.post("http://localhost:9999/v1/chat/completions").mock(
return_value=Response(200, json={"choices": [{"message": {"content": "Hello from LXC"}}]})
)
transport = ASGITransport(app=main.app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.post(
"/v1/chat/completions",
json={"model": "qwen-3-8b", "messages": [{"role": "user", "content": "hi"}]},
)
assert resp.status_code == 200
assert resp.json()["choices"][0]["message"]["content"] == "Hello from LXC"
asyncio.run(run_test())
def test_all_backends_down_returns_503(self):
"""When all backends are down, router returns 503."""
async def run_test():
with respx.mock:
# Sidecar down
respx.get("http://localhost:8081/models/status").mock(
side_effect=Exception("connection refused")
)
# OpenRouter down
respx.post("https://openrouter.ai/v1/chat/completions").mock(
side_effect=Exception("timeout")
)
# LXC down
respx.get("http://localhost:9999/v1/models").mock(
side_effect=Exception("connection refused")
)
transport = ASGITransport(app=main.app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.post(
"/v1/chat/completions",
json={"model": "qwen-3-8b", "messages": [{"role": "user", "content": "hi"}]},
)
assert resp.status_code == 503
asyncio.run(run_test())
def test_lxc_health_check_before_routing(self):
"""Router checks LXC health before routing to it."""
async def run_test():
with respx.mock:
# Sidecar down, OpenRouter down
respx.get("http://localhost:8081/models/status").mock(
side_effect=Exception("connection refused")
)
respx.post("https://openrouter.ai/v1/chat/completions").mock(
side_effect=Exception("timeout")
)
# LXC health check passes
respx.get("http://localhost:9999/v1/models").mock(
return_value=Response(200, json={"data": []})
)
# Then the actual chat completion
respx.post("http://localhost:9999/v1/chat/completions").mock(
return_value=Response(200, json={"choices": [{"message": {"content": "LXC"}}]})
)
transport = ASGITransport(app=main.app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.post(
"/v1/chat/completions",
json={"model": "qwen-3-8b", "messages": [{"role": "user", "content": "hi"}]},
)
assert resp.status_code == 200
asyncio.run(run_test())