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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
python-version: '3.11'

- name: Install dependencies
run: pip install httpx==0.28.1 pytest pytest-asyncio anyio pyyaml fastapi uvicorn python-multipart asyncpg langfuse redis
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
Expand Down
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ All core containers are configured with **Kubernetes-style liveness and readines
| **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 |
| **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 | — |
Expand Down Expand Up @@ -801,5 +801,3 @@ This project is supported by a dedicated NotebookLM companion notebook:
* **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.


11 changes: 4 additions & 7 deletions get_pr_status.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import subprocess
import shlex

def run_cmd(cmd):
# Fix the issues from Sourcery review!
# 1. Provide a static list of strings for args rather than a single string.
# 2. Use shell=False
args = shlex.split(cmd)
result = subprocess.run(args, shell=False, capture_output=True, text=True)
def run_cmd(cmd_list):
# Expect a list of strings directly instead of a single string to avoid command injection
# Enable check=True to surface non-zero exit codes as subprocess.CalledProcessError
result = subprocess.run(cmd_list, shell=False, capture_output=True, text=True, check=True)
return result.stdout.strip()
44 changes: 32 additions & 12 deletions litellm/tests/test_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,38 @@

mock_proxy_cli = MagicMock()

with patch('os.path.exists', return_value=False), \
patch('builtins.print'), \
patch('time.sleep'), \
patch('os.execvp'), \
patch('sys.stdout.flush'), \
patch('glob.glob', return_value=[]), \
patch('builtins.open'):

sys.modules['litellm'] = mock_litellm
sys.modules['litellm.proxy'] = MagicMock()
sys.modules['litellm.proxy.proxy_cli'] = mock_proxy_cli
spec.loader.exec_module(entrypoint)
# Mock socket instance for import-time check_tcp_port execution
mock_socket_instance = MagicMock()
mock_socket_instance.connect_ex.return_value = 0

# Save original modules to avoid leaking fake ones globally
orig_modules = {
'litellm': sys.modules.get('litellm'),
'litellm.proxy': sys.modules.get('litellm.proxy'),
'litellm.proxy.proxy_cli': sys.modules.get('litellm.proxy.proxy_cli')
}

try:
with patch('os.path.exists', return_value=False), \
patch('builtins.print'), \
patch('time.sleep'), \
patch('os.execvp'), \
patch('sys.stdout.flush'), \
patch('glob.glob', return_value=[]), \
patch('socket.socket', return_value=mock_socket_instance), \
patch('builtins.open'):

sys.modules['litellm'] = mock_litellm
sys.modules['litellm.proxy'] = MagicMock()
sys.modules['litellm.proxy.proxy_cli'] = mock_proxy_cli
spec.loader.exec_module(entrypoint)
finally:
# Restore original modules state
for k, v in orig_modules.items():
if v is None:
sys.modules.pop(k, None)
else:
sys.modules[k] = v

def test_check_tcp_port_success():
with patch('socket.socket') as mock_socket_class:
Expand Down
16 changes: 11 additions & 5 deletions pod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,14 @@ spec:
timeoutSeconds: 2
name: valkey-lf
readinessProbe:
tcpSocket:
port: 6380
exec:
command:
- valkey-cli
- -p
- "6380"
- -a
- langfuse-redis-2026
- ping
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
Expand All @@ -249,13 +255,13 @@ spec:
- name: DATABASE_URL
value: postgresql://postgres:***@127.0.0.1:5432/langfuse
- name: NEXTAUTH_SECRET
value: my-super-secret-nextauth-token-2026
value: NEXTAUTH_SECRET_PLACEHOLDER
- name: NEXTAUTH_URL
value: http://localhost:3001
- name: SALT
value: my-super-strong-salt-token-2026-value-1234
value: SALT_PLACEHOLDER
- name: ENCRYPTION_KEY
value: 4c265d39d04389f069225db1e88726727a090e7fc6275e8c910b81aa4b763135
value: ENCRYPTION_KEY_PLACEHOLDER
- name: HOSTNAME
value: 0.0.0.0
- name: PORT
Expand Down
2 changes: 1 addition & 1 deletion router/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 uvicorn httpx pyyaml python-multipart asyncpg langfuse redis
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/
Expand Down
23 changes: 18 additions & 5 deletions router/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,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", "")
Expand Down Expand Up @@ -3328,14 +3335,16 @@ async def get_visualizer():


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."""
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra="forbid")

tier: Union[int, str, None] = None
note: Optional[str] = Field(default=None, max_length=1000)
ts: Optional[str] = None
ts: Optional[str] = Field(default=None, max_length=100)

@field_validator("tier")
@classmethod
Expand All @@ -3360,12 +3369,16 @@ 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 in data:
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').")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down
60 changes: 56 additions & 4 deletions start-stack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,36 @@ mkdir -p valkey-data postgres-data langfuse-data clickhouse-data redis-lf-data m

ENV_FILE="${WORKDIR}/.env"

# Ensure the env file exists and has secure permissions (owner read/write only)
if [ ! -f "$ENV_FILE" ]; then
touch "$ENV_FILE"
chmod 600 "$ENV_FILE"
fi

# 1. Load or prompt for OpenRouter API Key
if [ -f "$ENV_FILE" ]; then
set -a
source "$ENV_FILE"
set +a
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
if [ -t 0 ]; then
echo "🔑 OpenRouter API Key not found."
echo -n "Please enter your OpenRouter API Key (input will be hidden): "
read -rs OPENROUTER_API_KEY
echo ""
echo "OPENROUTER_API_KEY=\"$OPENROUTER_API_KEY\"" > "$ENV_FILE"
chmod 644 "$ENV_FILE"
chmod 600 "$ENV_FILE"
echo "✓ API key saved securely to $ENV_FILE"
else
echo "❌ Error: OPENROUTER_API_KEY is not set in your environment or in $ENV_FILE"
Expand Down Expand Up @@ -86,6 +101,35 @@ else
echo "⚠️ Warning: Host agy daemon not responding on port 5005"
fi

if [ -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

# Ensure the env file exists and has secure permissions (owner read/write only)
touch "$ENV_FILE"
chmod 600 "$ENV_FILE"

if [ -z "$NEXTAUTH_SECRET" ]; then
NEXTAUTH_SECRET="$(openssl rand -base64 32)"
echo "NEXTAUTH_SECRET=\"$NEXTAUTH_SECRET\"" >> "$ENV_FILE"
echo "✓ Generated new NEXTAUTH_SECRET and saved to $ENV_FILE"
fi

if [ -z "$SALT" ]; then
SALT="$(openssl rand -hex 16)"
echo "SALT=\"$SALT\"" >> "$ENV_FILE"
echo "✓ Generated new SALT and saved to $ENV_FILE"
fi

if [ -z "$ENCRYPTION_KEY" ]; then
ENCRYPTION_KEY="$(openssl rand -hex 32)"
echo "ENCRYPTION_KEY=\"$ENCRYPTION_KEY\"" >> "$ENV_FILE"
echo "✓ Generated new ENCRYPTION_KEY and saved to $ENV_FILE"
fi
Comment on lines +115 to +131

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-high high

Security & Robustness Improvements

  1. Fail-Fast on Missing openssl:
    If openssl is not installed or fails, the script will silently write empty values to .env and proceed. This results in empty/weak cryptographic keys and salts in pod.yaml, which is a significant security risk. We should check for openssl availability before attempting generation.

  2. Secure .env File Permissions:
    The .env file stores highly sensitive secrets (including OPENROUTER_API_KEY, NEXTAUTH_SECRET, SALT, and ENCRYPTION_KEY). We should ensure the file is secured with chmod 600 (read/write by owner only) to prevent unauthorized local users from reading these secrets.

if [ -z "$NEXTAUTH_SECRET" ] || [ -z "$SALT" ] || [ -z "$ENCRYPTION_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

# Ensure the env file exists and has secure permissions (owner read/write only)
touch "$ENV_FILE"
chmod 600 "$ENV_FILE"

if [ -z "$NEXTAUTH_SECRET" ]; then
    NEXTAUTH_SECRET="$(openssl rand -base64 32)"
    echo "NEXTAUTH_SECRET=\"$NEXTAUTH_SECRET\"" >> "$ENV_FILE"
    echo "✓ Generated new NEXTAUTH_SECRET and saved to $ENV_FILE"
fi

if [ -z "$SALT" ]; then
    SALT="$(openssl rand -hex 16)"
    echo "SALT=\"$SALT\"" >> "$ENV_FILE"
    echo "✓ Generated new SALT and saved to $ENV_FILE"
fi

if [ -z "$ENCRYPTION_KEY" ]; then
    ENCRYPTION_KEY="$(openssl rand -hex 32)"
    echo "ENCRYPTION_KEY=\"$ENCRYPTION_KEY\"" >> "$ENV_FILE"
    echo "✓ Generated new ENCRYPTION_KEY and saved to $ENV_FILE"
fi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Excellent points! I've added a check for the openssl command before attempting to generate any missing secrets and ensure the .env file exists with chmod 600 permissions (read/write by owner only) to secure it from unauthorized access.


if [ -z "$LITELLM_MASTER_KEY" ]; then
LITELLM_MASTER_KEY="sk-litellm-$(openssl rand -hex 16)"
echo "LITELLM_MASTER_KEY=\"$LITELLM_MASTER_KEY\"" >> "$ENV_FILE"
Expand Down Expand Up @@ -305,9 +349,9 @@ if podman pod exists agent-router-pod 2>/dev/null; then
fi

render_pod_yaml() {
export WORKDIR HOME LITELLM_MASTER_KEY POSTGRES_PASSWORD
export WORKDIR HOME LITELLM_MASTER_KEY POSTGRES_PASSWORD NEXTAUTH_SECRET SALT ENCRYPTION_KEY
python3 - "$WORKDIR/pod.yaml" <<'PY'
import os, sys
import os, sys, urllib.parse
uid = os.getuid()
with open(sys.argv[1], "r", encoding="utf-8") as f:
text = f.read()
Expand All @@ -317,6 +361,9 @@ placeholders = [
"/run/user/1000",
"sk-lit...33bf",
"postgres:***",
"NEXTAUTH_SECRET_PLACEHOLDER",
"SALT_PLACEHOLDER",
"ENCRYPTION_KEY_PLACEHOLDER",
"postgres-password-***"
]
for ph in placeholders:
Expand All @@ -327,8 +374,13 @@ text = text.replace("/home/gpav/Vrac/LAB/AI/LLM-Routing", os.environ["WORKDIR"])
text = text.replace("/home/gpav/", os.environ["HOME"] + "/")
text = text.replace("/run/user/1000", f"/run/user/{uid}")
text = text.replace("sk-lit...33bf", os.environ["LITELLM_MASTER_KEY"])
text = text.replace("postgres:***", f"postgres:{os.environ['POSTGRES_PASSWORD']}")
# URL-encode the postgres password for DSN insertion
encoded_password = urllib.parse.quote_plus(os.environ['POSTGRES_PASSWORD'])
text = text.replace("postgres:***", f"postgres:{encoded_password}")
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"])
sys.stdout.write(text)
PY
}
Expand Down
6 changes: 3 additions & 3 deletions test_agy_behavior.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
async def test():
env = os.environ.copy()
cmd = [AGY, "--print", "say hi"]

proc = await asyncio.create_subprocess_exec(
*cmd, env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)

try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(proc.communicate(), timeout=15)
print(f"returncode: {proc.returncode}")
Expand All @@ -23,7 +23,7 @@ async def test():
except asyncio.TimeoutError:
proc.kill()
print("TIMEOUT")

# Also check the log for recent quota lines
log_path = os.path.expanduser("~/.gemini/antigravity-cli/cli.log")
if os.path.exists(log_path):
Expand Down
Loading