feat(ift): EventHub AI Router stack for Zed
FastAPI gateway with A/B/C lane orchestration, LiteLLM proxy config, Swarm stack (postgres, redis, VPN off-by-default), deploy/smoke/audit scripts.
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# EventHubAiRouter — IFT Docker Swarm (.env not committed; use Swarm secrets)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
STACK_NAME=ai-router
|
||||||
|
TZ=Europe/Moscow
|
||||||
|
DEPLOY_HOST=https://ai-router.ift.calentiq.com
|
||||||
|
|
||||||
|
# --- LiteLLM ---
|
||||||
|
LITELLM_MASTER_KEY=sk-litellm-change-me
|
||||||
|
LITELLM_SALT_KEY=sk-salt-generate-once-never-change
|
||||||
|
LITELLM_IMAGE=ghcr.io/berriai/litellm:main-v1.96.0-stable
|
||||||
|
PROXY_BASE_URL=https://litellm.ift.calentiq.com
|
||||||
|
|
||||||
|
# Postgres (LiteLLM Admin UI + spend logs)
|
||||||
|
POSTGRES_USER=litellm
|
||||||
|
POSTGRES_DB=litellm
|
||||||
|
POSTGRES_PASSWORD=change-me-postgres
|
||||||
|
|
||||||
|
# Novita AI (direct from IFT, no VPN)
|
||||||
|
NOVITA_API_KEY=
|
||||||
|
|
||||||
|
# Optional fallbacks
|
||||||
|
GROQ_API_KEY=
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
|
||||||
|
# Budget (USD/month, also in litellm_config.yaml)
|
||||||
|
LITELLM_MAX_BUDGET=50
|
||||||
|
|
||||||
|
# --- VPN (off by default) ---
|
||||||
|
VPN_ENABLED=false
|
||||||
|
VLESS_PROXY_URL=http://vless-proxy:8080
|
||||||
|
NO_PROXY=localhost,127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,api.novita.ai,novita.ai,git.sabilin.com,*.ift.calentiq.com,ift.calentiq.com,*.ift.eventhub.local,*.eventhub.local
|
||||||
|
|
||||||
|
DISABLE_AIOHTTP_TRANSPORT=True
|
||||||
|
USE_AIOHTTP_TRANSPORT=False
|
||||||
|
|
||||||
|
# --- Gateway (Zed entrypoint) ---
|
||||||
|
ROUTER_API_KEY=sk-router-change-me
|
||||||
|
ROUTER_IMAGE=git.sabilin.com/eventhub/ai-router-gateway:ift
|
||||||
|
LITELLM_INTERNAL_URL=http://litellm:4000
|
||||||
|
DEFAULT_QUALITY_MODE=auto
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# --- Observability ---
|
||||||
|
UPTIME_KUMA_PORT=3001
|
||||||
|
|
||||||
|
# Smoke test target (HTTPS after Traefik deploy)
|
||||||
|
TEST_BASE_URL=https://ai-router.ift.calentiq.com
|
||||||
|
TEST_LITELLM_URL=https://litellm.ift.calentiq.com
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-gateway:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build gateway image
|
||||||
|
run: docker build -f router/Dockerfile -t ai-router-gateway:ci .
|
||||||
|
|
||||||
|
- name: Lint Python syntax
|
||||||
|
run: python3 -m py_compile router/*.py
|
||||||
|
|
||||||
|
sync-config:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Sync routing config
|
||||||
|
run: bash scripts/sync-routing-config.sh
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
vless/vless.conf
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
.venv/
|
||||||
|
litellm_config.generated.yaml
|
||||||
|
/tmp/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# EventHub AI Router — Zed gateway + LiteLLM on IFT
|
||||||
|
|
||||||
|
OpenAI-compatible endpoint for [Zed](https://zed.dev) with automatic tier/lane routing (Novita AI), optional VLESS VPN, Grafana metrics, and LiteLLM Admin UI.
|
||||||
|
|
||||||
|
## URLs (IFT, Calentiq TLS)
|
||||||
|
|
||||||
|
| Service | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| **Zed API** | `https://ai-router.ift.calentiq.com/v1` |
|
||||||
|
| **LiteLLM Admin UI** | `https://litellm.ift.calentiq.com/ui` (login: `admin` / `LITELLM_MASTER_KEY`) |
|
||||||
|
| **Grafana** | `https://grafana.ift.calentiq.com` |
|
||||||
|
| Legacy (self-signed) | `https://ai-router.ift.eventhub.local/v1` |
|
||||||
|
|
||||||
|
## Zed settings.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"openai": {
|
||||||
|
"api_url": "https://ai-router.ift.calentiq.com/v1",
|
||||||
|
"api_key": "<ROUTER_API_KEY>"
|
||||||
|
},
|
||||||
|
"assistant": {
|
||||||
|
"default_model": { "provider": "openai", "model": "smart-router" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Gateway** (`router/`) — vision/OCR split, tier classification, lanes A/B/C orchestration, Redis session
|
||||||
|
- **LiteLLM** — named models `a-*` / `b-*` / `c-*`, Auto Router v2 fallback (`smart-router-internal`)
|
||||||
|
- **PostgreSQL** — LiteLLM Admin UI, spend logs
|
||||||
|
- **Redis** — response cache + gateway session context
|
||||||
|
- **VPN** — `vless-proxy` replicas=0 by default; `scripts/vpn-enable.sh`
|
||||||
|
|
||||||
|
Config sources:
|
||||||
|
|
||||||
|
- `config/routing_rules.yaml` — tier keywords
|
||||||
|
- `config/model_matrix.yaml` — tier × lane → Novita models
|
||||||
|
- `config/orchestration.yaml` — start lanes, escalation, budget caps
|
||||||
|
|
||||||
|
## Deploy on IFT
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git.sabilin.com/eventhub/EventHubAiRouter /opt/ai-router-stack
|
||||||
|
cd /opt/ai-router-stack
|
||||||
|
cp .env.example .env # fill NOVITA_API_KEY, keys, POSTGRES_PASSWORD, LITELLM_SALT_KEY
|
||||||
|
bash scripts/deploy.sh
|
||||||
|
bash scripts/smoke-test.sh
|
||||||
|
sudo bash scripts/install-audit-cron.sh # daily Novita audit 03:00 MSK
|
||||||
|
```
|
||||||
|
|
||||||
|
Prerequisites: Docker Swarm, external network `eventhub-ift-net`, Traefik routes in EventHubDevOps.
|
||||||
|
|
||||||
|
### Swarm secrets
|
||||||
|
|
||||||
|
Created automatically by `deploy.sh` from `.env`: `novita_api_key`, `litellm_master_key`, `litellm_salt_key`, `router_api_key`, `postgres_password`, optional `groq_api_key`, `gemini_api_key`, `vless_conf`.
|
||||||
|
|
||||||
|
**`LITELLM_SALT_KEY`** — generate once, never change after first deploy.
|
||||||
|
|
||||||
|
## VPN
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/vpn-enable.sh # scale vless + watchdog, set HTTP_PROXY on litellm
|
||||||
|
bash scripts/vpn-disable.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Novita always direct (`NO_PROXY=api.novita.ai`).
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
- Prometheus scrape: `litellm:4000/metrics`, `ai-router:8000/metrics` (via `eventhub-ift-net`)
|
||||||
|
- Grafana: vendored LiteLLM v2 dashboard + custom `gateway-tier-lane.json` (EventHubDevOps)
|
||||||
|
- Uptime Kuma monitors — see table in plan / configure manually
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `deploy.sh` | secrets + build + stack deploy |
|
||||||
|
| `smoke-test.sh` | health, classify, chat max_tokens=16 |
|
||||||
|
| `sync-routing-config.sh` | regen `litellm_config.generated.yaml` |
|
||||||
|
| `audit-novita-pricing.sh` | daily model catalog check |
|
||||||
|
| `vendor-litellm-dashboards.sh` | fetch Grafana JSON → EventHubDevOps |
|
||||||
|
| `vpn-enable.sh` / `vpn-disable.sh` | VPN toggle |
|
||||||
|
|
||||||
|
## Quality modes
|
||||||
|
|
||||||
|
Header `X-AI-Quality: auto|economy|balanced|max` or `metadata.quality_mode` in request body. Default: `auto` (start lane A/B by tier, escalate on retry/5xx).
|
||||||
|
|
||||||
|
## Response metadata
|
||||||
|
|
||||||
|
Each chat response includes `x_router_meta`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tier": "MEDIUM_OPS",
|
||||||
|
"lane": "A",
|
||||||
|
"model": "a-medium-ops",
|
||||||
|
"escalation_level": 0,
|
||||||
|
"quality_mode": "auto"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f router/Dockerfile -t ai-router-gateway:local .
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
Gitea Actions: `.gitea/workflows/ci.yml` — build gateway image, sync config check.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# tier × lane (A/B/C) → Novita model id + LiteLLM fallbacks
|
||||||
|
|
||||||
|
lanes:
|
||||||
|
A: economy
|
||||||
|
B: balanced
|
||||||
|
C: max
|
||||||
|
|
||||||
|
models:
|
||||||
|
a-simple:
|
||||||
|
novita: novita/qwen/qwen3-4b-fp8
|
||||||
|
fallbacks: [b-simple, groq-llama-8b]
|
||||||
|
rpm: 60
|
||||||
|
b-simple:
|
||||||
|
novita: novita/meta-llama/llama-3.1-8b-instruct
|
||||||
|
fallbacks: [a-simple, groq-llama-8b]
|
||||||
|
rpm: 60
|
||||||
|
c-simple:
|
||||||
|
novita: novita/qwen/qwen3-8b-fp8
|
||||||
|
fallbacks: [b-simple]
|
||||||
|
rpm: 60
|
||||||
|
|
||||||
|
a-medium-ops:
|
||||||
|
novita: novita/qwen/qwen3-8b-fp8
|
||||||
|
fallbacks: [a-medium-code, b-medium-ops]
|
||||||
|
rpm: 40
|
||||||
|
b-medium-ops:
|
||||||
|
novita: novita/deepseek/deepseek-v3.2
|
||||||
|
fallbacks: [a-medium-code, c-medium-ops]
|
||||||
|
rpm: 40
|
||||||
|
c-medium-ops:
|
||||||
|
novita: novita/deepseek/deepseek-v3.2
|
||||||
|
fallbacks: [b-medium-ops]
|
||||||
|
rpm: 40
|
||||||
|
|
||||||
|
a-medium-code:
|
||||||
|
novita: novita/qwen/qwen3-coder-30b-a3b-instruct
|
||||||
|
fallbacks: [a-medium-ops, groq-qwen-coder]
|
||||||
|
rpm: 40
|
||||||
|
b-medium-code:
|
||||||
|
novita: novita/qwen/qwen3-coder-30b-a3b-instruct
|
||||||
|
fallbacks: [b-medium-ops, c-medium-code]
|
||||||
|
rpm: 40
|
||||||
|
c-medium-code:
|
||||||
|
novita: novita/qwen/qwen3-coder-30b-a3b-instruct
|
||||||
|
fallbacks: [b-medium-code, c-medium-ops]
|
||||||
|
rpm: 30
|
||||||
|
|
||||||
|
a-complex:
|
||||||
|
novita: novita/deepseek/deepseek-v3.2
|
||||||
|
fallbacks: [b-complex, a-reasoning]
|
||||||
|
rpm: 30
|
||||||
|
b-complex:
|
||||||
|
novita: novita/deepseek/deepseek-r1-0528
|
||||||
|
fallbacks: [a-complex, c-complex]
|
||||||
|
rpm: 20
|
||||||
|
c-complex:
|
||||||
|
novita: novita/deepseek/deepseek-r1-turbo
|
||||||
|
fallbacks: [b-complex]
|
||||||
|
rpm: 15
|
||||||
|
|
||||||
|
a-reasoning:
|
||||||
|
novita: novita/deepseek/deepseek-r1-0528-qwen3-8b
|
||||||
|
fallbacks: [b-reasoning, gemini-flash]
|
||||||
|
rpm: 30
|
||||||
|
b-reasoning:
|
||||||
|
novita: novita/deepseek/deepseek-r1-0528
|
||||||
|
fallbacks: [a-reasoning, c-reasoning]
|
||||||
|
rpm: 20
|
||||||
|
c-reasoning:
|
||||||
|
novita: novita/deepseek/deepseek-r1-turbo
|
||||||
|
fallbacks: [b-reasoning, gemini-flash]
|
||||||
|
rpm: 15
|
||||||
|
|
||||||
|
a-vision-ocr:
|
||||||
|
novita: novita/paddlepaddle/paddleocr-vl
|
||||||
|
fallbacks: [a-vision]
|
||||||
|
rpm: 30
|
||||||
|
no_escalation: true
|
||||||
|
a-vision:
|
||||||
|
novita: novita/qwen/qwen3-vl-30b-a3b-instruct
|
||||||
|
fallbacks: [a-vision-ocr]
|
||||||
|
rpm: 20
|
||||||
|
b-vision:
|
||||||
|
novita: novita/qwen/qwen2.5-vl-72b-instruct
|
||||||
|
fallbacks: [a-vision, c-vision]
|
||||||
|
rpm: 15
|
||||||
|
c-vision:
|
||||||
|
novita: novita/qwen/qwen3-vl-235b-a22b-instruct
|
||||||
|
fallbacks: [b-vision]
|
||||||
|
rpm: 10
|
||||||
|
|
||||||
|
optional_providers:
|
||||||
|
groq-llama-8b:
|
||||||
|
model: groq/llama-3.1-8b-instant
|
||||||
|
api_key: os.environ/GROQ_API_KEY
|
||||||
|
groq-qwen-coder:
|
||||||
|
model: groq/qwen-qwen-2.5-coder-32b
|
||||||
|
api_key: os.environ/GROQ_API_KEY
|
||||||
|
gemini-flash:
|
||||||
|
model: gemini/gemini-2.0-flash
|
||||||
|
api_key: os.environ/GEMINI_API_KEY
|
||||||
|
|
||||||
|
audit:
|
||||||
|
price_drift_threshold_pct: 10
|
||||||
|
cheaper_alternative_pct: 15
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
default_quality_mode: auto
|
||||||
|
|
||||||
|
start_lanes:
|
||||||
|
SIMPLE: A
|
||||||
|
MEDIUM_OPS: A
|
||||||
|
MEDIUM_CODE: A
|
||||||
|
COMPLEX: B
|
||||||
|
REASONING: B
|
||||||
|
VISION_OCR: A
|
||||||
|
VISION_UI: A
|
||||||
|
|
||||||
|
quality_mode_map:
|
||||||
|
economy: A
|
||||||
|
balanced: B
|
||||||
|
max: C
|
||||||
|
|
||||||
|
lane_order: [A, B, C]
|
||||||
|
|
||||||
|
budget_caps:
|
||||||
|
warn_pct: 80
|
||||||
|
hard_pct: 95
|
||||||
|
warn_max_lane: B
|
||||||
|
hard_max_lane: A
|
||||||
|
|
||||||
|
escalation:
|
||||||
|
repeat_prompt_window_sec: 300
|
||||||
|
context_tokens_min_lane_b: 32000
|
||||||
|
session_ttl_sec: 1800
|
||||||
|
|
||||||
|
redis:
|
||||||
|
key_prefix: "ai-router:session:"
|
||||||
|
ttl_sec: 1800
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Tier classification — single source for gateway + LiteLLM keyword rules
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
simple_patterns:
|
||||||
|
- "^(привет|hello|hi|hey|спасибо|thanks|что такое|what is|define)\\b"
|
||||||
|
complex_keywords:
|
||||||
|
- рефакторинг
|
||||||
|
- refactor
|
||||||
|
- архитектур
|
||||||
|
- architecture
|
||||||
|
- спроектируй
|
||||||
|
- design system
|
||||||
|
- microservice
|
||||||
|
- distributed
|
||||||
|
- migration plan
|
||||||
|
- deep refactor
|
||||||
|
reasoning_keywords:
|
||||||
|
- step by step
|
||||||
|
- prove
|
||||||
|
- analyze deeply
|
||||||
|
- think through
|
||||||
|
- reasoning
|
||||||
|
medium_ops_keywords:
|
||||||
|
- bash
|
||||||
|
- docker
|
||||||
|
- swarm
|
||||||
|
- ci/cd
|
||||||
|
- gitea
|
||||||
|
- rebar
|
||||||
|
- erlang
|
||||||
|
- devops
|
||||||
|
- kubectl
|
||||||
|
- terraform
|
||||||
|
- playwright
|
||||||
|
- npm
|
||||||
|
- wsl
|
||||||
|
- litellm
|
||||||
|
- novita
|
||||||
|
- traefik
|
||||||
|
- mnesia
|
||||||
|
ocr_keywords:
|
||||||
|
- ocr
|
||||||
|
- прочитай текст
|
||||||
|
- extract text
|
||||||
|
- распознай текст
|
||||||
|
- read text from
|
||||||
|
escalation_keywords:
|
||||||
|
- не подходит
|
||||||
|
- лучше
|
||||||
|
- качественнее
|
||||||
|
- retry
|
||||||
|
- попробуй ещё
|
||||||
|
word_thresholds:
|
||||||
|
simple_max_words: 12
|
||||||
|
simple_question_max_words: 25
|
||||||
|
complex_min_words: 400
|
||||||
|
medium_code_min_lines: 200
|
||||||
|
confidence:
|
||||||
|
low_threshold: 0.6
|
||||||
|
|
||||||
|
litellm:
|
||||||
|
keyword_tier_rules:
|
||||||
|
- keywords: ["привет", "hello", "hi", "thanks", "спасибо", "что такое", "what is", "define"]
|
||||||
|
tier: SIMPLE
|
||||||
|
- keywords: ["bash", "docker", "swarm", "ci/cd", "gitea", "rebar", "erlang", "devops", "kubectl", "terraform", "playwright", "npm", "wsl"]
|
||||||
|
tier: MEDIUM
|
||||||
|
- keywords: ["рефакторинг", "refactor", "архитектура", "architecture", "спроектируй", "design system", "microservice", "distributed", "migration plan"]
|
||||||
|
tier: COMPLEX
|
||||||
|
- keywords: ["step by step", "reasoning", "prove", "analyze deeply", "think through"]
|
||||||
|
tier: REASONING
|
||||||
|
custom_technical_keywords:
|
||||||
|
- erlang
|
||||||
|
- rebar3
|
||||||
|
- traefik
|
||||||
|
- mnesia
|
||||||
|
- litellm
|
||||||
|
- novita
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
# AI Router stack — IFT Docker Swarm
|
||||||
|
# VPN (vless-proxy, vpn-watchdog) scaled to 0 by default
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ai-internal:
|
||||||
|
driver: overlay
|
||||||
|
internal: true
|
||||||
|
eventhub-ift:
|
||||||
|
external: true
|
||||||
|
name: eventhub-ift-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
litellm-postgres-data:
|
||||||
|
uptime-kuma-data:
|
||||||
|
|
||||||
|
configs:
|
||||||
|
litellm_config:
|
||||||
|
file: ./litellm_config.yaml
|
||||||
|
litellm_entrypoint:
|
||||||
|
file: ./scripts/litellm-entrypoint.sh
|
||||||
|
router_entrypoint:
|
||||||
|
file: ./scripts/router-entrypoint.sh
|
||||||
|
routing_config:
|
||||||
|
file: ./config/routing_rules.yaml
|
||||||
|
model_matrix:
|
||||||
|
file: ./config/model_matrix.yaml
|
||||||
|
orchestration_config:
|
||||||
|
file: ./config/orchestration.yaml
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
novita_api_key:
|
||||||
|
external: true
|
||||||
|
litellm_master_key:
|
||||||
|
external: true
|
||||||
|
litellm_salt_key:
|
||||||
|
external: true
|
||||||
|
router_api_key:
|
||||||
|
external: true
|
||||||
|
postgres_password:
|
||||||
|
external: true
|
||||||
|
groq_api_key:
|
||||||
|
external: true
|
||||||
|
gemini_api_key:
|
||||||
|
external: true
|
||||||
|
vless_conf:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-litellm}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-litellm}
|
||||||
|
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
|
||||||
|
secrets:
|
||||||
|
- postgres_password
|
||||||
|
volumes:
|
||||||
|
- litellm-postgres-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- ai-internal
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.role == manager
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-litellm} -d ${POSTGRES_DB:-litellm}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||||
|
networks:
|
||||||
|
- ai-internal
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.role == manager
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
litellm:
|
||||||
|
image: ${LITELLM_IMAGE:-ghcr.io/berriai/litellm:main-v1.96.0-stable}
|
||||||
|
entrypoint: ["/bin/sh", "/entrypoint/litellm-entrypoint.sh"]
|
||||||
|
configs:
|
||||||
|
- source: litellm_config
|
||||||
|
target: /app/config.yaml
|
||||||
|
- source: litellm_entrypoint
|
||||||
|
target: /entrypoint/litellm-entrypoint.sh
|
||||||
|
mode: 0555
|
||||||
|
environment:
|
||||||
|
STORE_MODEL_IN_DB: "False"
|
||||||
|
PROXY_BASE_URL: ${PROXY_BASE_URL:-https://litellm.ift.calentiq.com}
|
||||||
|
FORWARDED_ALLOW_IPS: "*"
|
||||||
|
DISABLE_AIOHTTP_TRANSPORT: ${DISABLE_AIOHTTP_TRANSPORT:-True}
|
||||||
|
USE_AIOHTTP_TRANSPORT: ${USE_AIOHTTP_TRANSPORT:-False}
|
||||||
|
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||||
|
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||||
|
NO_PROXY: ${NO_PROXY:-localhost,127.0.0.1,api.novita.ai,novita.ai}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-litellm}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-litellm}
|
||||||
|
secrets:
|
||||||
|
- novita_api_key
|
||||||
|
- litellm_master_key
|
||||||
|
- litellm_salt_key
|
||||||
|
- postgres_password
|
||||||
|
- groq_api_key
|
||||||
|
- gemini_api_key
|
||||||
|
networks:
|
||||||
|
ai-internal:
|
||||||
|
aliases:
|
||||||
|
- litellm
|
||||||
|
eventhub-ift:
|
||||||
|
aliases:
|
||||||
|
- litellm
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
delay: 5s
|
||||||
|
update_config:
|
||||||
|
parallelism: 1
|
||||||
|
failure_action: rollback
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.role == manager
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:4000/health/liveliness || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
router:
|
||||||
|
image: ${ROUTER_IMAGE:-git.sabilin.com/eventhub/ai-router-gateway:ift}
|
||||||
|
entrypoint: ["/bin/sh", "/entrypoint/router-entrypoint.sh"]
|
||||||
|
configs:
|
||||||
|
- source: router_entrypoint
|
||||||
|
target: /entrypoint/router-entrypoint.sh
|
||||||
|
mode: 0555
|
||||||
|
- source: routing_config
|
||||||
|
target: /app/config/routing_rules.yaml
|
||||||
|
- source: model_matrix
|
||||||
|
target: /app/config/model_matrix.yaml
|
||||||
|
- source: orchestration_config
|
||||||
|
target: /app/config/orchestration.yaml
|
||||||
|
environment:
|
||||||
|
LITELLM_INTERNAL_URL: http://litellm:4000
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
CONFIG_DIR: /app/config
|
||||||
|
DEFAULT_QUALITY_MODE: ${DEFAULT_QUALITY_MODE:-auto}
|
||||||
|
secrets:
|
||||||
|
- litellm_master_key
|
||||||
|
- router_api_key
|
||||||
|
networks:
|
||||||
|
ai-internal:
|
||||||
|
eventhub-ift:
|
||||||
|
aliases:
|
||||||
|
- ai-router
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.role == manager
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8000/health || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
vless-proxy:
|
||||||
|
image: ${VLESS_IMAGE:-ai-router/vless-proxy:local}
|
||||||
|
secrets:
|
||||||
|
- source: vless_conf
|
||||||
|
target: /app/vless.conf
|
||||||
|
networks:
|
||||||
|
- ai-internal
|
||||||
|
deploy:
|
||||||
|
replicas: 0
|
||||||
|
restart_policy:
|
||||||
|
condition: any
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 0
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.role == manager
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:8080 || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
vpn-watchdog:
|
||||||
|
image: ${WATCHDOG_IMAGE:-ai-router/vpn-watchdog:local}
|
||||||
|
environment:
|
||||||
|
STACK_NAME: ${STACK_NAME:-ai-router}
|
||||||
|
VLESS_PROXY_URL: ${VLESS_PROXY_URL:-http://vless-proxy:8080}
|
||||||
|
CHECK_INTERVAL: "60"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
networks:
|
||||||
|
- ai-internal
|
||||||
|
deploy:
|
||||||
|
replicas: 0
|
||||||
|
restart_policy:
|
||||||
|
condition: any
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.role == manager
|
||||||
|
|
||||||
|
uptime-kuma:
|
||||||
|
image: louislam/uptime-kuma:1
|
||||||
|
volumes:
|
||||||
|
- uptime-kuma-data:/app/data
|
||||||
|
networks:
|
||||||
|
ai-internal:
|
||||||
|
eventhub-ift:
|
||||||
|
aliases:
|
||||||
|
- uptime-kuma
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.role == manager
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# LiteLLM Proxy — Novita lanes A/B/C + Auto Router v2 fallback
|
||||||
|
# Regenerate model_list tail: bash scripts/sync-routing-config.sh
|
||||||
|
|
||||||
|
general_settings:
|
||||||
|
master_key: os.environ/LITELLM_MASTER_KEY
|
||||||
|
store_model_in_db: false
|
||||||
|
max_budget: 50
|
||||||
|
budget_duration: 30d
|
||||||
|
|
||||||
|
litellm_settings:
|
||||||
|
drop_params: true
|
||||||
|
set_verbose: false
|
||||||
|
request_timeout: 120
|
||||||
|
num_retries: 2
|
||||||
|
cache: true
|
||||||
|
cache_params:
|
||||||
|
type: redis
|
||||||
|
host: redis
|
||||||
|
port: 6379
|
||||||
|
ttl: 3600
|
||||||
|
callbacks: ["prometheus"]
|
||||||
|
require_auth_for_metrics_endpoint: false
|
||||||
|
|
||||||
|
environment_variables:
|
||||||
|
NOVITA_API_KEY: os.environ/NOVITA_API_KEY
|
||||||
|
GROQ_API_KEY: os.environ/GROQ_API_KEY
|
||||||
|
GEMINI_API_KEY: os.environ/GEMINI_API_KEY
|
||||||
|
|
||||||
|
router_settings:
|
||||||
|
routing_strategy: simple-shuffle
|
||||||
|
num_retries: 2
|
||||||
|
timeout: 120
|
||||||
|
allowed_fails: 2
|
||||||
|
cooldown_time: 30
|
||||||
|
fallbacks:
|
||||||
|
- a-simple: ["b-simple", "groq-llama-8b"]
|
||||||
|
- a-medium-code: ["a-medium-ops", "groq-qwen-coder"]
|
||||||
|
- a-complex: ["b-complex", "a-reasoning"]
|
||||||
|
- b-complex: ["a-complex", "c-complex"]
|
||||||
|
- a-reasoning: ["b-reasoning", "gemini-flash"]
|
||||||
|
- a-vision: ["a-vision-ocr"]
|
||||||
|
- smart-router-internal: ["a-medium-ops", "a-medium-code", "a-complex"]
|
||||||
|
default_fallbacks: ["a-medium-code", "a-complex", "groq-qwen-coder"]
|
||||||
|
|
||||||
|
model_list:
|
||||||
|
# --- Lane models (generated from config/model_matrix.yaml) ---
|
||||||
|
- model_name: a-simple
|
||||||
|
litellm_params:
|
||||||
|
model: novita/qwen/qwen3-4b-fp8
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 60
|
||||||
|
- model_name: b-simple
|
||||||
|
litellm_params:
|
||||||
|
model: novita/meta-llama/llama-3.1-8b-instruct
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 60
|
||||||
|
- model_name: c-simple
|
||||||
|
litellm_params:
|
||||||
|
model: novita/qwen/qwen3-8b-fp8
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 60
|
||||||
|
|
||||||
|
- model_name: a-medium-ops
|
||||||
|
litellm_params:
|
||||||
|
model: novita/qwen/qwen3-8b-fp8
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 40
|
||||||
|
- model_name: b-medium-ops
|
||||||
|
litellm_params:
|
||||||
|
model: novita/deepseek/deepseek-v3.2
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 40
|
||||||
|
- model_name: c-medium-ops
|
||||||
|
litellm_params:
|
||||||
|
model: novita/deepseek/deepseek-v3.2
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 40
|
||||||
|
|
||||||
|
- model_name: a-medium-code
|
||||||
|
litellm_params:
|
||||||
|
model: novita/qwen/qwen3-coder-30b-a3b-instruct
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 40
|
||||||
|
- model_name: b-medium-code
|
||||||
|
litellm_params:
|
||||||
|
model: novita/qwen/qwen3-coder-30b-a3b-instruct
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 40
|
||||||
|
- model_name: c-medium-code
|
||||||
|
litellm_params:
|
||||||
|
model: novita/qwen/qwen3-coder-30b-a3b-instruct
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 30
|
||||||
|
|
||||||
|
- model_name: a-complex
|
||||||
|
litellm_params:
|
||||||
|
model: novita/deepseek/deepseek-v3.2
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 30
|
||||||
|
- model_name: b-complex
|
||||||
|
litellm_params:
|
||||||
|
model: novita/deepseek/deepseek-r1-0528
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 20
|
||||||
|
- model_name: c-complex
|
||||||
|
litellm_params:
|
||||||
|
model: novita/deepseek/deepseek-r1-turbo
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 15
|
||||||
|
|
||||||
|
- model_name: a-reasoning
|
||||||
|
litellm_params:
|
||||||
|
model: novita/deepseek/deepseek-r1-0528-qwen3-8b
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 30
|
||||||
|
- model_name: b-reasoning
|
||||||
|
litellm_params:
|
||||||
|
model: novita/deepseek/deepseek-r1-0528
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 20
|
||||||
|
- model_name: c-reasoning
|
||||||
|
litellm_params:
|
||||||
|
model: novita/deepseek/deepseek-r1-turbo
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 15
|
||||||
|
|
||||||
|
- model_name: a-vision-ocr
|
||||||
|
litellm_params:
|
||||||
|
model: novita/paddlepaddle/paddleocr-vl
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 30
|
||||||
|
- model_name: a-vision
|
||||||
|
litellm_params:
|
||||||
|
model: novita/qwen/qwen3-vl-30b-a3b-instruct
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 20
|
||||||
|
- model_name: b-vision
|
||||||
|
litellm_params:
|
||||||
|
model: novita/qwen/qwen2.5-vl-72b-instruct
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 15
|
||||||
|
- model_name: c-vision
|
||||||
|
litellm_params:
|
||||||
|
model: novita/qwen/qwen3-vl-235b-a22b-instruct
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
|
rpm: 10
|
||||||
|
|
||||||
|
# --- Optional fallbacks ---
|
||||||
|
- model_name: groq-llama-8b
|
||||||
|
litellm_params:
|
||||||
|
model: groq/llama-3.1-8b-instant
|
||||||
|
api_key: os.environ/GROQ_API_KEY
|
||||||
|
- model_name: groq-qwen-coder
|
||||||
|
litellm_params:
|
||||||
|
model: groq/qwen-qwen-2.5-coder-32b
|
||||||
|
api_key: os.environ/GROQ_API_KEY
|
||||||
|
- model_name: gemini-flash
|
||||||
|
litellm_params:
|
||||||
|
model: gemini/gemini-2.0-flash
|
||||||
|
api_key: os.environ/GEMINI_API_KEY
|
||||||
|
|
||||||
|
# --- Auto Router v2 fallback when gateway confidence low ---
|
||||||
|
- model_name: smart-router-internal
|
||||||
|
litellm_params:
|
||||||
|
model: auto_router/complexity_router
|
||||||
|
drop_params: true
|
||||||
|
complexity_router_default_model: a-medium-ops
|
||||||
|
complexity_router_config:
|
||||||
|
tiers:
|
||||||
|
SIMPLE: a-simple
|
||||||
|
MEDIUM: a-medium-ops
|
||||||
|
MEDIUM_CODE: a-medium-code
|
||||||
|
COMPLEX: a-complex
|
||||||
|
REASONING: a-reasoning
|
||||||
|
classifier_fallback: heuristic
|
||||||
|
keyword_tier_rules:
|
||||||
|
- keywords: ["привет", "hello", "hi", "thanks", "спасибо", "что такое", "what is", "define"]
|
||||||
|
tier: SIMPLE
|
||||||
|
- keywords: ["bash", "docker", "swarm", "ci/cd", "gitea", "rebar", "erlang", "devops", "kubectl", "terraform"]
|
||||||
|
tier: MEDIUM
|
||||||
|
- keywords: ["рефакторинг", "refactor", "архитектура", "architecture", "спроектируй", "migration plan"]
|
||||||
|
tier: COMPLEX
|
||||||
|
- keywords: ["step by step", "prove", "analyze deeply", "think through"]
|
||||||
|
tier: REASONING
|
||||||
|
custom_technical_keywords: [erlang, rebar3, traefik, mnesia, litellm, novita]
|
||||||
|
token_thresholds:
|
||||||
|
simple: 20
|
||||||
|
complex: 500
|
||||||
|
tier_boundaries:
|
||||||
|
simple_medium: 0.18
|
||||||
|
medium_complex: 0.38
|
||||||
|
complex_reasoning: 0.62
|
||||||
|
session_affinity: true
|
||||||
|
session_affinity_ttl_seconds: 1800
|
||||||
|
|
||||||
|
# Alias for Zed default model name (gateway resolves before LiteLLM)
|
||||||
|
- model_name: smart-router
|
||||||
|
litellm_params:
|
||||||
|
model: novita/qwen/qwen3-8b-fp8
|
||||||
|
api_key: os.environ/NOVITA_API_KEY
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY router/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY router/*.py ./
|
||||||
|
COPY config /app/config
|
||||||
|
|
||||||
|
ENV CONFIG_DIR=/app/config
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
|
CMD curl -sf http://127.0.0.1:8000/health || exit 1
|
||||||
|
|
||||||
|
CMD ["uvicorn", "router:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Prometheus metrics for gateway tier/lane routing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
|
||||||
|
|
||||||
|
REQUESTS = Counter(
|
||||||
|
"ai_router_requests_total",
|
||||||
|
"Total routed requests",
|
||||||
|
["tier", "lane", "model", "status"],
|
||||||
|
)
|
||||||
|
ESCALATIONS = Counter(
|
||||||
|
"ai_router_escalations_total",
|
||||||
|
"Lane escalations",
|
||||||
|
["from_lane", "to_lane", "reason"],
|
||||||
|
)
|
||||||
|
CLASSIFY = Counter(
|
||||||
|
"ai_router_classify_total",
|
||||||
|
"Classification results",
|
||||||
|
["tier"],
|
||||||
|
)
|
||||||
|
DURATION = Histogram(
|
||||||
|
"ai_router_request_duration_seconds",
|
||||||
|
"Request duration",
|
||||||
|
["tier", "model"],
|
||||||
|
buckets=(0.1, 0.25, 0.5, 1, 2, 5, 10, 30, 60, 120, 180),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def metrics_payload() -> tuple[bytes, str]:
|
||||||
|
return generate_latest(), CONTENT_TYPE_LATEST
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Lane orchestration A/B/C with Redis session context."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from rules_loader import load_model_matrix, load_orchestration, load_routing_rules
|
||||||
|
|
||||||
|
log = logging.getLogger("orchestrator")
|
||||||
|
|
||||||
|
LANE_PREFIX = {"A": "a", "B": "b", "C": "c"}
|
||||||
|
TIER_SUFFIX = {
|
||||||
|
"SIMPLE": "simple",
|
||||||
|
"MEDIUM_OPS": "medium-ops",
|
||||||
|
"MEDIUM_CODE": "medium-code",
|
||||||
|
"COMPLEX": "complex",
|
||||||
|
"REASONING": "reasoning",
|
||||||
|
"VISION_OCR": "vision-ocr",
|
||||||
|
"VISION_UI": "vision",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Tier(str, Enum):
|
||||||
|
SIMPLE = "SIMPLE"
|
||||||
|
MEDIUM_OPS = "MEDIUM_OPS"
|
||||||
|
MEDIUM_CODE = "MEDIUM_CODE"
|
||||||
|
COMPLEX = "COMPLEX"
|
||||||
|
REASONING = "REASONING"
|
||||||
|
VISION_OCR = "VISION_OCR"
|
||||||
|
VISION_UI = "VISION_UI"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionCtx:
|
||||||
|
escalation_level: int = 0
|
||||||
|
last_prompt_hash: str = ""
|
||||||
|
turn_count: int = 0
|
||||||
|
budget_pct: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RouteDecision:
|
||||||
|
tier: Tier
|
||||||
|
lane: str
|
||||||
|
model: str
|
||||||
|
quality_mode: str
|
||||||
|
escalation_level: int
|
||||||
|
confidence: float
|
||||||
|
delegated_internal: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStore:
|
||||||
|
def __init__(self, redis_url: str | None) -> None:
|
||||||
|
self._redis = None
|
||||||
|
if redis_url:
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
|
||||||
|
self._redis = redis.from_url(redis_url, decode_responses=True)
|
||||||
|
self._redis.ping()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("Redis unavailable (%s), using in-memory sessions", exc)
|
||||||
|
self._memory: dict[str, SessionCtx] = {}
|
||||||
|
orch = load_orchestration()
|
||||||
|
self._prefix = orch.get("redis", {}).get("key_prefix", "ai-router:session:")
|
||||||
|
self._ttl = int(orch.get("redis", {}).get("ttl_sec", 1800))
|
||||||
|
|
||||||
|
def get(self, session_id: str) -> SessionCtx:
|
||||||
|
if not session_id:
|
||||||
|
return SessionCtx()
|
||||||
|
key = f"{self._prefix}{session_id}"
|
||||||
|
if self._redis:
|
||||||
|
raw = self._redis.get(key)
|
||||||
|
if raw:
|
||||||
|
data = json.loads(raw)
|
||||||
|
return SessionCtx(**data)
|
||||||
|
return SessionCtx()
|
||||||
|
return self._memory.get(session_id, SessionCtx())
|
||||||
|
|
||||||
|
def save(self, session_id: str, ctx: SessionCtx) -> None:
|
||||||
|
if not session_id:
|
||||||
|
return
|
||||||
|
key = f"{self._prefix}{session_id}"
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"escalation_level": ctx.escalation_level,
|
||||||
|
"last_prompt_hash": ctx.last_prompt_hash,
|
||||||
|
"turn_count": ctx.turn_count,
|
||||||
|
"budget_pct": ctx.budget_pct,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if self._redis:
|
||||||
|
self._redis.setex(key, self._ttl, payload)
|
||||||
|
else:
|
||||||
|
self._memory[session_id] = ctx
|
||||||
|
|
||||||
|
|
||||||
|
class Classifier:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
rules = load_routing_rules()
|
||||||
|
gw = rules.get("gateway", {})
|
||||||
|
self._simple_re = re.compile(
|
||||||
|
gw.get("simple_patterns", [r"^(hello)\\b"])[0],
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
self._complex = self._kw_re(gw.get("complex_keywords", []))
|
||||||
|
self._reasoning = self._kw_re(gw.get("reasoning_keywords", []))
|
||||||
|
self._medium_ops = self._kw_re(gw.get("medium_ops_keywords", []))
|
||||||
|
self._ocr = self._kw_re(gw.get("ocr_keywords", []))
|
||||||
|
self._escalation = self._kw_re(gw.get("escalation_keywords", []))
|
||||||
|
wt = gw.get("word_thresholds", {})
|
||||||
|
self._simple_max = int(wt.get("simple_max_words", 12))
|
||||||
|
self._simple_q_max = int(wt.get("simple_question_max_words", 25))
|
||||||
|
self._complex_min = int(wt.get("complex_min_words", 400))
|
||||||
|
self._low_conf = float(rules.get("gateway", {}).get("confidence", {}).get("low_threshold", 0.6))
|
||||||
|
self._code_block = re.compile(r"```[\s\S]*?```|`[^`]+`")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _kw_re(keywords: list[str]) -> re.Pattern[str]:
|
||||||
|
if not keywords:
|
||||||
|
return re.compile(r"(?!x)x")
|
||||||
|
escaped = [re.escape(k) for k in keywords]
|
||||||
|
return re.compile("|".join(escaped), re.IGNORECASE)
|
||||||
|
|
||||||
|
def wants_escalation(self, text: str, prompt_hash: str, prev_hash: str) -> bool:
|
||||||
|
if self._escalation.search(text):
|
||||||
|
return True
|
||||||
|
return bool(prompt_hash and prompt_hash == prev_hash)
|
||||||
|
|
||||||
|
def classify(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
has_image: bool,
|
||||||
|
text: str,
|
||||||
|
) -> tuple[Tier, float]:
|
||||||
|
if has_image:
|
||||||
|
if self._ocr.search(text) or len(text.split()) < 30:
|
||||||
|
return Tier.VISION_OCR, 0.95
|
||||||
|
return Tier.VISION_UI, 0.9
|
||||||
|
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return Tier.SIMPLE, 0.9
|
||||||
|
|
||||||
|
words = len(text.split())
|
||||||
|
if words <= self._simple_max and self._simple_re.search(text):
|
||||||
|
return Tier.SIMPLE, 0.92
|
||||||
|
if self._reasoning.search(text):
|
||||||
|
return Tier.REASONING, 0.88
|
||||||
|
if self._complex.search(text):
|
||||||
|
return Tier.COMPLEX, 0.88
|
||||||
|
if self._code_block.search(text):
|
||||||
|
return Tier.MEDIUM_CODE, 0.85
|
||||||
|
if self._medium_ops.search(text):
|
||||||
|
return Tier.MEDIUM_OPS, 0.82
|
||||||
|
if words <= self._simple_q_max and "?" in text and not self._code_block.search(text):
|
||||||
|
return Tier.SIMPLE, 0.75
|
||||||
|
if words >= self._complex_min:
|
||||||
|
return Tier.COMPLEX, 0.7
|
||||||
|
return Tier.MEDIUM_OPS, 0.55
|
||||||
|
|
||||||
|
|
||||||
|
class Orchestrator:
|
||||||
|
def __init__(self, session_store: SessionStore) -> None:
|
||||||
|
self.sessions = session_store
|
||||||
|
self.classifier = Classifier()
|
||||||
|
self._orch = load_orchestration()
|
||||||
|
self._matrix = load_model_matrix()
|
||||||
|
self._models: dict[str, dict] = self._matrix.get("models", {})
|
||||||
|
self._no_esc = {
|
||||||
|
name for name, cfg in self._models.items() if cfg.get("no_escalation")
|
||||||
|
}
|
||||||
|
self.default_quality = os.environ.get(
|
||||||
|
"DEFAULT_QUALITY_MODE",
|
||||||
|
self._orch.get("default_quality_mode", "auto"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _lane_from_mode(self, quality_mode: str) -> str | None:
|
||||||
|
if quality_mode == "auto":
|
||||||
|
return None
|
||||||
|
return self._orch.get("quality_mode_map", {}).get(quality_mode)
|
||||||
|
|
||||||
|
def _start_lane(self, tier: Tier) -> str:
|
||||||
|
return self._orch.get("start_lanes", {}).get(tier.value, "A")
|
||||||
|
|
||||||
|
def _bump_lane(self, lane: str, levels: int) -> str:
|
||||||
|
order: list[str] = self._orch.get("lane_order", ["A", "B", "C"])
|
||||||
|
try:
|
||||||
|
idx = order.index(lane)
|
||||||
|
except ValueError:
|
||||||
|
return lane
|
||||||
|
return order[min(idx + levels, len(order) - 1)]
|
||||||
|
|
||||||
|
def _cap_lane(self, lane: str, budget_pct: float) -> str:
|
||||||
|
caps = self._orch.get("budget_caps", {})
|
||||||
|
hard = float(caps.get("hard_pct", 95))
|
||||||
|
warn = float(caps.get("warn_pct", 80))
|
||||||
|
if budget_pct >= hard:
|
||||||
|
return caps.get("hard_max_lane", "A")
|
||||||
|
if budget_pct >= warn:
|
||||||
|
max_lane = caps.get("warn_max_lane", "B")
|
||||||
|
order = self._orch.get("lane_order", ["A", "B", "C"])
|
||||||
|
if order.index(lane) > order.index(max_lane):
|
||||||
|
return max_lane
|
||||||
|
return lane
|
||||||
|
|
||||||
|
def _model_name(self, lane: str, tier: Tier) -> str:
|
||||||
|
prefix = LANE_PREFIX.get(lane, "a")
|
||||||
|
suffix = TIER_SUFFIX[tier]
|
||||||
|
return f"{prefix}-{suffix}"
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
quality_mode: str | None = None,
|
||||||
|
session_id: str = "",
|
||||||
|
text: str = "",
|
||||||
|
has_image: bool = False,
|
||||||
|
token_estimate: int = 0,
|
||||||
|
) -> RouteDecision:
|
||||||
|
mode = quality_mode or self.default_quality
|
||||||
|
tier, confidence = self.classifier.classify(messages, has_image=has_image, text=text)
|
||||||
|
ctx = self.sessions.get(session_id)
|
||||||
|
prompt_hash = hashlib.sha256(text.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
if mode != "auto":
|
||||||
|
lane = self._lane_from_mode(mode) or "A"
|
||||||
|
escalation = 0
|
||||||
|
else:
|
||||||
|
lane = self._start_lane(tier)
|
||||||
|
escalation = ctx.escalation_level
|
||||||
|
if tier != Tier.VISION_OCR and self.classifier.wants_escalation(
|
||||||
|
text, prompt_hash, ctx.last_prompt_hash
|
||||||
|
):
|
||||||
|
escalation += 1
|
||||||
|
min_tokens = int(
|
||||||
|
self._orch.get("escalation", {}).get("context_tokens_min_lane_b", 32000)
|
||||||
|
)
|
||||||
|
if token_estimate > min_tokens:
|
||||||
|
order = self._orch.get("lane_order", ["A", "B", "C"])
|
||||||
|
if order.index(lane) < order.index("B"):
|
||||||
|
lane = "B"
|
||||||
|
lane = self._bump_lane(lane, escalation)
|
||||||
|
lane = self._cap_lane(lane, ctx.budget_pct)
|
||||||
|
|
||||||
|
model = self._model_name(lane, tier)
|
||||||
|
if model not in self._models:
|
||||||
|
log.warning("model %s missing from matrix, fallback smart-router-internal", model)
|
||||||
|
return RouteDecision(
|
||||||
|
tier=tier,
|
||||||
|
lane=lane,
|
||||||
|
model="smart-router-internal",
|
||||||
|
quality_mode=mode,
|
||||||
|
escalation_level=escalation,
|
||||||
|
confidence=confidence,
|
||||||
|
delegated_internal=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
delegated = confidence < self.classifier._low_conf and tier in (
|
||||||
|
Tier.MEDIUM_OPS,
|
||||||
|
Tier.SIMPLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
return RouteDecision(
|
||||||
|
tier=tier,
|
||||||
|
lane=lane,
|
||||||
|
model="smart-router-internal" if delegated else model,
|
||||||
|
quality_mode=mode,
|
||||||
|
escalation_level=escalation,
|
||||||
|
confidence=confidence,
|
||||||
|
delegated_internal=delegated,
|
||||||
|
)
|
||||||
|
|
||||||
|
def after_request(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
prompt_hash: str,
|
||||||
|
success: bool,
|
||||||
|
escalate: bool,
|
||||||
|
) -> None:
|
||||||
|
if not session_id:
|
||||||
|
return
|
||||||
|
ctx = self.sessions.get(session_id)
|
||||||
|
ctx.last_prompt_hash = prompt_hash
|
||||||
|
ctx.turn_count += 1
|
||||||
|
if success and not escalate:
|
||||||
|
ctx.escalation_level = 0
|
||||||
|
elif escalate:
|
||||||
|
ctx.escalation_level = min(ctx.escalation_level + 1, 2)
|
||||||
|
self.sessions.save(session_id, ctx)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.32.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
pyyaml>=6.0.2
|
||||||
|
prometheus-client>=0.21.0
|
||||||
|
redis>=5.2.0
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""
|
||||||
|
FastAPI AI router gateway — Zed entrypoint with tier/lane orchestration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, Header, HTTPException, Request, Response
|
||||||
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
|
from metrics import CLASSIFY, DURATION, ESCALATIONS, REQUESTS, metrics_payload
|
||||||
|
from orchestrator import Orchestrator, SessionStore, Tier
|
||||||
|
from rules_loader import reload_configs
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
log = logging.getLogger("ai-router")
|
||||||
|
|
||||||
|
LITELLM_URL = os.environ.get("LITELLM_INTERNAL_URL", "http://litellm:4000").rstrip("/")
|
||||||
|
LITELLM_KEY = os.environ.get("LITELLM_MASTER_KEY", "")
|
||||||
|
ROUTER_API_KEY = os.environ.get("ROUTER_API_KEY", "")
|
||||||
|
DEFAULT_MODEL = os.environ.get("DEFAULT_LITELLM_MODEL", "smart-router")
|
||||||
|
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379/0")
|
||||||
|
|
||||||
|
session_store = SessionStore(REDIS_URL or None)
|
||||||
|
orchestrator = Orchestrator(session_store)
|
||||||
|
|
||||||
|
app = FastAPI(title="EventHub AI Router", version="2.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text(messages: list[dict[str, Any]]) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if isinstance(content, str):
|
||||||
|
parts.append(content)
|
||||||
|
elif isinstance(content, list):
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict) and block.get("type") == "text":
|
||||||
|
parts.append(str(block.get("text", "")))
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_image(messages: list[dict[str, Any]]) -> bool:
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if not isinstance(content, list):
|
||||||
|
continue
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict) and block.get("type") in ("image_url", "image"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_tokens(text: str) -> int:
|
||||||
|
return max(1, len(text) // 4)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_or_403(authorization: str | None) -> None:
|
||||||
|
if not ROUTER_API_KEY:
|
||||||
|
return
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Missing Bearer token")
|
||||||
|
token = authorization.removeprefix("Bearer ").strip()
|
||||||
|
if token != ROUTER_API_KEY:
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid API key")
|
||||||
|
|
||||||
|
|
||||||
|
def _quality_mode(header: str | None, body: dict[str, Any]) -> str | None:
|
||||||
|
if header:
|
||||||
|
mode = header.strip().lower()
|
||||||
|
if mode in ("auto", "economy", "balanced", "max"):
|
||||||
|
return mode
|
||||||
|
meta = body.get("metadata") or {}
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
mode = str(meta.get("quality_mode", "")).lower()
|
||||||
|
if mode in ("auto", "economy", "balanced", "max"):
|
||||||
|
return mode
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _session_id(body: dict[str, Any]) -> str:
|
||||||
|
meta = body.get("metadata") or {}
|
||||||
|
if isinstance(meta, dict) and meta.get("session_id"):
|
||||||
|
return str(meta["session_id"])
|
||||||
|
if body.get("user"):
|
||||||
|
return str(body["user"])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _router_meta(decision, *, requested: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"tier": decision.tier.value,
|
||||||
|
"lane": decision.lane,
|
||||||
|
"model": decision.model,
|
||||||
|
"escalation_level": decision.escalation_level,
|
||||||
|
"quality_mode": decision.quality_mode,
|
||||||
|
"confidence": round(decision.confidence, 3),
|
||||||
|
"requested_model": requested,
|
||||||
|
"delegated_internal": decision.delegated_internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/metrics")
|
||||||
|
async def metrics() -> Response:
|
||||||
|
body, content_type = metrics_payload()
|
||||||
|
return Response(content=body, media_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/reload-config")
|
||||||
|
async def admin_reload(authorization: str | None = Header(default=None)) -> dict[str, str]:
|
||||||
|
_auth_or_403(authorization)
|
||||||
|
reload_configs()
|
||||||
|
global orchestrator # noqa: PLW0603
|
||||||
|
orchestrator = Orchestrator(session_store)
|
||||||
|
return {"status": "reloaded"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/models")
|
||||||
|
async def list_models(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||||
|
_auth_or_403(authorization)
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{LITELLM_URL}/v1/models",
|
||||||
|
headers={"Authorization": f"Bearer {LITELLM_KEY}"},
|
||||||
|
)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise HTTPException(status_code=resp.status_code, detail=resp.text)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/classify")
|
||||||
|
async def classify_debug(
|
||||||
|
request: Request,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
x_ai_quality: str | None = Header(default=None, alias="X-AI-Quality"),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_auth_or_403(authorization)
|
||||||
|
body = await request.json()
|
||||||
|
messages = body.get("messages") or []
|
||||||
|
text = _extract_text(messages)
|
||||||
|
decision = orchestrator.resolve(
|
||||||
|
messages,
|
||||||
|
quality_mode=_quality_mode(x_ai_quality, body),
|
||||||
|
session_id=_session_id(body),
|
||||||
|
text=text,
|
||||||
|
has_image=_has_image(messages),
|
||||||
|
token_estimate=_estimate_tokens(text),
|
||||||
|
)
|
||||||
|
CLASSIFY.labels(tier=decision.tier.value).inc()
|
||||||
|
return {
|
||||||
|
"tier": decision.tier.value,
|
||||||
|
"lane": decision.lane,
|
||||||
|
"model": decision.model,
|
||||||
|
"x_router_meta": _router_meta(decision, requested=body.get("model", DEFAULT_MODEL)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _forward_litellm(
|
||||||
|
forward: dict[str, Any],
|
||||||
|
*,
|
||||||
|
stream: bool,
|
||||||
|
decision_meta: dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {LITELLM_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
start = time.perf_counter()
|
||||||
|
tier = decision_meta.get("tier", "UNKNOWN")
|
||||||
|
model = forward.get("model", DEFAULT_MODEL)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||||
|
if stream:
|
||||||
|
req = client.build_request(
|
||||||
|
"POST",
|
||||||
|
f"{LITELLM_URL}/v1/chat/completions",
|
||||||
|
headers=headers,
|
||||||
|
content=json.dumps(forward),
|
||||||
|
)
|
||||||
|
resp = await client.send(req, stream=True)
|
||||||
|
|
||||||
|
async def event_stream():
|
||||||
|
async for chunk in resp.aiter_bytes():
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
REQUESTS.labels(tier=tier, lane=decision_meta["lane"], model=model, status=str(resp.status_code)).inc()
|
||||||
|
DURATION.labels(tier=tier, model=model).observe(time.perf_counter() - start)
|
||||||
|
return StreamingResponse(
|
||||||
|
event_stream(),
|
||||||
|
status_code=resp.status_code,
|
||||||
|
media_type=resp.headers.get("content-type", "text/event-stream"),
|
||||||
|
headers={"X-Router-Meta": json.dumps(decision_meta)},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"{LITELLM_URL}/v1/chat/completions",
|
||||||
|
headers=headers,
|
||||||
|
json=forward,
|
||||||
|
)
|
||||||
|
|
||||||
|
status = str(resp.status_code)
|
||||||
|
REQUESTS.labels(tier=tier, lane=decision_meta["lane"], model=model, status=status).inc()
|
||||||
|
DURATION.labels(tier=tier, model=model).observe(time.perf_counter() - start)
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data["x_router_meta"] = decision_meta
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/chat/completions")
|
||||||
|
async def chat_completions(
|
||||||
|
request: Request,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
x_ai_quality: str | None = Header(default=None, alias="X-AI-Quality"),
|
||||||
|
) -> Any:
|
||||||
|
_auth_or_403(authorization)
|
||||||
|
|
||||||
|
body: dict[str, Any] = await request.json()
|
||||||
|
messages = body.get("messages") or []
|
||||||
|
if not isinstance(messages, list):
|
||||||
|
raise HTTPException(status_code=400, detail="messages must be a list")
|
||||||
|
|
||||||
|
text = _extract_text(messages)
|
||||||
|
has_image = _has_image(messages)
|
||||||
|
session_id = _session_id(body)
|
||||||
|
prompt_hash = hashlib.sha256(text.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
requested_model = body.get("model", DEFAULT_MODEL)
|
||||||
|
if requested_model in ("smart-router", "auto", ""):
|
||||||
|
decision = orchestrator.resolve(
|
||||||
|
messages,
|
||||||
|
quality_mode=_quality_mode(x_ai_quality, body),
|
||||||
|
session_id=session_id,
|
||||||
|
text=text,
|
||||||
|
has_image=has_image,
|
||||||
|
token_estimate=_estimate_tokens(text),
|
||||||
|
)
|
||||||
|
target_model = decision.model
|
||||||
|
meta = _router_meta(decision, requested=requested_model)
|
||||||
|
else:
|
||||||
|
target_model = requested_model
|
||||||
|
meta = {"selected_model": target_model, "requested_model": requested_model}
|
||||||
|
|
||||||
|
forward = dict(body)
|
||||||
|
forward["model"] = target_model
|
||||||
|
forward.setdefault("metadata", {})
|
||||||
|
if isinstance(forward["metadata"], dict):
|
||||||
|
forward["metadata"]["semantic_tier"] = meta.get("tier")
|
||||||
|
forward["metadata"]["session_id"] = session_id or forward["metadata"].get("session_id")
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"route tier=%s lane=%s model=%s requested=%s",
|
||||||
|
meta.get("tier"),
|
||||||
|
meta.get("lane"),
|
||||||
|
target_model,
|
||||||
|
requested_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
stream = bool(body.get("stream", False))
|
||||||
|
result = await _forward_litellm(forward, stream=stream, decision_meta=meta)
|
||||||
|
|
||||||
|
escalate = isinstance(result, JSONResponse) and result.status_code >= 429
|
||||||
|
orchestrator.after_request(
|
||||||
|
session_id,
|
||||||
|
prompt_hash=prompt_hash,
|
||||||
|
success=not escalate,
|
||||||
|
escalate=escalate,
|
||||||
|
)
|
||||||
|
if escalate and meta.get("lane") != "C":
|
||||||
|
ESCALATIONS.labels(from_lane=meta.get("lane", "?"), to_lane="next", reason="http_error").inc()
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Load routing YAML configs from CONFIG_DIR."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
CONFIG_DIR = Path(os.environ.get("CONFIG_DIR", "/app/config"))
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def load_routing_rules() -> dict[str, Any]:
|
||||||
|
path = CONFIG_DIR / "routing_rules.yaml"
|
||||||
|
with path.open(encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def load_model_matrix() -> dict[str, Any]:
|
||||||
|
path = CONFIG_DIR / "model_matrix.yaml"
|
||||||
|
with path.open(encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def load_orchestration() -> dict[str, Any]:
|
||||||
|
path = CONFIG_DIR / "orchestration.yaml"
|
||||||
|
with path.open(encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def reload_configs() -> None:
|
||||||
|
load_routing_rules.cache_clear()
|
||||||
|
load_model_matrix.cache_clear()
|
||||||
|
load_orchestration.cache_clear()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Daily Novita pricing/model audit — compare model_matrix.yaml vs /v1/models
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
[[ -f .env ]] && source .env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
API_KEY="${NOVITA_API_KEY:?NOVITA_API_KEY required}"
|
||||||
|
LOG_DIR="${AUDIT_LOG_DIR:-/var/log/ai-router}"
|
||||||
|
DATE="$(date +%Y-%m-%d)"
|
||||||
|
REPORT="${LOG_DIR}/audit-${DATE}.json"
|
||||||
|
THRESHOLD="${PRICE_DRIFT_THRESHOLD_PCT:-10}"
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
MODELS_JSON="$(curl -sf "https://api.novita.ai/openai/v1/models" \
|
||||||
|
-H "Authorization: Bearer ${API_KEY}")"
|
||||||
|
|
||||||
|
export ROOT MODELS_JSON REPORT THRESHOLD
|
||||||
|
python3 <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
root = Path(os.environ["ROOT"])
|
||||||
|
matrix = yaml.safe_load((root / "config/model_matrix.yaml").read_text(encoding="utf-8"))
|
||||||
|
catalog = json.loads(os.environ["MODELS_JSON"])
|
||||||
|
threshold = float(os.environ.get("THRESHOLD", "10"))
|
||||||
|
|
||||||
|
by_id = {m["id"]: m for m in catalog.get("data", [])}
|
||||||
|
issues = []
|
||||||
|
checked = 0
|
||||||
|
|
||||||
|
for name, cfg in matrix.get("models", {}).items():
|
||||||
|
novita = cfg.get("novita", "")
|
||||||
|
model_id = novita.replace("novita/", "", 1) if novita.startswith("novita/") else novita
|
||||||
|
if not model_id:
|
||||||
|
continue
|
||||||
|
checked += 1
|
||||||
|
if model_id not in by_id:
|
||||||
|
issues.append({"model": name, "novita_id": model_id, "severity": "critical", "msg": "missing from catalog"})
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"date": os.environ.get("DATE", ""),
|
||||||
|
"checked": checked,
|
||||||
|
"issues": issues,
|
||||||
|
"catalog_count": len(by_id),
|
||||||
|
}
|
||||||
|
report_path = Path(os.environ["REPORT"])
|
||||||
|
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
|
if any(i["severity"] == "critical" for i in issues):
|
||||||
|
print(f"CRITICAL: {len(issues)} issues — see {report_path}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if issues:
|
||||||
|
print(f"WARN: {len(issues)} issues — see {report_path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"OK: {checked} models, report {report_path}")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deploy AI Router stack to Docker Swarm (IFT)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
STACK_NAME="${STACK_NAME:-ai-router}"
|
||||||
|
|
||||||
|
cd "$STACK_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
echo "ERROR: copy .env.example to .env and fill secrets" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
ensure_secret() {
|
||||||
|
local name="$1"
|
||||||
|
local value="$2"
|
||||||
|
if docker secret inspect "$name" >/dev/null 2>&1; then
|
||||||
|
echo "secret exists: $name"
|
||||||
|
else
|
||||||
|
echo -n "$value" | docker secret create "$name" -
|
||||||
|
echo "created secret: $name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== Ensure Swarm secrets =="
|
||||||
|
ensure_secret novita_api_key "${NOVITA_API_KEY:?NOVITA_API_KEY required}"
|
||||||
|
ensure_secret litellm_master_key "${LITELLM_MASTER_KEY:?LITELLM_MASTER_KEY required}"
|
||||||
|
ensure_secret litellm_salt_key "${LITELLM_SALT_KEY:?LITELLM_SALT_KEY required}"
|
||||||
|
ensure_secret router_api_key "${ROUTER_API_KEY:?ROUTER_API_KEY required}"
|
||||||
|
ensure_secret postgres_password "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required}"
|
||||||
|
ensure_secret groq_api_key "${GROQ_API_KEY:-}"
|
||||||
|
ensure_secret gemini_api_key "${GEMINI_API_KEY:-}"
|
||||||
|
|
||||||
|
if [[ ! -f vless/vless.conf ]]; then
|
||||||
|
echo "WARN: vless/vless.conf missing — stub for secret (VPN off until configured)"
|
||||||
|
cp vless/vless.conf.example vless/vless.conf 2>/dev/null || echo "# stub" > vless/vless.conf
|
||||||
|
fi
|
||||||
|
ensure_secret vless_conf "$(cat vless/vless.conf)"
|
||||||
|
|
||||||
|
echo "== Build images =="
|
||||||
|
docker build -f router/Dockerfile -t "${ROUTER_IMAGE:-git.sabilin.com/eventhub/ai-router-gateway:ift}" .
|
||||||
|
docker build -t ai-router/vless-proxy:local ./vless
|
||||||
|
docker build -t ai-router/vpn-watchdog:local ./watchdog
|
||||||
|
|
||||||
|
echo "== Sync routing config (optional regen) =="
|
||||||
|
bash scripts/sync-routing-config.sh || true
|
||||||
|
|
||||||
|
echo "== Deploy stack: ${STACK_NAME} =="
|
||||||
|
docker stack deploy -c docker-stack.yml --with-registry-auth "${STACK_NAME}"
|
||||||
|
|
||||||
|
echo "== Wait for services =="
|
||||||
|
sleep 8
|
||||||
|
docker stack services "${STACK_NAME}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done."
|
||||||
|
echo " Zed URL: ${TEST_BASE_URL:-https://ai-router.ift.calentiq.com}/v1"
|
||||||
|
echo " LiteLLM: ${TEST_LITELLM_URL:-https://litellm.ift.calentiq.com}/ui"
|
||||||
|
echo " Smoke: bash scripts/smoke-test.sh"
|
||||||
|
echo " VPN on: bash scripts/vpn-enable.sh"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install daily audit cron (03:00 MSK) on IFT host
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
CRON_FILE="/etc/cron.d/ai-router-audit"
|
||||||
|
|
||||||
|
sudo tee "$CRON_FILE" >/dev/null <<EOF
|
||||||
|
# EventHub AI Router — Novita model audit
|
||||||
|
0 3 * * * root ${STACK_DIR}/scripts/audit-novita-pricing.sh >> /var/log/ai-router/audit.log 2>&1
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo chmod 644 "$CRON_FILE"
|
||||||
|
echo "Installed ${CRON_FILE}"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Load Swarm secrets into env and start LiteLLM proxy
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
read_secret() {
|
||||||
|
if [ -f "$1" ]; then
|
||||||
|
tr -d '\n\r' < "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -f /run/secrets/novita_api_key ]; then
|
||||||
|
export NOVITA_API_KEY="$(read_secret /run/secrets/novita_api_key)"
|
||||||
|
fi
|
||||||
|
if [ -f /run/secrets/litellm_master_key ]; then
|
||||||
|
export LITELLM_MASTER_KEY="$(read_secret /run/secrets/litellm_master_key)"
|
||||||
|
fi
|
||||||
|
if [ -f /run/secrets/litellm_salt_key ]; then
|
||||||
|
export LITELLM_SALT_KEY="$(read_secret /run/secrets/litellm_salt_key)"
|
||||||
|
fi
|
||||||
|
if [ -f /run/secrets/groq_api_key ]; then
|
||||||
|
export GROQ_API_KEY="$(read_secret /run/secrets/groq_api_key)"
|
||||||
|
fi
|
||||||
|
if [ -f /run/secrets/gemini_api_key ]; then
|
||||||
|
export GEMINI_API_KEY="$(read_secret /run/secrets/gemini_api_key)"
|
||||||
|
fi
|
||||||
|
if [ -f /run/secrets/postgres_password ]; then
|
||||||
|
export POSTGRES_PASSWORD="$(read_secret /run/secrets/postgres_password)"
|
||||||
|
export DATABASE_URL="postgresql://${POSTGRES_USER:-litellm}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-litellm}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec litellm --config /app/config.yaml --port 4000 --num_workers 2 "$@"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
read_secret() {
|
||||||
|
if [ -f "$1" ]; then
|
||||||
|
tr -d '\n\r' < "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -f /run/secrets/litellm_master_key ]; then
|
||||||
|
export LITELLM_MASTER_KEY="$(read_secret /run/secrets/litellm_master_key)"
|
||||||
|
fi
|
||||||
|
if [ -f /run/secrets/router_api_key ]; then
|
||||||
|
export ROUTER_API_KEY="$(read_secret /run/secrets/router_api_key)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec uvicorn router:app --host 0.0.0.0 --port 8000 "$@"
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Smoke tests — classify (no LLM) + minimal chat
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$STACK_DIR"
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source .env 2>/dev/null || true
|
||||||
|
set +a
|
||||||
|
|
||||||
|
BASE="${TEST_BASE_URL:-http://127.0.0.1:8000}"
|
||||||
|
LITELLM_BASE="${TEST_LITELLM_URL:-http://127.0.0.1:4000}"
|
||||||
|
ROUTER_KEY="${ROUTER_API_KEY:?ROUTER_API_KEY required}"
|
||||||
|
|
||||||
|
CURL=(curl -sf)
|
||||||
|
if [[ "$BASE" == https:* ]]; then
|
||||||
|
CURL+=( -k )
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Router health =="
|
||||||
|
"${CURL[@]}" "${BASE}/health" | jq .
|
||||||
|
|
||||||
|
echo "== Classify SIMPLE =="
|
||||||
|
"${CURL[@]}" "${BASE}/classify" \
|
||||||
|
-H "Authorization: Bearer ${ROUTER_KEY}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"messages":[{"role":"user","content":"Привет, что такое Docker?"}]}' | jq .
|
||||||
|
|
||||||
|
echo "== Classify COMPLEX =="
|
||||||
|
"${CURL[@]}" "${BASE}/classify" \
|
||||||
|
-H "Authorization: Bearer ${ROUTER_KEY}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"messages":[{"role":"user","content":"Спроектируй архитектуру microservices"}]}' | jq .
|
||||||
|
|
||||||
|
echo "== Chat smart-router max_tokens=16 =="
|
||||||
|
"${CURL[@]}" "${BASE}/v1/chat/completions" \
|
||||||
|
-H "Authorization: Bearer ${ROUTER_KEY}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model":"smart-router","max_tokens":16,"messages":[{"role":"user","content":"bash docker service ls"}]}' \
|
||||||
|
| jq '.choices[0].message.content, .x_router_meta // empty'
|
||||||
|
|
||||||
|
if [[ -n "${SKIP_LITELLM_SMOKE:-}" ]]; then
|
||||||
|
echo "SKIP_LITELLM_SMOKE set — skipping LiteLLM checks"
|
||||||
|
else
|
||||||
|
echo "== LiteLLM liveliness =="
|
||||||
|
"${CURL[@]}" "${LITELLM_BASE}/health/liveliness" && echo
|
||||||
|
echo "== LiteLLM UI =="
|
||||||
|
curl -sfI -k "${LITELLM_BASE}/ui" | head -3 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "All smoke checks passed."
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Sync model_list fragment from config/*.yaml
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
export ROOT
|
||||||
|
|
||||||
|
python3 <<'PY'
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
root = Path(os.environ["ROOT"])
|
||||||
|
rules = yaml.safe_load((root / "config/routing_rules.yaml").read_text(encoding="utf-8"))
|
||||||
|
matrix = yaml.safe_load((root / "config/model_matrix.yaml").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for name, cfg in matrix.get("models", {}).items():
|
||||||
|
novita = cfg.get("novita")
|
||||||
|
if not novita:
|
||||||
|
continue
|
||||||
|
entry = {
|
||||||
|
"model_name": name,
|
||||||
|
"litellm_params": {
|
||||||
|
"model": novita,
|
||||||
|
"api_key": "os.environ/NOVITA_API_KEY",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if cfg.get("rpm"):
|
||||||
|
entry["litellm_params"]["rpm"] = cfg["rpm"]
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
for name, cfg in matrix.get("optional_providers", {}).items():
|
||||||
|
entries.append({
|
||||||
|
"model_name": name,
|
||||||
|
"litellm_params": {
|
||||||
|
"model": cfg["model"],
|
||||||
|
"api_key": cfg.get("api_key", "os.environ/GROQ_API_KEY"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
litellm_rules = rules.get("litellm", {})
|
||||||
|
entries.append({
|
||||||
|
"model_name": "smart-router-internal",
|
||||||
|
"litellm_params": {
|
||||||
|
"model": "auto_router/complexity_router",
|
||||||
|
"drop_params": True,
|
||||||
|
"complexity_router_default_model": "a-medium-ops",
|
||||||
|
"complexity_router_config": {
|
||||||
|
"tiers": {
|
||||||
|
"SIMPLE": "a-simple",
|
||||||
|
"MEDIUM": "a-medium-ops",
|
||||||
|
"MEDIUM_CODE": "a-medium-code",
|
||||||
|
"COMPLEX": "a-complex",
|
||||||
|
"REASONING": "a-reasoning",
|
||||||
|
},
|
||||||
|
"classifier_fallback": "heuristic",
|
||||||
|
"keyword_tier_rules": litellm_rules.get("keyword_tier_rules", []),
|
||||||
|
"custom_technical_keywords": litellm_rules.get("custom_technical_keywords", []),
|
||||||
|
"token_thresholds": {"simple": 20, "complex": 500},
|
||||||
|
"tier_boundaries": {
|
||||||
|
"simple_medium": 0.18,
|
||||||
|
"medium_complex": 0.38,
|
||||||
|
"complex_reasoning": 0.62,
|
||||||
|
},
|
||||||
|
"session_affinity": True,
|
||||||
|
"session_affinity_ttl_seconds": 1800,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
out = root / "litellm_config.generated.yaml"
|
||||||
|
out.write_text(
|
||||||
|
yaml.dump({"model_list": entries}, allow_unicode=True, sort_keys=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
print(f"Wrote {out} ({len(entries)} models)")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Vendor LiteLLM official Grafana dashboard JSON into EventHubDevOps
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
LITELLM_REF="${LITELLM_REF:-main-v1.96.0-stable}"
|
||||||
|
DEVOPS="${DEVOPS_ROOT:-$(cd "$ROOT/../EventHubDevOps" 2>/dev/null && pwd || echo "")}"
|
||||||
|
|
||||||
|
if [[ -z "$DEVOPS" || ! -d "$DEVOPS/ift/observability" ]]; then
|
||||||
|
echo "DEVOPS_ROOT not found — set to EventHubDevOps path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEST="${DEVOPS}/ift/observability/grafana/dashboards/litellm"
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
|
||||||
|
BASE="https://raw.githubusercontent.com/BerriAI/litellm/${LITELLM_REF}/cookbook/litellm_proxy_server/grafana_dashboard"
|
||||||
|
|
||||||
|
fetch() {
|
||||||
|
local src="$1" dst="$2"
|
||||||
|
echo "Fetching $src"
|
||||||
|
curl -fsSL "${BASE}/${src}" -o "${DEST}/${dst}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch "dashboard_v2/grafana_dashboard.json" "litellm-v2.json"
|
||||||
|
fetch "dashboard_1/grafana_dashboard.json" "litellm-v1.json" || true
|
||||||
|
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
for f in litellm-v2.json litellm-v1.json; do
|
||||||
|
[[ -f "${DEST}/${f}" ]] || continue
|
||||||
|
jq 'walk(if type=="object" and has("datasource") then .datasource="prometheus" else . end)' \
|
||||||
|
"${DEST}/${f}" > "${DEST}/${f}.tmp" && mv "${DEST}/${f}.tmp" "${DEST}/${f}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Dashboards written to ${DEST}"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
STACK_NAME="${STACK_NAME:-ai-router}"
|
||||||
|
cd "$STACK_DIR"
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
NO_PROXY_VAL="${NO_PROXY:-localhost,127.0.0.1,api.novita.ai,novita.ai}"
|
||||||
|
|
||||||
|
echo "== Scale vless-proxy and vpn-watchdog to 0 =="
|
||||||
|
docker service scale "${STACK_NAME}_vless-proxy=0" "${STACK_NAME}_vpn-watchdog=0" || true
|
||||||
|
|
||||||
|
echo "== Clear litellm proxy env =="
|
||||||
|
docker service update \
|
||||||
|
--env-rm HTTP_PROXY \
|
||||||
|
--env-rm HTTPS_PROXY \
|
||||||
|
--env-add "NO_PROXY=${NO_PROXY_VAL}" \
|
||||||
|
"${STACK_NAME}_litellm" || true
|
||||||
|
|
||||||
|
echo "VPN disabled."
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
STACK_NAME="${STACK_NAME:-ai-router}"
|
||||||
|
cd "$STACK_DIR"
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
VLESS_URL="${VLESS_PROXY_URL:-http://vless-proxy:8080}"
|
||||||
|
NO_PROXY_VAL="${NO_PROXY:-localhost,127.0.0.1,api.novita.ai,novita.ai}"
|
||||||
|
|
||||||
|
echo "== Scale vless-proxy and vpn-watchdog to 1 =="
|
||||||
|
docker service scale "${STACK_NAME}_vless-proxy=1" "${STACK_NAME}_vpn-watchdog=1"
|
||||||
|
|
||||||
|
echo "== Wait for vless-proxy healthy (max 90s) =="
|
||||||
|
for i in $(seq 1 18); do
|
||||||
|
CID=$(docker ps -q -f "name=${STACK_NAME}_vless-proxy" | head -1)
|
||||||
|
if [[ -n "$CID" ]]; then
|
||||||
|
STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$CID" 2>/dev/null || echo "starting")
|
||||||
|
if [[ "$STATUS" == "healthy" ]]; then
|
||||||
|
echo "vless-proxy is healthy"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo " attempt $i: status=$STATUS"
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "== E2E tunnel probe =="
|
||||||
|
docker run --rm --network "${STACK_NAME}_ai-internal" curlimages/curl:8.5.0 \
|
||||||
|
curl -sf -x "${VLESS_URL}" --max-time 15 https://www.google.com/generate_204 \
|
||||||
|
|| echo "WARN: tunnel probe failed (may need valid vless.conf)"
|
||||||
|
|
||||||
|
echo "== Point litellm through VPN =="
|
||||||
|
docker service update \
|
||||||
|
--env-add "HTTP_PROXY=${VLESS_URL}" \
|
||||||
|
--env-add "HTTPS_PROXY=${VLESS_URL}" \
|
||||||
|
--env-add "NO_PROXY=${NO_PROXY_VAL}" \
|
||||||
|
"${STACK_NAME}_litellm"
|
||||||
|
|
||||||
|
echo "VPN enabled. Novita bypasses via NO_PROXY."
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
FROM thejohnd0e/vless-to-http:latest
|
||||||
|
|
||||||
|
# vless.conf mounted via Swarm secret at /app/vless.conf
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Example VLESS config — copy to vless.conf and fill, or use Swarm secret vless_conf
|
||||||
|
# Format: see https://github.com/thejohnd0e/VLESS-to-HTTP
|
||||||
|
|
||||||
|
vless://UUID@host:port?encryption=none&security=tls&sni=example.com#label
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
FROM docker:27-cli
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl bash
|
||||||
|
|
||||||
|
COPY vpn-watchdog.sh /usr/local/bin/vpn-watchdog.sh
|
||||||
|
RUN chmod +x /usr/local/bin/vpn-watchdog.sh
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/vpn-watchdog.sh"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end VPN tunnel probe; restart vless-proxy on failure
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STACK_NAME="${STACK_NAME:-ai-router}"
|
||||||
|
VLESS_PROXY_URL="${VLESS_PROXY_URL:-http://vless-proxy:8080}"
|
||||||
|
CHECK_INTERVAL="${CHECK_INTERVAL:-60}"
|
||||||
|
|
||||||
|
log() { echo "[vpn-watchdog] $(date -Iseconds) $*"; }
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
if curl -sf -x "${VLESS_PROXY_URL}" --max-time 15 https://www.google.com/generate_204 >/dev/null 2>&1; then
|
||||||
|
log "tunnel OK"
|
||||||
|
else
|
||||||
|
log "tunnel FAIL — force restart ${STACK_NAME}_vless-proxy"
|
||||||
|
docker service update --force "${STACK_NAME}_vless-proxy" || log "service update failed"
|
||||||
|
fi
|
||||||
|
sleep "${CHECK_INTERVAL}"
|
||||||
|
done
|
||||||
Reference in New Issue
Block a user