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:
whit3rabbit
2026-04-06 21:24:44 -05:00
co-authored by Claude Sonnet 4.6
parent 3980985b17
commit dde1f902b6
15 changed files with 844 additions and 48 deletions
+33 -4
View File
@@ -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)
+36 -15
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+10 -1
View File
@@ -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 />}
+19 -1
View File
@@ -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 }) {
+15
View File
@@ -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 }}>
+81
View File
@@ -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)
+3
View File
@@ -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())
+192
View File
@@ -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}")),
}
}
+41
View File
@@ -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
}
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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 |