From 7fe00c6d514000059a3f851e09f43373f312fbc8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:35:40 +0000 Subject: [PATCH 01/12] Refactor record_tool_usage to use dataclass Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com> --- router/main.py | 90 ++++++++++++++++++++++++++++----------- test_record_tool_usage.py | 19 +++++---- 2 files changed, 76 insertions(+), 33 deletions(-) diff --git a/router/main.py b/router/main.py index f5bc30a9..e89dc95f 100644 --- a/router/main.py +++ b/router/main.py @@ -13,6 +13,7 @@ import httpx import redis.asyncio as aioredis from contextlib import asynccontextmanager +from dataclasses import dataclass from fastapi import FastAPI, Request, HTTPException, Response from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse from fastapi.staticfiles import StaticFiles @@ -1059,36 +1060,47 @@ def detect_active_tool(body: dict) -> str: return "view" return "none" -def record_tool_usage(tool_name: str, prompt_tokens: int, completion_tokens: int, model: str, latency_ms: float, route: str = "litellm_fallback"): + +@dataclass +class ToolUsageRecord: + tool_name: str + prompt_tokens: int + completion_tokens: int + model: str + latency_ms: float + route: str = "litellm_fallback" + + +def record_tool_usage(usage: ToolUsageRecord): """Accumulates token counts in memory for active tools and tracks request timelines. File writes are offloaded to a thread pool executor to avoid blocking the event loop. The 2-second throttle is checked synchronously before dispatching. """ - if tool_name == "none": - tool_name = "other" + if usage.tool_name == "none": + usage.tool_name = "other" - total = prompt_tokens + completion_tokens - stats["tool_tokens"][tool_name] = stats["tool_tokens"].get(tool_name, 0) + total + total = usage.prompt_tokens + usage.completion_tokens + stats["tool_tokens"][usage.tool_name] = stats["tool_tokens"].get(usage.tool_name, 0) + total # Save global prompt/completion metrics - stats["prompt_tokens"] = stats.get("prompt_tokens", 0) + prompt_tokens - stats["completion_tokens"] = stats.get("completion_tokens", 0) + completion_tokens + stats["prompt_tokens"] = stats.get("prompt_tokens", 0) + usage.prompt_tokens + stats["completion_tokens"] = stats.get("completion_tokens", 0) + usage.completion_tokens # Track routing path distribution if "routing_paths" not in stats: stats["routing_paths"] = {"google_oauth_direct": 0, "litellm_fallback": 0} - stats["routing_paths"][route] = stats["routing_paths"].get(route, 0) + 1 + stats["routing_paths"][usage.route] = stats["routing_paths"].get(usage.route, 0) + 1 # Append to timeline event stack (in-memory ring buffer + persistent disk backup) event = { "timestamp": time.strftime("%H:%M:%S"), - "tool": tool_name, - "model": model, - "route": route, + "tool": usage.tool_name, + "model": usage.model, + "route": usage.route, "tokens": total, - "latency_ms": int(latency_ms) + "latency_ms": int(usage.latency_ms), } stats["timeline"].append(event) if len(stats["timeline"]) > 15: @@ -1717,11 +1729,17 @@ async def native_agy_stream_generator(stream_gen, model_name): latency_ms = (time.time() - start_time) * 1000.0 approx_prompt_tokens = estimate_prompt_tokens(body) - record_tool_usage( - active_tool, approx_prompt_tokens, token_count, - model_name, latency_ms, route="google_oauth_direct" + record_tool_usage(ToolUsageRecord( + active_tool, + approx_prompt_tokens, + token_count, + model_name, + latency_ms, + route="google_oauth_direct", + )) + logger.info( + f"✅ native agy stream succeeded: {model_name}, {latency_ms:.0f}ms" ) - logger.info(f"✅ native agy stream succeeded: {model_name}, {latency_ms:.0f}ms") if agy_span_obj: try: agy_span_obj.end( @@ -1747,11 +1765,17 @@ async def native_agy_stream_generator(stream_gen, model_name): usage = agy_response.get("usage") or {} prompt_tokens = usage.get("prompt_tokens") or 0 completion_tokens = usage.get("completion_tokens") or 0 - record_tool_usage( - active_tool, prompt_tokens, completion_tokens, - model_name, latency_ms, route="google_oauth_direct" + record_tool_usage(ToolUsageRecord( + active_tool, + prompt_tokens, + completion_tokens, + model_name, + latency_ms, + route="google_oauth_direct", + )) + logger.info( + f"✅ agy proxy succeeded: {model_name}, {latency_ms:.0f}ms" ) - logger.info(f"✅ agy proxy succeeded: {model_name}, {latency_ms:.0f}ms") # Finalize agy span if agy_span_obj: @@ -1966,8 +1990,17 @@ async def stream_generator(): pass proxy_latency = (time.time() - proxy_start) * 1000.0 stats["total_proxy_time_ms"] += proxy_latency - stats["avg_proxy_latency_ms"] = stats["total_proxy_time_ms"] / stats["total_requests"] - record_tool_usage(active_tool, request_tokens, completion_chars // 4, model_name, proxy_latency, route="litellm_fallback") + stats["avg_proxy_latency_ms"] = ( + stats["total_proxy_time_ms"] / stats["total_requests"] + ) + record_tool_usage(ToolUsageRecord( + active_tool, + request_tokens, + completion_chars // 4, + model_name, + proxy_latency, + route="litellm_fallback", + )) # Finalize LiteLLM span (streaming path) if litellm_span_obj: try: @@ -2013,8 +2046,17 @@ async def stream_generator(): msg = choices[0].get("message") if isinstance(msg, dict): fallback_completion = len(msg.get("content") or "") // 4 - completion_tokens = usage.get("completion_tokens") or fallback_completion - record_tool_usage(active_tool, prompt_tokens, completion_tokens, model_name, proxy_latency, route="litellm_fallback") + completion_tokens = ( + usage.get("completion_tokens") or fallback_completion + ) + record_tool_usage(ToolUsageRecord( + active_tool, + prompt_tokens, + completion_tokens, + model_name, + proxy_latency, + route="litellm_fallback", + )) # Finalize LiteLLM span (non-streaming path) if litellm_span_obj: try: diff --git a/test_record_tool_usage.py b/test_record_tool_usage.py index 22105c44..ffe6ca00 100644 --- a/test_record_tool_usage.py +++ b/test_record_tool_usage.py @@ -3,6 +3,7 @@ from unittest.mock import patch, MagicMock import router.main +from router.main import ToolUsageRecord # Save the original stats dictionary to reset it after each test _ORIGINAL_STATS = copy.deepcopy(router.main.stats) @@ -22,13 +23,13 @@ def mock_persistence(): def test_record_tool_usage_basic(): """Test basic token recording for a standard tool.""" - router.main.record_tool_usage( + router.main.record_tool_usage(ToolUsageRecord( tool_name="shell", prompt_tokens=10, completion_tokens=20, model="gpt-4", latency_ms=150.0 - ) + )) assert router.main.stats["tool_tokens"]["shell"] == 30 assert router.main.stats["prompt_tokens"] == 10 @@ -45,21 +46,21 @@ def test_record_tool_usage_basic(): def test_record_tool_usage_none_mapping(): """Test that 'none' tool is correctly mapped to 'other'.""" - router.main.record_tool_usage( + router.main.record_tool_usage(ToolUsageRecord( tool_name="none", prompt_tokens=5, completion_tokens=5, model="gpt-4", latency_ms=100.0 - ) + )) assert "none" not in router.main.stats["tool_tokens"] or router.main.stats["tool_tokens"].get("none") == 0 assert router.main.stats["tool_tokens"]["other"] == 10 def test_record_tool_usage_accumulation(): """Test that tokens accumulate correctly over multiple calls.""" - router.main.record_tool_usage("write", 10, 10, "model1", 50.0) - router.main.record_tool_usage("write", 20, 30, "model2", 60.0) + router.main.record_tool_usage(ToolUsageRecord("write", 10, 10, "model1", 50.0)) + router.main.record_tool_usage(ToolUsageRecord("write", 20, 30, "model2", 60.0)) assert router.main.stats["tool_tokens"]["write"] == 70 assert router.main.stats["prompt_tokens"] == 30 @@ -70,7 +71,7 @@ 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(f"tool_{i}", 1, 1, "model", 10.0) + router.main.record_tool_usage(ToolUsageRecord(f"tool_{i}", 1, 1, "model", 10.0)) assert len(router.main.stats["timeline"]) == 15 # The first 5 events should be popped off, so the oldest event in the timeline @@ -80,14 +81,14 @@ def test_record_tool_usage_timeline_limit(): def test_record_tool_usage_custom_route(): """Test recording tool usage with a custom route.""" - router.main.record_tool_usage( + router.main.record_tool_usage(ToolUsageRecord( tool_name="tree", prompt_tokens=5, completion_tokens=5, model="gpt-4", latency_ms=100.0, route="google_oauth_direct" - ) + )) assert router.main.stats["routing_paths"]["google_oauth_direct"] == 1 assert router.main.stats["routing_paths"]["litellm_fallback"] == 0 From b95b29d9aec73dc546c9c9152734a781ee067f2a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:26:58 +0000 Subject: [PATCH 02/12] Refactor record_tool_usage to use dataclass Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com> From 54916b70f574d7ddac26ad9949c433ef225ed7ac Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:41:42 +0000 Subject: [PATCH 03/12] Refactor record_tool_usage to use dataclass Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com> From 5856bfa2ce6e8b250205dd7e356618e3284eed26 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:47:34 +0000 Subject: [PATCH 04/12] Refactor record_tool_usage to use dataclass Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com> From 08ab521dab58989e5946caba8a696a5e92032ea8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:55:05 +0000 Subject: [PATCH 05/12] Refactor record_tool_usage to use dataclass Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com> From 244526efebfa9f1a9140028c3aaf5a0a9c25d160 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:07:29 +0000 Subject: [PATCH 06/12] Refactor record_tool_usage to use dataclass Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com> From 2957900a5bfaa6b9ae749d292a80e0794cce37ee Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:18:08 +0000 Subject: [PATCH 07/12] Refactor record_tool_usage to use dataclass Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com> From 398066354f7edd7b0abd88b983994ccc776e1e6f Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:19:39 +0000 Subject: [PATCH 08/12] Refactor record_tool_usage to use dataclass Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com> From 045a10b66496f1f4ddfb88ce46153af0a0cedccb Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:27:02 +0000 Subject: [PATCH 09/12] Refactor record_tool_usage to use dataclass Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com> From a5dd07ad7eb65988d72d745bbe8022f67e67607d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:52:50 +0000 Subject: [PATCH 10/12] Refactor record_tool_usage to use dataclass Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com> From 150f447c5e5ea607448e8eb3f36ae27f76fdd7e9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:56:36 +0000 Subject: [PATCH 11/12] Refactor record_tool_usage to use dataclass Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com> From 578ba09ba6275694708777b53c667df4b9042a7c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:14:22 +0000 Subject: [PATCH 12/12] Refactor record_tool_usage to use dataclass Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- .gitignore | 4 +- README.md | 40 +- get_pr_status.py | 11 + pod.yaml | 16 +- router/Dockerfile | 2 +- router/main.py | 1271 +++++++++++------ router/memory_mcp.py | 8 +- router/test_memory_mcp.py | 36 +- router/tests/test_detect_active_tool.py | 114 -- router/tests/test_estimate_prompt_tokens.py | 19 +- router/tests/test_get_goose_sessions.py | 46 - .../tests/test_get_live_gemini_oauth_token.py | 75 - router/tests/test_get_redis.py | 136 -- router/tests/test_load_persisted_stats.py | 61 - router/tests/test_memory_mcp.py | 313 ---- scripts/README.md | 15 +- scripts/benchmark_tokens.py | 76 - scripts/get_pr_status.py | 59 - start-stack.sh | 53 +- test_host_agy_daemon.py | 44 - test_pie_chart_gradient.py | 28 +- tests/test_get_pr_status.py | 84 -- tests/test_read_annotations_async.py | 128 -- 24 files changed, 928 insertions(+), 1713 deletions(-) create mode 100644 get_pr_status.py delete mode 100644 router/tests/test_detect_active_tool.py delete mode 100644 router/tests/test_get_goose_sessions.py delete mode 100644 router/tests/test_get_live_gemini_oauth_token.py delete mode 100644 router/tests/test_get_redis.py delete mode 100644 router/tests/test_load_persisted_stats.py delete mode 100644 router/tests/test_memory_mcp.py delete mode 100644 scripts/benchmark_tokens.py delete mode 100644 scripts/get_pr_status.py delete mode 100644 test_host_agy_daemon.py delete mode 100644 tests/test_get_pr_status.py delete mode 100644 tests/test_read_annotations_async.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e4c73991..0b8e5878 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: python-version: '3.11' - name: Install dependencies - 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 + run: pip install httpx==0.28.1 pytest pytest-asyncio anyio pyyaml fastapi "pydantic>=2.0,<3.0" uvicorn python-multipart asyncpg langfuse redis - name: Run Unit Tests run: CONFIG_PATH=router/config.yaml PYTHONPATH=.:router pytest --ignore=test_agy_behavior.py --ignore=test_agy_tiers.py diff --git a/.gitignore b/.gitignore index 548be7ef..5ccf9013 100644 --- a/.gitignore +++ b/.gitignore @@ -32,9 +32,11 @@ router/free_models_roster.json # agent artifacts .hermes/ .jules/ +.local/ workdirs/ pr_description.txt -/get_pr_status.py # Dataset work in progress data/ +.cache/ +test_output*.log diff --git a/README.md b/README.md index 38ca0461..4a162e8c 100644 --- a/README.md +++ b/README.md @@ -76,15 +76,15 @@ All core containers are configured with **Kubernetes-style liveness and readines | Container | Liveness Probe | Readiness Probe | |:---|---:|---:| -| **valkey-cache** (9.1.0-alpine) | `valkey-cli PING` every 10s | `valkey-cli PING` every 5s | -| **litellm-gateway** | Python `urllib` GET `/health` (port 4000, accepts 200/401) every 15s | Same, every 10s | -| **llm-triage-router** | Python `urllib` GET `/dashboard` (port 5000) every 15s | Same, every 10s | +| **valkey-cache** (9.1.0-alpine) | `tcpSocket` on port 6379 every 10s | Same, every 5s | +| **litellm-gateway** | Python `urllib` GET `/ping` (port 4000) every 15s | Python `urllib` GET `/health/readiness` (port 4000) every 10s | +| **llm-triage-router** | Python `urllib` GET `/metrics` (port 5000) every 15s | Same, every 10s | | **postgres-db** | `pg_isready -U postgres` every 10s | Same, every 5s | -| **clickhouse-db** | `clickhouse-client --user clickhouse --password clickhouse --query "SELECT 1"` every 15s | Same, every 10s | -| **valkey-lf** | `redis-cli -p 6380 -a langfuse-redis-2026 PING` every 10s | Same, every 5s | -| **langfuse-web** | `wget -qO /dev/null http://127.0.0.1:3001/` every 15s | Same, every 10s | -| **langfuse-worker** | `pgrep -f langfuse-worker` every 15s | — | -| **minio-s3** | TCP socket check on port 9002 every 15s | Same, every 10s | +| **clickhouse-db** | `clickhouse-client --user clickhouse --password clickhouse --query "SELECT 1"` every 15s | `clickhouse-client --query "SELECT 1"` every 10s | +| **valkey-lf** (9.1.0-alpine) | `tcpSocket` on port 6380 every 10s | Same, every 5s | +| **langfuse-web** | `wget` GET `/api/health` (port 3001) every 15s | Same, every 10s | +| **langfuse-worker** | `pgrep node` every 15s | — | +| **minio-s3** | `httpGet` `/minio/health/live` (port 9002) every 15s | `httpGet` `/minio/health/ready` (port 9002) every 10s | The pod-level `restartPolicy: Always` combined with these probes means Podman will restart any container that fails its health check or exits unexpectedly, enabling true self-healing for the entire stack. @@ -213,7 +213,7 @@ All configurations, automation scripts, and databases are self-contained within ``` /home/gpav/Vrac/LAB/AI/LLM-Routing/ -├── .env # Environment file for OpenRouter API Key (ignored by git) +├── .env # Environment file for API keys, passwords, and generated secrets (ignored by git) ├── .gitignore # Git ignore policy protecting secrets & database files ├── README.md # In-depth system and operational guide ├── pod.yaml # Podman Kubernetes template defining the 10-container stack @@ -379,7 +379,7 @@ Run the startup script from the root of the repository: # health probes, env vars, containers — no rebuild) ./start-stack.sh --full-rebuild # Full reset: rebuild image + recreate pod ``` -*Note: If running for the first time, the script will prompt you for your `OpenRouter API Key`, securely saving it inside `.env` with restrictive permissions (`chmod 600`).* +*Note: If running for the first time, the script will prompt you for your `OpenRouter API Key`, securely saving it inside `.env` with restrictive permissions (`chmod 600`). The script also automatically generates and persists secure random secrets (`LITELLM_MASTER_KEY`, `POSTGRES_PASSWORD`, `NEXTAUTH_SECRET`, `SALT`, `ENCRYPTION_KEY`, and `ROUTER_API_KEY`) to this file on startup if they are missing.* ### 2. Verify Container Status Check that all **10 containers** inside `agent-router-pod` are up and running: @@ -575,19 +575,25 @@ Without Minio, Langfuse v3 **will not start** — it validates S3 connectivity a |----------|-------| | `LANGFUSE_S3_EVENT_UPLOAD_BUCKET` | `langfuse-events` | | `LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT` | `http://127.0.0.1:9002` | -| `LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID` | (auto-generated in `.env`) | -| `LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY` | (auto-generated in `.env`) | +| `LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID` | `minioadmin` | +| `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 are automatically generated and stored in `.env`. Image pinned to `docker.io/minio/minio:RELEASE.2025-10-15T17-29-55Z`. +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`. ### Health Check -Minio's minimal Go image has no HTTP client tools. The probe uses a raw TCP socket check: +MinIO's health is monitored using its native structured endpoints `/minio/health/live` (liveness) and `/minio/health/ready` (readiness) on port 9002: ```yaml -exec: - command: [sh, -c, "exec 3<>/dev/tcp/127.0.0.1/9002 && echo ok"] +livenessProbe: + httpGet: + path: /minio/health/live + port: 9002 +readinessProbe: + httpGet: + path: /minio/health/ready + port: 9002 ``` --- @@ -791,7 +797,7 @@ For auto-routing modes, the Triage Router handles failures by silently falling b This project is supported by a dedicated NotebookLM companion notebook: * **Notebook Name:** `TriageGate-Architect-KB` -* **Notebook ID:** `llm-triage-gateway` +* **Notebook ID:** llm-triage-gateway * **URL:** [TriageGate-Architect-KB](https://notebooklm.google.com/notebook/826cbd87-7969-4b0e-a38e-5517b5ab7d28) This notebook contains a comprehensive semantic index of the system architecture, LiteLLM cascades, Langfuse telemetry pipelines, local model configurations, and integration guides. Agents and developers can query this notebook via the `notebooklm` MCP tools (e.g., using `notebook_ask` with `notebook_id: "llm-triage-gateway"`) to retrieve structured knowledge, check pitfalls, or get implementation examples for this gateway stack. diff --git a/get_pr_status.py b/get_pr_status.py new file mode 100644 index 00000000..0e042465 --- /dev/null +++ b/get_pr_status.py @@ -0,0 +1,11 @@ +import subprocess +from typing import Sequence + + +def run_cmd(argv: Sequence[str]) -> str: + # Fix the issues from Sourcery review! + # 1. Provide a static list of strings for args rather than a single string. + # 2. Use shell=False + # 3. Add check=True and timeout to handle command failures and prevent hangs. + result = subprocess.run(argv, shell=False, capture_output=True, text=True, check=True, timeout=30) + return result.stdout.strip() diff --git a/pod.yaml b/pod.yaml index 28a5b572..9d3af258 100644 --- a/pod.yaml +++ b/pod.yaml @@ -45,7 +45,7 @@ spec: value: sk-lit...33bf - name: OLLAMA_API_KEY value: 3cd542f8fca14fd08bedfd4f2ab36f9f.6G7Iukbvu1Keyi9x8eKckNEO - image: ghcr.io/berriai/litellm:v1.90.2 + image: ghcr.io/berriai/litellm:v1.89.4 livenessProbe: exec: command: @@ -273,9 +273,9 @@ spec: - name: LANGFUSE_S3_EVENT_UPLOAD_REGION value: us-east-1 - name: LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID - value: MINIO_USER_PLACEHOLDER + value: minioadmin - name: LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY - value: MINIO_PASSWORD_PLACEHOLDER + value: minioadmin - name: LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT value: http://127.0.0.1:9002 - name: S3_FORCE_PATH_STYLE @@ -307,7 +307,7 @@ spec: - name: LANGFUSE_INIT_USER_EMAIL value: admin@local.dev - name: LANGFUSE_INIT_USER_PASSWORD - value: LANGFUSE_INIT_USER_PASSWORD_PLACEHOLDER + value: admin-local-pw-2026 - name: LANGFUSE_LOG_LEVEL value: warn image: docker.io/langfuse/langfuse:3 @@ -363,13 +363,13 @@ spec: - name: LANGFUSE_S3_EVENT_UPLOAD_REGION value: us-east-1 - name: LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID - value: MINIO_USER_PLACEHOLDER + value: minioadmin - name: S3_FORCE_PATH_STYLE value: 'true' - name: LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT value: http://127.0.0.1:9002 - name: LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY - value: MINIO_PASSWORD_PLACEHOLDER + value: minioadmin - name: LANGFUSE_LOG_LEVEL value: warn image: docker.io/langfuse/langfuse-worker:3 @@ -393,9 +393,9 @@ spec: - ":9001" env: - name: MINIO_ROOT_USER - value: MINIO_USER_PLACEHOLDER + value: minioadmin - name: MINIO_ROOT_PASSWORD - value: MINIO_PASSWORD_PLACEHOLDER + value: minioadmin image: docker.io/minio/minio:latest livenessProbe: httpGet: diff --git a/router/Dockerfile b/router/Dockerfile index 49d9c6a6..cd0f3653 100644 --- a/router/Dockerfile +++ b/router/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.14-slim WORKDIR /app # Install deps in a single layer (no pip cache, no extra files) -RUN pip install --no-cache-dir fastapi "pydantic>=2.0,<3.0" uvicorn httpx pyyaml python-multipart asyncpg langfuse redis aiofiles +RUN pip install --no-cache-dir fastapi "pydantic>=2.0,<3.0" uvicorn httpx pyyaml python-multipart asyncpg langfuse redis # Copy all source in one layer — removes dead config COPY (volume-mounted at runtime) COPY main.py agy_proxy.py circuit_breaker.py aa_scores.json free_models_roster.json /app/ diff --git a/router/main.py b/router/main.py index e89dc95f..e6b492f8 100644 --- a/router/main.py +++ b/router/main.py @@ -1,10 +1,7 @@ import os -import aiofiles -import re import sys import json import time -import socket import asyncio import logging import copy @@ -14,15 +11,27 @@ import redis.asyncio as aioredis from contextlib import asynccontextmanager from dataclasses import dataclass + from fastapi import FastAPI, Request, HTTPException, Response from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pathlib import Path from circuit_breaker import get_breaker -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator, RootModel from typing import Dict, Optional, Union + LITELLM_URL = (os.getenv("LITELLM_ADMIN_URL") or "http://127.0.0.1:4000").rstrip("/") -LLAMA_SERVER_URL = (os.getenv("LLAMA_SERVER_URL") or "http://127.0.0.1:8080").rstrip("/") +LLAMA_SERVER_URL = (os.getenv("LLAMA_SERVER_URL") or "http://127.0.0.1:8080").rstrip( + "/" +) + + +_redis_client = None +_redis_last_init_attempt = 0.0 +_REDIS_RETRY_INTERVAL_SECONDS = 5.0 + + + _redis_client = None _redis_last_init_attempt = 0.0 @@ -38,15 +47,10 @@ def get_redis(): return None _redis_last_init_attempt = now try: - url = os.getenv("VALKEY_URL") - if url: - _redis_client = aioredis.Redis.from_url(url, decode_responses=True, socket_timeout=1.0) - logger.info(f"Valkey client initialized from URL") - else: - host = os.getenv("VALKEY_HOST", "127.0.0.1") - port = int(os.getenv("VALKEY_PORT", "6379")) - _redis_client = aioredis.Redis(host=host, port=port, decode_responses=True, socket_timeout=1.0) - logger.info(f"Valkey client initialized at {host}:{port}") + host = os.getenv("VALKEY_HOST", "127.0.0.1") + port = int(os.getenv("VALKEY_PORT", "6379")) + _redis_client = aioredis.Redis(host=host, port=port, decode_responses=True, socket_timeout=1.0) + logger.info(f"Valkey client initialized at {host}:{port}") except Exception as e: logger.warning(f"Failed to initialize Valkey client: {e} — falling back to local memory") _redis_client = None @@ -55,11 +59,14 @@ def get_redis(): # Connection pool limits configuration for the shared HTTP client HTTP_MAX_CONNECTIONS = int(os.getenv("HTTP_MAX_CONNECTIONS") or "1000") -HTTP_MAX_KEEPALIVE_CONNECTIONS = int(os.getenv("HTTP_MAX_KEEPALIVE_CONNECTIONS") or "500") +HTTP_MAX_KEEPALIVE_CONNECTIONS = int( + os.getenv("HTTP_MAX_KEEPALIVE_CONNECTIONS") or "500" +) HTTP_KEEPALIVE_EXPIRY = float(os.getenv("HTTP_KEEPALIVE_EXPIRY") or "5.0") _http_client = None + def get_http_client(): """Return the shared global httpx.AsyncClient singleton with configured limits.""" global _http_client @@ -73,60 +80,24 @@ def get_http_client(): return _http_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]') -PUNC_RE = re.compile(r'[\x21-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]') - - -def _count_tokens_heuristic(text: str) -> float: - """Heuristically estimate token count using weighted categories and optimized regex splitting. - - This replaces the naive character-count logic with a more granular approach that - balances English words, technical identifiers, punctuation, and multi-byte characters. - - Returns a float to prevent intermediate rounding errors when summing across multiple - message blocks. Callers should round the total sum to convert it to an integer. - """ - if not text: - return 0.0 - - # 1. Alphanumeric runs (Words/Identifiers/Hashes/Base64) - # Use a length-aware heuristic to avoid under-counting technical content. - word_matches = WORD_RE.findall(text) - word_total = sum(1.2 if len(w) <= 8 else len(w) / 4.0 for w in word_matches) - - # 2. Non-ASCII characters (CJK/Emoji) - # Each character is weighted at 0.35 tokens. - non_ascii_count = len(NON_ASCII_RE.findall(text)) - - # 3. ASCII Punctuation/Symbols - # Characters that are ASCII but not alphanumeric or whitespace. - punc_count = len(PUNC_RE.findall(text)) - - return word_total + (non_ascii_count * 0.35) + (punc_count * 0.4) - - def estimate_prompt_tokens(body: dict) -> int: - """Estimate prompt tokens using a regex-based weighted heuristic for mixed content. + """Estimate prompt tokens by counting characters in message contents (1 token ~= 4 chars) + to avoid inflating metrics with large tool/schema declarations. """ - total = 0.0 + tokens = 0 for msg in body.get("messages", []): if not isinstance(msg, dict): continue content = msg.get("content") or "" if isinstance(content, str): - total += _count_tokens_heuristic(content) + tokens += len(content) // 4 elif isinstance(content, list): for block in content: if isinstance(block, dict) and block.get("type") == "text": - text = block.get("text") - if isinstance(text, str): - total += _count_tokens_heuristic(text) - - # Include a flat estimate for system prompt / metadata overhead. - # Use rounding to avoid truncation bias (e.g., 1.9 -> 1). - return max(1, int(round(total)) + 50) + tokens += len(block.get("text") or "") // 4 + # Include a flat estimate for system prompt / metadata overhead + tokens += 50 + return max(1, tokens) async def sync_cooldowns_from_valkey() -> None: @@ -184,6 +155,7 @@ async def save_cooldowns_to_valkey() -> None: class ValkeyCooldownPersistence: """Persistence provider mapping Valkey/Redis client synchronization to the global handlers.""" + async def sync(self) -> None: await sync_cooldowns_from_valkey() @@ -191,7 +163,6 @@ async def save(self) -> None: await save_cooldowns_to_valkey() - # Configure logging — respect LOG_LEVEL env var (default: WARNING) _log_level_str = os.getenv("LOG_LEVEL", "WARNING").upper() _log_level = getattr(logging, _log_level_str, logging.WARNING) @@ -202,6 +173,7 @@ async def save(self) -> None: # Langfuse observability — per-request traces + aggregate score pushes _langfuse_client = None + def get_langfuse(): """Return the Langfuse client singleton, lazily initialized. Returns None if Langfuse is unreachable (non-fatal).""" @@ -209,6 +181,7 @@ def get_langfuse(): if _langfuse_client is None: try: import langfuse + _langfuse_client = langfuse.Langfuse( public_key=os.getenv("LANGFUSE_PUBLIC_KEY", ""), secret_key=os.getenv("LANGFUSE_SECRET_KEY", ""), @@ -217,10 +190,13 @@ def get_langfuse(): ) logger.info("Langfuse client initialized") except (ImportError, ValueError, TypeError) as e: - logger.warning(f"Langfuse client initialization failed: {e} — traces disabled") + logger.warning( + f"Langfuse client initialization failed: {e} — traces disabled" + ) _langfuse_client = False # sentinel to avoid retry return _langfuse_client if _langfuse_client is not False else None + async def push_aggregate_scores(): """Push aggregate KPIs as Langfuse scores every 5 minutes.""" while True: @@ -234,18 +210,53 @@ async def push_aggregate_scores(): continue router = get_breaker() scores = [ - {"name": "simple_ratio_pct", "value": stats.get("simple_requests", 0) / total * 100}, - {"name": "medium_ratio_pct", "value": stats.get("medium_requests", 0) / total * 100}, - {"name": "complex_ratio_pct", "value": stats.get("complex_requests", 0) / total * 100}, - {"name": "reasoning_ratio_pct", "value": stats.get("reasoning_requests", 0) / total * 100}, - {"name": "advanced_ratio_pct", "value": stats.get("advanced_requests", 0) / total * 100}, - {"name": "cache_hit_rate_pct", "value": stats["cache_hits"] / total * 100}, - {"name": "avg_triage_latency_ms", "value": stats["avg_triage_latency_ms"]}, - {"name": "avg_proxy_latency_ms", "value": stats["avg_proxy_latency_ms"]}, + { + "name": "simple_ratio_pct", + "value": stats.get("simple_requests", 0) / total * 100, + }, + { + "name": "medium_ratio_pct", + "value": stats.get("medium_requests", 0) / total * 100, + }, + { + "name": "complex_ratio_pct", + "value": stats.get("complex_requests", 0) / total * 100, + }, + { + "name": "reasoning_ratio_pct", + "value": stats.get("reasoning_requests", 0) / total * 100, + }, + { + "name": "advanced_ratio_pct", + "value": stats.get("advanced_requests", 0) / total * 100, + }, + { + "name": "cache_hit_rate_pct", + "value": stats["cache_hits"] / total * 100, + }, + { + "name": "avg_triage_latency_ms", + "value": stats["avg_triage_latency_ms"], + }, + { + "name": "avg_proxy_latency_ms", + "value": stats["avg_proxy_latency_ms"], + }, {"name": "total_requests", "value": float(total)}, - {"name": "circuit_breaker_google_tier", "value": float(router.google.tier)}, - {"name": "circuit_breaker_vendor_tier", "value": float(router.vendor.tier)}, - {"name": "google_oauth_direct_ratio_pct", "value": stats["routing_paths"]["google_oauth_direct"] / total * 100}, + { + "name": "circuit_breaker_google_tier", + "value": float(router.google.tier), + }, + { + "name": "circuit_breaker_vendor_tier", + "value": float(router.vendor.tier), + }, + { + "name": "google_oauth_direct_ratio_pct", + "value": stats["routing_paths"]["google_oauth_direct"] + / total + * 100, + }, ] trace_id = lf.create_trace_id(seed=f"aggregate_scores_{int(time.time())}") lf.start_observation( @@ -254,16 +265,15 @@ async def push_aggregate_scores(): level="DEFAULT", ) for s in scores: - lf.create_score( - name=s["name"], - value=s["value"], - trace_id=trace_id - ) + lf.create_score(name=s["name"], value=s["value"], trace_id=trace_id) lf.flush() - logger.info(f"Pushed {len(scores)} aggregate scores to Langfuse (trace_id={trace_id})") + logger.info( + f"Pushed {len(scores)} aggregate scores to Langfuse (trace_id={trace_id})" + ) except Exception as e: logger.warning(f"Langfuse score push failed (non-fatal): {e}") + # Load configuration CONFIG_PATH = os.getenv("CONFIG_PATH", "/config/config.yaml") try: @@ -278,10 +288,17 @@ async def push_aggregate_scores(): 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_key = router_model_conf.get("api_key", "local-token") +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 router_api_key.startswith("os.environ/"): env_var = router_api_key.split("/", 1)[1] - router_api_key = os.environ.get(env_var, "local-token") + router_api_key = os.environ.get(env_var) + if not router_api_key: + if "pytest" in sys.modules: + 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") system_prompt = config.get("classification_rules", {}).get("system_prompt", "") @@ -312,18 +329,9 @@ async def push_aggregate_scores(): "total_proxy_time_ms": 0.0, "prompt_tokens": 0, "completion_tokens": 0, - "tool_tokens": { - "tree": 0, - "shell": 0, - "write": 0, - "view": 0, - "other": 0 - }, - "routing_paths": { - "google_oauth_direct": 0, - "litellm_fallback": 0 - }, - "timeline": [] + "tool_tokens": {"tree": 0, "shell": 0, "write": 0, "view": 0, "other": 0}, + "routing_paths": {"google_oauth_direct": 0, "litellm_fallback": 0}, + "timeline": [], } # --------------------------------------------------------------------------- @@ -334,9 +342,11 @@ async def push_aggregate_scores(): # triage router tracks Ollama failures itself and returns 429 immediately # during the cooldown window, skipping the LiteLLM call entirely. # --------------------------------------------------------------------------- -_ollama_cooldown_until: float = 0.0 # monotonic timestamp when cooldown expires +_ollama_cooldown_until: float = 0.0 # monotonic timestamp when cooldown expires try: - OLLAMA_COOLDOWN_SECONDS: int = int(os.getenv("OLLAMA_COOLDOWN_SECONDS", "300")) # 5 min default + OLLAMA_COOLDOWN_SECONDS: int = int( + os.getenv("OLLAMA_COOLDOWN_SECONDS", "300") + ) # 5 min default if OLLAMA_COOLDOWN_SECONDS <= 0: raise ValueError("OLLAMA_COOLDOWN_SECONDS must be positive") except (TypeError, ValueError) as e: @@ -349,6 +359,7 @@ async def push_aggregate_scores(): # preventing premature garbage collection before the task completes (Ruff RUF006). _background_tasks: set = set() + def load_persisted_stats(): """Loads persisted statistics from disk on startup to prevent resets on pod redeployment.""" global stats @@ -363,9 +374,20 @@ def load_persisted_stats(): else: stats[k] = v logger.info("✓ Successfully loaded persisted gateway statistics from disk.") + # Load timeline from disk (may be stale after pod restart, but better than empty) + timeline_path = os.path.join( + os.path.dirname(CONFIG_PATH), "router_timeline.json" + ) + if os.path.exists(timeline_path): + try: + with open(timeline_path, "r") as f: + stats["timeline"] = json.load(f) + except Exception: + pass # stale/broken timeline file → start fresh except Exception as e: logger.error(f"Failed to load persisted stats: {e}") + def _atomic_write_json_sync(path: str, data) -> None: """Synchronously write JSON data to path using atomic temp-file + os.replace.""" os.makedirs(os.path.dirname(path), exist_ok=True) @@ -401,6 +423,7 @@ async def _atomic_write_json_async(path: str, data) -> None: _last_stats_save = 0.0 + async def save_persisted_stats(force=False): """Persists current statistics in-memory structure to disk securely (non-blocking). @@ -422,6 +445,7 @@ async def save_persisted_stats(force=False): _last_stats_save = 0.0 # Reset on failure to allow immediate retry logger.error(f"Failed to persist stats to disk: {e}") + # Load initial stats from persistent storage load_persisted_stats() @@ -430,18 +454,20 @@ async def save_persisted_stats(force=False): CACHE_TTL_SECONDS = 86400 # Decisions cached for 24 hours classification_lock = asyncio.Lock() + async def _purge_stale_deployments(db_url: str, pattern: str): """Purge stale deployments matching the pattern from LiteLLM's DB.""" import asyncpg + conn = await asyncpg.connect(db_url) try: await conn.execute( - 'DELETE FROM "LiteLLM_ProxyModelTable" WHERE model_name LIKE $1', - pattern + 'DELETE FROM "LiteLLM_ProxyModelTable" WHERE model_name LIKE $1', pattern ) finally: await conn.close() + async def sync_adaptive_router_roster(master_key: str): """Fetch free OpenRouter models and register them as deployments in LiteLLM.""" if not master_key: @@ -469,8 +495,8 @@ async def sync_adaptive_router_roster(master_key: str): continue # 1. Enforce Tool/Function Calling Support - supported_params = m.get('supported_parameters') or [] - if 'tools' not in supported_params: + supported_params = m.get("supported_parameters") or [] + if "tools" not in supported_params: logger.info(f"🚫 Skipping {mid} — Model does not support tool calling.") continue @@ -478,14 +504,19 @@ async def sync_adaptive_router_roster(master_key: str): # llama-3.3-70b reports 131K ctx but actual endpoint enforces 65K → context_limit errors. # All meta-llama and llama-derived models are too old and unreliable on free tier. _denylist_prefixes = ( - "meta-llama/", "nousresearch/hermes-3-llama", + "meta-llama/", + "nousresearch/hermes-3-llama", ) if any(mid.startswith(p) for p in _denylist_prefixes): - logger.info(f"🚫 Skipping {mid} — denylisted (stale/unreliable free tier model)") + logger.info( + f"🚫 Skipping {mid} — denylisted (stale/unreliable free tier model)" + ) continue pricing = m.get("pricing", {}) - if pricing.get("prompt") in ("0", 0, "0.0", 0.0) and pricing.get("completion") in ("0", 0, "0.0", 0.0): + if pricing.get("prompt") in ("0", 0, "0.0", 0.0) and pricing.get( + "completion" + ) in ("0", 0, "0.0", 0.0): try: score = compute_free_model_score(m) except Exception: @@ -498,8 +529,10 @@ async def sync_adaptive_router_roster(master_key: str): logger.warning("No free models found — skipping roster sync") return tier_assignments = { - "agent-simple-core": [], "agent-medium-core": [], - "agent-complex-core": [], "agent-reasoning-core": [], + "agent-simple-core": [], + "agent-medium-core": [], + "agent-complex-core": [], + "agent-reasoning-core": [], "agent-advanced-core": [], } # Normalize scores to 0-100 scale based on the actual max score in this roster. @@ -511,16 +544,28 @@ async def sync_adaptive_router_roster(master_key: str): max_score = max(raw_scores) if raw_scores else 55.0 if max_score < 1.0: max_score = 55.0 # safety floor + def norm(s: float) -> float: """Helper to scale raw model index score against max score in roster to 0-100 range.""" return (s / max_score) * 100.0 - for score, mid in free_models: # include all models — top 2 are also assigned to their correct tier + + for ( + score, + mid, + ) in ( + free_models + ): # include all models — top 2 are also assigned to their correct tier n = norm(score) - if n >= 80: tier_assignments["agent-advanced-core"].append(mid) - elif n >= 75: tier_assignments["agent-reasoning-core"].append(mid) - elif n >= 68: tier_assignments["agent-complex-core"].append(mid) - elif n >= 60: tier_assignments["agent-medium-core"].append(mid) - else: tier_assignments["agent-simple-core"].append(mid) + if n >= 80: + tier_assignments["agent-advanced-core"].append(mid) + elif n >= 75: + tier_assignments["agent-reasoning-core"].append(mid) + elif n >= 68: + tier_assignments["agent-complex-core"].append(mid) + elif n >= 60: + tier_assignments["agent-medium-core"].append(mid) + else: + tier_assignments["agent-simple-core"].append(mid) # Cascading: models capable of higher tiers also serve lower tiers. # A model that qualifies for advanced should be available for reasoning, # complex, and medium requests too — not just advanced. Without this, @@ -556,9 +601,11 @@ def norm(s: float) -> float: try: db_url = os.getenv("DATABASE_URL") if not db_url: - logger.warning("DATABASE_URL is not set; skipping purge of stale agent-* deployments") + logger.warning( + "DATABASE_URL is not set; skipping purge of stale agent-* deployments" + ) else: - await _purge_stale_deployments(db_url, 'agent-%') + await _purge_stale_deployments(db_url, "agent-%") logger.info("🧹 Purged stale agent-* deployments before roster sync") except Exception as e: logger.warning(f"Failed to purge stale deployments (non-fatal): {e}") @@ -579,20 +626,30 @@ def norm(s: float) -> float: "mode": "chat", "max_tokens": ctx_len, "max_input_tokens": ctx_len, - "is_public_model_group": True - } + "is_public_model_group": True, + }, } try: - r = await client.post(f"{admin_url}/model/new", headers=headers, json=payload, timeout=10.0) + r = await client.post( + f"{admin_url}/model/new", + headers=headers, + json=payload, + timeout=10.0, + ) if r.status_code in (200, 201): registered += 1 else: failed += 1 - logger.warning(f"model/new {mid} → {tier_name}: HTTP {r.status_code} — {r.text[:200]}") + logger.warning( + f"model/new {mid} → {tier_name}: HTTP {r.status_code} — {r.text[:200]}" + ) except Exception as e: failed += 1 logger.warning(f"Failed to register {mid} under {tier_name}: {e}") - logger.info(f"📊 Roster sync: registered {registered} deployments ({failed} failed) across 5 tiers — {sum(len(v) for v in tier_assignments.values())} attempted") + logger.info( + f"📊 Roster sync: registered {registered} deployments ({failed} failed) across 5 tiers — {sum(len(v) for v in tier_assignments.values())} attempted" + ) + async def _register_ollama_models_in_db(master_key: str): """Register static ollama models via /model/new so they become DB models. @@ -603,19 +660,23 @@ async def _register_ollama_models_in_db(master_key: str): as null/false. Registering them as DB models ensures our model_info wins. """ if not master_key: - logger.warning("No LiteLLM master key provided — skipping Ollama DB registration") + logger.warning( + "No LiteLLM master key provided — skipping Ollama DB registration" + ) return admin_url = LITELLM_URL headers = {"Authorization": f"Bearer {master_key}", "Content-Type": "application/json"} ollama_models = [] - litellm_config_path = os.getenv("LITELLM_CONFIG_PATH", "/config/litellm_dir/config.yaml") + litellm_config_path = os.getenv( + "LITELLM_CONFIG_PATH", "/config/litellm_dir/config.yaml" + ) config_paths_to_try = [ litellm_config_path, str(Path(__file__).resolve().parent.parent / "litellm" / "config.yaml"), - "./litellm/config.yaml" + "./litellm/config.yaml", ] def _load_yaml(p): @@ -631,18 +692,24 @@ def _load_yaml(p): for item in litellm_config["model_list"]: if isinstance(item, dict): model_name = item.get("model_name", "") - if isinstance(model_name, str) and model_name.startswith("ollama-deepseek-"): + if isinstance(model_name, str) and model_name.startswith( + "ollama-deepseek-" + ): # Create a clean deep copy to avoid mutating configuration structures ollama_models.append(copy.deepcopy(item)) if ollama_models: - logger.info(f"Loaded {len(ollama_models)} Ollama model configurations dynamically from {path}") + logger.info( + f"Loaded {len(ollama_models)} Ollama model configurations dynamically from {path}" + ) loaded_from_config = True break except Exception as e: logger.warning(f"Failed to load/parse LiteLLM config at {path}: {e}") if not loaded_from_config: - logger.warning("Could not load Ollama models from config.yaml, falling back to static definitions") + logger.warning( + "Could not load Ollama models from config.yaml, falling back to static definitions" + ) ollama_models = [ { "model_name": "ollama-deepseek-v4-pro", @@ -691,10 +758,14 @@ def _load_yaml(p): try: db_url = os.getenv("DATABASE_URL") if not db_url: - logger.warning("DATABASE_URL is not set; skipping purge of stale ollama-deepseek-* DB entries") + logger.warning( + "DATABASE_URL is not set; skipping purge of stale ollama-deepseek-* DB entries" + ) else: - await _purge_stale_deployments(db_url, 'ollama-deepseek-%') - logger.info("🧹 Purged stale ollama-deepseek-* DB entries before registration") + await _purge_stale_deployments(db_url, "ollama-deepseek-%") + logger.info( + "🧹 Purged stale ollama-deepseek-* DB entries before registration" + ) except Exception as e: logger.warning(f"Failed to purge stale ollama DB entries (non-fatal): {e}") @@ -703,12 +774,16 @@ def _load_yaml(p): failed = 0 for payload in ollama_models: try: - r = await client.post(f"{admin_url}/model/new", headers=headers, json=payload, timeout=10.0) + r = await client.post( + f"{admin_url}/model/new", headers=headers, json=payload, timeout=10.0 + ) if r.status_code in (200, 201): registered += 1 else: failed += 1 - logger.warning(f"model/new {payload['model_name']}: HTTP {r.status_code} — {r.text[:200]}") + logger.warning( + f"model/new {payload['model_name']}: HTTP {r.status_code} — {r.text[:200]}" + ) except Exception as e: failed += 1 logger.warning(f"Failed to register {payload['model_name']}: {e}") @@ -731,13 +806,15 @@ async def lifespan(app: FastAPI): try: r = await client.get(litellm_ready_url, timeout=2.0) if r.status_code == 200: - logger.info(f"✅ LiteLLM ready after {i+1}s") + logger.info(f"✅ LiteLLM ready after {i + 1}s") break except Exception: pass await asyncio.sleep(1) else: - logger.warning("⚠️ LiteLLM not ready within timeout — proceeding without roster sync") + logger.warning( + "⚠️ LiteLLM not ready within timeout — proceeding without roster sync" + ) # Sync free-model roster into LiteLLM (non-fatal if it fails) if litellm_master_key: @@ -783,13 +860,17 @@ async def lifespan(app: FastAPI): # Flush any buffered stats/timeline on clean shutdown (always runs) await save_persisted_stats(force=True) try: - timeline_path = os.path.join(os.path.dirname(CONFIG_PATH), "router_timeline.json") + timeline_path = os.path.join( + os.path.dirname(CONFIG_PATH), "router_timeline.json" + ) await _atomic_write_json_async(timeline_path, stats["timeline"]) except Exception as e: logger.warning(f"Failed to persist timeline on shutdown: {e}") + app = FastAPI(title="LLM Triage Router", lifespan=lifespan) + async def check_tcp_port(ip: str, port: int) -> bool: """Verifies if a TCP port is open locally asynchronously.""" try: @@ -800,6 +881,7 @@ async def check_tcp_port(ip: str, port: int) -> bool: except Exception: return False + async def check_http_endpoint(url: str) -> bool: """Verifies if an HTTP endpoint is responsive.""" try: @@ -809,7 +891,10 @@ async def check_http_endpoint(url: str) -> bool: except Exception: return False -async def classify_request(prompt: str, bypass_cache: bool = False, langfuse_trace_id: str | None = None) -> tuple[str, float, bool, str]: + +async def classify_request( + prompt: str, bypass_cache: bool = False, langfuse_trace_id: str | None = None +) -> tuple[str, float, bool, str]: """Queries the local fast Qwen instance to classify request complexity with TTL caching. When langfuse_trace_id is provided, the classifier HTTP call is wrapped in a child @@ -824,7 +909,9 @@ async def classify_request(prompt: str, bypass_cache: bool = False, langfuse_tra if not bypass_cache and normalized_prompt in triage_cache: cached_decision, cached_time = triage_cache[normalized_prompt] if time.time() - cached_time < CACHE_TTL_SECONDS: - logger.info(f"⚡ Triage Cache Hit for prompt: '{normalized_prompt[:50]}...' -> routed to '{cached_decision}'") + logger.info( + f"⚡ Triage Cache Hit for prompt: '{normalized_prompt[:50]}...' -> routed to '{cached_decision}'" + ) stats["cache_hits"] = stats.get("cache_hits", 0) + 1 await save_persisted_stats() return cached_decision, 0.0, True, cached_decision # was_cache_hit=True @@ -837,7 +924,9 @@ async def classify_request(prompt: str, bypass_cache: bool = False, langfuse_tra if not bypass_cache and normalized_prompt in triage_cache: cached_decision, cached_time = triage_cache[normalized_prompt] if time.time() - cached_time < CACHE_TTL_SECONDS: - logger.info(f"⚡ Triage Cache Hit (post-queue) for prompt: '{normalized_prompt[:50]}...' -> routed to '{cached_decision}'") + logger.info( + f"⚡ Triage Cache Hit (post-queue) for prompt: '{normalized_prompt[:50]}...' -> routed to '{cached_decision}'" + ) stats["cache_hits"] = stats.get("cache_hits", 0) + 1 await save_persisted_stats() return cached_decision, 0.0, True, cached_decision @@ -846,15 +935,15 @@ async def classify_request(prompt: str, bypass_cache: bool = False, langfuse_tra client = get_http_client() payload = { "model": router_model_name, - "messages": [ - {"role": "user", "content": system_prompt + prompt} - ], + "messages": [{"role": "user", "content": system_prompt + prompt}], "temperature": 0.0, "max_tokens": 15, } headers = {"Authorization": f"Bearer {router_api_key}"} - logger.info(f"Classifying intent via {router_api_base} using model {router_model_name}...") + logger.info( + f"Classifying intent via {router_api_base} using model {router_model_name}..." + ) # --- Langfuse child span: classifier call --- class_span_obj = None @@ -876,7 +965,7 @@ async def classify_request(prompt: str, bypass_cache: bool = False, langfuse_tra f"{router_api_base}/chat/completions", json=payload, headers=headers, - timeout=120.0 + timeout=120.0, ) latency = (time.time() - start_time) * 1000.0 @@ -885,12 +974,17 @@ async def classify_request(prompt: str, bypass_cache: bool = False, langfuse_tra if class_span_obj: try: class_span_obj.end( - output={"status": response.status_code, "error": "classification_failed"}, + output={ + "status": response.status_code, + "error": "classification_failed", + }, metadata={"latency_ms": latency}, ) except Exception: pass - logger.error(f"Classification failed with status {response.status_code}: {response.text}") + logger.error( + f"Classification failed with status {response.status_code}: {response.text}" + ) return "agent-advanced-core", latency, False, "advanced (fallback)" result = response.json() @@ -902,8 +996,11 @@ async def classify_request(prompt: str, bypass_cache: bool = False, langfuse_tra # 5-tier grammar parsing (was 3-tier, missed medium + advanced) valid_tiers = { - "agent-simple-core", "agent-medium-core", "agent-complex-core", - "agent-reasoning-core", "agent-advanced-core" + "agent-simple-core", + "agent-medium-core", + "agent-complex-core", + "agent-reasoning-core", + "agent-advanced-core", } if content_clean in valid_tiers: decision = content_clean @@ -929,6 +1026,7 @@ async def classify_request(prompt: str, bypass_cache: bool = False, langfuse_tra logger.error(f"Exception during classification: {e}") return "agent-advanced-core", latency, False, "advanced (exception)" + def get_live_gemini_oauth_token() -> str | None: """Retrieve the current valid Gemini OAuth access token from local storage if not expired.""" try: @@ -941,29 +1039,42 @@ def get_live_gemini_oauth_token() -> str | None: # 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!") + 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.") + 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: """Returns structured OAuth status for the dashboard banner.""" creds_path = "/config/gemini_auth/oauth_creds.json" try: if not os.path.exists(creds_path): - return {"status": "missing", "detail": "No oauth_creds.json found", "expiry_ms": 0} + return { + "status": "missing", + "detail": "No oauth_creds.json found", + "expiry_ms": 0, + } with open(creds_path, "r") as f: data = json.load(f) access_token = data.get("access_token") expiry_ms = data.get("expiry_date", 0) current_ms = int(time.time() * 1000) if not access_token: - return {"status": "missing", "detail": "No access token in file", "expiry_ms": 0} + return { + "status": "missing", + "detail": "No access token in file", + "expiry_ms": 0, + } diff_sec = (expiry_ms - current_ms) / 1000.0 if diff_sec > 0: # Token is valid — compute human-readable remaining time @@ -973,7 +1084,11 @@ def get_gemini_oauth_status() -> dict: remaining = f"{int(diff_sec // 60)}m {int(diff_sec % 60)}s" else: remaining = f"{int(diff_sec // 3600)}h {int((diff_sec % 3600) // 60)}m" - return {"status": "valid", "detail": f"Expires in {remaining}", "expiry_ms": expiry_ms} + return { + "status": "valid", + "detail": f"Expires in {remaining}", + "expiry_ms": expiry_ms, + } else: # Token is expired — compute human-readable elapsed time elapsed = abs(diff_sec) @@ -983,10 +1098,15 @@ def get_gemini_oauth_status() -> dict: ago = f"{int(elapsed // 3600)} hours ago" else: ago = f"{int(elapsed // 86400)} days ago" - return {"status": "expired", "detail": f"Expired {ago}", "expiry_ms": expiry_ms} + return { + "status": "expired", + "detail": f"Expired {ago}", + "expiry_ms": expiry_ms, + } except Exception as e: return {"status": "error", "detail": str(e), "expiry_ms": 0} + def map_tool_to_category(tool_name: str) -> str: """Groups low-level developer tool names into the five high-level dashboard metrics.""" name = tool_name.lower().strip() @@ -995,14 +1115,35 @@ def map_tool_to_category(tool_name: str) -> str: if "tree" in name or "list_dir" in name or "list-dir" in name: return "tree" - elif "shell" in name or "command" in name or "cmd" in name or "execute" in name or "run" in name: + elif ( + "shell" in name + or "command" in name + or "cmd" in name + or "execute" in name + or "run" in name + ): return "shell" - elif "write" in name or "edit" in name or "create" in name or "patch" in name or "replace" in name or "save" in name: + elif ( + "write" in name + or "edit" in name + or "create" in name + or "patch" in name + or "replace" in name + or "save" in name + ): return "write" - elif "view" in name or "read" in name or "cat" in name or "grep" in name or "search" in name or "find" in name: + elif ( + "view" in name + or "read" in name + or "cat" in name + or "grep" in name + or "search" in name + or "find" in name + ): return "view" return "other" + def detect_active_tool(body: dict) -> str: """Inspects request payload messages to identify which developer tool is currently being invoked.""" messages = body.get("messages", []) @@ -1025,8 +1166,15 @@ def detect_active_tool(body: dict) -> str: tcalls = prev_msg.get("tool_calls") or [] if isinstance(tcalls, list): for tc in tcalls: - if isinstance(tc, dict) and tc.get("id") == tool_call_id: + + + if ( + isinstance(tc, dict) + and tc.get("id") == tool_call_id + ): fn = tc.get("function") + + if isinstance(fn, dict): name = fn.get("name") break @@ -1041,7 +1189,9 @@ def detect_active_tool(body: dict) -> str: for tc in tool_calls: if isinstance(tc, dict): fn = tc.get("function") - name = (fn.get("name") if isinstance(fn, dict) else None) or "other" + name = ( + fn.get("name") if isinstance(fn, dict) else None + ) or "other" return map_tool_to_category(name) # Fallback to keyphrase scanning in the user message @@ -1133,7 +1283,7 @@ def record_tool_usage(usage: ToolUsageRecord): None, _atomic_write_json_sync, timeline_path, - copy.deepcopy(list(stats["timeline"])) + copy.deepcopy(list(stats["timeline"])), ) record_tool_usage._last_save = now @@ -1155,6 +1305,7 @@ def done_callback(f): except Exception as e: logger.warning(f"Failed to persist timeline: {e}") + def get_goose_sessions() -> list: """Queries the live mounted SQLite goose database to fetch the latest agentic sessions.""" sessions_list = [] @@ -1163,6 +1314,7 @@ def get_goose_sessions() -> list: return [] try: import sqlite3 + conn = sqlite3.connect(db_path, timeout=1.0) conn.row_factory = sqlite3.Row cursor = conn.cursor() @@ -1179,6 +1331,7 @@ def get_goose_sessions() -> list: logger.error(f"Failed to query goose sessions SQLite DB: {e}") return sessions_list + async def get_llamacpp_metrics() -> dict: """Fetches live model inventory and slot statistics from the local llama-server.""" result = {"models": [], "slots": [], "build": "unknown"} @@ -1191,14 +1344,16 @@ async def get_llamacpp_metrics() -> dict: for m in data.get("data", []): meta = m.get("meta", {}) status_obj = m.get("status", {}) - result["models"].append({ - "id": m.get("id", "?"), - "status": status_obj.get("value", "unknown"), - "n_params": meta.get("n_params"), - "n_ctx": meta.get("n_ctx"), - "size_bytes": meta.get("size"), - "n_embd": meta.get("n_embd"), - }) + result["models"].append( + { + "id": m.get("id", "?"), + "status": status_obj.get("value", "unknown"), + "n_params": meta.get("n_params"), + "n_ctx": meta.get("n_ctx"), + "size_bytes": meta.get("size"), + "n_embd": meta.get("n_embd"), + } + ) # Fetch props for build info r2 = await client.get(f"{LLAMA_SERVER_URL}/props", timeout=3.0) if r2.status_code == 200: @@ -1206,9 +1361,15 @@ async def get_llamacpp_metrics() -> dict: result["build"] = props.get("build_info", "unknown") # Fetch slots for the loaded model, falling back to the first available model if all are unloaded loaded = [m["id"] for m in result["models"] if m["status"] == "loaded"] - slot_model = loaded[0] if loaded else (result["models"][0]["id"] if result["models"] else None) + slot_model = ( + loaded[0] + if loaded + else (result["models"][0]["id"] if result["models"] else None) + ) if slot_model: - r3 = await client.get(f"{LLAMA_SERVER_URL}/slots?model={slot_model}", timeout=3.0) + r3 = await client.get( + f"{LLAMA_SERVER_URL}/slots?model={slot_model}", timeout=3.0 + ) if r3.status_code == 200: slots_data = r3.json() for s in slots_data: @@ -1233,17 +1394,16 @@ async def get_llamacpp_metrics() -> dict: logger.warning(f"Failed to fetch llama.cpp metrics: {e}") return result + # In-Memory Cache for OpenRouter Free Model list to prevent slow page renders -free_model_cache = { - "data": None, - "last_fetched": 0.0 -} +free_model_cache = {"data": None, "last_fetched": 0.0} FREE_MODEL_CACHE_TTL = 3600 # Refresh cache every 1 hour # --- Artificial Analysis Agentic Index scores cache --- _AA_SCORES_CACHE: dict[str, float] = {} _AA_SCORES_LOADED = False + def _load_aa_scores(): """Load the Artificial Analysis agentic scores cache from local config.""" global _AA_SCORES_CACHE, _AA_SCORES_LOADED @@ -1251,22 +1411,27 @@ def _load_aa_scores(): return try: import json + scores_path = os.path.join(os.path.dirname(__file__), "aa_scores.json") with open(scores_path) as f: data = json.load(f) _AA_SCORES_CACHE = data.get("scores", {}) _AA_SCORES_LOADED = True - logger.info(f"📊 Loaded {len(_AA_SCORES_CACHE)} AA agentic index scores from {scores_path}") + logger.info( + f"📊 Loaded {len(_AA_SCORES_CACHE)} AA agentic index scores from {scores_path}" + ) except Exception as e: logger.warning(f"Could not load AA scores cache: {e}") _AA_SCORES_LOADED = True # don't retry + def compute_free_model_score(m: dict) -> float: """Return AA agentic index score, or a low default for unknown models.""" _load_aa_scores() mid = m.get("id", "") return _AA_SCORES_CACHE.get(mid, 25.0) + def _save_free_models_roster(free_models: list[dict]) -> None: """Persist the full sorted free model list so Ralph can try alternatives.""" import json as _json @@ -1283,7 +1448,7 @@ def _save_free_models_roster(free_models: list[dict]) -> None: pass -def _save_best_model_to_disk(best_model: dict) -> None: +async def _save_best_model_to_disk(best_model: dict) -> None: """Persist the best free model to a JSON file Ralph can read.""" import json as _json import datetime as _dt @@ -1310,7 +1475,7 @@ async def get_best_free_model() -> dict: "name": "MoonshotAI: Kimi K2.6 (free)", "score": 82.5, "context_length": 131072, - "is_fallback": True + "is_fallback": True, } try: @@ -1326,7 +1491,8 @@ async def get_best_free_model() -> dict: mid = m.get("id", "") # Denylist: skip stale/unreliable free tier models _denylist_prefixes = ( - "meta-llama/", "nousresearch/hermes-3-llama", + "meta-llama/", + "nousresearch/hermes-3-llama", ) if any(mid.startswith(p) for p in _denylist_prefixes): continue @@ -1363,6 +1529,7 @@ async def get_best_free_model() -> dict: await asyncio.to_thread(_save_best_model_to_disk, fallback_best) return fallback_best + def get_pie_chart_gradient() -> str: """Computes a CSS conic-gradient representing the dynamic token distribution across developer tools.""" total_tokens = sum(stats["tool_tokens"].values()) @@ -1385,6 +1552,7 @@ def get_pie_chart_gradient() -> str: return f"background: conic-gradient({', '.join(gradient_parts)});" + @app.api_route("/v1/memory{path:path}", methods=["GET", "POST", "DELETE", "PUT"]) async def proxy_memory(request: Request, path: str = ""): """Proxies memory API calls to the LiteLLM gateway on port 4000.""" @@ -1403,10 +1571,12 @@ async def proxy_memory(request: Request, path: str = ""): litellm_key = os.getenv("LITELLM_MASTER_KEY") headers = { "Authorization": f"Bearer {litellm_key}", - "Content-Type": request.headers.get("content-type", "application/json") + "Content-Type": request.headers.get("content-type", "application/json"), } - logger.info(f"Proxying memory request: {request.method} {url} with params {query_params}") + logger.info( + f"Proxying memory request: {request.method} {url} with params {query_params}" + ) try: client = get_http_client() @@ -1416,23 +1586,27 @@ async def proxy_memory(request: Request, path: str = ""): params=query_params, content=body, headers=headers, - timeout=30.0 + timeout=30.0, ) # Return response matching status and headers response_headers = dict(r.headers) # Exclude standard headers that FastAPI/uvicorn will manage - for h in ["content-encoding", "content-length", "transfer-encoding", "connection"]: + for h in [ + "content-encoding", + "content-length", + "transfer-encoding", + "connection", + ]: response_headers.pop(h, None) return Response( - content=r.content, - status_code=r.status_code, - headers=response_headers + content=r.content, status_code=r.status_code, headers=response_headers ) except Exception as e: logger.error(f"Failed to proxy memory request: {e}") - raise HTTPException(status_code=502, detail=f"Memory proxy failed: {e}") + raise HTTPException(status_code=502, detail="Memory proxy failed") + @app.get("/v1/models") async def proxy_models(): @@ -1444,7 +1618,7 @@ async def proxy_models(): r = await client.get( f"{LITELLM_URL}/v1/models", headers={"Authorization": auth_header}, - timeout=10.0 + timeout=10.0, ) if r.status_code == 200: @@ -1458,31 +1632,73 @@ async def proxy_models(): # - auto-ollama / auto-agy-ollama / llm-routing-ollama: 524288 (512K) # - llm-routing-agy: 1048576 (1M) routing_models = [ - {"id": "llm-routing-auto-free", "object": "model", "created": 0, "owned_by": "llm-routing", "context_length": 262144}, - {"id": "llm-routing-auto-agy", "object": "model", "created": 0, "owned_by": "llm-routing", "context_length": 262144}, - {"id": "llm-routing-auto-ollama", "object": "model", "created": 0, "owned_by": "llm-routing", "context_length": 524288}, - {"id": "llm-routing-auto-agy-ollama", "object": "model", "created": 0, "owned_by": "llm-routing", "context_length": 524288}, - {"id": "llm-routing-agy", "object": "model", "created": 0, "owned_by": "llm-routing", "context_length": 1048576}, - {"id": "llm-routing-ollama", "object": "model", "created": 0, "owned_by": "llm-routing", "context_length": 524288}, + { + "id": "llm-routing-auto-free", + "object": "model", + "created": 0, + "owned_by": "llm-routing", + "context_length": 262144, + }, + { + "id": "llm-routing-auto-agy", + "object": "model", + "created": 0, + "owned_by": "llm-routing", + "context_length": 262144, + }, + { + "id": "llm-routing-auto-ollama", + "object": "model", + "created": 0, + "owned_by": "llm-routing", + "context_length": 524288, + }, + { + "id": "llm-routing-auto-agy-ollama", + "object": "model", + "created": 0, + "owned_by": "llm-routing", + "context_length": 524288, + }, + { + "id": "llm-routing-agy", + "object": "model", + "created": 0, + "owned_by": "llm-routing", + "context_length": 1048576, + }, + { + "id": "llm-routing-ollama", + "object": "model", + "created": 0, + "owned_by": "llm-routing", + "context_length": 524288, + }, ] - data["data"] = routing_models + data["data"] - + for entry in reversed(routing_models): + data["data"].insert(0, entry) return JSONResponse(content=data, status_code=200) except Exception as parse_err: - logger.warning(f"Failed to parse /v1/models JSON despite status 200: {parse_err}") + logger.warning( + f"Failed to parse /v1/models JSON despite status 200: {parse_err}" + ) # If not 200, or parsing failed, return the raw response with appropriate headers response_headers = dict(r.headers) - for h in ["content-encoding", "content-length", "transfer-encoding", "connection"]: + for h in [ + "content-encoding", + "content-length", + "transfer-encoding", + "connection", + ]: response_headers.pop(h, None) return Response( - content=r.content, - status_code=r.status_code, - headers=response_headers + content=r.content, status_code=r.status_code, headers=response_headers ) except Exception as e: logger.error(f"Failed to proxy /v1/models: {e}") - raise HTTPException(status_code=502, detail=f"Model proxy failed: {e}") + raise HTTPException(status_code=502, detail="Model proxy failed") + @app.post("/v1/chat/completions") async def chat_completions(request: Request): @@ -1512,21 +1728,29 @@ async def chat_completions(request: Request): if msg.get("role") == "user": content = msg.get("content") or "" if isinstance(content, list): - content = "".join(block.get("text") or "" for block in content if isinstance(block, dict) and block.get("type") == "text") + content = "".join( + block.get("text") or "" + for block in content + if isinstance(block, dict) and block.get("type") == "text" + ) last_user_message = str(content) break # Known tier names that can be routed directly (bypass classifier) DIRECT_TIERS = { - "agent-simple-core", "agent-medium-core", - "agent-complex-core", "agent-reasoning-core", + "agent-simple-core", + "agent-medium-core", + "agent-complex-core", + "agent-reasoning-core", "agent-advanced-core", "llm-routing-agy", } AUTO_MODELS = { - "llm-routing-auto-free", "llm-routing-auto-agy", - "llm-routing-auto-ollama", "llm-routing-auto-agy-ollama", + "llm-routing-auto-free", + "llm-routing-auto-agy", + "llm-routing-auto-ollama", + "llm-routing-auto-agy-ollama", } client_model = body.get("model", "llm-routing-auto-free") @@ -1537,7 +1761,9 @@ async def chat_completions(request: Request): lf = get_langfuse() if lf: try: - langfuse_trace_id = lf.create_trace_id(seed=f"triage_{stats['total_requests']}") + langfuse_trace_id = lf.create_trace_id( + seed=f"triage_{stats['total_requests']}" + ) parent_obs = lf.start_observation( trace_context={"trace_id": langfuse_trace_id}, name=f"triage-{client_model}", @@ -1552,8 +1778,15 @@ async def chat_completions(request: Request): if client_model in AUTO_MODELS or client_model == "llm-routing-ollama": # Full pipeline: classify → route to best tier bypass_cache = request.headers.get("x-bypass-cache") == "true" - target_model, triage_latency, was_cache_hit, raw_classification = await classify_request( - last_user_message, bypass_cache=bypass_cache, langfuse_trace_id=langfuse_trace_id + ( + target_model, + triage_latency, + was_cache_hit, + raw_classification, + ) = await classify_request( + last_user_message, + bypass_cache=bypass_cache, + langfuse_trace_id=langfuse_trace_id, ) logger.info(f"Triage decision (auto/gated): Routing to -> '{target_model}'") elif client_model in DIRECT_TIERS: @@ -1562,19 +1795,23 @@ async def chat_completions(request: Request): triage_latency = 0.0 was_cache_hit = False raw_classification = f"direct ({client_model})" - logger.info(f"Direct routing: Client requested '{client_model}', skipping classifier") + logger.info( + f"Direct routing: Client requested '{client_model}', skipping classifier" + ) else: raise HTTPException( status_code=400, detail=f"Unknown model '{client_model}'. Use 'llm-routing-auto-free' for automatic routing, " - f"or one of: {', '.join(sorted(DIRECT_TIERS))}" + f"or one of: {', '.join(sorted(DIRECT_TIERS))}", ) # Update in-memory statistics stats["total_requests"] += 1 stats["last_triage_decision"] = target_model stats["total_triage_time_ms"] += triage_latency - stats["avg_triage_latency_ms"] = stats["total_triage_time_ms"] / stats["total_requests"] + stats["avg_triage_latency_ms"] = ( + stats["total_triage_time_ms"] / stats["total_requests"] + ) if target_model == "agent-simple-core": stats["simple_requests"] = stats.get("simple_requests", 0) + 1 @@ -1622,11 +1859,19 @@ async def chat_completions(request: Request): should_try_agy = ( client_model == "llm-routing-agy" # direct — always try - or (client_model in ("llm-routing-auto-agy", "llm-routing-auto-agy-ollama") and target_model in ("agent-advanced-core", "agent-reasoning-core")) + or ( + client_model in ("llm-routing-auto-agy", "llm-routing-auto-agy-ollama") + and target_model in ("agent-advanced-core", "agent-reasoning-core") + ) ) should_try_ollama = ( - client_model == "llm-routing-ollama" # always try (will map to flash for complex/below) - or (client_model in ("llm-routing-auto-ollama", "llm-routing-auto-agy-ollama") and target_model in ("agent-advanced-core", "agent-reasoning-core", "agent-complex-core")) + client_model + == "llm-routing-ollama" # always try (will map to flash for complex/below) + or ( + client_model in ("llm-routing-auto-ollama", "llm-routing-auto-agy-ollama") + and target_model + in ("agent-advanced-core", "agent-reasoning-core", "agent-complex-core") + ) ) # --- AGY PROXY --- @@ -1642,7 +1887,11 @@ async def chat_completions(request: Request): if msg.get("role") == "user": content = msg.get("content") or "" if isinstance(content, list): - content = "".join(block.get("text") or "" for block in content if isinstance(block, dict) and block.get("type") == "text") + content = "".join( + block.get("text") or "" + for block in content + if isinstance(block, dict) and block.get("type") == "text" + ) last_prompt = str(content) break @@ -1679,7 +1928,7 @@ async def chat_completions(request: Request): stream=is_stream_requested, target_tier=target_model, client=get_http_client(), - cooldown_persistence=ValkeyCooldownPersistence() + cooldown_persistence=ValkeyCooldownPersistence(), ) if agy_response: model_name = agy_response.get("model", "gemini-3.5-flash (via agy)") @@ -1689,6 +1938,7 @@ async def chat_completions(request: Request): async def native_agy_stream_generator(stream_gen, model_name): """Asynchronous generator yielding native OpenAI-compatible streaming chunks from the real agy daemon.""" import uuid + created_time = int(time.time()) chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" token_count = 0 @@ -1702,13 +1952,17 @@ async def native_agy_stream_generator(stream_gen, model_name): "object": "chat.completion.chunk", "created": created_time, "model": model_name, - "choices": [{ - "index": 0, - "delta": {"content": token}, - "finish_reason": None - }] + "choices": [ + { + "index": 0, + "delta": {"content": token}, + "finish_reason": None, + } + ], } - yield f"data: {json.dumps(chunk_data)}\n\n".encode("utf-8") + yield f"data: {json.dumps(chunk_data)}\n\n".encode( + "utf-8" + ) # End of stream chunk finish_data = { @@ -1716,13 +1970,17 @@ async def native_agy_stream_generator(stream_gen, model_name): "object": "chat.completion.chunk", "created": created_time, "model": model_name, - "choices": [{ - "index": 0, - "delta": {}, - "finish_reason": "stop" - }] + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": "stop", + } + ], } - yield f"data: {json.dumps(finish_data)}\n\n".encode("utf-8") + yield f"data: {json.dumps(finish_data)}\n\n".encode( + "utf-8" + ) yield b"data: [DONE]\n\n" # Success telemetry @@ -1743,23 +2001,37 @@ async def native_agy_stream_generator(stream_gen, model_name): if agy_span_obj: try: agy_span_obj.end( - output={"model": model_name, "tokens": token_count}, - metadata={"latency_ms": latency_ms, "tier": target_model}, + output={ + "model": model_name, + "tokens": token_count, + }, + metadata={ + "latency_ms": latency_ms, + "tier": target_model, + }, ) except Exception: pass except Exception as stream_err: - logger.error(f"Error during native agy stream generation: {stream_err}") + logger.error( + f"Error during native agy stream generation: {type(stream_err).__name__}" + ) if agy_span_obj: try: agy_span_obj.end( - output={"error": str(stream_err)[:200]}, + output={"error": type(stream_err).__name__}, metadata={"status": "failed"}, ) except Exception: pass raise - return StreamingResponse(native_agy_stream_generator(agy_response["stream"], model_name), media_type="text/event-stream") + + return StreamingResponse( + native_agy_stream_generator( + agy_response["stream"], model_name + ), + media_type="text/event-stream", + ) else: latency_ms = (time.time() - start_time) * 1000.0 usage = agy_response.get("usage") or {} @@ -1781,35 +2053,49 @@ async def native_agy_stream_generator(stream_gen, model_name): if agy_span_obj: try: agy_span_obj.end( - output={"model": model_name, "tokens": completion_tokens}, - metadata={"latency_ms": latency_ms, "tier": target_model}, + output={ + "model": model_name, + "tokens": completion_tokens, + }, + metadata={ + "latency_ms": latency_ms, + "tier": target_model, + }, ) except Exception: pass if is_stream_requested: # Robust fallback: simulate stream if we requested stream but got buffered response - content = (agy_response.get("choices") or [{}])[0].get("message", {}).get("content") or "" + content = (agy_response.get("choices") or [{}])[0].get( + "message", {} + ).get("content") or "" + async def agy_stream_generator(): """Asynchronous generator yielding simulated OpenAI-compatible streaming chunks from a static agy response.""" import uuid + created_time = int(time.time()) chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" chunk_size = 40 for i in range(0, len(content), chunk_size): - chunk_text = content[i:i+chunk_size] + chunk_text = content[i : i + chunk_size] chunk_data = { "id": chunk_id, "object": "chat.completion.chunk", "created": created_time, "model": model_name, - "choices": [{ - "index": 0, - "delta": {"content": chunk_text}, - "finish_reason": None - }] + "choices": [ + { + "index": 0, + "delta": {"content": chunk_text}, + "finish_reason": None, + } + ], } - yield f"data: {json.dumps(chunk_data)}\n\n".encode("utf-8") + yield f"data: {json.dumps(chunk_data)}\n\n".encode( + "utf-8" + ) await asyncio.sleep(0.005) finish_data = { @@ -1817,15 +2103,22 @@ async def agy_stream_generator(): "object": "chat.completion.chunk", "created": created_time, "model": model_name, - "choices": [{ - "index": 0, - "delta": {}, - "finish_reason": "stop" - }] + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": "stop", + } + ], } - yield f"data: {json.dumps(finish_data)}\n\n".encode("utf-8") + yield f"data: {json.dumps(finish_data)}\n\n".encode( + "utf-8" + ) yield b"data: [DONE]\n\n" - return StreamingResponse(agy_stream_generator(), media_type="text/event-stream") + + return StreamingResponse( + agy_stream_generator(), media_type="text/event-stream" + ) else: return agy_response except ImportError: @@ -1842,12 +2135,12 @@ async def agy_stream_generator(): if agy_span_obj: try: agy_span_obj.end( - output={"error": str(e)[:200]}, + output={"error": type(e).__name__}, metadata={"status": "failed"}, ) except Exception: pass - logger.error(f"agy proxy failed: {e}, falling back to LiteLLM") + logger.error(f"agy proxy failed: {type(e).__name__}, falling back to LiteLLM") original_target_model = target_model @@ -1879,7 +2172,9 @@ async def execute_proxy(model_name: str): backend_conf = backends.get(model_name) if not backend_conf: logger.error(f"Backend '{model_name}' not found in configuration backends.") - raise HTTPException(status_code=500, detail=f"Backend {model_name} misconfigured") + raise HTTPException( + status_code=500, detail=f"Backend {model_name} misconfigured" + ) backend_api_base = backend_conf["api_base"] backend_api_key = backend_conf["api_key"] @@ -1934,7 +2229,7 @@ async def execute_proxy(model_name: str): if _safe_max < 1024: raise HTTPException( status_code=400, - detail=f"Context window exceeded. Estimated input tokens ({_est_input}) plus safety margin (2048) exceeds model context limit ({_min_ctx})." + detail=f"Context window exceeded. Estimated input tokens ({_est_input}) plus safety margin (2048) exceeds model context limit ({_min_ctx}).", ) if requested_max_tokens > _safe_max: logger.warning( @@ -1948,18 +2243,27 @@ async def execute_proxy(model_name: str): logger.warning(f"Pre-screening failed (non-fatal): {e}") body_to_send = body.copy() body_to_send["model"] = model_name - if "metadata" not in body_to_send or not isinstance(body_to_send["metadata"], dict): + if "metadata" not in body_to_send or not isinstance( + body_to_send["metadata"], dict + ): body_to_send["metadata"] = {} body_to_send["metadata"]["trace_name"] = "agent-completion" if body.get("stream", False): logger.info(f"Proxying streaming to LiteLLM as model={model_name}") - req = client.build_request("POST", f"{backend_api_base}/chat/completions", json=body_to_send, headers=headers) + req = client.build_request( + "POST", + f"{backend_api_base}/chat/completions", + json=body_to_send, + headers=headers, + ) r = await client.send(req, stream=True) if r.status_code == 200: + async def stream_generator(): """Asynchronous generator that yields streaming chunks from LiteLLM completions response and logs usage stats on completion.""" import codecs + completion_chars = 0 request_tokens = estimate_prompt_tokens(body_to_send) sse_buffer = "" @@ -1979,10 +2283,14 @@ async def stream_generator(): try: data_json = json.loads(data_str) choices = data_json.get("choices", []) - if choices and isinstance(choices[0], dict): + if choices and isinstance( + choices[0], dict + ): delta = choices[0].get("delta") if isinstance(delta, dict): - content = delta.get("content") or "" + content = ( + delta.get("content") or "" + ) completion_chars += len(content) except Exception: pass @@ -2006,7 +2314,10 @@ async def stream_generator(): try: litellm_span_obj.end( output={"model": model_name, "stream": True}, - metadata={"latency_ms": proxy_latency, "tokens": completion_chars // 4}, + metadata={ + "latency_ms": proxy_latency, + "tokens": completion_chars // 4, + }, ) except Exception: pass @@ -2014,32 +2325,52 @@ async def stream_generator(): logger.error(f"Stream error: {ex}") if model_name.startswith("ollama-"): global _ollama_cooldown_until - _ollama_cooldown_until = time.monotonic() + OLLAMA_COOLDOWN_SECONDS + _ollama_cooldown_until = ( + time.monotonic() + OLLAMA_COOLDOWN_SECONDS + ) try: await save_cooldowns_to_valkey() logger.error( f"🧊 Ollama failed midway through stream, activating {OLLAMA_COOLDOWN_SECONDS}s cooldown" ) except Exception as save_err: - logger.warning(f"Failed to save cooldowns to Valkey: {save_err}") + logger.warning( + f"Failed to save cooldowns to Valkey: {save_err}" + ) finally: await r.aclose() - return StreamingResponse(stream_generator(), media_type="text/event-stream") + + return StreamingResponse( + stream_generator(), media_type="text/event-stream" + ) else: error_body = await r.aread() if r else b"" - logger.warning(f"LiteLLM stream failed ({r.status_code}): {error_body[:300]}") + logger.warning( + f"LiteLLM stream failed ({r.status_code}): {error_body[:300]}" + ) await r.aclose() - raise HTTPException(status_code=r.status_code, detail="LiteLLM upstream request failed") + raise HTTPException( + status_code=r.status_code, + detail="LiteLLM upstream request failed", + ) else: logger.info(f"Proxying to LiteLLM as model={model_name}") - response = await client.post(f"{backend_api_base}/chat/completions", json=body_to_send, headers=headers) + response = await client.post( + f"{backend_api_base}/chat/completions", + json=body_to_send, + headers=headers, + ) if response.status_code == 200: proxy_latency = (time.time() - proxy_start) * 1000.0 stats["total_proxy_time_ms"] += proxy_latency - stats["avg_proxy_latency_ms"] = stats["total_proxy_time_ms"] / stats["total_requests"] + stats["avg_proxy_latency_ms"] = ( + stats["total_proxy_time_ms"] / stats["total_requests"] + ) resp_json = response.json() usage = resp_json.get("usage") or {} - prompt_tokens = usage.get("prompt_tokens") or estimate_prompt_tokens(body_to_send) + prompt_tokens = usage.get( + "prompt_tokens" + ) or estimate_prompt_tokens(body_to_send) choices = resp_json.get("choices") or [] fallback_completion = 0 if choices and isinstance(choices[0], dict): @@ -2061,20 +2392,30 @@ async def stream_generator(): if litellm_span_obj: try: litellm_span_obj.end( - output={"model": model_name, "tokens": completion_tokens}, + output={ + "model": model_name, + "tokens": completion_tokens, + }, metadata={"latency_ms": proxy_latency}, ) except Exception: pass return resp_json else: - logger.warning(f"LiteLLM failed ({response.status_code}): {response.text[:300]}") - raise HTTPException(status_code=response.status_code, detail="LiteLLM upstream request failed") + logger.warning( + f"LiteLLM failed ({response.status_code}): {response.text[:300]}" + ) + raise HTTPException( + status_code=response.status_code, + detail="LiteLLM upstream request failed", + ) except HTTPException: raise except Exception as exc: logger.error(f"httpx call failed: {exc}") - raise HTTPException(status_code=502, detail=f"Proxy call failed: {exc}") from exc + raise HTTPException( + status_code=502, detail="Proxy call failed" + ) from exc if should_try_ollama: # Sync state from Valkey first @@ -2089,16 +2430,21 @@ async def stream_generator(): f"⏳ Ollama cooldown active ({remaining}s remaining), " f"skipping {target_model}" ) - if client_model in ("llm-routing-auto-ollama", "llm-routing-auto-agy-ollama"): + if client_model in ( + "llm-routing-auto-ollama", + "llm-routing-auto-agy-ollama", + ): # Auto mode: silently fall through to the free tier - logger.info(f"Auto-mode fallback: {target_model} → {original_target_model} (Ollama cooled down)") + logger.info( + f"Auto-mode fallback: {target_model} → {original_target_model} (Ollama cooled down)" + ) return await execute_proxy(original_target_model) else: # Direct/fallback llm-routing-ollama: return 429 so LiteLLM # skips this model group and moves to openrouter-auto raise HTTPException( status_code=429, - detail=f"Ollama backend cooled down ({remaining}s remaining)" + detail=f"Ollama backend cooled down ({remaining}s remaining)", ) try: @@ -2113,19 +2459,26 @@ async def stream_generator(): logger.error( f"🧊 Ollama failed ({e.status_code}), activating {OLLAMA_COOLDOWN_SECONDS}s cooldown" ) - if client_model in ("llm-routing-auto-ollama", "llm-routing-auto-agy-ollama"): + if client_model in ( + "llm-routing-auto-ollama", + "llm-routing-auto-agy-ollama", + ): if is_transient: - logger.warning(f"Ollama proxy failed ({e.detail}), falling back to free tier {original_target_model}") + logger.warning( + f"Ollama proxy failed ({e.detail}), falling back to free tier {original_target_model}" + ) return await execute_proxy(original_target_model) else: raise e else: # Direct/fallback llm-routing-ollama request if is_transient: - logger.error(f"Ollama proxy failed ({e.detail}) for direct/fallback request, returning 429") + logger.error( + f"Ollama proxy failed ({e.detail}) for direct/fallback request, returning 429" + ) raise HTTPException( status_code=429, - detail="Ollama backend rate limited/unavailable" + detail="Ollama backend rate limited/unavailable", ) from e else: raise e @@ -2136,17 +2489,22 @@ async def stream_generator(): logger.error( f"🧊 Ollama unexpected error ({e}), activating {OLLAMA_COOLDOWN_SECONDS}s cooldown" ) - if client_model in ("llm-routing-auto-ollama", "llm-routing-auto-agy-ollama"): - logger.warning(f"Ollama proxy error ({e}), falling back to free tier {original_target_model}") + if client_model in ( + "llm-routing-auto-ollama", + "llm-routing-auto-agy-ollama", + ): + logger.warning( + f"Ollama proxy error ({e}), falling back to free tier {original_target_model}" + ) return await execute_proxy(original_target_model) else: raise HTTPException( - status_code=429, - detail="Ollama backend rate limited/unavailable" + status_code=429, detail="Ollama backend rate limited/unavailable" ) from e else: return await execute_proxy(target_model) + @app.get("/metrics") async def metrics(): """Expose triage and circuit breaker metrics in Prometheus format.""" @@ -2205,36 +2563,50 @@ async def metrics(): # Circuit breaker metrics — dual breaker (google + vendor) google = breaker_status["google"] vendor = breaker_status["vendor"] - lines.append("# HELP circuit_breaker_google_tier Google breaker cooldown tier (0=open, 3=max)") + lines.append( + "# HELP circuit_breaker_google_tier Google breaker cooldown tier (0=open, 3=max)" + ) lines.append("# TYPE circuit_breaker_google_tier gauge") lines.append(f"circuit_breaker_google_tier {google['tier']}") - lines.append("# HELP circuit_breaker_vendor_tier Vendor breaker cooldown tier (0=open, 3=max)") + lines.append( + "# HELP circuit_breaker_vendor_tier Vendor breaker cooldown tier (0=open, 3=max)" + ) lines.append("# TYPE circuit_breaker_vendor_tier gauge") lines.append(f"circuit_breaker_vendor_tier {vendor['tier']}") - lines.append("# HELP circuit_breaker_agy_allowed Whether EITHER breaker allows agy (backward-compat)") + lines.append( + "# HELP circuit_breaker_agy_allowed Whether EITHER breaker allows agy (backward-compat)" + ) lines.append("# TYPE circuit_breaker_agy_allowed gauge") lines.append(f"circuit_breaker_agy_allowed {int(breaker.is_allowed_peek())}") lines.append("# HELP circuit_breaker_total_trips Total trips across both breakers") lines.append("# TYPE circuit_breaker_total_trips counter") - lines.append(f"circuit_breaker_total_trips {google['total_trips'] + vendor['total_trips']}") + lines.append( + f"circuit_breaker_total_trips {google['total_trips'] + vendor['total_trips']}" + ) # Ollama router-side cooldown metrics _now_mono = time.monotonic() _ollama_remaining = max(0.0, _ollama_cooldown_until - _now_mono) - lines.append("# HELP ollama_cooldown_active Whether Ollama is in router-side cooldown (1=active)") + lines.append( + "# HELP ollama_cooldown_active Whether Ollama is in router-side cooldown (1=active)" + ) lines.append("# TYPE ollama_cooldown_active gauge") lines.append(f"ollama_cooldown_active {int(_ollama_remaining > 0)}") - lines.append("# HELP ollama_cooldown_remaining_seconds Seconds remaining in Ollama cooldown") + lines.append( + "# HELP ollama_cooldown_remaining_seconds Seconds remaining in Ollama cooldown" + ) lines.append("# TYPE ollama_cooldown_remaining_seconds gauge") lines.append(f"ollama_cooldown_remaining_seconds {_ollama_remaining:.0f}") return Response(content="\n".join(lines), media_type="text/plain; version=0.0.4") + # Source badge helper: generates a colored inline source tag def src_badge(label, color): """Generate inline HTML span styled as a colored status/category badge.""" return f"{label}" + async def get_dashboard_data(): """Fetch all metrics and pre-compute HTML snippets for the dashboard.""" # Run ALL independent I/O concurrently with protective timeouts @@ -2347,11 +2719,31 @@ async def get_dashboard_data(): # 3. Calculative metrics — 5-tier triage table tier_data = [ - {"tier": "agent-simple-core", "count": stats.get("simple_requests", 0), "color": "#34d399"}, - {"tier": "agent-medium-core", "count": stats.get("medium_requests", 0), "color": "#fbbf24"}, - {"tier": "agent-complex-core", "count": stats.get("complex_requests", 0), "color": "#a78bfa"}, - {"tier": "agent-reasoning-core", "count": stats.get("reasoning_requests", 0), "color": "#60a5fa"}, - {"tier": "agent-advanced-core", "count": stats.get("advanced_requests", 0), "color": "#f472b6"}, + { + "tier": "agent-simple-core", + "count": stats.get("simple_requests", 0), + "color": "#34d399", + }, + { + "tier": "agent-medium-core", + "count": stats.get("medium_requests", 0), + "color": "#fbbf24", + }, + { + "tier": "agent-complex-core", + "count": stats.get("complex_requests", 0), + "color": "#a78bfa", + }, + { + "tier": "agent-reasoning-core", + "count": stats.get("reasoning_requests", 0), + "color": "#60a5fa", + }, + { + "tier": "agent-advanced-core", + "count": stats.get("advanced_requests", 0), + "color": "#f472b6", + }, ] total_tier = sum(t["count"] for t in tier_data) for t in tier_data: @@ -2362,12 +2754,12 @@ async def get_dashboard_data(): for t in tier_data: tier_table_rows += f""" - - - {t['tier']} + + + {t["tier"]} - {t['count']} - {t['ratio']:.1f}% + {t["count"]} + {t["ratio"]:.1f}% """ tier_table_html = f""" @@ -2396,7 +2788,6 @@ async def get_dashboard_data(): pct = (token_count / max_tool_val) * 100.0 overall_pct = (token_count / total_tool_tokens * 100.0) if total_tool_tokens > 0 else 0.0 color = TOOL_COLORS.get(tool_name, "#94a3b8") - # Horizontal meters tool_tokens_html += f"""
@@ -2425,22 +2816,26 @@ async def get_dashboard_data(): timeline_html = "
Waiting for active tool executions...
" else: for ev in reversed(stats["timeline"]): - route_label = ev.get('route', 'litellm_fallback') - route_color = '#fbbf24' if route_label == 'google_oauth_direct' else '#818cf8' - route_short = 'GOOGLE' if route_label == 'google_oauth_direct' else 'LITELLM' + route_label = ev.get("route", "litellm_fallback") + route_color = ( + "#fbbf24" if route_label == "google_oauth_direct" else "#818cf8" + ) + route_short = ( + "GOOGLE" if route_label == "google_oauth_direct" else "LITELLM" + ) timeline_html += f"""
- 🔧 {ev['tool']} {route_short} - {ev['timestamp']} + 🔧 {ev["tool"]} {route_short} + {ev["timestamp"]}
- Processed {ev['tokens']:,} tokens on {ev['model']} + Processed {ev["tokens"]:,} tokens on {ev["model"]}
- Latency: {ev['latency_ms']} ms + Latency: {ev["latency_ms"]} ms
@@ -2456,44 +2851,51 @@ async def get_dashboard_data(): """ else: for idx, sess in enumerate(goose_sessions): - is_active = (idx == 0) - badge_style = "background: rgba(129, 140, 248, 0.15); color: #c084fc; border: 1px solid rgba(129, 140, 248, 0.3);" if is_active else "background: rgba(255,255,255,0.03); color: #fff; border: 1px solid rgba(255,255,255,0.05);" - active_label = "ACTIVE" if is_active else "" + is_active = idx == 0 + badge_style = ( + "background: rgba(129, 140, 248, 0.15); color: #c084fc; border: 1px solid rgba(129, 140, 248, 0.3);" + if is_active + else "background: rgba(255,255,255,0.03); color: #fff; border: 1px solid rgba(255,255,255,0.05);" + ) + active_label = ( + "ACTIVE" + if is_active + else "" + ) - desc = sess.get('description') or sess.get('name') or "Interactive session" - tokens = sess.get('accumulated_total_tokens', 0) or 0 + desc = sess.get("description") or sess.get("name") or "Interactive session" + tokens = sess.get("accumulated_total_tokens", 0) or 0 goose_html += f"""
{active_label} - Session {sess['id']} + Session {sess["id"]}
- {sess.get('goose_mode', 'auto').upper()} + {sess.get("goose_mode", "auto").upper()}
{desc}
- 📅 {sess['updated_at']} + 📅 {sess["updated_at"]} {tokens:,} total tokens
""" # 8. Routing Paths pie chart & legend - routing_paths = stats.get("routing_paths", {"google_oauth_direct": 0, "litellm_fallback": 0}) + routing_paths = stats.get( + "routing_paths", {"google_oauth_direct": 0, "litellm_fallback": 0} + ) total_routed = sum(routing_paths.values()) routing_pie_gradient = "background: rgba(255, 255, 255, 0.05);" routing_legend_html = "" - routing_colors = { - "google_oauth_direct": "#fbbf24", - "litellm_fallback": "#818cf8" - } + routing_colors = {"google_oauth_direct": "#fbbf24", "litellm_fallback": "#818cf8"} routing_labels = { "google_oauth_direct": "Google OAuth Direct", - "litellm_fallback": "LiteLLM Fallback" + "litellm_fallback": "LiteLLM Fallback", } if total_routed > 0: current_angle = 0.0 @@ -2511,7 +2913,9 @@ async def get_dashboard_data():
""" current_angle = next_angle - routing_pie_gradient = f"background: conic-gradient({', '.join(route_grad_parts)});" + routing_pie_gradient = ( + f"background: conic-gradient({', '.join(route_grad_parts)});" + ) # 9. Model Usage — canonical source is Langfuse traces (replaces duplicated in-memory counter) # See router trace → LiteLLM trace linkage via X-Langfuse-Trace-Id header. @@ -2525,15 +2929,29 @@ async def get_dashboard_data(): llamacpp_models_html = "" if llamacpp["models"]: for m in llamacpp["models"]: - status_style = "background: rgba(16,185,129,0.12); color: #34d399; border: 1px solid rgba(16,185,129,0.25);" if m["status"] == "loaded" else "background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.4); border: 1px solid rgba(255,255,255,0.08);" - params_str = f"\U0001f9e0 {m['n_params']/1e9:.1f}B params" if m["n_params"] else "" - ctx_str = f"\U0001f4d0 ctx {m['n_ctx']:,}" if m["n_ctx"] else "" - size_str = f"\U0001f4be {m['size_bytes']/1e6:.0f} MB" if m["size_bytes"] else "" + status_style = ( + "background: rgba(16,185,129,0.12); color: #34d399; border: 1px solid rgba(16,185,129,0.25);" + if m["status"] == "loaded" + else "background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.4); border: 1px solid rgba(255,255,255,0.08);" + ) + params_str = ( + f"\U0001f9e0 {m['n_params'] / 1e9:.1f}B params" + if m["n_params"] + else "" + ) + ctx_str = ( + f"\U0001f4d0 ctx {m['n_ctx']:,}" if m["n_ctx"] else "" + ) + size_str = ( + f"\U0001f4be {m['size_bytes'] / 1e6:.0f} MB" + if m["size_bytes"] + else "" + ) llamacpp_models_html += f"""
- {m['id']} - {m['status'].upper()} + {m["id"]} + {m["status"].upper()}
{params_str}{ctx_str}{size_str} @@ -2547,14 +2965,18 @@ async def get_dashboard_data(): if llamacpp["slots"]: slot_items = "" for sl in llamacpp["slots"]: - dot_style = "background: #34d399; box-shadow: 0 0 8px #34d399;" if sl["is_processing"] else "background: rgba(255,255,255,0.15);" + dot_style = ( + "background: #34d399; box-shadow: 0 0 8px #34d399;" + if sl["is_processing"] + else "background: rgba(255,255,255,0.15);" + ) slot_items += f"""
-
Slot {sl['id']}
+
Slot {sl["id"]}
- Prompt: {sl['n_prompt_processed']} tok - Decoded: {sl['n_decoded']} tok + Prompt: {sl["n_prompt_processed"]} tok + Decoded: {sl["n_decoded"]} tok
""" @@ -2593,14 +3015,16 @@ async def get_dashboard_data(): "avg_proxy_latency_ms": stats["avg_proxy_latency_ms"], "cache_hits": stats["cache_hits"], "total_requests": stats["total_requests"], - "last_triage_decision": stats["last_triage_decision"] + "last_triage_decision": stats["last_triage_decision"], } + @app.get("/api/dashboard-stats") async def get_dashboard_stats(): """Return dashboard metrics and pre-computed HTML as JSON for asynchronous UI updates.""" return await get_dashboard_data() + @app.get("/dashboard", response_class=HTMLResponse) async def get_dashboard(): """Render the router main dashboard HTML showing system metrics, health checks, and recent token usage.""" @@ -3142,7 +3566,7 @@ async def get_dashboard():
- {src_badge('ROUTER', '#818cf8')} Gateway Performance Telemetry + {src_badge("ROUTER", "#818cf8")} Gateway Performance Telemetry Persistent telemetry
@@ -3170,7 +3594,7 @@ async def get_dashboard():
-
{src_badge('ROUTER', '#818cf8')} Triage Routing Split
+
{src_badge("ROUTER", "#818cf8")} Triage Routing Split
{tier_table_html}
@@ -3180,7 +3604,7 @@ async def get_dashboard():
- {src_badge('ROUTER', '#818cf8')} Tool Token Distribution + {src_badge("ROUTER", "#818cf8")} Tool Token Distribution Live conic-gradient pie
@@ -3213,7 +3637,7 @@ async def get_dashboard():
- {src_badge('ROUTER', '#818cf8')} Routing Path Distribution + {src_badge("ROUTER", "#818cf8")} Routing Path Distribution % requests per path
@@ -3229,7 +3653,7 @@ async def get_dashboard():
- {src_badge('LITELLM', '#34d399')} Model Usage + {src_badge("LITELLM", "#34d399")} Model Usage Full traces in Langfuse
@@ -3241,7 +3665,7 @@ async def get_dashboard():
- {src_badge('GOOSE', '#fbbf24')} Live Tool Token Meters + {src_badge("GOOSE", "#fbbf24")} Live Tool Token Meters Token meters per extension tool
@@ -3252,7 +3676,7 @@ async def get_dashboard():
- {src_badge('ROUTER', '#818cf8')} Request Timeline + {src_badge("ROUTER", "#818cf8")} Request Timeline Recent completions cascade
@@ -3266,27 +3690,27 @@ async def get_dashboard():
- {src_badge('INTELLECT', '#34d399')} Frontier Free Model + {src_badge("INTELLECT", "#34d399")} Frontier Free Model agentic index score
- {best_free_model['name']} - ⚡ {best_free_model['score']:.1f} + {best_free_model["name"]} + ⚡ {best_free_model["score"]:.1f}
- ID: {best_free_model['id']} + ID: {best_free_model["id"]}
- 📐 context {best_free_model['context_length']:,} tok - { "LIVE" if not best_free_model.get('is_fallback') else "FALLBACK" } + 📐 context {best_free_model["context_length"]:,} tok + {"LIVE" if not best_free_model.get("is_fallback") else "FALLBACK"}
-
{src_badge('ROUTER', '#818cf8')} Infrastructure Nodes
+
{src_badge("ROUTER", "#818cf8")} Infrastructure Nodes
@@ -3301,8 +3725,8 @@ async def get_dashboard(): LiteLLM Proxy :4000
- - {'Online' if litellm_status else 'Offline'} + + {"Online" if litellm_status else "Offline"}
@@ -3311,8 +3735,8 @@ async def get_dashboard(): Valkey Cache :6379
- - {'Online' if valkey_status else 'Offline'} + + {"Online" if valkey_status else "Offline"}
@@ -3321,8 +3745,8 @@ async def get_dashboard(): Llama-Server :8080
- - {'Online' if llama_server_status else 'Offline'} + + {"Online" if llama_server_status else "Offline"}
@@ -3331,8 +3755,8 @@ async def get_dashboard(): Langfuse Traces :3001
- - {'Online' if langfuse_status else 'Offline'} + + {"Online" if langfuse_status else "Offline"}
@@ -3340,8 +3764,8 @@ async def get_dashboard():
- {src_badge('LLAMA.CPP', '#fb923c')} Engine Metrics - build {data['llamacpp_build']} + {src_badge("LLAMA.CPP", "#fb923c")} Engine Metrics + build {data["llamacpp_build"]}
{llamacpp_models_html} @@ -3353,7 +3777,7 @@ async def get_dashboard():
-
{src_badge('GOOSE', '#fbbf24')} Session Directory
+
{src_badge("GOOSE", "#fbbf24")} Session Directory
{goose_html}
@@ -3365,21 +3789,21 @@ async def get_dashboard(): @@ -3395,6 +3819,7 @@ async def get_dashboard(): """ return html_content + # --- Static files (visualizer, data files) --- STATIC_DIR = Path(__file__).resolve().parent / "static" DATA_DIR = Path(__file__).resolve().parent / "data" @@ -3402,6 +3827,7 @@ async def get_dashboard(): app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data") + @app.get("/visualizer", response_class=HTMLResponse) async def get_visualizer(): """Serve the dataset visualizer for human review.""" @@ -3412,106 +3838,101 @@ async def get_visualizer(): return HTMLResponse("

Visualizer not found

", status_code=404) +VALID_TIERS = {"agent-simple-core", "agent-medium-core", "agent-complex-core", "agent-reasoning-core", "agent-advanced-core"} +MAX_ANNOTATION_KEY_LENGTH = 128 +MAX_ANNOTATION_ITEM_BYTES = 4096 + class AnnotationItem(BaseModel): """Pydantic model representing a single human dataset review annotation.""" - tier: Union[int, str, None] = None - note: str = "" - ts: Optional[str] = None + model_config = ConfigDict(extra="forbid") -VALID_TIERS = {"agent-simple-core", "agent-medium-core", "agent-complex-core", "agent-reasoning-core", "agent-advanced-core"} + tier: Union[int, str, None] = None + note: Optional[str] = Field(default=None, max_length=1000) + ts: Optional[str] = Field(default=None, max_length=100) + + @field_validator("tier") + @classmethod + def validate_tier(cls, v): + if v is None: + return v + if isinstance(v, int): + if v < 0 or v > 4: + raise ValueError(f"Invalid tier index {v}: must be between 0 and 4") + elif isinstance(v, str): + if v not in VALID_TIERS and v != "?": + raise ValueError(f"Invalid tier string '{v}'") + else: + raise ValueError("Tier must be int, str, or null") + return v + +class AnnotationPayload(RootModel): + root: Dict[str, AnnotationItem] + + @model_validator(mode="after") + def validate_payload(self) -> "AnnotationPayload": + data = self.root + if len(data) > 1000: + raise ValueError("Payload size limit exceeded: maximum of 1000 annotations allowed per request.") + for k, item in data.items(): + if len(k) > MAX_ANNOTATION_KEY_LENGTH: + raise ValueError(f"Invalid payload key '{k}': key is too long.") + is_valid_key = k.isdigit() or ( + k.startswith("h") and len(k) > 1 and all(c in "0123456789abcdef" for c in k[1:].lower()) + ) + if not is_valid_key: + raise ValueError(f"Invalid payload key '{k}': keys must be numeric strings or stable hash keys (e.g., 'h12345abc').") + if len(item.model_dump_json().encode("utf-8")) > MAX_ANNOTATION_ITEM_BYTES: + raise ValueError(f"Annotation '{k}' exceeds the maximum serialized size.") + return self # NOTE: annotations_lock (asyncio.Lock) only provides concurrency protection within # a single Python process. In multi-worker uvicorn deployments, concurrent requests # across different workers can still race. Eventual consistency is maintained via # the atomic file-replace mechanism, which is acceptable for this dashboard feature. annotations_lock = asyncio.Lock() -_annotations_cache = {} - -async def _read_annotations_async(path) -> dict: - import copy - - # Do not swallow OSError if file doesn't exist to preserve original behavior. - # The caller (save_annotations) handles the exception when reading existing annotations. - current_mtime = await asyncio.to_thread(os.path.getmtime, path) - - cache_entry = _annotations_cache.get(path) - if cache_entry is None or current_mtime != cache_entry["mtime"]: - async with aiofiles.open(path, "r", encoding="utf-8") as f: - # Read asynchronously, but parse in a thread pool to avoid blocking event loop - content = await f.read() - data = await asyncio.to_thread(json.loads, content) - _annotations_cache[path] = {"mtime": current_mtime, "data": data} +def _read_annotations_sync(path) -> dict: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) - return await asyncio.to_thread(copy.deepcopy, _annotations_cache[path]["data"]) @app.post("/dashboard/save-annotations") -async def save_annotations(payload: Dict[str, AnnotationItem]): +async def save_annotations(payload: AnnotationPayload): """Save human review annotations to disk.""" - if len(payload) > 1000: - raise HTTPException( - status_code=400, - detail="Payload size limit exceeded: maximum of 1000 annotations allowed per request." - ) - for k, item in payload.items(): - # Allow numeric strings (dataset indexes) or stable hash keys starting with 'h' (hexadecimal) - is_valid_key = k.isdigit() or ( - k.startswith("h") and len(k) > 1 and all(c in "0123456789abcdef" for c in k[1:].lower()) - ) - if not is_valid_key: - raise HTTPException( - status_code=400, - detail=f"Invalid payload key '{k}': keys must be numeric strings or stable hash keys (e.g., 'h12345abc')." - ) - - t = item.tier - if t is not None: - if isinstance(t, int): - if t < 0 or t > 4: - raise HTTPException( - status_code=400, - detail=f"Invalid tier index {t} for index {k}: must be between 0 and 4." - ) - elif isinstance(t, str): - if t not in VALID_TIERS and t != "?": - raise HTTPException( - status_code=400, - detail=f"Invalid tier string '{t}' for index {k}." - ) - else: - raise HTTPException( - status_code=400, - detail=f"Invalid tier type for index {k}: must be int, str, or null." - ) - - if item.note and len(item.note) > 1000: - raise HTTPException( - status_code=400, - detail=f"Note length limit exceeded at index {k}: maximum of 1000 characters allowed." - ) try: + data = payload.root ann_path = DATA_DIR / "annotations.json" existing = {} async with annotations_lock: if ann_path.exists(): try: - existing = await _read_annotations_async(str(ann_path)) + existing = await asyncio.to_thread( + _read_annotations_sync, str(ann_path) + ) except Exception as read_err: - logger.warning(f"Could not read existing annotations: {read_err}. Overwriting.") + logger.warning( + f"Could not read existing annotations: {read_err}. Overwriting." + ) # Merge new annotations into existing - for k, item in payload.items(): - existing[k] = item.model_dump() if hasattr(item, "model_dump") else item.dict() - + for k, item in data.items(): + # For partial updates, merge only fields provided in the request + update_data = item.model_dump(exclude_unset=True) + if k in existing and isinstance(existing[k], dict): + existing[k].update(update_data) + else: + existing[k] = item.model_dump() await _atomic_write_json_async(str(ann_path), existing) - return JSONResponse({"status": "ok", "saved": len(payload)}) + return JSONResponse({"status": "ok", "saved": len(data)}) except Exception as e: logger.error(f"Failed to save annotations: {e}") raise HTTPException(status_code=500, detail="Failed to save annotations") + if __name__ == "__main__": import uvicorn + logger.info(f"Starting LLM Triage Router on {host}:{port}...") uvicorn.run(app, host=host, port=port) diff --git a/router/memory_mcp.py b/router/memory_mcp.py index 7ec87c26..f2187282 100755 --- a/router/memory_mcp.py +++ b/router/memory_mcp.py @@ -17,7 +17,6 @@ import time import hashlib import httpx -import urllib.parse API_URL = "http://127.0.0.1:5000/v1/memory" PROTOCOL_VERSION = "2024-11-05" @@ -45,8 +44,7 @@ def _make_key(category: str, is_global: bool, data: str) -> str: # BLAKE2b: SOTA crypto hash, stdlib, faster than MD5, deterministic across restarts. # Provides uniqueness within the same millisecond. h = hashlib.blake2b((data + str(ts)).encode("utf-8"), digest_size=HASH_DIGEST_SIZE).hexdigest() - safe_category = urllib.parse.quote(category) - return f"{PREFIX}:{scope}:{safe_category}::{ts}:{h}" + return f"{PREFIX}:{scope}:{category}::{ts}:{h}" def _parse_key(key: str): @@ -55,7 +53,7 @@ def _parse_key(key: str): parts = key.split("::") prefix = parts[0].split(":") # memory:{scope}:{category} scope = prefix[1] if len(prefix) > 1 else "" - category = urllib.parse.unquote(prefix[2]) if len(prefix) > 2 else "" + category = prefix[2] if len(prefix) > 2 else "" ts_hash = parts[1] if len(parts) > 1 else "" ts = ts_hash.split(":")[0] if ts_hash else "" return {"scope": scope, "category": category, "timestamp": ts} @@ -70,8 +68,6 @@ def _parse_key(key: str): def _is_memory_key(key: str) -> bool: """Check if a key follows the memory:{scope}:{category}:: format.""" - if not isinstance(key, str): - return False return key.startswith(f"{PREFIX}:") diff --git a/router/test_memory_mcp.py b/router/test_memory_mcp.py index 7572c0c7..24d23a34 100644 --- a/router/test_memory_mcp.py +++ b/router/test_memory_mcp.py @@ -4,7 +4,7 @@ import json from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) -from memory_mcp import _is_memory_key, _make_key, SCOPE_GLOBAL, SCOPE_LOCAL, PREFIX, _memory_value, _parse_memory_value +from memory_mcp import _make_key, SCOPE_GLOBAL, SCOPE_LOCAL, PREFIX, _memory_value, _parse_memory_value def test_make_key_global(): """Test generating a key for global scope.""" @@ -129,37 +129,3 @@ def test_parse_memory_value_invalid_json_string(): """Test _parse_memory_value with invalid JSON string.""" result = _parse_memory_value("this is not a valid json string") assert result == {"data": "this is not a valid json string", "tags": []} - -def test_make_key_category_with_colons(): - """Test generating and parsing a key when the category contains colons.""" - from router.memory_mcp import _parse_key - category = "test:category::with:colons" - data = "test_data" - - key = _make_key(category, True, data) - - # Check that it encoded the colons safely - assert "%3A" in key - assert "test:category" not in key - - # Check that it parses back successfully - parsed = _parse_key(key) - assert parsed["category"] == category - assert parsed["scope"] == SCOPE_GLOBAL - - -def test_is_memory_key_true(): - """Test _is_memory_key with a valid memory key prefix.""" - assert _is_memory_key(f"{PREFIX}:global:test_cat::123:abc") is True - -def test_is_memory_key_false_wrong_prefix(): - """Test _is_memory_key with an incorrect prefix.""" - assert _is_memory_key("not_memory:global:test_cat::123:abc") is False - -def test_is_memory_key_empty(): - """Test _is_memory_key with an empty string.""" - assert _is_memory_key("") is False - -def test_is_memory_key_none_or_non_string(): - """Test _is_memory_key with None or non-string inputs.""" - assert _is_memory_key(None) is False diff --git a/router/tests/test_detect_active_tool.py b/router/tests/test_detect_active_tool.py deleted file mode 100644 index 3105ab1e..00000000 --- a/router/tests/test_detect_active_tool.py +++ /dev/null @@ -1,114 +0,0 @@ -import pytest -import os -import sys -from pathlib import Path - -# Set CONFIG_PATH for import -os.environ["CONFIG_PATH"] = str(Path(__file__).resolve().parent.parent / "config.yaml") - -# Add the parent directory to the path so we can import from router -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from router.main import detect_active_tool - -def test_detect_active_tool_empty(): - assert detect_active_tool({}) == "none" - assert detect_active_tool({"messages": []}) == "none" - assert detect_active_tool({"messages": [{"role": "system", "content": "hello"}]}) == "none" - -def test_detect_active_tool_role_tool_with_name(): - # Write tool name mapped to write - body = { - "messages": [ - {"role": "tool", "name": "edit_file", "content": "..."} - ] - } - assert detect_active_tool(body) == "write" - - # View tool mapped to view - body = { - "messages": [ - {"role": "tool", "name": "cat_file", "content": "..."} - ] - } - assert detect_active_tool(body) == "view" - -def test_detect_active_tool_role_tool_without_name_but_matched_tool_call_id(): - body = { - "messages": [ - {"role": "assistant", "tool_calls": [{"id": "call_123", "function": {"name": "read_file"}}]}, - {"role": "tool", "tool_call_id": "call_123", "content": "success"} - ] - } - assert detect_active_tool(body) == "view" - -def test_detect_active_tool_role_tool_without_name_unmatched_tool_call_id(): - body = { - "messages": [ - {"role": "assistant", "tool_calls": [{"id": "call_999", "function": {"name": "read_file"}}]}, - {"role": "tool", "tool_call_id": "call_123", "content": "success"} - ] - } - assert detect_active_tool(body) == "other" - -def test_detect_active_tool_role_assistant_with_tool_calls(): - body = { - "messages": [ - {"role": "user", "content": "do something"}, - {"role": "assistant", "tool_calls": [{"id": "call_1", "function": {"name": "write_to_file"}}]} - ] - } - assert detect_active_tool(body) == "write" - -def test_detect_active_tool_fallback_user_keyword(): - # Tests matching "tree" - body = { - "messages": [ - {"role": "user", "content": "show me the tree"} - ] - } - assert detect_active_tool(body) == "tree" - - # Tests matching "shell" - body = { - "messages": [ - {"role": "user", "content": "run this in shell"} - ] - } - assert detect_active_tool(body) == "shell" - - # Tests matching "write" - body = { - "messages": [ - {"role": "user", "content": "create file test.py"} - ] - } - assert detect_active_tool(body) == "write" - - # Tests matching "view" - body = { - "messages": [ - {"role": "user", "content": "cat main.py"} - ] - } - assert detect_active_tool(body) == "view" - -def test_detect_active_tool_ignores_invalid_message_formats(): - body = { - "messages": [ - "this is not a dict", - {"role": "user", "content": "read this"} - ] - } - assert detect_active_tool(body) == "view" - -def test_detect_active_tool_precedence(): - # If there are multiple tools, it processes from the last message backwards - body = { - "messages": [ - {"role": "tool", "name": "edit_file", "content": "done"}, - {"role": "tool", "name": "cat_file", "content": "..."} - ] - } - # It starts from the end, so it sees "cat_file" first, which maps to "view" - assert detect_active_tool(body) == "view" diff --git a/router/tests/test_estimate_prompt_tokens.py b/router/tests/test_estimate_prompt_tokens.py index ae74a9e5..e93390f9 100644 --- a/router/tests/test_estimate_prompt_tokens.py +++ b/router/tests/test_estimate_prompt_tokens.py @@ -23,27 +23,25 @@ def test_estimate_prompt_tokens_empty_messages(): def test_estimate_prompt_tokens_string_content(): body = { "messages": [ - {"content": "word " * 4}, # 4 * 1.2 = 4.8 - {"content": "word " * 8} # 8 * 1.2 = 9.6 + {"content": "1234"}, # 1 token + {"content": "12345678"} # 2 tokens ] } - # Total is int(round(4.8 + 9.6)) + 50 = int(round(14.4)) + 50 = 14 + 50 = 64 - assert estimate_prompt_tokens(body) == 50 + 14 + assert estimate_prompt_tokens(body) == 50 + 1 + 2 def test_estimate_prompt_tokens_list_content(): body = { "messages": [ { "content": [ - {"type": "text", "text": "word " * 4}, # 4.8 tokens + {"type": "text", "text": "1234"}, # 1 token {"type": "image_url", "url": "ignored"}, # 0 tokens - {"type": "text", "text": "word " * 8} # 9.6 tokens + {"type": "text", "text": "12345678"} # 2 tokens ] } ] } - # Total is int(round(4.8 + 9.6)) + 50 = 64 - assert estimate_prompt_tokens(body) == 50 + 14 + assert estimate_prompt_tokens(body) == 50 + 1 + 2 def test_estimate_prompt_tokens_mixed_and_invalid_msgs(): body = { @@ -54,11 +52,10 @@ def test_estimate_prompt_tokens_mixed_and_invalid_msgs(): "invalid_block_type", # Should be skipped {"type": "text", "text": None} # None text, handled as empty string ]}, - {"content": "word " * 4} # 4.8 tokens + {"content": "1234"} # 1 token ] } - # Total is int(round(4.8)) + 50 = 5 + 50 = 55 - assert estimate_prompt_tokens(body) == 50 + 5 + assert estimate_prompt_tokens(body) == 50 + 1 def test_estimate_prompt_tokens_missing_content(): body = { diff --git a/router/tests/test_get_goose_sessions.py b/router/tests/test_get_goose_sessions.py deleted file mode 100644 index 52640fef..00000000 --- a/router/tests/test_get_goose_sessions.py +++ /dev/null @@ -1,46 +0,0 @@ -import pytest -import sys -import os -from pathlib import Path -from unittest.mock import patch, MagicMock - -os.environ["CONFIG_PATH"] = str(Path(__file__).resolve().parent.parent / "config.yaml") -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from main import get_goose_sessions - -def test_get_goose_sessions_no_db(): - with patch('os.path.exists', return_value=False): - assert get_goose_sessions() == [] - -def test_get_goose_sessions_success(): - mock_sqlite3 = MagicMock() - mock_conn = MagicMock() - mock_cursor = MagicMock() - - mock_sqlite3.connect.return_value = mock_conn - mock_conn.cursor.return_value = mock_cursor - - mock_cursor.fetchall.return_value = [ - {"id": 1, "name": "s1"}, - {"id": 2, "name": "s2"} - ] - - with patch('os.path.exists', return_value=True): - with patch.dict(sys.modules, {'sqlite3': mock_sqlite3}): - result = get_goose_sessions() - - assert len(result) == 2 - assert result[0] == {"id": 1, "name": "s1"} - mock_sqlite3.connect.assert_called_once_with("/config/goose_sessions/sessions/sessions.db", timeout=1.0) - mock_cursor.execute.assert_called_once() - mock_conn.close.assert_called_once() - -def test_get_goose_sessions_exception(): - mock_sqlite3 = MagicMock() - mock_sqlite3.connect.side_effect = Exception("DB error") - - with patch('os.path.exists', return_value=True): - with patch.dict(sys.modules, {'sqlite3': mock_sqlite3}): - result = get_goose_sessions() - assert result == [] diff --git a/router/tests/test_get_live_gemini_oauth_token.py b/router/tests/test_get_live_gemini_oauth_token.py deleted file mode 100644 index a494ad21..00000000 --- a/router/tests/test_get_live_gemini_oauth_token.py +++ /dev/null @@ -1,75 +0,0 @@ -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 - -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 = main.get_live_gemini_oauth_token() - assert token == "valid_token_123" - mock_logger.assert_called_with("🔑 Found valid, unexpired Gemini OAuth token from host!") - -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 = 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.") - -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 = main.get_live_gemini_oauth_token() - assert token is None - -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 = main.get_live_gemini_oauth_token() - assert token is None - mock_logger.assert_called() - -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 = 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/router/tests/test_get_redis.py b/router/tests/test_get_redis.py deleted file mode 100644 index 0fdeaed2..00000000 --- a/router/tests/test_get_redis.py +++ /dev/null @@ -1,136 +0,0 @@ -import os -import time -from unittest.mock import patch, MagicMock - -import pytest - -import router.main as main - -@pytest.fixture(autouse=True) -def reset_redis_globals(): - """Reset the global variables before and after each test.""" - original_client = main._redis_client - original_last_attempt = main._redis_last_init_attempt - - main._redis_client = None - main._redis_last_init_attempt = 0.0 - - yield - - main._redis_client = original_client - main._redis_last_init_attempt = original_last_attempt - -def test_get_redis_already_initialized(): - """If the client is already initialized, it should return the client immediately.""" - mock_client = MagicMock() - main._redis_client = mock_client - - assert main.get_redis() is mock_client - -@patch("router.main.time.monotonic") -def test_get_redis_cooldown(mock_monotonic): - """If init failed recently, it should return None without attempting to initialize.""" - main._redis_client = None - main._redis_last_init_attempt = 100.0 - - # Time elapsed is less than 5.0 seconds - mock_monotonic.return_value = 103.0 - - assert main.get_redis() is None - -@patch("router.main.time.monotonic") -@patch("router.main.aioredis.Redis") -@patch.dict(os.environ, {"VALKEY_HOST": "my-host", "VALKEY_PORT": "1234"}) -def test_get_redis_initialization_success(mock_redis, mock_monotonic): - """If sufficient time has passed, it should initialize and return the client.""" - main._redis_client = None - main._redis_last_init_attempt = 100.0 - - # Time elapsed is 10.0 seconds (greater than 5.0) - mock_monotonic.return_value = 110.0 - - mock_redis_instance = MagicMock() - mock_redis.return_value = mock_redis_instance - - client = main.get_redis() - - assert client is mock_redis_instance - assert main._redis_client is mock_redis_instance - assert main._redis_last_init_attempt == 110.0 - mock_redis.assert_called_once_with(host="my-host", port=1234, decode_responses=True, socket_timeout=1.0) - -@patch("router.main.time.monotonic") -@patch("router.main.logger.warning") -@patch.dict(os.environ, {"VALKEY_HOST": "my-host", "VALKEY_PORT": "invalid"}) -def test_get_redis_initialization_failure(mock_logger_warning, mock_monotonic): - """If initialization fails, it should catch the exception, log a warning, and return None.""" - main._redis_client = None - main._redis_last_init_attempt = 100.0 - - # Time elapsed is 10.0 seconds - mock_monotonic.return_value = 110.0 - - # The int() conversion of VALKEY_PORT will raise ValueError - client = main.get_redis() - - assert client is None - assert main._redis_client is None - assert main._redis_last_init_attempt == 110.0 - mock_logger_warning.assert_called_once() - assert "Failed to initialize Valkey client" in mock_logger_warning.call_args[0][0] - -@patch("router.main.time.monotonic") -@patch("router.main.aioredis.Redis") -@patch("router.main.logger.warning") -@patch.dict(os.environ, {"VALKEY_HOST": "my-host", "VALKEY_PORT": "1234"}) -def test_get_redis_initialization_exception(mock_logger_warning, mock_redis, mock_monotonic): - """If aioredis.Redis throws an exception, it should catch it and return None.""" - main._redis_client = None - main._redis_last_init_attempt = 100.0 - - mock_monotonic.return_value = 110.0 - - mock_redis.side_effect = Exception("Test Exception") - - client = main.get_redis() - - assert client is None - assert main._redis_client is None - assert main._redis_last_init_attempt == 110.0 - mock_logger_warning.assert_called_once() - assert "Test Exception" in mock_logger_warning.call_args[0][0] - -@patch("router.main.time.monotonic") -@patch("router.main.aioredis.Redis.from_url") -@patch.dict(os.environ, {"VALKEY_URL": "redis://my-url:1234"}) -def test_get_redis_simulation_flow_url(mock_from_url, mock_monotonic): - """Simulate the full flow for from_url: failure -> cooldown -> success -> cached.""" - # State is reset by the autouse fixture reset_redis_globals - - # 1. First attempt fails - mock_monotonic.return_value = 10.0 - mock_from_url.side_effect = Exception("Connection error") - assert main.get_redis() is None - assert main._redis_last_init_attempt == 10.0 - - # 2. Second attempt during cooldown (e.g. 12.0s) - mock_monotonic.return_value = 12.0 - mock_from_url.reset_mock() - assert main.get_redis() is None - mock_from_url.assert_not_called() - - # 3. Third attempt after cooldown (e.g. 16.0s) succeeds - mock_monotonic.return_value = 16.0 - mock_redis_instance = MagicMock() - mock_from_url.side_effect = None - mock_from_url.return_value = mock_redis_instance - client = main.get_redis() - assert client is mock_redis_instance - assert main._redis_client is mock_redis_instance - mock_from_url.assert_called_once() - - # 4. Fourth attempt returns cached instance - mock_monotonic.return_value = 18.0 - mock_from_url.reset_mock() - assert main.get_redis() is mock_redis_instance - mock_from_url.assert_not_called() diff --git a/router/tests/test_load_persisted_stats.py b/router/tests/test_load_persisted_stats.py deleted file mode 100644 index 76edbf51..00000000 --- a/router/tests/test_load_persisted_stats.py +++ /dev/null @@ -1,61 +0,0 @@ -import json -import pytest -from unittest.mock import patch, mock_open - -import router.main -from router.main import load_persisted_stats - -@pytest.fixture -def mock_stats(): - # Setup a clean stats dictionary for testing - clean_stats = { - "total_requests": 0, - "nested_dict": {"a": 1, "b": 2}, - "existing_key": "value" - } - with patch.dict(router.main.stats, clean_stats, clear=True): - yield router.main.stats - -def test_load_persisted_stats_file_not_exists(mock_stats): - with patch("router.main.os.path.exists", return_value=False) as mock_exists: - load_persisted_stats() - mock_exists.assert_called_once_with(router.main.STATS_JSON_PATH) - # Stats should remain unchanged - assert mock_stats["total_requests"] == 0 - -def test_load_persisted_stats_success(mock_stats): - mock_data = { - "total_requests": 100, - "nested_dict": {"b": 3, "c": 4}, - "new_key": "new_value" - } - mock_json = json.dumps(mock_data) - - with patch("router.main.os.path.exists", return_value=True): - 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 - assert mock_stats["total_requests"] == 100 - # Assert nested_dict updated via if block (b updated, c added, a unchanged) - assert mock_stats["nested_dict"] == {"a": 1, "b": 3, "c": 4} - # Assert new_key added via else block - assert mock_stats["new_key"] == "new_value" - # Assert existing_key unchanged - assert mock_stats["existing_key"] == "value" - - mock_logger.assert_called_once_with("✓ Successfully loaded persisted gateway statistics from disk.") - -def test_load_persisted_stats_exception(mock_stats): - 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 - assert mock_stats["total_requests"] == 0 - - # Error should be logged - mock_logger.assert_called_once() - assert "Failed to load persisted stats: Mock read error" in mock_logger.call_args[0][0] diff --git a/router/tests/test_memory_mcp.py b/router/tests/test_memory_mcp.py deleted file mode 100644 index 8c93f1f6..00000000 --- a/router/tests/test_memory_mcp.py +++ /dev/null @@ -1,313 +0,0 @@ -import json -import re -import sys -import time -from pathlib import Path - -# Dynamic project root discovery -root = Path(__file__).resolve() -while root.parent != root and not (root / ".git").exists(): - root = root.parent -sys.path.insert(0, str(root)) -sys.path.insert(0, str(root / "router")) - -import pytest -from memory_mcp import ( - PREFIX, - SCOPE_GLOBAL, - SCOPE_LOCAL, - _make_key, - _memory_entry, - _memory_value, - _parse_key, - _parse_memory_value, -) - - -# ===================================================================== -# Tests from router/test_memory_mcp.py -# ===================================================================== - -def test_make_key_global(): - """Test generating a key for global scope.""" - category = "test_cat" - data = "test_data" - - before_ts = int(time.time() * 1000) - key = _make_key(category, True, data) - after_ts = int(time.time() * 1000) - - # Expected format: f"{PREFIX}:{scope}:{category}::{ts}:{h}" - assert key.startswith(f"{PREFIX}:{SCOPE_GLOBAL}:{category}::") - - # Extract timestamp and hash part - match = re.match(rf"^{PREFIX}:{SCOPE_GLOBAL}:{category}::(\d+):([a-f0-9]+)$", key) - assert match is not None, f"Key {key} does not match expected format" - - ts = int(match.group(1)) - h = match.group(2) - - assert before_ts <= ts <= after_ts - assert len(h) == 20 - - -def test_make_key_local(): - """Test generating a key for local scope.""" - category = "another_cat" - data = "more_data" - - before_ts = int(time.time() * 1000) - key = _make_key(category, False, data) - after_ts = int(time.time() * 1000) - - assert key.startswith(f"{PREFIX}:{SCOPE_LOCAL}:{category}::") - - match = re.match(rf"^{PREFIX}:{SCOPE_LOCAL}:{category}::(\d+):([a-f0-9]+)$", key) - assert match is not None, f"Key {key} does not match expected format" - - ts = int(match.group(1)) - h = match.group(2) - - assert before_ts <= ts <= after_ts - assert len(h) == 20 - - -def test_make_key_formatting_details(monkeypatch): - """Test the exact output formatting of _make_key using deterministic BLAKE2b.""" - # Mock time.time to return a predictable float so ts = 1620000000123 - monkeypatch.setattr(time, "time", lambda: 1620000000.123) - - # data="data", ts=1620000000123 -> blake2b("data1620000000123", digest_size=10) -> 5e5dad075ca7764bc51f - key1 = _make_key("cat1", True, "data") - assert key1 == f"{PREFIX}:{SCOPE_GLOBAL}:cat1::1620000000123:5e5dad075ca7764bc51f" - - key2 = _make_key("cat2", False, "data") - assert key2 == f"{PREFIX}:{SCOPE_LOCAL}:cat2::1620000000123:5e5dad075ca7764bc51f" - - -def test_make_key_determinism_and_uniqueness(): - """Test determinism for same inputs within same timestamp, and uniqueness across timestamps/data.""" - category = "test_cat" - data1 = "data1" - data2 = "data2" - - key1 = _make_key(category, True, data1) - time.sleep(0.002) - key2 = _make_key(category, True, data1) - key3 = _make_key(category, True, data2) - - # Uniqueness across data - assert key1 != key3 - - # Check determinism: if the timestamp parts are the same, the keys should be identical - ts1 = key1.split("::")[1].split(":")[0] - ts2 = key2.split("::")[1].split(":")[0] - if ts1 == ts2: - assert key1 == key2 - else: - # If timestamp is different, keys should be different - assert key1 != key2 - - -def test_memory_value_happy_path(): - """Test _memory_value with standard data and tags.""" - result = _memory_value("some data", ["tag1", "tag2"]) - parsed = json.loads(result) - assert parsed == {"data": "some data", "tags": ["tag1", "tag2"]} - - -def test_memory_value_missing_tags(): - """Test _memory_value when tags is None.""" - result = _memory_value("some data", None) - parsed = json.loads(result) - assert parsed == {"data": "some data", "tags": []} - - -def test_memory_value_unicode(): - """Test _memory_value properly handles unicode and ensure_ascii=False.""" - result = _memory_value("こんにちは", ["世界"]) - # If ensure_ascii=False, the unicode characters shouldn't be escaped (no \uXXXX) - assert "こんにちは" in result - assert "世界" in result - parsed = json.loads(result) - assert parsed == {"data": "こんにちは", "tags": ["世界"]} - - -def test_parse_memory_value_success(): - """Test _parse_memory_value successfully decodes valid JSON.""" - raw = '{"data": "info", "tags": ["a"]}' - result = _parse_memory_value(raw) - assert result == {"data": "info", "tags": ["a"]} - - -def test_parse_memory_value_invalid_json(): - """Test _parse_memory_value with invalid JSON.""" - result = _parse_memory_value("{invalid_json:") - assert result == {"data": "{invalid_json:", "tags": []} - - -def test_parse_memory_value_type_error(): - """Test _parse_memory_value with TypeError (e.g. passing None).""" - result = _parse_memory_value(None) - assert result == {"data": None, "tags": []} - - -def test_parse_memory_value_invalid_json_string(): - """Test _parse_memory_value with invalid JSON string.""" - result = _parse_memory_value("this is not a valid json string") - assert result == {"data": "this is not a valid json string", "tags": []} - - -# ===================================================================== -# Tests from test_memory_mcp.py (root) -# ===================================================================== - -def test_memory_entry_happy_path(): - """Test correctly formatted and complete memory entry.""" - valid_key = "memory:global:project_standards::1689201948123:a1b2c3d4e5f6" - valid_value = json.dumps({"data": "Use pytest for all tests", "tags": ["testing", "python"]}) - lmem = { - "key": valid_key, - "value": valid_value, - "memory_id": "test_id_123" - } - - result = _memory_entry(lmem) - - assert result is not None - assert result["key"] == valid_key - assert result["category"] == "project_standards" - assert result["data"] == "Use pytest for all tests" - assert result["tags"] == ["testing", "python"] - assert result["scope"] == "global" - assert result["timestamp"] == "1689201948123" - assert result["memory_id"] == "test_id_123" - - -def test_memory_entry_invalid_key(): - """Test with a key that does not start with 'memory:'.""" - lmem = { - "key": "notamemory:global:cat::123:hash", - "value": json.dumps({"data": "test", "tags": []}) - } - - result = _memory_entry(lmem) - assert result is None - - -def test_memory_entry_malformed_json_value(): - """Test with malformed/string value where JSON parsing fails.""" - valid_key = "memory:local:notes::1689201948123:a1b2c3d4e5f6" - # value is just a raw string, not JSON - lmem = { - "key": valid_key, - "value": "This is just a raw string without tags" - } - - result = _memory_entry(lmem) - - assert result is not None - assert result["data"] == "This is just a raw string without tags" - assert result["tags"] == [] # Falls back to empty tags list - assert result["category"] == "notes" - assert result["scope"] == "local" - - -def test_memory_entry_missing_fields(): - """Test gracefully handling dictionaries with missing keys.""" - # Missing 'value' and 'memory_id' - lmem1 = { - "key": "memory:global:ideas::123:hash" - } - result1 = _memory_entry(lmem1) - assert result1 is not None - assert result1["data"] == "" - assert result1["tags"] == [] - assert result1["memory_id"] == "" - - # Missing 'key' - lmem2 = { - "value": json.dumps({"data": "test", "tags": []}) - } - result2 = _memory_entry(lmem2) - assert result2 is None - - # Empty dict - result3 = _memory_entry({}) - assert result3 is None - - -@pytest.mark.parametrize( - "key, expected", - [ - ( - "memory:local:code::20240101T120000Z:abc123hash", - {"scope": "local", "category": "code", "timestamp": "20240101T120000Z"}, - ), - ( - "memory:global:general", - {"scope": "global", "category": "general", "timestamp": ""}, - ), - ( - "memory:local::20240101T120000Z:abc123hash", - {"scope": "local", "category": "", "timestamp": "20240101T120000Z"}, - ), - ( - "memory", - {"scope": "", "category": "", "timestamp": ""}, - ), - ( - "", - {"scope": "", "category": "", "timestamp": ""}, - ), - ( - None, - {"scope": "", "category": "", "timestamp": ""}, - ), - ( - "memory:global:category:with:colons::20240101T120000Z:abc123hash", - {"scope": "global", "category": "category", "timestamp": "20240101T120000Z"}, - ), - ( - "memory:global:general::20240101T120000Z", - {"scope": "global", "category": "general", "timestamp": "20240101T120000Z"}, - ), - ], - ids=[ - "happy_path", - "missing_timestamp_hash", - "missing_category", - "missing_scope_and_category", - "empty_string", - "invalid_type", - "extra_colons_in_category", - "missing_hash_but_has_timestamp", - ] -) -def test_parse_key(key, expected): - """Test _parse_key with various valid and invalid formats.""" - result = _parse_key(key) - assert result == expected - -def test_parse_memory_value_valid_json(): - raw_data = json.dumps({"data": "some data", "tags": ["tag1", "tag2"]}) - result = _parse_memory_value(raw_data) - assert result == {"data": "some data", "tags": ["tag1", "tag2"]} - - -def test_parse_memory_value_invalid_json(): - raw_data = "this is not json" - result = _parse_memory_value(raw_data) - assert result == {"data": "this is not json", "tags": []} - - -def test_parse_memory_value_type_error(): - raw_data = 12345 - result = _parse_memory_value(raw_data) # type: ignore[arg-type] - assert result == {"data": 12345, "tags": []} - - -def test_parse_memory_value_non_dict_json(): - raw_data = '"just a string"' - result = _parse_memory_value(raw_data) - assert result == "just a string" diff --git a/scripts/README.md b/scripts/README.md index 29bf80a4..0139e8d3 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,7 +4,7 @@ This directory and the repository root contain various scripts used for stack or --- -## 1. Stack Orchestration, Backups, and PR Utilities +## 1. Stack Orchestration & Backups ### `start-stack.sh` (Root Directory) Unified startup and credential extraction script for the Podman Kubernetes container stack. @@ -16,13 +16,6 @@ Unified startup and credential extraction script for the Podman Kubernetes conta ### `scripts/backup.sh` Automated database backup script that runs before every stack deployment. Uses `pg_isready` to safely wait for database connections and manages timestamped backups under `backups/`. -### `scripts/get_pr_status.py` -A CLI utility to fetch and print the status of a GitHub PR (its state, review decisions, and checks status rollup) using the `gh` CLI. -- **Usage**: - - `python3 scripts/get_pr_status.py` (Fetches status of the current branch's PR) - - `python3 scripts/get_pr_status.py ` (Fetches status for the specified PR ID) - - --- ## 2. Routing & Cooldown Verification Scripts @@ -86,12 +79,6 @@ The integration test suite is located in the root directory. Tests are categoriz ### Performance & Monitoring Tests - **`test_stream_latency.py`**: Measures Time-To-First-Token (TTFT) and token generation speed. -### Dashboard & Annotations Tests -- **`tests/test_read_annotations_async.py`**: Unit tests for the asynchronous dashboard annotation reading and caching mechanism. - -### Utility Script Tests -- **`tests/test_get_pr_status.py`**: Unit tests validating command execution, status fetching, and error/timeout handling in `scripts/get_pr_status.py`. - ### Simulation Tests - **`test_agy_behavior.py`**: Asserts the behavior of the `agy` CLI client under quota limits. - **`test_quota_reset.sh`**: Simulates/triggers quota reset conditions. diff --git a/scripts/benchmark_tokens.py b/scripts/benchmark_tokens.py deleted file mode 100644 index d701b4eb..00000000 --- a/scripts/benchmark_tokens.py +++ /dev/null @@ -1,76 +0,0 @@ -import sys -import os -from pathlib import Path - -# Set CONFIG_PATH and ROUTER_API_KEY for import -os.environ["CONFIG_PATH"] = str(Path(__file__).resolve().parent.parent / "router" / "config.yaml") -os.environ["ROUTER_API_KEY"] = "local-token" -# Add the parent directory and the router directory to the path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "router")) - -from router.main import estimate_prompt_tokens - -def verify_accuracy(): - """Benchmarking utility to verify token estimation accuracy across content types.""" - # Test cases inspired by the problem description - test_cases = [ - { - "name": "English prose", - "content": "This is a standard English prose sentence intended to evaluate the accuracy of the token estimation heuristic for typical content. " * 5, - "actual_tokens": 110, - }, - { - "name": "Python code", - "content": """ -def calculate_factorial(n): - if n == 0: - return 1 - else: - return n * calculate_factorial(n-1) - -for i in range(10): - print(f"Factorial of {i} is {calculate_factorial(i)}") -""" * 3, - "actual_tokens": 150, - }, - { - "name": "CJK text", - "content": "这是一个测试,用于验证中文字符的令牌估算逻辑。它应该比字符计数更准确。" * 5, - "actual_tokens": 60, - }, - { - "name": "Whitespace-padded JSON", - "content": '{\n "key": "value",\n "nested": {\n "inner": "data"\n }\n}\n' * 5, - "actual_tokens": 60, - }, - { - "name": "Emoji", - "content": "🚀🔥-🤖✨-📈💎-🚨🛠️-🌐" * 5, - "actual_tokens": 25, - } - ] - - print(f"{'Case':<25} | {'Actual':<7} | {'Estimated':<9} | {'Error':<7}") - print("-" * 55) - - all_passed = True - for case in test_cases: - body = {"messages": [{"content": case["content"]}]} - est = estimate_prompt_tokens(body) - 50 # Subtract metadata overhead - error = abs(est - case["actual_tokens"]) / case["actual_tokens"] - print(f"{case['name']:<25} | {case['actual_tokens']:<7} | {est:<9} | {error:.1%}") - # Acceptance criteria: within ±25% for these rough heuristics - if error > 0.25: - print(f" --> FAILURE: {case['name']} error exceeds target threshold") - all_passed = False - - assert all_passed, "Token estimation accuracy benchmark failed" - -if __name__ == "__main__": - try: - verify_accuracy() - sys.exit(0) - except AssertionError as e: - print(f"\nERROR: {e}") - sys.exit(1) diff --git a/scripts/get_pr_status.py b/scripts/get_pr_status.py deleted file mode 100644 index d65f65f1..00000000 --- a/scripts/get_pr_status.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -import subprocess -import json -import sys -from typing import Sequence - - -def run_cmd(argv: Sequence[str]) -> str: - """Runs a command and returns stripped stdout.""" - result = subprocess.run(argv, shell=False, capture_output=True, text=True, check=True, timeout=30) - return result.stdout.strip() - - -def get_pr_status(pr_id: str = "") -> None: - """Fetches and prints the status of a PR using gh CLI.""" - cmd = ["gh", "pr", "view"] - if pr_id: - cmd.append(pr_id) - cmd.extend(["--json", "state,reviewDecision,statusCheckRollup"]) - - try: - output = run_cmd(cmd) - data = json.loads(output) - - state = data.get("state") - review = data.get("reviewDecision") or "NONE" - checks = data.get("statusCheckRollup", []) - - # Summarize checks - success_count = 0 - total_count = len(checks) - for check in checks: - # gh CLI returns conclusion for CheckRun and state for StatusContext - conclusion = check.get("conclusion") or check.get("state") - if conclusion == "SUCCESS": - success_count += 1 - - print(f"PR Status: {state}") - print(f"Review Decision: {review}") - print(f"Checks: {success_count}/{total_count} passed") - - except subprocess.CalledProcessError as e: - print(f"Error: Failed to fetch PR status: {e.stderr.strip()}", file=sys.stderr) - sys.exit(1) - except json.JSONDecodeError: - print("Error: Failed to parse gh CLI output", file=sys.stderr) - sys.exit(1) - except Exception as e: - print(f"An unexpected error occurred: {e}", file=sys.stderr) - sys.exit(1) - - -def main(): - pr_id = sys.argv[1] if len(sys.argv) > 1 else "" - get_pr_status(pr_id) - - -if __name__ == "__main__": - main() diff --git a/start-stack.sh b/start-stack.sh index 03bb1220..c9b826dc 100755 --- a/start-stack.sh +++ b/start-stack.sh @@ -31,6 +31,12 @@ if [ -f "$ENV_FILE" ]; then fi # Ensure openssl is installed if we need to generate passwords/keys +if [ -z "$POSTGRES_PASSWORD" ] || [ -z "$NEXTAUTH_SECRET" ] || [ -z "$SALT" ] || [ -z "$ENCRYPTION_KEY" ] || [ -z "$LITELLM_MASTER_KEY" ]; then + if ! command -v openssl &>/dev/null; then + echo "❌ Error: 'openssl' is required to generate secure random keys but was not found in PATH." + exit 1 + fi +fi if [ -z "$OPENROUTER_API_KEY" ]; then @@ -95,7 +101,7 @@ else echo "⚠️ Warning: Host agy daemon not responding on port 5005" fi -if [ -z "$POSTGRES_PASSWORD" ] || [ -z "$NEXTAUTH_SECRET" ] || [ -z "$SALT" ] || [ -z "$ENCRYPTION_KEY" ] || [ -z "$LITELLM_MASTER_KEY" ] || [ -z "$ROUTER_API_KEY" ] || [ -z "$MINIO_ROOT_USER" ] || [ -z "$MINIO_ROOT_PASSWORD" ]; then +if [ -z "$NEXTAUTH_SECRET" ] || [ -z "$SALT" ] || [ -z "$ENCRYPTION_KEY" ] || [ -z "$LITELLM_MASTER_KEY" ] || [ -z "$ROUTER_API_KEY" ]; then if ! command -v openssl &>/dev/null; then echo "❌ Error: 'openssl' is required to generate secure random keys but was not found in PATH." exit 1 @@ -135,43 +141,12 @@ if [ -z "$LITELLM_MASTER_KEY" ]; then exit 1 fi -if [ -z "$LANGFUSE_INIT_USER_PASSWORD" ]; then - LANGFUSE_INIT_USER_PASSWORD="$(openssl rand -hex 16)" - echo "LANGFUSE_INIT_USER_PASSWORD=\"$LANGFUSE_INIT_USER_PASSWORD\"" >> "$ENV_FILE" - echo "✓ Generated new LANGFUSE_INIT_USER_PASSWORD and saved to $ENV_FILE" -fi - -if [ -z "$REDIS_AUTH" ]; then - REDIS_AUTH="$(openssl rand -hex 16)" - echo "REDIS_AUTH=\"$REDIS_AUTH\"" >> "$ENV_FILE" - echo "✓ Generated new REDIS_AUTH and saved to $ENV_FILE" -fi - -if [ -z "$CLICKHOUSE_PASSWORD" ]; then - CLICKHOUSE_PASSWORD="$(openssl rand -hex 16)" - echo "CLICKHOUSE_PASSWORD=\"$CLICKHOUSE_PASSWORD\"" >> "$ENV_FILE" - echo "✓ Generated new CLICKHOUSE_PASSWORD and saved to $ENV_FILE" -fi - if [ -z "$ROUTER_API_KEY" ]; then ROUTER_API_KEY="$(openssl rand -hex 32)" echo "ROUTER_API_KEY=\"$ROUTER_API_KEY\"" >> "$ENV_FILE" echo "✓ Generated new ROUTER_API_KEY and saved to $ENV_FILE" fi -if [ -z "$MINIO_ROOT_USER" ]; then - MINIO_ROOT_USER="minio-$(openssl rand -hex 4)" - echo "MINIO_ROOT_USER=\"$MINIO_ROOT_USER\"" >> "$ENV_FILE" - echo "✓ Generated new MINIO_ROOT_USER and saved to $ENV_FILE" -fi - -if [ -z "$MINIO_ROOT_PASSWORD" ]; then - MINIO_ROOT_PASSWORD="$(openssl rand -hex 16)" - echo "MINIO_ROOT_PASSWORD=\"$MINIO_ROOT_PASSWORD\"" >> "$ENV_FILE" - echo "✓ Generated new MINIO_ROOT_PASSWORD and saved to $ENV_FILE" -fi - - # DYNAMIC_LITELLM_MASTER_KEY_PLACEHOLDER in router config is resolved at runtime from env @@ -280,7 +255,7 @@ setup_minio_buckets() { # Ensure mc alias points to the correct MinIO S3 API port (9002, not 9000) # The default 'local' alias in the MinIO image points to :9000 which is ClickHouse, # not MinIO. We must override it. - podman exec agent-router-pod-minio-s3 mc alias set local http://127.0.0.1:9002 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" 2>/dev/null + podman exec agent-router-pod-minio-s3 mc alias set local http://127.0.0.1:9002 minioadmin minioadmin 2>/dev/null # Create required buckets (idempotent) local BUCKETS=("langfuse-events" "proj-triage-gateway-id") @@ -381,7 +356,7 @@ if podman pod exists agent-router-pod 2>/dev/null; then fi render_pod_yaml() { - export WORKDIR HOME LITELLM_MASTER_KEY POSTGRES_PASSWORD NEXTAUTH_SECRET SALT ENCRYPTION_KEY LANGFUSE_INIT_USER_PASSWORD MINIO_ROOT_USER MINIO_ROOT_PASSWORD + export WORKDIR HOME LITELLM_MASTER_KEY POSTGRES_PASSWORD NEXTAUTH_SECRET SALT ENCRYPTION_KEY python3 - "$WORKDIR/pod.yaml" <<'PY' import os, sys, urllib.parse uid = os.getuid() @@ -396,14 +371,11 @@ placeholders = [ "NEXTAUTH_SECRET_PLACEHOLDER", "SALT_PLACEHOLDER", "ENCRYPTION_KEY_PLACEHOLDER", - "postgres-password-***", - "MINIO_USER_PLACEHOLDER", - "MINIO_PASSWORD_PLACEHOLDER" - "LANGFUSE_INIT_USER_PASSWORD_PLACEHOLDER" + "postgres-password-***" ] for ph in placeholders: if ph not in text: - sys.stderr.write(f"Error: Required placeholder '{ph}' not found in pod.yaml. Ensure you are using the latest version of the template.\n") + sys.stderr.write(f"Error: Required placeholder '{ph}' not found in pod.yaml\n") sys.exit(1) text = text.replace("/home/gpav/Vrac/LAB/AI/LLM-Routing", os.environ["WORKDIR"]) text = text.replace("/home/gpav/", os.environ["HOME"] + "/") @@ -416,9 +388,6 @@ text = text.replace("postgres-password-***", os.environ["POSTGRES_PASSWORD"]) text = text.replace("NEXTAUTH_SECRET_PLACEHOLDER", os.environ["NEXTAUTH_SECRET"]) text = text.replace("SALT_PLACEHOLDER", os.environ["SALT"]) text = text.replace("ENCRYPTION_KEY_PLACEHOLDER", os.environ["ENCRYPTION_KEY"]) -text = text.replace("MINIO_USER_PLACEHOLDER", os.environ["MINIO_ROOT_USER"]) -text = text.replace("MINIO_PASSWORD_PLACEHOLDER", os.environ["MINIO_ROOT_PASSWORD"]) -text = text.replace("LANGFUSE_INIT_USER_PASSWORD_PLACEHOLDER", os.environ["LANGFUSE_INIT_USER_PASSWORD"]) sys.stdout.write(text) PY } diff --git a/test_host_agy_daemon.py b/test_host_agy_daemon.py deleted file mode 100644 index e0cae61c..00000000 --- a/test_host_agy_daemon.py +++ /dev/null @@ -1,44 +0,0 @@ -import os -import json -import pytest -from unittest.mock import patch, mock_open - -import host_agy_daemon - -def test_get_last_conversation_id_success(monkeypatch): - test_data = {"/test/path": "conv_123"} - monkeypatch.setattr(os, "getcwd", lambda: "/test/path") - monkeypatch.setattr(os.path, "exists", lambda x: True) - - m_open = mock_open(read_data=json.dumps(test_data)) - with patch("builtins.open", m_open): - assert host_agy_daemon.get_last_conversation_id() == "conv_123" - -def test_get_last_conversation_id_not_found(monkeypatch): - test_data = {"/other/path": "conv_123"} - monkeypatch.setattr(os, "getcwd", lambda: "/test/path") - monkeypatch.setattr(os.path, "exists", lambda x: True) - - m_open = mock_open(read_data=json.dumps(test_data)) - with patch("builtins.open", m_open): - assert host_agy_daemon.get_last_conversation_id() is None - -def test_get_last_conversation_id_file_missing(monkeypatch): - monkeypatch.setattr(os.path, "exists", lambda x: False) - assert host_agy_daemon.get_last_conversation_id() is None - -def test_get_last_conversation_id_exception(monkeypatch): - monkeypatch.setattr(os.path, "exists", lambda x: True) - # Invalid JSON to trigger JSONDecodeError - m_open = mock_open(read_data="{invalid json") - with patch("builtins.open", m_open): - assert host_agy_daemon.get_last_conversation_id() is None - -def test_get_last_conversation_id_io_error(monkeypatch): - monkeypatch.setattr(os.path, "exists", lambda x: True) - - def raise_error(*args, **kwargs): - raise IOError("permission denied") - - with patch("builtins.open", side_effect=raise_error): - assert host_agy_daemon.get_last_conversation_id() is None diff --git a/test_pie_chart_gradient.py b/test_pie_chart_gradient.py index c9e85499..1d9a33bd 100644 --- a/test_pie_chart_gradient.py +++ b/test_pie_chart_gradient.py @@ -4,26 +4,22 @@ @pytest.fixture def mock_stats(): - # Patch router.main.stats with a real dictionary containing tool_tokens - # to ensure the function under test reads from the correct key. - test_stats = { - "tool_tokens": { - "tree": 0, - "shell": 0, - "write": 0, - "view": 0, - "other": 0 - } - } - with patch("router.main.stats", test_stats): - yield test_stats + with patch("router.main.stats") as mock_stats_obj: + yield mock_stats_obj def test_get_pie_chart_gradient_empty(mock_stats): + mock_stats.__getitem__.return_value = { + "tree": 0, + "shell": 0, + "write": 0, + "view": 0, + "other": 0 + } result = get_pie_chart_gradient() assert result == "background: rgba(255, 255, 255, 0.05);" def test_get_pie_chart_gradient_one_tool(mock_stats): - mock_stats["tool_tokens"] = { + mock_stats.__getitem__.return_value = { "tree": 100, "shell": 0, "write": 0, @@ -34,7 +30,7 @@ def test_get_pie_chart_gradient_one_tool(mock_stats): assert result == "background: conic-gradient(#34d399 0.0% 100.0%);" def test_get_pie_chart_gradient_multiple_tools(mock_stats): - mock_stats["tool_tokens"] = { + mock_stats.__getitem__.return_value = { "tree": 50, "shell": 25, "write": 25, @@ -45,7 +41,7 @@ def test_get_pie_chart_gradient_multiple_tools(mock_stats): assert result == "background: conic-gradient(#34d399 0.0% 50.0%, #fbbf24 50.0% 75.0%, #a78bfa 75.0% 100.0%);" def test_get_pie_chart_gradient_unrecognized_tool(mock_stats): - mock_stats["tool_tokens"] = { + mock_stats.__getitem__.return_value = { "unknown_tool": 100 } result = get_pie_chart_gradient() diff --git a/tests/test_get_pr_status.py b/tests/test_get_pr_status.py deleted file mode 100644 index 93885a14..00000000 --- a/tests/test_get_pr_status.py +++ /dev/null @@ -1,84 +0,0 @@ -import pytest -import subprocess -import json -from unittest.mock import patch, MagicMock -from scripts.get_pr_status import run_cmd, get_pr_status - -def test_run_cmd_success(): - output = run_cmd(["echo", "hello"]) - assert output == "hello" - -def test_run_cmd_strips_whitespace(): - output = run_cmd(["echo", " hello "]) - assert output == "hello" - -def test_run_cmd_error(): - with pytest.raises(subprocess.CalledProcessError): - run_cmd(["false"]) - -@patch("scripts.get_pr_status.subprocess.run") -def test_run_cmd_timeout(mock_run): - # run_cmd has a 30s timeout. We mock subprocess.run to raise it immediately. - mock_run.side_effect = subprocess.TimeoutExpired(["sleep", "0.1"], 30) - with pytest.raises(subprocess.TimeoutExpired): - run_cmd(["sleep", "0.1"]) - -@patch("scripts.get_pr_status.run_cmd") -def test_get_pr_status_success(mock_run_cmd, capsys): - mock_data = { - "state": "OPEN", - "reviewDecision": "APPROVED", - "statusCheckRollup": [ - {"conclusion": "SUCCESS", "name": "test1"}, - {"state": "SUCCESS", "name": "test2"}, - {"conclusion": "FAILURE", "name": "test3"} - ] - } - mock_run_cmd.return_value = json.dumps(mock_data) - - get_pr_status("123") - - captured = capsys.readouterr() - assert "PR Status: OPEN" in captured.out - assert "Review Decision: APPROVED" in captured.out - assert "Checks: 2/3 passed" in captured.out - mock_run_cmd.assert_called_once_with(["gh", "pr", "view", "123", "--json", "state,reviewDecision,statusCheckRollup"]) - -@patch("scripts.get_pr_status.run_cmd") -def test_get_pr_status_no_id(mock_run_cmd, capsys): - mock_data = { - "state": "MERGED", - "reviewDecision": None, - "statusCheckRollup": [] - } - mock_run_cmd.return_value = json.dumps(mock_data) - - get_pr_status() - - captured = capsys.readouterr() - assert "PR Status: MERGED" in captured.out - assert "Review Decision: NONE" in captured.out - assert "Checks: 0/0 passed" in captured.out - mock_run_cmd.assert_called_once_with(["gh", "pr", "view", "--json", "state,reviewDecision,statusCheckRollup"]) - -@patch("scripts.get_pr_status.run_cmd") -def test_get_pr_status_error(mock_run_cmd, capsys): - mock_run_cmd.side_effect = subprocess.CalledProcessError(1, ["gh"], stderr="gh not found") - - with pytest.raises(SystemExit) as e: - get_pr_status("123") - - assert e.value.code == 1 - captured = capsys.readouterr() - assert "Error: Failed to fetch PR status: gh not found" in captured.err - -@patch("scripts.get_pr_status.run_cmd") -def test_get_pr_status_invalid_json(mock_run_cmd, capsys): - mock_run_cmd.return_value = "invalid json" - - with pytest.raises(SystemExit) as e: - get_pr_status("123") - - assert e.value.code == 1 - captured = capsys.readouterr() - assert "Error: Failed to parse gh CLI output" in captured.err diff --git a/tests/test_read_annotations_async.py b/tests/test_read_annotations_async.py deleted file mode 100644 index b401bc8b..00000000 --- a/tests/test_read_annotations_async.py +++ /dev/null @@ -1,128 +0,0 @@ -import pytest -from unittest.mock import patch, AsyncMock, MagicMock -import copy -import sys -import os -import json -import asyncio - -# Ensure the root directory is in the path -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -import router.main -from router.main import _read_annotations_async - -@pytest.fixture(autouse=True) -def clear_cache(): - router.main._annotations_cache.clear() - yield - -@pytest.mark.asyncio -async def test_read_annotations_async_initial_read(): - fake_path = "/tmp/annotations.json" - fake_data = {"annotation1": "data1"} - - # Mock aiofiles.open - mock_file = AsyncMock() - mock_file.read.return_value = '{"annotation1": "data1"}' - - mock_context_manager = MagicMock() - mock_context_manager.__aenter__ = AsyncMock(return_value=mock_file) - mock_context_manager.__aexit__ = AsyncMock(return_value=False) - - mock_aiofiles_open = MagicMock(return_value=mock_context_manager) - - with patch("os.path.getmtime", return_value=100.0) as mock_getmtime, \ - patch("aiofiles.open", mock_aiofiles_open) as mock_open: - - result = await _read_annotations_async(fake_path) - - mock_getmtime.assert_called_once_with(fake_path) - mock_open.assert_called_once_with(fake_path, "r", encoding="utf-8") - assert result == fake_data - - # Verify cache is populated - assert fake_path in router.main._annotations_cache - assert router.main._annotations_cache[fake_path]["mtime"] == 100.0 - assert router.main._annotations_cache[fake_path]["data"] == fake_data - -@pytest.mark.asyncio -async def test_read_annotations_async_cache_hit(): - fake_path = "/tmp/annotations.json" - fake_data = {"annotation1": "data1"} - - # Pre-populate cache - router.main._annotations_cache[fake_path] = {"mtime": 100.0, "data": fake_data} - - # Mock aiofiles.open (should NOT be called) - mock_aiofiles_open = MagicMock() - - with patch("os.path.getmtime", return_value=100.0) as mock_getmtime, \ - patch("aiofiles.open", mock_aiofiles_open) as mock_open: - - result = await _read_annotations_async(fake_path) - - mock_getmtime.assert_called_once_with(fake_path) - mock_open.assert_not_called() - assert result == fake_data - -@pytest.mark.asyncio -async def test_read_annotations_async_cache_invalidation(): - fake_path = "/tmp/annotations.json" - fake_data_old = {"annotation1": "data1"} - fake_data_new = {"annotation2": "data2"} - - # Pre-populate cache with old mtime - router.main._annotations_cache[fake_path] = {"mtime": 100.0, "data": fake_data_old} - - mock_file = AsyncMock() - mock_file.read.return_value = '{"annotation2": "data2"}' - - mock_context_manager = MagicMock() - mock_context_manager.__aenter__ = AsyncMock(return_value=mock_file) - mock_context_manager.__aexit__ = AsyncMock(return_value=False) - - mock_aiofiles_open = MagicMock(return_value=mock_context_manager) - - with patch("os.path.getmtime", return_value=200.0) as mock_getmtime, \ - patch("aiofiles.open", mock_aiofiles_open) as mock_open: - - result = await _read_annotations_async(fake_path) - - mock_getmtime.assert_called_once_with(fake_path) - mock_open.assert_called_once_with(fake_path, "r", encoding="utf-8") - assert result == fake_data_new - - # Verify cache is updated - assert router.main._annotations_cache[fake_path]["mtime"] == 200.0 - assert router.main._annotations_cache[fake_path]["data"] == fake_data_new - -@pytest.mark.asyncio -async def test_read_annotations_async_deepcopy(): - fake_path = "/tmp/annotations.json" - fake_data = {"annotation1": {"nested": "value"}} - - # Pre-populate cache - router.main._annotations_cache[fake_path] = {"mtime": 100.0, "data": fake_data} - - with patch("os.path.getmtime", return_value=100.0): - # First read - result1 = await _read_annotations_async(fake_path) - - # Mutate the result - result1["annotation1"]["nested"] = "mutated" - - # Second read - result2 = await _read_annotations_async(fake_path) - - # Verify second read returns original data, not mutated - assert result2["annotation1"]["nested"] == "value" - assert router.main._annotations_cache[fake_path]["data"]["annotation1"]["nested"] == "value" - -@pytest.mark.asyncio -async def test_read_annotations_async_file_not_found(): - fake_path = "/tmp/annotations.json" - - with patch("os.path.getmtime", side_effect=FileNotFoundError): - with pytest.raises(FileNotFoundError): - await _read_annotations_async(fake_path)