github-actions[bot] commited on
Commit
e1d8498
·
1 Parent(s): 8f35d19

deploy: b1de43e — 更新 README.md

Browse files
.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: LitellmGUI
3
- emoji: 📚
4
- colorFrom: blue
5
- colorTo: gray
6
  sdk: docker
 
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}"