diff --git a/README.md b/README.md index 4a162e8c..c093e9fe 100644 --- a/README.md +++ b/README.md @@ -487,7 +487,7 @@ Navigate your web browser to: 👉 **`http://localhost:5000/dashboard`** The triage router hosts a beautiful, single-pane-of-glass **Glassmorphic Status Control Panel** styled with modern vanilla CSS featuring: -* **System Status Healthchecks**: Live connection status checks via TCP sockets (Valkey, Postgres) and HTTP pings (LiteLLM, Llama-server). +* **System Status Healthchecks**: Live connection status checks via TCP sockets (Valkey, Postgres), HTTP pings (LiteLLM, Llama-server), and non-blocking asynchronous checks for Gemini OAuth token validation status. * **Real-time Routing Metrics**: Active classification splits (simple vs complex), request logs, and processing latencies. * **Direct Application Portals**: One-click navigation links to target web utilities (LiteLLM administration console, Langfuse telemetry console, Llama-Server playground). @@ -571,6 +571,9 @@ Without Minio, Langfuse v3 **will not start** — it validates S3 connectivity a ### Configuration +> [!WARNING] +> The credentials `minioadmin` / `minioadmin` and settings specified below are strictly for **local development and testing**. You **must** override these default credentials before any shared, staging, or production use. + | Env Var | Value | |----------|-------| | `LANGFUSE_S3_EVENT_UPLOAD_BUCKET` | `langfuse-events` | @@ -579,12 +582,15 @@ Without Minio, Langfuse v3 **will not start** — it validates S3 connectivity a | `LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY` | `minioadmin` | | `S3_FORCE_PATH_STYLE` | `true` | -Minio runs on ports **9001** (web console) and **9002** (S3 API). Credentials: `minioadmin` / `minioadmin`. Image pinned to `docker.io/minio/minio:RELEASE.2025-10-15T17-29-55Z`. +Minio runs on ports **9001** (web console) and **9002** (S3 API). Default credentials (`minioadmin` / `minioadmin`) are only meant for local/dev setups. Image pinned to `docker.io/minio/minio:RELEASE.2025-10-15T17-29-55Z`. ### Health Check MinIO's health is monitored using its native structured endpoints `/minio/health/live` (liveness) and `/minio/health/ready` (readiness) on port 9002: +> [!IMPORTANT] +> When deploying to staging or production, ensure that custom credentials are configured (rather than the default `minioadmin` keys described in [Configuration](#configuration)), and ensure the deployment's probes and S3 configurations are updated to reference the new values. + ```yaml livenessProbe: httpGet: diff --git a/router/main.py b/router/main.py index e6b492f8..2bfb552e 100644 --- a/router/main.py +++ b/router/main.py @@ -25,6 +25,8 @@ "/" ) +GEMINI_OAUTH_CREDS_PATH = "/config/gemini_auth/oauth_creds.json" + _redis_client = None _redis_last_init_attempt = 0.0 @@ -1027,45 +1029,48 @@ async def classify_request( return "agent-advanced-core", latency, False, "advanced (exception)" -def get_live_gemini_oauth_token() -> str | None: +def _read_json_file_sync(file_path: str) -> dict: + """Helper to read JSON files synchronously.""" + with open(file_path, "r") as f: + return json.load(f) + +async def get_live_gemini_oauth_token() -> str | None: """Retrieve the current valid Gemini OAuth access token from local storage if not expired.""" try: - creds_path = "/config/gemini_auth/oauth_creds.json" - if os.path.exists(creds_path): - with open(creds_path, "r") as f: - data = json.load(f) - access_token = data.get("access_token") - expiry_ms = data.get("expiry_date", 0) - # Convert current time to milliseconds - current_ms = int(time.time() * 1000) - if access_token and current_ms < expiry_ms: - logger.info( - "🔑 Found valid, unexpired Gemini OAuth token from host!" - ) - return access_token - else: - # agy CLI uses the OS system keyring (GNOME Keyring), not this - # stale disk file. The file being expired is expected — don't warn. - logger.debug( - "Gemini OAuth token on disk is expired — agy uses system keyring instead." - ) + if await asyncio.to_thread(os.path.exists, GEMINI_OAUTH_CREDS_PATH): + data = await asyncio.to_thread(_read_json_file_sync, GEMINI_OAUTH_CREDS_PATH) + access_token = data.get("access_token") + expiry_ms = data.get("expiry_date", 0) + # Convert current time to milliseconds + current_ms = int(time.time() * 1000) + if access_token and current_ms < expiry_ms: + logger.info( + "🔑 Found valid, unexpired Gemini OAuth token from host!" + ) + return access_token + else: + # agy CLI uses the OS system keyring (GNOME Keyring), not this + # stale disk file. The file being expired is expected — don't warn. + logger.debug( + "Gemini OAuth token on disk is expired — agy uses system keyring instead." + ) except Exception as e: logger.error(f"Failed to read live OAuth token: {e}") return None -def get_gemini_oauth_status() -> dict: + + +async def get_gemini_oauth_status() -> dict: """Returns structured OAuth status for the dashboard banner.""" - creds_path = "/config/gemini_auth/oauth_creds.json" try: - if not os.path.exists(creds_path): + if not await asyncio.to_thread(os.path.exists, GEMINI_OAUTH_CREDS_PATH): return { "status": "missing", "detail": "No oauth_creds.json found", "expiry_ms": 0, } - with open(creds_path, "r") as f: - data = json.load(f) + data = await asyncio.to_thread(_read_json_file_sync, GEMINI_OAUTH_CREDS_PATH) access_token = data.get("access_token") expiry_ms = data.get("expiry_date", 0) current_ms = int(time.time() * 1000) @@ -2626,7 +2631,7 @@ async def get_dashboard_data(): check_http_endpoint("http://127.0.0.1:4000/"), check_http_endpoint("http://127.0.0.1:8080/health"), check_http_endpoint("http://127.0.0.1:3001"), - asyncio.to_thread(get_gemini_oauth_status), + get_gemini_oauth_status(), asyncio.wait_for(get_best_free_model(), timeout=5.0), asyncio.to_thread(get_goose_sessions), asyncio.wait_for(get_llamacpp_metrics(), timeout=5.0), diff --git a/router/tests/test_dashboard_data.py b/router/tests/test_dashboard_data.py index 643425f8..695bbabc 100644 --- a/router/tests/test_dashboard_data.py +++ b/router/tests/test_dashboard_data.py @@ -17,7 +17,7 @@ async def test_get_dashboard_data_structure(): 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") as mock_oauth, \ + 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, \ diff --git a/router/tests/test_get_live_gemini_oauth_token.py b/router/tests/test_get_live_gemini_oauth_token.py new file mode 100644 index 00000000..42a1ee1f --- /dev/null +++ b/router/tests/test_get_live_gemini_oauth_token.py @@ -0,0 +1,80 @@ +import os +import sys +import json +import time +import pytest +from unittest.mock import patch, mock_open + +# Add router to sys.path properly +router_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if router_path not in sys.path: + sys.path.insert(0, router_path) + +with patch('builtins.open', mock_open(read_data=''' +server: + host: 0.0.0.0 + port: 5000 +router: + router_model: + api_key: "dummy_key" + model: "dummy_model" +''')): + import main + +@pytest.mark.asyncio +async def test_get_live_gemini_oauth_token_valid(): + """Test retrieving a valid, unexpired token.""" + mock_data = { + "access_token": "valid_token_123", + "expiry_date": int(time.time() * 1000) + 3600000 # 1 hour in the future + } + with patch("os.path.exists", return_value=True), \ + patch("builtins.open", mock_open(read_data=json.dumps(mock_data))), \ + patch("main.logger.info") as mock_logger: + token = await main.get_live_gemini_oauth_token() + assert token == "valid_token_123" + mock_logger.assert_called_with("🔑 Found valid, unexpired Gemini OAuth token from host!") + +@pytest.mark.asyncio +async def test_get_live_gemini_oauth_token_expired(): + """Test that an expired token returns None.""" + mock_data = { + "access_token": "expired_token_456", + "expiry_date": int(time.time() * 1000) - 3600000 # 1 hour in the past + } + with patch("os.path.exists", return_value=True), \ + patch("builtins.open", mock_open(read_data=json.dumps(mock_data))), \ + patch("main.logger.debug") as mock_logger: + token = await main.get_live_gemini_oauth_token() + assert token is None + mock_logger.assert_called_with("Gemini OAuth token on disk is expired — agy uses system keyring instead.") + +@pytest.mark.asyncio +async def test_get_live_gemini_oauth_token_missing_file(): + """Test behavior when the credentials file does not exist.""" + with patch("os.path.exists", return_value=False): + token = await main.get_live_gemini_oauth_token() + assert token is None + +@pytest.mark.asyncio +async def test_get_live_gemini_oauth_token_invalid_json(): + """Test behavior when the credentials file contains invalid JSON.""" + with patch("os.path.exists", return_value=True), \ + patch("builtins.open", mock_open(read_data="invalid json")), \ + patch("main.logger.error") as mock_logger: + token = await main.get_live_gemini_oauth_token() + assert token is None + mock_logger.assert_called() + +@pytest.mark.asyncio +async def test_get_live_gemini_oauth_token_missing_access_token(): + """Test behavior when the JSON does not contain an access_token.""" + mock_data = { + "expiry_date": int(time.time() * 1000) + 3600000 + } + with patch("os.path.exists", return_value=True), \ + patch("builtins.open", mock_open(read_data=json.dumps(mock_data))), \ + patch("main.logger.debug") as mock_logger: + token = await main.get_live_gemini_oauth_token() + assert token is None + mock_logger.assert_called_with("Gemini OAuth token on disk is expired — agy uses system keyring instead.") diff --git a/test_record_tool_usage.py b/test_record_tool_usage.py index ffe6ca00..d0651714 100644 --- a/test_record_tool_usage.py +++ b/test_record_tool_usage.py @@ -59,8 +59,20 @@ def test_record_tool_usage_none_mapping(): def test_record_tool_usage_accumulation(): """Test that tokens accumulate correctly over multiple calls.""" - router.main.record_tool_usage(ToolUsageRecord("write", 10, 10, "model1", 50.0)) - router.main.record_tool_usage(ToolUsageRecord("write", 20, 30, "model2", 60.0)) + router.main.record_tool_usage(ToolUsageRecord( + tool_name="write", + prompt_tokens=10, + completion_tokens=10, + model="model1", + latency_ms=50.0 + )) + router.main.record_tool_usage(ToolUsageRecord( + tool_name="write", + prompt_tokens=20, + completion_tokens=30, + model="model2", + latency_ms=60.0 + )) assert router.main.stats["tool_tokens"]["write"] == 70 assert router.main.stats["prompt_tokens"] == 30 @@ -71,7 +83,13 @@ def test_record_tool_usage_timeline_limit(): """Test that the timeline buffer is capped at 15 events.""" # Add 20 events for i in range(20): - router.main.record_tool_usage(ToolUsageRecord(f"tool_{i}", 1, 1, "model", 10.0)) + router.main.record_tool_usage(ToolUsageRecord( + tool_name=f"tool_{i}", + prompt_tokens=1, + completion_tokens=1, + model="model", + latency_ms=10.0 + )) assert len(router.main.stats["timeline"]) == 15 # The first 5 events should be popped off, so the oldest event in the timeline