Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
e1d8498
1
Parent(s): 8f35d19
deploy: b1de43e — 更新 README.md
Browse files- .env.example +38 -0
- .gitattributes +0 -35
- .gitignore +35 -0
- Dockerfile +138 -0
- Dockerfile.huggingface +138 -0
- LICENSE +21 -0
- Makefile +53 -0
- README.md +82 -5
- backend/Dockerfile +18 -0
- backend/package.json +29 -0
- backend/src/database.js +242 -0
- backend/src/index.js +132 -0
- backend/src/litellm.js +250 -0
- backend/src/logger.js +26 -0
- backend/src/routes/models.js +283 -0
- backend/src/routes/stats.js +237 -0
- docker-compose.yml +117 -0
- frontend/Dockerfile +22 -0
- frontend/index.html +17 -0
- frontend/nginx.conf +109 -0
- frontend/package.json +25 -0
- frontend/postcss.config.js +6 -0
- frontend/src/App.jsx +127 -0
- frontend/src/api.js +32 -0
- frontend/src/components/DocsView.jsx +291 -0
- frontend/src/components/ModelCard.jsx +217 -0
- frontend/src/components/ModelForm.jsx +282 -0
- frontend/src/components/ModelManager.jsx +166 -0
- frontend/src/components/StatsBar.jsx +30 -0
- frontend/src/components/TestPanel.jsx +288 -0
- frontend/src/index.css +277 -0
- frontend/src/main.jsx +10 -0
- frontend/tailwind.config.js +55 -0
- frontend/vite.config.js +30 -0
- huggingface/README.md +87 -0
- huggingface/entrypoint.sh +100 -0
- huggingface/nginx.conf +126 -0
- huggingface/supervisord.conf +105 -0
- litellm/config.yaml +73 -0
- nginx/nginx.conf +109 -0
- scripts/setup.sh +101 -0
.env.example
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 2 |
+
# AI Gateway Hub — Environment Configuration
|
| 3 |
+
# Copy this file to .env and fill in your values.
|
| 4 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 5 |
+
|
| 6 |
+
# ── Security ──────────────────────────────────────────────────────────────────
|
| 7 |
+
# Master key for LiteLLM proxy admin API.
|
| 8 |
+
# Change this in production!
|
| 9 |
+
LITELLM_MASTER_KEY=sk-gateway-master-key-change-me
|
| 10 |
+
|
| 11 |
+
# JWT secret for backend sessions (future use).
|
| 12 |
+
JWT_SECRET=super-secret-jwt-key-change-in-production
|
| 13 |
+
|
| 14 |
+
# ── Networking ────────────────────────────────────────────────────────────────
|
| 15 |
+
# The public URL where your gateway is accessible.
|
| 16 |
+
# This is used to generate the OpenAI-compatible endpoint URLs shown in the UI.
|
| 17 |
+
GATEWAY_PUBLIC_URL=http://localhost
|
| 18 |
+
|
| 19 |
+
# HTTP/HTTPS ports for the nginx reverse proxy.
|
| 20 |
+
HTTP_PORT=80
|
| 21 |
+
HTTPS_PORT=443
|
| 22 |
+
|
| 23 |
+
# ── Optional: Pre-configured provider keys ───────────────────────────────────
|
| 24 |
+
# These can be set here as env vars for providers configured in litellm/config.yaml.
|
| 25 |
+
# OPENAI_API_KEY=sk-...
|
| 26 |
+
# ANTHROPIC_API_KEY=sk-ant-...
|
| 27 |
+
# GROQ_API_KEY=gsk_...
|
| 28 |
+
# GEMINI_API_KEY=...
|
| 29 |
+
# COHERE_API_KEY=...
|
| 30 |
+
# MISTRAL_API_KEY=...
|
| 31 |
+
# TOGETHER_API_KEY=...
|
| 32 |
+
# PERPLEXITYAI_API_KEY=pplx-...
|
| 33 |
+
|
| 34 |
+
# ── Logging ───────────────────────────────────────────────────────────────────
|
| 35 |
+
# BUG FIX #1: Was "info" (level 2) which silently drops all Morgan HTTP logs
|
| 36 |
+
# (level 3). Must be "http" or lower to see HTTP access logs in the console.
|
| 37 |
+
# Winston level hierarchy: error(0) < warn(1) < info(2) < http(3) < verbose(4)
|
| 38 |
+
LOG_LEVEL=http
|
.gitattributes
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment
|
| 2 |
+
.env
|
| 3 |
+
.env.local
|
| 4 |
+
|
| 5 |
+
# Dependencies
|
| 6 |
+
node_modules/
|
| 7 |
+
*/node_modules/
|
| 8 |
+
|
| 9 |
+
# Build output
|
| 10 |
+
frontend/dist/
|
| 11 |
+
frontend/.vite/
|
| 12 |
+
|
| 13 |
+
# Data volumes (Docker)
|
| 14 |
+
data/
|
| 15 |
+
*.db
|
| 16 |
+
*.db-shm
|
| 17 |
+
*.db-wal
|
| 18 |
+
|
| 19 |
+
# Logs
|
| 20 |
+
*.log
|
| 21 |
+
logs/
|
| 22 |
+
|
| 23 |
+
# SSL certificates
|
| 24 |
+
nginx/ssl/*.pem
|
| 25 |
+
nginx/ssl/*.key
|
| 26 |
+
nginx/ssl/*.crt
|
| 27 |
+
|
| 28 |
+
# OS
|
| 29 |
+
.DS_Store
|
| 30 |
+
Thumbs.db
|
| 31 |
+
|
| 32 |
+
# IDE
|
| 33 |
+
.idea/
|
| 34 |
+
.vscode/
|
| 35 |
+
*.swp
|
Dockerfile
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 2 |
+
# Dockerfile.huggingface
|
| 3 |
+
#
|
| 4 |
+
# Single-container build for Hugging Face Spaces (Docker SDK).
|
| 5 |
+
#
|
| 6 |
+
# Architecture inside the container:
|
| 7 |
+
#
|
| 8 |
+
# ┌──────────────────────────────────────────┐ port 7860 (HF Spaces)
|
| 9 |
+
# │ nginx │──────────────────────►
|
| 10 |
+
# │ / → static frontend files │
|
| 11 |
+
# │ /api/* → localhost:3001 (backend) │
|
| 12 |
+
# │ /v1/* → localhost:4000 (litellm) │
|
| 13 |
+
# └──────────────────────────────────────────┘
|
| 14 |
+
# │ │
|
| 15 |
+
# ▼ ▼
|
| 16 |
+
# Node.js backend LiteLLM proxy
|
| 17 |
+
# (port 3001) (port 4000)
|
| 18 |
+
# │
|
| 19 |
+
# ▼
|
| 20 |
+
# SQLite /app/data/gateway.db
|
| 21 |
+
#
|
| 22 |
+
# All processes are managed by supervisord.
|
| 23 |
+
#
|
| 24 |
+
# Bug fixes applied:
|
| 25 |
+
# [Bug2] Added build-essential so better-sqlite3 can compile via node-gyp
|
| 26 |
+
# if the prebuilt binary download fails.
|
| 27 |
+
# [Bug3] Changed `npm ci --only=production` → `npm ci --omit=dev`
|
| 28 |
+
# (--only=production is deprecated since npm v7; Node 20 ships npm v10).
|
| 29 |
+
# [Bug5] LiteLLM pip version extracted to ARG LITELLM_VERSION so it stays
|
| 30 |
+
# in sync with docker-compose.yml without touching two separate lines.
|
| 31 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 32 |
+
|
| 33 |
+
# ── Stage 1: Build React frontend ─────────────────────────────────────────────
|
| 34 |
+
FROM node:20-alpine AS frontend-builder
|
| 35 |
+
|
| 36 |
+
WORKDIR /build
|
| 37 |
+
|
| 38 |
+
# Build args match the original frontend/Dockerfile
|
| 39 |
+
ARG VITE_API_BASE=/api
|
| 40 |
+
ARG VITE_APP_NAME="AI Gateway Hub"
|
| 41 |
+
ENV VITE_API_BASE=$VITE_API_BASE \
|
| 42 |
+
VITE_APP_NAME=$VITE_APP_NAME
|
| 43 |
+
|
| 44 |
+
# Layer-cache package install separately from source copy
|
| 45 |
+
COPY frontend/package*.json ./
|
| 46 |
+
RUN npm ci
|
| 47 |
+
|
| 48 |
+
COPY frontend/ .
|
| 49 |
+
RUN npm run build
|
| 50 |
+
|
| 51 |
+
# ── Stage 2: Production runtime ───────────────────────────────────────────────
|
| 52 |
+
FROM python:3.11-slim
|
| 53 |
+
|
| 54 |
+
# ── LiteLLM version — keep in sync with docker-compose.yml image tag ──────────
|
| 55 |
+
# docker-compose.yml: ghcr.io/berriai/litellm:main-v1.81.14-stable
|
| 56 |
+
# → PyPI package: litellm==1.81.14
|
| 57 |
+
# To upgrade: change both this ARG and the docker-compose image tag together.
|
| 58 |
+
ARG LITELLM_VERSION=1.81.14
|
| 59 |
+
|
| 60 |
+
# ── System deps ───────────────────────────────────────────────────────────────
|
| 61 |
+
# build-essential (make + g++) is required as a fallback compilation path for
|
| 62 |
+
# better-sqlite3. prebuild-install downloads a prebuilt .node binary at npm ci
|
| 63 |
+
# time; if that download fails (version mismatch, network timeout, etc.) the
|
| 64 |
+
# package falls back to compiling from source via node-gyp, which needs make
|
| 65 |
+
# and g++. Without build-essential that fallback path crashes the build.
|
| 66 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 67 |
+
curl \
|
| 68 |
+
gnupg \
|
| 69 |
+
ca-certificates \
|
| 70 |
+
nginx \
|
| 71 |
+
supervisor \
|
| 72 |
+
build-essential \
|
| 73 |
+
&& \
|
| 74 |
+
# Node.js 20 LTS (backend runtime — frontend is pre-built in Stage 1)
|
| 75 |
+
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
| 76 |
+
apt-get install -y --no-install-recommends nodejs && \
|
| 77 |
+
# Clean up (gnupg no longer needed after nodesource setup)
|
| 78 |
+
apt-get purge -y gnupg && \
|
| 79 |
+
apt-get autoremove -y && \
|
| 80 |
+
rm -rf /var/lib/apt/lists/*
|
| 81 |
+
|
| 82 |
+
# ── LiteLLM proxy ─────────────────────────────────────────────────────────────
|
| 83 |
+
RUN pip install --no-cache-dir "litellm[proxy]==${LITELLM_VERSION}"
|
| 84 |
+
|
| 85 |
+
# ── Non-root user (HF Spaces recommendation: uid=1000) ────────────────────────
|
| 86 |
+
RUN useradd -m -u 1000 -s /bin/bash user
|
| 87 |
+
|
| 88 |
+
# ── Directory layout ──────────────────────────────────────────────────────────
|
| 89 |
+
RUN mkdir -p \
|
| 90 |
+
/app/frontend/dist \
|
| 91 |
+
/app/backend/src \
|
| 92 |
+
/app/litellm \
|
| 93 |
+
/app/huggingface \
|
| 94 |
+
/app/data \
|
| 95 |
+
/var/log/supervisor \
|
| 96 |
+
/tmp/nginx/client_body \
|
| 97 |
+
/tmp/nginx/proxy \
|
| 98 |
+
/tmp/nginx/fastcgi \
|
| 99 |
+
/tmp/nginx/uwsgi \
|
| 100 |
+
/tmp/nginx/scgi \
|
| 101 |
+
&& \
|
| 102 |
+
# Give nginx writable log/lib dirs when running as uid 1000
|
| 103 |
+
chown -R user:user \
|
| 104 |
+
/app \
|
| 105 |
+
/var/log/supervisor \
|
| 106 |
+
/var/log/nginx \
|
| 107 |
+
/var/lib/nginx \
|
| 108 |
+
/tmp/nginx
|
| 109 |
+
|
| 110 |
+
# ── Frontend (pre-built in Stage 1) ───────────────────────────────────────────
|
| 111 |
+
COPY --from=frontend-builder --chown=user:user /build/dist /app/frontend/dist
|
| 112 |
+
|
| 113 |
+
# ── Backend ───────────────────────────────────────────────────────────────────
|
| 114 |
+
COPY --chown=user:user backend/package*.json /app/backend/
|
| 115 |
+
|
| 116 |
+
# FIX [Bug3]: --only=production is deprecated since npm v7 and broken in npm v10
|
| 117 |
+
# (Node 20). The correct flag is --omit=dev.
|
| 118 |
+
# We run npm ci as root so node-gyp can write to system temp dirs during native
|
| 119 |
+
# addon compilation (better-sqlite3), then hand ownership to the user.
|
| 120 |
+
RUN cd /app/backend && npm ci --omit=dev && chown -R user:user /app/backend/node_modules
|
| 121 |
+
|
| 122 |
+
COPY --chown=user:user backend/src/ /app/backend/src/
|
| 123 |
+
|
| 124 |
+
# ── Configs ───────────────────────────────────────────────────────────────────
|
| 125 |
+
COPY --chown=user:user litellm/config.yaml /app/litellm/config.yaml
|
| 126 |
+
COPY --chown=user:user huggingface/nginx.conf /app/huggingface/nginx.conf
|
| 127 |
+
COPY --chown=user:user huggingface/supervisord.conf /app/huggingface/supervisord.conf
|
| 128 |
+
COPY --chown=user:user huggingface/entrypoint.sh /app/huggingface/entrypoint.sh
|
| 129 |
+
RUN chmod +x /app/huggingface/entrypoint.sh
|
| 130 |
+
|
| 131 |
+
# ── Switch to non-root ────────────────────────────────────────────────────────
|
| 132 |
+
USER user
|
| 133 |
+
WORKDIR /app
|
| 134 |
+
|
| 135 |
+
# HF Spaces listens on 7860 (declared in huggingface/README.md frontmatter)
|
| 136 |
+
EXPOSE 7860
|
| 137 |
+
|
| 138 |
+
ENTRYPOINT ["/app/huggingface/entrypoint.sh"]
|
Dockerfile.huggingface
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 2 |
+
# Dockerfile.huggingface
|
| 3 |
+
#
|
| 4 |
+
# Single-container build for Hugging Face Spaces (Docker SDK).
|
| 5 |
+
#
|
| 6 |
+
# Architecture inside the container:
|
| 7 |
+
#
|
| 8 |
+
# ┌──────────────────────────────────────────┐ port 7860 (HF Spaces)
|
| 9 |
+
# │ nginx │──────────────────────►
|
| 10 |
+
# │ / → static frontend files │
|
| 11 |
+
# │ /api/* → localhost:3001 (backend) │
|
| 12 |
+
# │ /v1/* → localhost:4000 (litellm) │
|
| 13 |
+
# └──────────────────────────────────────────┘
|
| 14 |
+
# │ │
|
| 15 |
+
# ▼ ▼
|
| 16 |
+
# Node.js backend LiteLLM proxy
|
| 17 |
+
# (port 3001) (port 4000)
|
| 18 |
+
# │
|
| 19 |
+
# ▼
|
| 20 |
+
# SQLite /app/data/gateway.db
|
| 21 |
+
#
|
| 22 |
+
# All processes are managed by supervisord.
|
| 23 |
+
#
|
| 24 |
+
# Bug fixes applied:
|
| 25 |
+
# [Bug2] Added build-essential so better-sqlite3 can compile via node-gyp
|
| 26 |
+
# if the prebuilt binary download fails.
|
| 27 |
+
# [Bug3] Changed `npm ci --only=production` → `npm ci --omit=dev`
|
| 28 |
+
# (--only=production is deprecated since npm v7; Node 20 ships npm v10).
|
| 29 |
+
# [Bug5] LiteLLM pip version extracted to ARG LITELLM_VERSION so it stays
|
| 30 |
+
# in sync with docker-compose.yml without touching two separate lines.
|
| 31 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 32 |
+
|
| 33 |
+
# ── Stage 1: Build React frontend ─────────────────────────────────────────────
|
| 34 |
+
FROM node:20-alpine AS frontend-builder
|
| 35 |
+
|
| 36 |
+
WORKDIR /build
|
| 37 |
+
|
| 38 |
+
# Build args match the original frontend/Dockerfile
|
| 39 |
+
ARG VITE_API_BASE=/api
|
| 40 |
+
ARG VITE_APP_NAME="AI Gateway Hub"
|
| 41 |
+
ENV VITE_API_BASE=$VITE_API_BASE \
|
| 42 |
+
VITE_APP_NAME=$VITE_APP_NAME
|
| 43 |
+
|
| 44 |
+
# Layer-cache package install separately from source copy
|
| 45 |
+
COPY frontend/package*.json ./
|
| 46 |
+
RUN npm ci
|
| 47 |
+
|
| 48 |
+
COPY frontend/ .
|
| 49 |
+
RUN npm run build
|
| 50 |
+
|
| 51 |
+
# ── Stage 2: Production runtime ───────────────────────────────────────────────
|
| 52 |
+
FROM python:3.11-slim
|
| 53 |
+
|
| 54 |
+
# ── LiteLLM version — keep in sync with docker-compose.yml image tag ──────────
|
| 55 |
+
# docker-compose.yml: ghcr.io/berriai/litellm:main-v1.81.14-stable
|
| 56 |
+
# → PyPI package: litellm==1.81.14
|
| 57 |
+
# To upgrade: change both this ARG and the docker-compose image tag together.
|
| 58 |
+
ARG LITELLM_VERSION=1.81.14
|
| 59 |
+
|
| 60 |
+
# ── System deps ───────────────────────────────────────────────────────────────
|
| 61 |
+
# build-essential (make + g++) is required as a fallback compilation path for
|
| 62 |
+
# better-sqlite3. prebuild-install downloads a prebuilt .node binary at npm ci
|
| 63 |
+
# time; if that download fails (version mismatch, network timeout, etc.) the
|
| 64 |
+
# package falls back to compiling from source via node-gyp, which needs make
|
| 65 |
+
# and g++. Without build-essential that fallback path crashes the build.
|
| 66 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 67 |
+
curl \
|
| 68 |
+
gnupg \
|
| 69 |
+
ca-certificates \
|
| 70 |
+
nginx \
|
| 71 |
+
supervisor \
|
| 72 |
+
build-essential \
|
| 73 |
+
&& \
|
| 74 |
+
# Node.js 20 LTS (backend runtime — frontend is pre-built in Stage 1)
|
| 75 |
+
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
| 76 |
+
apt-get install -y --no-install-recommends nodejs && \
|
| 77 |
+
# Clean up (gnupg no longer needed after nodesource setup)
|
| 78 |
+
apt-get purge -y gnupg && \
|
| 79 |
+
apt-get autoremove -y && \
|
| 80 |
+
rm -rf /var/lib/apt/lists/*
|
| 81 |
+
|
| 82 |
+
# ── LiteLLM proxy ─────────────────────────────────────────────────────────────
|
| 83 |
+
RUN pip install --no-cache-dir "litellm[proxy]==${LITELLM_VERSION}"
|
| 84 |
+
|
| 85 |
+
# ── Non-root user (HF Spaces recommendation: uid=1000) ────────────────────────
|
| 86 |
+
RUN useradd -m -u 1000 -s /bin/bash user
|
| 87 |
+
|
| 88 |
+
# ── Directory layout ──────────────────────────────────────────────────────────
|
| 89 |
+
RUN mkdir -p \
|
| 90 |
+
/app/frontend/dist \
|
| 91 |
+
/app/backend/src \
|
| 92 |
+
/app/litellm \
|
| 93 |
+
/app/huggingface \
|
| 94 |
+
/app/data \
|
| 95 |
+
/var/log/supervisor \
|
| 96 |
+
/tmp/nginx/client_body \
|
| 97 |
+
/tmp/nginx/proxy \
|
| 98 |
+
/tmp/nginx/fastcgi \
|
| 99 |
+
/tmp/nginx/uwsgi \
|
| 100 |
+
/tmp/nginx/scgi \
|
| 101 |
+
&& \
|
| 102 |
+
# Give nginx writable log/lib dirs when running as uid 1000
|
| 103 |
+
chown -R user:user \
|
| 104 |
+
/app \
|
| 105 |
+
/var/log/supervisor \
|
| 106 |
+
/var/log/nginx \
|
| 107 |
+
/var/lib/nginx \
|
| 108 |
+
/tmp/nginx
|
| 109 |
+
|
| 110 |
+
# ── Frontend (pre-built in Stage 1) ───────────────────────────────────────────
|
| 111 |
+
COPY --from=frontend-builder --chown=user:user /build/dist /app/frontend/dist
|
| 112 |
+
|
| 113 |
+
# ── Backend ───────────────────────────────────────────────────────────────────
|
| 114 |
+
COPY --chown=user:user backend/package*.json /app/backend/
|
| 115 |
+
|
| 116 |
+
# FIX [Bug3]: --only=production is deprecated since npm v7 and broken in npm v10
|
| 117 |
+
# (Node 20). The correct flag is --omit=dev.
|
| 118 |
+
# We run npm ci as root so node-gyp can write to system temp dirs during native
|
| 119 |
+
# addon compilation (better-sqlite3), then hand ownership to the user.
|
| 120 |
+
RUN cd /app/backend && npm ci --omit=dev && chown -R user:user /app/backend/node_modules
|
| 121 |
+
|
| 122 |
+
COPY --chown=user:user backend/src/ /app/backend/src/
|
| 123 |
+
|
| 124 |
+
# ── Configs ───────────────────────────────────────────────────────────────────
|
| 125 |
+
COPY --chown=user:user litellm/config.yaml /app/litellm/config.yaml
|
| 126 |
+
COPY --chown=user:user huggingface/nginx.conf /app/huggingface/nginx.conf
|
| 127 |
+
COPY --chown=user:user huggingface/supervisord.conf /app/huggingface/supervisord.conf
|
| 128 |
+
COPY --chown=user:user huggingface/entrypoint.sh /app/huggingface/entrypoint.sh
|
| 129 |
+
RUN chmod +x /app/huggingface/entrypoint.sh
|
| 130 |
+
|
| 131 |
+
# ── Switch to non-root ────────────────────────────────────────────────────────
|
| 132 |
+
USER user
|
| 133 |
+
WORKDIR /app
|
| 134 |
+
|
| 135 |
+
# HF Spaces listens on 7860 (declared in huggingface/README.md frontmatter)
|
| 136 |
+
EXPOSE 7860
|
| 137 |
+
|
| 138 |
+
ENTRYPOINT ["/app/huggingface/entrypoint.sh"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 AI Gateway Hub Contributors
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
Makefile
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.PHONY: up down restart logs build clean setup dev
|
| 2 |
+
|
| 3 |
+
# ── Production ────────────────────────────────────────────────────────────────
|
| 4 |
+
up:
|
| 5 |
+
docker compose up -d
|
| 6 |
+
|
| 7 |
+
down:
|
| 8 |
+
docker compose down
|
| 9 |
+
|
| 10 |
+
restart:
|
| 11 |
+
docker compose restart
|
| 12 |
+
|
| 13 |
+
build:
|
| 14 |
+
docker compose build --no-cache
|
| 15 |
+
|
| 16 |
+
logs:
|
| 17 |
+
docker compose logs -f
|
| 18 |
+
|
| 19 |
+
logs-backend:
|
| 20 |
+
docker compose logs -f backend
|
| 21 |
+
|
| 22 |
+
logs-litellm:
|
| 23 |
+
docker compose logs -f litellm
|
| 24 |
+
|
| 25 |
+
status:
|
| 26 |
+
docker compose ps
|
| 27 |
+
|
| 28 |
+
# ── Setup ─────────────────────────────────────────────────────────────────────
|
| 29 |
+
setup:
|
| 30 |
+
chmod +x scripts/setup.sh && ./scripts/setup.sh
|
| 31 |
+
|
| 32 |
+
env:
|
| 33 |
+
@if [ ! -f .env ]; then cp .env.example .env && echo ".env created"; else echo ".env already exists"; fi
|
| 34 |
+
|
| 35 |
+
# ── Development ───────────────────────────────────────────────────────────────
|
| 36 |
+
dev-backend:
|
| 37 |
+
cd backend && npm run dev
|
| 38 |
+
|
| 39 |
+
dev-frontend:
|
| 40 |
+
cd frontend && npm run dev
|
| 41 |
+
|
| 42 |
+
dev-install:
|
| 43 |
+
cd backend && npm install
|
| 44 |
+
cd frontend && npm install
|
| 45 |
+
|
| 46 |
+
# ── Cleanup ───────────────────────────────────────────────────────────────────
|
| 47 |
+
clean:
|
| 48 |
+
docker compose down -v --remove-orphans
|
| 49 |
+
docker image prune -f
|
| 50 |
+
|
| 51 |
+
clean-data:
|
| 52 |
+
docker compose down -v
|
| 53 |
+
@echo "All persistent data removed."
|
README.md
CHANGED
|
@@ -1,10 +1,87 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: AI Gateway Hub
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Universal AI Gateway — any provider to OpenAI API
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# 🚀 AI Gateway Hub
|
| 14 |
+
|
| 15 |
+
> **Universal AI API Gateway** — Register any AI provider (OpenAI, Anthropic, Ollama, Gemini, Groq, and 100+ more) and get a single OpenAI-compatible endpoint. Powered by [LiteLLM](https://litellm.ai).
|
| 16 |
+
|
| 17 |
+
## ⚙️ Setup (Required Before First Use)
|
| 18 |
+
|
| 19 |
+
This Space needs a few secrets set before it will work correctly.
|
| 20 |
+
|
| 21 |
+
Go to **Space Settings → Repository secrets** and add:
|
| 22 |
+
|
| 23 |
+
| Secret | Required | Description |
|
| 24 |
+
|---|---|---|
|
| 25 |
+
| `LITELLM_MASTER_KEY` | ✅ Yes | Admin key for the LiteLLM proxy. Use a strong random string, e.g. `sk-gateway-xxxxxxxx` |
|
| 26 |
+
| `JWT_SECRET` | ✅ Yes | Secret for backend session tokens. Any long random string |
|
| 27 |
+
| `GATEWAY_PUBLIC_URL` | ✅ Yes | The public URL of this Space: `https://<your-username>-<space-name>.hf.space` |
|
| 28 |
+
| `OPENAI_API_KEY` | Optional | Required only if you register OpenAI models |
|
| 29 |
+
| `ANTHROPIC_API_KEY` | Optional | Required only if you register Anthropic models |
|
| 30 |
+
| `GROQ_API_KEY` | Optional | Required only if you register Groq models |
|
| 31 |
+
| `GEMINI_API_KEY` | Optional | Required only if you register Google Gemini models |
|
| 32 |
+
|
| 33 |
+
> **Tip:** `GATEWAY_PUBLIC_URL` is set automatically on the **first** deploy by the GitHub Actions workflow and is never overwritten afterward, so you can safely customise it to a custom domain later.
|
| 34 |
+
|
| 35 |
+
> **Tip:** Any provider API key you register through the UI is stored encrypted in the Space's SQLite database and never exposed to callers of the gateway.
|
| 36 |
+
|
| 37 |
+
## 🚀 Quick Start
|
| 38 |
+
|
| 39 |
+
1. After setting secrets above, wait for the Space to restart (it rebuilds automatically)
|
| 40 |
+
2. Open the Space — you'll see the **AI Gateway Hub** dashboard
|
| 41 |
+
3. Click **Add Model** and fill in your provider details
|
| 42 |
+
4. Use the generated `model name` with any OpenAI SDK:
|
| 43 |
+
|
| 44 |
+
```python
|
| 45 |
+
from openai import OpenAI
|
| 46 |
+
|
| 47 |
+
client = OpenAI(
|
| 48 |
+
base_url="https://<your-username>-<space-name>.hf.space/v1",
|
| 49 |
+
api_key="any-string", # callers don't need the real provider key
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
response = client.chat.completions.create(
|
| 53 |
+
model="anthropic/claude-3-5-sonnet-20241022", # alias shown in the UI
|
| 54 |
+
messages=[{"role": "user", "content": "Hello!"}],
|
| 55 |
+
)
|
| 56 |
+
print(response.choices[0].message.content)
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## 📡 API Endpoints
|
| 60 |
+
|
| 61 |
+
| Path | Description |
|
| 62 |
+
|---|---|
|
| 63 |
+
| `GET /` | Web dashboard |
|
| 64 |
+
| `POST /v1/chat/completions` | OpenAI-compatible chat (streaming supported) |
|
| 65 |
+
| `POST /v1/embeddings` | Text embeddings |
|
| 66 |
+
| `GET /v1/models` | List registered models |
|
| 67 |
+
| `GET /api/models` | Management API — list models |
|
| 68 |
+
| `POST /api/models` | Management API — register model |
|
| 69 |
+
| `GET /api/health` | Health check |
|
| 70 |
+
|
| 71 |
+
## 📦 Supported Providers
|
| 72 |
+
|
| 73 |
+
OpenAI · Anthropic · Google Gemini · Ollama · Groq · Mistral · Cohere · Together AI · AWS Bedrock · Azure OpenAI · HuggingFace · DeepSeek · Perplexity · Replicate · any OpenAI-compatible endpoint
|
| 74 |
+
|
| 75 |
+
Full list: [docs.litellm.ai/docs/providers](https://docs.litellm.ai/docs/providers)
|
| 76 |
+
|
| 77 |
+
## ⚠️ Important Notes
|
| 78 |
+
|
| 79 |
+
- **Persistence:** The SQLite database (`/app/data/gateway.db`) storing your registered models is **ephemeral** on the free CPU Basic hardware tier — it resets on Space restart. To persist data, upgrade to a paid hardware tier or use an external database.
|
| 80 |
+
- **Rate limits:** The free HF Spaces tier has CPU/memory limits. For production use, consider duplicating this Space with upgraded hardware.
|
| 81 |
+
- **Security:** This Space is public by default. Anyone can call your `/v1/*` endpoints. For private use, set the Space visibility to **Private** in Space Settings.
|
| 82 |
+
|
| 83 |
+
## 🔗 Source
|
| 84 |
+
|
| 85 |
+
This Space is automatically deployed from the [AI Gateway Hub GitHub repository](https://github.com/your-org/ai-gateway-hub).
|
| 86 |
+
|
| 87 |
+
> Replace `your-org/ai-gateway-hub` above with your actual repository URL.
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-alpine AS base
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
|
| 4 |
+
# Install build dependencies for better-sqlite3
|
| 5 |
+
RUN apk add --no-cache python3 make g++ curl
|
| 6 |
+
|
| 7 |
+
COPY package*.json ./
|
| 8 |
+
RUN npm ci --only=production
|
| 9 |
+
|
| 10 |
+
COPY src/ ./src/
|
| 11 |
+
|
| 12 |
+
RUN mkdir -p /app/data
|
| 13 |
+
|
| 14 |
+
EXPOSE 3001
|
| 15 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
|
| 16 |
+
CMD curl -f http://localhost:3001/api/health || exit 1
|
| 17 |
+
|
| 18 |
+
CMD ["node", "src/index.js"]
|
backend/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "ai-gateway-backend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "AI Gateway Hub — Backend API Server",
|
| 5 |
+
"main": "src/index.js",
|
| 6 |
+
"type": "commonjs",
|
| 7 |
+
"scripts": {
|
| 8 |
+
"start": "node src/index.js",
|
| 9 |
+
"dev": "nodemon src/index.js"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"axios": "^1.7.2",
|
| 13 |
+
"better-sqlite3": "^9.6.0",
|
| 14 |
+
"cors": "^2.8.5",
|
| 15 |
+
"express": "^4.19.2",
|
| 16 |
+
"express-rate-limit": "^7.3.1",
|
| 17 |
+
"helmet": "^7.1.0",
|
| 18 |
+
"morgan": "^1.10.0",
|
| 19 |
+
"nanoid": "^3.3.7",
|
| 20 |
+
"uuid": "^10.0.0",
|
| 21 |
+
"winston": "^3.13.0"
|
| 22 |
+
},
|
| 23 |
+
"devDependencies": {
|
| 24 |
+
"nodemon": "^3.1.4"
|
| 25 |
+
},
|
| 26 |
+
"engines": {
|
| 27 |
+
"node": ">=18.0.0"
|
| 28 |
+
}
|
| 29 |
+
}
|
backend/src/database.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
|
| 3 |
+
const Database = require("better-sqlite3");
|
| 4 |
+
const path = require("path");
|
| 5 |
+
const { logger } = require("./logger");
|
| 6 |
+
|
| 7 |
+
const DB_PATH = process.env.DB_PATH || path.join(__dirname, "../data/gateway.db");
|
| 8 |
+
|
| 9 |
+
let db;
|
| 10 |
+
|
| 11 |
+
function getDb() {
|
| 12 |
+
if (!db) {
|
| 13 |
+
db = new Database(DB_PATH, { verbose: null });
|
| 14 |
+
db.pragma("journal_mode = WAL");
|
| 15 |
+
db.pragma("foreign_keys = ON");
|
| 16 |
+
migrate(db);
|
| 17 |
+
logger.info(`SQLite database opened at ${DB_PATH}`);
|
| 18 |
+
}
|
| 19 |
+
return db;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function migrate(db) {
|
| 23 |
+
db.exec(`
|
| 24 |
+
CREATE TABLE IF NOT EXISTS models (
|
| 25 |
+
id TEXT PRIMARY KEY,
|
| 26 |
+
name TEXT NOT NULL UNIQUE,
|
| 27 |
+
display_name TEXT NOT NULL,
|
| 28 |
+
provider TEXT NOT NULL,
|
| 29 |
+
litellm_model TEXT NOT NULL,
|
| 30 |
+
api_base TEXT,
|
| 31 |
+
api_key TEXT,
|
| 32 |
+
description TEXT,
|
| 33 |
+
tags TEXT DEFAULT '[]',
|
| 34 |
+
model_type TEXT DEFAULT 'chat',
|
| 35 |
+
enabled INTEGER DEFAULT 1,
|
| 36 |
+
litellm_id TEXT,
|
| 37 |
+
created_at TEXT DEFAULT (datetime('now')),
|
| 38 |
+
updated_at TEXT DEFAULT (datetime('now'))
|
| 39 |
+
);
|
| 40 |
+
|
| 41 |
+
CREATE TABLE IF NOT EXISTS usage_logs (
|
| 42 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 43 |
+
model_id TEXT REFERENCES models(id),
|
| 44 |
+
model_name TEXT NOT NULL,
|
| 45 |
+
request_type TEXT NOT NULL,
|
| 46 |
+
status INTEGER NOT NULL,
|
| 47 |
+
latency_ms INTEGER,
|
| 48 |
+
tokens_in INTEGER,
|
| 49 |
+
tokens_out INTEGER,
|
| 50 |
+
created_at TEXT DEFAULT (datetime('now'))
|
| 51 |
+
);
|
| 52 |
+
|
| 53 |
+
CREATE TABLE IF NOT EXISTS settings (
|
| 54 |
+
key TEXT PRIMARY KEY,
|
| 55 |
+
value TEXT NOT NULL
|
| 56 |
+
);
|
| 57 |
+
|
| 58 |
+
CREATE INDEX IF NOT EXISTS idx_models_enabled ON models(enabled);
|
| 59 |
+
CREATE INDEX IF NOT EXISTS idx_usage_model ON usage_logs(model_id);
|
| 60 |
+
CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_logs(created_at);
|
| 61 |
+
`);
|
| 62 |
+
|
| 63 |
+
// Seed default settings
|
| 64 |
+
const upsertSetting = db.prepare(
|
| 65 |
+
`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`
|
| 66 |
+
);
|
| 67 |
+
upsertSetting.run("gateway_version", "1.0.0");
|
| 68 |
+
upsertSetting.run("require_auth", "false");
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// ─── Field mapping ─────────────────────────────────────────────────────────
|
| 72 |
+
//
|
| 73 |
+
// BUG FIX #5: Replace the fragile inline ternary chain with a single
|
| 74 |
+
// declarative map. Every camelCase JS key maps to its snake_case SQL column.
|
| 75 |
+
// Adding a new field only requires one entry here — not edits spread across
|
| 76 |
+
// multiple places.
|
| 77 |
+
//
|
| 78 |
+
// Convention: if a JS key IS the same as its SQL column, it can be omitted
|
| 79 |
+
// (the fallback at the end of jsToCol() handles it), but explicit entries are
|
| 80 |
+
// preferred for clarity.
|
| 81 |
+
//
|
| 82 |
+
const JS_TO_COL = {
|
| 83 |
+
displayName: "display_name",
|
| 84 |
+
litellmModel: "litellm_model",
|
| 85 |
+
apiBase: "api_base",
|
| 86 |
+
apiKey: "api_key",
|
| 87 |
+
modelType: "model_type",
|
| 88 |
+
litellmId: "litellm_id",
|
| 89 |
+
// Fields whose JS name == SQL column (listed for documentation purposes)
|
| 90 |
+
id: "id",
|
| 91 |
+
name: "name",
|
| 92 |
+
provider: "provider",
|
| 93 |
+
description: "description",
|
| 94 |
+
tags: "tags",
|
| 95 |
+
enabled: "enabled",
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
function jsToCol(key) {
|
| 99 |
+
return JS_TO_COL[key] || key;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// ─── Model CRUD ────────────────────────────────────────────────────────────
|
| 103 |
+
|
| 104 |
+
function listModels({ enabledOnly = false } = {}) {
|
| 105 |
+
const db = getDb();
|
| 106 |
+
const where = enabledOnly ? "WHERE enabled = 1" : "";
|
| 107 |
+
const rows = db.prepare(`SELECT * FROM models ${where} ORDER BY created_at DESC`).all();
|
| 108 |
+
return rows.map(deserializeModel);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function getModel(id) {
|
| 112 |
+
const db = getDb();
|
| 113 |
+
const row = db.prepare("SELECT * FROM models WHERE id = ?").get(id);
|
| 114 |
+
return row ? deserializeModel(row) : null;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function getModelByName(name) {
|
| 118 |
+
const db = getDb();
|
| 119 |
+
const row = db.prepare("SELECT * FROM models WHERE name = ?").get(name);
|
| 120 |
+
return row ? deserializeModel(row) : null;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function createModel(model) {
|
| 124 |
+
const db = getDb();
|
| 125 |
+
const stmt = db.prepare(`
|
| 126 |
+
INSERT INTO models
|
| 127 |
+
(id, name, display_name, provider, litellm_model, api_base, api_key,
|
| 128 |
+
description, tags, model_type, enabled, litellm_id)
|
| 129 |
+
VALUES
|
| 130 |
+
(@id, @name, @display_name, @provider, @litellm_model, @api_base, @api_key,
|
| 131 |
+
@description, @tags, @model_type, @enabled, @litellm_id)
|
| 132 |
+
`);
|
| 133 |
+
stmt.run({ ...serializeModel(model) });
|
| 134 |
+
return getModel(model.id);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function updateModel(id, updates) {
|
| 138 |
+
const db = getDb();
|
| 139 |
+
|
| 140 |
+
const keys = Object.keys(updates).filter((k) => k !== "id");
|
| 141 |
+
if (keys.length === 0) return getModel(id);
|
| 142 |
+
|
| 143 |
+
// Normalize types for SQLite
|
| 144 |
+
const normalized = { ...updates };
|
| 145 |
+
if (typeof normalized.enabled === "boolean") {
|
| 146 |
+
normalized.enabled = normalized.enabled ? 1 : 0;
|
| 147 |
+
}
|
| 148 |
+
if (normalized.tags && Array.isArray(normalized.tags)) {
|
| 149 |
+
normalized.tags = JSON.stringify(normalized.tags);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// BUG FIX #5: Use the declarative JS_TO_COL map instead of an inline
|
| 153 |
+
// ternary chain. Each SET clause: `sql_col = @jsKey` so that
|
| 154 |
+
// better-sqlite3's named binding (@jsKey) finds the value in `normalized`.
|
| 155 |
+
const fields = keys
|
| 156 |
+
.map((k) => `${jsToCol(k)} = @${k}`)
|
| 157 |
+
.join(", ");
|
| 158 |
+
|
| 159 |
+
db.prepare(
|
| 160 |
+
`UPDATE models SET ${fields}, updated_at = datetime('now') WHERE id = @id`
|
| 161 |
+
).run({ id, ...normalized });
|
| 162 |
+
|
| 163 |
+
return getModel(id);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function deleteModel(id) {
|
| 167 |
+
const db = getDb();
|
| 168 |
+
db.prepare("DELETE FROM models WHERE id = ?").run(id);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
function logUsage(entry) {
|
| 172 |
+
const db = getDb();
|
| 173 |
+
db.prepare(`
|
| 174 |
+
INSERT INTO usage_logs (model_id, model_name, request_type, status, latency_ms, tokens_in, tokens_out)
|
| 175 |
+
VALUES (@modelId, @modelName, @requestType, @status, @latencyMs, @tokensIn, @tokensOut)
|
| 176 |
+
`).run(entry);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function getStats() {
|
| 180 |
+
const db = getDb();
|
| 181 |
+
return {
|
| 182 |
+
totalModels: db.prepare("SELECT COUNT(*) as n FROM models").get().n,
|
| 183 |
+
enabledModels: db.prepare("SELECT COUNT(*) as n FROM models WHERE enabled = 1").get().n,
|
| 184 |
+
totalRequests: db.prepare("SELECT COUNT(*) as n FROM usage_logs").get().n,
|
| 185 |
+
successRequests: db.prepare("SELECT COUNT(*) as n FROM usage_logs WHERE status = 200").get().n,
|
| 186 |
+
avgLatency: db.prepare("SELECT AVG(latency_ms) as n FROM usage_logs WHERE status = 200").get().n || 0,
|
| 187 |
+
};
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// ─── Serialization helpers ─────────────────────────────────────────────────
|
| 191 |
+
|
| 192 |
+
function serializeModel(m) {
|
| 193 |
+
// IMPORTANT: raw key is stored as _apiKey in model records coming from routes,
|
| 194 |
+
// m.apiKey may be the masked "••••••••" string — never write that to the DB.
|
| 195 |
+
const rawKey = m._apiKey || (m.apiKey && m.apiKey !== "••••••••" ? m.apiKey : null) || m.api_key || null;
|
| 196 |
+
return {
|
| 197 |
+
id: m.id,
|
| 198 |
+
name: m.name,
|
| 199 |
+
display_name: m.displayName || m.display_name,
|
| 200 |
+
provider: m.provider,
|
| 201 |
+
litellm_model: m.litellmModel || m.litellm_model,
|
| 202 |
+
api_base: m.apiBase || m.api_base || null,
|
| 203 |
+
api_key: rawKey,
|
| 204 |
+
description: m.description || null,
|
| 205 |
+
tags: JSON.stringify(m.tags || []),
|
| 206 |
+
model_type: m.modelType || m.model_type || "chat",
|
| 207 |
+
enabled: m.enabled !== false ? 1 : 0,
|
| 208 |
+
litellm_id: m.litellmId || m.litellm_id || null,
|
| 209 |
+
};
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
function deserializeModel(row) {
|
| 213 |
+
return {
|
| 214 |
+
id: row.id,
|
| 215 |
+
name: row.name,
|
| 216 |
+
displayName: row.display_name,
|
| 217 |
+
provider: row.provider,
|
| 218 |
+
litellmModel: row.litellm_model,
|
| 219 |
+
apiBase: row.api_base,
|
| 220 |
+
apiKey: row.api_key ? "••••••••" : null, // mask key
|
| 221 |
+
_apiKey: row.api_key, // raw key for internal use
|
| 222 |
+
description: row.description,
|
| 223 |
+
tags: JSON.parse(row.tags || "[]"),
|
| 224 |
+
modelType: row.model_type,
|
| 225 |
+
enabled: row.enabled === 1,
|
| 226 |
+
litellmId: row.litellm_id,
|
| 227 |
+
createdAt: row.created_at,
|
| 228 |
+
updatedAt: row.updated_at,
|
| 229 |
+
};
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
module.exports = {
|
| 233 |
+
getDb,
|
| 234 |
+
listModels,
|
| 235 |
+
getModel,
|
| 236 |
+
getModelByName,
|
| 237 |
+
createModel,
|
| 238 |
+
updateModel,
|
| 239 |
+
deleteModel,
|
| 240 |
+
logUsage,
|
| 241 |
+
getStats,
|
| 242 |
+
};
|
backend/src/index.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
|
| 3 |
+
const express = require("express");
|
| 4 |
+
const cors = require("cors");
|
| 5 |
+
const helmet = require("helmet");
|
| 6 |
+
const morgan = require("morgan");
|
| 7 |
+
const rateLimit = require("express-rate-limit");
|
| 8 |
+
|
| 9 |
+
const { logger } = require("./logger");
|
| 10 |
+
const modelsRouter = require("./routes/models");
|
| 11 |
+
const statsRouter = require("./routes/stats");
|
| 12 |
+
const db = require("./database");
|
| 13 |
+
const litellm = require("./litellm");
|
| 14 |
+
|
| 15 |
+
const app = express();
|
| 16 |
+
const PORT = parseInt(process.env.PORT || "3001", 10);
|
| 17 |
+
|
| 18 |
+
// ─── Middleware ────────────────────────────────────────────────────────────
|
| 19 |
+
|
| 20 |
+
app.use(helmet({ crossOriginResourcePolicy: false }));
|
| 21 |
+
app.use(
|
| 22 |
+
cors({
|
| 23 |
+
origin: "*",
|
| 24 |
+
methods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
|
| 25 |
+
allowedHeaders: ["Content-Type", "Authorization"],
|
| 26 |
+
})
|
| 27 |
+
);
|
| 28 |
+
app.use(express.json({ limit: "10mb" }));
|
| 29 |
+
app.use(
|
| 30 |
+
morgan("combined", {
|
| 31 |
+
stream: { write: (msg) => logger.http(msg.trim()) },
|
| 32 |
+
skip: (req) => req.url === "/api/health",
|
| 33 |
+
})
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
// Rate limiting
|
| 37 |
+
app.use(
|
| 38 |
+
"/api/",
|
| 39 |
+
rateLimit({
|
| 40 |
+
windowMs: 60 * 1000,
|
| 41 |
+
max: 200,
|
| 42 |
+
standardHeaders: true,
|
| 43 |
+
legacyHeaders: false,
|
| 44 |
+
message: { success: false, error: "Too many requests" },
|
| 45 |
+
})
|
| 46 |
+
);
|
| 47 |
+
|
| 48 |
+
// ─── Routes ───────────────────────────────────────────────────────────────
|
| 49 |
+
|
| 50 |
+
// BUG FIX: The simple stub was removed. statsRouter's /health checks LiteLLM + DB health.
|
| 51 |
+
app.use("/api/models", modelsRouter);
|
| 52 |
+
app.use("/api", statsRouter);
|
| 53 |
+
|
| 54 |
+
// 404
|
| 55 |
+
app.use((req, res) => {
|
| 56 |
+
res.status(404).json({ success: false, error: "Not found" });
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
// Error handler
|
| 60 |
+
app.use((err, req, res, _next) => {
|
| 61 |
+
logger.error("Unhandled error", { error: err.message, stack: err.stack });
|
| 62 |
+
res.status(500).json({ success: false, error: "Internal server error" });
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
// ─── Startup ──────────────────────────────────────────────────────────────
|
| 66 |
+
|
| 67 |
+
async function start() {
|
| 68 |
+
// Initialize DB
|
| 69 |
+
db.getDb();
|
| 70 |
+
logger.info("Database initialized");
|
| 71 |
+
|
| 72 |
+
// BUG FIX: 5s hardcoded delay was unreliable — LiteLLM can take 30-60s to start.
|
| 73 |
+
// Use retry loop with backoff instead.
|
| 74 |
+
syncModelsToLitellmWithRetry();
|
| 75 |
+
|
| 76 |
+
app.listen(PORT, "0.0.0.0", () => {
|
| 77 |
+
logger.info(`AI Gateway Backend running on port ${PORT}`);
|
| 78 |
+
logger.info(`Gateway public URL: ${process.env.GATEWAY_PUBLIC_URL || "http://localhost"}`);
|
| 79 |
+
});
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Retry wrapper: attempts sync with exponential backoff for up to ~5 minutes.
|
| 84 |
+
* Handles the case where LiteLLM container starts slower than the backend.
|
| 85 |
+
*/
|
| 86 |
+
async function syncModelsToLitellmWithRetry() {
|
| 87 |
+
const MAX_ATTEMPTS = 10;
|
| 88 |
+
const BASE_DELAY_MS = 5000;
|
| 89 |
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
| 90 |
+
try {
|
| 91 |
+
await litellm.healthCheck();
|
| 92 |
+
await syncModelsToLitellm();
|
| 93 |
+
return;
|
| 94 |
+
} catch (err) {
|
| 95 |
+
const delay = Math.min(BASE_DELAY_MS * attempt, 30000);
|
| 96 |
+
logger.warn(`LiteLLM not ready (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${delay}ms...`);
|
| 97 |
+
await new Promise((r) => setTimeout(r, delay));
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
logger.error("LiteLLM did not become ready after all retry attempts. Models not synced.");
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/**
|
| 104 |
+
* On startup, re-register all persisted models with LiteLLM.
|
| 105 |
+
* This handles the case where LiteLLM was restarted and lost its in-memory state.
|
| 106 |
+
*/
|
| 107 |
+
async function syncModelsToLitellm() {
|
| 108 |
+
try {
|
| 109 |
+
const models = db.listModels({ enabledOnly: true });
|
| 110 |
+
logger.info(`Syncing ${models.length} models to LiteLLM...`);
|
| 111 |
+
|
| 112 |
+
for (const model of models) {
|
| 113 |
+
try {
|
| 114 |
+
// _apiKey is the raw (unmasked) API key set by deserializeModel
|
| 115 |
+
const litellmId = await litellm.registerModel({ ...model, _apiKey: model._apiKey });
|
| 116 |
+
db.updateModel(model.id, { litellmId });
|
| 117 |
+
logger.info(`Synced: ${model.name}`);
|
| 118 |
+
} catch (err) {
|
| 119 |
+
logger.warn(`Failed to sync model ${model.name}: ${err.message}`);
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
logger.info("Model sync complete");
|
| 124 |
+
} catch (err) {
|
| 125 |
+
logger.error("Model sync failed", { error: err.message });
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
start().catch((err) => {
|
| 130 |
+
logger.error("Fatal startup error", { error: err.message });
|
| 131 |
+
process.exit(1);
|
| 132 |
+
});
|
backend/src/litellm.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
“use strict”;
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
|
| 5 |
+
- LiteLLM Proxy Management Client
|
| 6 |
+
- Wraps LiteLLM’s admin API for dynamic model registration.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
const axios = require(“axios”);
|
| 10 |
+
const { logger } = require(”./logger”);
|
| 11 |
+
|
| 12 |
+
const LITELLM_BASE_URL = process.env.LITELLM_BASE_URL || “http://litellm:4000”;
|
| 13 |
+
const LITELLM_MASTER_KEY = process.env.LITELLM_MASTER_KEY || “sk-gateway-master-key”;
|
| 14 |
+
|
| 15 |
+
const client = axios.create({
|
| 16 |
+
baseURL: LITELLM_BASE_URL,
|
| 17 |
+
timeout: 15000,
|
| 18 |
+
headers: {
|
| 19 |
+
Authorization: `Bearer ${LITELLM_MASTER_KEY}`,
|
| 20 |
+
“Content-Type”: “application/json”,
|
| 21 |
+
},
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
// ─── Model Registration ────────────────────────────────────────────────────
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
|
| 28 |
+
- Register a new model in LiteLLM at runtime.
|
| 29 |
+
- @param {object} model - Our internal model record
|
| 30 |
+
- @returns {string} - LiteLLM internal model ID
|
| 31 |
+
*/
|
| 32 |
+
async function registerModel(model) {
|
| 33 |
+
const payload = {
|
| 34 |
+
model_name: model.name, // OpenAI-compatible alias exposed to callers
|
| 35 |
+
litellm_params: buildLitellmParams(model),
|
| 36 |
+
model_info: {
|
| 37 |
+
id: model.id,
|
| 38 |
+
description: model.description || “”,
|
| 39 |
+
model_type: model.modelType || “chat”,
|
| 40 |
+
},
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
logger.info(“Registering model with LiteLLM”, { modelName: model.name });
|
| 44 |
+
|
| 45 |
+
const response = await client.post(”/model/new”, payload);
|
| 46 |
+
|
| 47 |
+
// ─── BUG FIX #7: Ghost model on missing LiteLLM ID ──────────────────────
|
| 48 |
+
//
|
| 49 |
+
// ORIGINAL CODE:
|
| 50 |
+
// const litellmId = response.data?.model_info?.id || response.data?.id || model.id;
|
| 51 |
+
//
|
| 52 |
+
// The final `|| model.id` fallback silently stored our own UUID as
|
| 53 |
+
// `litellm_id` whenever LiteLLM returned a response without a recognisable
|
| 54 |
+
// ID field. This created a “ghost model” scenario:
|
| 55 |
+
//
|
| 56 |
+
// 1. DB stored our own UUID as `litellm_id`.
|
| 57 |
+
// 2. On delete, deregisterModel() sent that UUID to LiteLLM /model/delete.
|
| 58 |
+
// 3. LiteLLM couldn’t find it → returned an error we silently swallowed.
|
| 59 |
+
// 4. Model removed from our DB but remained live inside LiteLLM’s in-memory
|
| 60 |
+
// router — still accepting real API traffic indefinitely.
|
| 61 |
+
//
|
| 62 |
+
// FIX: Extract the ID from the two documented response paths. If neither
|
| 63 |
+
// yields an ID, emit a structured WARN (visible in logs/alerts) then fall
|
| 64 |
+
// back to model.id only as a last resort so the creation flow is not broken.
|
| 65 |
+
// The warning makes the deregistration risk explicit to operators.
|
| 66 |
+
//
|
| 67 |
+
// LiteLLM /model/new documented response shapes:
|
| 68 |
+
// v1.x+ → { model_info: { id: “<uuid>”, … }, model_name: “…” }
|
| 69 |
+
// older → { id: “<uuid>”, … }
|
| 70 |
+
// ─────────────────────────────────────────────────────────────────────────
|
| 71 |
+
const litellmId =
|
| 72 |
+
response.data?.model_info?.id ||
|
| 73 |
+
response.data?.id ||
|
| 74 |
+
null;
|
| 75 |
+
|
| 76 |
+
if (!litellmId) {
|
| 77 |
+
logger.warn(
|
| 78 |
+
“[BUG#7] LiteLLM /model/new response contained no recognisable model ID. “ +
|
| 79 |
+
“Falling back to internal UUID as litellm_id. “ +
|
| 80 |
+
“Subsequent deregisterModel() calls for this model will likely fail silently, “ +
|
| 81 |
+
“leaving a ghost model active inside LiteLLM. “ +
|
| 82 |
+
“Inspect responseData below and verify your LiteLLM version.”,
|
| 83 |
+
{
|
| 84 |
+
modelName: model.name,
|
| 85 |
+
internalId: model.id,
|
| 86 |
+
responseTopLevelKeys: response.data ? Object.keys(response.data) : [],
|
| 87 |
+
responseData: response.data,
|
| 88 |
+
}
|
| 89 |
+
);
|
| 90 |
+
// Retain fallback so createModel() still returns a usable record.
|
| 91 |
+
return model.id;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
logger.info(“Model registered in LiteLLM”, { modelName: model.name, litellmId });
|
| 95 |
+
return litellmId;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/**
|
| 99 |
+
|
| 100 |
+
- Remove a model from LiteLLM.
|
| 101 |
+
-
|
| 102 |
+
- @param {string} litellmId - LiteLLM’s model ID (returned from registerModel)
|
| 103 |
+
-
|
| 104 |
+
- ─── BUG FIX #6: /model/delete field-name version incompatibility ──────────
|
| 105 |
+
-
|
| 106 |
+
- ORIGINAL CODE:
|
| 107 |
+
- await client.post(”/model/delete”, { id: litellmId });
|
| 108 |
+
-
|
| 109 |
+
- The request-body field name accepted by /model/delete has changed across
|
| 110 |
+
- LiteLLM releases:
|
| 111 |
+
-
|
| 112 |
+
- • Older versions (pre-v1.x): { model_id: “<id>” }
|
| 113 |
+
- • Current versions (v1.x+): { id: “<id>” }
|
| 114 |
+
-
|
| 115 |
+
- Sending only `id` against an older deployment produces a silent no-op:
|
| 116 |
+
- LiteLLM returns HTTP 200 but ignores the request because it only reads
|
| 117 |
+
- `model_id`. The same failure mode applies in reverse on newer versions.
|
| 118 |
+
-
|
| 119 |
+
- FIX: Send BOTH fields in every request. LiteLLM’s Pydantic models use
|
| 120 |
+
- `model_config = ConfigDict(extra="ignore")`, so unknown keys are silently
|
| 121 |
+
- discarded — the payload is safe for all known versions.
|
| 122 |
+
-
|
| 123 |
+
- If deletion silently fails after a future LiteLLM upgrade, verify the
|
| 124 |
+
- current accepted field name via the running instance’s Swagger UI:
|
| 125 |
+
- http://<litellm-host>:4000/docs → POST /model/delete
|
| 126 |
+
- ─────────────────────────────────────────────────────────────────────────
|
| 127 |
+
*/
|
| 128 |
+
async function deregisterModel(litellmId) {
|
| 129 |
+
if (!litellmId) return;
|
| 130 |
+
try {
|
| 131 |
+
await client.post(”/model/delete”, {
|
| 132 |
+
id: litellmId, // accepted by LiteLLM v1.x+
|
| 133 |
+
model_id: litellmId, // accepted by LiteLLM pre-v1.x
|
| 134 |
+
});
|
| 135 |
+
logger.info(“Model deregistered from LiteLLM”, { litellmId });
|
| 136 |
+
} catch (err) {
|
| 137 |
+
// Model may not exist in LiteLLM (e.g. was never synced, or litellmId
|
| 138 |
+
// is our own UUID fallback from Bug Fix #7). Log at warn so the operator
|
| 139 |
+
// is aware but the delete flow is not blocked.
|
| 140 |
+
logger.warn(“Could not deregister model from LiteLLM”, {
|
| 141 |
+
litellmId,
|
| 142 |
+
httpStatus: err.response?.status,
|
| 143 |
+
error: err.message,
|
| 144 |
+
});
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
/**
|
| 149 |
+
|
| 150 |
+
- List all models currently registered in LiteLLM.
|
| 151 |
+
*/
|
| 152 |
+
async function listLitellmModels() {
|
| 153 |
+
const response = await client.get(”/model/info”);
|
| 154 |
+
return response.data?.data || response.data || [];
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/**
|
| 158 |
+
|
| 159 |
+
- Update a model in LiteLLM (delete + re-add since update isn’t atomic).
|
| 160 |
+
*/
|
| 161 |
+
async function updateModel(oldLitellmId, model) {
|
| 162 |
+
await deregisterModel(oldLitellmId);
|
| 163 |
+
return registerModel(model);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/**
|
| 167 |
+
|
| 168 |
+
- Check LiteLLM liveness.
|
| 169 |
+
-
|
| 170 |
+
- Uses /health/liveliness instead of /health:
|
| 171 |
+
- - /health validates all registered model upstreams. If any upstream is
|
| 172 |
+
- unreachable it returns an error even though LiteLLM itself is healthy,
|
| 173 |
+
- causing syncModelsToLitellmWithRetry() to retry endlessly and give up.
|
| 174 |
+
- - /health/liveliness only checks that the LiteLLM process is alive, which
|
| 175 |
+
- is the correct signal for “ready to accept /model/new requests”.
|
| 176 |
+
- This also matches the docker-compose.yml container healthcheck target.
|
| 177 |
+
*/
|
| 178 |
+
async function healthCheck() {
|
| 179 |
+
const response = await client.get(”/health/liveliness”);
|
| 180 |
+
return response.data;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/**
|
| 184 |
+
|
| 185 |
+
- Test a model by sending a minimal chat completion request.
|
| 186 |
+
*/
|
| 187 |
+
async function testModel(modelName, options = {}) {
|
| 188 |
+
const start = Date.now();
|
| 189 |
+
const messages =
|
| 190 |
+
options.messages && options.messages.length > 0
|
| 191 |
+
? options.messages
|
| 192 |
+
: [{ role: “user”, content: options.prompt || “Say ‘OK’ in one word.” }];
|
| 193 |
+
try {
|
| 194 |
+
const response = await client.post(
|
| 195 |
+
“/v1/chat/completions”,
|
| 196 |
+
{
|
| 197 |
+
model: modelName,
|
| 198 |
+
messages,
|
| 199 |
+
max_tokens: 256,
|
| 200 |
+
stream: false,
|
| 201 |
+
},
|
| 202 |
+
{ timeout: 30000 }
|
| 203 |
+
);
|
| 204 |
+
return {
|
| 205 |
+
success: true,
|
| 206 |
+
latencyMs: Date.now() - start,
|
| 207 |
+
response: response.data,
|
| 208 |
+
};
|
| 209 |
+
} catch (err) {
|
| 210 |
+
return {
|
| 211 |
+
success: false,
|
| 212 |
+
latencyMs: Date.now() - start,
|
| 213 |
+
error: err.response?.data || err.message,
|
| 214 |
+
};
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
| 219 |
+
|
| 220 |
+
function buildLitellmParams(model) {
|
| 221 |
+
const params = {
|
| 222 |
+
model: model.litellmModel,
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
// Note: original code also checked `model._apiBase` which is never set
|
| 226 |
+
// anywhere in the codebase — that dead reference has been removed.
|
| 227 |
+
if (model.apiBase) {
|
| 228 |
+
params.api_base = model.apiBase;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// Only include api_key if provided and non-empty
|
| 232 |
+
const key = model._apiKey || model.apiKey;
|
| 233 |
+
if (key && key !== “••••••••” && key.trim() !== “”) {
|
| 234 |
+
params.api_key = key.trim();
|
| 235 |
+
} else {
|
| 236 |
+
// LiteLLM requires some api_key for most providers; use placeholder
|
| 237 |
+
params.api_key = “none”;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
return params;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
module.exports = {
|
| 244 |
+
registerModel,
|
| 245 |
+
deregisterModel,
|
| 246 |
+
listLitellmModels,
|
| 247 |
+
updateModel,
|
| 248 |
+
healthCheck,
|
| 249 |
+
testModel,
|
| 250 |
+
};
|
backend/src/logger.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
|
| 3 |
+
const winston = require("winston");
|
| 4 |
+
|
| 5 |
+
const logger = winston.createLogger({
|
| 6 |
+
// BUG FIX: "info" level (2) drops Morgan HTTP logs (level 3). Default to "http".
|
| 7 |
+
level: process.env.LOG_LEVEL || "http",
|
| 8 |
+
format: winston.format.combine(
|
| 9 |
+
winston.format.timestamp(),
|
| 10 |
+
winston.format.errors({ stack: true }),
|
| 11 |
+
winston.format.json()
|
| 12 |
+
),
|
| 13 |
+
transports: [
|
| 14 |
+
new winston.transports.Console({
|
| 15 |
+
format: winston.format.combine(
|
| 16 |
+
winston.format.colorize(),
|
| 17 |
+
winston.format.printf(({ level, message, timestamp, ...meta }) => {
|
| 18 |
+
const extras = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : "";
|
| 19 |
+
return `${timestamp} [${level}] ${message}${extras}`;
|
| 20 |
+
})
|
| 21 |
+
),
|
| 22 |
+
}),
|
| 23 |
+
],
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
module.exports = { logger };
|
backend/src/routes/models.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
|
| 3 |
+
const express = require("express");
|
| 4 |
+
const { v4: uuidv4 } = require("uuid");
|
| 5 |
+
const router = express.Router();
|
| 6 |
+
|
| 7 |
+
const db = require("../database");
|
| 8 |
+
const litellm = require("../litellm");
|
| 9 |
+
const { logger } = require("../logger");
|
| 10 |
+
|
| 11 |
+
const GATEWAY_PUBLIC_URL =
|
| 12 |
+
(process.env.GATEWAY_PUBLIC_URL || "http://localhost").replace(/\/$/, "");
|
| 13 |
+
|
| 14 |
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Build the OpenAI-compatible model alias (the `model` field callers use).
|
| 18 |
+
*
|
| 19 |
+
* BUG NOTE #9: Aliases may contain forward slashes (e.g.
|
| 20 |
+
* "anthropic/claude-3-5-sonnet-20241022"). This is intentional and safe for
|
| 21 |
+
* our management API because all model lookups use the UUID `id`, not the
|
| 22 |
+
* `name` slug, as the :id URL parameter. Avoid adding routes that look up
|
| 23 |
+
* models by name via URL params — the slash would be mis-parsed by Express.
|
| 24 |
+
*/
|
| 25 |
+
function buildModelAlias(provider, litellmModel) {
|
| 26 |
+
const base = litellmModel.startsWith(`${provider}/`) ? litellmModel : `${provider}/${litellmModel}`;
|
| 27 |
+
const slug = base
|
| 28 |
+
.toLowerCase()
|
| 29 |
+
.replace(/[^a-z0-9\-_./]/g, "-")
|
| 30 |
+
.replace(/-+/g, "-")
|
| 31 |
+
.replace(/^-|-$/g, "");
|
| 32 |
+
return slug;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function modelResponse(model) {
|
| 36 |
+
return {
|
| 37 |
+
...model,
|
| 38 |
+
openaiEndpoint: `${GATEWAY_PUBLIC_URL}/v1`,
|
| 39 |
+
openaiModelName: model.name,
|
| 40 |
+
curlExample: buildCurlExample(model.name),
|
| 41 |
+
pythonExample: buildPythonExample(model.name),
|
| 42 |
+
};
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function buildCurlExample(modelName) {
|
| 46 |
+
return `curl ${GATEWAY_PUBLIC_URL}/v1/chat/completions \\
|
| 47 |
+
-H "Content-Type: application/json" \\
|
| 48 |
+
-H "Authorization: Bearer any-key" \\
|
| 49 |
+
-d '{
|
| 50 |
+
"model": "${modelName}",
|
| 51 |
+
"messages": [{"role": "user", "content": "Hello!"}]
|
| 52 |
+
}'`;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function buildPythonExample(modelName) {
|
| 56 |
+
return `from openai import OpenAI
|
| 57 |
+
|
| 58 |
+
client = OpenAI(
|
| 59 |
+
base_url="${GATEWAY_PUBLIC_URL}/v1",
|
| 60 |
+
api_key="any-key", # No real key needed
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
response = client.chat.completions.create(
|
| 64 |
+
model="${modelName}",
|
| 65 |
+
messages=[{"role": "user", "content": "Hello!"}],
|
| 66 |
+
)
|
| 67 |
+
print(response.choices[0].message.content)`;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// ─── Routes ───────────────────────────────────────────────────────────────
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* GET /api/models
|
| 74 |
+
*/
|
| 75 |
+
router.get("/", (req, res) => {
|
| 76 |
+
try {
|
| 77 |
+
const models = db.listModels();
|
| 78 |
+
res.json({ success: true, data: models.map(modelResponse), count: models.length });
|
| 79 |
+
} catch (err) {
|
| 80 |
+
logger.error("Failed to list models", { error: err.message });
|
| 81 |
+
res.status(500).json({ success: false, error: "Failed to fetch models" });
|
| 82 |
+
}
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* GET /api/models/:id
|
| 87 |
+
*/
|
| 88 |
+
router.get("/:id", (req, res) => {
|
| 89 |
+
try {
|
| 90 |
+
const model = db.getModel(req.params.id);
|
| 91 |
+
if (!model) return res.status(404).json({ success: false, error: "Model not found" });
|
| 92 |
+
res.json({ success: true, data: modelResponse(model) });
|
| 93 |
+
} catch (err) {
|
| 94 |
+
res.status(500).json({ success: false, error: err.message });
|
| 95 |
+
}
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
/**
|
| 99 |
+
* POST /api/models
|
| 100 |
+
* Register a new model.
|
| 101 |
+
*/
|
| 102 |
+
router.post("/", async (req, res) => {
|
| 103 |
+
const {
|
| 104 |
+
displayName,
|
| 105 |
+
provider,
|
| 106 |
+
litellmModel,
|
| 107 |
+
apiBase,
|
| 108 |
+
apiKey,
|
| 109 |
+
description,
|
| 110 |
+
tags = [],
|
| 111 |
+
modelType = "chat",
|
| 112 |
+
} = req.body;
|
| 113 |
+
|
| 114 |
+
if (!displayName || !provider || !litellmModel) {
|
| 115 |
+
return res.status(400).json({
|
| 116 |
+
success: false,
|
| 117 |
+
error: "displayName, provider, and litellmModel are required",
|
| 118 |
+
});
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
const id = uuidv4();
|
| 122 |
+
const name = buildModelAlias(provider, litellmModel);
|
| 123 |
+
|
| 124 |
+
if (db.getModelByName(name)) {
|
| 125 |
+
return res.status(409).json({
|
| 126 |
+
success: false,
|
| 127 |
+
error: `A model with alias "${name}" already exists. Use a different provider label or model.`,
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const modelRecord = {
|
| 132 |
+
id,
|
| 133 |
+
name,
|
| 134 |
+
displayName,
|
| 135 |
+
provider,
|
| 136 |
+
litellmModel,
|
| 137 |
+
apiBase: apiBase || null,
|
| 138 |
+
apiKey: apiKey || null,
|
| 139 |
+
description: description || null,
|
| 140 |
+
tags,
|
| 141 |
+
modelType,
|
| 142 |
+
enabled: true,
|
| 143 |
+
litellmId: null,
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
try {
|
| 147 |
+
db.createModel(modelRecord);
|
| 148 |
+
|
| 149 |
+
try {
|
| 150 |
+
const litellmId = await litellm.registerModel({ ...modelRecord, _apiKey: modelRecord.apiKey });
|
| 151 |
+
db.updateModel(id, { litellmId });
|
| 152 |
+
modelRecord.litellmId = litellmId;
|
| 153 |
+
} catch (litellmErr) {
|
| 154 |
+
logger.warn("Could not register model with LiteLLM (will retry on next request)", {
|
| 155 |
+
modelName: name,
|
| 156 |
+
error: litellmErr.message,
|
| 157 |
+
});
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
const created = db.getModel(id);
|
| 161 |
+
logger.info("Model created", { id, name });
|
| 162 |
+
res.status(201).json({ success: true, data: modelResponse(created) });
|
| 163 |
+
} catch (err) {
|
| 164 |
+
logger.error("Failed to create model", { error: err.message });
|
| 165 |
+
res.status(500).json({ success: false, error: "Failed to create model" });
|
| 166 |
+
}
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
/**
|
| 170 |
+
* PATCH /api/models/:id
|
| 171 |
+
* Update model metadata.
|
| 172 |
+
*/
|
| 173 |
+
router.patch("/:id", async (req, res) => {
|
| 174 |
+
try {
|
| 175 |
+
const existing = db.getModel(req.params.id);
|
| 176 |
+
if (!existing) return res.status(404).json({ success: false, error: "Model not found" });
|
| 177 |
+
|
| 178 |
+
const allowedFields = ["displayName", "description", "tags", "modelType", "enabled", "apiKey", "apiBase"];
|
| 179 |
+
const updates = {};
|
| 180 |
+
for (const field of allowedFields) {
|
| 181 |
+
if (req.body[field] !== undefined) {
|
| 182 |
+
updates[field] = req.body[field];
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
db.updateModel(req.params.id, updates);
|
| 187 |
+
|
| 188 |
+
// Re-sync with LiteLLM if key/endpoint changed
|
| 189 |
+
if (updates.apiKey !== undefined || updates.apiBase !== undefined) {
|
| 190 |
+
try {
|
| 191 |
+
const updated = db.getModel(req.params.id);
|
| 192 |
+
|
| 193 |
+
// BUG FIX #8: Was `updates.apiKey || existing._apiKey` which treated
|
| 194 |
+
// apiKey="" (intentional clear) as falsy and fell back to the old key,
|
| 195 |
+
// so LiteLLM would still use the stale key even though DB was cleared.
|
| 196 |
+
// Now: if apiKey was explicitly sent (even as ""), honour it; only fall
|
| 197 |
+
// back to the existing key when apiKey was NOT part of this PATCH.
|
| 198 |
+
const rawKey = updates.apiKey !== undefined
|
| 199 |
+
? (updates.apiKey || null) // "" → null (clear the key in LiteLLM)
|
| 200 |
+
: existing._apiKey;
|
| 201 |
+
|
| 202 |
+
const newLitellmId = await litellm.updateModel(existing.litellmId, {
|
| 203 |
+
...updated,
|
| 204 |
+
_apiKey: rawKey,
|
| 205 |
+
});
|
| 206 |
+
db.updateModel(req.params.id, { litellmId: newLitellmId });
|
| 207 |
+
} catch (litellmErr) {
|
| 208 |
+
logger.warn("LiteLLM re-sync failed", { error: litellmErr.message });
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
const updated = db.getModel(req.params.id);
|
| 213 |
+
res.json({ success: true, data: modelResponse(updated) });
|
| 214 |
+
} catch (err) {
|
| 215 |
+
res.status(500).json({ success: false, error: err.message });
|
| 216 |
+
}
|
| 217 |
+
});
|
| 218 |
+
|
| 219 |
+
/**
|
| 220 |
+
* DELETE /api/models/:id
|
| 221 |
+
*/
|
| 222 |
+
router.delete("/:id", async (req, res) => {
|
| 223 |
+
try {
|
| 224 |
+
const model = db.getModel(req.params.id);
|
| 225 |
+
if (!model) return res.status(404).json({ success: false, error: "Model not found" });
|
| 226 |
+
|
| 227 |
+
if (model.litellmId) {
|
| 228 |
+
await litellm.deregisterModel(model.litellmId);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
db.deleteModel(req.params.id);
|
| 232 |
+
logger.info("Model deleted", { id: req.params.id, name: model.name });
|
| 233 |
+
res.json({ success: true, message: "Model deleted successfully" });
|
| 234 |
+
} catch (err) {
|
| 235 |
+
res.status(500).json({ success: false, error: err.message });
|
| 236 |
+
}
|
| 237 |
+
});
|
| 238 |
+
|
| 239 |
+
/**
|
| 240 |
+
* POST /api/models/:id/test
|
| 241 |
+
*/
|
| 242 |
+
router.post("/:id/test", async (req, res) => {
|
| 243 |
+
try {
|
| 244 |
+
const model = db.getModel(req.params.id);
|
| 245 |
+
if (!model) return res.status(404).json({ success: false, error: "Model not found" });
|
| 246 |
+
|
| 247 |
+
const result = await litellm.testModel(model.name, {
|
| 248 |
+
prompt: req.body.prompt,
|
| 249 |
+
messages: req.body.messages,
|
| 250 |
+
});
|
| 251 |
+
|
| 252 |
+
db.logUsage({
|
| 253 |
+
modelId: model.id,
|
| 254 |
+
modelName: model.name,
|
| 255 |
+
requestType: "test",
|
| 256 |
+
status: result.success ? 200 : 500,
|
| 257 |
+
latencyMs: result.latencyMs,
|
| 258 |
+
tokensIn: result.response?.usage?.prompt_tokens || 0,
|
| 259 |
+
tokensOut: result.response?.usage?.completion_tokens || 0,
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
res.json({ success: true, data: result });
|
| 263 |
+
} catch (err) {
|
| 264 |
+
res.status(500).json({ success: false, error: err.message });
|
| 265 |
+
}
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
/**
|
| 269 |
+
* POST /api/models/:id/toggle
|
| 270 |
+
*/
|
| 271 |
+
router.post("/:id/toggle", (req, res) => {
|
| 272 |
+
try {
|
| 273 |
+
const model = db.getModel(req.params.id);
|
| 274 |
+
if (!model) return res.status(404).json({ success: false, error: "Model not found" });
|
| 275 |
+
db.updateModel(req.params.id, { enabled: !model.enabled });
|
| 276 |
+
const updated = db.getModel(req.params.id);
|
| 277 |
+
res.json({ success: true, data: modelResponse(updated) });
|
| 278 |
+
} catch (err) {
|
| 279 |
+
res.status(500).json({ success: false, error: err.message });
|
| 280 |
+
}
|
| 281 |
+
});
|
| 282 |
+
|
| 283 |
+
module.exports = router;
|
backend/src/routes/stats.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
|
| 3 |
+
const express = require("express");
|
| 4 |
+
const router = express.Router();
|
| 5 |
+
const db = require("../database");
|
| 6 |
+
const litellm = require("../litellm");
|
| 7 |
+
const { logger } = require("../logger");
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* GET /api/stats
|
| 11 |
+
* Dashboard statistics.
|
| 12 |
+
*
|
| 13 |
+
* BUG FIX #1: Was router.get("/") which only matched GET /api/ (the bare mount path).
|
| 14 |
+
* Frontend calls GET /api/stats — this route was never reached, always 404.
|
| 15 |
+
*/
|
| 16 |
+
router.get("/stats", (req, res) => {
|
| 17 |
+
try {
|
| 18 |
+
const stats = db.getStats();
|
| 19 |
+
res.json({ success: true, data: stats });
|
| 20 |
+
} catch (err) {
|
| 21 |
+
res.status(500).json({ success: false, error: err.message });
|
| 22 |
+
}
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* GET /api/health
|
| 27 |
+
* Service health check.
|
| 28 |
+
*/
|
| 29 |
+
router.get("/health", async (req, res) => {
|
| 30 |
+
const status = { api: "ok", litellm: "unknown", db: "unknown" };
|
| 31 |
+
|
| 32 |
+
// DB check
|
| 33 |
+
try {
|
| 34 |
+
db.getStats();
|
| 35 |
+
status.db = "ok";
|
| 36 |
+
} catch {
|
| 37 |
+
status.db = "error";
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// LiteLLM check
|
| 41 |
+
try {
|
| 42 |
+
await litellm.healthCheck();
|
| 43 |
+
status.litellm = "ok";
|
| 44 |
+
} catch {
|
| 45 |
+
status.litellm = "degraded";
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const allOk = Object.values(status).every((v) => v === "ok");
|
| 49 |
+
res.status(allOk ? 200 : 207).json({ success: true, data: status });
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* GET /api/providers
|
| 54 |
+
* Returns a curated list of known LiteLLM provider prefixes and example models
|
| 55 |
+
* to help users fill in the model registration form.
|
| 56 |
+
*/
|
| 57 |
+
router.get("/providers", (req, res) => {
|
| 58 |
+
res.json({
|
| 59 |
+
success: true,
|
| 60 |
+
data: KNOWN_PROVIDERS,
|
| 61 |
+
});
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
const KNOWN_PROVIDERS = [
|
| 65 |
+
{
|
| 66 |
+
id: "openai",
|
| 67 |
+
name: "OpenAI",
|
| 68 |
+
prefix: "openai/",
|
| 69 |
+
requiresApiKey: true,
|
| 70 |
+
defaultApiBase: "https://api.openai.com/v1",
|
| 71 |
+
exampleModels: ["openai/gpt-4o", "openai/gpt-4-turbo", "openai/gpt-3.5-turbo"],
|
| 72 |
+
modelTypes: ["chat", "embedding", "image", "audio"],
|
| 73 |
+
docs: "https://docs.litellm.ai/docs/providers/openai",
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
id: "anthropic",
|
| 77 |
+
name: "Anthropic",
|
| 78 |
+
prefix: "anthropic/",
|
| 79 |
+
requiresApiKey: true,
|
| 80 |
+
defaultApiBase: "https://api.anthropic.com",
|
| 81 |
+
exampleModels: [
|
| 82 |
+
"anthropic/claude-3-5-sonnet-20241022",
|
| 83 |
+
"anthropic/claude-3-opus-20240229",
|
| 84 |
+
"anthropic/claude-3-haiku-20240307",
|
| 85 |
+
],
|
| 86 |
+
modelTypes: ["chat"],
|
| 87 |
+
docs: "https://docs.litellm.ai/docs/providers/anthropic",
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
id: "google",
|
| 91 |
+
name: "Google Gemini",
|
| 92 |
+
prefix: "gemini/",
|
| 93 |
+
requiresApiKey: true,
|
| 94 |
+
defaultApiBase: null,
|
| 95 |
+
exampleModels: ["gemini/gemini-1.5-pro", "gemini/gemini-1.5-flash", "gemini/gemini-pro"],
|
| 96 |
+
modelTypes: ["chat", "embedding"],
|
| 97 |
+
docs: "https://docs.litellm.ai/docs/providers/gemini",
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
id: "ollama",
|
| 101 |
+
name: "Ollama (Local)",
|
| 102 |
+
prefix: "ollama/",
|
| 103 |
+
requiresApiKey: false,
|
| 104 |
+
defaultApiBase: "http://host.docker.internal:11434",
|
| 105 |
+
exampleModels: ["ollama/llama3", "ollama/mistral", "ollama/codellama", "ollama/phi3"],
|
| 106 |
+
modelTypes: ["chat", "completion", "embedding"],
|
| 107 |
+
docs: "https://docs.litellm.ai/docs/providers/ollama",
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
id: "cohere",
|
| 111 |
+
name: "Cohere",
|
| 112 |
+
prefix: "cohere/",
|
| 113 |
+
requiresApiKey: true,
|
| 114 |
+
defaultApiBase: null,
|
| 115 |
+
exampleModels: ["cohere/command-r-plus", "cohere/command-r", "cohere/command"],
|
| 116 |
+
modelTypes: ["chat", "embedding"],
|
| 117 |
+
docs: "https://docs.litellm.ai/docs/providers/cohere_chat",
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
id: "mistral",
|
| 121 |
+
name: "Mistral AI",
|
| 122 |
+
prefix: "mistral/",
|
| 123 |
+
requiresApiKey: true,
|
| 124 |
+
defaultApiBase: null,
|
| 125 |
+
exampleModels: ["mistral/mistral-large-latest", "mistral/mistral-medium", "mistral/mistral-small"],
|
| 126 |
+
modelTypes: ["chat"],
|
| 127 |
+
docs: "https://docs.litellm.ai/docs/providers/mistral",
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
id: "groq",
|
| 131 |
+
name: "Groq",
|
| 132 |
+
prefix: "groq/",
|
| 133 |
+
requiresApiKey: true,
|
| 134 |
+
defaultApiBase: "https://api.groq.com/openai/v1",
|
| 135 |
+
exampleModels: ["groq/llama3-70b-8192", "groq/mixtral-8x7b-32768", "groq/gemma-7b-it"],
|
| 136 |
+
modelTypes: ["chat"],
|
| 137 |
+
docs: "https://docs.litellm.ai/docs/providers/groq",
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
id: "azure",
|
| 141 |
+
name: "Azure OpenAI",
|
| 142 |
+
prefix: "azure/",
|
| 143 |
+
requiresApiKey: true,
|
| 144 |
+
defaultApiBase: "https://<your-resource>.openai.azure.com",
|
| 145 |
+
exampleModels: ["azure/gpt-4o", "azure/gpt-35-turbo"],
|
| 146 |
+
modelTypes: ["chat", "embedding", "image"],
|
| 147 |
+
docs: "https://docs.litellm.ai/docs/providers/azure",
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
id: "vertex_ai",
|
| 151 |
+
name: "Google Vertex AI",
|
| 152 |
+
prefix: "vertex_ai/",
|
| 153 |
+
requiresApiKey: false,
|
| 154 |
+
defaultApiBase: null,
|
| 155 |
+
exampleModels: ["vertex_ai/gemini-1.5-pro", "vertex_ai/claude-3-5-sonnet@20241022"],
|
| 156 |
+
modelTypes: ["chat", "embedding"],
|
| 157 |
+
docs: "https://docs.litellm.ai/docs/providers/vertex",
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
id: "bedrock",
|
| 161 |
+
name: "AWS Bedrock",
|
| 162 |
+
prefix: "bedrock/",
|
| 163 |
+
requiresApiKey: false,
|
| 164 |
+
defaultApiBase: null,
|
| 165 |
+
exampleModels: [
|
| 166 |
+
"bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0",
|
| 167 |
+
"bedrock/amazon.titan-text-express-v1",
|
| 168 |
+
],
|
| 169 |
+
modelTypes: ["chat", "embedding"],
|
| 170 |
+
docs: "https://docs.litellm.ai/docs/providers/bedrock",
|
| 171 |
+
},
|
| 172 |
+
{
|
| 173 |
+
id: "huggingface",
|
| 174 |
+
name: "HuggingFace",
|
| 175 |
+
prefix: "huggingface/",
|
| 176 |
+
requiresApiKey: true,
|
| 177 |
+
defaultApiBase: "https://api-inference.huggingface.co/models/<model>",
|
| 178 |
+
exampleModels: ["huggingface/HuggingFaceH4/zephyr-7b-beta"],
|
| 179 |
+
modelTypes: ["chat", "completion", "embedding"],
|
| 180 |
+
docs: "https://docs.litellm.ai/docs/providers/huggingface",
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
id: "openai_compatible",
|
| 184 |
+
name: "OpenAI-Compatible (Custom)",
|
| 185 |
+
prefix: "openai/",
|
| 186 |
+
requiresApiKey: false,
|
| 187 |
+
defaultApiBase: "https://your-custom-endpoint.example.com/v1",
|
| 188 |
+
exampleModels: ["openai/your-model-name"],
|
| 189 |
+
modelTypes: ["chat", "completion", "embedding"],
|
| 190 |
+
docs: "https://docs.litellm.ai/docs/providers/openai_compatible",
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
id: "together_ai",
|
| 194 |
+
name: "Together AI",
|
| 195 |
+
prefix: "together_ai/",
|
| 196 |
+
requiresApiKey: true,
|
| 197 |
+
defaultApiBase: null,
|
| 198 |
+
exampleModels: [
|
| 199 |
+
"together_ai/togethercomputer/llama-3-70b",
|
| 200 |
+
"together_ai/mistralai/Mixtral-8x7B-Instruct-v0.1",
|
| 201 |
+
],
|
| 202 |
+
modelTypes: ["chat", "embedding"],
|
| 203 |
+
docs: "https://docs.litellm.ai/docs/providers/togetherai",
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
id: "replicate",
|
| 207 |
+
name: "Replicate",
|
| 208 |
+
prefix: "replicate/",
|
| 209 |
+
requiresApiKey: true,
|
| 210 |
+
defaultApiBase: null,
|
| 211 |
+
exampleModels: ["replicate/meta/llama-3-70b-instruct"],
|
| 212 |
+
modelTypes: ["chat", "image"],
|
| 213 |
+
docs: "https://docs.litellm.ai/docs/providers/replicate",
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
id: "deepseek",
|
| 217 |
+
name: "DeepSeek",
|
| 218 |
+
prefix: "deepseek/",
|
| 219 |
+
requiresApiKey: true,
|
| 220 |
+
defaultApiBase: "https://api.deepseek.com/v1",
|
| 221 |
+
exampleModels: ["deepseek/deepseek-chat", "deepseek/deepseek-coder"],
|
| 222 |
+
modelTypes: ["chat"],
|
| 223 |
+
docs: "https://docs.litellm.ai/docs/providers/deepseek",
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
id: "perplexity",
|
| 227 |
+
name: "Perplexity AI",
|
| 228 |
+
prefix: "perplexity/",
|
| 229 |
+
requiresApiKey: true,
|
| 230 |
+
defaultApiBase: "https://api.perplexity.ai",
|
| 231 |
+
exampleModels: ["perplexity/llama-3.1-sonar-large-128k-online"],
|
| 232 |
+
modelTypes: ["chat"],
|
| 233 |
+
docs: "https://docs.litellm.ai/docs/providers/perplexity",
|
| 234 |
+
},
|
| 235 |
+
];
|
| 236 |
+
|
| 237 |
+
module.exports = router;
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: "3.9"
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
# ─── LiteLLM Proxy Gateway ───────────────────────────────────────────────
|
| 5 |
+
litellm:
|
| 6 |
+
# BUG FIX #12: Was "main-latest" which points to the HEAD of the main branch
|
| 7 |
+
# and pulls in breaking changes on every `docker compose pull`. LiteLLM ships
|
| 8 |
+
# multiple commits per day; /model/delete field names, config.yaml keys, and
|
| 9 |
+
# routing behaviour have all changed between minor versions without notice.
|
| 10 |
+
#
|
| 11 |
+
# "main-stable" is the LiteLLM-maintained tag that always points to the
|
| 12 |
+
# latest release that has passed their 12-hour load-test suite. It is still
|
| 13 |
+
# a moving target, so for production deployments pin to a specific version:
|
| 14 |
+
# ghcr.io/berriai/litellm:main-v1.81.14-stable
|
| 15 |
+
#
|
| 16 |
+
# To upgrade: review the release notes at https://docs.litellm.ai/release_notes
|
| 17 |
+
# then bump the version below and redeploy.
|
| 18 |
+
image: ghcr.io/berriai/litellm:main-v1.81.14-stable
|
| 19 |
+
container_name: ai_gateway_litellm
|
| 20 |
+
restart: unless-stopped
|
| 21 |
+
volumes:
|
| 22 |
+
- ./litellm/config.yaml:/app/config.yaml:ro
|
| 23 |
+
- litellm_data:/app/data
|
| 24 |
+
environment:
|
| 25 |
+
- LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:-sk-gateway-master-key}
|
| 26 |
+
# NOTE: STORE_MODEL_IN_DB requires PostgreSQL (not SQLite) in LiteLLM >= 1.x.
|
| 27 |
+
# We omit it here — our backend manages model persistence via SQLite and
|
| 28 |
+
# re-registers models with LiteLLM on startup via the /model/new API.
|
| 29 |
+
- PORT=4000
|
| 30 |
+
# NOTE: --detailed_debug flag is intentionally omitted.
|
| 31 |
+
# It dumps every internal processing step (including per-token details) to
|
| 32 |
+
# stdout, causing massive log volume and container slowdown in production.
|
| 33 |
+
# Use --debug only during active troubleshooting; omit it normally.
|
| 34 |
+
command: >
|
| 35 |
+
--config /app/config.yaml
|
| 36 |
+
--port 4000
|
| 37 |
+
--num_workers 4
|
| 38 |
+
healthcheck:
|
| 39 |
+
# LiteLLM image has no curl (GitHub issue #9295). Use wget + /health/liveliness.
|
| 40 |
+
test: ["CMD-SHELL", "wget --quiet --tries=1 -O /dev/null http://localhost:4000/health/liveliness || exit 1"]
|
| 41 |
+
interval: 30s
|
| 42 |
+
timeout: 10s
|
| 43 |
+
retries: 5
|
| 44 |
+
start_period: 60s
|
| 45 |
+
networks:
|
| 46 |
+
- gateway_net
|
| 47 |
+
|
| 48 |
+
# ─── Backend API ─────────────────────────────────────────────────────────
|
| 49 |
+
backend:
|
| 50 |
+
build:
|
| 51 |
+
context: ./backend
|
| 52 |
+
dockerfile: Dockerfile
|
| 53 |
+
container_name: ai_gateway_backend
|
| 54 |
+
restart: unless-stopped
|
| 55 |
+
environment:
|
| 56 |
+
- NODE_ENV=production
|
| 57 |
+
- PORT=3001
|
| 58 |
+
- LITELLM_BASE_URL=http://litellm:4000
|
| 59 |
+
- LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:-sk-gateway-master-key}
|
| 60 |
+
- DB_PATH=/app/data/gateway.db
|
| 61 |
+
- JWT_SECRET=${JWT_SECRET:-super-secret-jwt-key-change-in-production}
|
| 62 |
+
- GATEWAY_PUBLIC_URL=${GATEWAY_PUBLIC_URL:-http://localhost}
|
| 63 |
+
volumes:
|
| 64 |
+
- backend_data:/app/data
|
| 65 |
+
depends_on:
|
| 66 |
+
litellm:
|
| 67 |
+
condition: service_healthy
|
| 68 |
+
networks:
|
| 69 |
+
- gateway_net
|
| 70 |
+
healthcheck:
|
| 71 |
+
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
|
| 72 |
+
interval: 20s
|
| 73 |
+
timeout: 5s
|
| 74 |
+
retries: 3
|
| 75 |
+
|
| 76 |
+
# ─── Frontend ─────────────────────────────────────────────────────────────
|
| 77 |
+
frontend:
|
| 78 |
+
build:
|
| 79 |
+
context: ./frontend
|
| 80 |
+
dockerfile: Dockerfile
|
| 81 |
+
args:
|
| 82 |
+
- VITE_API_BASE=/api
|
| 83 |
+
- VITE_APP_NAME=AI Gateway Hub
|
| 84 |
+
container_name: ai_gateway_frontend
|
| 85 |
+
restart: unless-stopped
|
| 86 |
+
networks:
|
| 87 |
+
- gateway_net
|
| 88 |
+
depends_on:
|
| 89 |
+
- backend
|
| 90 |
+
|
| 91 |
+
# ─── Nginx Reverse Proxy ──────────────────────────────────────────────────
|
| 92 |
+
nginx:
|
| 93 |
+
image: nginx:1.25-alpine
|
| 94 |
+
container_name: ai_gateway_nginx
|
| 95 |
+
restart: unless-stopped
|
| 96 |
+
ports:
|
| 97 |
+
- "${HTTP_PORT:-80}:80"
|
| 98 |
+
# To enable HTTPS: add certs to nginx/ssl/ and uncomment below + add SSL server block to nginx.conf
|
| 99 |
+
# - "${HTTPS_PORT:-443}:443"
|
| 100 |
+
volumes:
|
| 101 |
+
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
| 102 |
+
- nginx_logs:/var/log/nginx
|
| 103 |
+
depends_on:
|
| 104 |
+
- frontend
|
| 105 |
+
- backend
|
| 106 |
+
- litellm
|
| 107 |
+
networks:
|
| 108 |
+
- gateway_net
|
| 109 |
+
|
| 110 |
+
volumes:
|
| 111 |
+
litellm_data:
|
| 112 |
+
backend_data:
|
| 113 |
+
nginx_logs:
|
| 114 |
+
|
| 115 |
+
networks:
|
| 116 |
+
gateway_net:
|
| 117 |
+
driver: bridge
|
frontend/Dockerfile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-alpine AS builder
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
|
| 4 |
+
ARG VITE_API_BASE=/api
|
| 5 |
+
ARG VITE_APP_NAME="AI Gateway Hub"
|
| 6 |
+
|
| 7 |
+
ENV VITE_API_BASE=$VITE_API_BASE
|
| 8 |
+
ENV VITE_APP_NAME=$VITE_APP_NAME
|
| 9 |
+
|
| 10 |
+
COPY package*.json ./
|
| 11 |
+
RUN npm ci
|
| 12 |
+
|
| 13 |
+
COPY . .
|
| 14 |
+
RUN npm run build
|
| 15 |
+
|
| 16 |
+
# ── Production stage ──
|
| 17 |
+
FROM nginx:1.25-alpine AS production
|
| 18 |
+
COPY --from=builder /app/dist /usr/share/nginx/html
|
| 19 |
+
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
| 20 |
+
|
| 21 |
+
EXPOSE 80
|
| 22 |
+
CMD ["nginx", "-g", "daemon off;"]
|
frontend/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>AI Gateway Hub</title>
|
| 7 |
+
<meta name="description" content="Universal AI API Gateway — Convert any AI provider to OpenAI-compatible endpoints via LiteLLM" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
| 11 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<div id="root"></div>
|
| 15 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 16 |
+
</body>
|
| 17 |
+
</html>
|
frontend/nginx.conf
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
user nginx;
|
| 2 |
+
worker_processes auto;
|
| 3 |
+
error_log /var/log/nginx/error.log warn;
|
| 4 |
+
pid /var/run/nginx.pid;
|
| 5 |
+
|
| 6 |
+
events {
|
| 7 |
+
worker_connections 4096;
|
| 8 |
+
# NOTE: removed "use epoll" — Linux-only, breaks on macOS Docker Desktop.
|
| 9 |
+
multi_accept on;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
http {
|
| 13 |
+
include /etc/nginx/mime.types;
|
| 14 |
+
default_type application/octet-stream;
|
| 15 |
+
|
| 16 |
+
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
| 17 |
+
'$status $body_bytes_sent "$http_referer" '
|
| 18 |
+
'"$http_user_agent" rt=$request_time';
|
| 19 |
+
|
| 20 |
+
access_log /var/log/nginx/access.log main;
|
| 21 |
+
|
| 22 |
+
sendfile on;
|
| 23 |
+
tcp_nopush on;
|
| 24 |
+
tcp_nodelay on;
|
| 25 |
+
keepalive_timeout 65;
|
| 26 |
+
gzip on;
|
| 27 |
+
gzip_vary on;
|
| 28 |
+
gzip_proxied any;
|
| 29 |
+
gzip_comp_level 6;
|
| 30 |
+
gzip_types text/plain text/css text/xml application/json application/javascript
|
| 31 |
+
application/xml+rss application/atom+xml image/svg+xml;
|
| 32 |
+
|
| 33 |
+
# Rate limiting
|
| 34 |
+
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
|
| 35 |
+
limit_req_zone $binary_remote_addr zone=v1:10m rate=200r/m;
|
| 36 |
+
|
| 37 |
+
# Upstream definitions
|
| 38 |
+
upstream backend {
|
| 39 |
+
server backend:3001;
|
| 40 |
+
keepalive 32;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
upstream litellm {
|
| 44 |
+
server litellm:4000;
|
| 45 |
+
keepalive 64;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
upstream frontend {
|
| 49 |
+
server frontend:80;
|
| 50 |
+
keepalive 16;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
server {
|
| 54 |
+
listen 80;
|
| 55 |
+
server_name _;
|
| 56 |
+
|
| 57 |
+
client_max_body_size 100M;
|
| 58 |
+
proxy_read_timeout 300s;
|
| 59 |
+
proxy_connect_timeout 10s;
|
| 60 |
+
proxy_send_timeout 300s;
|
| 61 |
+
|
| 62 |
+
# ── OpenAI-compatible AI API endpoints (via LiteLLM) ──────────────
|
| 63 |
+
location /v1/ {
|
| 64 |
+
limit_req zone=v1 burst=50 nodelay;
|
| 65 |
+
|
| 66 |
+
proxy_pass http://litellm;
|
| 67 |
+
proxy_http_version 1.1;
|
| 68 |
+
proxy_set_header Host $host;
|
| 69 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 70 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 71 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 72 |
+
proxy_set_header Connection "";
|
| 73 |
+
|
| 74 |
+
# SSE / streaming support
|
| 75 |
+
proxy_buffering off;
|
| 76 |
+
proxy_cache off;
|
| 77 |
+
chunked_transfer_encoding on;
|
| 78 |
+
proxy_read_timeout 300s;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
# ── Backend Management API ────────────────────────────────────────
|
| 82 |
+
location /api/ {
|
| 83 |
+
limit_req zone=api burst=20 nodelay;
|
| 84 |
+
|
| 85 |
+
proxy_pass http://backend;
|
| 86 |
+
proxy_http_version 1.1;
|
| 87 |
+
proxy_set_header Host $host;
|
| 88 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 89 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 90 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 91 |
+
proxy_set_header Connection "";
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
# ── Frontend SPA ──────────────────────────────────────────────────
|
| 95 |
+
# BUG FIX #12: Removed Upgrade / Connection "upgrade" headers.
|
| 96 |
+
# The frontend is a static React SPA served by nginx — it does not use
|
| 97 |
+
# WebSockets. Sending Upgrade headers to a plain HTTP upstream is
|
| 98 |
+
# incorrect. Connection "" clears the hop-by-hop header and enables
|
| 99 |
+
# HTTP/1.1 keepalive to the upstream frontend container.
|
| 100 |
+
location / {
|
| 101 |
+
proxy_pass http://frontend;
|
| 102 |
+
proxy_http_version 1.1;
|
| 103 |
+
proxy_set_header Host $host;
|
| 104 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 105 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 106 |
+
proxy_set_header Connection "";
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
}
|
frontend/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "ai-gateway-frontend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"react": "^18.3.1",
|
| 13 |
+
"react-dom": "^18.3.1",
|
| 14 |
+
"lucide-react": "^0.383.0"
|
| 15 |
+
},
|
| 16 |
+
"devDependencies": {
|
| 17 |
+
"@types/react": "^18.3.3",
|
| 18 |
+
"@types/react-dom": "^18.3.0",
|
| 19 |
+
"@vitejs/plugin-react": "^4.3.1",
|
| 20 |
+
"autoprefixer": "^10.4.19",
|
| 21 |
+
"postcss": "^8.4.40",
|
| 22 |
+
"tailwindcss": "^3.4.7",
|
| 23 |
+
"vite": "^5.3.4"
|
| 24 |
+
}
|
| 25 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
frontend/src/App.jsx
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from "react";
|
| 2 |
+
import {
|
| 3 |
+
Cpu, LayoutGrid, FlaskConical, BookOpen,
|
| 4 |
+
Github, Activity, ChevronRight, Zap
|
| 5 |
+
} from "lucide-react";
|
| 6 |
+
import { api } from "./api";
|
| 7 |
+
import ModelManager from "./components/ModelManager";
|
| 8 |
+
import TestPanel from "./components/TestPanel";
|
| 9 |
+
import DocsView from "./components/DocsView";
|
| 10 |
+
import StatsBar from "./components/StatsBar";
|
| 11 |
+
|
| 12 |
+
const NAV = [
|
| 13 |
+
{ id: "models", label: "Models", icon: LayoutGrid },
|
| 14 |
+
{ id: "test", label: "Test API", icon: FlaskConical },
|
| 15 |
+
{ id: "docs", label: "Integration Docs", icon: BookOpen },
|
| 16 |
+
];
|
| 17 |
+
|
| 18 |
+
export default function App() {
|
| 19 |
+
const [tab, setTab] = useState("models");
|
| 20 |
+
const [stats, setStats] = useState(null);
|
| 21 |
+
const [health, setHealth] = useState(null);
|
| 22 |
+
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
Promise.all([api.getStats(), api.getHealth()])
|
| 25 |
+
.then(([s, h]) => { setStats(s.data); setHealth(h.data); })
|
| 26 |
+
.catch(() => {});
|
| 27 |
+
|
| 28 |
+
const interval = setInterval(() => {
|
| 29 |
+
api.getStats().then(s => setStats(s.data)).catch(() => {});
|
| 30 |
+
}, 30000);
|
| 31 |
+
return () => clearInterval(interval);
|
| 32 |
+
}, []);
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<div className="min-h-screen bg-surface-0 flex flex-col">
|
| 36 |
+
{/* ── Header ─────────────────────────────────────────────────────── */}
|
| 37 |
+
<header className="glass sticky top-0 z-50 border-b border-white/5">
|
| 38 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between gap-4">
|
| 39 |
+
{/* Logo */}
|
| 40 |
+
<div className="flex items-center gap-3 flex-shrink-0">
|
| 41 |
+
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-accent-green to-accent-cyan
|
| 42 |
+
flex items-center justify-center">
|
| 43 |
+
<Zap size={16} className="text-surface-0" />
|
| 44 |
+
</div>
|
| 45 |
+
<div className="flex items-center gap-1.5">
|
| 46 |
+
<span className="font-display font-semibold text-text-primary text-sm">
|
| 47 |
+
AI Gateway
|
| 48 |
+
</span>
|
| 49 |
+
<span className="font-display font-semibold text-accent-green text-sm">Hub</span>
|
| 50 |
+
</div>
|
| 51 |
+
<div className="hidden sm:flex items-center gap-1 text-text-muted">
|
| 52 |
+
<ChevronRight size={12} />
|
| 53 |
+
<span className="text-xs font-mono text-text-secondary">LiteLLM-powered</span>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
{/* Nav */}
|
| 58 |
+
<nav className="flex items-center gap-1">
|
| 59 |
+
{NAV.map(({ id, label, icon: Icon }) => (
|
| 60 |
+
<button
|
| 61 |
+
key={id}
|
| 62 |
+
onClick={() => setTab(id)}
|
| 63 |
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium
|
| 64 |
+
transition-all duration-150 ${
|
| 65 |
+
tab === id
|
| 66 |
+
? "bg-accent-green/10 text-accent-green border border-accent-green/20"
|
| 67 |
+
: "text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
| 68 |
+
}`}
|
| 69 |
+
>
|
| 70 |
+
<Icon size={13} />
|
| 71 |
+
<span className="hidden sm:inline">{label}</span>
|
| 72 |
+
</button>
|
| 73 |
+
))}
|
| 74 |
+
</nav>
|
| 75 |
+
|
| 76 |
+
{/* Status + GitHub */}
|
| 77 |
+
<div className="flex items-center gap-2 flex-shrink-0">
|
| 78 |
+
<StatusDot health={health} />
|
| 79 |
+
<a
|
| 80 |
+
href="https://github.com/your-org/ai-gateway-hub"
|
| 81 |
+
target="_blank"
|
| 82 |
+
rel="noreferrer"
|
| 83 |
+
className="text-text-muted hover:text-text-primary transition-colors"
|
| 84 |
+
>
|
| 85 |
+
<Github size={16} />
|
| 86 |
+
</a>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
</header>
|
| 90 |
+
|
| 91 |
+
{/* ── Stats bar ──────────────────────────────────────────────────── */}
|
| 92 |
+
{stats && <StatsBar stats={stats} />}
|
| 93 |
+
|
| 94 |
+
{/* ── Main content ───────────────────────────────────────────────── */}
|
| 95 |
+
<main className="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 py-6">
|
| 96 |
+
{tab === "models" && <ModelManager />}
|
| 97 |
+
{tab === "test" && <TestPanel />}
|
| 98 |
+
{tab === "docs" && <DocsView />}
|
| 99 |
+
</main>
|
| 100 |
+
|
| 101 |
+
{/* ── Footer ─────────────────────────────────────────────────────── */}
|
| 102 |
+
<footer className="border-t border-white/5 py-4 text-center">
|
| 103 |
+
<p className="text-text-muted text-xs font-mono">
|
| 104 |
+
AI Gateway Hub · Powered by{" "}
|
| 105 |
+
<a href="https://litellm.ai" target="_blank" rel="noreferrer"
|
| 106 |
+
className="text-accent-green hover:underline">LiteLLM</a>
|
| 107 |
+
{" "}· OpenAI-compatible proxy for any AI provider
|
| 108 |
+
</p>
|
| 109 |
+
</footer>
|
| 110 |
+
</div>
|
| 111 |
+
);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function StatusDot({ health }) {
|
| 115 |
+
if (!health) return null;
|
| 116 |
+
const allOk = Object.values(health).every(v => v === "ok");
|
| 117 |
+
return (
|
| 118 |
+
<div className="flex items-center gap-1.5 text-xs">
|
| 119 |
+
<div className={`w-1.5 h-1.5 rounded-full ${
|
| 120 |
+
allOk ? "bg-accent-green animate-pulse" : "bg-accent-orange"
|
| 121 |
+
}`} />
|
| 122 |
+
<span className="text-text-muted hidden sm:inline font-mono">
|
| 123 |
+
{allOk ? "online" : "degraded"}
|
| 124 |
+
</span>
|
| 125 |
+
</div>
|
| 126 |
+
);
|
| 127 |
+
}
|
frontend/src/api.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const BASE = import.meta.env.VITE_API_BASE || "/api";
|
| 2 |
+
|
| 3 |
+
async function request(method, path, body) {
|
| 4 |
+
const res = await fetch(`${BASE}${path}`, {
|
| 5 |
+
method,
|
| 6 |
+
headers: { "Content-Type": "application/json" },
|
| 7 |
+
body: body ? JSON.stringify(body) : undefined,
|
| 8 |
+
});
|
| 9 |
+
const data = await res.json();
|
| 10 |
+
if (!data.success) throw new Error(data.error || "Request failed");
|
| 11 |
+
return data;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export const api = {
|
| 15 |
+
// Models
|
| 16 |
+
listModels: () => request("GET", "/models"),
|
| 17 |
+
getModel: (id) => request("GET", `/models/${id}`),
|
| 18 |
+
createModel: (body) => request("POST", "/models", body),
|
| 19 |
+
updateModel: (id, body) => request("PATCH", `/models/${id}`, body),
|
| 20 |
+
deleteModel: (id) => request("DELETE", `/models/${id}`),
|
| 21 |
+
// BUG FIX: accepts messages array (full conversation) not just a string prompt
|
| 22 |
+
testModel: (id, messages) => request("POST", `/models/${id}/test`, {
|
| 23 |
+
messages: Array.isArray(messages) ? messages : undefined,
|
| 24 |
+
prompt: typeof messages === "string" ? messages : undefined,
|
| 25 |
+
}),
|
| 26 |
+
toggleModel: (id) => request("POST", `/models/${id}/toggle`),
|
| 27 |
+
|
| 28 |
+
// Stats & info
|
| 29 |
+
getStats: () => request("GET", "/stats"),
|
| 30 |
+
getHealth: () => request("GET", "/health"),
|
| 31 |
+
getProviders: () => request("GET", "/providers"),
|
| 32 |
+
};
|
frontend/src/components/DocsView.jsx
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { Copy, Check, ExternalLink } from "lucide-react";
|
| 3 |
+
|
| 4 |
+
export default function DocsView() {
|
| 5 |
+
const [copied, setCopied] = useState(null);
|
| 6 |
+
const [active, setActive] = useState("overview");
|
| 7 |
+
// BUG FIX #7: Removed unused `codeTab` / `setCodeTab` state that was
|
| 8 |
+
// declared but never referenced in JSX, creating dead React state on every
|
| 9 |
+
// render and confusing future developers.
|
| 10 |
+
|
| 11 |
+
const BASE = window.location.origin;
|
| 12 |
+
|
| 13 |
+
const copy = (text, key) => {
|
| 14 |
+
navigator.clipboard.writeText(text);
|
| 15 |
+
setCopied(key);
|
| 16 |
+
setTimeout(() => setCopied(null), 1500);
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const sections = [
|
| 20 |
+
{
|
| 21 |
+
id: "overview",
|
| 22 |
+
title: "Overview",
|
| 23 |
+
content: (
|
| 24 |
+
<div className="space-y-3 text-text-secondary text-sm leading-relaxed">
|
| 25 |
+
<p>
|
| 26 |
+
AI Gateway Hub proxies any AI provider through a single{" "}
|
| 27 |
+
<span className="badge badge-green font-mono text-xs">OpenAI-compatible</span>{" "}
|
| 28 |
+
API endpoint. Add any model via the UI, then call it using the standard OpenAI SDK
|
| 29 |
+
from any language.
|
| 30 |
+
</p>
|
| 31 |
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-4">
|
| 32 |
+
{[
|
| 33 |
+
{ label: "Base URL", value: `${BASE}/v1`, badge: "green" },
|
| 34 |
+
{ label: "Auth", value: "Any bearer token (passthrough)", badge: "cyan" },
|
| 35 |
+
{ label: "Protocol", value: "OpenAI API v1", badge: "purple" },
|
| 36 |
+
].map(({ label, value, badge }) => (
|
| 37 |
+
<div key={label} className="card p-3">
|
| 38 |
+
<div className={`badge badge-${badge} mb-2`}>{label}</div>
|
| 39 |
+
<p className="font-mono text-xs text-text-primary">{value}</p>
|
| 40 |
+
</div>
|
| 41 |
+
))}
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
),
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
id: "python",
|
| 48 |
+
title: "Python (openai SDK)",
|
| 49 |
+
lang: "python",
|
| 50 |
+
code: `from openai import OpenAI
|
| 51 |
+
|
| 52 |
+
# Point the official OpenAI SDK at your gateway
|
| 53 |
+
client = OpenAI(
|
| 54 |
+
base_url="${BASE}/v1",
|
| 55 |
+
api_key="any-string", # No real key needed for keyless providers
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# Chat completions
|
| 59 |
+
response = client.chat.completions.create(
|
| 60 |
+
model="anthropic-claude-3-5-sonnet-20241022", # Your registered alias
|
| 61 |
+
messages=[
|
| 62 |
+
{"role": "system", "content": "You are a helpful assistant."},
|
| 63 |
+
{"role": "user", "content": "Explain quantum entanglement simply."},
|
| 64 |
+
],
|
| 65 |
+
temperature=0.7,
|
| 66 |
+
)
|
| 67 |
+
print(response.choices[0].message.content)
|
| 68 |
+
|
| 69 |
+
# Streaming
|
| 70 |
+
stream = client.chat.completions.create(
|
| 71 |
+
model="ollama-llama3",
|
| 72 |
+
messages=[{"role": "user", "content": "Write a haiku."}],
|
| 73 |
+
stream=True,
|
| 74 |
+
)
|
| 75 |
+
for chunk in stream:
|
| 76 |
+
print(chunk.choices[0].delta.content or "", end="", flush=True)
|
| 77 |
+
|
| 78 |
+
# Embeddings
|
| 79 |
+
embedding = client.embeddings.create(
|
| 80 |
+
model="openai-text-embedding-3-small",
|
| 81 |
+
input="Hello world",
|
| 82 |
+
)
|
| 83 |
+
print(embedding.data[0].embedding[:5])`,
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
id: "nodejs",
|
| 87 |
+
title: "Node.js / TypeScript",
|
| 88 |
+
lang: "javascript",
|
| 89 |
+
code: `import OpenAI from "openai";
|
| 90 |
+
|
| 91 |
+
const client = new OpenAI({
|
| 92 |
+
baseURL: "${BASE}/v1",
|
| 93 |
+
apiKey: "any-string",
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
// Chat completions
|
| 97 |
+
const response = await client.chat.completions.create({
|
| 98 |
+
model: "anthropic-claude-3-5-sonnet-20241022",
|
| 99 |
+
messages: [
|
| 100 |
+
{ role: "system", content: "You are a helpful assistant." },
|
| 101 |
+
{ role: "user", content: "What is the capital of France?" },
|
| 102 |
+
],
|
| 103 |
+
});
|
| 104 |
+
console.log(response.choices[0].message.content);
|
| 105 |
+
|
| 106 |
+
// Streaming
|
| 107 |
+
const stream = await client.chat.completions.create({
|
| 108 |
+
model: "ollama-llama3",
|
| 109 |
+
messages: [{ role: "user", content: "Count to 10." }],
|
| 110 |
+
stream: true,
|
| 111 |
+
});
|
| 112 |
+
for await (const chunk of stream) {
|
| 113 |
+
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
|
| 114 |
+
}`,
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
id: "curl",
|
| 118 |
+
title: "cURL",
|
| 119 |
+
lang: "bash",
|
| 120 |
+
code: `# Chat completion
|
| 121 |
+
curl ${BASE}/v1/chat/completions \\
|
| 122 |
+
-H "Content-Type: application/json" \\
|
| 123 |
+
-H "Authorization: Bearer any-key" \\
|
| 124 |
+
-d '{
|
| 125 |
+
"model": "anthropic-claude-3-5-sonnet-20241022",
|
| 126 |
+
"messages": [
|
| 127 |
+
{"role": "user", "content": "Hello, who are you?"}
|
| 128 |
+
]
|
| 129 |
+
}'
|
| 130 |
+
|
| 131 |
+
# List available models
|
| 132 |
+
curl ${BASE}/v1/models \\
|
| 133 |
+
-H "Authorization: Bearer any-key"
|
| 134 |
+
|
| 135 |
+
# Embeddings
|
| 136 |
+
curl ${BASE}/v1/embeddings \\
|
| 137 |
+
-H "Content-Type: application/json" \\
|
| 138 |
+
-H "Authorization: Bearer any-key" \\
|
| 139 |
+
-d '{
|
| 140 |
+
"model": "openai-text-embedding-3-small",
|
| 141 |
+
"input": "The food was delicious."
|
| 142 |
+
}'`,
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
id: "langchain",
|
| 146 |
+
title: "LangChain",
|
| 147 |
+
lang: "python",
|
| 148 |
+
code: `from langchain_openai import ChatOpenAI, OpenAIEmbeddings
|
| 149 |
+
|
| 150 |
+
# Drop-in replacement — just change base_url
|
| 151 |
+
llm = ChatOpenAI(
|
| 152 |
+
base_url="${BASE}/v1",
|
| 153 |
+
api_key="any-key",
|
| 154 |
+
model="anthropic-claude-3-5-sonnet-20241022",
|
| 155 |
+
temperature=0,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
result = llm.invoke("What are the benefits of LangChain?")
|
| 159 |
+
print(result.content)
|
| 160 |
+
|
| 161 |
+
# Embeddings
|
| 162 |
+
embeddings = OpenAIEmbeddings(
|
| 163 |
+
base_url="${BASE}/v1",
|
| 164 |
+
api_key="any-key",
|
| 165 |
+
model="openai-text-embedding-3-small",
|
| 166 |
+
)
|
| 167 |
+
vector = embeddings.embed_query("Hello world")`,
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
id: "litellm_sdk",
|
| 171 |
+
title: "LiteLLM SDK",
|
| 172 |
+
lang: "python",
|
| 173 |
+
code: `import litellm
|
| 174 |
+
|
| 175 |
+
# Call via gateway
|
| 176 |
+
response = litellm.completion(
|
| 177 |
+
model="openai/anthropic-claude-3-5-sonnet-20241022",
|
| 178 |
+
api_base="${BASE}/v1",
|
| 179 |
+
api_key="any-key",
|
| 180 |
+
messages=[{"role": "user", "content": "Hello!"}],
|
| 181 |
+
)
|
| 182 |
+
print(response.choices[0].message.content)`,
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
id: "endpoints",
|
| 186 |
+
title: "Supported Endpoints",
|
| 187 |
+
content: (
|
| 188 |
+
<div className="space-y-2">
|
| 189 |
+
{[
|
| 190 |
+
{ method: "POST", path: "/v1/chat/completions", desc: "Chat completions (streaming supported)" },
|
| 191 |
+
{ method: "POST", path: "/v1/completions", desc: "Text completions" },
|
| 192 |
+
{ method: "POST", path: "/v1/embeddings", desc: "Text embeddings" },
|
| 193 |
+
{ method: "POST", path: "/v1/images/generations", desc: "Image generation (DALL-E / Stable Diffusion)" },
|
| 194 |
+
{ method: "POST", path: "/v1/audio/speech", desc: "Text-to-speech" },
|
| 195 |
+
{ method: "POST", path: "/v1/audio/transcriptions",desc: "Speech-to-text (Whisper)" },
|
| 196 |
+
{ method: "GET", path: "/v1/models", desc: "List all registered models" },
|
| 197 |
+
].map(({ method, path, desc }) => (
|
| 198 |
+
<div key={path} className="flex items-center gap-3 text-sm py-2 border-b border-white/5 last:border-0">
|
| 199 |
+
<span className={`badge flex-shrink-0 ${
|
| 200 |
+
method === "GET" ? "badge-cyan" : "badge-green"
|
| 201 |
+
}`}>{method}</span>
|
| 202 |
+
<span className="font-mono text-text-primary text-xs flex-shrink-0">{path}</span>
|
| 203 |
+
<span className="text-text-muted text-xs">{desc}</span>
|
| 204 |
+
</div>
|
| 205 |
+
))}
|
| 206 |
+
</div>
|
| 207 |
+
),
|
| 208 |
+
},
|
| 209 |
+
];
|
| 210 |
+
|
| 211 |
+
return (
|
| 212 |
+
<div className="animate-fade-in">
|
| 213 |
+
<div className="mb-6">
|
| 214 |
+
<h1 className="font-display text-xl font-semibold text-text-primary">Integration Docs</h1>
|
| 215 |
+
<p className="text-text-secondary text-sm mt-0.5">
|
| 216 |
+
Use any OpenAI-compatible client to connect to your registered models.
|
| 217 |
+
</p>
|
| 218 |
+
</div>
|
| 219 |
+
|
| 220 |
+
<div className="flex gap-6">
|
| 221 |
+
{/* Sidebar */}
|
| 222 |
+
<nav className="hidden lg:block w-44 flex-shrink-0 space-y-0.5">
|
| 223 |
+
{sections.map(s => (
|
| 224 |
+
<button
|
| 225 |
+
key={s.id}
|
| 226 |
+
onClick={() => setActive(s.id)}
|
| 227 |
+
className={`w-full text-left px-3 py-2 rounded text-xs font-medium transition-colors ${
|
| 228 |
+
active === s.id
|
| 229 |
+
? "bg-accent-green/10 text-accent-green"
|
| 230 |
+
: "text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
| 231 |
+
}`}
|
| 232 |
+
>
|
| 233 |
+
{s.title}
|
| 234 |
+
</button>
|
| 235 |
+
))}
|
| 236 |
+
<div className="pt-3 border-t border-white/5 mt-3">
|
| 237 |
+
<a
|
| 238 |
+
href="https://docs.litellm.ai/docs/providers"
|
| 239 |
+
target="_blank" rel="noreferrer"
|
| 240 |
+
className="flex items-center gap-1.5 px-3 py-2 text-xs text-text-muted hover:text-accent-cyan"
|
| 241 |
+
>
|
| 242 |
+
<ExternalLink size={10} />
|
| 243 |
+
LiteLLM Providers
|
| 244 |
+
</a>
|
| 245 |
+
</div>
|
| 246 |
+
</nav>
|
| 247 |
+
|
| 248 |
+
{/* Content */}
|
| 249 |
+
<div className="flex-1 min-w-0 space-y-4">
|
| 250 |
+
{/* Mobile tab strip */}
|
| 251 |
+
<div className="lg:hidden flex gap-1 flex-wrap mb-4">
|
| 252 |
+
{sections.map(s => (
|
| 253 |
+
<button
|
| 254 |
+
key={s.id}
|
| 255 |
+
onClick={() => setActive(s.id)}
|
| 256 |
+
className={`badge cursor-pointer ${active === s.id ? "badge-green" : "badge-gray"}`}
|
| 257 |
+
>
|
| 258 |
+
{s.title}
|
| 259 |
+
</button>
|
| 260 |
+
))}
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
{sections
|
| 264 |
+
.filter(s => s.id === active)
|
| 265 |
+
.map(s => (
|
| 266 |
+
<div key={s.id} className="card p-5 animate-slide-in">
|
| 267 |
+
<h2 className="font-display font-semibold text-text-primary mb-4">{s.title}</h2>
|
| 268 |
+
{s.content && s.content}
|
| 269 |
+
{s.code && (
|
| 270 |
+
<div className="relative">
|
| 271 |
+
<button
|
| 272 |
+
onClick={() => copy(s.code, s.id)}
|
| 273 |
+
className="absolute top-3 right-3 text-text-muted hover:text-text-primary transition-colors z-10"
|
| 274 |
+
>
|
| 275 |
+
{copied === s.id
|
| 276 |
+
? <Check size={13} className="text-accent-green" />
|
| 277 |
+
: <Copy size={13} />
|
| 278 |
+
}
|
| 279 |
+
</button>
|
| 280 |
+
<div className="code-block text-text-secondary text-[0.75rem] leading-relaxed">
|
| 281 |
+
{s.code}
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
)}
|
| 285 |
+
</div>
|
| 286 |
+
))}
|
| 287 |
+
</div>
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
);
|
| 291 |
+
}
|
frontend/src/components/ModelCard.jsx
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import {
|
| 3 |
+
Copy, Check, Pencil, Trash2, ToggleLeft, ToggleRight,
|
| 4 |
+
ChevronDown, ChevronUp, Terminal, Globe, Key
|
| 5 |
+
} from "lucide-react";
|
| 6 |
+
import { api } from "../api";
|
| 7 |
+
|
| 8 |
+
const TYPE_BADGE = {
|
| 9 |
+
chat: "badge-green",
|
| 10 |
+
embedding: "badge-cyan",
|
| 11 |
+
image: "badge-purple",
|
| 12 |
+
audio: "badge-orange",
|
| 13 |
+
completion: "badge-gray",
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export default function ModelCard({ model, onEdit, onDelete, onToggle }) {
|
| 17 |
+
const [copied, setCopied] = useState(null);
|
| 18 |
+
const [expanded, setExpanded] = useState(false);
|
| 19 |
+
const [testing, setTesting] = useState(false);
|
| 20 |
+
const [testResult, setTestResult] = useState(null);
|
| 21 |
+
|
| 22 |
+
const copy = (text, key) => {
|
| 23 |
+
navigator.clipboard.writeText(text);
|
| 24 |
+
setCopied(key);
|
| 25 |
+
setTimeout(() => setCopied(null), 1500);
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const runTest = async () => {
|
| 29 |
+
setTesting(true);
|
| 30 |
+
setTestResult(null);
|
| 31 |
+
try {
|
| 32 |
+
const res = await api.testModel(model.id);
|
| 33 |
+
setTestResult(res.data);
|
| 34 |
+
} catch (e) {
|
| 35 |
+
setTestResult({ success: false, error: e.message });
|
| 36 |
+
} finally {
|
| 37 |
+
setTesting(false);
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<div className={`card flex flex-col animate-slide-in transition-all duration-200 ${
|
| 43 |
+
model.enabled ? "" : "opacity-50"
|
| 44 |
+
}`}>
|
| 45 |
+
{/* ── Card header ── */}
|
| 46 |
+
<div className="p-4 flex items-start justify-between gap-3">
|
| 47 |
+
<div className="min-w-0 flex-1">
|
| 48 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 49 |
+
<span className={`badge ${TYPE_BADGE[model.modelType] || "badge-gray"}`}>
|
| 50 |
+
{model.modelType || "chat"}
|
| 51 |
+
</span>
|
| 52 |
+
<span className="badge badge-gray">{model.provider}</span>
|
| 53 |
+
{!model.enabled && <span className="badge badge-gray">disabled</span>}
|
| 54 |
+
</div>
|
| 55 |
+
<h3 className="font-display font-semibold text-text-primary mt-2 text-sm leading-tight">
|
| 56 |
+
{model.displayName}
|
| 57 |
+
</h3>
|
| 58 |
+
{model.description && (
|
| 59 |
+
<p className="text-text-muted text-xs mt-1 leading-relaxed line-clamp-2">
|
| 60 |
+
{model.description}
|
| 61 |
+
</p>
|
| 62 |
+
)}
|
| 63 |
+
</div>
|
| 64 |
+
<div className="flex items-center gap-1 flex-shrink-0">
|
| 65 |
+
<button onClick={onToggle} title={model.enabled ? "Disable" : "Enable"}
|
| 66 |
+
className="p-1.5 rounded text-text-muted hover:text-text-primary transition-colors">
|
| 67 |
+
{model.enabled
|
| 68 |
+
? <ToggleRight size={16} className="text-accent-green" />
|
| 69 |
+
: <ToggleLeft size={16} />
|
| 70 |
+
}
|
| 71 |
+
</button>
|
| 72 |
+
<button onClick={onEdit}
|
| 73 |
+
className="p-1.5 rounded text-text-muted hover:text-text-primary transition-colors">
|
| 74 |
+
<Pencil size={14} />
|
| 75 |
+
</button>
|
| 76 |
+
<button onClick={onDelete}
|
| 77 |
+
className="p-1.5 rounded text-text-muted hover:text-accent-red transition-colors">
|
| 78 |
+
<Trash2 size={14} />
|
| 79 |
+
</button>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<hr className="divider" />
|
| 84 |
+
|
| 85 |
+
{/* ── Endpoint info ── */}
|
| 86 |
+
<div className="p-4 space-y-2">
|
| 87 |
+
<InfoRow
|
| 88 |
+
icon={Globe}
|
| 89 |
+
label="Endpoint"
|
| 90 |
+
value={model.openaiEndpoint}
|
| 91 |
+
onCopy={() => copy(model.openaiEndpoint, "endpoint")}
|
| 92 |
+
copied={copied === "endpoint"}
|
| 93 |
+
mono
|
| 94 |
+
/>
|
| 95 |
+
<InfoRow
|
| 96 |
+
icon={Terminal}
|
| 97 |
+
label="Model Name"
|
| 98 |
+
value={model.openaiModelName}
|
| 99 |
+
onCopy={() => copy(model.openaiModelName, "model")}
|
| 100 |
+
copied={copied === "model"}
|
| 101 |
+
mono
|
| 102 |
+
accent
|
| 103 |
+
/>
|
| 104 |
+
{model.apiBase && (
|
| 105 |
+
<InfoRow
|
| 106 |
+
icon={Globe}
|
| 107 |
+
label="Source API"
|
| 108 |
+
value={model.apiBase}
|
| 109 |
+
mono
|
| 110 |
+
/>
|
| 111 |
+
)}
|
| 112 |
+
<InfoRow
|
| 113 |
+
icon={Key}
|
| 114 |
+
label="API Key"
|
| 115 |
+
value={model.apiKey ? "Configured ✓" : "Not required / None"}
|
| 116 |
+
className={model.apiKey ? "text-accent-green" : "text-text-muted"}
|
| 117 |
+
/>
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
{/* ── Expandable section ── */}
|
| 121 |
+
<div className="border-t border-white/5">
|
| 122 |
+
<button
|
| 123 |
+
onClick={() => setExpanded(e => !e)}
|
| 124 |
+
className="w-full px-4 py-2.5 flex items-center justify-between text-xs
|
| 125 |
+
text-text-muted hover:text-text-secondary transition-colors"
|
| 126 |
+
>
|
| 127 |
+
<span>LiteLLM model: <span className="font-mono text-text-secondary">{model.litellmModel}</span></span>
|
| 128 |
+
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
| 129 |
+
</button>
|
| 130 |
+
|
| 131 |
+
{expanded && (
|
| 132 |
+
<div className="px-4 pb-4 space-y-3 animate-slide-in">
|
| 133 |
+
{/* Curl example */}
|
| 134 |
+
<div>
|
| 135 |
+
<div className="flex items-center justify-between mb-1.5">
|
| 136 |
+
<span className="text-xs text-text-muted font-mono">curl</span>
|
| 137 |
+
<button
|
| 138 |
+
onClick={() => copy(model.curlExample, "curl")}
|
| 139 |
+
className="text-text-muted hover:text-text-primary transition-colors"
|
| 140 |
+
>
|
| 141 |
+
{copied === "curl" ? <Check size={12} className="text-accent-green" /> : <Copy size={12} />}
|
| 142 |
+
</button>
|
| 143 |
+
</div>
|
| 144 |
+
<div className="code-block text-text-secondary text-[0.7rem] leading-relaxed max-h-32 overflow-y-auto">
|
| 145 |
+
{model.curlExample}
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
{/* Test button */}
|
| 150 |
+
<div>
|
| 151 |
+
<button
|
| 152 |
+
onClick={runTest}
|
| 153 |
+
disabled={testing || !model.enabled}
|
| 154 |
+
className="btn-secondary w-full justify-center text-xs"
|
| 155 |
+
>
|
| 156 |
+
{testing ? (
|
| 157 |
+
<><RefreshCwIcon className="animate-spin" size={12} /> Testing…</>
|
| 158 |
+
) : "Run connectivity test"}
|
| 159 |
+
</button>
|
| 160 |
+
|
| 161 |
+
{testResult && (
|
| 162 |
+
<div className={`mt-2 p-2 rounded text-xs font-mono border ${
|
| 163 |
+
testResult.success
|
| 164 |
+
? "bg-accent-green/5 border-accent-green/20 text-accent-green"
|
| 165 |
+
: "bg-accent-red/5 border-accent-red/20 text-accent-red"
|
| 166 |
+
}`}>
|
| 167 |
+
{testResult.success
|
| 168 |
+
? `✓ OK — ${testResult.latencyMs}ms`
|
| 169 |
+
: `✗ ${JSON.stringify(testResult.error)?.slice(0, 120)}`
|
| 170 |
+
}
|
| 171 |
+
</div>
|
| 172 |
+
)}
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
)}
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
function InfoRow({ icon: Icon, label, value, onCopy, copied, mono, accent, className }) {
|
| 182 |
+
return (
|
| 183 |
+
<div className="flex items-center gap-2 group">
|
| 184 |
+
<Icon size={11} className="text-text-muted flex-shrink-0" />
|
| 185 |
+
<span className="text-text-muted text-xs flex-shrink-0 w-20">{label}</span>
|
| 186 |
+
<span className={`text-xs flex-1 truncate ${
|
| 187 |
+
mono ? "font-mono" : ""
|
| 188 |
+
} ${accent ? "text-accent-cyan" : "text-text-secondary"} ${className || ""}`}>
|
| 189 |
+
{value}
|
| 190 |
+
</span>
|
| 191 |
+
{onCopy && (
|
| 192 |
+
<button
|
| 193 |
+
onClick={onCopy}
|
| 194 |
+
className="opacity-0 group-hover:opacity-100 transition-opacity text-text-muted hover:text-text-primary flex-shrink-0"
|
| 195 |
+
>
|
| 196 |
+
{copied
|
| 197 |
+
? <Check size={11} className="text-accent-green" />
|
| 198 |
+
: <Copy size={11} />
|
| 199 |
+
}
|
| 200 |
+
</button>
|
| 201 |
+
)}
|
| 202 |
+
</div>
|
| 203 |
+
);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
// Inline icon to avoid import issues
|
| 207 |
+
function RefreshCwIcon({ size, className }) {
|
| 208 |
+
return (
|
| 209 |
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
| 210 |
+
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
| 211 |
+
className={className}>
|
| 212 |
+
<polyline points="23 4 23 10 17 10" />
|
| 213 |
+
<polyline points="1 20 1 14 7 14" />
|
| 214 |
+
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
| 215 |
+
</svg>
|
| 216 |
+
);
|
| 217 |
+
}
|
frontend/src/components/ModelForm.jsx
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from "react";
|
| 2 |
+
import { X, ExternalLink, Info, Eye, EyeOff } from "lucide-react";
|
| 3 |
+
import { api } from "../api";
|
| 4 |
+
|
| 5 |
+
const MODEL_TYPES = [
|
| 6 |
+
{ value: "chat", label: "Chat / Instruct" },
|
| 7 |
+
{ value: "completion", label: "Text Completion" },
|
| 8 |
+
{ value: "embedding", label: "Embedding" },
|
| 9 |
+
{ value: "image", label: "Image Generation" },
|
| 10 |
+
{ value: "audio", label: "Audio / Speech" },
|
| 11 |
+
];
|
| 12 |
+
|
| 13 |
+
export default function ModelForm({ initial, onSave, onClose }) {
|
| 14 |
+
const isEdit = !!initial;
|
| 15 |
+
|
| 16 |
+
const [form, setForm] = useState({
|
| 17 |
+
displayName: initial?.displayName || "",
|
| 18 |
+
provider: initial?.provider || "",
|
| 19 |
+
litellmModel:initial?.litellmModel|| "",
|
| 20 |
+
apiBase: initial?.apiBase || "",
|
| 21 |
+
apiKey: "",
|
| 22 |
+
description: initial?.description || "",
|
| 23 |
+
modelType: initial?.modelType || "chat",
|
| 24 |
+
tags: initial?.tags?.join(", ") || "",
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
const [providers, setProviders] = useState([]);
|
| 28 |
+
const [saving, setSaving] = useState(false);
|
| 29 |
+
const [error, setError] = useState(null);
|
| 30 |
+
const [showKey, setShowKey] = useState(false);
|
| 31 |
+
const [selectedProvider, setSelectedProvider] = useState(null);
|
| 32 |
+
|
| 33 |
+
useEffect(() => {
|
| 34 |
+
api.getProviders().then(r => setProviders(r.data)).catch(() => {});
|
| 35 |
+
}, []);
|
| 36 |
+
|
| 37 |
+
const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }));
|
| 38 |
+
|
| 39 |
+
const handleProviderSelect = (e) => {
|
| 40 |
+
const prov = providers.find(p => p.id === e.target.value);
|
| 41 |
+
setSelectedProvider(prov || null);
|
| 42 |
+
if (prov) {
|
| 43 |
+
setForm(f => ({
|
| 44 |
+
...f,
|
| 45 |
+
provider: prov.id,
|
| 46 |
+
apiBase: prov.defaultApiBase || "",
|
| 47 |
+
litellmModel: prov.exampleModels?.[0] || "",
|
| 48 |
+
modelType: prov.modelTypes?.[0] || "chat",
|
| 49 |
+
}));
|
| 50 |
+
}
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
const handleSubmit = async () => {
|
| 54 |
+
if (!form.displayName.trim() || !form.provider.trim() || !form.litellmModel.trim()) {
|
| 55 |
+
setError("Display name, provider, and LiteLLM model are required.");
|
| 56 |
+
return;
|
| 57 |
+
}
|
| 58 |
+
setSaving(true);
|
| 59 |
+
setError(null);
|
| 60 |
+
try {
|
| 61 |
+
await onSave({
|
| 62 |
+
...form,
|
| 63 |
+
tags: form.tags.split(",").map(t => t.trim()).filter(Boolean),
|
| 64 |
+
apiKey: form.apiKey || undefined,
|
| 65 |
+
});
|
| 66 |
+
} catch (e) {
|
| 67 |
+
setError(e.message);
|
| 68 |
+
} finally {
|
| 69 |
+
setSaving(false);
|
| 70 |
+
}
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
| 75 |
+
onClick={(e) => e.target === e.currentTarget && onClose()}>
|
| 76 |
+
{/* Backdrop */}
|
| 77 |
+
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
| 78 |
+
|
| 79 |
+
{/* Modal */}
|
| 80 |
+
<div className="relative w-full max-w-xl glass rounded-xl border border-white/10
|
| 81 |
+
shadow-2xl shadow-black/50 animate-slide-in overflow-hidden">
|
| 82 |
+
{/* Header */}
|
| 83 |
+
<div className="flex items-center justify-between px-5 py-4 border-b border-white/5">
|
| 84 |
+
<div>
|
| 85 |
+
<h2 className="font-display font-semibold text-text-primary">
|
| 86 |
+
{isEdit ? "Edit Model" : "Register New Model"}
|
| 87 |
+
</h2>
|
| 88 |
+
<p className="text-text-muted text-xs mt-0.5">
|
| 89 |
+
{isEdit ? "Update model configuration." : "Add any AI model to the gateway."}
|
| 90 |
+
</p>
|
| 91 |
+
</div>
|
| 92 |
+
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors">
|
| 93 |
+
<X size={18} />
|
| 94 |
+
</button>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
{/* Body */}
|
| 98 |
+
<div className="px-5 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
| 99 |
+
|
| 100 |
+
{/* Provider quick-select */}
|
| 101 |
+
{!isEdit && providers.length > 0 && (
|
| 102 |
+
<div>
|
| 103 |
+
<label className="form-label">Quick-select provider</label>
|
| 104 |
+
<select className="select" onChange={handleProviderSelect} defaultValue="">
|
| 105 |
+
<option value="">— choose a provider to pre-fill —</option>
|
| 106 |
+
{providers.map(p => (
|
| 107 |
+
<option key={p.id} value={p.id}>{p.name}</option>
|
| 108 |
+
))}
|
| 109 |
+
</select>
|
| 110 |
+
{selectedProvider && (
|
| 111 |
+
<a
|
| 112 |
+
href={selectedProvider.docs}
|
| 113 |
+
target="_blank" rel="noreferrer"
|
| 114 |
+
className="inline-flex items-center gap-1 text-accent-cyan text-xs mt-1.5 hover:underline"
|
| 115 |
+
>
|
| 116 |
+
<ExternalLink size={10} />
|
| 117 |
+
LiteLLM docs for {selectedProvider.name}
|
| 118 |
+
</a>
|
| 119 |
+
)}
|
| 120 |
+
</div>
|
| 121 |
+
)}
|
| 122 |
+
|
| 123 |
+
{/* Display name */}
|
| 124 |
+
<div>
|
| 125 |
+
<label className="form-label">Display Name <Required /></label>
|
| 126 |
+
<input
|
| 127 |
+
className="input"
|
| 128 |
+
placeholder="e.g. My Claude Proxy"
|
| 129 |
+
value={form.displayName}
|
| 130 |
+
onChange={set("displayName")}
|
| 131 |
+
/>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
{/* Provider */}
|
| 135 |
+
<div>
|
| 136 |
+
<label className="form-label">Provider ID <Required /></label>
|
| 137 |
+
<input
|
| 138 |
+
className="input"
|
| 139 |
+
placeholder="e.g. anthropic, openai, ollama"
|
| 140 |
+
value={form.provider}
|
| 141 |
+
onChange={set("provider")}
|
| 142 |
+
/>
|
| 143 |
+
<p className="text-text-muted text-xs mt-1">
|
| 144 |
+
Used to build the OpenAI model alias: <span className="font-mono">provider/model</span>
|
| 145 |
+
</p>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
{/* LiteLLM model name */}
|
| 149 |
+
<div>
|
| 150 |
+
<label className="form-label">
|
| 151 |
+
LiteLLM Model Name <Required />
|
| 152 |
+
<a
|
| 153 |
+
href="https://docs.litellm.ai/docs/providers"
|
| 154 |
+
target="_blank" rel="noreferrer"
|
| 155 |
+
className="ml-2 text-accent-cyan text-xs inline-flex items-center gap-0.5 hover:underline"
|
| 156 |
+
>
|
| 157 |
+
<ExternalLink size={9} /> LiteLLM docs
|
| 158 |
+
</a>
|
| 159 |
+
</label>
|
| 160 |
+
<input
|
| 161 |
+
className="input input-mono"
|
| 162 |
+
placeholder="e.g. anthropic/claude-3-5-sonnet-20241022"
|
| 163 |
+
value={form.litellmModel}
|
| 164 |
+
onChange={set("litellmModel")}
|
| 165 |
+
/>
|
| 166 |
+
{selectedProvider?.exampleModels?.length > 0 && (
|
| 167 |
+
<div className="flex flex-wrap gap-1 mt-1.5">
|
| 168 |
+
{selectedProvider.exampleModels.map(m => (
|
| 169 |
+
<button
|
| 170 |
+
key={m}
|
| 171 |
+
type="button"
|
| 172 |
+
onClick={() => setForm(f => ({ ...f, litellmModel: m }))}
|
| 173 |
+
className="badge badge-gray cursor-pointer hover:badge-green font-mono text-[0.65rem]"
|
| 174 |
+
>
|
| 175 |
+
{m}
|
| 176 |
+
</button>
|
| 177 |
+
))}
|
| 178 |
+
</div>
|
| 179 |
+
)}
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
{/* API Base */}
|
| 183 |
+
<div>
|
| 184 |
+
<label className="form-label">API Base URL <span className="text-text-muted">(optional)</span></label>
|
| 185 |
+
<input
|
| 186 |
+
className="input input-mono"
|
| 187 |
+
placeholder="e.g. https://api.anthropic.com or http://localhost:11434"
|
| 188 |
+
value={form.apiBase}
|
| 189 |
+
onChange={set("apiBase")}
|
| 190 |
+
/>
|
| 191 |
+
<p className="text-text-muted text-xs mt-1">
|
| 192 |
+
Leave blank to use LiteLLM's default for this provider.
|
| 193 |
+
</p>
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
{/* API Key */}
|
| 197 |
+
<div>
|
| 198 |
+
<label className="form-label">
|
| 199 |
+
API Key <span className="text-text-muted">(optional)</span>
|
| 200 |
+
</label>
|
| 201 |
+
<div className="relative">
|
| 202 |
+
<input
|
| 203 |
+
className="input input-mono pr-9"
|
| 204 |
+
placeholder={isEdit ? "Leave blank to keep existing" : "sk-… (leave empty for keyless providers)"}
|
| 205 |
+
type={showKey ? "text" : "password"}
|
| 206 |
+
value={form.apiKey}
|
| 207 |
+
onChange={set("apiKey")}
|
| 208 |
+
autoComplete="new-password"
|
| 209 |
+
/>
|
| 210 |
+
<button
|
| 211 |
+
type="button"
|
| 212 |
+
onClick={() => setShowKey(s => !s)}
|
| 213 |
+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
|
| 214 |
+
>
|
| 215 |
+
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
| 216 |
+
</button>
|
| 217 |
+
</div>
|
| 218 |
+
<p className="text-text-muted text-xs mt-1 flex items-center gap-1">
|
| 219 |
+
<Info size={10} />
|
| 220 |
+
Keys are stored server-side and masked in the UI.
|
| 221 |
+
For Ollama and local models, no key is needed.
|
| 222 |
+
</p>
|
| 223 |
+
</div>
|
| 224 |
+
|
| 225 |
+
{/* Model type */}
|
| 226 |
+
<div>
|
| 227 |
+
<label className="form-label">Model Type</label>
|
| 228 |
+
<select className="select" value={form.modelType} onChange={set("modelType")}>
|
| 229 |
+
{MODEL_TYPES.map(t => (
|
| 230 |
+
<option key={t.value} value={t.value}>{t.label}</option>
|
| 231 |
+
))}
|
| 232 |
+
</select>
|
| 233 |
+
</div>
|
| 234 |
+
|
| 235 |
+
{/* Description */}
|
| 236 |
+
<div>
|
| 237 |
+
<label className="form-label">Description <span className="text-text-muted">(optional)</span></label>
|
| 238 |
+
<textarea
|
| 239 |
+
className="input resize-none"
|
| 240 |
+
rows={2}
|
| 241 |
+
placeholder="Brief description of this model endpoint…"
|
| 242 |
+
value={form.description}
|
| 243 |
+
onChange={set("description")}
|
| 244 |
+
/>
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
{/* Tags */}
|
| 248 |
+
<div>
|
| 249 |
+
<label className="form-label">Tags <span className="text-text-muted">(comma-separated)</span></label>
|
| 250 |
+
<input
|
| 251 |
+
className="input"
|
| 252 |
+
placeholder="e.g. production, fast, vision"
|
| 253 |
+
value={form.tags}
|
| 254 |
+
onChange={set("tags")}
|
| 255 |
+
/>
|
| 256 |
+
</div>
|
| 257 |
+
|
| 258 |
+
{error && (
|
| 259 |
+
<div className="p-3 rounded-lg bg-accent-red/10 border border-accent-red/20 text-accent-red text-sm">
|
| 260 |
+
{error}
|
| 261 |
+
</div>
|
| 262 |
+
)}
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
{/* Footer */}
|
| 266 |
+
<div className="flex items-center justify-end gap-2 px-5 py-3.5 border-t border-white/5 bg-surface-1/30">
|
| 267 |
+
<button onClick={onClose} className="btn-secondary">Cancel</button>
|
| 268 |
+
<button onClick={handleSubmit} disabled={saving} className="btn-primary">
|
| 269 |
+
{saving ? "Saving…" : isEdit ? "Save Changes" : "Register Model"}
|
| 270 |
+
</button>
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
);
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function Required() {
|
| 278 |
+
return <span className="text-accent-red text-xs ml-0.5">*</span>;
|
| 279 |
+
}
|
| 280 |
+
// BUG FIX: Removed module-level document.createElement("style") — it ran on every
|
| 281 |
+
// module evaluation and injected duplicate <style> tags on HMR reloads.
|
| 282 |
+
// The .form-label class is now defined in index.css @layer components.
|
frontend/src/components/ModelManager.jsx
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from "react";
|
| 2 |
+
import { Plus, RefreshCw } from "lucide-react";
|
| 3 |
+
import { api } from "../api";
|
| 4 |
+
import ModelCard from "./ModelCard";
|
| 5 |
+
import ModelForm from "./ModelForm";
|
| 6 |
+
|
| 7 |
+
export default function ModelManager() {
|
| 8 |
+
const [models, setModels] = useState([]);
|
| 9 |
+
const [loading, setLoading] = useState(true);
|
| 10 |
+
const [showForm, setShowForm] = useState(false);
|
| 11 |
+
const [editModel, setEditModel] = useState(null);
|
| 12 |
+
const [filter, setFilter] = useState("all");
|
| 13 |
+
|
| 14 |
+
const load = async () => {
|
| 15 |
+
setLoading(true);
|
| 16 |
+
try {
|
| 17 |
+
const res = await api.listModels();
|
| 18 |
+
setModels(res.data);
|
| 19 |
+
} catch (e) {
|
| 20 |
+
console.error(e);
|
| 21 |
+
} finally {
|
| 22 |
+
setLoading(false);
|
| 23 |
+
}
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
useEffect(() => { load(); }, []);
|
| 27 |
+
|
| 28 |
+
const handleDelete = async (id) => {
|
| 29 |
+
if (!confirm("Delete this model? It will be removed from the gateway.")) return;
|
| 30 |
+
await api.deleteModel(id);
|
| 31 |
+
setModels(m => m.filter(x => x.id !== id));
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const handleToggle = async (id) => {
|
| 35 |
+
const res = await api.toggleModel(id);
|
| 36 |
+
setModels(m => m.map(x => x.id === id ? res.data : x));
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const handleSave = async (data) => {
|
| 40 |
+
if (editModel) {
|
| 41 |
+
const res = await api.updateModel(editModel.id, data);
|
| 42 |
+
setModels(m => m.map(x => x.id === editModel.id ? res.data : x));
|
| 43 |
+
} else {
|
| 44 |
+
const res = await api.createModel(data);
|
| 45 |
+
setModels(m => [res.data, ...m]);
|
| 46 |
+
}
|
| 47 |
+
setShowForm(false);
|
| 48 |
+
setEditModel(null);
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const TYPE_COLORS = {
|
| 52 |
+
chat: "green", embedding: "cyan", image: "purple",
|
| 53 |
+
audio: "orange", completion: "gray",
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
const filtered = filter === "all"
|
| 57 |
+
? models
|
| 58 |
+
: models.filter(m => m.modelType === filter || (filter === "enabled" ? m.enabled : !m.enabled));
|
| 59 |
+
|
| 60 |
+
const types = [...new Set(models.map(m => m.modelType))].filter(Boolean);
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div className="animate-fade-in">
|
| 64 |
+
{/* ── Header ── */}
|
| 65 |
+
<div className="flex items-center justify-between mb-6">
|
| 66 |
+
<div>
|
| 67 |
+
<h1 className="font-display text-xl font-semibold text-text-primary">
|
| 68 |
+
Model Registry
|
| 69 |
+
</h1>
|
| 70 |
+
<p className="text-text-secondary text-sm mt-0.5">
|
| 71 |
+
Register any AI model and get an OpenAI-compatible endpoint instantly.
|
| 72 |
+
</p>
|
| 73 |
+
</div>
|
| 74 |
+
<div className="flex items-center gap-2">
|
| 75 |
+
<button onClick={load} className="btn-secondary" title="Refresh">
|
| 76 |
+
<RefreshCw size={14} className={loading ? "animate-spin" : ""} />
|
| 77 |
+
</button>
|
| 78 |
+
<button onClick={() => { setEditModel(null); setShowForm(true); }} className="btn-primary">
|
| 79 |
+
<Plus size={14} />
|
| 80 |
+
Add Model
|
| 81 |
+
</button>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{/* ── Filters ── */}
|
| 86 |
+
{models.length > 0 && (
|
| 87 |
+
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
| 88 |
+
{["all", "enabled", ...types].map(f => (
|
| 89 |
+
<button
|
| 90 |
+
key={f}
|
| 91 |
+
onClick={() => setFilter(f)}
|
| 92 |
+
className={`badge cursor-pointer transition-all ${
|
| 93 |
+
filter === f
|
| 94 |
+
? `badge-${TYPE_COLORS[f] || "green"}`
|
| 95 |
+
: "badge-gray hover:border-surface-4"
|
| 96 |
+
}`}
|
| 97 |
+
>
|
| 98 |
+
{f}
|
| 99 |
+
</button>
|
| 100 |
+
))}
|
| 101 |
+
</div>
|
| 102 |
+
)}
|
| 103 |
+
|
| 104 |
+
{/* ── Content ── */}
|
| 105 |
+
{loading ? (
|
| 106 |
+
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
| 107 |
+
{[...Array(3)].map((_, i) => (
|
| 108 |
+
<div key={i} className="card p-5 animate-pulse">
|
| 109 |
+
<div className="h-4 bg-surface-3 rounded w-2/3 mb-3" />
|
| 110 |
+
<div className="h-3 bg-surface-3 rounded w-full mb-2" />
|
| 111 |
+
<div className="h-3 bg-surface-3 rounded w-3/4" />
|
| 112 |
+
</div>
|
| 113 |
+
))}
|
| 114 |
+
</div>
|
| 115 |
+
) : filtered.length === 0 ? (
|
| 116 |
+
<EmptyState onAdd={() => setShowForm(true)} hasModels={models.length > 0} />
|
| 117 |
+
) : (
|
| 118 |
+
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
| 119 |
+
{filtered.map(model => (
|
| 120 |
+
<ModelCard
|
| 121 |
+
key={model.id}
|
| 122 |
+
model={model}
|
| 123 |
+
onEdit={() => { setEditModel(model); setShowForm(true); }}
|
| 124 |
+
onDelete={() => handleDelete(model.id)}
|
| 125 |
+
onToggle={() => handleToggle(model.id)}
|
| 126 |
+
/>
|
| 127 |
+
))}
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
|
| 131 |
+
{/* ── Form modal ── */}
|
| 132 |
+
{showForm && (
|
| 133 |
+
<ModelForm
|
| 134 |
+
initial={editModel}
|
| 135 |
+
onSave={handleSave}
|
| 136 |
+
onClose={() => { setShowForm(false); setEditModel(null); }}
|
| 137 |
+
/>
|
| 138 |
+
)}
|
| 139 |
+
</div>
|
| 140 |
+
);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function EmptyState({ onAdd, hasModels }) {
|
| 144 |
+
return (
|
| 145 |
+
<div className="card p-12 text-center border-dashed">
|
| 146 |
+
<div className="w-14 h-14 rounded-2xl bg-accent-green/10 border border-accent-green/20
|
| 147 |
+
flex items-center justify-center mx-auto mb-4">
|
| 148 |
+
<Plus size={24} className="text-accent-green" />
|
| 149 |
+
</div>
|
| 150 |
+
<h3 className="font-display font-semibold text-text-primary mb-1">
|
| 151 |
+
{hasModels ? "No models match this filter" : "No models registered yet"}
|
| 152 |
+
</h3>
|
| 153 |
+
<p className="text-text-secondary text-sm mb-5 max-w-sm mx-auto">
|
| 154 |
+
{hasModels
|
| 155 |
+
? "Try a different filter to see your models."
|
| 156 |
+
: "Add your first model to start routing AI requests through an OpenAI-compatible endpoint."}
|
| 157 |
+
</p>
|
| 158 |
+
{!hasModels && (
|
| 159 |
+
<button onClick={onAdd} className="btn-primary mx-auto">
|
| 160 |
+
<Plus size={14} />
|
| 161 |
+
Add your first model
|
| 162 |
+
</button>
|
| 163 |
+
)}
|
| 164 |
+
</div>
|
| 165 |
+
);
|
| 166 |
+
}
|
frontend/src/components/StatsBar.jsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Cpu, Activity, CheckCircle2, Clock } from "lucide-react";
|
| 2 |
+
|
| 3 |
+
export default function StatsBar({ stats }) {
|
| 4 |
+
const items = [
|
| 5 |
+
{ icon: Cpu, label: "Total Models", value: stats.totalModels },
|
| 6 |
+
{ icon: CheckCircle2, label: "Active", value: stats.enabledModels, accent: "green" },
|
| 7 |
+
{ icon: Activity, label: "Requests", value: stats.totalRequests },
|
| 8 |
+
{
|
| 9 |
+
icon: Clock,
|
| 10 |
+
label: "Avg Latency",
|
| 11 |
+
value: stats.avgLatency ? `${Math.round(stats.avgLatency)}ms` : "—",
|
| 12 |
+
},
|
| 13 |
+
];
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div className="border-b border-white/5 bg-surface-1/30">
|
| 17 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-2 flex items-center gap-6 overflow-x-auto">
|
| 18 |
+
{items.map(({ icon: Icon, label, value, accent }) => (
|
| 19 |
+
<div key={label} className="flex items-center gap-2 flex-shrink-0">
|
| 20 |
+
<Icon size={12} className={accent === "green" ? "text-accent-green" : "text-text-muted"} />
|
| 21 |
+
<span className="text-text-muted text-xs">{label}:</span>
|
| 22 |
+
<span className={`text-xs font-mono font-medium ${
|
| 23 |
+
accent === "green" ? "text-accent-green" : "text-text-secondary"
|
| 24 |
+
}`}>{value}</span>
|
| 25 |
+
</div>
|
| 26 |
+
))}
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
);
|
| 30 |
+
}
|
frontend/src/components/TestPanel.jsx
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from "react";
|
| 2 |
+
import { Copy, Check, Zap } from "lucide-react";
|
| 3 |
+
import { api } from "../api";
|
| 4 |
+
|
| 5 |
+
export default function TestPanel() {
|
| 6 |
+
const [models, setModels] = useState([]);
|
| 7 |
+
const [selectedId, setSelectedId] = useState("");
|
| 8 |
+
const [messages, setMessages] = useState([
|
| 9 |
+
{ role: "system", content: "You are a helpful assistant." },
|
| 10 |
+
{ role: "user", content: "Hello! Tell me what model you are in one sentence." },
|
| 11 |
+
]);
|
| 12 |
+
const [response, setResponse] = useState(null);
|
| 13 |
+
const [loading, setLoading] = useState(false);
|
| 14 |
+
const [copied, setCopied] = useState(null);
|
| 15 |
+
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
api.listModels()
|
| 18 |
+
.then(r => {
|
| 19 |
+
const enabled = r.data.filter(m => m.enabled && m.modelType === "chat");
|
| 20 |
+
setModels(enabled);
|
| 21 |
+
if (enabled.length > 0) setSelectedId(enabled[0].id);
|
| 22 |
+
})
|
| 23 |
+
.catch(() => {});
|
| 24 |
+
}, []);
|
| 25 |
+
|
| 26 |
+
const selected = models.find(m => m.id === selectedId);
|
| 27 |
+
|
| 28 |
+
const addMessage = () => {
|
| 29 |
+
setMessages(m => [...m, { role: "user", content: "" }]);
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const updateMessage = (i, field, value) => {
|
| 33 |
+
setMessages(m => m.map((msg, idx) => idx === i ? { ...msg, [field]: value } : msg));
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const removeMessage = (i) => {
|
| 37 |
+
setMessages(m => m.filter((_, idx) => idx !== i));
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const runTest = async () => {
|
| 41 |
+
const validMessages = messages.filter(m => m.content.trim());
|
| 42 |
+
if (!selected || !validMessages.length) return;
|
| 43 |
+
setLoading(true);
|
| 44 |
+
setResponse(null);
|
| 45 |
+
const start = Date.now();
|
| 46 |
+
try {
|
| 47 |
+
const res = await api.testModel(selectedId, validMessages);
|
| 48 |
+
setResponse({ ...res.data, _latency: Date.now() - start });
|
| 49 |
+
} catch (e) {
|
| 50 |
+
setResponse({ error: e.message, _latency: Date.now() - start });
|
| 51 |
+
} finally {
|
| 52 |
+
setLoading(false);
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
const copy = (text, key) => {
|
| 57 |
+
navigator.clipboard.writeText(text);
|
| 58 |
+
setCopied(key);
|
| 59 |
+
setTimeout(() => setCopied(null), 1500);
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
const curlCode = selected
|
| 63 |
+
? `curl ${selected.openaiEndpoint}/chat/completions \\
|
| 64 |
+
-H "Content-Type: application/json" \\
|
| 65 |
+
-H "Authorization: Bearer any-key" \\
|
| 66 |
+
-d '${JSON.stringify({ model: selected.openaiModelName, messages: messages.filter(m => m.content.trim()) }, null, 2)}'`
|
| 67 |
+
: "";
|
| 68 |
+
|
| 69 |
+
const pythonCode = selected
|
| 70 |
+
? `from openai import OpenAI
|
| 71 |
+
|
| 72 |
+
client = OpenAI(
|
| 73 |
+
base_url="${selected.openaiEndpoint}",
|
| 74 |
+
api_key="any-key",
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
response = client.chat.completions.create(
|
| 78 |
+
model="${selected.openaiModelName}",
|
| 79 |
+
messages=${JSON.stringify(messages.filter(m => m.content.trim()), null, 4).replace(/^/gm, " ").trim()},
|
| 80 |
+
)
|
| 81 |
+
print(response.choices[0].message.content)`
|
| 82 |
+
: "";
|
| 83 |
+
|
| 84 |
+
const jsCode = selected
|
| 85 |
+
? `import OpenAI from "openai";
|
| 86 |
+
|
| 87 |
+
const client = new OpenAI({
|
| 88 |
+
baseURL: "${selected.openaiEndpoint}",
|
| 89 |
+
apiKey: "any-key",
|
| 90 |
+
dangerouslyAllowBrowser: true,
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
const response = await client.chat.completions.create({
|
| 94 |
+
model: "${selected.openaiModelName}",
|
| 95 |
+
messages: ${JSON.stringify(messages.filter(m => m.content.trim()), null, 2)},
|
| 96 |
+
});
|
| 97 |
+
console.log(response.choices[0].message.content);`
|
| 98 |
+
: "";
|
| 99 |
+
|
| 100 |
+
const [codeTab, setCodeTab] = useState("curl");
|
| 101 |
+
const codeMap = { curl: curlCode, python: pythonCode, javascript: jsCode };
|
| 102 |
+
|
| 103 |
+
return (
|
| 104 |
+
<div className="animate-fade-in">
|
| 105 |
+
<div className="mb-6">
|
| 106 |
+
<h1 className="font-display text-xl font-semibold text-text-primary">API Tester</h1>
|
| 107 |
+
<p className="text-text-secondary text-sm mt-0.5">
|
| 108 |
+
Test your registered models with real requests.
|
| 109 |
+
</p>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
| 113 |
+
{/* ── Left: Request builder ── */}
|
| 114 |
+
<div className="space-y-4">
|
| 115 |
+
{/* Model select */}
|
| 116 |
+
<div className="card p-4">
|
| 117 |
+
<label className="text-xs font-medium text-text-secondary mb-2 block">
|
| 118 |
+
Select Model
|
| 119 |
+
</label>
|
| 120 |
+
{models.length === 0 ? (
|
| 121 |
+
<p className="text-text-muted text-sm">No chat models registered yet.</p>
|
| 122 |
+
) : (
|
| 123 |
+
<select className="select" value={selectedId} onChange={e => setSelectedId(e.target.value)}>
|
| 124 |
+
{models.map(m => (
|
| 125 |
+
<option key={m.id} value={m.id}>{m.displayName} — {m.openaiModelName}</option>
|
| 126 |
+
))}
|
| 127 |
+
</select>
|
| 128 |
+
)}
|
| 129 |
+
{selected && (
|
| 130 |
+
<div className="mt-3 p-2.5 bg-surface-2 rounded-lg">
|
| 131 |
+
<div className="flex items-center gap-2 text-xs">
|
| 132 |
+
<span className="text-text-muted">Endpoint:</span>
|
| 133 |
+
<span className="font-mono text-accent-cyan flex-1 truncate">{selected.openaiEndpoint}</span>
|
| 134 |
+
<button onClick={() => copy(selected.openaiEndpoint, "ep")}
|
| 135 |
+
className="text-text-muted hover:text-text-primary">
|
| 136 |
+
{copied === "ep" ? <Check size={11} className="text-accent-green" /> : <Copy size={11} />}
|
| 137 |
+
</button>
|
| 138 |
+
</div>
|
| 139 |
+
<div className="flex items-center gap-2 text-xs mt-1">
|
| 140 |
+
<span className="text-text-muted">Model:</span>
|
| 141 |
+
<span className="font-mono text-accent-green flex-1 truncate">{selected.openaiModelName}</span>
|
| 142 |
+
<button onClick={() => copy(selected.openaiModelName, "mn")}
|
| 143 |
+
className="text-text-muted hover:text-text-primary">
|
| 144 |
+
{copied === "mn" ? <Check size={11} className="text-accent-green" /> : <Copy size={11} />}
|
| 145 |
+
</button>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
)}
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
{/* Messages */}
|
| 152 |
+
<div className="card p-4 space-y-2">
|
| 153 |
+
<div className="flex items-center justify-between mb-1">
|
| 154 |
+
<label className="text-xs font-medium text-text-secondary">Messages</label>
|
| 155 |
+
<button onClick={addMessage} className="text-xs text-accent-green hover:underline">
|
| 156 |
+
+ Add message
|
| 157 |
+
</button>
|
| 158 |
+
</div>
|
| 159 |
+
{messages.map((msg, i) => (
|
| 160 |
+
<div key={i} className="flex gap-2 items-start">
|
| 161 |
+
<select
|
| 162 |
+
className="select w-24 flex-shrink-0 text-xs py-1.5"
|
| 163 |
+
value={msg.role}
|
| 164 |
+
onChange={e => updateMessage(i, "role", e.target.value)}
|
| 165 |
+
>
|
| 166 |
+
<option value="system">system</option>
|
| 167 |
+
<option value="user">user</option>
|
| 168 |
+
<option value="assistant">assistant</option>
|
| 169 |
+
</select>
|
| 170 |
+
<textarea
|
| 171 |
+
className="input flex-1 resize-none text-xs"
|
| 172 |
+
rows={msg.role === "system" ? 1 : 2}
|
| 173 |
+
value={msg.content}
|
| 174 |
+
onChange={e => updateMessage(i, "content", e.target.value)}
|
| 175 |
+
placeholder={`${msg.role} message…`}
|
| 176 |
+
/>
|
| 177 |
+
{messages.length > 1 && (
|
| 178 |
+
<button onClick={() => removeMessage(i)}
|
| 179 |
+
className="text-text-muted hover:text-accent-red transition-colors mt-1.5">
|
| 180 |
+
<span className="text-xs">✕</span>
|
| 181 |
+
</button>
|
| 182 |
+
)}
|
| 183 |
+
</div>
|
| 184 |
+
))}
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
<button
|
| 188 |
+
onClick={runTest}
|
| 189 |
+
disabled={loading || !selected}
|
| 190 |
+
className="btn-primary w-full justify-center"
|
| 191 |
+
>
|
| 192 |
+
<Zap size={14} />
|
| 193 |
+
{loading ? "Sending request…" : "Send Request"}
|
| 194 |
+
</button>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
{/* ── Right: Response + Code ── */}
|
| 198 |
+
<div className="space-y-4">
|
| 199 |
+
{/* Response */}
|
| 200 |
+
<div className="card">
|
| 201 |
+
<div className="px-4 py-3 border-b border-white/5 flex items-center justify-between">
|
| 202 |
+
<span className="text-xs font-medium text-text-secondary">Response</span>
|
| 203 |
+
{response && (
|
| 204 |
+
<span className="text-xs font-mono text-text-muted">
|
| 205 |
+
{response._latency}ms
|
| 206 |
+
</span>
|
| 207 |
+
)}
|
| 208 |
+
</div>
|
| 209 |
+
<div className="p-4 min-h-[160px]">
|
| 210 |
+
{loading && (
|
| 211 |
+
<div className="flex items-center gap-2 text-text-muted text-sm">
|
| 212 |
+
<div className="w-2 h-2 bg-accent-green rounded-full animate-pulse" />
|
| 213 |
+
Waiting for response…
|
| 214 |
+
</div>
|
| 215 |
+
)}
|
| 216 |
+
{!loading && !response && (
|
| 217 |
+
<p className="text-text-muted text-sm">Response will appear here.</p>
|
| 218 |
+
)}
|
| 219 |
+
{!loading && response && (
|
| 220 |
+
<>
|
| 221 |
+
{response.error ? (
|
| 222 |
+
<div className="text-accent-red text-sm font-mono whitespace-pre-wrap">
|
| 223 |
+
{JSON.stringify(response.error, null, 2)}
|
| 224 |
+
</div>
|
| 225 |
+
) : (
|
| 226 |
+
<div className="space-y-3">
|
| 227 |
+
<div className="p-3 bg-surface-2 rounded-lg">
|
| 228 |
+
<p className="text-text-primary text-sm leading-relaxed whitespace-pre-wrap">
|
| 229 |
+
{/* FIX: response state shape is { success, latencyMs, response: <LiteLLM data>, _latency }
|
| 230 |
+
The actual LiteLLM chat completion object lives at response.response,
|
| 231 |
+
not at the top level. Previously accessed response.choices which was
|
| 232 |
+
always undefined, causing "No content in response" on every call. */}
|
| 233 |
+
{response.response?.choices?.[0]?.message?.content || "No content in response"}
|
| 234 |
+
</p>
|
| 235 |
+
</div>
|
| 236 |
+
{response.response?.usage && (
|
| 237 |
+
<div className="flex gap-4 text-xs text-text-muted font-mono">
|
| 238 |
+
<span>in: {response.response.usage.prompt_tokens}</span>
|
| 239 |
+
<span>out: {response.response.usage.completion_tokens}</span>
|
| 240 |
+
<span>total: {response.response.usage.total_tokens}</span>
|
| 241 |
+
<span>model: {response.response.model}</span>
|
| 242 |
+
</div>
|
| 243 |
+
)}
|
| 244 |
+
</div>
|
| 245 |
+
)}
|
| 246 |
+
</>
|
| 247 |
+
)}
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
{/* Code examples */}
|
| 252 |
+
{selected && (
|
| 253 |
+
<div className="card">
|
| 254 |
+
<div className="px-4 py-3 border-b border-white/5 flex items-center gap-2">
|
| 255 |
+
{["curl", "python", "javascript"].map(t => (
|
| 256 |
+
<button
|
| 257 |
+
key={t}
|
| 258 |
+
onClick={() => setCodeTab(t)}
|
| 259 |
+
className={`text-xs px-2.5 py-1 rounded font-mono transition-colors ${
|
| 260 |
+
codeTab === t
|
| 261 |
+
? "bg-accent-green/10 text-accent-green border border-accent-green/20"
|
| 262 |
+
: "text-text-muted hover:text-text-secondary"
|
| 263 |
+
}`}
|
| 264 |
+
>
|
| 265 |
+
{t}
|
| 266 |
+
</button>
|
| 267 |
+
))}
|
| 268 |
+
<div className="flex-1" />
|
| 269 |
+
<button
|
| 270 |
+
onClick={() => copy(codeMap[codeTab], "code")}
|
| 271 |
+
className="text-text-muted hover:text-text-primary transition-colors"
|
| 272 |
+
>
|
| 273 |
+
{copied === "code"
|
| 274 |
+
? <Check size={13} className="text-accent-green" />
|
| 275 |
+
: <Copy size={13} />
|
| 276 |
+
}
|
| 277 |
+
</button>
|
| 278 |
+
</div>
|
| 279 |
+
<div className="code-block m-4 mt-3 text-text-secondary max-h-56 overflow-y-auto">
|
| 280 |
+
{codeMap[codeTab]}
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
)}
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
);
|
| 288 |
+
}
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
@layer base {
|
| 6 |
+
* {
|
| 7 |
+
box-sizing: border-box;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
html {
|
| 11 |
+
scroll-behavior: smooth;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
body {
|
| 15 |
+
background-color: #050709;
|
| 16 |
+
color: #e6edf3;
|
| 17 |
+
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
| 18 |
+
-webkit-font-smoothing: antialiased;
|
| 19 |
+
-moz-osx-font-smoothing: grayscale;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
::-webkit-scrollbar {
|
| 23 |
+
width: 6px;
|
| 24 |
+
height: 6px;
|
| 25 |
+
}
|
| 26 |
+
::-webkit-scrollbar-track {
|
| 27 |
+
background: #0d1117;
|
| 28 |
+
}
|
| 29 |
+
::-webkit-scrollbar-thumb {
|
| 30 |
+
background: #30363d;
|
| 31 |
+
border-radius: 3px;
|
| 32 |
+
}
|
| 33 |
+
::-webkit-scrollbar-thumb:hover {
|
| 34 |
+
background: #484f58;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
::selection {
|
| 38 |
+
background: rgba(0, 255, 135, 0.2);
|
| 39 |
+
color: #e6edf3;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
@layer components {
|
| 44 |
+
.glass {
|
| 45 |
+
background: rgba(13, 17, 23, 0.8);
|
| 46 |
+
backdrop-filter: blur(12px);
|
| 47 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.glow-green {
|
| 51 |
+
box-shadow: 0 0 20px rgba(0, 255, 135, 0.15);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.glow-cyan {
|
| 55 |
+
box-shadow: 0 0 20px rgba(0, 212, 255, 0.15);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.code-block {
|
| 59 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 60 |
+
font-size: 0.8rem;
|
| 61 |
+
background: #0d1117;
|
| 62 |
+
border: 1px solid #21262d;
|
| 63 |
+
border-radius: 6px;
|
| 64 |
+
padding: 1rem;
|
| 65 |
+
overflow-x: auto;
|
| 66 |
+
white-space: pre;
|
| 67 |
+
line-height: 1.6;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.badge {
|
| 71 |
+
display: inline-flex;
|
| 72 |
+
align-items: center;
|
| 73 |
+
gap: 4px;
|
| 74 |
+
font-size: 0.7rem;
|
| 75 |
+
font-weight: 500;
|
| 76 |
+
padding: 2px 8px;
|
| 77 |
+
border-radius: 999px;
|
| 78 |
+
text-transform: uppercase;
|
| 79 |
+
letter-spacing: 0.05em;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.badge-green {
|
| 83 |
+
background: rgba(0, 255, 135, 0.1);
|
| 84 |
+
color: #00ff87;
|
| 85 |
+
border: 1px solid rgba(0, 255, 135, 0.2);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.badge-cyan {
|
| 89 |
+
background: rgba(0, 212, 255, 0.1);
|
| 90 |
+
color: #00d4ff;
|
| 91 |
+
border: 1px solid rgba(0, 212, 255, 0.2);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.badge-purple {
|
| 95 |
+
background: rgba(168, 85, 247, 0.1);
|
| 96 |
+
color: #a855f7;
|
| 97 |
+
border: 1px solid rgba(168, 85, 247, 0.2);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.badge-orange {
|
| 101 |
+
background: rgba(255, 107, 53, 0.1);
|
| 102 |
+
color: #ff6b35;
|
| 103 |
+
border: 1px solid rgba(255, 107, 53, 0.2);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.badge-gray {
|
| 107 |
+
background: rgba(72, 79, 88, 0.3);
|
| 108 |
+
color: #8b949e;
|
| 109 |
+
border: 1px solid #30363d;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/* Used by ModelForm field labels */
|
| 113 |
+
.form-label {
|
| 114 |
+
display: block;
|
| 115 |
+
font-size: 0.75rem;
|
| 116 |
+
font-weight: 500;
|
| 117 |
+
color: #8b949e;
|
| 118 |
+
margin-bottom: 6px;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.btn-primary {
|
| 122 |
+
display: inline-flex;
|
| 123 |
+
align-items: center;
|
| 124 |
+
gap: 8px;
|
| 125 |
+
background: #00ff87;
|
| 126 |
+
color: #050709;
|
| 127 |
+
font-weight: 600;
|
| 128 |
+
font-size: 0.875rem;
|
| 129 |
+
padding: 8px 16px;
|
| 130 |
+
border-radius: 6px;
|
| 131 |
+
transition: all 0.15s ease;
|
| 132 |
+
cursor: pointer;
|
| 133 |
+
border: none;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.btn-primary:hover {
|
| 137 |
+
background: #00e07a;
|
| 138 |
+
transform: translateY(-1px);
|
| 139 |
+
box-shadow: 0 4px 12px rgba(0, 255, 135, 0.3);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.btn-primary:active {
|
| 143 |
+
transform: translateY(0);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.btn-primary:disabled {
|
| 147 |
+
opacity: 0.5;
|
| 148 |
+
cursor: not-allowed;
|
| 149 |
+
transform: none;
|
| 150 |
+
box-shadow: none;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.btn-secondary {
|
| 154 |
+
display: inline-flex;
|
| 155 |
+
align-items: center;
|
| 156 |
+
gap: 8px;
|
| 157 |
+
background: transparent;
|
| 158 |
+
color: #8b949e;
|
| 159 |
+
font-weight: 500;
|
| 160 |
+
font-size: 0.875rem;
|
| 161 |
+
padding: 7px 15px;
|
| 162 |
+
border-radius: 6px;
|
| 163 |
+
transition: all 0.15s ease;
|
| 164 |
+
cursor: pointer;
|
| 165 |
+
border: 1px solid #30363d;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.btn-secondary:hover {
|
| 169 |
+
background: #21262d;
|
| 170 |
+
color: #e6edf3;
|
| 171 |
+
border-color: #484f58;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.btn-danger {
|
| 175 |
+
display: inline-flex;
|
| 176 |
+
align-items: center;
|
| 177 |
+
gap: 8px;
|
| 178 |
+
background: transparent;
|
| 179 |
+
color: #ff4757;
|
| 180 |
+
font-weight: 500;
|
| 181 |
+
font-size: 0.875rem;
|
| 182 |
+
padding: 7px 15px;
|
| 183 |
+
border-radius: 6px;
|
| 184 |
+
transition: all 0.15s ease;
|
| 185 |
+
cursor: pointer;
|
| 186 |
+
border: 1px solid rgba(255, 71, 87, 0.3);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.btn-danger:hover {
|
| 190 |
+
background: rgba(255, 71, 87, 0.1);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.input {
|
| 194 |
+
width: 100%;
|
| 195 |
+
background: #0d1117;
|
| 196 |
+
border: 1px solid #30363d;
|
| 197 |
+
border-radius: 6px;
|
| 198 |
+
padding: 8px 12px;
|
| 199 |
+
color: #e6edf3;
|
| 200 |
+
font-size: 0.875rem;
|
| 201 |
+
font-family: inherit;
|
| 202 |
+
transition: border-color 0.15s ease;
|
| 203 |
+
outline: none;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.input:focus {
|
| 207 |
+
border-color: #00ff87;
|
| 208 |
+
box-shadow: 0 0 0 2px rgba(0, 255, 135, 0.1);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.input::placeholder {
|
| 212 |
+
color: #484f58;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.input-mono {
|
| 216 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 217 |
+
font-size: 0.8rem;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.select {
|
| 221 |
+
width: 100%;
|
| 222 |
+
background: #0d1117;
|
| 223 |
+
border: 1px solid #30363d;
|
| 224 |
+
border-radius: 6px;
|
| 225 |
+
padding: 8px 12px;
|
| 226 |
+
color: #e6edf3;
|
| 227 |
+
font-size: 0.875rem;
|
| 228 |
+
font-family: inherit;
|
| 229 |
+
outline: none;
|
| 230 |
+
cursor: pointer;
|
| 231 |
+
transition: border-color 0.15s ease;
|
| 232 |
+
appearance: none;
|
| 233 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
| 234 |
+
background-repeat: no-repeat;
|
| 235 |
+
background-position: right 12px center;
|
| 236 |
+
padding-right: 36px;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.select:focus {
|
| 240 |
+
border-color: #00ff87;
|
| 241 |
+
box-shadow: 0 0 0 2px rgba(0, 255, 135, 0.1);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.card {
|
| 245 |
+
background: #0d1117;
|
| 246 |
+
border: 1px solid #21262d;
|
| 247 |
+
border-radius: 8px;
|
| 248 |
+
transition: border-color 0.15s ease;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.card:hover {
|
| 252 |
+
border-color: #30363d;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.divider {
|
| 256 |
+
border: none;
|
| 257 |
+
border-top: 1px solid #21262d;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/* BUG FIX: Moved from ModelForm.jsx module-level style injection */
|
| 261 |
+
.form-label {
|
| 262 |
+
display: block;
|
| 263 |
+
font-size: 0.75rem;
|
| 264 |
+
font-weight: 500;
|
| 265 |
+
color: #8b949e;
|
| 266 |
+
margin-bottom: 6px;
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
@layer utilities {
|
| 271 |
+
.text-gradient-green {
|
| 272 |
+
background: linear-gradient(135deg, #00ff87, #00d4ff);
|
| 273 |
+
-webkit-background-clip: text;
|
| 274 |
+
-webkit-text-fill-color: transparent;
|
| 275 |
+
background-clip: text;
|
| 276 |
+
}
|
| 277 |
+
}
|
frontend/src/main.jsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import ReactDOM from "react-dom/client";
|
| 3 |
+
import App from "./App";
|
| 4 |
+
import "./index.css";
|
| 5 |
+
|
| 6 |
+
ReactDOM.createRoot(document.getElementById("root")).render(
|
| 7 |
+
<React.StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</React.StrictMode>
|
| 10 |
+
);
|
frontend/tailwind.config.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
export default {
|
| 3 |
+
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
| 4 |
+
theme: {
|
| 5 |
+
extend: {
|
| 6 |
+
fontFamily: {
|
| 7 |
+
sans: ["'IBM Plex Sans'", "system-ui", "sans-serif"],
|
| 8 |
+
mono: ["'IBM Plex Mono'", "monospace"],
|
| 9 |
+
display: ["'Space Grotesk'", "sans-serif"],
|
| 10 |
+
},
|
| 11 |
+
colors: {
|
| 12 |
+
surface: {
|
| 13 |
+
0: "#050709",
|
| 14 |
+
1: "#0d1117",
|
| 15 |
+
2: "#161b22",
|
| 16 |
+
3: "#21262d",
|
| 17 |
+
4: "#30363d",
|
| 18 |
+
},
|
| 19 |
+
accent: {
|
| 20 |
+
green: "#00ff87",
|
| 21 |
+
cyan: "#00d4ff",
|
| 22 |
+
purple: "#a855f7",
|
| 23 |
+
orange: "#ff6b35",
|
| 24 |
+
red: "#ff4757",
|
| 25 |
+
},
|
| 26 |
+
text: {
|
| 27 |
+
primary: "#e6edf3",
|
| 28 |
+
secondary: "#8b949e",
|
| 29 |
+
muted: "#484f58",
|
| 30 |
+
},
|
| 31 |
+
},
|
| 32 |
+
animation: {
|
| 33 |
+
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
| 34 |
+
"slide-in": "slideIn 0.2s ease-out",
|
| 35 |
+
"fade-in": "fadeIn 0.3s ease-out",
|
| 36 |
+
glow: "glow 2s ease-in-out infinite alternate",
|
| 37 |
+
},
|
| 38 |
+
keyframes: {
|
| 39 |
+
slideIn: {
|
| 40 |
+
from: { transform: "translateY(-8px)", opacity: "0" },
|
| 41 |
+
to: { transform: "translateY(0)", opacity: "1" },
|
| 42 |
+
},
|
| 43 |
+
fadeIn: {
|
| 44 |
+
from: { opacity: "0" },
|
| 45 |
+
to: { opacity: "1" },
|
| 46 |
+
},
|
| 47 |
+
glow: {
|
| 48 |
+
from: { boxShadow: "0 0 5px rgba(0,255,135,0.3)" },
|
| 49 |
+
to: { boxShadow: "0 0 20px rgba(0,255,135,0.6)" },
|
| 50 |
+
},
|
| 51 |
+
},
|
| 52 |
+
},
|
| 53 |
+
},
|
| 54 |
+
plugins: [],
|
| 55 |
+
};
|
frontend/vite.config.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vite";
|
| 2 |
+
import react from "@vitejs/plugin-react";
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
plugins: [react()],
|
| 6 |
+
server: {
|
| 7 |
+
port: 5173,
|
| 8 |
+
proxy: {
|
| 9 |
+
"/api": {
|
| 10 |
+
target: "http://localhost:3001",
|
| 11 |
+
changeOrigin: true,
|
| 12 |
+
},
|
| 13 |
+
"/v1": {
|
| 14 |
+
target: "http://localhost:4000",
|
| 15 |
+
changeOrigin: true,
|
| 16 |
+
},
|
| 17 |
+
},
|
| 18 |
+
},
|
| 19 |
+
build: {
|
| 20 |
+
outDir: "dist",
|
| 21 |
+
sourcemap: false,
|
| 22 |
+
rollupOptions: {
|
| 23 |
+
output: {
|
| 24 |
+
manualChunks: {
|
| 25 |
+
react: ["react", "react-dom"],
|
| 26 |
+
},
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
},
|
| 30 |
+
});
|
huggingface/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AI Gateway Hub
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Universal AI Gateway — any provider to OpenAI API
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# 🚀 AI Gateway Hub
|
| 14 |
+
|
| 15 |
+
> **Universal AI API Gateway** — Register any AI provider (OpenAI, Anthropic, Ollama, Gemini, Groq, and 100+ more) and get a single OpenAI-compatible endpoint. Powered by [LiteLLM](https://litellm.ai).
|
| 16 |
+
|
| 17 |
+
## ⚙️ Setup (Required Before First Use)
|
| 18 |
+
|
| 19 |
+
This Space needs a few secrets set before it will work correctly.
|
| 20 |
+
|
| 21 |
+
Go to **Space Settings → Repository secrets** and add:
|
| 22 |
+
|
| 23 |
+
| Secret | Required | Description |
|
| 24 |
+
|---|---|---|
|
| 25 |
+
| `LITELLM_MASTER_KEY` | ✅ Yes | Admin key for the LiteLLM proxy. Use a strong random string, e.g. `sk-gateway-xxxxxxxx` |
|
| 26 |
+
| `JWT_SECRET` | ✅ Yes | Secret for backend session tokens. Any long random string |
|
| 27 |
+
| `GATEWAY_PUBLIC_URL` | ✅ Yes | The public URL of this Space: `https://<your-username>-<space-name>.hf.space` |
|
| 28 |
+
| `OPENAI_API_KEY` | Optional | Required only if you register OpenAI models |
|
| 29 |
+
| `ANTHROPIC_API_KEY` | Optional | Required only if you register Anthropic models |
|
| 30 |
+
| `GROQ_API_KEY` | Optional | Required only if you register Groq models |
|
| 31 |
+
| `GEMINI_API_KEY` | Optional | Required only if you register Google Gemini models |
|
| 32 |
+
|
| 33 |
+
> **Tip:** `GATEWAY_PUBLIC_URL` is set automatically on the **first** deploy by the GitHub Actions workflow and is never overwritten afterward, so you can safely customise it to a custom domain later.
|
| 34 |
+
|
| 35 |
+
> **Tip:** Any provider API key you register through the UI is stored encrypted in the Space's SQLite database and never exposed to callers of the gateway.
|
| 36 |
+
|
| 37 |
+
## 🚀 Quick Start
|
| 38 |
+
|
| 39 |
+
1. After setting secrets above, wait for the Space to restart (it rebuilds automatically)
|
| 40 |
+
2. Open the Space — you'll see the **AI Gateway Hub** dashboard
|
| 41 |
+
3. Click **Add Model** and fill in your provider details
|
| 42 |
+
4. Use the generated `model name` with any OpenAI SDK:
|
| 43 |
+
|
| 44 |
+
```python
|
| 45 |
+
from openai import OpenAI
|
| 46 |
+
|
| 47 |
+
client = OpenAI(
|
| 48 |
+
base_url="https://<your-username>-<space-name>.hf.space/v1",
|
| 49 |
+
api_key="any-string", # callers don't need the real provider key
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
response = client.chat.completions.create(
|
| 53 |
+
model="anthropic/claude-3-5-sonnet-20241022", # alias shown in the UI
|
| 54 |
+
messages=[{"role": "user", "content": "Hello!"}],
|
| 55 |
+
)
|
| 56 |
+
print(response.choices[0].message.content)
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## 📡 API Endpoints
|
| 60 |
+
|
| 61 |
+
| Path | Description |
|
| 62 |
+
|---|---|
|
| 63 |
+
| `GET /` | Web dashboard |
|
| 64 |
+
| `POST /v1/chat/completions` | OpenAI-compatible chat (streaming supported) |
|
| 65 |
+
| `POST /v1/embeddings` | Text embeddings |
|
| 66 |
+
| `GET /v1/models` | List registered models |
|
| 67 |
+
| `GET /api/models` | Management API — list models |
|
| 68 |
+
| `POST /api/models` | Management API — register model |
|
| 69 |
+
| `GET /api/health` | Health check |
|
| 70 |
+
|
| 71 |
+
## 📦 Supported Providers
|
| 72 |
+
|
| 73 |
+
OpenAI · Anthropic · Google Gemini · Ollama · Groq · Mistral · Cohere · Together AI · AWS Bedrock · Azure OpenAI · HuggingFace · DeepSeek · Perplexity · Replicate · any OpenAI-compatible endpoint
|
| 74 |
+
|
| 75 |
+
Full list: [docs.litellm.ai/docs/providers](https://docs.litellm.ai/docs/providers)
|
| 76 |
+
|
| 77 |
+
## ⚠️ Important Notes
|
| 78 |
+
|
| 79 |
+
- **Persistence:** The SQLite database (`/app/data/gateway.db`) storing your registered models is **ephemeral** on the free CPU Basic hardware tier — it resets on Space restart. To persist data, upgrade to a paid hardware tier or use an external database.
|
| 80 |
+
- **Rate limits:** The free HF Spaces tier has CPU/memory limits. For production use, consider duplicating this Space with upgraded hardware.
|
| 81 |
+
- **Security:** This Space is public by default. Anyone can call your `/v1/*` endpoints. For private use, set the Space visibility to **Private** in Space Settings.
|
| 82 |
+
|
| 83 |
+
## 🔗 Source
|
| 84 |
+
|
| 85 |
+
This Space is automatically deployed from the [AI Gateway Hub GitHub repository](https://github.com/your-org/ai-gateway-hub).
|
| 86 |
+
|
| 87 |
+
> Replace `your-org/ai-gateway-hub` above with your actual repository URL.
|
huggingface/entrypoint.sh
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 3 |
+
# entrypoint.sh — container startup for Hugging Face Spaces
|
| 4 |
+
#
|
| 5 |
+
# Responsibilities:
|
| 6 |
+
# 1. Apply sensible defaults for any env vars not set via HF Space secrets
|
| 7 |
+
# 2. Ensure required directories exist and are writable (ephemeral /tmp)
|
| 8 |
+
# 3. Sanity-check required files baked into the image
|
| 9 |
+
# 4. Hand off to supervisord (which manages litellm, backend, nginx)
|
| 10 |
+
#
|
| 11 |
+
# Environment variables (set in HF Space → Settings → Repository secrets):
|
| 12 |
+
#
|
| 13 |
+
# LITELLM_MASTER_KEY Required. Admin key for LiteLLM proxy.
|
| 14 |
+
# JWT_SECRET Required. Secret for backend session tokens.
|
| 15 |
+
# GATEWAY_PUBLIC_URL Required. Public URL of this Space, e.g.
|
| 16 |
+
# https://<user>-<space>.hf.space
|
| 17 |
+
# (auto-set by the GitHub Actions workflow)
|
| 18 |
+
# DB_PATH Optional. SQLite path. Default: /app/data/gateway.db
|
| 19 |
+
# LOG_LEVEL Optional. Winston log level. Default: http
|
| 20 |
+
#
|
| 21 |
+
# Provider API keys (optional, set whichever you use):
|
| 22 |
+
# OPENAI_API_KEY, ANTHROPIC_API_KEY, GROQ_API_KEY, GEMINI_API_KEY, …
|
| 23 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 24 |
+
set -euo pipefail
|
| 25 |
+
|
| 26 |
+
echo "──────────────────────────────────────────"
|
| 27 |
+
echo " AI Gateway Hub — starting (HF Spaces)"
|
| 28 |
+
echo "──────────────────────────────────────────"
|
| 29 |
+
|
| 30 |
+
# ── 1. Defaults for optional / missing env vars ───────────────────────────────
|
| 31 |
+
|
| 32 |
+
# LITELLM_MASTER_KEY is critical — warn loudly if using the insecure default
|
| 33 |
+
if [ -z "${LITELLM_MASTER_KEY:-}" ]; then
|
| 34 |
+
export LITELLM_MASTER_KEY="sk-gateway-hf-insecure-change-me"
|
| 35 |
+
echo "⚠️ WARNING: LITELLM_MASTER_KEY not set. Using insecure default."
|
| 36 |
+
echo " Set it in: HF Space → Settings → Repository secrets"
|
| 37 |
+
fi
|
| 38 |
+
|
| 39 |
+
if [ -z "${JWT_SECRET:-}" ]; then
|
| 40 |
+
# Generate a random secret at runtime; sessions won't survive restarts
|
| 41 |
+
# but this is acceptable for a demo Space.
|
| 42 |
+
export JWT_SECRET
|
| 43 |
+
JWT_SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
| 44 |
+
echo "ℹ️ JWT_SECRET not set — generated an ephemeral secret for this session."
|
| 45 |
+
fi
|
| 46 |
+
|
| 47 |
+
export GATEWAY_PUBLIC_URL="${GATEWAY_PUBLIC_URL:-http://localhost:7860}"
|
| 48 |
+
export DB_PATH="${DB_PATH:-/app/data/gateway.db}"
|
| 49 |
+
export LOG_LEVEL="${LOG_LEVEL:-http}"
|
| 50 |
+
export NODE_ENV="${NODE_ENV:-production}"
|
| 51 |
+
|
| 52 |
+
echo "GATEWAY_PUBLIC_URL : ${GATEWAY_PUBLIC_URL}"
|
| 53 |
+
echo "DB_PATH : ${DB_PATH}"
|
| 54 |
+
echo "LOG_LEVEL : ${LOG_LEVEL}"
|
| 55 |
+
|
| 56 |
+
# ── 2. Create / verify writable directories ───────────────────────────────────
|
| 57 |
+
|
| 58 |
+
# /app/data persists the SQLite DB; may be ephemeral on free-tier HF Spaces.
|
| 59 |
+
mkdir -p "$(dirname "${DB_PATH}")"
|
| 60 |
+
|
| 61 |
+
# nginx needs these temp dirs when running as non-root
|
| 62 |
+
mkdir -p \
|
| 63 |
+
/tmp/nginx/client_body \
|
| 64 |
+
/tmp/nginx/proxy \
|
| 65 |
+
/tmp/nginx/fastcgi \
|
| 66 |
+
/tmp/nginx/uwsgi \
|
| 67 |
+
/tmp/nginx/scgi
|
| 68 |
+
|
| 69 |
+
# supervisor socket + pid files live in /tmp (non-root writable)
|
| 70 |
+
mkdir -p /tmp/supervisor
|
| 71 |
+
|
| 72 |
+
echo "✅ Directories ready"
|
| 73 |
+
|
| 74 |
+
# ── 3. Sanity-check required files baked into the image ──────────────────────
|
| 75 |
+
|
| 76 |
+
if [ ! -f "/app/litellm/config.yaml" ]; then
|
| 77 |
+
echo "❌ /app/litellm/config.yaml not found — aborting."
|
| 78 |
+
exit 1
|
| 79 |
+
fi
|
| 80 |
+
echo "✅ LiteLLM config found"
|
| 81 |
+
|
| 82 |
+
if [ ! -f "/app/frontend/dist/index.html" ]; then
|
| 83 |
+
echo "❌ Frontend build missing at /app/frontend/dist/index.html"
|
| 84 |
+
echo " This should have been built in the Docker image Stage 1."
|
| 85 |
+
exit 1
|
| 86 |
+
fi
|
| 87 |
+
echo "✅ Frontend build present"
|
| 88 |
+
|
| 89 |
+
# ── 4. Print startup summary ──────────────────────────────────────────────────
|
| 90 |
+
echo ""
|
| 91 |
+
echo "Starting services:"
|
| 92 |
+
echo " • LiteLLM proxy → localhost:4000"
|
| 93 |
+
echo " • Node.js backend → localhost:3001"
|
| 94 |
+
echo " • nginx (public) → 0.0.0.0:7860"
|
| 95 |
+
echo ""
|
| 96 |
+
echo "Access your gateway at: ${GATEWAY_PUBLIC_URL}"
|
| 97 |
+
echo "──────────────────────────────────────────"
|
| 98 |
+
|
| 99 |
+
# ── 5. Exec supervisord (replaces this script as PID 1) ──────────────────────
|
| 100 |
+
exec /usr/bin/supervisord -c /app/huggingface/supervisord.conf
|
huggingface/nginx.conf
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 2 |
+
# nginx configuration for Hugging Face Spaces (single-container deployment)
|
| 3 |
+
#
|
| 4 |
+
# Key differences from nginx/nginx.conf (multi-container docker-compose):
|
| 5 |
+
#
|
| 6 |
+
# 1. No `user` directive — nginx runs as the current user (uid 1000).
|
| 7 |
+
# The `user` directive requires root; omitting it defaults to the process owner.
|
| 8 |
+
#
|
| 9 |
+
# 2. All temp/pid paths redirected to /tmp — /var/run is root-owned.
|
| 10 |
+
#
|
| 11 |
+
# 3. Frontend served as static files directly from /app/frontend/dist —
|
| 12 |
+
# no upstream `frontend` container exists in the single-container layout.
|
| 13 |
+
#
|
| 14 |
+
# 4. Upstream addresses are localhost (127.0.0.1) not container hostnames.
|
| 15 |
+
#
|
| 16 |
+
# 5. Port 7860 — required by HF Spaces (set in huggingface/README.md).
|
| 17 |
+
#
|
| 18 |
+
# Bug fix applied:
|
| 19 |
+
# [Bug8] Added explicit `log_format gateway` with a [nginx] prefix so that
|
| 20 |
+
# nginx access log lines are distinguishable from litellm/backend logs
|
| 21 |
+
# when all three processes write to the same Docker log stream via
|
| 22 |
+
# supervisord stdout/stderr forwarding.
|
| 23 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
# No `user` directive — process runs as uid 1000 (set in Dockerfile USER)
|
| 26 |
+
worker_processes auto;
|
| 27 |
+
error_log /dev/stderr warn;
|
| 28 |
+
pid /tmp/nginx.pid;
|
| 29 |
+
|
| 30 |
+
events {
|
| 31 |
+
worker_connections 1024;
|
| 32 |
+
multi_accept on;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
http {
|
| 36 |
+
include /etc/nginx/mime.types;
|
| 37 |
+
default_type application/octet-stream;
|
| 38 |
+
|
| 39 |
+
# ── Writable temp dirs for non-root nginx ──────────────────────────────
|
| 40 |
+
client_body_temp_path /tmp/nginx/client_body;
|
| 41 |
+
proxy_temp_path /tmp/nginx/proxy;
|
| 42 |
+
fastcgi_temp_path /tmp/nginx/fastcgi;
|
| 43 |
+
uwsgi_temp_path /tmp/nginx/uwsgi;
|
| 44 |
+
scgi_temp_path /tmp/nginx/scgi;
|
| 45 |
+
|
| 46 |
+
# FIX [Bug8]: Named log format with [nginx] prefix so nginx access lines
|
| 47 |
+
# are distinguishable from litellm / backend lines in the merged Docker log
|
| 48 |
+
# stream produced by supervisord's stdout forwarding.
|
| 49 |
+
log_format gateway '[nginx] $remote_addr - "$request" $status $body_bytes_sent '
|
| 50 |
+
'"$http_referer" "$http_user_agent" ${request_time}s';
|
| 51 |
+
access_log /dev/stdout gateway;
|
| 52 |
+
|
| 53 |
+
sendfile on;
|
| 54 |
+
tcp_nopush on;
|
| 55 |
+
tcp_nodelay on;
|
| 56 |
+
keepalive_timeout 65;
|
| 57 |
+
|
| 58 |
+
gzip on;
|
| 59 |
+
gzip_vary on;
|
| 60 |
+
gzip_proxied any;
|
| 61 |
+
gzip_comp_level 6;
|
| 62 |
+
gzip_types
|
| 63 |
+
text/plain text/css text/xml text/javascript
|
| 64 |
+
application/json application/javascript application/xml+rss
|
| 65 |
+
application/atom+xml image/svg+xml;
|
| 66 |
+
|
| 67 |
+
server {
|
| 68 |
+
listen 7860;
|
| 69 |
+
server_name _;
|
| 70 |
+
|
| 71 |
+
client_max_body_size 100M;
|
| 72 |
+
proxy_read_timeout 300s;
|
| 73 |
+
proxy_connect_timeout 10s;
|
| 74 |
+
proxy_send_timeout 300s;
|
| 75 |
+
|
| 76 |
+
# ── LiteLLM OpenAI-compatible gateway ─────────────────────────────
|
| 77 |
+
location /v1/ {
|
| 78 |
+
proxy_pass http://127.0.0.1:4000;
|
| 79 |
+
proxy_http_version 1.1;
|
| 80 |
+
proxy_set_header Host $host;
|
| 81 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 82 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 83 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 84 |
+
proxy_set_header Connection "";
|
| 85 |
+
|
| 86 |
+
# SSE / streaming — must disable buffering end-to-end
|
| 87 |
+
proxy_buffering off;
|
| 88 |
+
proxy_cache off;
|
| 89 |
+
chunked_transfer_encoding on;
|
| 90 |
+
proxy_read_timeout 300s;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
# ── Backend management API ─────────────────────────────────────────
|
| 94 |
+
location /api/ {
|
| 95 |
+
proxy_pass http://127.0.0.1:3001;
|
| 96 |
+
proxy_http_version 1.1;
|
| 97 |
+
proxy_set_header Host $host;
|
| 98 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 99 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 100 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 101 |
+
proxy_set_header Connection "";
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
# ── Frontend SPA (served directly — no upstream container) ────────
|
| 105 |
+
# Vite build outputs to /app/frontend/dist.
|
| 106 |
+
# try_files handles client-side routing (React Router / direct URLs).
|
| 107 |
+
location / {
|
| 108 |
+
root /app/frontend/dist;
|
| 109 |
+
index index.html;
|
| 110 |
+
try_files $uri $uri/ /index.html;
|
| 111 |
+
|
| 112 |
+
# Short cache for HTML entry point (users always get fresh shell)
|
| 113 |
+
location = /index.html {
|
| 114 |
+
add_header Cache-Control "no-cache, must-revalidate";
|
| 115 |
+
expires 0;
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
# ── Vite content-hashed assets (cache aggressively) ───────────────
|
| 120 |
+
location /assets/ {
|
| 121 |
+
root /app/frontend/dist;
|
| 122 |
+
expires 1y;
|
| 123 |
+
add_header Cache-Control "public, immutable";
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
}
|
huggingface/supervisord.conf
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 2 |
+
# supervisord.conf — process manager for the HF Spaces single container
|
| 3 |
+
#
|
| 4 |
+
# Process start order (by priority value, lower = earlier):
|
| 5 |
+
# 10 litellm — AI proxy, must be up before backend tries to register models
|
| 6 |
+
# 20 backend — Node.js management API (has built-in retry for LiteLLM)
|
| 7 |
+
# 30 nginx — reverse proxy, last to start so upstreams are ready
|
| 8 |
+
#
|
| 9 |
+
# All stdout/stderr are forwarded to /dev/stdout and /dev/stderr so HF Spaces
|
| 10 |
+
# can capture logs in its built-in log viewer.
|
| 11 |
+
#
|
| 12 |
+
# Bug fixes applied:
|
| 13 |
+
# [Bug1] Removed `user=user` — already non-root via Dockerfile USER; adding
|
| 14 |
+
# it again causes supervisord to crash with "Can't drop privilege".
|
| 15 |
+
#
|
| 16 |
+
# [BugA] environment= values must be on a SINGLE LINE or with the value
|
| 17 |
+
# starting on the SAME LINE as the key (not on the next line).
|
| 18 |
+
#
|
| 19 |
+
# Python's ConfigParser (used by supervisord) parses:
|
| 20 |
+
# environment=
|
| 21 |
+
# KEY="val"
|
| 22 |
+
# as value = '\nKEY="val"' (leading newline).
|
| 23 |
+
#
|
| 24 |
+
# Supervisord's env parser then fails with:
|
| 25 |
+
# Error: Format string '\nKEY=...' for 'environment' is badly
|
| 26 |
+
# formatted: float argument required, not dict
|
| 27 |
+
# See: https://github.com/Supervisor/supervisor/issues/1123
|
| 28 |
+
#
|
| 29 |
+
# With the leading \n, supervisord REFUSES TO START — the whole
|
| 30 |
+
# container crashes at boot. Even if it didn't crash, LITELLM_BASE_URL
|
| 31 |
+
# would not be set, so backend/src/litellm.js would fall back to
|
| 32 |
+
# "http://litellm:4000" (Docker Compose hostname), which is
|
| 33 |
+
# unresolvable inside a single container. All LiteLLM API calls fail.
|
| 34 |
+
#
|
| 35 |
+
# FIX: Keep the entire value on one line after the = sign.
|
| 36 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 37 |
+
|
| 38 |
+
[supervisord]
|
| 39 |
+
nodaemon=true ; stay in foreground so Docker sees it as PID 1
|
| 40 |
+
logfile=/dev/null ; supervisord's own log → suppress (child logs are enough)
|
| 41 |
+
logfile_maxbytes=0
|
| 42 |
+
pidfile=/tmp/supervisord.pid ; /var/run is root-owned; use /tmp
|
| 43 |
+
; NOTE: do NOT add `user=` here. The container already runs as uid=1000 via
|
| 44 |
+
; Dockerfile USER. Adding `user=` when already non-root crashes supervisord.
|
| 45 |
+
|
| 46 |
+
[unix_http_server]
|
| 47 |
+
file=/tmp/supervisor.sock
|
| 48 |
+
|
| 49 |
+
[supervisorctl]
|
| 50 |
+
serverurl=unix:///tmp/supervisor.sock
|
| 51 |
+
|
| 52 |
+
[rpcinterface:supervisor]
|
| 53 |
+
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
|
| 54 |
+
|
| 55 |
+
# ── LiteLLM proxy ─────────────────────────────────────────────────────────────
|
| 56 |
+
[program:litellm]
|
| 57 |
+
command=litellm --config /app/litellm/config.yaml --port 4000 --num_workers 2
|
| 58 |
+
directory=/app
|
| 59 |
+
autostart=true
|
| 60 |
+
autorestart=true
|
| 61 |
+
startsecs=15 ; give LiteLLM time to initialise before health checks
|
| 62 |
+
startretries=5
|
| 63 |
+
priority=10
|
| 64 |
+
stdout_logfile=/dev/stdout
|
| 65 |
+
stdout_logfile_maxbytes=0
|
| 66 |
+
stderr_logfile=/dev/stderr
|
| 67 |
+
stderr_logfile_maxbytes=0
|
| 68 |
+
; All env vars (LITELLM_MASTER_KEY etc.) are inherited from entrypoint.sh via
|
| 69 |
+
; exec → supervisord env inheritance. No environment= needed here.
|
| 70 |
+
|
| 71 |
+
# ── Backend (Node.js management API) ──────────────────────────────────────────
|
| 72 |
+
[program:backend]
|
| 73 |
+
command=node /app/backend/src/index.js
|
| 74 |
+
directory=/app/backend
|
| 75 |
+
autostart=true
|
| 76 |
+
autorestart=true
|
| 77 |
+
startsecs=5
|
| 78 |
+
startretries=10
|
| 79 |
+
priority=20
|
| 80 |
+
stdout_logfile=/dev/stdout
|
| 81 |
+
stdout_logfile_maxbytes=0
|
| 82 |
+
stderr_logfile=/dev/stderr
|
| 83 |
+
stderr_logfile_maxbytes=0
|
| 84 |
+
; FIX [BugA]: environment= value MUST start on the same line as the key.
|
| 85 |
+
; Starting it on the next line causes supervisord to parse a leading \n, which
|
| 86 |
+
; makes its env formatter raise "badly formatted" and refuse to start.
|
| 87 |
+
;
|
| 88 |
+
; These three vars are always the same inside this container and different from
|
| 89 |
+
; the docker-compose defaults, so we override them explicitly. All other vars
|
| 90 |
+
; (LITELLM_MASTER_KEY, GATEWAY_PUBLIC_URL, JWT_SECRET, DB_PATH, LOG_LEVEL)
|
| 91 |
+
; are inherited from entrypoint.sh exports via supervisord's environment.
|
| 92 |
+
environment=LITELLM_BASE_URL="http://127.0.0.1:4000",PORT="3001",NODE_ENV="production"
|
| 93 |
+
|
| 94 |
+
# ── nginx reverse proxy ────────────────────────────────────────────────────────
|
| 95 |
+
[program:nginx]
|
| 96 |
+
command=/usr/sbin/nginx -c /app/huggingface/nginx.conf -g "daemon off;"
|
| 97 |
+
autostart=true
|
| 98 |
+
autorestart=true
|
| 99 |
+
startsecs=3
|
| 100 |
+
startretries=5
|
| 101 |
+
priority=30
|
| 102 |
+
stdout_logfile=/dev/stdout
|
| 103 |
+
stdout_logfile_maxbytes=0
|
| 104 |
+
stderr_logfile=/dev/stderr
|
| 105 |
+
stderr_logfile_maxbytes=0
|
litellm/config.yaml
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 2 |
+
# LiteLLM Gateway Configuration
|
| 3 |
+
# AI Gateway Hub — https://github.com/your-org/ai-gateway-hub
|
| 4 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 5 |
+
|
| 6 |
+
general_settings:
|
| 7 |
+
master_key: os.environ/LITELLM_MASTER_KEY
|
| 8 |
+
|
| 9 |
+
# NOTE: store_model_in_db is intentionally disabled.
|
| 10 |
+
# LiteLLM >= 1.x requires PostgreSQL for this feature; SQLite is NOT supported.
|
| 11 |
+
# AI Gateway Hub's own backend (Node.js + SQLite) persists model configs and
|
| 12 |
+
# re-registers them with LiteLLM on startup via POST /model/new.
|
| 13 |
+
# store_model_in_db: true ← requires postgres, keep commented out
|
| 14 |
+
|
| 15 |
+
# ─── BUG FIX #14: Removed `allow_model_access_groups_on_request: true` ───
|
| 16 |
+
#
|
| 17 |
+
# That key is NOT a valid LiteLLM general_settings field. It appears in no
|
| 18 |
+
# version of the official LiteLLM docs or source code under general_settings.
|
| 19 |
+
# LiteLLM silently ignores unknown keys in this block (Pydantic extra="ignore"),
|
| 20 |
+
# so the setting had zero effect — it was pure dead config that could mislead
|
| 21 |
+
# operators into believing access-group enforcement was active when it was not.
|
| 22 |
+
#
|
| 23 |
+
# If you need to restrict which models a virtual key can access, use
|
| 24 |
+
# LiteLLM's Virtual Keys + model access groups feature instead:
|
| 25 |
+
# https://docs.litellm.ai/docs/proxy/virtual_keys
|
| 26 |
+
# ─────────────────────────────────────────────────────────────────────────
|
| 27 |
+
|
| 28 |
+
litellm_settings:
|
| 29 |
+
# Drop unsupported parameters instead of raising errors
|
| 30 |
+
drop_params: true
|
| 31 |
+
# Return original response on errors
|
| 32 |
+
set_verbose: false
|
| 33 |
+
# Request timeout in seconds
|
| 34 |
+
request_timeout: 120
|
| 35 |
+
# Retry configuration
|
| 36 |
+
num_retries: 2
|
| 37 |
+
# NOTE: max_budget: 0 is intentionally absent — it would block all paid requests.
|
| 38 |
+
# NOTE: proxy_server_settings block is intentionally absent — it is not a valid
|
| 39 |
+
# top-level LiteLLM config key and is silently ignored.
|
| 40 |
+
# Callbacks belong here under litellm_settings, e.g.:
|
| 41 |
+
# success_callback: ["langfuse"]
|
| 42 |
+
# failure_callback: ["langfuse"]
|
| 43 |
+
|
| 44 |
+
router_settings:
|
| 45 |
+
# "simple-shuffle" is the correct default for a single-instance deployment.
|
| 46 |
+
# "usage-based-routing" requires a Redis backend for cross-process TPM/RPM
|
| 47 |
+
# tracking; this project has no Redis service in docker-compose.yml so that
|
| 48 |
+
# strategy would fail a Redis connection on every request.
|
| 49 |
+
routing_strategy: "simple-shuffle"
|
| 50 |
+
num_retries: 2
|
| 51 |
+
timeout: 120
|
| 52 |
+
retry_after: 5
|
| 53 |
+
|
| 54 |
+
# ─── Pre-configured model examples (users can add more via the UI) ───────────
|
| 55 |
+
model_list:
|
| 56 |
+
# ── Example: Ollama local models (no API key needed) ──────────────────────
|
| 57 |
+
- model_name: ollama/llama3
|
| 58 |
+
litellm_params:
|
| 59 |
+
model: ollama/llama3
|
| 60 |
+
api_base: "http://host.docker.internal:11434"
|
| 61 |
+
model_info:
|
| 62 |
+
description: "Llama 3 via local Ollama (no API key required)"
|
| 63 |
+
supports_function_calling: true
|
| 64 |
+
|
| 65 |
+
# ── Example: OpenAI-compatible custom endpoint ────────────────────────────
|
| 66 |
+
- model_name: custom/my-openai-compatible
|
| 67 |
+
litellm_params:
|
| 68 |
+
model: openai/gpt-3.5-turbo
|
| 69 |
+
api_base: "https://your-custom-endpoint.example.com/v1"
|
| 70 |
+
api_key: "optional-key-or-empty"
|
| 71 |
+
model_info:
|
| 72 |
+
description: "Custom OpenAI-compatible endpoint example"
|
| 73 |
+
supports_function_calling: true
|
nginx/nginx.conf
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
user nginx;
|
| 2 |
+
worker_processes auto;
|
| 3 |
+
error_log /var/log/nginx/error.log warn;
|
| 4 |
+
pid /var/run/nginx.pid;
|
| 5 |
+
|
| 6 |
+
events {
|
| 7 |
+
worker_connections 4096;
|
| 8 |
+
# NOTE: removed "use epoll" — Linux-only, breaks on macOS Docker Desktop.
|
| 9 |
+
multi_accept on;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
http {
|
| 13 |
+
include /etc/nginx/mime.types;
|
| 14 |
+
default_type application/octet-stream;
|
| 15 |
+
|
| 16 |
+
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
| 17 |
+
'$status $body_bytes_sent "$http_referer" '
|
| 18 |
+
'"$http_user_agent" rt=$request_time';
|
| 19 |
+
|
| 20 |
+
access_log /var/log/nginx/access.log main;
|
| 21 |
+
|
| 22 |
+
sendfile on;
|
| 23 |
+
tcp_nopush on;
|
| 24 |
+
tcp_nodelay on;
|
| 25 |
+
keepalive_timeout 65;
|
| 26 |
+
gzip on;
|
| 27 |
+
gzip_vary on;
|
| 28 |
+
gzip_proxied any;
|
| 29 |
+
gzip_comp_level 6;
|
| 30 |
+
gzip_types text/plain text/css text/xml application/json application/javascript
|
| 31 |
+
application/xml+rss application/atom+xml image/svg+xml;
|
| 32 |
+
|
| 33 |
+
# Rate limiting
|
| 34 |
+
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
|
| 35 |
+
limit_req_zone $binary_remote_addr zone=v1:10m rate=200r/m;
|
| 36 |
+
|
| 37 |
+
# Upstream definitions
|
| 38 |
+
upstream backend {
|
| 39 |
+
server backend:3001;
|
| 40 |
+
keepalive 32;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
upstream litellm {
|
| 44 |
+
server litellm:4000;
|
| 45 |
+
keepalive 64;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
upstream frontend {
|
| 49 |
+
server frontend:80;
|
| 50 |
+
keepalive 16;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
server {
|
| 54 |
+
listen 80;
|
| 55 |
+
server_name _;
|
| 56 |
+
|
| 57 |
+
client_max_body_size 100M;
|
| 58 |
+
proxy_read_timeout 300s;
|
| 59 |
+
proxy_connect_timeout 10s;
|
| 60 |
+
proxy_send_timeout 300s;
|
| 61 |
+
|
| 62 |
+
# ── OpenAI-compatible AI API endpoints (via LiteLLM) ──────────────
|
| 63 |
+
location /v1/ {
|
| 64 |
+
limit_req zone=v1 burst=50 nodelay;
|
| 65 |
+
|
| 66 |
+
proxy_pass http://litellm;
|
| 67 |
+
proxy_http_version 1.1;
|
| 68 |
+
proxy_set_header Host $host;
|
| 69 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 70 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 71 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 72 |
+
proxy_set_header Connection "";
|
| 73 |
+
|
| 74 |
+
# SSE / streaming support
|
| 75 |
+
proxy_buffering off;
|
| 76 |
+
proxy_cache off;
|
| 77 |
+
chunked_transfer_encoding on;
|
| 78 |
+
proxy_read_timeout 300s;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
# ── Backend Management API ────────────────────────────────────────
|
| 82 |
+
location /api/ {
|
| 83 |
+
limit_req zone=api burst=20 nodelay;
|
| 84 |
+
|
| 85 |
+
proxy_pass http://backend;
|
| 86 |
+
proxy_http_version 1.1;
|
| 87 |
+
proxy_set_header Host $host;
|
| 88 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 89 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 90 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 91 |
+
proxy_set_header Connection "";
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
# ── Frontend SPA ──────────────────────────────────────────────────
|
| 95 |
+
# BUG FIX #12: Removed Upgrade / Connection "upgrade" headers.
|
| 96 |
+
# The frontend is a static React SPA served by nginx — it does not use
|
| 97 |
+
# WebSockets. Sending Upgrade headers to a plain HTTP upstream is
|
| 98 |
+
# incorrect. Connection "" clears the hop-by-hop header and enables
|
| 99 |
+
# HTTP/1.1 keepalive to the upstream frontend container.
|
| 100 |
+
location / {
|
| 101 |
+
proxy_pass http://frontend;
|
| 102 |
+
proxy_http_version 1.1;
|
| 103 |
+
proxy_set_header Host $host;
|
| 104 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 105 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 106 |
+
proxy_set_header Connection "";
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
}
|
scripts/setup.sh
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 3 |
+
# AI Gateway Hub — Quick Setup Script
|
| 4 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 5 |
+
set -euo pipefail
|
| 6 |
+
|
| 7 |
+
BOLD="\033[1m"
|
| 8 |
+
GREEN="\033[32m"
|
| 9 |
+
CYAN="\033[36m"
|
| 10 |
+
YELLOW="\033[33m"
|
| 11 |
+
RED="\033[31m"
|
| 12 |
+
RESET="\033[0m"
|
| 13 |
+
|
| 14 |
+
header() { echo -e "\n${BOLD}${CYAN}▶ $1${RESET}"; }
|
| 15 |
+
ok() { echo -e " ${GREEN}✓${RESET} $1"; }
|
| 16 |
+
warn() { echo -e " ${YELLOW}⚠${RESET} $1"; }
|
| 17 |
+
err() { echo -e " ${RED}✗${RESET} $1"; exit 1; }
|
| 18 |
+
|
| 19 |
+
echo -e "${BOLD}${GREEN}"
|
| 20 |
+
cat <<'EOF'
|
| 21 |
+
_ _____ _____ _ _ _ _
|
| 22 |
+
/_\ |_ _| | __ \ | | | | | | | |
|
| 23 |
+
/ _ \ | | | | \/ __ _| |_ _____ ____ _ _ _| |_| |_ _| |__
|
| 24 |
+
/ ___ \ | | | | __ / _` | __/ _ \ \ /\ / / _` | | | | _ | | | | '_ \
|
| 25 |
+
\/_/\_\|_| | |_\ \ (_| | || __/\ V V / (_| | |_| | | | | |_| | |_) |
|
| 26 |
+
\____/\__,_|\__\___| \_/\_/ \__,_|\__, \_| |_/\__,_|_.__/
|
| 27 |
+
__/ |
|
| 28 |
+
|___/
|
| 29 |
+
EOF
|
| 30 |
+
echo -e "${RESET}"
|
| 31 |
+
|
| 32 |
+
header "Checking prerequisites"
|
| 33 |
+
command -v docker &>/dev/null || err "Docker is not installed. https://docs.docker.com/get-docker/"
|
| 34 |
+
docker compose version &>/dev/null 2>&1 || \
|
| 35 |
+
docker-compose version &>/dev/null 2>&1 || err "Docker Compose is not installed."
|
| 36 |
+
ok "Docker $(docker --version | awk '{print $3}' | tr -d ',')"
|
| 37 |
+
ok "Docker Compose available"
|
| 38 |
+
|
| 39 |
+
header "Setting up environment"
|
| 40 |
+
if [ ! -f .env ]; then
|
| 41 |
+
cp .env.example .env
|
| 42 |
+
|
| 43 |
+
# BUG FIX #11: The original fallback used `xxd` which is not present in many
|
| 44 |
+
# minimal Linux images (Alpine, BusyBox-based). Replaced with `od` which is
|
| 45 |
+
# POSIX-standard and universally available. The `openssl rand -hex` path is
|
| 46 |
+
# still the primary method; `od` is only the fallback.
|
| 47 |
+
gen_hex() {
|
| 48 |
+
local bytes=$1
|
| 49 |
+
openssl rand -hex "$bytes" 2>/dev/null || \
|
| 50 |
+
od -An -N"$bytes" -tx1 /dev/urandom 2>/dev/null | tr -d ' \n'
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
MASTER_KEY="sk-gateway-$(gen_hex 16)"
|
| 54 |
+
JWT_SECRET="$(gen_hex 32)"
|
| 55 |
+
|
| 56 |
+
sed -i.bak "s/sk-gateway-master-key-change-me/${MASTER_KEY}/" .env
|
| 57 |
+
sed -i.bak "s/super-secret-jwt-key-change-in-production/${JWT_SECRET}/" .env
|
| 58 |
+
rm -f .env.bak
|
| 59 |
+
ok ".env created with random secrets"
|
| 60 |
+
else
|
| 61 |
+
warn ".env already exists — skipping"
|
| 62 |
+
fi
|
| 63 |
+
|
| 64 |
+
header "Creating SSL directory"
|
| 65 |
+
mkdir -p nginx/ssl
|
| 66 |
+
ok "nginx/ssl/ created"
|
| 67 |
+
|
| 68 |
+
header "Building and starting services"
|
| 69 |
+
docker compose pull litellm 2>/dev/null || true
|
| 70 |
+
docker compose build --parallel
|
| 71 |
+
docker compose up -d
|
| 72 |
+
|
| 73 |
+
header "Waiting for services to be healthy"
|
| 74 |
+
echo -n " Waiting"
|
| 75 |
+
for i in $(seq 1 30); do
|
| 76 |
+
sleep 2
|
| 77 |
+
echo -n "."
|
| 78 |
+
if curl -sf http://localhost/api/health &>/dev/null; then
|
| 79 |
+
echo ""
|
| 80 |
+
ok "Gateway is ready!"
|
| 81 |
+
break
|
| 82 |
+
fi
|
| 83 |
+
if [ "$i" -eq 30 ]; then
|
| 84 |
+
echo ""
|
| 85 |
+
warn "Timeout waiting for health check. Check logs: docker compose logs"
|
| 86 |
+
fi
|
| 87 |
+
done
|
| 88 |
+
|
| 89 |
+
echo ""
|
| 90 |
+
echo -e "${BOLD}${GREEN}═══════════════════════════════════════════════════${RESET}"
|
| 91 |
+
echo -e "${BOLD} 🚀 AI Gateway Hub is running!${RESET}"
|
| 92 |
+
echo ""
|
| 93 |
+
echo -e " ${CYAN}Web UI:${RESET} http://localhost"
|
| 94 |
+
echo -e " ${CYAN}API Endpoint:${RESET} http://localhost/v1"
|
| 95 |
+
echo -e " ${CYAN}Management API:${RESET} http://localhost/api"
|
| 96 |
+
echo ""
|
| 97 |
+
echo -e " ${YELLOW}Next steps:${RESET}"
|
| 98 |
+
echo -e " 1. Open http://localhost in your browser"
|
| 99 |
+
echo -e " 2. Click 'Add Model' to register your first AI model"
|
| 100 |
+
echo -e " 3. Use the generated endpoint with any OpenAI SDK"
|
| 101 |
+
echo -e "${BOLD}${GREEN}═══════════════════════════════════════════════════${RESET}"
|