Active Tool Split %
- {pie_legend_html} +Per-model usage, token consumption & cost are tracked with full trace detail in Langfuse.
- Open Langfuse Observability β + Open Langfuse Observability βdiff --git a/pr_description.txt b/pr_description.txt new file mode 100644 index 00000000..36d5ba85 --- /dev/null +++ b/pr_description.txt @@ -0,0 +1,9 @@ +π― **What:** The `_memory_entry` helper function in `router/memory_mcp.py` was previously completely untested. This function is responsible for converting a raw dictionary representation of a memory entry from LiteLLM into a structured dictionary used by the MCP. + +π **Coverage:** The new `test_memory_mcp.py` file covers several scenarios for the `_memory_entry` dictionary translation helper: +- **Happy Path:** Tests that valid and complete memory dictionaries are properly parsed, mapped, and typed. +- **Invalid Key:** Verifies that if a memory has a key that does not start with `"memory:"`, the function properly returns `None`. +- **Malformed/String Value:** Tests the graceful fallback when a memory value is a raw string instead of the expected JSON payload, ensuring it still parses the string into the `data` field and initializes an empty `tags` array without throwing a JSON decode error. +- **Missing Fields:** Tests dictionary inputs that are missing either `"key"` or `"value"` or both to ensure graceful behavior, fallback values, and early exits without `KeyError`s. + +β¨ **Result:** Increased code reliability and coverage for the core memory data transformation logic with pure, isolated dictionary tests ensuring zero runtime side effects. diff --git a/router/agy_proxy.py b/router/agy_proxy.py index 05368826..16adb8e9 100644 --- a/router/agy_proxy.py +++ b/router/agy_proxy.py @@ -43,8 +43,6 @@ async def save(self) -> None: logger = logging.getLogger("agy-proxy") -AGY_DAEMON_URL = (os.getenv("AGY_DAEMON_URL") or "http://127.0.0.1:5005").rstrip("/") - # In container: mounted from host /home/gpav/.local/bin/agy AGY_BINARY = os.environ.get("AGY_BINARY_PATH", "/usr/local/bin/agy") if not os.path.exists(AGY_BINARY): @@ -93,7 +91,7 @@ async def _run_agy_print(client: httpx.AsyncClient, prompt: str, model_override: """ Forward the agy execution request to the host-side agy daemon. """ - url = f"{AGY_DAEMON_URL}/run" + url = "http://127.0.0.1:5005/run" payload = { "prompt": prompt, "model_override": model_override, @@ -301,7 +299,7 @@ async def try_agy_proxy(prompt: str, messages: list = None, tier_timeout = min(AGY_TIMEOUT_SECS, remaining) if stream: - url = f"{AGY_DAEMON_URL}/run" + url = "http://127.0.0.1:5005/run" payload = { "prompt": proxy_prompt, "model_override": tier["env_override"], diff --git a/router/main.py b/router/main.py index 23a465e4..07803a00 100644 --- a/router/main.py +++ b/router/main.py @@ -7,68 +7,17 @@ import logging import copy import tempfile -import uuid -import codecs -import sqlite3 -import uvicorn -from datetime import datetime, timezone -from pathlib import Path -from typing import Dict, Optional, Union -from contextlib import asynccontextmanager - import yaml import httpx -try: - import asyncpg -except ImportError: - asyncpg = None - -try: - import langfuse -except ImportError: - langfuse = None import redis.asyncio as aioredis +from contextlib import asynccontextmanager from fastapi import FastAPI, Request, HTTPException, Response from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel - +from pathlib import Path from circuit_breaker import get_breaker -try: - from agy_proxy import try_agy_proxy -except ImportError: - try_agy_proxy = None - -from urllib.parse import urlparse - -# Global Configuration from Environment -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("/") -LANGFUSE_URL = (os.getenv("LANGFUSE_URL") or "http://127.0.0.1:3001").rstrip("/") -VALKEY_HOST = os.getenv("VALKEY_HOST") or "127.0.0.1" - -def _get_valkey_port() -> int: - val = os.getenv("VALKEY_PORT") - if not val: - return 6379 - try: - return int(val) - except ValueError: - return 6379 - -VALKEY_PORT = _get_valkey_port() - -def _get_port_suffix(url: str) -> str: - try: - port = urlparse(url).port - return f":{port}" if port else "" - except Exception: - return "" - -LITELLM_PORT = _get_port_suffix(LITELLM_URL) -LLAMA_SERVER_PORT = _get_port_suffix(LLAMA_SERVER_URL) -LANGFUSE_PORT = _get_port_suffix(LANGFUSE_URL) - +from pydantic import BaseModel +from typing import Dict, Optional, Union _redis_client = None _redis_last_init_attempt = 0.0 @@ -84,8 +33,8 @@ def get_redis(): return None _redis_last_init_attempt = now try: - host = VALKEY_HOST - port = VALKEY_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: @@ -203,16 +152,15 @@ def get_langfuse(): global _langfuse_client if _langfuse_client is None: try: - if langfuse is None: - raise ImportError("langfuse is not installed") + import langfuse _langfuse_client = langfuse.Langfuse( public_key=os.getenv("LANGFUSE_PUBLIC_KEY", ""), secret_key=os.getenv("LANGFUSE_SECRET_KEY", ""), - host=LANGFUSE_URL, + host=os.getenv("LANGFUSE_HOST", "http://127.0.0.1:3001"), release="llm-triage-router-v1", ) logger.info("Langfuse client initialized") - except Exception as e: + except (ImportError, ValueError, TypeError) as e: 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 @@ -273,7 +221,7 @@ async def push_aggregate_scores(): port = config.get("server", {}).get("port", 5000) router_model_conf = config.get("router", {}).get("router_model", {}) -router_api_base = router_model_conf.get("api_base", f"{LLAMA_SERVER_URL}/v1") +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_model_name = router_model_conf.get("model", "qwen-0.8b-routing") @@ -424,8 +372,7 @@ async def save_persisted_stats(force=False): async def _purge_stale_deployments(db_url: str, pattern: str): """Purge stale deployments matching the pattern from LiteLLM's DB.""" - if asyncpg is None: - raise ImportError("asyncpg is not installed") + import asyncpg conn = await asyncpg.connect(db_url) try: await conn.execute( @@ -441,7 +388,7 @@ async def sync_adaptive_router_roster(master_key: str): logger.warning("No LITELLM_MASTER_KEY β skipping roster sync") return headers = {"Authorization": f"Bearer {master_key}", "Content-Type": "application/json"} - admin_url = LITELLM_URL + admin_url = "http://127.0.0.1:4000" try: async with httpx.AsyncClient(timeout=5.0) as client: r = await client.get("https://openrouter.ai/api/v1/models") @@ -598,7 +545,7 @@ async def _register_ollama_models_in_db(master_key: str): logger.warning("No LiteLLM master key provided β skipping Ollama DB registration") return - admin_url = LITELLM_URL + admin_url = os.getenv("LITELLM_ADMIN_URL", "http://127.0.0.1:4000") headers = {"Authorization": f"Bearer {master_key}", "Content-Type": "application/json"} ollama_models = [] @@ -711,10 +658,10 @@ async def lifespan(app: FastAPI): get_http_client() await sync_cooldowns_from_valkey() - litellm_ready_url = f"{LITELLM_URL}/health/readiness" + litellm_ready_url = "http://127.0.0.1:4000/health/readiness" litellm_master_key = os.getenv("LITELLM_MASTER_KEY", "") max_wait = 180 - logger.info(f"β³ Waiting for LiteLLM on {LITELLM_URL} (max {max_wait}s)...") + logger.info(f"β³ Waiting for LiteLLM on :4000 (max {max_wait}s)...") for i in range(max_wait): try: async with httpx.AsyncClient(timeout=2.0) as client: @@ -1140,6 +1087,7 @@ def get_goose_sessions() -> list: if not os.path.exists(db_path): return [] try: + import sqlite3 conn = sqlite3.connect(db_path, timeout=1.0) conn.row_factory = sqlite3.Row cursor = conn.cursor() @@ -1162,7 +1110,7 @@ async def get_llamacpp_metrics() -> dict: try: async with httpx.AsyncClient(timeout=3.0) as client: # Fetch model list - r = await client.get(f"{LLAMA_SERVER_URL}/v1/models") + r = await client.get("http://127.0.0.1:8080/v1/models") if r.status_code == 200: data = r.json() for m in data.get("data", []): @@ -1177,7 +1125,7 @@ async def get_llamacpp_metrics() -> dict: "n_embd": meta.get("n_embd"), }) # Fetch props for build info - r2 = await client.get(f"{LLAMA_SERVER_URL}/props") + r2 = await client.get("http://127.0.0.1:8080/props") if r2.status_code == 200: props = r2.json() result["build"] = props.get("build_info", "unknown") @@ -1185,7 +1133,7 @@ async def get_llamacpp_metrics() -> dict: 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) if slot_model: - r3 = await client.get(f"{LLAMA_SERVER_URL}/slots?model={slot_model}") + r3 = await client.get(f"http://127.0.0.1:8080/slots?model={slot_model}") if r3.status_code == 200: slots_data = r3.json() for s in slots_data: @@ -1221,6 +1169,7 @@ def _load_aa_scores(): if _AA_SCORES_LOADED: 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) @@ -1239,24 +1188,28 @@ def compute_free_model_score(m: dict) -> float: 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 + import datetime as _dt payload = { "models": free_models, - "updated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "") + "Z", + "updated_at": _dt.datetime.utcnow().isoformat() + "Z", "count": len(free_models) } try: with open("/config/router_dir/free_models_roster.json", "w") as f: - json.dump(payload, f, indent=2) + _json.dump(payload, f, indent=2) except Exception: pass def _save_best_model_to_disk(best_model: dict) -> None: """Persist the best free model to a JSON file Ralph can read.""" - payload = {**best_model, "updated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "") + "Z"} + import json as _json + import datetime as _dt + payload = {**best_model, "updated_at": _dt.datetime.utcnow().isoformat() + "Z"} try: with open("/config/router_dir/best_free_model.json", "w") as f: - json.dump(payload, f, indent=2) + _json.dump(payload, f, indent=2) except Exception: pass # Non-critical β Ralph falls back gracefully @@ -1361,8 +1314,8 @@ def get_pie_chart_gradient() -> str: @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.""" - litellm_base = f"{LITELLM_URL}/v1/memory" + """Proxies memory API calls to the LiteLLM gateway on port 4000.""" + litellm_base = "http://127.0.0.1:4000/v1/memory" # Resolve the destination URL url = f"{litellm_base}{path}" @@ -1415,7 +1368,7 @@ async def proxy_models(): async with httpx.AsyncClient(timeout=10.0) as client: auth_header = "Bearer " + (litellm_key or "") r = await client.get( - f"{LITELLM_URL}/v1/models", + "http://127.0.0.1:4000/v1/models", headers={"Authorization": auth_header} ) data = r.json() @@ -1589,8 +1542,8 @@ async def chat_completions(request: Request): if should_try_agy: agy_span_obj = None try: - if try_agy_proxy is None: - raise ImportError("agy_proxy is not available") + from agy_proxy import try_agy_proxy + last_prompt = "" for msg in reversed(messages): if not isinstance(msg, dict): @@ -1644,6 +1597,7 @@ async def chat_completions(request: Request): # Real native stream generator 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 @@ -1735,6 +1689,7 @@ async def native_agy_stream_generator(stream_gen, model_name): 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 @@ -1901,6 +1856,7 @@ async def execute_proxy(model_name: str): 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 = "" @@ -2162,10 +2118,10 @@ async def get_dashboard_data(): """Fetch all metrics and pre-compute HTML snippets for the dashboard.""" await sync_cooldowns_from_valkey() # 1. Run live health checks - valkey_status = await check_tcp_port(VALKEY_HOST, VALKEY_PORT) - litellm_status = await check_http_endpoint(f"{LITELLM_URL}/") - llama_server_status = await check_http_endpoint(f"{LLAMA_SERVER_URL}/health") - langfuse_status = await check_http_endpoint(LANGFUSE_URL) + valkey_status = await check_tcp_port("127.0.0.1", 6379) + litellm_status = await check_http_endpoint("http://127.0.0.1:4000/") + llama_server_status = await check_http_endpoint("http://127.0.0.1:8080/health") + langfuse_status = await check_http_endpoint("http://127.0.0.1:3001") # 1c. Check Gemini OAuth token status oauth_status = await asyncio.to_thread(get_gemini_oauth_status) @@ -2487,7 +2443,7 @@ async def get_dashboard_data(): "t_tokens": t_tokens, "llamacpp_models_html": llamacpp_models_html, "llamacpp_slots_html": llamacpp_slots_html, - "llamacpp": llamacpp, + "llamacpp_build": llamacpp["build"], "avg_triage_latency_ms": stats["avg_triage_latency_ms"], "avg_proxy_latency_ms": stats["avg_proxy_latency_ms"], "cache_hits": stats["cache_hits"], @@ -2525,7 +2481,6 @@ async def get_dashboard(): t_tokens = data["t_tokens"] llamacpp_models_html = data["llamacpp_models_html"] llamacpp_slots_html = data["llamacpp_slots_html"] - llamacpp = data["llamacpp"] avg_triage_latency_ms = data["avg_triage_latency_ms"] avg_proxy_latency_ms = data["avg_proxy_latency_ms"] cache_hits = data["cache_hits"] @@ -2931,10 +2886,93 @@ async def get_dashboard(): }}
@@ -2949,7 +2987,9 @@ async def get_dashboard(): - {oauth_banner_html} +Per-model usage, token consumption & cost are tracked with full trace detail in Langfuse.
- Open Langfuse Observability β + Open Langfuse Observability β