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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
python-version: '3.11'

- name: Install dependencies
run: pip install httpx==0.28.1 pytest pytest-asyncio redis
run: pip install httpx==0.28.1

- name: Run Circuit Breaker Tests
run: python3 test_circuit_breaker.py
Expand Down
10 changes: 7 additions & 3 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2024-06-24 - [Test implementation of detect_active_tool]
**Learning:** `router/main.py` requires configuration context (`CONFIG_PATH`) to be set, otherwise importing it for unit tests throws an error since it attempts to read config upon importing.
**Action:** Always mock or provide required environment variables such as `CONFIG_PATH` before importing functions from `router/main.py`.
## 2024-06-16 - Synchronous I/O in Async API Handlers
**Learning:** `save_persisted_stats()` was being called synchronously on every API request, cache hit, and tool usage log, triggering blocking disk I/O in the main event loop.
**Action:** Always throttle or batch background telemetry writes in async Python applications to prevent blocking the event loop under load.

## 2026-06-24 - Async Offloading for SQLite I/O
**Learning:** Synchronous blocking I/O (like SQLite queries) inside an async event loop handler can cause significant latency spikes and block other requests.
**Action:** Use `asyncio.to_thread()` to offload synchronous database interactions to a worker thread, ensuring the main event loop remains responsive.
13 changes: 0 additions & 13 deletions pr_description.md

This file was deleted.

39 changes: 0 additions & 39 deletions router/test_memory_mcp.py

This file was deleted.

85 changes: 0 additions & 85 deletions test_agy_proxy.py

This file was deleted.

84 changes: 0 additions & 84 deletions test_circuit_breaker.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@

import sys
import time
import asyncio
import pytest
from unittest.mock import AsyncMock, patch
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))

Expand Down Expand Up @@ -139,27 +136,6 @@ def test_backward_compatibility():
print("✓ Master record_failure and record_success maintain compatibility")



def test_dual_breaker_tier_max_logic():
"""Master breaker tier returns max of sub-breakers."""
reset_breakers()
b = get_breaker()

test_cases = [
(0, 0, 0),
(1, 0, 1),
(0, 2, 2),
(3, 3, 3),
(3, 1, 3),
]
for google_tier, vendor_tier, expected_tier in test_cases:
b.google.tier = google_tier
b.vendor.tier = vendor_tier
assert b.tier == expected_tier, f"Expected tier {expected_tier} for google={google_tier}, vendor={vendor_tier}, but got {b.tier}"

print("✓ Dual breaker tier correctly evaluates to max of sub-breakers")


def test_full_cycle():
"""Complete cycle: success → 3 failures → probe success → reset."""
reset_breakers()
Expand Down Expand Up @@ -199,61 +175,6 @@ def test_full_cycle():
print("✓ Full cycle: 3 failures → Tier 3 → probe success → reset")


@pytest.mark.asyncio
async def test_save_to_valkey_success():
"""Verify state is correctly serialized and persisted to Valkey."""
b = get_breaker()
sub = b.google
sub.tier = 2
sub.cooldown_until = 1234567890.0
sub.probe_granted = True
sub.total_trips = 5
sub.last_trip_time = 1234567000.0

mock_redis = AsyncMock()

with patch('time.time', return_value=1234560000.0):
await sub.save_to_valkey(mock_redis)

expected_state = {
"tier": "2",
"cooldown_until": "1234567890.0",
"probe_granted": "True",
"total_trips": "5",
"last_trip_time": "1234567000.0",
}

mock_redis.hset.assert_awaited_once_with("circuit_breaker:google", mapping=expected_state)
# TTL logic: max(3600.0, cooldown_until - now + 3600.0)
# max(3600.0, 1234567890.0 - 1234560000.0 + 3600.0) = max(3600.0, 7890.0 + 3600.0) = 11490
mock_redis.expire.assert_awaited_once_with("circuit_breaker:google", 11490)
print("✓ Valkey save succeeds with correct data and TTL")


@pytest.mark.asyncio
async def test_save_to_valkey_no_client():
"""Verify early return when redis client is None."""
b = get_breaker()
sub = b.google
# Should not raise exception
await sub.save_to_valkey(None)
print("✓ Valkey save handles None client safely")


@pytest.mark.asyncio
async def test_save_to_valkey_exception_handling():
"""Verify exceptions during Valkey save are caught and logged."""
b = get_breaker()
sub = b.google

mock_redis = AsyncMock()
mock_redis.hset.side_effect = Exception("Connection lost")

with patch('router.circuit_breaker.logger') as mock_logger:
await sub.save_to_valkey(mock_redis)
mock_logger.warning.assert_called_once()


if __name__ == "__main__":
test_initial_state()
test_first_failure_trips_to_tier1()
Expand All @@ -263,12 +184,7 @@ async def test_save_to_valkey_exception_handling():
test_success_resets()
test_backward_compatibility()
test_full_cycle()
test_dual_breaker_tier_max_logic()

asyncio.run(test_save_to_valkey_success())
asyncio.run(test_save_to_valkey_no_client())
asyncio.run(test_save_to_valkey_exception_handling())

print("\n" + "=" * 60)
print(" ALL CIRCUIT BREAKER TESTS PASSED ✓")
print("=" * 60)
44 changes: 44 additions & 0 deletions test_compute_free_model_score.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import pytest
from unittest.mock import patch, mock_open
import json

from router import main as router_main
from router.main import compute_free_model_score

@pytest.fixture(autouse=True)
def reset_cache():
"""Reset the global cache state before each test."""
router_main._AA_SCORES_CACHE = {}
router_main._AA_SCORES_LOADED = False
yield
router_main._AA_SCORES_CACHE = {}
router_main._AA_SCORES_LOADED = False

def test_compute_free_model_score_known_model():
"""Test when the model id exists in the cache."""
mock_data = json.dumps({"scores": {"model-a": 85.5}})
with patch("builtins.open", mock_open(read_data=mock_data)):
score = compute_free_model_score({"id": "model-a"})
assert score == 85.5

def test_compute_free_model_score_unknown_model():
"""Test when the model id is not in the cache."""
mock_data = json.dumps({"scores": {"model-a": 85.5}})
with patch("builtins.open", mock_open(read_data=mock_data)):
score = compute_free_model_score({"id": "model-b"})
assert score == 25.0

def test_compute_free_model_score_missing_id():
"""Test when the model dictionary is missing an 'id'."""
mock_data = json.dumps({"scores": {"model-a": 85.5}})
with patch("builtins.open", mock_open(read_data=mock_data)):
score = compute_free_model_score({"name": "just a name"})
assert score == 25.0
Comment on lines +31 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add a true malformed-id case.

Line 31 only covers the missing-key path. A present-but-malformed id (for example None or 123) still flows through m.get("id", ""), so this suite does not quite cover the “malformed or missing IDs” contract yet.

Suggested test shape
+@pytest.mark.parametrize("model", [
+    {"name": "just a name"},
+    {"id": None},
+    {"id": 123},
+])
+def test_compute_free_model_score_invalid_id_defaults(model):
+    mock_data = json.dumps({"scores": {"model-a": 85.5}})
+    with patch("builtins.open", mock_open(read_data=mock_data)):
+        assert compute_free_model_score(model) == 25.0
-
-def test_compute_free_model_score_missing_id():
-    """Test when the model dictionary is missing an 'id'."""
-    mock_data = json.dumps({"scores": {"model-a": 85.5}})
-    with patch("builtins.open", mock_open(read_data=mock_data)):
-        score = compute_free_model_score({"name": "just a name"})
-        assert score == 25.0
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_compute_free_model_score_missing_id():
"""Test when the model dictionary is missing an 'id'."""
mock_data = json.dumps({"scores": {"model-a": 85.5}})
with patch("builtins.open", mock_open(read_data=mock_data)):
score = compute_free_model_score({"name": "just a name"})
assert score == 25.0
`@pytest.mark.parametrize`("model", [
{"name": "just a name"},
{"id": None},
{"id": 123},
])
def test_compute_free_model_score_invalid_id_defaults(model):
"""Test malformed or missing IDs default to the fallback score."""
mock_data = json.dumps({"scores": {"model-a": 85.5}})
with patch("builtins.open", mock_open(read_data=mock_data)):
assert compute_free_model_score(model) == 25.0
🧰 Tools
🪛 ast-grep (0.44.0)

[info] 32-32: use jsonify instead of json.dumps for JSON output
Context: json.dumps({"scores": {"model-a": 85.5}})
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test_compute_free_model_score.py` around lines 31 - 36, The current test only
covers a missing id key, but not a malformed present id value. Update the test
in test_compute_free_model_score_missing_id or add a sibling case that calls
compute_free_model_score with a model dict whose id is present but invalid (such
as None or a non-string like 123), so the coverage exercises the m.get("id", "")
path for malformed IDs as well as missing ones.


def test_compute_free_model_score_file_not_found():
"""Test fallback when the aa_scores.json file is missing or fails to load."""
with patch("builtins.open", side_effect=FileNotFoundError):
score = compute_free_model_score({"id": "model-a"})
assert score == 25.0
assert router_main._AA_SCORES_LOADED is True
assert router_main._AA_SCORES_CACHE == {}
Comment on lines +17 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Patching builtins.open globally can have unintended side effects on other modules, background threads, or pytest's internal operations (such as formatting tracebacks or reading source files) that run during the test execution. It is a safer and more robust practice in Python to patch open where it is looked up and used, i.e., "router.main.open".

def test_compute_free_model_score_known_model():
    """Test when the model id exists in the cache."""
    mock_data = json.dumps({"scores": {"model-a": 85.5}})
    with patch("router.main.open", mock_open(read_data=mock_data)):
        score = compute_free_model_score({"id": "model-a"})
        assert score == 85.5

def test_compute_free_model_score_unknown_model():
    """Test when the model id is not in the cache."""
    mock_data = json.dumps({"scores": {"model-a": 85.5}})
    with patch("router.main.open", mock_open(read_data=mock_data)):
        score = compute_free_model_score({"id": "model-b"})
        assert score == 25.0

def test_compute_free_model_score_missing_id():
    """Test when the model dictionary is missing an 'id'."""
    mock_data = json.dumps({"scores": {"model-a": 85.5}})
    with patch("router.main.open", mock_open(read_data=mock_data)):
        score = compute_free_model_score({"name": "just a name"})
        assert score == 25.0

def test_compute_free_model_score_file_not_found():
    """Test fallback when the aa_scores.json file is missing or fails to load."""
    with patch("router.main.open", side_effect=FileNotFoundError):
        score = compute_free_model_score({"id": "model-a"})
        assert score == 25.0
        assert router_main._AA_SCORES_LOADED is True
        assert router_main._AA_SCORES_CACHE == {}

Loading