Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ MINIO_CONSOLE_PORT="9011"

# Dev uses locally-built router image (for testing code changes)
ROUTER_IMAGE="localhost/llm-routing-dev:latest"

# Shared infrastructure (same llama classifier across dev & prod)
LLAMA_CLASSIFIER_URL="https://x570.vendeuvre.lan/llm-routing/llama/v1"
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ jobs:
run: pip install httpx==0.28.1 pytest pytest-asyncio anyio pyyaml fastapi "pydantic>=2.0,<3.0" uvicorn python-multipart asyncpg langfuse redis aiofiles==25.1.0

- name: Run Unit Tests
run: CONFIG_PATH=router/config.yaml PYTHONPATH=.:router pytest --ignore=tests/test_agy_behavior.py --ignore=tests/test_agy_tiers.py --ignore=tests/test_antigravity.py
run: CONFIG_PATH=router/config.yaml PYTHONPATH=. pytest --ignore=tests/test_agy_behavior.py --ignore=tests/test_agy_tiers.py --ignore=tests/test_antigravity.py

- name: Run Breaker Verification
run: PYTHONPATH=. python3 scripts/verification/verify_breaker.py

- name: Run Integration Verification
run: PYTHONPATH=.:router python3 tests/test_a2_verify.py
run: PYTHONPATH=. python3 tests/test_a2_verify.py
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ graph TD
Client["goose-cli Client"] -->|Port 5000| Router["FastAPI Triage Router"]

subgraph FastAPIRouter ["FastAPI Router Pod Context"]
Router -->|1. Complexity Triage| LlamaServer["Local Llama-Server\n(Port 8080 - qwen-2b-routing)"]
Router -->|1. Complexity Triage| LlamaServer["Local Llama-Server\n(Port 8080 - qwen-4b-routing)"]
Router -->|"2. agy Proxy (Gemini/Claude)"| AgyProxy["agy Proxy Module\n(agy_proxy.py)"]

AgyProxy -->|Tier 1| AgyGemini["agy --print\n(Gemini 3.5 Flash)"]
Expand Down Expand Up @@ -109,7 +109,7 @@ sequenceDiagram
Router->>Router: Check model name → decide route

alt Model = llm-routing-auto-free / auto-agy / auto-ollama / auto-agy-ollama
Router->>Llama: POST /v1/chat/completions (Complexity triage via qwen-2b-routing)
Router->>Llama: POST /v1/chat/completions (Complexity triage via qwen-4b-routing)
Llama-->>Router: JSON Response (5-tier: simple / medium / complex / reasoning / advanced)
else Model = direct tier (agent-*-core / llm-routing-agy / llm-routing-ollama)
Note over Router: Skip classifier, use model as tier
Expand Down Expand Up @@ -279,7 +279,7 @@ Exposes the entry endpoint (`http://localhost:5000/v1`) and evaluates prompt com
Orchestrates routing fallback chains, Redis caching, and telemetry callbacks:
- **`drop_params: true`**: Automatically strips unsupported arguments when transitioning to models that don't support them.
- **Request Timeouts (`300s`)**: Provides ample padding to prevent connection aborts during dynamic RAM swapping operations on the local GPU `llama-server`.
- **Local Embedding Model (`local-nomic-embed`)**: A zero-cost embedding model (`nomic-embed-text-v1.5-Q4_K_M`, ~137MB GGUF) running on llama-server. Configured in `litellm/config.yaml` with `api_base: http://127.0.0.1:8080/v1`. Used by `vector_store_settings` for semantic cache similarity search, replacing paid OpenRouter embeddings.
- **Local Embedding Model (`local-nomic-embed`)**: A zero-cost embedding model (`nomic-embed-text-v1.5-Q4_K_M`, ~137MB GGUF) running on llama-server. Configured in `litellm/config.yaml` with `api_base` set via `LLAMA_CLASSIFIER_URL` env var (resolved at deploy time from `.env`). Used by `vector_store_settings` for semantic cache similarity search, replacing paid OpenRouter embeddings.
- **`vector_store_settings`**: PostgreSQL-backed vector store for semantic caching, configured in `litellm/config.yaml`:
- `store_type: "postgres"` — pgvector extension on the local PostgreSQL instance
- `embedding_model: "local-nomic-embed"` — uses the local nomic-embed model (no API costs)
Expand Down
6 changes: 3 additions & 3 deletions litellm/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,15 @@ model_list:
max_input_tokens: 524288
is_public_model_group: true

# Re-enabled 2026-07-08 — llama.cpp on port 8081, alias local-qwen-3.6
# Re-enabled 2026-07-08 — llama.cpp, canonical endpoint set via .env
- litellm_params:
api_base: http://127.0.0.1:8081/v1
api_base: LLAMA_CLASSIFIER_URL_PLACEHOLDER
api_key: local-token
model: openai/local-qwen-3.6
request_timeout: 300
model_name: local-qwen-3.6
- litellm_params:
api_base: http://127.0.0.1:8080/v1
api_base: LLAMA_CLASSIFIER_URL_PLACEHOLDER
api_key: local-token
model: openai/nomic-embed-text-v1.5-Q4_K_M
request_timeout: 30
Expand Down
4 changes: 2 additions & 2 deletions pod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ spec:
- python3
- -c
- import urllib.request; urllib.request.urlopen('http://localhost:ROUTER_PORT_PLACEHOLDER/metrics')
initialDelaySeconds: 20
initialDelaySeconds: 240
periodSeconds: 15
timeoutSeconds: 5
name: llm-triage-router
Expand All @@ -155,7 +155,7 @@ spec:
- python3
- -c
- import urllib.request; urllib.request.urlopen('http://localhost:ROUTER_PORT_PLACEHOLDER/metrics')
initialDelaySeconds: 10
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
volumeMounts:
Expand Down
Empty file added router/__init__.py
Empty file.
2 changes: 1 addition & 1 deletion router/agy_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ async def save(self) -> None:
"""Push updated cooldown state to Valkey."""
...

from circuit_breaker import get_google_breaker, get_vendor_breaker
from router.circuit_breaker import get_google_breaker, get_vendor_breaker

logger = logging.getLogger("agy-proxy")

Expand Down
4 changes: 2 additions & 2 deletions router/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ server:
router:
strategy: "llm"
router_model:
api_base: "http://127.0.0.1:8080/v1"
api_base: "os.environ/LLAMA_CLASSIFIER_URL"
api_key: "os.environ/ROUTER_API_KEY"
model: "gemma4-26a4b-routing"
model: "qwen-4b-routing"

classification_rules:
system_prompt: |
Expand Down
77 changes: 67 additions & 10 deletions router/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from urllib.parse import urlparse
from circuit_breaker import get_breaker
try:
from router.circuit_breaker import get_breaker
except ImportError:
from circuit_breaker import get_breaker
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator, RootModel
from typing import Dict, Optional, Union

Expand Down Expand Up @@ -85,19 +88,52 @@ def get_redis():
_http_client = None


def _http_limits() -> httpx.Limits:
"""Shared connection limits for all httpx clients."""
return httpx.Limits(
max_connections=HTTP_MAX_CONNECTIONS,
max_keepalive_connections=HTTP_MAX_KEEPALIVE_CONNECTIONS,
keepalive_expiry=HTTP_KEEPALIVE_EXPIRY,
)


def get_http_client():
"""Return the shared global httpx.AsyncClient singleton with configured limits."""
global _http_client
if _http_client is None:
limits = httpx.Limits(
max_connections=HTTP_MAX_CONNECTIONS,
max_keepalive_connections=HTTP_MAX_KEEPALIVE_CONNECTIONS,
keepalive_expiry=HTTP_KEEPALIVE_EXPIRY,
)
_http_client = httpx.AsyncClient(limits=limits, timeout=3600.0)
_http_client = httpx.AsyncClient(limits=_http_limits(), timeout=3600.0)
return _http_client


_classifier_client: httpx.AsyncClient | None = None


def get_classifier_client():
"""Return a singleton httpx client for classifier calls (internal self-signed TLS).

By default verify is disabled because the classifier sits behind HAProxy with
a self-signed certificate on the internal network. Set CLASSIFIER_CA_BUNDLE
to a PEM file path to enable TLS verification (e.g. for CI or staging).
"""
global _classifier_client
if _classifier_client is None:
ca_bundle = os.getenv("CLASSIFIER_CA_BUNDLE")
if ca_bundle is not None:
ca_bundle_stripped = ca_bundle.strip()
if ca_bundle_stripped.lower() in ("false", "0", "off", "no", "none", "null", "disabled", ""):
verify = False
elif ca_bundle_stripped.lower() in ("true", "1", "on", "yes"):
verify = True
else:
verify = ca_bundle_stripped
else:
verify = False
_classifier_client = httpx.AsyncClient(
limits=_http_limits(), timeout=3600.0, verify=verify
)
return _classifier_client


# Compiled regular expressions for token estimation heuristics
WORD_RE = re.compile(r'[a-zA-Z0-9]+')
NON_ASCII_RE = re.compile(r'[^\s\x00-\x7F]')
Expand Down Expand Up @@ -344,10 +380,25 @@ async def push_aggregate_scores():
port = config.get("server", {}).get("port", 5000)

router_model_conf = config.get("router", {}).get("router_model", {})
router_api_base = router_model_conf.get("api_base", "http://127.0.0.1:8080/v1")
router_api_base = router_model_conf.get("api_base") or "http://127.0.0.1:8080/v1"
if isinstance(router_api_base, str):
if router_api_base.startswith("os.environ/"):
env_var = router_api_base.split("/", 1)[1]
router_api_base = os.environ.get(env_var, "")
if not router_api_base:
if "pytest" in sys.modules:
router_api_base = "http://127.0.0.1:8080/v1"
else:
raise RuntimeError(
f"Configuration error: Environment variable '{env_var}' is missing or empty."
)
router_api_base = router_api_base.rstrip("/")

router_api_key = router_model_conf.get("api_key")
if not router_api_key:
raise RuntimeError("Configuration error: 'api_key' is missing from router_model configuration.")
if not isinstance(router_api_key, str):
router_api_key = str(router_api_key)
if router_api_key.startswith("os.environ/"):
env_var = router_api_key.split("/", 1)[1]
router_api_key = os.environ.get(env_var)
Expand All @@ -356,7 +407,7 @@ async def push_aggregate_scores():
router_api_key = "local-token"
else:
raise RuntimeError(f"Configuration error: Environment variable '{env_var}' is missing or empty.")
router_model_name = router_model_conf.get("model", "qwen-0.8b-routing")
router_model_name = router_model_conf.get("model", "qwen-4b-routing")

system_prompt = config.get("classification_rules", {}).get("system_prompt", "")
backends = {b["name"]: b for b in config.get("backends", [])}
Expand Down Expand Up @@ -910,6 +961,12 @@ async def lifespan(app: FastAPI):
await _http_client.aclose()
_http_client = None

# Close classifier client
global _classifier_client
if _classifier_client is not None:
await _classifier_client.aclose()
_classifier_client = None

# Close Redis client
global _redis_client
if _redis_client is not None and _redis_client is not False:
Expand Down Expand Up @@ -991,7 +1048,7 @@ async def classify_request(
return cached_decision, 0.0, True, cached_decision

try:
client = get_http_client()
client = get_classifier_client()
try:
max_chars = max(0, int(os.getenv("CLASSIFIER_INPUT_MAX_CHARS", "300")))
except ValueError:
Expand Down
27 changes: 10 additions & 17 deletions router/tests/test_dashboard_data.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,20 @@
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import sys
import os

@pytest.mark.asyncio
async def test_get_dashboard_data_structure():
# Ensure router directory is in sys.path
router_path = os.path.join(os.getcwd(), "router")
if router_path not in sys.path:
sys.path.insert(0, router_path)

import main
from router import main

# Mocking all I/O and external calls
with patch("main.sync_cooldowns_from_valkey", new_callable=AsyncMock) as mock_sync, \
patch("main.check_tcp_port", new_callable=AsyncMock) as mock_tcp, \
patch("main.check_http_endpoint", new_callable=AsyncMock) as mock_http, \
patch("main.get_gemini_oauth_status", new_callable=AsyncMock) as mock_oauth, \
patch("main.get_best_free_model", new_callable=AsyncMock) as mock_best_model, \
patch("main.get_goose_sessions") as mock_goose, \
patch("main.get_llamacpp_metrics", new_callable=AsyncMock) as mock_llamacpp, \
patch("main.get_pie_chart_gradient") as mock_gradient, \
patch("main.stats") as mock_stats:
with patch("router.main.sync_cooldowns_from_valkey", new_callable=AsyncMock) as mock_sync, \
patch("router.main.check_tcp_port", new_callable=AsyncMock) as mock_tcp, \
patch("router.main.check_http_endpoint", new_callable=AsyncMock) as mock_http, \
patch("router.main.get_gemini_oauth_status", new_callable=AsyncMock) as mock_oauth, \
patch("router.main.get_best_free_model", new_callable=AsyncMock) as mock_best_model, \
patch("router.main.get_goose_sessions") as mock_goose, \
patch("router.main.get_llamacpp_metrics", new_callable=AsyncMock) as mock_llamacpp, \
patch("router.main.get_pie_chart_gradient") as mock_gradient, \
patch("router.main.stats") as mock_stats:

# Setup mock return values
mock_sync.return_value = None
Expand Down
2 changes: 1 addition & 1 deletion router/tests/test_detect_active_tool.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import pytest
from main import detect_active_tool
from router.main import detect_active_tool

def test_detect_active_tool_empty():
assert detect_active_tool({}) == "none"
Expand Down
2 changes: 1 addition & 1 deletion router/tests/test_estimate_prompt_tokens.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import pytest
from main import estimate_prompt_tokens
from router.main import estimate_prompt_tokens

def test_estimate_prompt_tokens_empty():
assert estimate_prompt_tokens({}) == 50
Expand Down
2 changes: 1 addition & 1 deletion router/tests/test_get_gemini_oauth_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import json
from unittest.mock import patch, mock_open

import main
from router import main

@pytest.mark.asyncio
async def test_get_gemini_oauth_status_missing_file():
Expand Down
2 changes: 1 addition & 1 deletion router/tests/test_get_goose_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import sys
from unittest.mock import patch, MagicMock

from main import get_goose_sessions
from router.main import get_goose_sessions

def test_get_goose_sessions_no_db():
with patch('os.path.exists', return_value=False):
Expand Down
18 changes: 9 additions & 9 deletions router/tests/test_load_persisted_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
import pytest
from unittest.mock import patch, mock_open

import main
from main import load_persisted_stats
from router import main
from router.main import load_persisted_stats

@pytest.fixture
def mock_stats():
Expand All @@ -17,7 +17,7 @@ def mock_stats():
yield main.stats

def test_load_persisted_stats_file_not_exists(mock_stats):
with patch("main.os.path.exists", return_value=False) as mock_exists:
with patch("router.main.os.path.exists", return_value=False) as mock_exists:
load_persisted_stats()
mock_exists.assert_called_once_with(main.STATS_JSON_PATH)
# Stats should remain unchanged
Expand All @@ -31,9 +31,9 @@ def test_load_persisted_stats_success(mock_stats):
}
mock_json = json.dumps(mock_data)

with patch("main.os.path.exists", side_effect=lambda p: p == main.STATS_JSON_PATH):
with patch("main.open", mock_open(read_data=mock_json)):
with patch("main.logger.info") as mock_logger:
with patch("router.main.os.path.exists", side_effect=lambda p: p == main.STATS_JSON_PATH):
with patch("router.main.open", mock_open(read_data=mock_json)):
with patch("router.main.logger.info") as mock_logger:
load_persisted_stats()

# Assert simple value updated via else block
Expand All @@ -48,9 +48,9 @@ def test_load_persisted_stats_success(mock_stats):
mock_logger.assert_called_once_with("✓ Successfully loaded persisted gateway statistics from disk.")

def test_load_persisted_stats_exception(mock_stats):
with patch("main.os.path.exists", return_value=True):
with patch("main.open", side_effect=Exception("Mock read error")):
with patch("main.logger.error") as mock_logger:
with patch("router.main.os.path.exists", return_value=True):
with patch("router.main.open", side_effect=Exception("Mock read error")):
with patch("router.main.logger.error") as mock_logger:
load_persisted_stats()

# Stats should remain unchanged
Expand Down
10 changes: 2 additions & 8 deletions router/tests/test_resolve_external_urls.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import pytest
import os
import sys
from unittest.mock import MagicMock

# Ensure router directory is in sys.path
router_path = os.path.join(os.getcwd(), "router")
if router_path not in sys.path:
sys.path.insert(0, router_path)

import main
from main import resolve_external_urls
from router import main
from router.main import resolve_external_urls


class MockRequest:
Expand Down
4 changes: 2 additions & 2 deletions router/tests/test_routing_behavior.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ async def test_classify_request_truncation_default():
mock_client.post.return_value = mock_response

# Force bypass_cache=True to ensure classify_request always hits llama-server
with patch("router.main.get_http_client", return_value=mock_client), \
with patch("router.main.get_classifier_client", return_value=mock_client), \
patch.dict(os.environ, {}, clear=False):
# We verify behavior with default (no env var set -> defaults to 300)
long_prompt = "a" * 500
Expand Down Expand Up @@ -57,7 +57,7 @@ async def test_classify_request_truncation_custom_env():
mock_client = AsyncMock()
mock_client.post.return_value = mock_response

with patch("router.main.get_http_client", return_value=mock_client), \
with patch("router.main.get_classifier_client", return_value=mock_client), \
patch.dict(os.environ, {"CLASSIFIER_INPUT_MAX_CHARS": "10"}):
long_prompt = "a" * 500
decision, _, _, _ = await classify_request(long_prompt, bypass_cache=True)
Expand Down
Loading