LLM API v0.1.0 — private OpenAI-compatible local model server for Apple Silicon
FastAPI gateway + SQLite registry, MLX-LM / mlx-vlm and llama.cpp workers, load-on-demand with memory policy and eviction, OpenAI endpoints (chat/completions/embeddings/rerank, streaming), management API, Model Harvester, benchmarks, API keys, Next.js 16 console, tests and docs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
70 changed files +13,830 −0
added
.env.example
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +# LLM API — environment (copy to <LLM_API_ROOT>/.env or export before `llm-api serve`) | |
| 2 | + | |
| 3 | +APP_ENV=production | |
| 4 | +DOMAIN=www.llm-api.io | |
| 5 | +PUBLIC_URL=https://www.llm-api.io | |
| 6 | + | |
| 7 | +# Bind address of the API gateway (the public reverse proxy talks to this) | |
| 8 | +HOST=127.0.0.1 | |
| 9 | +PORT=8300 | |
| 10 | +# Where the Next.js dashboard runs (proxied by the API for non-/api, non-/v1 paths) | |
| 11 | +WEB_URL=http://127.0.0.1:8301 | |
| 12 | + | |
| 13 | +# Data root: models/, data/ (SQLite + secrets), logs/ | |
| 14 | +LLM_API_ROOT=/Users/USERNAME/llm-api | |
| 15 | +# Override the model directory (e.g. an external SSD) | |
| 16 | +# MODEL_ROOT=/Volumes/LLM/models | |
| 17 | + | |
| 18 | +# Memory policy (GB). 64 GB machine: 8 macOS + 3 app + 4 KV + 4 safety = 45 GB for models | |
| 19 | +MAX_MODEL_MEMORY_GB=45 | |
| 20 | +ABSOLUTE_MAX_MEMORY_GB=50 | |
| 21 | +MACOS_RESERVE_GB=8 | |
| 22 | +MIN_FREE_DISK_GB=100 | |
| 23 | + | |
| 24 | +MAX_SIMULTANEOUS_MODELS=1 | |
| 25 | +MODEL_IDLE_TIMEOUT_MINUTES=30 | |
| 26 | +SMALL_MODEL_RESIDENT_GB=3 | |
| 27 | +LOAD_TIMEOUT_SECONDS=900 | |
| 28 | +GENERATION_TIMEOUT_SECONDS=1800 | |
| 29 | +PRELOAD_MODEL=none | |
| 30 | + | |
| 31 | +ENABLE_MLX=true | |
| 32 | +ENABLE_GGUF=true | |
| 33 | +ALLOW_DOWNLOADS=true | |
| 34 | +LOG_PROMPTS=false | |
| 35 | + | |
| 36 | +DEFAULT_MAX_TOKENS=2048 | |
| 37 | +DEFAULT_CONTEXT=16384 | |
| 38 | + | |
| 39 | +# Hugging Face token for gated repositories (never exposed to the frontend) | |
| 40 | +HF_TOKEN= | |
| 41 | +LLAMA_SERVER_BIN=llama-server | |
| 42 | + | |
| 43 | +# Sessions/CSRF secret (auto-generated into data/.secret when empty) | |
| 44 | +SECRET_KEY= | |
| 45 | +# First-run admin (used only when no user exists) | |
| 46 | +ADMIN_EMAIL= | |
| 47 | +ADMIN_PASSWORD= | |
| 48 | +SESSION_HOURS=336 | |
| 49 | +SECURE_COOKIES=true | |
| 50 | + | |
| 51 | +METRICS_INTERVAL_SECONDS=15 | |
| 52 | +METRICS_RETENTION_DAYS=30 | |
added
.gitignore
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +# python | |
| 2 | +__pycache__/ | |
| 3 | +*.pyc | |
| 4 | +server/.venv/ | |
| 5 | +.venv/ | |
| 6 | +*.egg-info/ | |
| 7 | +.pytest_cache/ | |
| 8 | +.ruff_cache/ | |
| 9 | +# node | |
| 10 | +web/node_modules/ | |
| 11 | +web/.next/ | |
| 12 | +web/next-env.d.ts | |
| 13 | +web/*.tsbuildinfo | |
| 14 | +# secrets / data | |
| 15 | +.env | |
| 16 | +.env.* | |
| 17 | +!.env.example | |
| 18 | +data/ | |
| 19 | +logs/ | |
| 20 | +models/ | |
| 21 | +# os | |
| 22 | +.DS_Store | |
added
CLAUDE.md
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +# CLAUDE.md — LLM API (www.llm-api.io) | |
| 2 | + | |
| 3 | +Private OpenAI-compatible local model server for Apple Silicon. Full product spec: see `docs/` and README. | |
| 4 | +Runs on **M1M64** (Mac Studio M1 Max 64 GB, rented, user `simon`), deployed with `mld deploy llm-api --node M1M64` | |
| 5 | +(manifest `M1M32:~/dispatch/apps/llm-api.json`), public via MacLustr Tunnel (BHS64) at https://www.llm-api.io. | |
| 6 | + | |
| 7 | +## Layout | |
| 8 | +- `server/` Python 3.13 FastAPI (`llm_api`): gateway, registry, manager, workers (`llm_api/worker/mlx_worker.py`, llama-server), tests (`pytest`, fake workers, no MLX needed). | |
| 9 | +- `web/` Next.js 16 console (proxied by the API in production; `pnpm dev` proxies /api,/v1 to `API_URL`). | |
| 10 | +- Data root on the node: `~/llm-api/` (`models/` library, `data/` SQLite + `.secret` + `.env`, `logs/`). Code: `~/apps/llm-api/`. | |
| 11 | + | |
| 12 | +## Rules (non-negotiable) | |
| 13 | +1. Never load a model whose estimate exceeds the budget; never rely on swap; never fake metrics. | |
| 14 | +2. Workers only on 127.0.0.1; memory release = kill the worker process. | |
| 15 | +3. Never mark ready before warm-up; never delete model files automatically. | |
| 16 | +4. Verify MLX / mlx-lm / mlx-vlm / llama.cpp / huggingface-hub APIs against the installed versions before changing runtime code. | |
| 17 | +5. Always test load → inference → unload → memory recovery after touching the manager or workers. | |
| 18 | + | |
| 19 | +## Dev loop | |
| 20 | +```bash | |
| 21 | +cd server && python -m compileall -q llm_api && rsync -az --delete --exclude __pycache__ --exclude .venv ./ M1M64:~/apps/llm-api/server/ | |
| 22 | +ssh M1M64 'source ~/llm-api/.venv/bin/activate; cd ~/apps/llm-api/server && python -m pytest -q --basetemp=/tmp/llmapi-tests; rm -rf /tmp/llmapi-tests' | |
| 23 | +cd web && pnpm exec tsc --noEmit && pnpm build | |
| 24 | +``` | |
| 25 | +Gotchas: pytest temp dirs must stay sparse (`--basetemp` + sparse fixtures — a real 400-layer fixture once filled the 1.8 TB SSD); `socket.getfqdn` hangs on the rented Mac (never use stock `HTTPServer.server_bind`); MLX work must stay on one thread per worker. | |
added
README.md
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +# LLM API — `https://www.llm-api.io` | |
| 2 | + | |
| 3 | +A private, production-grade, **OpenAI-compatible local LLM API for Apple Silicon**. Store a large library of models on SSD, load only the one that is requested into unified memory, unload it when something else is asked for, and expose everything behind one clean, authenticated API plus a management console. | |
| 4 | + | |
| 5 | +``` | |
| 6 | +OpenAI API — but backed by my own Apple Silicon machine and my own locally stored models. | |
| 7 | +``` | |
| 8 | + | |
| 9 | +First deployment: Mac Studio M1 Max, 10-core CPU, 32-core GPU, 64 GB unified memory, 1.8 TB SSD (node `M1M64`). | |
| 10 | + | |
| 11 | +## What it does | |
| 12 | + | |
| 13 | +- **Load-on-demand**: `{"model": "qwen3.8-27b-4bit"}` loads that model from SSD if it is not resident, evicting the previous one (LRU + idle timeout), then serves the request. Switches are serialized; requests queue while a model loads. | |
| 14 | +- **Two runtimes**: MLX / MLX-LM (preferred, safetensors) and llama.cpp `llama-server` (GGUF, Metal). Each model runs in its **own worker process** on `127.0.0.1` — killing the process is how memory is guaranteed to come back. | |
| 15 | +- **Memory policy**: weights + KV cache + runtime overhead are estimated *before* loading; the default budget is 45 GB of 64 GB (never all of it). Too-large models are refused with a structured error. Swap is treated as a warning, never a feature. | |
| 16 | +- **Compatibility engine**: `compatible` / `compatible_with_restrictions` / `experimental` / `not_recommended` / `incompatible`, with a recommended context per model. | |
| 17 | +- **OpenAI endpoints**: `/v1/models`, `/v1/chat/completions` (streaming, tools, reasoning content), `/v1/completions`, `/v1/embeddings`, `/v1/rerank`. Works with the official OpenAI SDKs. | |
| 18 | +- **Management API + console**: model library, per-model pages, playground, downloads from Hugging Face (inspected first: size, RAM, disk reserve), **Model Harvester** (explores HF for models that truly fit, dedupes quantizations, proposes a download queue), benchmarks, API keys (hashed), settings, real-time telemetry (RAM, GPU, CPU, thermal, disk) via SSE. | |
| 19 | +- **Aliases and `auto`**: `fast`, `coder`, `reasoning`, `vision`, `embedding`, `default`… `model: "auto"` routes by prompt content (never when a model is named explicitly). | |
| 20 | + | |
| 21 | +## Layout | |
| 22 | + | |
| 23 | +``` | |
| 24 | +server/ Python 3.13 · FastAPI · SQLite — API gateway, registry, model manager, workers | |
| 25 | +web/ Next.js 16 · React 19 · Tailwind 4 — console (proxied by the API in production) | |
| 26 | +scripts/ install.sh | |
| 27 | +docs/ architecture, api, models, security, deployment, troubleshooting | |
| 28 | +``` | |
| 29 | + | |
| 30 | +## Quick start (clean Mac) | |
| 31 | + | |
| 32 | +```bash | |
| 33 | +git clone <repo> llm-api && cd llm-api | |
| 34 | +./scripts/install.sh --test # checks the Mac, installs uv/Node/llama.cpp, Python deps, builds the web app | |
| 35 | +$EDITOR ~/llm-api/.env # ADMIN_EMAIL, ADMIN_PASSWORD, HF_TOKEN, PUBLIC_URL | |
| 36 | +(cd ~/llm-api && ../path/to/server/.venv/bin/llm-api serve) # API on 127.0.0.1:8300 | |
| 37 | +(cd web && pnpm start -p 8301 -H 127.0.0.1) # console | |
| 38 | +``` | |
| 39 | + | |
| 40 | +Then open `http://127.0.0.1:8300`, sign in, create an API key, and: | |
| 41 | + | |
| 42 | +```python | |
| 43 | +from openai import OpenAI | |
| 44 | +client = OpenAI(base_url="https://www.llm-api.io/v1", api_key="llm_live_xxxxx") | |
| 45 | +r = client.chat.completions.create(model="default", messages=[{"role": "user", "content": "Hello"}]) | |
| 46 | +print(r.choices[0].message.content) | |
| 47 | +``` | |
| 48 | + | |
| 49 | +```bash | |
| 50 | +curl https://www.llm-api.io/v1/chat/completions \ | |
| 51 | + -H "Authorization: Bearer llm_live_xxxxx" -H "Content-Type: application/json" \ | |
| 52 | + -d '{"model": "qwen3.8-27b-4bit", "messages": [{"role": "user", "content": "Explain monetary policy."}], "stream": true}' | |
| 53 | +``` | |
| 54 | + | |
| 55 | +## Verified on the M1 Max (2026-09-10) | |
| 56 | + | |
| 57 | +| Step | Result | | |
| 58 | +|---|---| | |
| 59 | +| Cold load Qwen3-4B-Instruct-2507 (MLX 4-bit) | 1.7 s, ~100 tok/s generation | | |
| 60 | +| Cold load Llama-3.2-3B (MLX 4-bit) | 1.4 s, ~170 tok/s | | |
| 61 | +| Gemma-3-1B GGUF via llama.cpp | 1.1 s, ~150 tok/s, streaming with `timings` | | |
| 62 | +| Switch A → B → A | previous worker killed, memory returned to the OS before the next load | | |
| 63 | +| OpenAI Python SDK | models / chat / stream / embeddings OK | | |
| 64 | +| Memory rejection | budget lowered → model refused with `MODEL_TOO_LARGE`, compatibility re-evaluated | | |
| 65 | + | |
| 66 | +## Tests | |
| 67 | + | |
| 68 | +```bash | |
| 69 | +cd server && .venv/bin/python -m pytest -q | |
| 70 | +``` | |
| 71 | + | |
| 72 | +The suite runs without MLX (fake workers) and covers: authentication, API keys (creation, rejection, revocation), registry scanning, missing-file detection, load-on-demand, streaming, model switching, concurrent requests + switch lock, memory rejection, worker crash, load timeout, deletion confirmation, low-disk refusal, benchmarks, restart cleanup, settings validation. | |
| 73 | + | |
| 74 | +## Docs | |
| 75 | + | |
| 76 | +- [docs/architecture.md](docs/architecture.md) — components, data flow, worker protocol, memory model | |
| 77 | +- [docs/api.md](docs/api.md) — OpenAI endpoints, extensions, management API, errors | |
| 78 | +- [docs/models.md](docs/models.md) — storage layout, discovery, compatibility, quantization policy, Harvester, starter library | |
| 79 | +- [docs/security.md](docs/security.md) — auth, keys, CSRF, sandboxing, what is never logged | |
| 80 | +- [docs/deployment.md](docs/deployment.md) — M1M64 deployment with `mld`, PM2, MacLustr Tunnel, DNS | |
| 81 | +- [docs/troubleshooting.md](docs/troubleshooting.md) — failure modes and what to look at | |
| 82 | + | |
| 83 | +## Non-negotiables (from CLAUDE.md) | |
| 84 | + | |
| 85 | +Never crash the host with an oversized model · never rely on swap · never expose secrets or raw workers · never mark a model ready before a successful warm-up · never delete model files automatically · never fake metrics · verify runtime APIs against the installed versions · always test load → inference → unload → memory recovery · keep the design ready for multiple Apple Silicon nodes. | |
added
docs/api.md
+77 −0
@@ -0,0 +1,77 @@ | ||
| 1 | +# API | |
| 2 | + | |
| 3 | +Base URL: `https://www.llm-api.io/v1` (also reachable at `http://127.0.0.1:8300/v1` on the node). Interactive OpenAPI schema: `/openapi`. | |
| 4 | + | |
| 5 | +## Authentication | |
| 6 | + | |
| 7 | +`Authorization: Bearer llm_live_…` (or `X-API-Key`). Keys are created in the console (Keys page) or `llm-api create-key <name> [--admin]`. Scopes: `inference` (OpenAI endpoints), `admin` (management API). The console session cookie also works for `/v1` (playground). | |
| 8 | + | |
| 9 | +## OpenAI-compatible endpoints | |
| 10 | + | |
| 11 | +| Method | Path | Notes | | |
| 12 | +|---|---|---| | |
| 13 | +| GET | `/v1/models` | installed models + aliases; extra fields: `runtime`, `quantization`, `estimated_ram_gb`, `context`, `compatibility`, `status`, `capabilities` | | |
| 14 | +| GET | `/v1/models/{id}` | | | |
| 15 | +| POST | `/v1/chat/completions` | `stream`, `temperature`, `top_p`, `max_tokens`/`max_completion_tokens`, `stop` (≤ 8), `seed`, `presence_penalty`, `frequency_penalty`, `logit_bias`, `tools`, `stream_options.include_usage` (always on) | | |
| 16 | +| POST | `/v1/completions` | `prompt`, `echo`, same sampling params | | |
| 17 | +| POST | `/v1/embeddings` | `input` (str or ≤ 256 strings), `dimensions`, `encoding_format` | | |
| 18 | +| POST | `/v1/rerank` | `query`, `documents` (≤ 200), `top_n`, `return_documents`, `instruction` | | |
| 19 | + | |
| 20 | +Extensions (ignored by SDKs): | |
| 21 | + | |
| 22 | +- `timings`: `{ttft_ms, prompt_ms, generation_ms, total_ms, prompt_tps, generation_tps, peak_memory_gb}` on responses and on the final stream chunk. | |
| 23 | +- `usage.prompt_tokens_details.cached_tokens` when the prompt cache was reused. | |
| 24 | +- `reasoning_content` on `message` / `delta` for thinking models. Control with `chat_template_kwargs: {"enable_thinking": false}`, `reasoning: {"effort": "none"}` or `reasoning_effort`. | |
| 25 | +- `top_k`, `min_p`, `repetition_penalty` (MLX). | |
| 26 | +- `model: "auto"` — routed by prompt (code → `coder`, long/analytical → `reasoning`, images → `vision`, embeddings → `embedding`), else current model / `default` alias / default model setting. | |
| 27 | + | |
| 28 | +### Model resolution | |
| 29 | + | |
| 30 | +`model` may be a registry id (`qwen3.8-27b-4bit`), an alias (`coder`), the directory name, or the Hugging Face repository (`mlx-community/Qwen3.8-27B-4bit`). | |
| 31 | + | |
| 32 | +### Errors | |
| 33 | + | |
| 34 | +```json | |
| 35 | +{"error": {"message": "Model requires approximately 52.0 GB at a 32768 context but the safe limit is 45 GB.", | |
| 36 | + "type": "model_memory_error", "code": "MODEL_TOO_LARGE", "param": null, "estimate": {...}}} | |
| 37 | +``` | |
| 38 | + | |
| 39 | +| HTTP | code | when | | |
| 40 | +|---|---|---| | |
| 41 | +| 400 | `INVALID_REQUEST`, `CONTEXT_TOO_LARGE`, `TEMPLATE_ERROR`, `VISION_UNSUPPORTED` | bad body, prompt longer than the loaded context | | |
| 42 | +| 401 | `INVALID_API_KEY`, `UNAUTHENTICATED` | | | |
| 43 | +| 403 | `FORBIDDEN`, `CSRF`, `DOWNLOADS_DISABLED`, `PATH_NOT_ALLOWED` | | | |
| 44 | +| 404 | `MODEL_NOT_FOUND` | | | |
| 45 | +| 409 | `CONFLICT`, `ALREADY_INSTALLED` | | | |
| 46 | +| 413 | `BODY_TOO_LARGE` | > 20 MB | | |
| 47 | +| 422 | `MODEL_INCOMPATIBLE`, `RUNTIME_UNSUPPORTED`, `WRONG_MODEL_TYPE` | | | |
| 48 | +| 429 | `RATE_LIMITED` | 600 req/min per IP; 8 logins/min | | |
| 49 | +| 502 | `WORKER_UNREACHABLE`, `DOWNLOAD_FAILED` | | | |
| 50 | +| 503 | `MODEL_LOAD_FAILED`, `MODEL_LOAD_TIMEOUT`, `WORKER_CRASHED`, `WORKER_BUSY` | | | |
| 51 | +| 504 | `GENERATION_TIMEOUT` | | | |
| 52 | +| 507 | `MODEL_TOO_LARGE`, `INSUFFICIENT_DISK` | | | |
| 53 | + | |
| 54 | +## Management API (`/api`, admin) | |
| 55 | + | |
| 56 | +Session mutations need header `X-LLM-CSRF: 1`. | |
| 57 | + | |
| 58 | +- Auth: `GET /api/auth/status`, `POST /api/auth/setup|login|logout|password`, `GET /api/auth/me` | |
| 59 | +- Models: `GET /api/models[?include_missing]`, `GET /api/models/{id}` (benchmarks, events, memory curve), `GET /api/models/{id}/files`, `POST /api/models/{id}/load {context?, force?}`, `POST /api/models/{id}/unload`, `PATCH /api/models/{id}` (favorite, pinned, enabled, notes, tags, name, overrides), `POST /api/models/{id}/pin?pinned=`, `POST /api/models/{id}/favorite?favorite=`, `DELETE /api/models/{id} {confirm: id, keep_benchmarks}`, `POST /api/models/rescan`, `POST /api/models/{id}/benchmark {max_tokens, runs, long_prompt}`, `GET /api/models/{id}/benchmarks`, `POST /api/models/{id}/tokenize` | |
| 60 | +- Aliases: `GET /api/aliases`, `PUT /api/aliases {alias, model_id}`, `DELETE /api/aliases/{alias}` | |
| 61 | +- Downloads: `POST /api/models/inspect {repository, quant?}`, `POST /api/models/download {repository, quant?, force?}`, `GET /api/downloads`, `POST /api/downloads/{job}/retry`, `GET /api/jobs[?kind]`, `POST /api/jobs/{id}/cancel` | |
| 62 | +- Harvester: `POST /api/harvest/scan {runtimes, authors, limit_per_author, min_downloads, max_ram_gb, families, tasks, search}`, `GET /api/harvest/candidates[?task&runtime&family&size_class&q&include_duplicates]` (+ starter slots), `POST /api/harvest/select|dismiss {repo_id, selected}`, `POST /api/harvest/queue` | |
| 63 | +- System: `GET /api/system`, `/api/system/memory`, `/api/system/gpu`, `/api/system/storage`, `/api/system/processes`, `/api/system/health`, `/api/system/metrics?minutes&hours`, `GET /api/runtime/status`, `GET /api/runtime/current-model` | |
| 64 | +- Keys: `GET/POST /api/keys`, `PATCH /api/keys/{id} {name}`, `DELETE /api/keys/{id}` | |
| 65 | +- Settings: `GET/PATCH /api/settings` | |
| 66 | +- Logs: `GET /api/logs/audit|events|requests`, `GET /api/logs/worker/{model_id}` | |
| 67 | +- Live: `GET /api/events` — SSE stream: `snapshot`, `manager`, `model` (load progress), `metrics` (every 3 s), `job`, `request`, `alert`, `settings`. | |
| 68 | + | |
| 69 | +## Status | |
| 70 | + | |
| 71 | +`GET /health` (public): | |
| 72 | + | |
| 73 | +```json | |
| 74 | +{"status": "ok", "hardware": {"chip": "Apple M1 Max", "memory_gb": 64.0, "gpu_cores": 32}, | |
| 75 | + "model": {"loaded": true, "id": "qwen3-4b-instruct-2507-4bit", "loaded_models": ["…"]}, | |
| 76 | + "memory": {"used_gb": 23.1, "available_gb": 40.9, "pressure": "normal"}, "uptime_seconds": 1234, "version": "0.1.0"} | |
| 77 | +``` | |
added
docs/architecture.md
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +# Architecture | |
| 2 | + | |
| 3 | +``` | |
| 4 | +Client (OpenAI SDK, curl, console) | |
| 5 | + │ HTTPS www.llm-api.io | |
| 6 | + ▼ | |
| 7 | +MacLustr Tunnel gateway (BHS64, Caddy TLS) ──WireGuard──▶ M1M64 wg1 10.67.0.x | |
| 8 | + ▼ | |
| 9 | +FastAPI gateway 127.0.0.1:8300 (server/llm_api) | |
| 10 | + ├─ /v1/* OpenAI-compatible routes ── auth (API key or admin session) ── proxy to worker | |
| 11 | + ├─ /api/* management API ── admin session / admin-scoped key, CSRF header | |
| 12 | + ├─ /health public status | |
| 13 | + └─ /* reverse proxy → Next.js console 127.0.0.1:8301 | |
| 14 | + │ | |
| 15 | + ├─ Registry (SQLite) models, aliases, benchmarks, events, requests, jobs, keys, settings, audit | |
| 16 | + ├─ Scanner safetensors + GGUF headers → architecture, params, quant, KV bytes/token | |
| 17 | + ├─ Compatibility engine estimate(weights, KV, overhead) vs budget → status + recommended context | |
| 18 | + ├─ Model Manager switch lock, eviction (LRU / pinned), spawn, wait-ready, warm-up, monitor, idle | |
| 19 | + ├─ Job runner downloads (serialized), scans, benchmarks, harvests | |
| 20 | + ├─ Downloader / Harvester Hugging Face inspection, verified downloads, manifests, discovery | |
| 21 | + ├─ Metrics collector psutil + ioreg + vm_stat + pmset → SSE + SQLite history | |
| 22 | + └─ Event bus SSE fan-out to the console | |
| 23 | + │ | |
| 24 | + ▼ one process per loaded model, 127.0.0.1:8310-8399 | |
| 25 | + ┌──────────────────────┐ ┌──────────────────────────┐ | |
| 26 | + │ mlx_worker (python) │ │ llama-server (llama.cpp) │ | |
| 27 | + │ mlx-lm / mlx-vlm │ │ Metal, --jinja, slots │ | |
| 28 | + │ /v1/chat/completions │ │ /v1/chat/completions │ | |
| 29 | + │ /v1/embeddings … │ │ /v1/embeddings … │ | |
| 30 | + └──────────────────────┘ └──────────────────────────┘ | |
| 31 | +``` | |
| 32 | + | |
| 33 | +## Request path (load-on-demand) | |
| 34 | + | |
| 35 | +1. `POST /v1/chat/completions` → auth → body validation → model resolution (id, alias, name, repository, `auto`). | |
| 36 | +2. `ModelManager.ensure_loaded(id)`: | |
| 37 | + - fast path if the worker is `ready`; | |
| 38 | + - otherwise take the **switch lock** (one switch at a time; other requests wait on it, so no thrashing); | |
| 39 | + - policy: enabled, compatible (or `force`), estimate at the chosen context ≤ budget; | |
| 40 | + - eviction: keep at most `MAX_SIMULTANEOUS_MODELS` large models and `resident + new ≤ budget`; victims are LRU, pinned last; small embedding/reranker workers (≤ 3 GB) may stay resident; | |
| 41 | + - real free-memory check after eviction (waits for the OS to reclaim); | |
| 42 | + - spawn the worker, poll `/health` until `ready` (or crash/timeout), run a **warm-up** inference, measure memory, mark `ready`, record load time. | |
| 43 | +3. The request is proxied to the worker (streamed for SSE). Usage + `timings` are recorded per request and per model. | |
| 44 | + | |
| 45 | +## Worker protocol | |
| 46 | + | |
| 47 | +Every runtime is a local HTTP server speaking the OpenAI API. Adapters only differ in how they spawn and how they detect readiness: | |
| 48 | + | |
| 49 | +- **MLXAdapter** → `python -m llm_api.worker.mlx_worker --model-path … --port … --max-context …`. All MLX work (import, load, generate, embed) runs on a single dedicated thread inside the worker (MLX streams are thread-affine). Features: chat templates (tools, `enable_thinking`), `<think>` → `reasoning_content`, tool-call parsing, stop sequences with hold-back, prompt-cache reuse across turns, sampling (temperature, top_p, top_k, min_p, penalties, logit_bias, seed), last-token-pooled embeddings, yes/no rerank scoring, vision via mlx-vlm. | |
| 50 | +- **LlamaCppAdapter** → `llama-server -m … --port … -ngl 999 -c ctx --jinja --no-webui -np 1 -fa auto --cache-reuse 256` (+ `--embeddings`, `--reranking`, `--mmproj`, `--reasoning-format deepseek`). | |
| 51 | + | |
| 52 | +Memory release = process exit (SIGTERM → SIGKILL). The manager waits for `vm_stat` available memory to come back before the next load. | |
| 53 | + | |
| 54 | +## Memory model | |
| 55 | + | |
| 56 | +``` | |
| 57 | +total = weights × 1.03–1.04 | |
| 58 | + + KV bytes/token × context (2 · layers · kv_heads · head_dim · 2 bytes; sliding-window aware) | |
| 59 | + + runtime overhead (1.0–1.2 GB) + activations (0.6–0.8 GB) + vision (0.8 GB) | |
| 60 | + + long-context scratch (≤ 2 GB) | |
| 61 | +``` | |
| 62 | + | |
| 63 | +Recommended context = largest of 2K…32K that fits the budget (larger contexts can be requested explicitly). Budget default 45 GB on 64 GB; absolute limit 50 GB; both editable in Settings, which re-evaluates every model. | |
| 64 | + | |
| 65 | +## Crash recovery | |
| 66 | + | |
| 67 | +`data/workers.json` lists live worker PIDs. On startup, any leftover worker is killed and registry state reset; jobs left `running` are marked failed. The monitor loop (5 s) detects a dead worker and publishes `worker_crashed`. | |
| 68 | + | |
| 69 | +## Future cluster | |
| 70 | + | |
| 71 | +Nothing binds the gateway to one node: the registry row carries `path`/`runtime`, the manager talks HTTP to workers, telemetry is per host. A central router could hold several `ModelManager`s (one per node, over the private WireGuard network) and score `model already loaded · free RAM · GPU · queue · tokens/s · thermal`. Model replication would reuse the verified-manifest download path over the private network only. | |
added
docs/deployment.md
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +# Deployment | |
| 2 | + | |
| 3 | +## Production topology (2026-09-10) | |
| 4 | + | |
| 5 | +``` | |
| 6 | +GoDaddy DNS llm-api.io A www → 51.161.112.61 (BHS64) | |
| 7 | +BHS64 (OVH Beauharnois) Caddy TLS https://www.llm-api.io → 10.67.0.x:8300 over WireGuard | |
| 8 | +M1M64 (Mac Studio M1 Max 64 GB, rented, public IP 45.74.241.221, user simon) | |
| 9 | + wg1 10.67.0.x | |
| 10 | + PM2 (LaunchDaemon) llm-api-server → .venv/bin/llm-api serve 127.0.0.1:8300 | |
| 11 | + llm-api-web → next start -p 8301 -H 127.0.0.1 | |
| 12 | + ~/llm-api/ models/ (SSD library) data/ (SQLite, .secret, .env) logs/ | |
| 13 | + ~/apps/llm-api/ code (rsync'd by mld: server/ + web/) | |
| 14 | +``` | |
| 15 | + | |
| 16 | +The whole app is deployed and routed by the cluster orchestrator **mld** (gateway M1M32): | |
| 17 | + | |
| 18 | +```bash | |
| 19 | +mld stage ~/Desktop/Cluster/llm-api llm-api # laptop → M1M32 staging (excludes node_modules, .next, .venv) | |
| 20 | +mld deploy llm-api --node M1M64 # rsync → post_sync hooks (uv venv + pip, pnpm build) → PM2 → health → tunnel route → registry | |
| 21 | +mld status --live | grep llm-api | |
| 22 | +mld logs llm-api | |
| 23 | +``` | |
| 24 | + | |
| 25 | +Manifest: `M1M32:~/dispatch/apps/llm-api.json` (dir `~/apps/llm-api`, port 8300, health `/health`, tunnel `www.llm-api.io` on BHS64, pinned to `M1M64`). Secrets (`ADMIN_PASSWORD`, `HF_TOKEN`) live in the manifest's `env` on M1M32 only and are written into the PM2 environment; the app also reads `~/llm-api/.env`. | |
| 26 | + | |
| 27 | +M1M64 is a *reserved* node: always deploy with `--node M1M64`. It has no graphical session guaranteed at boot, so PM2 runs as a system LaunchDaemon (`mld prepare` does this). | |
| 28 | + | |
| 29 | +## Manual (without mld) | |
| 30 | + | |
| 31 | +```bash | |
| 32 | +./scripts/install.sh | |
| 33 | +cd ~/llm-api && /path/to/server/.venv/bin/llm-api serve # API | |
| 34 | +cd /path/to/web && pnpm start -p 8301 -H 127.0.0.1 # console | |
| 35 | +``` | |
| 36 | + | |
| 37 | +PM2: | |
| 38 | + | |
| 39 | +```bash | |
| 40 | +pm2 start /path/to/server/.venv/bin/llm-api --name llm-api-server --cwd ~/llm-api -- serve | |
| 41 | +pm2 start node --name llm-api-web --cwd /path/to/web -- node_modules/next/dist/bin/next start -p 8301 -H 127.0.0.1 | |
| 42 | +pm2 save && pm2 startup | |
| 43 | +``` | |
| 44 | + | |
| 45 | +launchd alternative: a `LaunchDaemon` plist running `.venv/bin/llm-api serve` with `WorkingDirectory=~/llm-api`, `KeepAlive=true`, `RunAtLoad=true` (see `mld` render for the pattern). | |
| 46 | + | |
| 47 | +## Reverse proxy | |
| 48 | + | |
| 49 | +Any TLS terminator works (Caddy, nginx). Forward everything to `127.0.0.1:8300`; the API serves `/v1`, `/api`, `/health`, `/openapi` itself and proxies the rest to Next. SSE needs buffering off (`X-Accel-Buffering: no` is already set). `api.llm-api.io` can point at the same upstream if a separate API host is wanted. | |
| 50 | + | |
| 51 | +## Updating | |
| 52 | + | |
| 53 | +```bash | |
| 54 | +mld stage ~/Desktop/Cluster/llm-api llm-api && mld deploy llm-api --node M1M64 | |
| 55 | +``` | |
| 56 | + | |
| 57 | +Deploy = rsync + rebuild + `pm2 restart`; the manager unloads the loaded model on shutdown and clears stale state on start. | |
added
docs/models.md
+55 −0
@@ -0,0 +1,55 @@ | ||
| 1 | +# Models | |
| 2 | + | |
| 3 | +## Storage | |
| 4 | + | |
| 5 | +``` | |
| 6 | +<LLM_API_ROOT>/models/ | |
| 7 | +├── mlx/<family>/<Repo-Name>/ safetensors + config.json + tokenizer (+ llm-api.json manifest) | |
| 8 | +├── gguf/<family>/<Repo-Name-GGUF>/ one chosen quantization (+ mmproj for vision) | |
| 9 | +├── embeddings/ rerankers/ vision/ same layout, by role | |
| 10 | +└── manifests/<org--repo>.json copy of every download manifest | |
| 11 | +``` | |
| 12 | + | |
| 13 | +One recommended quantization per model; the downloader picks it (`Q4_K_M` → `Q5_K_M` → `Q6_K` → `Q8_0` → `MXFP4`… for GGUF) unless a `quant` is given. Nothing is ever deleted by a scan. | |
| 14 | + | |
| 15 | +## Discovery (`POST /api/models/rescan`, startup) | |
| 16 | + | |
| 17 | +For each directory: `config.json` + `*.safetensors` → MLX model (architecture, layers, heads, quant bits from `quantization`, params from safetensors headers — packed `uint32` ×32/bits); `*.gguf` → llama.cpp model (GGUF header: architecture, `block_count`, `head_count_kv`, `key_length`, `context_length`, `file_type`, tensor shapes). Type detection: embedding (name / pipeline / pooling), reranker, vision (`vision_config`, mmproj, model type), thinking / tools (chat template). Registry ids are stable slugs (`qwen3-4b-instruct-2507-4bit`); a directory keeps its id across rescans. Manual overrides (task, context, KV bits, pooling, extra llama args) live in `overrides` and survive rescans. | |
| 18 | + | |
| 19 | +## Size classes (estimated RAM at the recommended context) | |
| 20 | + | |
| 21 | +TINY < 5 GB · SMALL 5–10 · MEDIUM 10–20 · LARGE 20–35 · XL 35–45 · TOO_LARGE > budget. | |
| 22 | + | |
| 23 | +## Compatibility | |
| 24 | + | |
| 25 | +| status | meaning | | |
| 26 | +|---|---| | |
| 27 | +| `compatible` | fits with ≥ 16K context in the budget | | |
| 28 | +| `compatible_with_restrictions` | fits, but context must stay below 16K (the recommended context is enforced at load) | | |
| 29 | +| `experimental` | runs but unverified: architecture not in the llama.cpp known list, VLM type falling back to text, very low quantization on a large model | | |
| 30 | +| `not_recommended` | fits only through heavy swap (≤ absolute limit but > budget) — not loadable without `force` | | |
| 31 | +| `incompatible` | runtime missing, architecture unsupported by the installed mlx-lm/mlx-vlm, or > absolute limit | | |
| 32 | + | |
| 33 | +## Quantization policy (Harvester scoring) | |
| 34 | + | |
| 35 | +≤ 8B prefer 8/6-bit · 10–20B Q6/Q5 · 20–40B Q5/Q4 · 40–80B Q4 · larger Q3 only when genuinely useful. Lower than that is penalized (`too_low`). | |
| 36 | + | |
| 37 | +## Model Harvester | |
| 38 | + | |
| 39 | +`POST /api/harvest/scan` lists trusted authors (MLX: `mlx-community`; GGUF: `unsloth`, `bartowski`, `ggml-org`, `lmstudio-community`), excludes ASR/TTS/image/video/NSFW/base/draft repos, estimates RAM from the listing config + safetensors metadata (MLX) or from the chosen GGUF file (one repo-tree call), evaluates compatibility, scores (popularity, recency, size, quantization policy), and **dedupes by base model + runtime** (only the best quantization per base model is proposed). Candidates land in the Harvester page with a suggested starter library (small/medium/large general, coding, reasoning, vision, embedding, reranker). Selected candidates are queued through the normal inspected download path. | |
| 40 | + | |
| 41 | +## Starter library on M1M64 (downloaded 2026-09-10, all mlx-community unless noted) | |
| 42 | + | |
| 43 | +| slot | model | RAM est. | | |
| 44 | +|---|---|---| | |
| 45 | +| tiny test | Qwen3-4B-Instruct-2507-4bit, Llama-3.2-3B-Instruct-4bit, gemma-3-1b-it Q4_K_M (GGUF) | 4–9 GB | | |
| 46 | +| small general / vision | Qwen3.5-9B-MLX-4bit | ~8 GB | | |
| 47 | +| medium general | gemma-4-12B-it-qat-4bit | ~10 GB | | |
| 48 | +| reasoning | gpt-oss-20b-MXFP4-Q8 | ~14 GB | | |
| 49 | +| coding | Devstral-Small-2-24B-Instruct-2512-4bit, Qwen3-Coder-30B-A3B-Instruct-4bit | 15–20 GB | | |
| 50 | +| large general | Qwen3.8-27B-4bit, Qwen3.6-35B-A3B-4bit (MoE), gemma-4-26b-a4b-it-4bit (MoE) | 17–24 GB | | |
| 51 | +| XL | Llama-3.3-70B-Instruct-4bit | ~42 GB (context ≤ 8K) | | |
| 52 | +| embedding | Qwen3-Embedding-0.6B-8bit (MLX), embeddinggemma-300M (GGUF) | < 1 GB, can stay resident | | |
| 53 | +| reranker | Qwen3-Reranker-0.6B-4bit | < 1 GB | | |
| 54 | + | |
| 55 | +Versions verified against the installed runtimes: mlx 0.32.2, mlx-lm 0.31.3, mlx-vlm 0.7.0, llama.cpp 0.4.0 (Homebrew), huggingface-hub 1.31.0. | |
added
docs/security.md
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +# Security | |
| 2 | + | |
| 3 | +- **Private by default.** The API binds `127.0.0.1:8300`; workers bind `127.0.0.1:8310-8399`; the console binds `127.0.0.1:8301`. Only the WireGuard-connected gateway (Caddy on BHS64) reaches port 8300 over the tunnel; nothing else is exposed. | |
| 4 | +- **Admin auth**: single admin user, argon2 password hashes, signed session cookie (`itsdangerous`, HttpOnly, Secure behind HTTPS, SameSite=Lax, 14 days). First user is seeded from `ADMIN_EMAIL`/`ADMIN_PASSWORD` or created once via `/api/auth/setup` (only when no user exists). Login rate limit 8/min/IP. Password change requires the current password. | |
| 5 | +- **CSRF**: session-authenticated mutations require header `X-LLM-CSRF: 1` and, when an `Origin` is present, it must match the public URL / host. | |
| 6 | +- **API keys**: `llm_live_` + 40 random chars, stored as SHA-256; only the prefix is displayed after creation. Scopes `inference` / `admin`. Revocation is immediate. Per-key last-used and request counters. | |
| 7 | +- **Request validation**: JSON bodies ≤ 20 MB (middleware + parser), sampling ranges checked, ≤ 8 stop sequences, ≤ 256 embedding inputs, ≤ 200 rerank documents, `n=1` only. | |
| 8 | +- **Path safety**: repository ids validated (`org/name`, no `..`); model deletion refuses paths outside the model root; scanned paths are only read. | |
| 9 | +- **No shell from the API**: workers are started with `subprocess.Popen(list)` (no shell); user-supplied extra llama args are filtered (`|;&$\`` rejected) and only settable by an admin. | |
| 10 | +- **Secrets**: `.env` is `600` and outside the repo; `HF_TOKEN` only reaches the downloader; the session secret is generated into `data/.secret` (600). Keys, passwords and tokens are never logged. | |
| 11 | +- **Prompt privacy**: `LOG_PROMPTS=false` by default; when enabled, prompts/completions are stored truncated in `inference_requests`. | |
| 12 | +- **Audit log**: login/failed login/password change, key create/revoke, model load/unload/update/delete, downloads, harvest scans, settings changes, server start/stop — with actor and IP. | |
| 13 | +- **Headers**: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`, `Cache-Control: no-store` on API paths. Console is `noindex`. | |
| 14 | +- **Memory safety** is part of security here: a model that would exhaust unified memory is refused before any allocation; swap use raises a warning alert; workers are killed on unload so memory always returns. | |
added
docs/troubleshooting.md
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +# Troubleshooting | |
| 2 | + | |
| 3 | +| Symptom | Where to look | Likely cause / fix | | |
| 4 | +|---|---|---| | |
| 5 | +| `MODEL_NOT_FOUND` | `GET /v1/models`, Models page | id/alias typo; model files missing (`installed=false` after rescan) | | |
| 6 | +| `MODEL_TOO_LARGE` / `not_recommended` | model page → *Memory by context* | budget 45 GB exceeded; lower context (`overrides.context`), use a smaller quant, or raise the budget in Settings (never above RAM − 8 GB) | | |
| 7 | +| `MODEL_INCOMPATIBLE` (architecture) | `pip show mlx-lm`, `llama-server --version` | runtime too old for the architecture: upgrade mlx-lm / llama.cpp, rescan | | |
| 8 | +| `MODEL_LOAD_FAILED` with log tail | `~/llm-api/logs/workers/worker-<id>.log` | corrupted download (re-download with force), missing tokenizer file, unsupported quant | | |
| 9 | +| `MODEL_LOAD_TIMEOUT` | same log + Activity Monitor | very large model on a cold SSD cache; raise `LOAD_TIMEOUT_SECONDS` | | |
| 10 | +| `WORKER_CRASHED` | worker log, `dmesg`-style Metal errors | out-of-memory at the Metal level → lower context / KV bits (`overrides.kv_bits=8`) | | |
| 11 | +| `CONTEXT_TOO_LARGE` | request | prompt longer than the loaded context; reload with a larger `context` (model page) | | |
| 12 | +| `GENERATION_TIMEOUT` | Settings | `GENERATION_TIMEOUT_SECONDS`; check thermal throttling in System | | |
| 13 | +| Swap warning / memory pressure `critical` | Dashboard alert, System | model exceeds the envelope; unload, lower budget; never leave it swapping | | |
| 14 | +| Download fails 401/403 | Settings → HF token | gated repo: set `HF_TOKEN`, accept the license on Hugging Face | | |
| 15 | +| Download fails 429 | Downloads history | HF rate limit: retry later | | |
| 16 | +| `INSUFFICIENT_DISK` | System → Storage | `MIN_FREE_DISK_GB` reserve; delete unused models | | |
| 17 | +| Dashboard 503 "not reachable" | `pm2 logs llm-api-web`, port 8301 | Next not running / build failed (`pnpm build`) | | |
| 18 | +| Login loop | cookies | behind HTTPS set `SECURE_COOKIES=true`; ensure the proxy passes `X-Forwarded-Proto` | | |
| 19 | +| Model shows loaded after a crash | restart server | stale state is cleared at startup (`workers.json`) | | |
| 20 | +| GPU shows `—` | `ioreg -r -c IOAccelerator` | metric unavailable on this macOS; degrades gracefully | | |
| 21 | + | |
| 22 | +Logs: `~/llm-api/logs/llm-api.log` (server), `~/llm-api/logs/workers/*.log` (per model), `pm2 logs`. Events: `/api/logs/events`, audit `/api/logs/audit`. | |
| 23 | + | |
| 24 | +Handy commands on the node: | |
| 25 | + | |
| 26 | +```bash | |
| 27 | +source ~/apps/llm-api/server/.venv/bin/activate && cd ~/llm-api | |
| 28 | +llm-api status # hardware + telemetry + model count | |
| 29 | +llm-api scan # rescan and list registry with compatibility | |
| 30 | +pgrep -fl "mlx_worker|llama-server" | |
| 31 | +curl -s localhost:8300/health | |
| 32 | +``` | |
added
scripts/install.sh
+98 −0
@@ -0,0 +1,98 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# LLM API — install on a clean Apple Silicon Mac. | |
| 3 | +# Usage: ./scripts/install.sh [--root ~/llm-api] [--no-llama] [--no-web] [--test] | |
| 4 | +set -euo pipefail | |
| 5 | + | |
| 6 | +ROOT="${LLM_API_ROOT:-$HOME/llm-api}" | |
| 7 | +WITH_LLAMA=1 | |
| 8 | +WITH_WEB=1 | |
| 9 | +RUN_TEST=0 | |
| 10 | +for a in "$@"; do | |
| 11 | + case "$a" in | |
| 12 | + --root) shift; ROOT="$1";; | |
| 13 | + --root=*) ROOT="${a#--root=}";; | |
| 14 | + --no-llama) WITH_LLAMA=0;; | |
| 15 | + --no-web) WITH_WEB=0;; | |
| 16 | + --test) RUN_TEST=1;; | |
| 17 | + esac | |
| 18 | +done | |
| 19 | +HERE="$(cd "$(dirname "$0")/.." && pwd)" | |
| 20 | +say() { printf "\033[1;34m==>\033[0m %s\n" "$*"; } | |
| 21 | +warn() { printf "\033[1;33m!!\033[0m %s\n" "$*"; } | |
| 22 | + | |
| 23 | +# 1. platform checks ------------------------------------------------------- | |
| 24 | +[ "$(uname -s)" = "Darwin" ] || { echo "macOS required"; exit 1; } | |
| 25 | +[ "$(uname -m)" = "arm64" ] || { echo "Apple Silicon (arm64) required"; exit 1; } | |
| 26 | +CHIP=$(sysctl -n machdep.cpu.brand_string) | |
| 27 | +MEM_GB=$(( $(sysctl -n hw.memsize) / 1024 / 1024 / 1024 )) | |
| 28 | +FREE_GB=$(df -g "$HOME" | awk 'NR==2 {print $4}') | |
| 29 | +say "Detected $CHIP, ${MEM_GB} GB unified memory, ${FREE_GB} GB free on $HOME" | |
| 30 | +[ "$MEM_GB" -ge 16 ] || warn "Less than 16 GB of memory: only small models will run." | |
| 31 | +[ "$FREE_GB" -ge 120 ] || warn "Less than 120 GB free: downloads stop at the MIN_FREE_DISK_GB reserve." | |
| 32 | +export PATH="/opt/homebrew/bin:$HOME/.local/bin:$PATH" | |
| 33 | + | |
| 34 | +# 2. toolchain --------------------------------------------------------------- | |
| 35 | +xcode-select -p >/dev/null 2>&1 || { say "Installing Xcode Command Line Tools (a dialog may appear)"; xcode-select --install || true; } | |
| 36 | +command -v brew >/dev/null || { say "Installing Homebrew"; /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; eval "$(/opt/homebrew/bin/brew shellenv)"; } | |
| 37 | +command -v uv >/dev/null || { say "Installing uv"; brew install uv; } | |
| 38 | +if [ "$WITH_WEB" = 1 ]; then | |
| 39 | + command -v node >/dev/null || { say "Installing Node"; brew install node; } | |
| 40 | + command -v pnpm >/dev/null || { say "Installing pnpm"; brew install pnpm; } | |
| 41 | +fi | |
| 42 | +if [ "$WITH_LLAMA" = 1 ]; then | |
| 43 | + command -v llama-server >/dev/null || { say "Installing llama.cpp (Metal)"; brew install llama.cpp; } | |
| 44 | +fi | |
| 45 | + | |
| 46 | +# 3. directories --------------------------------------------------------------- | |
| 47 | +say "Creating $ROOT" | |
| 48 | +mkdir -p "$ROOT"/{models/{mlx,gguf,embeddings,rerankers,vision,manifests},data,logs} | |
| 49 | +chmod 700 "$ROOT/data" | |
| 50 | + | |
| 51 | +# 4. python env ----------------------------------------------------------------- | |
| 52 | +cd "$HERE/server" | |
| 53 | +if [ ! -x .venv/bin/python ]; then | |
| 54 | + say "Creating Python 3.13 virtualenv" | |
| 55 | + uv venv --python 3.13 .venv | |
| 56 | +fi | |
| 57 | +say "Installing Python dependencies (MLX, MLX-LM, mlx-vlm, FastAPI…)" | |
| 58 | +uv pip install --python .venv/bin/python -q -e ".[mlx,test]" "mlx-vlm>=0.7" asgi-lifespan | |
| 59 | +.venv/bin/python - <<'PY' | |
| 60 | +import mlx.core as mx, mlx_lm | |
| 61 | +print(f" mlx {mx.__version__} · mlx-lm {mlx_lm.__version__} · Metal device: {mx.default_device()}") | |
| 62 | +PY | |
| 63 | + | |
| 64 | +# 5. web ------------------------------------------------------------------------ | |
| 65 | +if [ "$WITH_WEB" = 1 ]; then | |
| 66 | + say "Installing and building the dashboard" | |
| 67 | + (cd "$HERE/web" && pnpm install --frozen-lockfile --silent && NEXT_TELEMETRY_DISABLED=1 pnpm build | tail -3) | |
| 68 | +fi | |
| 69 | + | |
| 70 | +# 6. env file (never overwrite) ---------------------------------------------------- | |
| 71 | +if [ ! -f "$ROOT/.env" ]; then | |
| 72 | + say "Writing $ROOT/.env from .env.example (edit ADMIN_EMAIL / ADMIN_PASSWORD / HF_TOKEN)" | |
| 73 | + sed "s#/Users/USERNAME/llm-api#$ROOT#" "$HERE/.env.example" > "$ROOT/.env" | |
| 74 | + chmod 600 "$ROOT/.env" | |
| 75 | +else | |
| 76 | + say "$ROOT/.env already exists — left untouched" | |
| 77 | +fi | |
| 78 | + | |
| 79 | +# 7. database + scan ------------------------------------------------------------------ | |
| 80 | +say "Initializing database and scanning $ROOT/models" | |
| 81 | +(cd "$ROOT" && "$HERE/server/.venv/bin/llm-api" scan | tail -5) | |
| 82 | + | |
| 83 | +# 8. optional local inference test ------------------------------------------------------- | |
| 84 | +if [ "$RUN_TEST" = 1 ]; then | |
| 85 | + say "Downloading a tiny test model and running one generation" | |
| 86 | + "$HERE/server/.venv/bin/hf" download mlx-community/Qwen3-0.6B-4bit --local-dir "$ROOT/models/mlx/qwen/Qwen3-0.6B-4bit" >/dev/null | |
| 87 | + "$HERE/server/.venv/bin/python" -m mlx_lm generate --model "$ROOT/models/mlx/qwen/Qwen3-0.6B-4bit" --prompt "Say hello in one word." --max-tokens 8 | tail -3 | |
| 88 | +fi | |
| 89 | + | |
| 90 | +cat <<EOF | |
| 91 | + | |
| 92 | +Done. Next steps: | |
| 93 | + 1. Edit $ROOT/.env (ADMIN_EMAIL, ADMIN_PASSWORD, HF_TOKEN, PUBLIC_URL) | |
| 94 | + 2. Start the API: cd $ROOT && $HERE/server/.venv/bin/llm-api serve | |
| 95 | + 3. Start the dashboard: cd $HERE/web && pnpm start -p 8301 -H 127.0.0.1 | |
| 96 | + 4. Open http://127.0.0.1:8300 — or put a TLS reverse proxy in front for https://$(grep -E '^DOMAIN=' "$ROOT/.env" | cut -d= -f2) | |
| 97 | + Process management: see docs/deployment.md (PM2 / launchd examples). | |
| 98 | +EOF | |
added
server/llm_api/__init__.py
+3 −0
@@ -0,0 +1,3 @@ | ||
| 1 | +"""LLM API — private OpenAI-compatible local model server for Apple Silicon.""" | |
| 2 | + | |
| 3 | +__version__ = "0.1.0" | |
added
server/llm_api/api/__init__.py
+0 −0
added
server/llm_api/api/admin_routes.py
+809 −0
@@ -0,0 +1,809 @@ | ||
| 1 | +"""Private management API (/api/*): auth, models, system, runtime, downloads, harvest, keys, settings, events.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import asyncio | |
| 6 | +import json | |
| 7 | +import time | |
| 8 | +from pathlib import Path | |
| 9 | +from typing import Any | |
| 10 | + | |
| 11 | +from fastapi import APIRouter, Depends, Query, Request, Response | |
| 12 | +from fastapi.responses import StreamingResponse | |
| 13 | +from pydantic import BaseModel, Field | |
| 14 | + | |
| 15 | +from ..auth import SESSION_COOKIE, Principal, get_auth, login_limiter, require_admin | |
| 16 | +from ..errors import APIError, AuthError, Conflict, ModelNotFound, RateLimited | |
| 17 | +from ..events import bus | |
| 18 | +from ..hardware import GB, detect_hardware | |
| 19 | + | |
| 20 | +router = APIRouter(prefix="/api") | |
| 21 | + | |
| 22 | +SETTINGS_KEYS = { | |
| 23 | + "max_model_memory_gb": float, "absolute_max_memory_gb": float, "min_free_disk_gb": float, | |
| 24 | + "model_idle_timeout_minutes": int, "max_simultaneous_models": int, "preferred_runtime": str, | |
| 25 | + "default_model": str, "preload_model": str, "log_prompts": bool, "allow_downloads": bool, | |
| 26 | + "allow_gguf": bool, "allow_mlx": bool, "default_context": int, "default_max_tokens": int, | |
| 27 | + "benchmark_max_tokens": int, "benchmark_runs": int, | |
| 28 | +} | |
| 29 | + | |
| 30 | + | |
| 31 | +def _ip(request: Request) -> str: | |
| 32 | + fwd = request.headers.get("x-forwarded-for") | |
| 33 | + return fwd.split(",")[0].strip() if fwd else (request.client.host if request.client else "?") | |
| 34 | + | |
| 35 | + | |
| 36 | +def _actor(request: Request) -> str: | |
| 37 | + p = getattr(request.state, "principal", None) | |
| 38 | + return f"{p.kind}:{p.name}" if p else "anonymous" | |
| 39 | + | |
| 40 | + | |
| 41 | +# --------------------------------------------------------------------------- | |
| 42 | +# Auth | |
| 43 | +# --------------------------------------------------------------------------- | |
| 44 | + | |
| 45 | + | |
| 46 | +class LoginBody(BaseModel): | |
| 47 | + email: str | |
| 48 | + password: str | |
| 49 | + | |
| 50 | + | |
| 51 | +class SetupBody(BaseModel): | |
| 52 | + email: str | |
| 53 | + password: str = Field(min_length=10) | |
| 54 | + | |
| 55 | + | |
| 56 | +class PasswordBody(BaseModel): | |
| 57 | + current_password: str | |
| 58 | + new_password: str = Field(min_length=10) | |
| 59 | + | |
| 60 | + | |
| 61 | +def _set_cookie(response: Response, request: Request, token: str) -> None: | |
| 62 | + settings = request.app.state.settings | |
| 63 | + secure = settings.secure_cookies or request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https" | |
| 64 | + response.set_cookie(SESSION_COOKIE, token, httponly=True, secure=secure, samesite="lax", | |
| 65 | + max_age=settings.session_hours * 3600, path="/") | |
| 66 | + | |
| 67 | + | |
| 68 | +@router.get("/auth/status") | |
| 69 | +async def auth_status(request: Request): | |
| 70 | + auth = get_auth(request) | |
| 71 | + from ..auth import principal_from_request | |
| 72 | + p = await principal_from_request(request, auth) | |
| 73 | + return {"needs_setup": (await auth.user_count()) == 0, "authenticated": bool(p and p.has("admin")), | |
| 74 | + "principal": {"kind": p.kind, "name": p.name} if p else None} | |
| 75 | + | |
| 76 | + | |
| 77 | +@router.post("/auth/setup") | |
| 78 | +async def auth_setup(body: SetupBody, request: Request, response: Response): | |
| 79 | + auth = get_auth(request) | |
| 80 | + if await auth.user_count() > 0: | |
| 81 | + raise Conflict("Setup already completed.") | |
| 82 | + uid = await auth.create_user(body.email, body.password) | |
| 83 | + token = auth.make_session(uid, body.email.lower()) | |
| 84 | + _set_cookie(response, request, token) | |
| 85 | + await request.app.state.db.audit("auth.setup", actor=body.email, ip=_ip(request)) | |
| 86 | + return {"ok": True, "email": body.email.lower()} | |
| 87 | + | |
| 88 | + | |
| 89 | +@router.post("/auth/login") | |
| 90 | +async def auth_login(body: LoginBody, request: Request, response: Response): | |
| 91 | + auth = get_auth(request) | |
| 92 | + if not login_limiter.check(_ip(request)): | |
| 93 | + raise RateLimited("Too many login attempts. Try again in a minute.") | |
| 94 | + user = await auth.authenticate(body.email, body.password) | |
| 95 | + if not user: | |
| 96 | + await request.app.state.db.audit("auth.login_failed", actor=body.email, ip=_ip(request)) | |
| 97 | + raise AuthError("Invalid email or password.", code="INVALID_CREDENTIALS") | |
| 98 | + token = auth.make_session(user["id"], user["email"]) | |
| 99 | + _set_cookie(response, request, token) | |
| 100 | + await request.app.state.db.audit("auth.login", actor=user["email"], ip=_ip(request)) | |
| 101 | + return {"ok": True, "email": user["email"]} | |
| 102 | + | |
| 103 | + | |
| 104 | +@router.post("/auth/logout") | |
| 105 | +async def auth_logout(request: Request, response: Response): | |
| 106 | + response.delete_cookie(SESSION_COOKIE, path="/") | |
| 107 | + return {"ok": True} | |
| 108 | + | |
| 109 | + | |
| 110 | +@router.get("/auth/me") | |
| 111 | +async def auth_me(request: Request, p: Principal = Depends(require_admin)): | |
| 112 | + return {"kind": p.kind, "name": p.name, "scopes": sorted(p.scopes)} | |
| 113 | + | |
| 114 | + | |
| 115 | +@router.post("/auth/password") | |
| 116 | +async def auth_password(body: PasswordBody, request: Request, p: Principal = Depends(require_admin)): | |
| 117 | + auth = get_auth(request) | |
| 118 | + if p.kind != "session": | |
| 119 | + raise APIError("Password changes require a dashboard session.") | |
| 120 | + user = await auth.authenticate(p.name, body.current_password) | |
| 121 | + if not user: | |
| 122 | + raise AuthError("Current password is incorrect.", code="INVALID_CREDENTIALS") | |
| 123 | + await auth.change_password(user["id"], body.new_password) | |
| 124 | + await request.app.state.db.audit("auth.password_changed", actor=p.name, ip=_ip(request)) | |
| 125 | + return {"ok": True} | |
| 126 | + | |
| 127 | + | |
| 128 | +# --------------------------------------------------------------------------- | |
| 129 | +# Models | |
| 130 | +# --------------------------------------------------------------------------- | |
| 131 | + | |
| 132 | + | |
| 133 | +def _decorate(manager, m: dict) -> dict: | |
| 134 | + st = manager.status_of(m["id"]) | |
| 135 | + m["status"] = st | |
| 136 | + m["loaded"] = st == "ready" | |
| 137 | + lm = manager.loaded.get(m["id"]) | |
| 138 | + if lm: | |
| 139 | + m["worker"] = lm.to_dict() | |
| 140 | + p = manager.progress.get(m["id"]) | |
| 141 | + if p: | |
| 142 | + m["progress"] = p | |
| 143 | + return m | |
| 144 | + | |
| 145 | + | |
| 146 | +@router.get("/models") | |
| 147 | +async def list_models(request: Request, p: Principal = Depends(require_admin), include_missing: bool = False): | |
| 148 | + manager = request.app.state.manager | |
| 149 | + models = await manager.registry.list_models(include_missing=include_missing) | |
| 150 | + aliases = await manager.registry.aliases() | |
| 151 | + by_model: dict[str, list[str]] = {} | |
| 152 | + for a, mid in aliases.items(): | |
| 153 | + by_model.setdefault(mid, []).append(a) | |
| 154 | + for m in models: | |
| 155 | + _decorate(manager, m) | |
| 156 | + m["aliases"] = by_model.get(m["id"], []) | |
| 157 | + return {"models": models, "aliases": aliases, "count": len(models)} | |
| 158 | + | |
| 159 | + | |
| 160 | +@router.get("/models/{model_id}") | |
| 161 | +async def get_model(model_id: str, request: Request, p: Principal = Depends(require_admin)): | |
| 162 | + manager = request.app.state.manager | |
| 163 | + m = await manager.registry.resolve(model_id) | |
| 164 | + if not m: | |
| 165 | + raise ModelNotFound(f"Model '{model_id}' not found.") | |
| 166 | + _decorate(manager, m) | |
| 167 | + db = request.app.state.db | |
| 168 | + m["benchmarks"] = await db.fetchall("SELECT * FROM model_benchmarks WHERE model_id=? ORDER BY created_at DESC LIMIT 30", (m["id"],)) | |
| 169 | + for b in m["benchmarks"]: | |
| 170 | + for k in ("params", "notes"): | |
| 171 | + if b.get(k): | |
| 172 | + try: | |
| 173 | + b[k] = json.loads(b[k]) | |
| 174 | + except Exception: | |
| 175 | + pass | |
| 176 | + m["events"] = await db.fetchall("SELECT * FROM model_events WHERE model_id=? ORDER BY created_at DESC LIMIT 50", (m["id"],)) | |
| 177 | + m["aliases"] = [a for a, mid in (await manager.registry.aliases()).items() if mid == m["id"]] | |
| 178 | + m["recent_requests"] = await db.fetchall( | |
| 179 | + "SELECT created_at, endpoint, prompt_tokens, completion_tokens, ttft_ms, total_ms, tps, status FROM inference_requests " | |
| 180 | + "WHERE model_id=? ORDER BY created_at DESC LIMIT 20", (m["id"],)) | |
| 181 | + from ..models.estimator import CONTEXT_STEPS, estimate | |
| 182 | + rt = m["runtime"] | |
| 183 | + m["memory_curve"] = [estimate(m["weights_bytes"], rt, m["kv_bytes_per_token"] or 0, c, m["vision"]).to_dict() | |
| 184 | + for c in CONTEXT_STEPS if not m["max_context"] or c <= max(m["max_context"], 2048)] | |
| 185 | + return m | |
| 186 | + | |
| 187 | + | |
| 188 | +@router.get("/models/{model_id}/files") | |
| 189 | +async def model_files(model_id: str, request: Request, p: Principal = Depends(require_admin)): | |
| 190 | + manager = request.app.state.manager | |
| 191 | + m = await manager.registry.resolve(model_id) | |
| 192 | + if not m: | |
| 193 | + raise ModelNotFound(f"Model '{model_id}' not found.") | |
| 194 | + return {"path": m["path"], "files": await manager.registry.files(m["id"])} | |
| 195 | + | |
| 196 | + | |
| 197 | +class LoadBody(BaseModel): | |
| 198 | + context: int | None = None | |
| 199 | + force: bool = False | |
| 200 | + | |
| 201 | + | |
| 202 | +@router.post("/models/{model_id}/load") | |
| 203 | +async def load_model(model_id: str, request: Request, body: LoadBody | None = None, p: Principal = Depends(require_admin)): | |
| 204 | + manager = request.app.state.manager | |
| 205 | + body = body or LoadBody() | |
| 206 | + lm = await manager.load(model_id, context=body.context, force=body.force) | |
| 207 | + await request.app.state.db.audit("model.load", actor=_actor(request), target=lm.model["id"], ip=_ip(request)) | |
| 208 | + return {"ok": True, "worker": lm.to_dict()} | |
| 209 | + | |
| 210 | + | |
| 211 | +@router.post("/models/{model_id}/unload") | |
| 212 | +async def unload_model(model_id: str, request: Request, p: Principal = Depends(require_admin)): | |
| 213 | + manager = request.app.state.manager | |
| 214 | + m = await manager.registry.resolve(model_id) | |
| 215 | + if not m: | |
| 216 | + raise ModelNotFound(f"Model '{model_id}' not found.") | |
| 217 | + ok = await manager.unload(m["id"], reason="manual") | |
| 218 | + await request.app.state.db.audit("model.unload", actor=_actor(request), target=m["id"], ip=_ip(request)) | |
| 219 | + return {"ok": ok, "status": manager.status_of(m["id"])} | |
| 220 | + | |
| 221 | + | |
| 222 | +class PatchModel(BaseModel): | |
| 223 | + favorite: bool | None = None | |
| 224 | + pinned: bool | None = None | |
| 225 | + enabled: bool | None = None | |
| 226 | + notes: str | None = None | |
| 227 | + tags: list[str] | None = None | |
| 228 | + name: str | None = None | |
| 229 | + overrides: dict[str, Any] | None = None | |
| 230 | + | |
| 231 | + | |
| 232 | +@router.patch("/models/{model_id}") | |
| 233 | +async def patch_model(model_id: str, body: PatchModel, request: Request, p: Principal = Depends(require_admin)): | |
| 234 | + manager = request.app.state.manager | |
| 235 | + m = await manager.registry.resolve(model_id) | |
| 236 | + if not m: | |
| 237 | + raise ModelNotFound(f"Model '{model_id}' not found.") | |
| 238 | + fields = {k: v for k, v in body.model_dump().items() if v is not None} | |
| 239 | + if "overrides" in fields: | |
| 240 | + ov = fields["overrides"] | |
| 241 | + allowed = {"task", "vision", "embedding", "reranker", "family", "quantization", "max_context", "context", "kv_bits", | |
| 242 | + "parameter_count", "pooling", "llama_args", "thinking", "tools", "name"} | |
| 243 | + fields["overrides"] = {k: v for k, v in ov.items() if k in allowed} | |
| 244 | + if fields: | |
| 245 | + await manager.registry.update(m["id"], **fields) | |
| 246 | + if "overrides" in fields: | |
| 247 | + await manager.registry.rescan() | |
| 248 | + await manager.refresh_model(m["id"]) | |
| 249 | + await request.app.state.db.audit("model.update", actor=_actor(request), target=m["id"], detail=fields, ip=_ip(request)) | |
| 250 | + return _decorate(manager, await manager.registry.get(m["id"])) # type: ignore[arg-type] | |
| 251 | + | |
| 252 | + | |
| 253 | +@router.post("/models/{model_id}/pin") | |
| 254 | +async def pin_model(model_id: str, request: Request, p: Principal = Depends(require_admin), pinned: bool = True): | |
| 255 | + manager = request.app.state.manager | |
| 256 | + m = await manager.registry.resolve(model_id) | |
| 257 | + if not m: | |
| 258 | + raise ModelNotFound(f"Model '{model_id}' not found.") | |
| 259 | + await manager.registry.update(m["id"], pinned=pinned) | |
| 260 | + await manager.refresh_model(m["id"]) | |
| 261 | + return {"ok": True, "pinned": pinned} | |
| 262 | + | |
| 263 | + | |
| 264 | +@router.post("/models/{model_id}/favorite") | |
| 265 | +async def favorite_model(model_id: str, request: Request, p: Principal = Depends(require_admin), favorite: bool = True): | |
| 266 | + manager = request.app.state.manager | |
| 267 | + m = await manager.registry.resolve(model_id) | |
| 268 | + if not m: | |
| 269 | + raise ModelNotFound(f"Model '{model_id}' not found.") | |
| 270 | + await manager.registry.update(m["id"], favorite=favorite) | |
| 271 | + return {"ok": True, "favorite": favorite} | |
| 272 | + | |
| 273 | + | |
| 274 | +class DeleteBody(BaseModel): | |
| 275 | + confirm: str | |
| 276 | + keep_benchmarks: bool = True | |
| 277 | + | |
| 278 | + | |
| 279 | +@router.delete("/models/{model_id}") | |
| 280 | +async def delete_model(model_id: str, body: DeleteBody, request: Request, p: Principal = Depends(require_admin)): | |
| 281 | + manager = request.app.state.manager | |
| 282 | + m = await manager.registry.resolve(model_id) | |
| 283 | + if not m: | |
| 284 | + raise ModelNotFound(f"Model '{model_id}' not found.") | |
| 285 | + if body.confirm != m["id"]: | |
| 286 | + raise APIError("Type the exact model id in 'confirm' to delete it.", code="CONFIRMATION_REQUIRED") | |
| 287 | + if manager.loaded.get(m["id"]): | |
| 288 | + raise Conflict("Unload the model before deleting it.") | |
| 289 | + return await request.app.state.downloader.delete_model(m["id"], actor=_actor(request), keep_benchmarks=body.keep_benchmarks) | |
| 290 | + | |
| 291 | + | |
| 292 | +@router.post("/models/rescan") | |
| 293 | +async def rescan(request: Request, p: Principal = Depends(require_admin)): | |
| 294 | + state = request.app.state | |
| 295 | + | |
| 296 | + async def run(job): | |
| 297 | + def prog(frac, name): | |
| 298 | + state.jobs.update(job, progress=frac, current=name) | |
| 299 | + summary = await state.manager.registry.rescan(progress=prog) | |
| 300 | + bus.publish("models", {"event": "rescan", **summary}) | |
| 301 | + return summary | |
| 302 | + | |
| 303 | + job = state.jobs.submit("scan", "Rescan model directory", {}, run) | |
| 304 | + # Scans are quick: wait a bit so callers get the result directly when possible | |
| 305 | + for _ in range(100): | |
| 306 | + await asyncio.sleep(0.1) | |
| 307 | + if job.status in ("completed", "failed"): | |
| 308 | + break | |
| 309 | + return {"job": job.to_dict()} | |
| 310 | + | |
| 311 | + | |
| 312 | +class BenchBody(BaseModel): | |
| 313 | + max_tokens: int = 256 | |
| 314 | + runs: int = 2 | |
| 315 | + long_prompt: bool = True | |
| 316 | + | |
| 317 | + | |
| 318 | +@router.post("/models/{model_id}/benchmark") | |
| 319 | +async def benchmark(model_id: str, request: Request, body: BenchBody | None = None, p: Principal = Depends(require_admin)): | |
| 320 | + state = request.app.state | |
| 321 | + m = await state.manager.registry.resolve(model_id) | |
| 322 | + if not m: | |
| 323 | + raise ModelNotFound(f"Model '{model_id}' not found.") | |
| 324 | + body = body or BenchBody() | |
| 325 | + from ..bench import run_benchmark | |
| 326 | + | |
| 327 | + async def run(job): | |
| 328 | + return await run_benchmark(state, m["id"], job, body.model_dump()) | |
| 329 | + | |
| 330 | + job = state.jobs.submit("benchmark", f"Benchmark {m['name']}", {"model_id": m["id"], **body.model_dump()}, run) | |
| 331 | + return {"job": job.to_dict()} | |
| 332 | + | |
| 333 | + | |
| 334 | +@router.get("/models/{model_id}/benchmarks") | |
| 335 | +async def benchmarks(model_id: str, request: Request, p: Principal = Depends(require_admin)): | |
| 336 | + rows = await request.app.state.db.fetchall("SELECT * FROM model_benchmarks WHERE model_id=? ORDER BY created_at DESC LIMIT 100", (model_id,)) | |
| 337 | + for b in rows: | |
| 338 | + for k in ("params", "notes"): | |
| 339 | + if b.get(k): | |
| 340 | + try: | |
| 341 | + b[k] = json.loads(b[k]) | |
| 342 | + except Exception: | |
| 343 | + pass | |
| 344 | + return {"benchmarks": rows} | |
| 345 | + | |
| 346 | + | |
| 347 | +# --------------------------------------------------------------------------- | |
| 348 | +# Aliases | |
| 349 | +# --------------------------------------------------------------------------- | |
| 350 | + | |
| 351 | + | |
| 352 | +class AliasBody(BaseModel): | |
| 353 | + alias: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]*$") | |
| 354 | + model_id: str | |
| 355 | + | |
| 356 | + | |
| 357 | +@router.get("/aliases") | |
| 358 | +async def list_aliases(request: Request, p: Principal = Depends(require_admin)): | |
| 359 | + return {"aliases": await request.app.state.manager.registry.aliases()} | |
| 360 | + | |
| 361 | + | |
| 362 | +@router.put("/aliases") | |
| 363 | +async def put_alias(body: AliasBody, request: Request, p: Principal = Depends(require_admin)): | |
| 364 | + reg = request.app.state.manager.registry | |
| 365 | + m = await reg.get(body.model_id) | |
| 366 | + if not m: | |
| 367 | + raise ModelNotFound(f"Model '{body.model_id}' not found.") | |
| 368 | + if await reg.get(body.alias): | |
| 369 | + raise Conflict("An installed model already has this id.") | |
| 370 | + if body.alias == "auto": | |
| 371 | + raise APIError("'auto' is reserved.") | |
| 372 | + await reg.set_alias(body.alias, m["id"]) | |
| 373 | + await request.app.state.db.audit("alias.set", actor=_actor(request), target=body.alias, detail=body.model_id) | |
| 374 | + return {"aliases": await reg.aliases()} | |
| 375 | + | |
| 376 | + | |
| 377 | +@router.delete("/aliases/{alias}") | |
| 378 | +async def delete_alias(alias: str, request: Request, p: Principal = Depends(require_admin)): | |
| 379 | + reg = request.app.state.manager.registry | |
| 380 | + await reg.delete_alias(alias) | |
| 381 | + return {"aliases": await reg.aliases()} | |
| 382 | + | |
| 383 | + | |
| 384 | +# --------------------------------------------------------------------------- | |
| 385 | +# System / runtime | |
| 386 | +# --------------------------------------------------------------------------- | |
| 387 | + | |
| 388 | + | |
| 389 | +@router.get("/system") | |
| 390 | +async def system(request: Request, p: Principal = Depends(require_admin)): | |
| 391 | + state = request.app.state | |
| 392 | + hw = detect_hardware(state.settings.models_dir).to_dict() | |
| 393 | + tel = state.metrics.last or await state.metrics.sample() | |
| 394 | + budget, absolute, max_models = await state.manager.budgets() | |
| 395 | + return {"hardware": hw, "telemetry": tel, "manager": state.manager.snapshot(), | |
| 396 | + "policy": {"max_model_memory_gb": budget, "absolute_max_memory_gb": absolute, "max_simultaneous_models": max_models, | |
| 397 | + "macos_reserve_gb": state.settings.macos_reserve_gb, | |
| 398 | + "min_free_disk_gb": float(await state.db.get_setting("min_free_disk_gb", state.settings.min_free_disk_gb))}, | |
| 399 | + "version": request.app.version, "started_at": state.metrics.started_at, | |
| 400 | + "runtimes": await _runtime_versions()} | |
| 401 | + | |
| 402 | + | |
| 403 | +async def _runtime_versions() -> dict: | |
| 404 | + out: dict[str, Any] = {} | |
| 405 | + try: | |
| 406 | + import mlx.core as mx | |
| 407 | + import mlx_lm | |
| 408 | + out["mlx"] = mx.__version__ | |
| 409 | + out["mlx_lm"] = mlx_lm.__version__ | |
| 410 | + except Exception: | |
| 411 | + out["mlx"] = None | |
| 412 | + try: | |
| 413 | + import mlx_vlm | |
| 414 | + out["mlx_vlm"] = mlx_vlm.__version__ | |
| 415 | + except Exception: | |
| 416 | + out["mlx_vlm"] = None | |
| 417 | + try: | |
| 418 | + import shutil | |
| 419 | + import subprocess | |
| 420 | + b = shutil.which("llama-server") or "/opt/homebrew/bin/llama-server" | |
| 421 | + r = subprocess.run([b, "--version"], capture_output=True, text=True, timeout=5) | |
| 422 | + out["llama_cpp"] = (r.stdout + r.stderr).strip().splitlines()[0][:80] if (r.stdout or r.stderr) else None | |
| 423 | + except Exception: | |
| 424 | + out["llama_cpp"] = None | |
| 425 | + return out | |
| 426 | + | |
| 427 | + | |
| 428 | +@router.get("/system/memory") | |
| 429 | +async def system_memory(request: Request, p: Principal = Depends(require_admin)): | |
| 430 | + tel = await request.app.state.metrics.sample() | |
| 431 | + return {k: tel[k] for k in tel if k.startswith(("mem_", "swap_", "worker_", "loaded_"))} | |
| 432 | + | |
| 433 | + | |
| 434 | +@router.get("/system/gpu") | |
| 435 | +async def system_gpu(request: Request, p: Principal = Depends(require_admin)): | |
| 436 | + tel = await request.app.state.metrics.sample() | |
| 437 | + hw = detect_hardware(request.app.state.settings.models_dir) | |
| 438 | + return {"gpu_cores": hw.gpu_cores, "chip": hw.chip, "gpu_percent": tel["gpu_percent"], | |
| 439 | + "gpu_renderer_percent": tel["gpu_renderer_percent"], "gpu_memory_gb": tel["gpu_memory_gb"], | |
| 440 | + "thermal_state": tel["thermal_state"]} | |
| 441 | + | |
| 442 | + | |
| 443 | +@router.get("/system/storage") | |
| 444 | +async def system_storage(request: Request, p: Principal = Depends(require_admin)): | |
| 445 | + state = request.app.state | |
| 446 | + settings = state.settings | |
| 447 | + import shutil | |
| 448 | + | |
| 449 | + def du(path: Path) -> int: | |
| 450 | + total = 0 | |
| 451 | + if path.exists(): | |
| 452 | + for f in path.rglob("*"): | |
| 453 | + if f.is_file(): | |
| 454 | + try: | |
| 455 | + total += f.stat().st_size | |
| 456 | + except OSError: | |
| 457 | + pass | |
| 458 | + return total | |
| 459 | + | |
| 460 | + usage = shutil.disk_usage(settings.models_dir) | |
| 461 | + models_bytes, logs_bytes, db_bytes = await asyncio.gather( | |
| 462 | + asyncio.to_thread(du, settings.models_dir), asyncio.to_thread(du, settings.logs_path), | |
| 463 | + asyncio.to_thread(lambda: sum(du(p) for p in [settings.db_path] if p.exists()) + du(settings.data_path))) | |
| 464 | + hf_cache = Path.home() / ".cache" / "huggingface" | |
| 465 | + cache_bytes = await asyncio.to_thread(du, hf_cache) | |
| 466 | + by_model = await state.db.fetchall("SELECT id, name, disk_size_bytes, runtime, last_used_at FROM models WHERE installed=1 ORDER BY disk_size_bytes DESC") | |
| 467 | + return {"total_gb": round(usage.total / GB, 1), "used_gb": round(usage.used / GB, 1), "free_gb": round(usage.free / GB, 1), | |
| 468 | + "models_gb": round(models_bytes / GB, 2), "logs_gb": round(logs_bytes / GB, 3), "database_gb": round(db_bytes / GB, 3), | |
| 469 | + "cache_gb": round(cache_bytes / GB, 2), "other_gb": round(max(0, usage.used - models_bytes - logs_bytes - db_bytes - cache_bytes) / GB, 1), | |
| 470 | + "model_root": str(settings.models_dir), "models": by_model, | |
| 471 | + "min_free_gb": float(await state.db.get_setting("min_free_disk_gb", settings.min_free_disk_gb))} | |
| 472 | + | |
| 473 | + | |
| 474 | +@router.get("/system/processes") | |
| 475 | +async def system_processes(request: Request, p: Principal = Depends(require_admin)): | |
| 476 | + import psutil | |
| 477 | + manager = request.app.state.manager | |
| 478 | + procs = [] | |
| 479 | + for lm in manager.loaded.values(): | |
| 480 | + try: | |
| 481 | + pr = psutil.Process(lm.handle.pid) | |
| 482 | + procs.append({"model_id": lm.model["id"], "pid": lm.handle.pid, "port": lm.handle.port, "runtime": lm.handle.runtime, | |
| 483 | + "rss_gb": round(lm.handle.memory_bytes() / GB, 2), "cpu_percent": pr.cpu_percent(interval=None), | |
| 484 | + "status": lm.status, "threads": pr.num_threads(), "created": pr.create_time()}) | |
| 485 | + except psutil.Error: | |
| 486 | + pass | |
| 487 | + me = psutil.Process() | |
| 488 | + return {"workers": procs, "server": {"pid": me.pid, "rss_gb": round(me.memory_info().rss / GB, 3), | |
| 489 | + "cpu_percent": me.cpu_percent(interval=None), "threads": me.num_threads()}} | |
| 490 | + | |
| 491 | + | |
| 492 | +@router.get("/system/health") | |
| 493 | +async def system_health_admin(request: Request): | |
| 494 | + return await public_health(request) | |
| 495 | + | |
| 496 | + | |
| 497 | +async def public_health(request: Request) -> dict: | |
| 498 | + state = request.app.state | |
| 499 | + hw = detect_hardware(state.settings.models_dir) | |
| 500 | + tel = state.metrics.last or await state.metrics.sample() | |
| 501 | + cur = state.manager.current_model() | |
| 502 | + return {"status": "ok", "hardware": {"chip": hw.chip, "memory_gb": hw.memory_gb, "gpu_cores": hw.gpu_cores}, | |
| 503 | + "model": {"loaded": cur is not None, "id": cur.model["id"] if cur else None, | |
| 504 | + "loaded_models": [lm.model["id"] for lm in state.manager.loaded.values() if lm.status == "ready"]}, | |
| 505 | + "memory": {"used_gb": tel["mem_used_gb"], "available_gb": tel["mem_available_gb"], "pressure": tel["mem_pressure_level"]}, | |
| 506 | + "uptime_seconds": round(time.time() - state.metrics.started_at), "version": request.app.version} | |
| 507 | + | |
| 508 | + | |
| 509 | +@router.get("/system/metrics") | |
| 510 | +async def system_metrics(request: Request, p: Principal = Depends(require_admin), minutes: int = Query(60, ge=5, le=60 * 24 * 7), | |
| 511 | + hours: int = Query(24, ge=1, le=24 * 30)): | |
| 512 | + state = request.app.state | |
| 513 | + return {"history": await state.metrics.history(minutes), "requests": await state.metrics.request_stats(hours), | |
| 514 | + "live": state.metrics.last} | |
| 515 | + | |
| 516 | + | |
| 517 | +@router.get("/runtime/status") | |
| 518 | +async def runtime_status(request: Request, p: Principal = Depends(require_admin)): | |
| 519 | + return request.app.state.manager.snapshot() | |
| 520 | + | |
| 521 | + | |
| 522 | +@router.get("/runtime/current-model") | |
| 523 | +async def runtime_current(request: Request, p: Principal = Depends(require_admin)): | |
| 524 | + cur = request.app.state.manager.current_model() | |
| 525 | + return {"model": cur.to_dict() if cur else None} | |
| 526 | + | |
| 527 | + | |
| 528 | +# --------------------------------------------------------------------------- | |
| 529 | +# Jobs / downloads / harvest | |
| 530 | +# --------------------------------------------------------------------------- | |
| 531 | + | |
| 532 | + | |
| 533 | +@router.get("/jobs") | |
| 534 | +async def jobs(request: Request, p: Principal = Depends(require_admin), kind: str | None = None, limit: int = 100): | |
| 535 | + kinds = {kind} if kind else None | |
| 536 | + return {"jobs": await request.app.state.jobs.history(kinds, limit)} | |
| 537 | + | |
| 538 | + | |
| 539 | +@router.post("/jobs/{job_id}/cancel") | |
| 540 | +async def cancel_job(job_id: str, request: Request, p: Principal = Depends(require_admin)): | |
| 541 | + ok = await request.app.state.jobs.cancel(job_id) | |
| 542 | + return {"ok": ok} | |
| 543 | + | |
| 544 | + | |
| 545 | +class InspectBody(BaseModel): | |
| 546 | + repository: str | |
| 547 | + quant: str | None = None | |
| 548 | + | |
| 549 | + | |
| 550 | +class DownloadBody(BaseModel): | |
| 551 | + repository: str | |
| 552 | + quant: str | None = None | |
| 553 | + force: bool = False | |
| 554 | + | |
| 555 | + | |
| 556 | +@router.post("/models/inspect") | |
| 557 | +async def inspect_repo(body: InspectBody, request: Request, p: Principal = Depends(require_admin)): | |
| 558 | + return await request.app.state.downloader.inspect(body.repository, body.quant) | |
| 559 | + | |
| 560 | + | |
| 561 | +@router.post("/models/download") | |
| 562 | +async def download(body: DownloadBody, request: Request, p: Principal = Depends(require_admin)): | |
| 563 | + job = await request.app.state.downloader.start_download(body.repository, body.quant, force=body.force, actor=_actor(request)) | |
| 564 | + return {"job": job.to_dict()} | |
| 565 | + | |
| 566 | + | |
| 567 | +@router.get("/downloads") | |
| 568 | +async def downloads(request: Request, p: Principal = Depends(require_admin)): | |
| 569 | + return {"downloads": await request.app.state.jobs.history({"download"}, 100)} | |
| 570 | + | |
| 571 | + | |
| 572 | +@router.post("/downloads/{job_id}/retry") | |
| 573 | +async def retry_download(job_id: str, request: Request, p: Principal = Depends(require_admin)): | |
| 574 | + rows = await request.app.state.db.fetchone("SELECT payload FROM jobs WHERE id=?", (job_id,)) | |
| 575 | + if not rows: | |
| 576 | + raise APIError("Job not found.", status_code=404, code="JOB_NOT_FOUND") | |
| 577 | + payload = json.loads(rows["payload"] or "{}") | |
| 578 | + job = await request.app.state.downloader.start_download(payload["repository"], payload.get("quant"), force=True, actor=_actor(request)) | |
| 579 | + return {"job": job.to_dict()} | |
| 580 | + | |
| 581 | + | |
| 582 | +class HarvestBody(BaseModel): | |
| 583 | + runtimes: list[str] | None = None | |
| 584 | + authors: list[str] | None = None | |
| 585 | + limit_per_author: int = 150 | |
| 586 | + min_downloads: int = 500 | |
| 587 | + max_ram_gb: float | None = None | |
| 588 | + families: list[str] | None = None | |
| 589 | + tasks: list[str] | None = None | |
| 590 | + search: str | None = None | |
| 591 | + | |
| 592 | + | |
| 593 | +@router.post("/harvest/scan") | |
| 594 | +async def harvest_scan(body: HarvestBody, request: Request, p: Principal = Depends(require_admin)): | |
| 595 | + job = await request.app.state.harvester.start_scan(body.model_dump(), actor=_actor(request)) | |
| 596 | + return {"job": job.to_dict()} | |
| 597 | + | |
| 598 | + | |
| 599 | +@router.get("/harvest/candidates") | |
| 600 | +async def harvest_candidates(request: Request, p: Principal = Depends(require_admin), include_duplicates: bool = False, | |
| 601 | + task: str | None = None, runtime: str | None = None, family: str | None = None, | |
| 602 | + size_class: str | None = None, q: str | None = None, limit: int = 300): | |
| 603 | + h = request.app.state.harvester | |
| 604 | + rows = await h.candidates(include_duplicates=include_duplicates, task=task, runtime=runtime, family=family, | |
| 605 | + size_class=size_class, q=q, limit=limit) | |
| 606 | + last = await request.app.state.db.scalar("SELECT MAX(scanned_at) FROM harvest_candidates") | |
| 607 | + return {"candidates": rows, "last_scan": last, "starter": await h.suggest_starter()} | |
| 608 | + | |
| 609 | + | |
| 610 | +class SelectBody(BaseModel): | |
| 611 | + repo_id: str | |
| 612 | + selected: bool = True | |
| 613 | + | |
| 614 | + | |
| 615 | +@router.post("/harvest/select") | |
| 616 | +async def harvest_select(body: SelectBody, request: Request, p: Principal = Depends(require_admin)): | |
| 617 | + await request.app.state.harvester.select(body.repo_id, body.selected) | |
| 618 | + return {"ok": True} | |
| 619 | + | |
| 620 | + | |
| 621 | +@router.post("/harvest/dismiss") | |
| 622 | +async def harvest_dismiss(body: SelectBody, request: Request, p: Principal = Depends(require_admin)): | |
| 623 | + await request.app.state.harvester.dismiss(body.repo_id) | |
| 624 | + return {"ok": True} | |
| 625 | + | |
| 626 | + | |
| 627 | +@router.post("/harvest/queue") | |
| 628 | +async def harvest_queue(request: Request, p: Principal = Depends(require_admin)): | |
| 629 | + return {"queued": await request.app.state.harvester.queue_selected(actor=_actor(request))} | |
| 630 | + | |
| 631 | + | |
| 632 | +# --------------------------------------------------------------------------- | |
| 633 | +# API keys | |
| 634 | +# --------------------------------------------------------------------------- | |
| 635 | + | |
| 636 | + | |
| 637 | +class KeyBody(BaseModel): | |
| 638 | + name: str = Field(min_length=1, max_length=64) | |
| 639 | + scopes: list[str] = ["inference"] | |
| 640 | + | |
| 641 | + | |
| 642 | +@router.get("/keys") | |
| 643 | +async def list_keys(request: Request, p: Principal = Depends(require_admin)): | |
| 644 | + rows = await request.app.state.db.fetchall( | |
| 645 | + "SELECT id, name, prefix, scopes, created_at, last_used_at, request_count, revoked_at FROM api_keys ORDER BY created_at DESC") | |
| 646 | + for r in rows: | |
| 647 | + r["scopes"] = r["scopes"].split(",") | |
| 648 | + return {"keys": rows} | |
| 649 | + | |
| 650 | + | |
| 651 | +@router.post("/keys") | |
| 652 | +async def create_key(body: KeyBody, request: Request, p: Principal = Depends(require_admin)): | |
| 653 | + scopes = [s for s in body.scopes if s in ("inference", "admin")] or ["inference"] | |
| 654 | + raw, row = await get_auth(request).create_key(body.name, scopes) | |
| 655 | + await request.app.state.db.audit("key.create", actor=_actor(request), target=body.name, ip=_ip(request)) | |
| 656 | + row = dict(row) | |
| 657 | + row["scopes"] = row["scopes"].split(",") | |
| 658 | + return {"key": raw, "record": row} | |
| 659 | + | |
| 660 | + | |
| 661 | +class KeyPatch(BaseModel): | |
| 662 | + name: str | None = None | |
| 663 | + | |
| 664 | + | |
| 665 | +@router.patch("/keys/{key_id}") | |
| 666 | +async def rename_key(key_id: int, body: KeyPatch, request: Request, p: Principal = Depends(require_admin)): | |
| 667 | + if body.name: | |
| 668 | + await request.app.state.db.execute("UPDATE api_keys SET name=? WHERE id=?", (body.name, key_id)) | |
| 669 | + return {"ok": True} | |
| 670 | + | |
| 671 | + | |
| 672 | +@router.delete("/keys/{key_id}") | |
| 673 | +async def revoke_key(key_id: int, request: Request, p: Principal = Depends(require_admin)): | |
| 674 | + await request.app.state.db.execute("UPDATE api_keys SET revoked_at=? WHERE id=? AND revoked_at IS NULL", (time.time(), key_id)) | |
| 675 | + await request.app.state.db.audit("key.revoke", actor=_actor(request), target=str(key_id), ip=_ip(request)) | |
| 676 | + return {"ok": True} | |
| 677 | + | |
| 678 | + | |
| 679 | +# --------------------------------------------------------------------------- | |
| 680 | +# Settings / logs / events | |
| 681 | +# --------------------------------------------------------------------------- | |
| 682 | + | |
| 683 | + | |
| 684 | +@router.get("/settings") | |
| 685 | +async def get_settings_(request: Request, p: Principal = Depends(require_admin)): | |
| 686 | + state = request.app.state | |
| 687 | + s = state.settings | |
| 688 | + defaults = { | |
| 689 | + "max_model_memory_gb": s.max_model_memory_gb, "absolute_max_memory_gb": s.absolute_max_memory_gb, | |
| 690 | + "min_free_disk_gb": s.min_free_disk_gb, "model_idle_timeout_minutes": s.model_idle_timeout_minutes, | |
| 691 | + "max_simultaneous_models": s.max_simultaneous_models, "preferred_runtime": "mlx", "default_model": None, | |
| 692 | + "preload_model": s.preload_model, "log_prompts": s.log_prompts, "allow_downloads": s.allow_downloads, | |
| 693 | + "allow_gguf": s.enable_gguf, "allow_mlx": s.enable_mlx, "default_context": s.default_context, | |
| 694 | + "default_max_tokens": s.default_max_tokens, "benchmark_max_tokens": 256, "benchmark_runs": 2, | |
| 695 | + } | |
| 696 | + stored = await state.db.all_settings() | |
| 697 | + merged = {**defaults, **{k: v for k, v in stored.items() if k in defaults}} | |
| 698 | + return {"settings": merged, "defaults": defaults, "paths": {"root": str(s.root), "models": str(s.models_dir), | |
| 699 | + "data": str(s.data_path), "logs": str(s.logs_path), "db": str(s.db_path)}, | |
| 700 | + "hf_token_set": bool(s.hf_token), "public_url": s.public_url} | |
| 701 | + | |
| 702 | + | |
| 703 | +@router.patch("/settings") | |
| 704 | +async def patch_settings(request: Request, p: Principal = Depends(require_admin)): | |
| 705 | + state = request.app.state | |
| 706 | + body = await request.json() | |
| 707 | + if not isinstance(body, dict): | |
| 708 | + raise APIError("Body must be an object.") | |
| 709 | + changed = {} | |
| 710 | + hw = detect_hardware(state.settings.models_dir) | |
| 711 | + for k, v in body.items(): | |
| 712 | + if k not in SETTINGS_KEYS: | |
| 713 | + continue | |
| 714 | + typ = SETTINGS_KEYS[k] | |
| 715 | + try: | |
| 716 | + if typ is bool: | |
| 717 | + v = bool(v) | |
| 718 | + elif v is None or v == "": | |
| 719 | + v = None | |
| 720 | + else: | |
| 721 | + v = typ(v) | |
| 722 | + except (TypeError, ValueError): | |
| 723 | + raise APIError(f"Invalid value for {k}.", param=k) | |
| 724 | + if k in ("max_model_memory_gb", "absolute_max_memory_gb") and v is not None: | |
| 725 | + if v < 1 or v > hw.memory_gb - 4: | |
| 726 | + raise APIError(f"{k} must be between 1 and {hw.memory_gb - 4:.0f} GB on this machine.", param=k) | |
| 727 | + if k == "max_simultaneous_models" and v is not None and (v < 1 or v > 4): | |
| 728 | + raise APIError("max_simultaneous_models must be 1-4.", param=k) | |
| 729 | + changed[k] = v | |
| 730 | + await state.db.set_setting(k, v) | |
| 731 | + if changed: | |
| 732 | + await state.db.audit("settings.update", actor=_actor(request), detail=changed, ip=_ip(request)) | |
| 733 | + if any(k in changed for k in ("max_model_memory_gb", "absolute_max_memory_gb")): | |
| 734 | + await state.manager.registry.reevaluate_all() | |
| 735 | + bus.publish("settings", changed) | |
| 736 | + return {"changed": changed} | |
| 737 | + | |
| 738 | + | |
| 739 | +@router.get("/logs/audit") | |
| 740 | +async def audit_logs(request: Request, p: Principal = Depends(require_admin), limit: int = 200): | |
| 741 | + rows = await request.app.state.db.fetchall("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT ?", (limit,)) | |
| 742 | + return {"logs": rows} | |
| 743 | + | |
| 744 | + | |
| 745 | +@router.get("/logs/events") | |
| 746 | +async def model_events(request: Request, p: Principal = Depends(require_admin), limit: int = 200): | |
| 747 | + rows = await request.app.state.db.fetchall("SELECT * FROM model_events ORDER BY created_at DESC LIMIT ?", (limit,)) | |
| 748 | + return {"events": rows} | |
| 749 | + | |
| 750 | + | |
| 751 | +@router.get("/logs/requests") | |
| 752 | +async def request_logs(request: Request, p: Principal = Depends(require_admin), limit: int = 200): | |
| 753 | + rows = await request.app.state.db.fetchall( | |
| 754 | + "SELECT id, created_at, model_id, requested_model, endpoint, api_key_id, stream, prompt_tokens, completion_tokens, ttft_ms, " | |
| 755 | + "total_ms, tps, load_wait_ms, status, error_code FROM inference_requests ORDER BY created_at DESC LIMIT ?", (limit,)) | |
| 756 | + return {"requests": rows} | |
| 757 | + | |
| 758 | + | |
| 759 | +@router.get("/logs/worker/{model_id}") | |
| 760 | +async def worker_log(model_id: str, request: Request, p: Principal = Depends(require_admin), lines: int = 200): | |
| 761 | + path = request.app.state.settings.logs_path / "workers" / f"worker-{model_id}.log" | |
| 762 | + if not path.exists(): | |
| 763 | + return {"lines": []} | |
| 764 | + txt = path.read_text(errors="replace").splitlines() | |
| 765 | + return {"lines": txt[-lines:]} | |
| 766 | + | |
| 767 | + | |
| 768 | +@router.get("/events") | |
| 769 | +async def events(request: Request, p: Principal = Depends(require_admin)): | |
| 770 | + q = bus.subscribe() | |
| 771 | + state = request.app.state | |
| 772 | + | |
| 773 | + async def gen(): | |
| 774 | + try: | |
| 775 | + # initial snapshot | |
| 776 | + snap = {"seq": 0, "ts": time.time(), "type": "snapshot", | |
| 777 | + "data": {"manager": state.manager.snapshot(), "metrics": state.metrics.last, | |
| 778 | + "jobs": state.jobs.list(limit=30)}} | |
| 779 | + yield bus.format_sse(snap) | |
| 780 | + while True: | |
| 781 | + try: | |
| 782 | + ev = await asyncio.wait_for(q.get(), timeout=15) | |
| 783 | + yield bus.format_sse(ev) | |
| 784 | + except asyncio.TimeoutError: | |
| 785 | + yield ": ping\n\n" | |
| 786 | + if await request.is_disconnected(): | |
| 787 | + break | |
| 788 | + finally: | |
| 789 | + bus.unsubscribe(q) | |
| 790 | + | |
| 791 | + return StreamingResponse(gen(), media_type="text/event-stream", | |
| 792 | + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) | |
| 793 | + | |
| 794 | + | |
| 795 | +@router.post("/models/{model_id}/tokenize") | |
| 796 | +async def tokenize(model_id: str, request: Request, p: Principal = Depends(require_admin)): | |
| 797 | + manager = request.app.state.manager | |
| 798 | + lm = manager.get_ready(model_id) | |
| 799 | + if not lm: | |
| 800 | + raise Conflict("Model is not loaded.") | |
| 801 | + body = await request.json() | |
| 802 | + if lm.handle.runtime == "mlx": | |
| 803 | + r = await manager.client.post(f"{lm.handle.base_url}/tokenize", json=body, timeout=30) | |
| 804 | + else: | |
| 805 | + text = body.get("text") or " ".join(m.get("content", "") for m in body.get("messages", []) if isinstance(m.get("content"), str)) | |
| 806 | + r = await manager.client.post(f"{lm.handle.base_url}/tokenize", json={"content": text}, timeout=30) | |
| 807 | + d = r.json() | |
| 808 | + return {"tokens": len(d.get("tokens", [])), "max_context": lm.handle.context} | |
| 809 | + return r.json() | |
added
server/llm_api/api/openai_routes.py
+369 −0
@@ -0,0 +1,369 @@ | ||
| 1 | +"""OpenAI-compatible endpoints. Requests are validated, the model is loaded on demand, then the | |
| 2 | +request is proxied to the local worker. Usage/timings are recorded from the worker's response.""" | |
| 3 | + | |
| 4 | +from __future__ import annotations | |
| 5 | + | |
| 6 | +import asyncio | |
| 7 | +import json | |
| 8 | +import logging | |
| 9 | +import time | |
| 10 | +from typing import Any | |
| 11 | + | |
| 12 | +import httpx | |
| 13 | +from fastapi import APIRouter, Depends, Request | |
| 14 | +from fastapi.responses import JSONResponse, StreamingResponse | |
| 15 | + | |
| 16 | +from ..auth import Principal, require_inference | |
| 17 | +from ..errors import APIError, GenerationTimeout, WorkerCrashed | |
| 18 | +from ..events import bus | |
| 19 | +from ..manager import ModelManager | |
| 20 | + | |
| 21 | +log = logging.getLogger("llm_api.openai") | |
| 22 | +router = APIRouter() | |
| 23 | + | |
| 24 | + | |
| 25 | +def _manager(request: Request) -> ModelManager: | |
| 26 | + return request.app.state.manager | |
| 27 | + | |
| 28 | + | |
| 29 | +async def _parse_body(request: Request) -> dict: | |
| 30 | + settings = request.app.state.settings | |
| 31 | + raw = await request.body() | |
| 32 | + if len(raw) > settings.max_body_bytes: | |
| 33 | + raise APIError("Request body too large.", status_code=413, code="BODY_TOO_LARGE") | |
| 34 | + try: | |
| 35 | + body = json.loads(raw or b"{}") | |
| 36 | + except json.JSONDecodeError: | |
| 37 | + raise APIError("Request body is not valid JSON.") | |
| 38 | + if not isinstance(body, dict): | |
| 39 | + raise APIError("Request body must be a JSON object.") | |
| 40 | + return body | |
| 41 | + | |
| 42 | + | |
| 43 | +def _validate_sampling(body: dict) -> None: | |
| 44 | + def num(k, lo, hi): | |
| 45 | + v = body.get(k) | |
| 46 | + if v is None: | |
| 47 | + return | |
| 48 | + if not isinstance(v, (int, float)) or v < lo or v > hi: | |
| 49 | + raise APIError(f"{k} must be a number between {lo} and {hi}.", param=k) | |
| 50 | + num("temperature", 0, 2) | |
| 51 | + num("top_p", 0, 1) | |
| 52 | + num("presence_penalty", -2, 2) | |
| 53 | + num("frequency_penalty", -2, 2) | |
| 54 | + mt = body.get("max_tokens", body.get("max_completion_tokens")) | |
| 55 | + if mt is not None and (not isinstance(mt, int) or mt < 1 or mt > 200000): | |
| 56 | + raise APIError("max_tokens must be a positive integer.", param="max_tokens") | |
| 57 | + n = body.get("n") | |
| 58 | + if n not in (None, 1): | |
| 59 | + raise APIError("Only n=1 is supported.", param="n") | |
| 60 | + stop = body.get("stop") | |
| 61 | + if stop is not None and not isinstance(stop, (str, list)): | |
| 62 | + raise APIError("stop must be a string or an array of strings.", param="stop") | |
| 63 | + if isinstance(stop, list) and len(stop) > 8: | |
| 64 | + raise APIError("At most 8 stop sequences are supported.", param="stop") | |
| 65 | + | |
| 66 | + | |
| 67 | +async def _pick_model(request: Request, body: dict, endpoint: str) -> tuple[str, dict]: | |
| 68 | + """Resolve model name (alias/auto) -> registry row.""" | |
| 69 | + manager = _manager(request) | |
| 70 | + name = body.get("model") | |
| 71 | + if not name or not isinstance(name, str): | |
| 72 | + # default model setting | |
| 73 | + name = await request.app.state.db.get_setting("default_model") | |
| 74 | + if not name: | |
| 75 | + cur = manager.current_model() | |
| 76 | + if cur: | |
| 77 | + name = cur.model["id"] | |
| 78 | + if not name: | |
| 79 | + raise APIError("model is required.", param="model") | |
| 80 | + if name == "auto": | |
| 81 | + from ..routing import choose_auto | |
| 82 | + name = await choose_auto(request.app.state, body, endpoint) | |
| 83 | + model = await manager.registry.resolve(name) | |
| 84 | + if not model: | |
| 85 | + from ..errors import ModelNotFound | |
| 86 | + raise ModelNotFound(f"The model '{name}' does not exist. Use GET /v1/models to list available models.") | |
| 87 | + return name, model | |
| 88 | + | |
| 89 | + | |
| 90 | +async def _record(app_state, *, model_id: str | None, requested: str, endpoint: str, principal: Principal, | |
| 91 | + stream: bool, usage: dict | None, timings: dict | None, status: int, error_code: str | None, | |
| 92 | + started: float, load_wait_ms: float, prompt: Any = None, completion: str | None = None) -> None: | |
| 93 | + db = app_state.db | |
| 94 | + settings = app_state.settings | |
| 95 | + log_prompts = bool(await db.get_setting("log_prompts", settings.log_prompts)) | |
| 96 | + usage = usage or {} | |
| 97 | + timings = timings or {} | |
| 98 | + tps = timings.get("generation_tps") or timings.get("predicted_per_second") | |
| 99 | + ttft = timings.get("ttft_ms") or timings.get("prompt_ms") | |
| 100 | + total_ms = (time.time() - started) * 1000 | |
| 101 | + ctoks = usage.get("completion_tokens") or timings.get("predicted_n") | |
| 102 | + ptoks = usage.get("prompt_tokens") or timings.get("prompt_n") | |
| 103 | + await db.execute( | |
| 104 | + "INSERT INTO inference_requests(created_at, model_id, requested_model, endpoint, api_key_id, stream, prompt_tokens, " | |
| 105 | + "completion_tokens, ttft_ms, total_ms, tps, load_wait_ms, status, error_code, prompt, completion) " | |
| 106 | + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", | |
| 107 | + (started, model_id, requested, endpoint, principal.id if principal.kind == "api_key" else None, int(stream), | |
| 108 | + ptoks, ctoks, ttft, round(total_ms, 1), tps, round(load_wait_ms, 1), status, error_code, | |
| 109 | + json.dumps(prompt, ensure_ascii=False)[:20000] if (log_prompts and prompt is not None) else None, | |
| 110 | + (completion or "")[:20000] if log_prompts else None)) | |
| 111 | + if model_id and status < 400: | |
| 112 | + await app_state.manager.registry.record_usage(model_id, int(ctoks or 0), tps, ttft) | |
| 113 | + app_state.manager.stats["tokens"] += int(ctoks or 0) | |
| 114 | + bus.publish("request", {"model_id": model_id, "endpoint": endpoint, "status": status, "tps": tps, "ttft_ms": ttft, | |
| 115 | + "completion_tokens": ctoks, "prompt_tokens": ptoks, "total_ms": round(total_ms), "stream": stream}) | |
| 116 | + | |
| 117 | + | |
| 118 | +def _extract_timings(data: dict) -> dict: | |
| 119 | + t = data.get("timings") or {} | |
| 120 | + if "predicted_per_second" in t and "generation_tps" not in t: # llama.cpp | |
| 121 | + t = {"generation_tps": t.get("predicted_per_second"), "prompt_tps": t.get("prompt_per_second"), | |
| 122 | + "ttft_ms": t.get("prompt_ms"), "prompt_ms": t.get("prompt_ms"), "generation_ms": t.get("predicted_ms"), | |
| 123 | + "predicted_n": t.get("predicted_n"), "prompt_n": t.get("prompt_n"), "cached_tokens": t.get("cache_n")} | |
| 124 | + return t | |
| 125 | + | |
| 126 | + | |
| 127 | +async def _proxy(request: Request, endpoint: str, body: dict, principal: Principal, *, kind: str): | |
| 128 | + """Common path for chat/completions/embeddings/rerank.""" | |
| 129 | + app_state = request.app.state | |
| 130 | + manager: ModelManager = app_state.manager | |
| 131 | + started = time.time() | |
| 132 | + requested, model = await _pick_model(request, body, endpoint) | |
| 133 | + if kind == "chat" or kind == "completion": | |
| 134 | + if model.get("embedding") or model.get("reranker"): | |
| 135 | + raise APIError(f"Model '{model['id']}' is an {'embedding' if model.get('embedding') else 'reranking'} model and cannot generate text.", | |
| 136 | + code="WRONG_MODEL_TYPE") | |
| 137 | + if kind == "embeddings" and not model.get("embedding"): | |
| 138 | + # Allow causal LMs served by MLX to embed (last-token pooling) only if explicitly tagged; otherwise reject | |
| 139 | + if model["runtime"] != "mlx": | |
| 140 | + raise APIError(f"Model '{model['id']}' is not an embedding model.", code="WRONG_MODEL_TYPE") | |
| 141 | + if kind == "rerank" and not model.get("reranker"): | |
| 142 | + raise APIError(f"Model '{model['id']}' is not a reranking model.", code="WRONG_MODEL_TYPE") | |
| 143 | + stream = bool(body.get("stream")) and kind in ("chat", "completion") | |
| 144 | + t_load = time.time() | |
| 145 | + try: | |
| 146 | + lm = await manager.ensure_loaded(model["id"], reason=f"{endpoint}") | |
| 147 | + except APIError as e: | |
| 148 | + await _record(app_state, model_id=model["id"], requested=requested, endpoint=endpoint, principal=principal, | |
| 149 | + stream=stream, usage=None, timings=None, status=e.status_code, error_code=e.code, started=started, | |
| 150 | + load_wait_ms=(time.time() - t_load) * 1000) | |
| 151 | + raise | |
| 152 | + load_wait_ms = (time.time() - t_load) * 1000 | |
| 153 | + body = dict(body) | |
| 154 | + body["model"] = model["id"] | |
| 155 | + if stream and kind == "chat": | |
| 156 | + so = dict(body.get("stream_options") or {}) | |
| 157 | + so["include_usage"] = True | |
| 158 | + body["stream_options"] = so | |
| 159 | + url = f"{lm.handle.base_url}{endpoint}" | |
| 160 | + timeout = httpx.Timeout(connect=10.0, read=float(app_state.settings.generation_timeout_seconds), write=60.0, pool=10.0) | |
| 161 | + prompt_for_log = body.get("messages") or body.get("prompt") or body.get("input") | |
| 162 | + | |
| 163 | + async with manager.use(lm): | |
| 164 | + if not stream: | |
| 165 | + try: | |
| 166 | + r = await manager.client.post(url, json=body, timeout=timeout) | |
| 167 | + except httpx.ReadTimeout: | |
| 168 | + raise GenerationTimeout("The model did not finish generating in time.") | |
| 169 | + except httpx.HTTPError as e: | |
| 170 | + if not lm.handle.alive(): | |
| 171 | + raise WorkerCrashed(f"The inference worker for '{model['id']}' crashed during the request.") | |
| 172 | + raise APIError(f"Worker connection error: {e}", status_code=502, code="WORKER_UNREACHABLE", error_type="runtime_error") | |
| 173 | + try: | |
| 174 | + data = r.json() | |
| 175 | + except Exception: | |
| 176 | + data = {"error": {"message": r.text[:500], "type": "runtime_error", "code": "WORKER_BAD_RESPONSE"}} | |
| 177 | + if r.status_code >= 400: | |
| 178 | + err = data.get("error") if isinstance(data, dict) else None | |
| 179 | + code = (err or {}).get("code") if isinstance(err, dict) else None | |
| 180 | + msg = (err or {}).get("message") if isinstance(err, dict) else str(err) | |
| 181 | + await _record(app_state, model_id=model["id"], requested=requested, endpoint=endpoint, principal=principal, | |
| 182 | + stream=False, usage=None, timings=None, status=r.status_code, error_code=str(code or "WORKER_ERROR"), | |
| 183 | + started=started, load_wait_ms=load_wait_ms, prompt=prompt_for_log) | |
| 184 | + # normalise llama.cpp error shape | |
| 185 | + return JSONResponse(status_code=r.status_code, content={"error": {"message": msg or "worker error", | |
| 186 | + "type": (err or {}).get("type", "runtime_error") if isinstance(err, dict) else "runtime_error", | |
| 187 | + "code": code or "WORKER_ERROR"}}) | |
| 188 | + data["model"] = requested if requested != "auto" else model["id"] | |
| 189 | + timings = _extract_timings(data) | |
| 190 | + if timings: | |
| 191 | + data["timings"] = timings | |
| 192 | + completion = None | |
| 193 | + if kind == "chat": | |
| 194 | + completion = ((data.get("choices") or [{}])[0].get("message") or {}).get("content") | |
| 195 | + elif kind == "completion": | |
| 196 | + completion = (data.get("choices") or [{}])[0].get("text") | |
| 197 | + await _record(app_state, model_id=model["id"], requested=requested, endpoint=endpoint, principal=principal, | |
| 198 | + stream=False, usage=data.get("usage"), timings=timings, status=200, error_code=None, | |
| 199 | + started=started, load_wait_ms=load_wait_ms, prompt=prompt_for_log, completion=completion) | |
| 200 | + return JSONResponse(content=data) | |
| 201 | + | |
| 202 | + # ---- streaming ---------------------------------------------------- | |
| 203 | + req = manager.client.build_request("POST", url, json=body, timeout=timeout) | |
| 204 | + try: | |
| 205 | + resp = await manager.client.send(req, stream=True) | |
| 206 | + except httpx.HTTPError as e: | |
| 207 | + raise APIError(f"Worker connection error: {e}", status_code=502, code="WORKER_UNREACHABLE", error_type="runtime_error") | |
| 208 | + if resp.status_code >= 400: | |
| 209 | + raw = await resp.aread() | |
| 210 | + await resp.aclose() | |
| 211 | + try: | |
| 212 | + data = json.loads(raw) | |
| 213 | + except Exception: | |
| 214 | + data = {"error": {"message": raw.decode(errors="replace")[:500], "type": "runtime_error", "code": "WORKER_ERROR"}} | |
| 215 | + err = data.get("error") or {} | |
| 216 | + await _record(app_state, model_id=model["id"], requested=requested, endpoint=endpoint, principal=principal, | |
| 217 | + stream=True, usage=None, timings=None, status=resp.status_code, error_code=str(err.get("code") or "WORKER_ERROR"), | |
| 218 | + started=started, load_wait_ms=load_wait_ms, prompt=prompt_for_log) | |
| 219 | + return JSONResponse(status_code=resp.status_code, content={"error": {"message": err.get("message", "worker error"), | |
| 220 | + "type": err.get("type", "runtime_error"), | |
| 221 | + "code": err.get("code", "WORKER_ERROR")}}) | |
| 222 | + | |
| 223 | + lm.in_flight += 1 # held until the generator finishes | |
| 224 | + | |
| 225 | + async def gen(): | |
| 226 | + usage = None | |
| 227 | + timings: dict = {} | |
| 228 | + completion_parts: list[str] = [] | |
| 229 | + status = 200 | |
| 230 | + error_code = None | |
| 231 | + try: | |
| 232 | + async for line in resp.aiter_lines(): | |
| 233 | + if not line: | |
| 234 | + continue | |
| 235 | + if line.startswith("data: "): | |
| 236 | + payload = line[6:] | |
| 237 | + if payload.strip() == "[DONE]": | |
| 238 | + yield b"data: [DONE]\n\n" | |
| 239 | + continue | |
| 240 | + try: | |
| 241 | + obj = json.loads(payload) | |
| 242 | + except json.JSONDecodeError: | |
| 243 | + yield (line + "\n\n").encode() | |
| 244 | + continue | |
| 245 | + if "error" in obj and "choices" not in obj: | |
| 246 | + status = 500 | |
| 247 | + error_code = (obj["error"] or {}).get("code", "GENERATION_FAILED") | |
| 248 | + yield (f"data: {json.dumps(obj)}\n\n").encode() | |
| 249 | + continue | |
| 250 | + obj["model"] = requested if requested != "auto" else model["id"] | |
| 251 | + if obj.get("usage"): | |
| 252 | + usage = obj["usage"] | |
| 253 | + if obj.get("timings"): | |
| 254 | + timings = _extract_timings(obj) | |
| 255 | + obj["timings"] = timings | |
| 256 | + for ch in obj.get("choices") or []: | |
| 257 | + d = ch.get("delta") or {} | |
| 258 | + if d.get("content"): | |
| 259 | + completion_parts.append(d["content"]) | |
| 260 | + if ch.get("text"): | |
| 261 | + completion_parts.append(ch["text"]) | |
| 262 | + yield (f"data: {json.dumps(obj, ensure_ascii=False)}\n\n").encode() | |
| 263 | + else: | |
| 264 | + yield (line + "\n").encode() | |
| 265 | + except (httpx.ReadTimeout, asyncio.TimeoutError): | |
| 266 | + status, error_code = 504, "GENERATION_TIMEOUT" | |
| 267 | + yield (f"data: {json.dumps({'error': {'message': 'generation timed out', 'type': 'runtime_error', 'code': 'GENERATION_TIMEOUT'}})}\n\n").encode() | |
| 268 | + except httpx.HTTPError: | |
| 269 | + status, error_code = 502, "WORKER_CRASHED" if not lm.handle.alive() else "WORKER_STREAM_ERROR" | |
| 270 | + yield (f"data: {json.dumps({'error': {'message': 'worker stream interrupted', 'type': 'runtime_error', 'code': error_code}})}\n\n").encode() | |
| 271 | + finally: | |
| 272 | + await resp.aclose() | |
| 273 | + lm.in_flight = max(0, lm.in_flight - 1) | |
| 274 | + lm.last_used = time.time() | |
| 275 | + try: | |
| 276 | + await _record(app_state, model_id=model["id"], requested=requested, endpoint=endpoint, principal=principal, | |
| 277 | + stream=True, usage=usage, timings=timings, status=status, error_code=error_code, | |
| 278 | + started=started, load_wait_ms=load_wait_ms, prompt=prompt_for_log, | |
| 279 | + completion="".join(completion_parts)) | |
| 280 | + except Exception: | |
| 281 | + log.exception("record failed") | |
| 282 | + | |
| 283 | + return StreamingResponse(gen(), media_type="text/event-stream", | |
| 284 | + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"}) | |
| 285 | + | |
| 286 | + | |
| 287 | +# --------------------------------------------------------------------------- | |
| 288 | + | |
| 289 | + | |
| 290 | +@router.get("/v1/models") | |
| 291 | +async def list_models(request: Request, principal: Principal = Depends(require_inference)): | |
| 292 | + manager = _manager(request) | |
| 293 | + models = await manager.registry.list_models() | |
| 294 | + aliases = await manager.registry.aliases() | |
| 295 | + out = [] | |
| 296 | + for m in models: | |
| 297 | + if not m.get("enabled"): | |
| 298 | + continue | |
| 299 | + st = manager.status_of(m["id"]) | |
| 300 | + out.append({ | |
| 301 | + "id": m["id"], "object": "model", "created": int(m["created_at"]), "owned_by": m.get("provider") or "local", | |
| 302 | + "root": m["id"], "parent": None, | |
| 303 | + # llm-api extensions (ignored by SDKs) | |
| 304 | + "name": m["name"], "family": m["family"], "runtime": m["runtime"], "quantization": m["quantization"], | |
| 305 | + "parameter_count": m["parameter_count"], "estimated_ram_gb": m["estimated_ram_gb"], | |
| 306 | + "context": m["recommended_context"], "max_context": m["max_context"], "task": m["task"], | |
| 307 | + "compatibility": m["compatibility_status"], "status": st, "loaded": st == "ready", | |
| 308 | + "capabilities": {"vision": m["vision"], "embedding": m["embedding"], "reranking": m["reranker"], | |
| 309 | + "tools": m["tools"], "thinking": m["thinking"]}, | |
| 310 | + }) | |
| 311 | + for alias, mid in aliases.items(): | |
| 312 | + out.append({"id": alias, "object": "model", "created": 0, "owned_by": "alias", "root": mid, "parent": mid, | |
| 313 | + "alias_of": mid}) | |
| 314 | + return {"object": "list", "data": out} | |
| 315 | + | |
| 316 | + | |
| 317 | +@router.get("/v1/models/{model_id:path}") | |
| 318 | +async def get_model(model_id: str, request: Request, principal: Principal = Depends(require_inference)): | |
| 319 | + manager = _manager(request) | |
| 320 | + m = await manager.registry.resolve(model_id) | |
| 321 | + if not m: | |
| 322 | + from ..errors import ModelNotFound | |
| 323 | + raise ModelNotFound(f"The model '{model_id}' does not exist.") | |
| 324 | + return {"id": m["id"], "object": "model", "created": int(m["created_at"]), "owned_by": m.get("provider") or "local", | |
| 325 | + "status": manager.status_of(m["id"])} | |
| 326 | + | |
| 327 | + | |
| 328 | +@router.post("/v1/chat/completions") | |
| 329 | +async def chat_completions(request: Request, principal: Principal = Depends(require_inference)): | |
| 330 | + body = await _parse_body(request) | |
| 331 | + msgs = body.get("messages") | |
| 332 | + if not isinstance(msgs, list) or not msgs: | |
| 333 | + raise APIError("messages must be a non-empty array.", param="messages") | |
| 334 | + for m in msgs: | |
| 335 | + if not isinstance(m, dict) or "role" not in m: | |
| 336 | + raise APIError("Each message needs a role.", param="messages") | |
| 337 | + _validate_sampling(body) | |
| 338 | + return await _proxy(request, "/v1/chat/completions", body, principal, kind="chat") | |
| 339 | + | |
| 340 | + | |
| 341 | +@router.post("/v1/completions") | |
| 342 | +async def completions(request: Request, principal: Principal = Depends(require_inference)): | |
| 343 | + body = await _parse_body(request) | |
| 344 | + if "prompt" not in body: | |
| 345 | + raise APIError("prompt is required.", param="prompt") | |
| 346 | + _validate_sampling(body) | |
| 347 | + return await _proxy(request, "/v1/completions", body, principal, kind="completion") | |
| 348 | + | |
| 349 | + | |
| 350 | +@router.post("/v1/embeddings") | |
| 351 | +async def embeddings(request: Request, principal: Principal = Depends(require_inference)): | |
| 352 | + body = await _parse_body(request) | |
| 353 | + if "input" not in body: | |
| 354 | + raise APIError("input is required.", param="input") | |
| 355 | + inp = body["input"] | |
| 356 | + n = len(inp) if isinstance(inp, list) else 1 | |
| 357 | + if n > 256: | |
| 358 | + raise APIError("At most 256 inputs per request.", param="input") | |
| 359 | + return await _proxy(request, "/v1/embeddings", body, principal, kind="embeddings") | |
| 360 | + | |
| 361 | + | |
| 362 | +@router.post("/v1/rerank") | |
| 363 | +async def rerank(request: Request, principal: Principal = Depends(require_inference)): | |
| 364 | + body = await _parse_body(request) | |
| 365 | + if not body.get("query") or not isinstance(body.get("documents"), list): | |
| 366 | + raise APIError("query and documents are required.") | |
| 367 | + if len(body["documents"]) > 200: | |
| 368 | + raise APIError("At most 200 documents per request.", param="documents") | |
| 369 | + return await _proxy(request, "/v1/rerank", body, principal, kind="rerank") | |
added
server/llm_api/auth.py
+210 −0
Large diff (210 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/bench.py
+158 −0
Large diff (158 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/cli.py
+72 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/config.py
+122 −0
Large diff (122 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/db.py
+300 −0
Large diff (300 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/downloads.py
+341 −0
Large diff (341 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/errors.py
+122 −0
Large diff (122 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/events.py
+48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/hardware.py
+254 −0
Large diff (254 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/harvester.py
+397 −0
Large diff (397 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/jobs.py
+162 −0
Large diff (162 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/main.py
+166 −0
Large diff (166 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/manager.py
+506 −0
Large diff (506 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/metrics.py
+98 −0
Large diff (98 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/proxy_ui.py
+50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/routing.py
+70 −0
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/runtimes/__init__.py
+5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/runtimes/base.py
+149 −0
Large diff (149 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/runtimes/llamacpp_adapter.py
+69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/runtimes/mlx_adapter.py
+58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/worker/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/worker/mlx_worker.py
+1117 −0
Large diff (1,117 lines) is not loaded by default to keep the page fast.Load diff
added
server/llm_api/worker/openai_types.py
+239 −0
Large diff (239 lines) is not loaded by default to keep the page fast.Load diff
added
server/pyproject.toml
+48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
server/tests/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
server/tests/conftest.py
+294 −0
Large diff (294 lines) is not loaded by default to keep the page fast.Load diff
added
server/tests/test_api.py
+298 −0
Large diff (298 lines) is not loaded by default to keep the page fast.Load diff
added
server/tests/test_units.py
+140 −0
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
added
web/.gitignore
+41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added
web/README.md
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
web/eslint.config.mjs
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
web/next.config.ts
+20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added
web/package.json
+27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added
web/pnpm-lock.yaml
+4184 −0
Large diff (4,184 lines) is not loaded by default to keep the page fast.Load diff
added
web/pnpm-workspace.yaml
+3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added
web/postcss.config.mjs
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/app/docs/page.tsx
+64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/app/downloads/page.tsx
+149 −0
Large diff (149 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/app/favicon.ico
+0 −0
Binary file not shown.
added
web/src/app/globals.css
+101 −0
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/app/harvester/page.tsx
+159 −0
Large diff (159 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/app/keys/page.tsx
+89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/app/layout.tsx
+21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/app/login/page.tsx
+73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/app/page.tsx
+170 −0
Large diff (170 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/app/playground/page.tsx
+199 −0
Large diff (199 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/app/settings/page.tsx
+134 −0
Large diff (134 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/app/system/page.tsx
+132 −0
Large diff (132 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/components/shell.tsx
+157 −0
Large diff (157 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/components/ui.tsx
+233 −0
Large diff (233 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/lib/api.ts
+89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/lib/events.tsx
+130 −0
Large diff (130 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/lib/format.ts
+73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/lib/types.ts
+225 −0
Large diff (225 lines) is not loaded by default to keep the page fast.Load diff
added
web/tsconfig.json
+34 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff