mirror of
https://github.com/whit3rabbit/anyllm-proxy.git
synced 2026-09-22 00:00:50 +00:00
feat: model persistence, model discovery, config directory, and admin UI updates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3980985b17
commit
dde1f902b6
@@ -37,6 +37,9 @@ All implementation phases are complete.
|
||||
- Security hardening: plaintext HTTP startup warning, 1MB admin body limit, CSP header, model name validation
|
||||
- Security fixes (2026-03-30 audit): `AWS_ACCESS_KEY_ID`/`GOOGLE_ACCESS_TOKEN` redacted in env endpoint; admin rate limiter uses sliding window; all audit entries include `source_ip`; OIDC discovery and webhook callbacks use SSRF-safe HTTP client and validate URLs against private IP ranges; CSRF public-route decision documented; non-Unix token file warning already present
|
||||
- Admin rate limiter: 10 RPM per source IP (60-second sliding window, in-memory). Resets on process restart. `set_admin_rpm()` overrides the limit for tests.
|
||||
- Model persistence: models added via admin API stored in SQLite `model_deployment` table, survive restarts; YAML config models loaded first, admin-added models merged on top
|
||||
- Model discovery: `POST /admin/api/models/discover` fetches available models from providers (OpenRouter, DeepInfra public; Ollama local; configured backend with key). Admin UI has discover section on Models tab.
|
||||
- Config directory: `~/.anyllm/` stores admin.db, .admin_token, .anyllm.env, config.yaml by default. Override with `ANYLLM_HOME` env var or individual file env vars.
|
||||
- Model mapping and lossy-translation warnings
|
||||
- `POST /v1/embeddings` passthrough: forwards directly to the backend with no translation; works with OpenAI, Vertex, Gemini (`gemini-embedding-exp-03-07`), and vLLM/HuggingFace models. Not mounted for the Anthropic passthrough backend.
|
||||
- `x-anyllm-degradation` response header: set when features are silently dropped during translation (opt-in via `ANYLLM_DEGRADATION_WARNINGS=true`; auto-enabled when `PROXY_CONFIG` is set). Examples: `top_k`, `thinking_config`, `cache_control`, `document_blocks`, `stop_sequences_truncated`
|
||||
@@ -70,8 +73,8 @@ docker compose up
|
||||
Key Docker env vars:
|
||||
- `WEBUI=1` or `ADMIN=1`: enable admin UI (also requires `ADMIN_BIND=0.0.0.0` when in Docker)
|
||||
- `ADMIN_BIND`: bind address for admin server (default `127.0.0.1`; set `0.0.0.0` in Docker)
|
||||
- `ADMIN_DB_PATH`: SQLite path (default `admin.db` in CWD; compose sets `/data/admin.db`)
|
||||
- `ADMIN_TOKEN_PATH`: where the auto-generated admin token is written (compose sets `/data/.admin_token`)
|
||||
- `ADMIN_DB_PATH`: SQLite path (default `~/.anyllm/admin.db`; compose sets `/data/admin.db`)
|
||||
- `ADMIN_TOKEN_PATH`: where the auto-generated admin token is written (default `~/.anyllm/.admin_token`; compose sets `/data/.admin_token`)
|
||||
|
||||
CI: `.github/workflows/docker.yml` builds linux/amd64 + linux/arm64 on native runners, merges into a multi-arch manifest. Requires `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` secrets in the repo.
|
||||
|
||||
@@ -105,6 +108,31 @@ Package contents:
|
||||
|
||||
After installing: `sudo systemctl enable --now anyllm-proxy`, then edit `/etc/default/anyllm-proxy` with your API keys.
|
||||
|
||||
## Config Directory
|
||||
|
||||
Data lives in `~/.anyllm/` by default. Override with `ANYLLM_HOME` or per-file env vars.
|
||||
|
||||
```
|
||||
~/.anyllm/
|
||||
admin.db SQLite (keys, models, audit, env imports)
|
||||
.admin_token Auto-generated admin auth token
|
||||
.anyllm.env Environment file (optional, auto-loaded)
|
||||
config.yaml Proxy config (optional, auto-detected)
|
||||
```
|
||||
|
||||
Lookup order (first match wins):
|
||||
|
||||
| File | 1st | 2nd | 3rd |
|
||||
|------|-----|-----|-----|
|
||||
| Env file | `--env-file` flag | CWD `.anyllm.env` | `~/.anyllm/.anyllm.env` |
|
||||
| Config | `PROXY_CONFIG` env | `~/.anyllm/config.yaml` | -- |
|
||||
| Database | `ADMIN_DB_PATH` env | `~/.anyllm/admin.db` | -- |
|
||||
| Token | `ADMIN_TOKEN_PATH` env | `~/.anyllm/.admin_token` | -- |
|
||||
|
||||
Docker sets explicit paths (`/data/admin.db`, `/data/.admin_token`) via env vars, so the home directory convention does not apply in containers.
|
||||
|
||||
See [docs/CONFIG.md](docs/CONFIG.md) for full details.
|
||||
|
||||
## Build and Test
|
||||
|
||||
```bash
|
||||
@@ -128,6 +156,7 @@ OPENAI_API_KEY=sk-... cargo run -p anyllm_proxy
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `ANYLLM_HOME`: Override the data directory (default: `~/.anyllm`). All default file paths resolve relative to this directory.
|
||||
- `BACKEND`: Backend provider: `openai` (default), `azure`, `vertex`, `gemini`, `anthropic` (passthrough), or `bedrock` (SigV4-signed, Anthropic format)
|
||||
- `OPENAI_API_KEY`: OpenAI API key (required when BACKEND=openai, empty default)
|
||||
- `OPENAI_BASE_URL`: OpenAI base URL (default: `https://api.openai.com`)
|
||||
@@ -135,8 +164,8 @@ OPENAI_API_KEY=sk-... cargo run -p anyllm_proxy
|
||||
- `LISTEN_PORT`: Server port (default: `3000`)
|
||||
- `ADMIN_PORT`: Admin server port (default: `3001`; must differ from `LISTEN_PORT`)
|
||||
- `ADMIN_BIND`: Admin server bind address (default: `127.0.0.1`; set `0.0.0.0` in Docker)
|
||||
- `ADMIN_DB_PATH`: SQLite database path (default: `admin.db` in CWD)
|
||||
- `ADMIN_TOKEN_PATH`: Path for auto-generated admin token file (default: `.admin_token` in CWD)
|
||||
- `ADMIN_DB_PATH`: SQLite database path (default: `~/.anyllm/admin.db`)
|
||||
- `ADMIN_TOKEN_PATH`: Path for auto-generated admin token file (default: `~/.anyllm/.admin_token`)
|
||||
- `DISABLE_ADMIN`: Set to `1` to force-disable admin UI even when `--webui` flag is passed
|
||||
- `BIG_MODEL`: Backend model for sonnet/opus requests (default: `gpt-4o` for OpenAI, `gemini-2.5-pro` for Vertex/Gemini)
|
||||
- `SMALL_MODEL`: Backend model for haiku requests (default: `gpt-4o-mini` for OpenAI, `gemini-2.5-flash` for Vertex/Gemini)
|
||||
|
||||
@@ -14,7 +14,7 @@ Download a binary from the [releases page](https://github.com/whit3rabbit/anyllm
|
||||
cargo install anyllm_proxy
|
||||
```
|
||||
|
||||
Create a `.anyllm.env` config file:
|
||||
Create a `.anyllm.env` config file in `~/.anyllm/` (or the current directory):
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=unused
|
||||
@@ -23,7 +23,7 @@ BIG_MODEL=qwen2.5-coder:32b
|
||||
SMALL_MODEL=qwen2.5-coder:32b
|
||||
```
|
||||
|
||||
Run the proxy (auto-loads `.anyllm.env` from the current directory):
|
||||
Run the proxy (auto-loads `.anyllm.env` from `~/.anyllm/` or the current directory):
|
||||
|
||||
```bash
|
||||
anyllm_proxy
|
||||
@@ -36,7 +36,7 @@ anyllm_proxy
|
||||
|---|---|---|
|
||||
| **Config** | 3 env vars or `.anyllm.env` | `config.toml` / `config.yaml` |
|
||||
| **Routing** | Single backend | Multi-backend with path prefixes |
|
||||
| **Admin UI** | Not started | `--webui` flag |
|
||||
| **Admin UI** | `--webui` (guided setup if no config) | `--webui` (full dashboard) |
|
||||
| **Translation warnings** | Silent (never exposed to clients) | `x-anyllm-degradation` header active |
|
||||
| **How to enable** | Default | Pass `--webui`, set `PROXY_CONFIG`, or `ANYLLM_DEGRADATION_WARNINGS=true` |
|
||||
|
||||
@@ -59,20 +59,22 @@ Pass `--webui` (or `--admin`) to start the admin dashboard alongside the proxy:
|
||||
```bash
|
||||
anyllm_proxy --webui
|
||||
# Proxy: http://localhost:3000
|
||||
# Admin UI: http://127.0.0.1:3001/admin/?token=$(cat .admin_token)
|
||||
# Admin UI: http://127.0.0.1:3001/admin/?token=$(cat ~/.anyllm/.admin_token)
|
||||
```
|
||||
|
||||
If no backend is configured, the UI opens on the **Settings** tab with a getting-started guide and env file import.
|
||||
|
||||
The admin server binds to `127.0.0.1:3001` by default (localhost only). The dashboard tabs:
|
||||
|
||||
- **Dashboard:** Live RPM, error rate, P50/P95 latency, per-backend cards, filterable live request feed.
|
||||
- **Request Log:** Historical log with filters (backend, status, key, date range), paginated, with per-request cost and token detail.
|
||||
- **Access Control:** Virtual key CRUD — create, edit (RPM/TPM limits, budget, expiry, model allowlist), revoke without restarting.
|
||||
- **Backends:** Configured backends and their status.
|
||||
- **Models:** Add/remove model routing deployments (LiteLLM config mode only).
|
||||
- **Models:** Discover models from providers (OpenRouter, DeepInfra, Ollama, or configured backend), add/remove deployments. Changes are persisted to SQLite and survive restarts.
|
||||
- **Audit:** All admin config mutations and key lifecycle events.
|
||||
- **Settings:** Mutable config (log level, log_bodies, model mappings), read-only env vars (secrets masked), **Export .env** to generate a `.anyllm.env` template.
|
||||
- **Settings:** Mutable config (log level, log_bodies, model mappings), read-only env vars (secrets masked), **Import/Export .anyllm.env**. Shows a getting-started guide when no backend is configured.
|
||||
|
||||
**Token:** On first start an admin token is auto-generated and written to `.admin_token`. Pass it as `?token=` in the URL or `Authorization: Bearer` for API calls. To set a fixed token instead:
|
||||
**Token:** On first start an admin token is auto-generated and written to `~/.anyllm/.admin_token`. Pass it as `?token=` in the URL or `Authorization: Bearer` for API calls. To set a fixed token instead:
|
||||
|
||||
```bash
|
||||
ADMIN_TOKEN=mysecret anyllm_proxy --webui
|
||||
@@ -104,14 +106,33 @@ docker compose up
|
||||
| `ADMIN_PORT` | `3001` | Admin server port |
|
||||
| `ADMIN_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` in Docker) |
|
||||
| `ADMIN_TOKEN` | auto-generated | Fixed token (min 32 chars recommended) |
|
||||
| `ADMIN_TOKEN_PATH` | `.admin_token` | Where the auto-generated token is written |
|
||||
| `ADMIN_DB_PATH` | `admin.db` | SQLite database path |
|
||||
| `ADMIN_TOKEN_PATH` | `~/.anyllm/.admin_token` | Where the auto-generated token is written |
|
||||
| `ADMIN_DB_PATH` | `~/.anyllm/admin.db` | SQLite database path |
|
||||
| `ANYLLM_HOME` | `~/.anyllm` | Data directory for all default file paths |
|
||||
| `ADMIN_LOG_RETENTION_DAYS` | `7` | Request log retention |
|
||||
| `DISABLE_ADMIN` | — | Set to `1` to force-disable |
|
||||
| `WEBUI` / `ADMIN` | — | Docker entrypoint shorthand for `--webui` |
|
||||
|
||||
**CSRF:** State-mutating admin API calls (POST/PUT/DELETE) require an `X-CSRF-Token` header. Fetch a one-time token from `GET /admin/csrf-token` before each mutating request. The SPA handles this automatically; scripts must do it explicitly. Admin endpoints are rate-limited to 10 requests/minute per IP.
|
||||
|
||||
### Config Directory
|
||||
|
||||
All data files live in `~/.anyllm/` by default. The directory is created on first run.
|
||||
|
||||
```
|
||||
~/.anyllm/
|
||||
admin.db SQLite (keys, models, audit, env imports)
|
||||
.admin_token Auto-generated admin auth token
|
||||
.anyllm.env Environment file (auto-loaded if present)
|
||||
config.yaml Proxy config (auto-detected if present)
|
||||
```
|
||||
|
||||
Override the directory with `ANYLLM_HOME=/path/to/dir`, or override individual files with `ADMIN_DB_PATH`, `ADMIN_TOKEN_PATH`, `--env-file`, or `PROXY_CONFIG`.
|
||||
|
||||
The proxy looks for `.anyllm.env` in three places (first match wins): `--env-file` flag, then the current directory, then `~/.anyllm/`. Similarly, `config.yaml` is auto-detected in `~/.anyllm/` when `PROXY_CONFIG` is not set.
|
||||
|
||||
Docker Compose sets explicit paths (`/data/admin.db`, `/data/.admin_token`) so the home directory convention does not apply in containers. See [docs/CONFIG.md](docs/CONFIG.md) for full details.
|
||||
|
||||
## Advanced Mode
|
||||
|
||||
### Multiple backends on one proxy (recommended)
|
||||
@@ -361,10 +382,10 @@ ANTHROPIC_BASE_URL=http://localhost:3000/deepseek_api claude
|
||||
|
||||
### The Admin Dashboard
|
||||
|
||||
See [Admin Web Interface](#admin-web-interface-optional) for the full reference. When using a LiteLLM config, the **Models** tab lets you add/remove deployments live. All mutations are recorded in the **Audit** tab.
|
||||
See [Admin Web Interface](#admin-web-interface-optional) for the full reference. The **Models** tab lets you discover models from providers and add/remove deployments (persisted to SQLite). All mutations are recorded in the **Audit** tab.
|
||||
|
||||
```bash
|
||||
open http://127.0.0.1:3001/admin/?token=$(cat .admin_token)
|
||||
open http://127.0.0.1:3001/admin/?token=$(cat ~/.anyllm/.admin_token)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -457,7 +478,7 @@ Create short-lived, rate-limited, or budget-capped API keys without restarting t
|
||||
```bash
|
||||
# Create a key with RPM/TPM limits, a monthly budget, and a model allowlist
|
||||
curl -X POST http://localhost:3001/admin/api/keys \
|
||||
-H "Authorization: Bearer $(cat .admin_token)" \
|
||||
-H "Authorization: Bearer $(cat ~/.anyllm/.admin_token)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"description": "dev key",
|
||||
@@ -477,17 +498,17 @@ curl http://localhost:3000/v1/messages \
|
||||
|
||||
# Update limits on an existing key (no restart needed)
|
||||
curl -X PUT http://localhost:3001/admin/api/keys/1 \
|
||||
-H "Authorization: Bearer $(cat .admin_token)" \
|
||||
-H "Authorization: Bearer $(cat ~/.anyllm/.admin_token)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"rpm_limit": 120, "max_budget_usd": 20.00}'
|
||||
|
||||
# Check spend for a key
|
||||
curl http://localhost:3001/admin/api/keys/1/spend \
|
||||
-H "Authorization: Bearer $(cat .admin_token)"
|
||||
-H "Authorization: Bearer $(cat ~/.anyllm/.admin_token)"
|
||||
|
||||
# Revoke immediately (no restart needed)
|
||||
curl -X DELETE http://localhost:3001/admin/api/keys/1 \
|
||||
-H "Authorization: Bearer $(cat .admin_token)"
|
||||
-H "Authorization: Bearer $(cat ~/.anyllm/.admin_token)"
|
||||
```
|
||||
|
||||
`budget_duration` accepts `daily`, `monthly`, or `lifetime`. `allowed_models` supports exact names and `prefix/*` wildcards. A key at 100% of its budget returns 429 with period reset information. Webhook notifications fire at 80%, 95%, and 100% of the budget via `WEBHOOK_URLS`.
|
||||
|
||||
+14
-10
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useAuthStore } from './store/auth'
|
||||
import { useWsStore } from './store/ws'
|
||||
import { connectWs, disconnectWs } from './api/websocket'
|
||||
import { useStatus } from './api/queries'
|
||||
import LoginPage from './components/layout/LoginPage'
|
||||
import Nav from './components/layout/Nav'
|
||||
import Dashboard from './tabs/dashboard/Dashboard'
|
||||
@@ -24,6 +25,7 @@ export default function App() {
|
||||
const qc = useQueryClient()
|
||||
const [activeTab, setActiveTab] = useState<Tab>('dashboard')
|
||||
const [bootstrapping, setBootstrapping] = useState(true)
|
||||
const { data: status } = useStatus(!!token)
|
||||
|
||||
// On mount: if ?token= is in the URL and no token is stored, validate and log in.
|
||||
useEffect(() => {
|
||||
@@ -54,6 +56,13 @@ export default function App() {
|
||||
}
|
||||
}, [token])
|
||||
|
||||
// Default to the Settings tab on first load when nothing is configured.
|
||||
useEffect(() => {
|
||||
if (token && status && !status.configured) {
|
||||
setActiveTab('settings')
|
||||
}
|
||||
}, [status?.configured, token]) // eslint-disable-line react-hooks/exhaustive-deps -- run when status first arrives
|
||||
|
||||
// Invalidate query cache on relevant WS events.
|
||||
useEffect(() => {
|
||||
if (!lastEvent) return
|
||||
@@ -73,7 +82,7 @@ export default function App() {
|
||||
<div className="tab-content">
|
||||
{activeTab === 'dashboard' && <Dashboard />}
|
||||
{activeTab === 'requests' && <RequestLog />}
|
||||
{activeTab === 'settings' && <Settings />}
|
||||
{activeTab === 'settings' && <Settings configured={status?.configured ?? true} />}
|
||||
{activeTab === 'backends' && <Backends />}
|
||||
{activeTab === 'keys' && <Keys />}
|
||||
{activeTab === 'models' && <Models />}
|
||||
|
||||
@@ -5,9 +5,20 @@ import type {
|
||||
Metrics, RequestsResponse, VirtualKey, KeySpend,
|
||||
Backend, ConfigResponse, ObservabilityResponse,
|
||||
ModelsResponse, AuditResponse, TrafficResponse, UptimeResponse,
|
||||
EnvImportResponse,
|
||||
EnvImportResponse, ProxyStatus, DiscoverResponse,
|
||||
} from './types'
|
||||
|
||||
// ── Status ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useStatus(enabled = true) {
|
||||
return useQuery<ProxyStatus>({
|
||||
queryKey: ['status'],
|
||||
queryFn: () => apiFetch('/admin/api/status'),
|
||||
enabled,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Dashboard ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useMetrics() {
|
||||
@@ -171,6 +182,13 @@ export function useRemoveModel() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useDiscoverModels() {
|
||||
return useMutation<DiscoverResponse, Error, { source: string; url?: string }>({
|
||||
mutationFn: (body) =>
|
||||
mutatingFetch<DiscoverResponse>('POST', '/admin/api/models/discover', body),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Audit ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useAudit(params: { page: number; page_size: number }) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Mirrors the JSON shapes returned by /admin/api/* endpoints.
|
||||
// Keep in sync with Rust structs in crates/proxy/src/admin/state.rs and routes/.
|
||||
|
||||
export interface ProxyStatus {
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
export interface Metrics {
|
||||
total_requests: number
|
||||
successful_requests: number
|
||||
@@ -140,6 +144,17 @@ export interface ModelsResponse {
|
||||
routing_strategy: string
|
||||
}
|
||||
|
||||
export interface DiscoveredModel {
|
||||
id: string
|
||||
name: string | null
|
||||
}
|
||||
|
||||
export interface DiscoverResponse {
|
||||
models: DiscoveredModel[]
|
||||
source: string
|
||||
auth_used: boolean
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number
|
||||
timestamp: string
|
||||
|
||||
@@ -1,17 +1,123 @@
|
||||
import { useState } from 'react'
|
||||
import { useModels, useAddModel, useRemoveModel } from '../../api/queries'
|
||||
import { useModels, useAddModel, useRemoveModel, useDiscoverModels } from '../../api/queries'
|
||||
import EmptyState from '../../components/shared/EmptyState'
|
||||
|
||||
const AUTH_HINTS: Record<string, { text: string; needsKey: boolean }> = {
|
||||
openrouter: { text: 'Public, no key needed', needsKey: false },
|
||||
deepinfra: { text: 'Public, no key needed', needsKey: false },
|
||||
ollama: { text: 'No key needed (local)', needsKey: false },
|
||||
configured: { text: 'API key required', needsKey: true },
|
||||
custom: { text: 'API key may be required', needsKey: true },
|
||||
}
|
||||
|
||||
// Inline SVG key icon (12x12), used as auth indicator next to sources that need a key.
|
||||
function KeyIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" style={{ verticalAlign: '-1px', marginRight: 3 }}>
|
||||
<path
|
||||
d="M10.5 1a4.5 4.5 0 0 0-4.1 6.35L2 11.75V15h3.25v-2H7v-1.75h1.75L9.65 10.4A4.5 4.5 0 1 0 10.5 1zm1 3a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Models() {
|
||||
const { data, isLoading, error } = useModels()
|
||||
const add = useAddModel()
|
||||
const remove = useRemoveModel()
|
||||
const discover = useDiscoverModels()
|
||||
const [name, setName] = useState('')
|
||||
const [model, setModel] = useState('')
|
||||
const [provider, setProvider] = useState('openai')
|
||||
const [discoverSource, setDiscoverSource] = useState('openrouter')
|
||||
const [customUrl, setCustomUrl] = useState('')
|
||||
|
||||
const hint = AUTH_HINTS[discoverSource] ?? AUTH_HINTS.custom
|
||||
|
||||
function handleDiscover() {
|
||||
discover.mutate({
|
||||
source: discoverSource,
|
||||
...(discoverSource === 'custom' ? { url: customUrl } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Discover models section */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="section-label" style={{ marginBottom: 8 }}>Discover Models</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<select value={discoverSource} onChange={(e) => { setDiscoverSource(e.target.value); discover.reset() }}>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="deepinfra">DeepInfra</option>
|
||||
<option value="ollama">Ollama (local)</option>
|
||||
<option value="configured">Configured backend</option>
|
||||
<option value="custom">Custom URL</option>
|
||||
</select>
|
||||
{discoverSource === 'custom' && (
|
||||
<input
|
||||
placeholder="https://api.example.com"
|
||||
value={customUrl}
|
||||
onChange={(e) => setCustomUrl(e.target.value)}
|
||||
style={{ minWidth: 220 }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleDiscover}
|
||||
disabled={discover.isPending || (discoverSource === 'custom' && !customUrl)}
|
||||
>
|
||||
{discover.isPending ? 'Fetching...' : 'Fetch'}
|
||||
</button>
|
||||
<span className="dim" style={{ fontSize: 12 }}>
|
||||
{hint.needsKey && <KeyIcon />}{hint.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Discovery error */}
|
||||
{discover.isError && (
|
||||
<div style={{ marginTop: 8, padding: '6px 10px', background: 'var(--err-dim)', borderLeft: '3px solid var(--err)', borderRadius: 'var(--r)', fontSize: 12 }}>
|
||||
{discover.error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discovery results */}
|
||||
{discover.data && discover.data.models.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div className="dim" style={{ fontSize: 12, marginBottom: 4 }}>
|
||||
{discover.data.models.length} model{discover.data.models.length !== 1 ? 's' : ''} found.
|
||||
Click to populate the form below.
|
||||
</div>
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--r)', fontSize: 12 }}>
|
||||
{discover.data.models.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => setModel(m.id)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: model === m.id ? 'var(--accent-dim)' : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.target as HTMLElement).style.background = 'var(--surface-2)' }}
|
||||
onMouseLeave={(e) => { (e.target as HTMLElement).style.background = model === m.id ? 'var(--accent-dim)' : '' }}
|
||||
>
|
||||
<span className="mono">{m.id}</span>
|
||||
{m.name && m.name !== m.id && <span className="dim" style={{ marginLeft: 8 }}>{m.name}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{discover.data && discover.data.models.length === 0 && (
|
||||
<div className="dim" style={{ marginTop: 8, fontSize: 12 }}>No models returned.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual add model form */}
|
||||
<div className="form-group">
|
||||
<div className="form-label">Add Model</div>
|
||||
<div className="form-row" style={{ flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -12,7 +12,7 @@ function restartPending() {
|
||||
return sessionStorage.getItem(RESTART_KEY) === '1'
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
export default function Settings({ configured = true }: { configured?: boolean }) {
|
||||
const { data: cfg, isLoading, error } = useConfig()
|
||||
const { data: envData } = useEnv()
|
||||
const save = useSaveConfig()
|
||||
@@ -77,6 +77,42 @@ export default function Settings() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Getting-started notice — shown when no backend is configured */}
|
||||
{!configured && (
|
||||
<div style={{ marginBottom: 20, padding: '12px 16px', border: '1px solid var(--border)', borderLeft: '3px solid var(--warn)', borderRadius: 'var(--r)', fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>No proxy configuration found — nothing to forward requests to.</div>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
The proxy needs a backend endpoint (where to forward) and a listen port (where to accept).
|
||||
LISTEN_PORT defaults to 3000. Create a <span className="mono">.anyllm.env</span> and import it below,
|
||||
or pass it at startup: <span className="mono">anyllm-proxy --webui --env-file .anyllm.env</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4, fontSize: 12 }}>OpenAI</div>
|
||||
<pre style={{ margin: 0, padding: '6px 10px', background: 'var(--surface-2)', borderRadius: 'var(--r)', fontSize: 11, overflowX: 'auto' }}>
|
||||
{`OPENAI_API_KEY=sk-...
|
||||
PROXY_API_KEYS=my-key`}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4, fontSize: 12 }}>Ollama / local LLM</div>
|
||||
<pre style={{ margin: 0, padding: '6px 10px', background: 'var(--surface-2)', borderRadius: 'var(--r)', fontSize: 11, overflowX: 'auto' }}>
|
||||
{`OPENAI_BASE_URL=http://localhost:11434/v1
|
||||
PROXY_OPEN_RELAY=true`}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4, fontSize: 12 }}>OpenRouter / custom</div>
|
||||
<pre style={{ margin: 0, padding: '6px 10px', background: 'var(--surface-2)', borderRadius: 'var(--r)', fontSize: 11, overflowX: 'auto' }}>
|
||||
{`OPENAI_BASE_URL=https://openrouter.ai/api/v1
|
||||
OPENAI_API_KEY=sk-or-...
|
||||
PROXY_API_KEYS=my-key`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restart-required banner — shown after a successful import */}
|
||||
{showRestartBanner && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, padding: '8px 12px', background: 'var(--warn-dim)', borderLeft: '3px solid var(--warn)', borderRadius: 'var(--r)', fontSize: 13 }}>
|
||||
|
||||
@@ -143,6 +143,21 @@ pub fn init_db(conn: &Connection) -> rusqlite::Result<()> {
|
||||
);",
|
||||
)?;
|
||||
|
||||
// model_deployment: models added via the admin API, persisted across restarts.
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS model_deployment (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
model_name TEXT NOT NULL,
|
||||
backend_name TEXT NOT NULL,
|
||||
actual_model TEXT NOT NULL,
|
||||
rpm_limit INTEGER,
|
||||
tpm_limit INTEGER,
|
||||
weight INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(model_name, backend_name, actual_model)
|
||||
);",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -176,6 +191,72 @@ pub fn list_env_import(conn: &Connection) -> rusqlite::Result<Vec<(String, Strin
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
// ── Model deployment persistence ─────────────────────────────────────────────
|
||||
|
||||
/// Row returned by `list_model_deployments`.
|
||||
pub struct ModelDeploymentRow {
|
||||
pub model_name: String,
|
||||
pub backend_name: String,
|
||||
pub actual_model: String,
|
||||
pub rpm_limit: Option<u32>,
|
||||
pub tpm_limit: Option<u64>,
|
||||
pub weight: u32,
|
||||
}
|
||||
|
||||
/// Insert or ignore a model deployment (unique constraint prevents duplicates).
|
||||
pub fn insert_model_deployment(
|
||||
conn: &Connection,
|
||||
model_name: &str,
|
||||
backend_name: &str,
|
||||
actual_model: &str,
|
||||
rpm: Option<u32>,
|
||||
tpm: Option<u64>,
|
||||
weight: u32,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO model_deployment
|
||||
(model_name, backend_name, actual_model, rpm_limit, tpm_limit, weight, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
model_name,
|
||||
backend_name,
|
||||
actual_model,
|
||||
rpm,
|
||||
tpm,
|
||||
weight,
|
||||
chrono_now()
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all deployments for a given model name. Returns the number of rows deleted.
|
||||
pub fn delete_model_deployments(conn: &Connection, model_name: &str) -> rusqlite::Result<usize> {
|
||||
conn.execute(
|
||||
"DELETE FROM model_deployment WHERE model_name = ?1",
|
||||
[model_name],
|
||||
)
|
||||
}
|
||||
|
||||
/// Return all persisted model deployments, ordered by model name.
|
||||
pub fn list_model_deployments(conn: &Connection) -> rusqlite::Result<Vec<ModelDeploymentRow>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT model_name, backend_name, actual_model, rpm_limit, tpm_limit, weight
|
||||
FROM model_deployment ORDER BY model_name, backend_name",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(ModelDeploymentRow {
|
||||
model_name: r.get(0)?,
|
||||
backend_name: r.get(1)?,
|
||||
actual_model: r.get(2)?,
|
||||
rpm_limit: r.get(3)?,
|
||||
tpm_limit: r.get(4)?,
|
||||
weight: r.get(5)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
fn now_unix_secs() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod keys;
|
||||
pub mod logs;
|
||||
pub mod mcp;
|
||||
pub mod models;
|
||||
pub mod status;
|
||||
pub mod traffic;
|
||||
pub mod uptime;
|
||||
|
||||
@@ -399,6 +400,7 @@ pub fn admin_router(shared: SharedState, token: Arc<zeroize::Zeroizing<String>>)
|
||||
"/admin/api/models",
|
||||
get(models::list_models).post(models::add_model),
|
||||
)
|
||||
.route("/admin/api/models/discover", post(models::discover_models))
|
||||
.route("/admin/api/models/{name}", delete(models::remove_model))
|
||||
.route("/admin/api/audit", get(audit::get_audit_log))
|
||||
.route(
|
||||
@@ -409,6 +411,7 @@ pub fn admin_router(shared: SharedState, token: Arc<zeroize::Zeroizing<String>>)
|
||||
"/admin/api/mcp-servers/{name}",
|
||||
delete(mcp::remove_mcp_server),
|
||||
)
|
||||
.route("/admin/api/status", get(status::get_status))
|
||||
.route("/admin/api/traffic", get(traffic::get_traffic))
|
||||
.route("/admin/api/uptime", get(uptime::get_uptime))
|
||||
.with_state(shared.clone())
|
||||
|
||||
@@ -6,6 +6,17 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Shared HTTP client for model discovery (lightweight, short timeout).
|
||||
static DISCOVER_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("failed to build discover HTTP client")
|
||||
});
|
||||
|
||||
/// GET /admin/api/models -- list all routed model names and deployment counts.
|
||||
pub(super) async fn list_models(State(shared): State<SharedState>) -> impl IntoResponse {
|
||||
@@ -114,6 +125,21 @@ pub(super) async fn add_model(
|
||||
let mut router = router_lock.write().unwrap_or_else(|e| e.into_inner());
|
||||
router.add_deployment(body.model_name.clone(), deployment);
|
||||
|
||||
// Persist to SQLite so the deployment survives restarts.
|
||||
if let Ok(db) = shared.db.lock() {
|
||||
if let Err(e) = crate::admin::db::insert_model_deployment(
|
||||
&db,
|
||||
&body.model_name,
|
||||
&body.backend_name,
|
||||
&body.actual_model,
|
||||
body.rpm,
|
||||
body.tpm,
|
||||
body.weight,
|
||||
) {
|
||||
tracing::warn!(error = %e, "failed to persist model deployment to SQLite");
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
model_name = %body.model_name,
|
||||
backend = %body.backend_name,
|
||||
@@ -165,6 +191,12 @@ pub(super) async fn remove_model(
|
||||
|
||||
let mut router = router_lock.write().unwrap_or_else(|e| e.into_inner());
|
||||
if router.remove_model(&name) {
|
||||
// Remove from SQLite as well.
|
||||
if let Ok(db) = shared.db.lock() {
|
||||
if let Err(e) = crate::admin::db::delete_model_deployments(&db, &name) {
|
||||
tracing::warn!(error = %e, "failed to remove model deployment from SQLite");
|
||||
}
|
||||
}
|
||||
tracing::info!(model_name = %name, "removed model via admin API");
|
||||
super::emit_audit(
|
||||
&shared,
|
||||
@@ -191,3 +223,163 @@ pub(super) async fn remove_model(
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model discovery ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(super) struct DiscoverRequest {
|
||||
source: String,
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct DiscoverResponse {
|
||||
models: Vec<DiscoveredModel>,
|
||||
source: String,
|
||||
auth_used: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct DiscoveredModel {
|
||||
id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// POST /admin/api/models/discover -- fetch available models from a provider.
|
||||
pub(super) async fn discover_models(Json(body): Json<DiscoverRequest>) -> impl IntoResponse {
|
||||
let (url, api_key) = match resolve_discover_target(&body) {
|
||||
Ok(v) => v,
|
||||
Err(msg) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": msg })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let auth_used = api_key.is_some();
|
||||
let mut req = DISCOVER_CLIENT.get(&url);
|
||||
if let Some(ref key) = api_key {
|
||||
req = req.header("Authorization", format!("Bearer {key}"));
|
||||
}
|
||||
|
||||
let resp = match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let msg = if e.is_connect() {
|
||||
format!("connection refused: {url}")
|
||||
} else if e.is_timeout() {
|
||||
format!("request timed out: {url}")
|
||||
} else {
|
||||
format!("request failed: {e}")
|
||||
};
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(serde_json::json!({ "error": msg })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
"error": "API key required. Configure a key in Settings, then try again."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("upstream returned {}", resp.status())
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let json: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(serde_json::json!({ "error": format!("invalid JSON: {e}") })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Standard OpenAI format: { "data": [{ "id": "...", "name": "..." }, ...] }
|
||||
let mut models: Vec<DiscoveredModel> = json
|
||||
.get("data")
|
||||
.and_then(|d| d.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|m| {
|
||||
let id = m.get("id")?.as_str()?.to_string();
|
||||
let name = m.get("name").and_then(|n| n.as_str()).map(String::from);
|
||||
Some(DiscoveredModel { id, name })
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
models.sort_unstable_by(|a, b| a.id.cmp(&b.id));
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(DiscoverResponse {
|
||||
models,
|
||||
source: body.source,
|
||||
auth_used,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Map the source name to a (URL, optional API key) pair.
|
||||
fn resolve_discover_target(body: &DiscoverRequest) -> Result<(String, Option<String>), String> {
|
||||
match body.source.as_str() {
|
||||
"openrouter" => Ok(("https://openrouter.ai/api/v1/models".into(), None)),
|
||||
"deepinfra" => Ok(("https://api.deepinfra.com/v1/openai/models".into(), None)),
|
||||
"ollama" => {
|
||||
let base = std::env::var("OPENAI_BASE_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:11434".into());
|
||||
let base = base.trim_end_matches('/');
|
||||
// Ollama exposes /v1/models when running in OpenAI-compat mode,
|
||||
// but the native endpoint is /api/tags. Try /v1/models first.
|
||||
Ok((format!("{base}/v1/models"), None))
|
||||
}
|
||||
"configured" => {
|
||||
let base = std::env::var("OPENAI_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://api.openai.com".into());
|
||||
let base = base.trim_end_matches('/');
|
||||
let key = std::env::var("OPENAI_API_KEY")
|
||||
.ok()
|
||||
.filter(|k| !k.is_empty());
|
||||
Ok((format!("{base}/v1/models"), key))
|
||||
}
|
||||
"custom" => {
|
||||
let url = body
|
||||
.url
|
||||
.as_deref()
|
||||
.filter(|u| !u.is_empty())
|
||||
.ok_or("url is required for custom source")?;
|
||||
let url = url.trim_end_matches('/');
|
||||
// If the URL already ends with /models, use as-is; otherwise append.
|
||||
let url = if url.ends_with("/models") {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("{url}/v1/models")
|
||||
};
|
||||
Ok((url, None))
|
||||
}
|
||||
other => Err(format!("unknown source: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ProxyStatus {
|
||||
pub configured: bool,
|
||||
}
|
||||
|
||||
/// GET /admin/api/status -- returns whether the proxy has a backend configured.
|
||||
/// "Configured" means the user set at least one backend-relevant env var
|
||||
/// (API key, base URL, provider choice) or pointed to a config file.
|
||||
pub async fn get_status() -> Json<ProxyStatus> {
|
||||
Json(ProxyStatus {
|
||||
configured: is_backend_configured(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns true when the user has provided enough information for the proxy to
|
||||
/// know where to forward requests: an API key, a custom base URL, an explicit
|
||||
/// backend choice, or a config file.
|
||||
pub fn is_backend_configured() -> bool {
|
||||
const BACKEND_SIGNALS: &[&str] = &[
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
"BACKEND",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"VERTEX_API_KEY",
|
||||
"GOOGLE_ACCESS_TOKEN",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AZURE_OPENAI_API_KEY",
|
||||
"AZURE_OPENAI_ENDPOINT",
|
||||
];
|
||||
let has_signal = BACKEND_SIGNALS
|
||||
.iter()
|
||||
.any(|k| std::env::var(k).map(|v| !v.is_empty()).unwrap_or(false));
|
||||
let has_proxy_config = std::env::var("PROXY_CONFIG")
|
||||
.map(|p| std::path::Path::new(&p).exists())
|
||||
.unwrap_or(false);
|
||||
has_signal || has_proxy_config
|
||||
}
|
||||
@@ -14,6 +14,8 @@ use std::sync::LazyLock;
|
||||
/// All known env var names accepted by anyllm_proxy.
|
||||
/// Import warns on keys not in this list but still applies them.
|
||||
pub(crate) const KNOWN_KEYS: &[&str] = &[
|
||||
// Data directory
|
||||
"ANYLLM_HOME",
|
||||
// Core proxy
|
||||
"BACKEND",
|
||||
"LISTEN_PORT",
|
||||
|
||||
+151
-15
@@ -3,6 +3,7 @@ use anyllm_proxy::{
|
||||
server::{routes, state},
|
||||
tools,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
@@ -16,19 +17,28 @@ fn main() {
|
||||
// inherited from the parent process; skip env file loading to avoid duplicate messages.
|
||||
let is_run_child = std::env::var("_ANYLLM_RUN_CHILD").is_ok();
|
||||
|
||||
// Resolve the data directory early so all path defaults can use it.
|
||||
let data_dir = resolve_data_dir();
|
||||
if !is_run_child {
|
||||
eprintln!("anyllm_proxy: data directory: {}", data_dir.display());
|
||||
let data_dir_env = data_dir.join(".anyllm.env");
|
||||
let env_file_path = args
|
||||
.windows(2)
|
||||
.find(|w| w[0] == "--env-file")
|
||||
.map(|w| w[1].as_str())
|
||||
.map(|w| w[1].to_string())
|
||||
.or_else(|| {
|
||||
if std::path::Path::new(".anyllm.env").exists() {
|
||||
Some(".anyllm.env")
|
||||
Some(".anyllm.env".into())
|
||||
} else if data_dir_env.exists() {
|
||||
Some(data_dir_env.to_string_lossy().into_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let env_file_vars = env_file_path.map(parse_env_file).unwrap_or_default();
|
||||
let env_file_vars = env_file_path
|
||||
.as_deref()
|
||||
.map(parse_env_file)
|
||||
.unwrap_or_default();
|
||||
|
||||
// SAFETY: genuinely single-threaded here (no tokio runtime yet).
|
||||
unsafe {
|
||||
@@ -56,7 +66,7 @@ fn main() {
|
||||
// Runs after .anyllm.env so the file still takes precedence over DB imports,
|
||||
// and before the async runtime to keep set_var single-threaded safe.
|
||||
if !is_run_child {
|
||||
let db_path = std::env::var("ADMIN_DB_PATH").unwrap_or_else(|_| "admin.db".to_string());
|
||||
let db_path = resolve_db_path(&data_dir);
|
||||
let db_vars = load_env_from_sqlite(&db_path);
|
||||
if !db_vars.is_empty() {
|
||||
unsafe {
|
||||
@@ -71,6 +81,16 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect config file in data directory if PROXY_CONFIG is not set.
|
||||
if std::env::var("PROXY_CONFIG").is_err() {
|
||||
let data_config = data_dir.join("config.yaml");
|
||||
if data_config.exists() {
|
||||
let path_str = data_config.to_string_lossy().into_owned();
|
||||
unsafe { std::env::set_var("PROXY_CONFIG", &path_str) };
|
||||
eprintln!("anyllm_proxy: auto-detected config: {path_str}");
|
||||
}
|
||||
}
|
||||
|
||||
// Extract litellm master_key before the runtime starts (still single-threaded).
|
||||
if std::env::var("PROXY_API_KEYS").is_err() {
|
||||
if let Ok(ref config_path) = std::env::var("PROXY_CONFIG") {
|
||||
@@ -82,6 +102,37 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Warn when no backend is configured so users aren't left guessing why
|
||||
// requests fail. Skip when spawned as a child of the "run" subcommand.
|
||||
if !is_run_child && !anyllm_proxy::admin::routes::status::is_backend_configured() {
|
||||
eprintln!(
|
||||
"\n\
|
||||
anyllm-proxy: no backend configured. The proxy has nothing to forward requests to.\n\
|
||||
\n\
|
||||
The proxy needs an endpoint to forward to (backend) and a port to listen on (front).\n\
|
||||
LISTEN_PORT defaults to 3000. Pick a backend:\n\
|
||||
\n\
|
||||
# OpenAI (remote, needs API key)\n\
|
||||
OPENAI_API_KEY=sk-...\n\
|
||||
PROXY_API_KEYS=my-key # key your clients send\n\
|
||||
\n\
|
||||
# Ollama / local LLM (no API key required)\n\
|
||||
OPENAI_BASE_URL=http://localhost:11434/v1\n\
|
||||
PROXY_OPEN_RELAY=true # allow any key (local dev only)\n\
|
||||
\n\
|
||||
# OpenRouter / any OpenAI-compatible endpoint\n\
|
||||
OPENAI_BASE_URL=https://openrouter.ai/api/v1\n\
|
||||
OPENAI_API_KEY=sk-or-...\n\
|
||||
PROXY_API_KEYS=my-key\n\
|
||||
\n\
|
||||
Save to ~/.anyllm/.anyllm.env or load explicitly:\n\
|
||||
\n\
|
||||
anyllm-proxy --env-file /path/to/.anyllm.env\n\
|
||||
\n\
|
||||
Configure via UI: anyllm-proxy --webui\n"
|
||||
);
|
||||
}
|
||||
|
||||
// Detect "run" subcommand: anyllm_proxy [proxy_opts...] run <command> [args...]
|
||||
// Starts the proxy in the background and launches <command> with the proxy's
|
||||
// ANTHROPIC_* env vars pre-configured, then exits when <command> exits.
|
||||
@@ -101,10 +152,10 @@ fn main() {
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build tokio runtime")
|
||||
.block_on(async_main(args));
|
||||
.block_on(async_main(args, data_dir));
|
||||
}
|
||||
|
||||
async fn async_main(args: Vec<String>) {
|
||||
async fn async_main(args: Vec<String>, data_dir: PathBuf) {
|
||||
// ---- Phase 3: Init tracing (needs RUST_LOG from env file) ----
|
||||
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||
@@ -131,7 +182,39 @@ async fn async_main(args: Vec<String>) {
|
||||
// Env aliases were already applied in sync main(). Load config.
|
||||
let load_result = config::MultiConfig::load();
|
||||
let multi_config = load_result.multi_config;
|
||||
let model_router = load_result.model_router;
|
||||
|
||||
// Ensure a ModelRouter always exists (empty if no config file).
|
||||
// Then merge persisted model deployments from SQLite.
|
||||
let model_router = {
|
||||
let router = load_result.model_router.unwrap_or_else(|| {
|
||||
Arc::new(std::sync::RwLock::new(
|
||||
config::model_router::ModelRouter::new(std::collections::HashMap::new()),
|
||||
))
|
||||
});
|
||||
// Load persisted deployments from the DB (best-effort, non-fatal).
|
||||
let db_path = resolve_db_path(&data_dir);
|
||||
if let Ok(conn) = rusqlite::Connection::open(&db_path) {
|
||||
if let Ok(rows) = admin::db::list_model_deployments(&conn) {
|
||||
if !rows.is_empty() {
|
||||
let mut rw = router.write().unwrap_or_else(|e| e.into_inner());
|
||||
for row in &rows {
|
||||
rw.add_deployment(
|
||||
row.model_name.clone(),
|
||||
Arc::new(config::model_router::Deployment::with_weight(
|
||||
row.backend_name.clone(),
|
||||
row.actual_model.clone(),
|
||||
row.rpm_limit,
|
||||
row.tpm_limit,
|
||||
row.weight,
|
||||
)),
|
||||
);
|
||||
}
|
||||
tracing::info!(count = rows.len(), "loaded persisted model deployments from DB");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(router)
|
||||
};
|
||||
|
||||
// litellm master_key was already applied in fn main() (single-threaded).
|
||||
// Log confirmation if it was set.
|
||||
@@ -355,8 +438,7 @@ async fn async_main(args: Vec<String>) {
|
||||
panic!("ADMIN_PORT ({admin_port}) must differ from LISTEN_PORT ({listen_port})");
|
||||
}
|
||||
|
||||
// SQLite: open or create the database file in the current directory.
|
||||
let db_path = std::env::var("ADMIN_DB_PATH").unwrap_or_else(|_| "admin.db".into());
|
||||
let db_path = resolve_db_path(&data_dir);
|
||||
let conn =
|
||||
rusqlite::Connection::open(&db_path).expect("failed to open SQLite database for admin");
|
||||
admin::db::init_db(&conn).expect("failed to initialize admin database schema");
|
||||
@@ -545,7 +627,7 @@ async fn async_main(args: Vec<String>) {
|
||||
let mut buf = [0u8; 32];
|
||||
getrandom::fill(&mut buf).expect("getrandom failed");
|
||||
let token = hex::encode(buf);
|
||||
let token_path = resolve_admin_token_path();
|
||||
let token_path = resolve_admin_token_path(&data_dir);
|
||||
let token_path = token_path.to_string_lossy().to_string();
|
||||
// Write token to file with restrictive permissions instead of stderr,
|
||||
// because stderr is captured by container log drivers in production.
|
||||
@@ -687,7 +769,7 @@ async fn async_main(args: Vec<String>) {
|
||||
>,
|
||||
>,
|
||||
> = if enable_admin {
|
||||
let db_path = std::env::var("ADMIN_DB_PATH").unwrap_or_else(|_| "admin.db".into());
|
||||
let db_path = resolve_db_path(&data_dir);
|
||||
let batch_conn = rusqlite::Connection::open(&db_path)
|
||||
.expect("failed to open second SQLite connection for batch engine");
|
||||
anyllm_batch_engine::db::migrate_old_tables(&batch_conn)
|
||||
@@ -823,6 +905,7 @@ async fn async_main(args: Vec<String>) {
|
||||
/// Parse a `.env`-format file and return `(key, value)` pairs to set.
|
||||
///
|
||||
/// Delegates parsing to `anyllm_proxy::env_parser::parse_env_content` (pure, no side effects).
|
||||
///
|
||||
/// Hard errors are printed to stderr and result in an empty list; warnings are printed as-is.
|
||||
/// Already-set environment variables are skipped so the real environment always wins.
|
||||
/// Compatible with Docker `--env-file` and standard dotenv tooling.
|
||||
@@ -892,12 +975,65 @@ fn load_env_from_sqlite(db_path: &str) -> Vec<(String, String)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve the data directory where config, DB, and token files live.
|
||||
/// Priority: ANYLLM_HOME env var > ~/.anyllm/ > CWD (fallback if HOME unresolvable).
|
||||
/// Creates the directory on first use (mode 0700 on Unix).
|
||||
fn resolve_data_dir() -> PathBuf {
|
||||
let dir = if let Ok(home) = std::env::var("ANYLLM_HOME") {
|
||||
PathBuf::from(home)
|
||||
} else if let Some(home) = home_dir() {
|
||||
home.join(".anyllm")
|
||||
} else {
|
||||
// No home directory (unusual). Fall back to CWD.
|
||||
PathBuf::from(".")
|
||||
};
|
||||
|
||||
if !dir.exists() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true).mode(0o700);
|
||||
if let Err(e) = builder.create(&dir) {
|
||||
eprintln!(
|
||||
"anyllm_proxy: could not create data directory '{}': {e}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
eprintln!(
|
||||
"anyllm_proxy: could not create data directory '{}': {e}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
/// Cross-platform home directory lookup.
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// Resolve SQLite DB path: ADMIN_DB_PATH env var > data_dir/admin.db.
|
||||
fn resolve_db_path(data_dir: &std::path::Path) -> String {
|
||||
std::env::var("ADMIN_DB_PATH")
|
||||
.unwrap_or_else(|_| data_dir.join("admin.db").to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Resolve admin token file path from `ADMIN_TOKEN_PATH` env var,
|
||||
/// falling back to `.admin_token` in the current directory.
|
||||
fn resolve_admin_token_path() -> std::path::PathBuf {
|
||||
/// falling back to `~/.anyllm/.admin_token`.
|
||||
fn resolve_admin_token_path(data_dir: &std::path::Path) -> PathBuf {
|
||||
match std::env::var("ADMIN_TOKEN_PATH") {
|
||||
Ok(p) => {
|
||||
let path = std::path::PathBuf::from(&p);
|
||||
let path = PathBuf::from(&p);
|
||||
// Reject paths containing traversal sequences to prevent writing
|
||||
// the admin token to unexpected locations via misconfigured env vars.
|
||||
if p.contains("..") {
|
||||
@@ -905,7 +1041,7 @@ fn resolve_admin_token_path() -> std::path::PathBuf {
|
||||
}
|
||||
path
|
||||
}
|
||||
Err(_) => std::path::PathBuf::from(".admin_token"),
|
||||
Err(_) => data_dir.join(".admin_token"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# Configuration
|
||||
|
||||
## Config Directory
|
||||
|
||||
anyllm-proxy stores its data in `~/.anyllm/` by default:
|
||||
|
||||
```
|
||||
~/.anyllm/
|
||||
admin.db SQLite database (keys, models, audit, env imports)
|
||||
.admin_token Auto-generated admin auth token
|
||||
.anyllm.env Environment file (optional)
|
||||
config.yaml Proxy config (optional)
|
||||
```
|
||||
|
||||
Override the entire directory with `ANYLLM_HOME=/path/to/dir`.
|
||||
|
||||
Override individual files with their respective env vars (see below).
|
||||
|
||||
## File Lookup Order
|
||||
|
||||
Each file has a specific resolution order. The first match wins.
|
||||
|
||||
| File | Priority 1 (highest) | Priority 2 | Priority 3 |
|
||||
|------|---------------------|------------|------------|
|
||||
| Env file | `--env-file` CLI flag | `.anyllm.env` in CWD | `~/.anyllm/.anyllm.env` |
|
||||
| Config file | `PROXY_CONFIG` env var | `~/.anyllm/config.yaml` | (none) |
|
||||
| Database | `ADMIN_DB_PATH` env var | `~/.anyllm/admin.db` | |
|
||||
| Token | `ADMIN_TOKEN_PATH` env var | `~/.anyllm/.admin_token` | |
|
||||
|
||||
The data directory path itself is logged at startup:
|
||||
|
||||
```
|
||||
anyllm_proxy: data directory: /home/user/.anyllm
|
||||
```
|
||||
|
||||
## Model Persistence
|
||||
|
||||
Models added via the admin API (`POST /admin/api/models`) are stored in
|
||||
the SQLite database and survive restarts.
|
||||
|
||||
If a YAML config file (`config.yaml` or `PROXY_CONFIG`) also defines
|
||||
models, those are loaded first as the base layer. Models added through
|
||||
the admin UI are merged on top. On conflict (same model name + backend +
|
||||
actual model), the YAML definition takes priority.
|
||||
|
||||
To reset to YAML-only models, remove the admin-added entries via
|
||||
`DELETE /admin/api/models/{name}`.
|
||||
|
||||
## Docker
|
||||
|
||||
Docker Compose sets explicit paths via environment variables:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
ADMIN_DB_PATH: /data/admin.db
|
||||
ADMIN_TOKEN_PATH: /data/.admin_token
|
||||
```
|
||||
|
||||
These override the `~/.anyllm/` convention. The home directory layout
|
||||
does not apply inside containers.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Minimal .anyllm.env
|
||||
|
||||
```env
|
||||
# OpenAI (remote)
|
||||
OPENAI_API_KEY=sk-your-key-here
|
||||
PROXY_API_KEYS=my-proxy-key
|
||||
|
||||
# Or: Ollama (local, no API key)
|
||||
# OPENAI_BASE_URL=http://localhost:11434/v1
|
||||
# PROXY_OPEN_RELAY=true
|
||||
```
|
||||
|
||||
Save this to `~/.anyllm/.anyllm.env` and the proxy picks it up
|
||||
automatically on next start.
|
||||
|
||||
### Startup options
|
||||
|
||||
```bash
|
||||
# Auto-loads ~/.anyllm/.anyllm.env if present
|
||||
anyllm-proxy
|
||||
|
||||
# Explicit env file
|
||||
anyllm-proxy --env-file /path/to/my.env
|
||||
|
||||
# With admin UI
|
||||
anyllm-proxy --webui
|
||||
|
||||
# Both
|
||||
anyllm-proxy --webui --env-file /path/to/my.env
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See [ENV.md](ENV.md) for the full list. Key additions:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ANYLLM_HOME` | `~/.anyllm` | Override the data directory path |
|
||||
| `ADMIN_DB_PATH` | `$ANYLLM_HOME/admin.db` | SQLite database file |
|
||||
| `ADMIN_TOKEN_PATH` | `$ANYLLM_HOME/.admin_token` | Admin auth token file |
|
||||
Reference in New Issue
Block a user