Skip to content
Closed
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
60 changes: 58 additions & 2 deletions .agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ When working on this project, always refer to the dedicated **NotebookLM Compani
- Local model benchmark metrics and `llama-server` configurations

### Notebook Details
- **Notebook Name:** `TriageGate-Architect-KB`
- **Notebook Name:** `LLM-Routing-KB`
- **Notebook ID:** `llm-triage-gateway`
- **Notebook URL:** [TriageGate-Architect-KB](https://notebooklm.google.com/notebook/826cbd87-7969-4b0e-a38e-5517b5ab7d28)
- **Notebook URL:** [LLM-Routing-KB](https://notebooklm.google.com/notebook/826cbd87-7969-4b0e-a38e-5517b5ab7d28)

### How to Query
Use the `notebooklm` MCP tools to search or ask questions about this codebase and stack:
Expand All @@ -26,3 +26,59 @@ To prevent directory reorganization regressions, outdated file restorations, or
2. **Directory Rename Safety**: If Git reports conflicts related to moved directories or files, do not manually stage deletions of tracked files from moved directories (e.g., under the old `tests/` or `scripts/` paths) or re-create files at the root level. Resolve conflicts by directing all changes and file operations to the newly refactored paths.
3. **Verify Security Credentials**: Never accept resolutions that overwrite configuration files (`pod.yaml`, `start-stack.sh`) with hardcoded default passwords. Ensure placeholder-based configurations are preserved.
4. **Enforce Test Suite Count**: Run the full unit test suite (`pytest`) after conflict resolution. Verify that the total number of passing tests is equal to or greater than before the resolution.

## Production Deployment Checklist (boy user)

### One-Time Host Prerequisites (already configured on x570.vendeuvre.lan)
- `net.ipv4.ip_unprivileged_port_start=80` persisted in `/etc/sysctl.d/99-unprivileged-ports.conf`
- Host firewall ports `80/tcp` and `443/tcp` opened in `firewalld` (e.g. `sudo firewall-cmd --zone=public --add-port=80/tcp --permanent && sudo firewall-cmd --zone=public --add-port=443/tcp --permanent && sudo firewall-cmd --reload`)
- `boy` SSH host alias in `~/.ssh/config` — use `ssh boy` / `rsync ... boy:` throughout
- Required mount directories created under `boy`'s home:
- `/mnt/DATA/boy/.gemini/`
- `/mnt/DATA/boy/.local/bin/agy` (copy of the `agy` binary)
- `/mnt/DATA/boy/.local/share/goose/`
- `/mnt/DATA/boy/.local/share/keyrings/`
- HAProxy SSL cert: `/mnt/DATA/boy/haproxy/certs/vendeuvre.pem`
- HAProxy config: `/mnt/DATA/boy/haproxy/haproxy.cfg`

### Fresh Deploy Steps (after a PR is merged to master)
```bash
# 1. Clean up old deploy on boy
ssh boy "rm -rf /mnt/DATA/boy/LLM-Routing"

# 2. Clone fresh from master
ssh boy "git clone https://github.com/sheepdestroyer/LLM-Routing.git /mnt/DATA/boy/LLM-Routing"

# 3. Start the full stack (builds and launches all containers)
ssh boy "cd /mnt/DATA/boy/LLM-Routing && ./start-stack.sh --full-rebuild"

# 4. Start (or restart) production HAProxy
ssh boy "podman rm -f production-haproxy || true"
ssh boy "podman run -d --name production-haproxy --restart always --net host \
-v /mnt/DATA/boy/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro \
-v /mnt/DATA/boy/haproxy/certs:/usr/local/etc/haproxy/certs:ro \
docker.io/library/haproxy:alpine"

# 5. Start the host-side agy daemon
ssh boy "pkill -f host_agy_daemon.py || true"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

When starting a background process using nohup ... & over SSH, failing to redirect standard input (stdin) can cause the SSH session to hang or terminate the background process when the connection closes. Redirecting stdin from /dev/null ensures the background process is properly decoupled.

Suggested change
ssh boy "pkill -f host_agy_daemon.py || true"
ssh boy "nohup python3 /mnt/DATA/boy/LLM-Routing/scripts/host_agy_daemon.py >/tmp/agy-daemon.log 2>&1 </dev/null &"

ssh boy "nohup python3 /mnt/DATA/boy/LLM-Routing/scripts/host_agy_daemon.py >/tmp/agy-daemon.log 2>&1 </dev/null &"

# 6. Verify end-to-end
# NOTE: -k is intentional — the HAProxy cert is self-signed (local CA).
# Replace the cert with a trusted CA-signed cert to remove -k.
ssh boy "curl -k -s --resolve x570.vendeuvre.lan:443:127.0.0.1 https://x570.vendeuvre.lan/llm-routing/dashboard" | head -5
```

### Notes
- The `agy-daemon.service` systemd unit cannot be reloaded via `systemctl --user` from
the agent terminal (DBus is not connected). Start the daemon manually with `nohup` as
shown above, or instruct the user to run it in their own session.
- **Sudo Password Precaution**: Always preserve exact bytes (including trailing spaces or newlines) when reading `~/.sudo_password` (e.g. `'your_password_here '`). Stripping whitespace will cause authentication to fail.
- `start-stack.sh` without `--full-rebuild` will do a fast pod restart (reuses images).
Use `--full-rebuild` after code changes or image updates.
- **GitHub CLI Authentication**: If running `gh` commands fails with a 401 error, ensure that `GITHUB_TOKEN` is exported (e.g., mapped from `GITHUB_MCP_PAT` in `~/.bashrc` via `export GITHUB_TOKEN="$GITHUB_MCP_PAT"`).

## GitHub API & Operations Policy
When interacting with the GitHub API or performing repository/PR metadata operations:
1. **Prefer `gh` CLI**: Always prefer using the GitHub CLI (`gh`) instead of executing raw `curl` commands.
2. **REST API Fallback via `gh api`**: If standard `gh` commands (like `gh pr view`) fail due to missing GraphQL token scopes (e.g., `read:org`), use `gh api` to run REST queries against the endpoint (e.g., `gh api repos/{owner}/{repo}/pulls/{pr_number}/reviews`) as it does not require GraphQL scopes.
15 changes: 7 additions & 8 deletions litellm/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,13 @@ model_list:
max_input_tokens: 524288
is_public_model_group: true

# DISABLED 2026-06-08 — 20GB on disk, 23GB GTT (system RAM as GPU buffer).
# Uncomment to re-enable once a lighter model replaces it.
#- litellm_params:
# api_base: http://127.0.0.1:8080/v1
# api_key: local-token
# model: openai/qwen-35b-q4ks
# request_timeout: 300
# model_name: local-qwen-3.6
# Re-enabled 2026-07-08 — llama.cpp on port 8081, alias local-qwen-3.6
- litellm_params:
api_base: http://127.0.0.1:8081/v1
api_key: local-token
model: openai/local-qwen-3.6
request_timeout: 300
model_name: local-qwen-3.6
- litellm_params:
api_base: http://127.0.0.1:8080/v1
api_key: local-token
Expand Down
36 changes: 20 additions & 16 deletions pod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ spec:
value: LITELLM_MASTER_KEY_PLACEHOLDER
- name: OLLAMA_API_KEY
value: OLLAMA_API_KEY_PLACEHOLDER
- name: SERVER_ROOT_PATH
value: /llm-routing/litellm
- name: PROXY_BASE_URL
value: PROXY_BASE_URL_PLACEHOLDER
image: ghcr.io/berriai/litellm:v1.91.0
livenessProbe:
exec:
Expand All @@ -62,7 +66,7 @@ spec:
- python3
- -c
- import urllib.request; urllib.request.urlopen('http://localhost:4000/health/readiness')
initialDelaySeconds: 10
initialDelaySeconds: 45
periodSeconds: 10
timeoutSeconds: 5
volumeMounts:
Expand Down Expand Up @@ -427,48 +431,48 @@ spec:
restartPolicy: Always
volumes:
- hostPath:
path: /home/gpav/Vrac/LAB/AI/LLM-Routing/valkey-data
path: /mnt/DATA/boy/LLM-Routing/valkey-data
name: valkey-storage
- hostPath:
path: /home/gpav/Vrac/LAB/AI/LLM-Routing/litellm
path: /mnt/DATA/boy/LLM-Routing/litellm
name: litellm-config
- hostPath:
path: /home/gpav/Vrac/LAB/AI/LLM-Routing/router
path: /mnt/DATA/boy/LLM-Routing/router
name: router-config
- hostPath:
path: /home/gpav/Vrac/LAB/AI/LLM-Routing/postgres-data
path: /mnt/DATA/boy/LLM-Routing/postgres-data
name: postgres-storage
- hostPath:
path: /home/gpav/Vrac/LAB/AI/LLM-Routing/clickhouse-data
path: /mnt/DATA/boy/LLM-Routing/clickhouse-data
name: clickhouse-storage
- hostPath:
path: /home/gpav/Vrac/LAB/AI/LLM-Routing/redis-lf-data
path: /mnt/DATA/boy/LLM-Routing/redis-lf-data
name: redis-lf-storage
- hostPath:
path: /home/gpav/Vrac/LAB/AI/LLM-Routing/minio-data
path: /mnt/DATA/boy/LLM-Routing/minio-data
name: minio-storage
- hostPath:
path: /home/gpav/.gemini
path: /mnt/DATA/boy/.gemini
name: gemini-secrets
- hostPath:
path: /home/gpav/.local/share/goose
path: /mnt/DATA/boy/.local/share/goose
name: goose-sessions
- hostPath:
path: /home/gpav/.local/bin
path: /mnt/DATA/boy/.local/bin
name: agy-bin
- hostPath:
path: /run/user/1000/bus
path: /run/user/1002/bus
name: dbus-socket
- hostPath:
path: /home/gpav/.local/share/keyrings
path: /mnt/DATA/boy/.local/share/keyrings
name: keyring-store
- hostPath:
path: /run/user/1000
path: /run/user/1002
name: xdg-runtime
- hostPath:
path: /home/gpav/Vrac/LAB/AI/LLM-Routing
path: /mnt/DATA/boy/LLM-Routing
name: env-file
- hostPath:
path: /home/gpav/Vrac/LAB/AI/LLM-Routing/data
path: /mnt/DATA/boy/LLM-Routing/data
type: DirectoryOrCreate
name: dataset-data
23 changes: 15 additions & 8 deletions router/free_models_roster.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@
"score": 28.0,
"context_length": 256000
},
{
"id": "tencent/hy3:free",
"name": "Tencent: Hy3 (free)",
"score": 25.0,
"context_length": 262144
},
{
"id": "poolside/laguna-xs-2.1:free",
"name": "Poolside: Laguna XS 2.1 (free)",
"score": 25.0,
"context_length": 262144
},
{
"id": "cohere/north-mini-code:free",
"name": "Cohere: North Mini Code (free)",
Expand All @@ -54,12 +66,7 @@
"score": 25.0,
"context_length": 128000
},
{
"id": "openrouter/owl-alpha",
"name": "Owl Alpha",
"score": 25.0,
"context_length": 1048756
},

{
"id": "google/lyria-3-pro-preview",
"name": "Google: Lyria 3 Pro Preview",
Expand Down Expand Up @@ -139,6 +146,6 @@
"context_length": 32768
}
],
"updated_at": "2026-06-24T18:40:15.482014Z",
"count": 23
"updated_at": "2026-07-08T22:55:47.271165Z",
"count": 24
}
49 changes: 44 additions & 5 deletions router/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from circuit_breaker import get_breaker
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator, RootModel
from typing import Dict, Optional, Union
from urllib.parse import urlparse

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(
Expand Down Expand Up @@ -3070,8 +3071,46 @@ async def get_dashboard_stats():


@app.get("/dashboard", response_class=HTMLResponse)
async def get_dashboard():
async def get_dashboard(request: Request):
"""Render the router main dashboard HTML showing system metrics, health checks, and recent token usage."""
external_host_env = os.getenv("BASEURL") or os.getenv("BASE_URL")
if external_host_env:
if "://" not in external_host_env:
parsed = urlparse(f"http://{external_host_env}")
else:
parsed = urlparse(external_host_env)
external_host = parsed.hostname or "localhost"
external_netloc = parsed.netloc or "localhost"
else:
external_host = request.base_url.hostname or "localhost"
external_netloc = request.base_url.netloc or "localhost"

domain = os.getenv("ROUTING_DOMAIN") or "vendeuvre.lan"
if not isinstance(external_host, str) or not re.match(r"^[a-zA-Z0-9.-]+$", external_host):
external_host = "localhost"
if not isinstance(external_netloc, str) or not re.match(r"^[a-zA-Z0-9.-]+(?::\d+)?$", external_netloc):
external_netloc = "localhost"

# Enforce strict domain validation to prevent loose substring match bypasses (e.g., attacker-vendeuvre.lan)
is_valid_external = external_host == domain or external_host.endswith("." + domain)
is_valid_base = request.base_url.hostname == domain or (request.base_url.hostname or "").endswith("." + domain)

if is_valid_external:
langfuse_url = f"https://{external_netloc}/llm-routing/langfuse"
litellm_url = f"https://{external_netloc}/llm-routing/litellm/ui"
llama_url = f"https://{external_netloc}/llm-routing/llama/"
elif is_valid_base:
scheme = request.url.scheme if re.match(r"^(?:http|https)$", request.url.scheme) else "https"
netloc = request.url.netloc if re.match(r"^[a-zA-Z0-9.-]+(?::\d+)?$", request.url.netloc) else "localhost"
base = f"{scheme}://{netloc}"
langfuse_url = f"{base}/llm-routing/langfuse"
litellm_url = f"{base}/llm-routing/litellm/ui"
llama_url = f"{base}/llm-routing/llama/"
else:
langfuse_url = f"http://{external_host}:3001"
litellm_url = f"http://{external_host}:4000/ui"
llama_url = f"http://{external_host}:8080"
Comment on lines +3076 to +3112

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

The domain validation logic uses loose substring matching (domain in external_netloc and domain in request.base_url.hostname). This can be bypassed if an attacker accesses the dashboard using a domain like attacker-vendeuvre.lan or vendeuvre.lan.attacker.com, potentially leading to open redirect or phishing link generation on the dashboard.

Additionally, the duplicate from urllib.parse import urlparse imports and redundant import re inside the function can be cleaned up.

    external_host_env = os.getenv("BASEURL") or os.getenv("BASE_URL")
    if external_host_env:
        from urllib.parse import urlparse
        if "://" not in external_host_env:
            parsed = urlparse(f"http://{external_host_env}")
        else:
            parsed = urlparse(external_host_env)
        external_host = parsed.hostname or "localhost"
        external_netloc = parsed.netloc or "localhost"
    else:
        external_host = request.base_url.hostname or "localhost"
        external_netloc = request.base_url.netloc or "localhost"

    domain = os.getenv("ROUTING_DOMAIN") or "vendeuvre.lan"
    if not isinstance(external_host, str) or not re.match(r"^[a-zA-Z0-9.-]+$", external_host):
        external_host = "localhost"
    if not isinstance(external_netloc, str) or not re.match(r"^[a-zA-Z0-9.-]+(?::\d+)?$", external_netloc):
        external_netloc = "localhost"

    # Strict domain validation to prevent substring matching vulnerabilities (e.g., attacker-vendeuvre.lan)
    is_valid_external = external_host == domain or external_host.endswith("." + domain)
    is_valid_base = request.base_url.hostname == domain or (request.base_url.hostname or "").endswith("." + domain)

    if is_valid_external:
        langfuse_url = f"https://{external_netloc}/llm-routing/langfuse"
        litellm_url = f"https://{external_netloc}/llm-routing/litellm/ui"
        llama_url = f"https://{external_netloc}/llm-routing/llama/"
    elif is_valid_base:
        scheme = request.url.scheme if re.match(r"^(?:http|https)$", request.url.scheme) else "https"
        netloc = request.url.netloc if re.match(r"^[a-zA-Z0-9.-]+(?::\d+)?$", request.url.netloc) else "localhost"
        base = f"{scheme}://{netloc}"
        langfuse_url = f"{base}/llm-routing/langfuse"
        litellm_url = f"{base}/llm-routing/litellm/ui"
        llama_url = f"{base}/llm-routing/llama/"
    else:
        langfuse_url = f"http://{external_host}:3001"
        litellm_url = f"http://{external_host}:4000/ui"
        llama_url = f"http://{external_host}:8080"


data = await get_dashboard_data()

# Unpack data for the f-string template
Expand Down Expand Up @@ -3710,7 +3749,7 @@ async def get_dashboard():
</div>
<div style="text-align: center; padding: 25px 20px;">
<p style="opacity: 0.7; margin-bottom: 14px; font-size: 14px;">Per-model usage, token consumption & cost are tracked with full trace detail in Langfuse.</p>
<a href="http://localhost:3001" target="_blank" style="display: inline-block; padding: 8px 18px; background: rgba(232,121,249,0.12); color: #e879f9; border: 1px solid rgba(232,121,249,0.25); border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 13px;">Open Langfuse Observability →</a>
<a href="{langfuse_url}" target="_blank" style="display: inline-block; padding: 8px 18px; background: rgba(232,121,249,0.12); color: #e879f9; border: 1px solid rgba(232,121,249,0.25); border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 13px;">Open Langfuse Observability →</a>
</div>
</div>

Expand Down Expand Up @@ -3846,15 +3885,15 @@ async def get_dashboard():
</a>
</div>
<div class="btn-group">
<a href="http://localhost:3001" target="_blank" class="btn">
<a href="{langfuse_url}" target="_blank" class="btn">
<span>{src_badge("LANGFUSE", "#e879f9")} Observability UI</span>
<span class="btn-arrow">→</span>
</a>
<a href="http://localhost:4000/ui" target="_blank" class="btn">
<a href="{litellm_url}" target="_blank" class="btn">
<span>{src_badge("LITELLM", "#34d399")} Admin UI</span>
<span class="btn-arrow">→</span>
</a>
<a href="http://localhost:8080" target="_blank" class="btn">
<a href="{llama_url}" target="_blank" class="btn">
<span>{src_badge("LLAMA.CPP", "#fb923c")} Server Router UI</span>
<span class="btn-arrow">→</span>
</a>
Expand Down
32 changes: 24 additions & 8 deletions start-stack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,8 @@ 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 OLLAMA_API_KEY LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY CLASSIFIER_INPUT_MAX_CHARS REDIS_AUTH CLICKHOUSE_PASSWORD
PUBLIC_BASE_URL="${PUBLIC_BASE_URL:-${BASE_URL:-${BASEURL:-https://x570.vendeuvre.lan/llm-routing}}}"
export WORKDIR HOME LITELLM_MASTER_KEY POSTGRES_PASSWORD NEXTAUTH_SECRET SALT ENCRYPTION_KEY LANGFUSE_INIT_USER_PASSWORD MINIO_ROOT_USER MINIO_ROOT_PASSWORD OLLAMA_API_KEY LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY CLASSIFIER_INPUT_MAX_CHARS REDIS_AUTH CLICKHOUSE_PASSWORD PUBLIC_BASE_URL
python3 - "$WORKDIR/pod.yaml" <<'PY'
import os, sys, urllib.parse, json
uid = os.getuid()
Expand All @@ -538,7 +539,8 @@ placeholders = [
"MINIO_PASSWORD_PLACEHOLDER",
"LANGFUSE_INIT_USER_PASSWORD_PLACEHOLDER",
"REDIS_AUTH_PLACEHOLDER",
"CLICKHOUSE_PASSWORD_PLACEHOLDER"
"CLICKHOUSE_PASSWORD_PLACEHOLDER",
"PROXY_BASE_URL_PLACEHOLDER"
]
for ph in placeholders:
if ph not in text:
Expand All @@ -563,6 +565,10 @@ text = text.replace("MINIO_PASSWORD_PLACEHOLDER", yaml_scalar(os.environ["MINIO_
text = text.replace("LANGFUSE_INIT_USER_PASSWORD_PLACEHOLDER", yaml_scalar(os.environ["LANGFUSE_INIT_USER_PASSWORD"]))
text = text.replace("REDIS_AUTH_PLACEHOLDER", yaml_scalar(os.environ["REDIS_AUTH"]))
text = text.replace("CLICKHOUSE_PASSWORD_PLACEHOLDER", yaml_scalar(os.environ["CLICKHOUSE_PASSWORD"]))
# Derive PROXY_BASE_URL from PUBLIC_BASE_URL
public_base_url = os.environ["PUBLIC_BASE_URL"].rstrip("/")
proxy_base_url = f"{public_base_url}/litellm"
text = text.replace("PROXY_BASE_URL_PLACEHOLDER", yaml_scalar(proxy_base_url))
import re
unresolved = sorted(set(re.findall(r"\b[A-Z0-9_]+_PLACEHOLDER\b", text)))
if unresolved:
Expand Down Expand Up @@ -596,13 +602,18 @@ if podman pod exists agent-router-pod 2>/dev/null; then
podman pod restart agent-router-pod
setup_minio_buckets
verify_stack_health
# Derive base URLs from configuration/env with sensible defaults
PUBLIC_BASE_URL="${PUBLIC_BASE_URL:-https://x570.vendeuvre.lan/llm-routing}"
LOCAL_BASE_URL="${LOCAL_BASE_URL:-http://localhost:5000}"

echo ""
echo "========================================================================="
echo "🎉 SUCCESS: LLM Triage Gateway restarted!"
echo "📍 Entry endpoint : http://localhost:5000/v1"
echo "⚙️ Dashboard URL : http://localhost:5000/dashboard"
echo "📍 Entry endpoint : ${PUBLIC_BASE_URL}/v1"
echo " (local) : ${LOCAL_BASE_URL}/v1"
echo "⚙️ Dashboard URL : ${PUBLIC_BASE_URL}/dashboard"
echo "🔑 Gateway API Key : gateway-pass"
echo "🔐 LiteLLM Admin UI: http://localhost:4000/ui"
echo "🔐 LiteLLM Admin UI: ${PUBLIC_BASE_URL}/litellm/ui"
echo " Username: admin | Password: $LITELLM_MASTER_KEY"
echo "========================================================================="
exit 0
Expand All @@ -619,11 +630,16 @@ else
verify_stack_health
fi

# Derive base URLs from configuration/env with sensible defaults
PUBLIC_BASE_URL="${PUBLIC_BASE_URL:-https://x570.vendeuvre.lan/llm-routing}"
LOCAL_BASE_URL="${LOCAL_BASE_URL:-http://localhost:5000}"

echo "========================================================================="
echo "🎉 SUCCESS: LLM Triage Gateway successfully deployed!"
echo "📍 Entry endpoint : http://localhost:5000/v1"
echo "⚙️ Dashboard URL : http://localhost:5000/dashboard"
echo "📍 Entry endpoint : ${PUBLIC_BASE_URL}/v1"
echo " (local) : ${LOCAL_BASE_URL}/v1"
echo "⚙️ Dashboard URL : ${PUBLIC_BASE_URL}/dashboard"
echo "🔑 Gateway API Key : gateway-pass"
echo "🔐 LiteLLM Admin UI: http://localhost:4000/ui"
echo "🔐 LiteLLM Admin UI: ${PUBLIC_BASE_URL}/litellm/ui"
echo " Username: admin | Password: $LITELLM_MASTER_KEY"
echo "========================================================================="