SPB Git forge

spb/llm-api

Public
0commits 0branches 0releases
0 Bsize
maindefault branch
last push

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>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026)

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
@@ -0,0 +1,210 @@
1 +"""Authentication: admin sessions (signed cookie) and API keys (hashed)."""
2 +
3 +from __future__ import annotations
4 +
5 +import hashlib
6 +import secrets
7 +import time
8 +from dataclasses import dataclass
9 +
10 +from argon2 import PasswordHasher
11 +from argon2.exceptions import VerifyMismatchError
12 +from fastapi import Depends, Request
13 +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
14 +
15 +from .config import Settings, get_settings
16 +from .db import Database
17 +from .errors import AuthError, Forbidden, RateLimited
18 +
19 +SESSION_COOKIE = "llm_session"
20 +KEY_PREFIX = "llm_live_"
21 +ph = PasswordHasher()
22 +
23 +
24 +def hash_password(pw: str) -> str:
25 + return ph.hash(pw)
26 +
27 +
28 +def verify_password(hash_: str, pw: str) -> bool:
29 + try:
30 + return ph.verify(hash_, pw)
31 + except VerifyMismatchError:
32 + return False
33 + except Exception:
34 + return False
35 +
36 +
37 +def hash_key(raw: str) -> str:
38 + return hashlib.sha256(raw.encode()).hexdigest()
39 +
40 +
41 +def generate_key() -> str:
42 + return KEY_PREFIX + secrets.token_urlsafe(32).replace("-", "").replace("_", "")[:40]
43 +
44 +
45 +@dataclass
46 +class Principal:
47 + kind: str # session | api_key
48 + id: int | None
49 + name: str
50 + scopes: set[str]
51 +
52 + def has(self, scope: str) -> bool:
53 + return "admin" in self.scopes or scope in self.scopes
54 +
55 +
56 +class RateLimiter:
57 + """Small in-memory sliding window limiter keyed by string."""
58 +
59 + def __init__(self, limit: int, window: float):
60 + self.limit = limit
61 + self.window = window
62 + self.hits: dict[str, list[float]] = {}
63 +
64 + def check(self, key: str) -> bool:
65 + now = time.time()
66 + arr = [t for t in self.hits.get(key, []) if now - t < self.window]
67 + if len(arr) >= self.limit:
68 + self.hits[key] = arr
69 + return False
70 + arr.append(now)
71 + self.hits[key] = arr
72 + return True
73 +
74 +
75 +login_limiter = RateLimiter(limit=8, window=60)
76 +api_limiter = RateLimiter(limit=600, window=60)
77 +
78 +
79 +class Auth:
80 + def __init__(self, db: Database, settings: Settings):
81 + self.db = db
82 + self.settings = settings
83 + self.serializer = URLSafeTimedSerializer(settings.secret, salt="llm-api-session")
84 +
85 + # ---- users ----------------------------------------------------------------
86 + async def user_count(self) -> int:
87 + return int(await self.db.scalar("SELECT COUNT(*) FROM users") or 0)
88 +
89 + async def create_user(self, email: str, password: str) -> int:
90 + if len(password) < 10:
91 + raise ValueError("password must be at least 10 characters")
92 + return await self.db.execute("INSERT INTO users(email, password_hash, role, created_at) VALUES(?,?,?,?)",
93 + (email.strip().lower(), hash_password(password), "admin", time.time()))
94 +
95 + async def authenticate(self, email: str, password: str) -> dict | None:
96 + row = await self.db.fetchone("SELECT * FROM users WHERE email=?", (email.strip().lower(),))
97 + if not row or not verify_password(row["password_hash"], password):
98 + return None
99 + await self.db.execute("UPDATE users SET last_login_at=? WHERE id=?", (time.time(), row["id"]))
100 + return row
101 +
102 + async def change_password(self, user_id: int, new_password: str) -> None:
103 + if len(new_password) < 10:
104 + raise ValueError("password must be at least 10 characters")
105 + await self.db.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(new_password), user_id))
106 +
107 + # ---- sessions ---------------------------------------------------------------
108 + def make_session(self, user_id: int, email: str) -> str:
109 + return self.serializer.dumps({"uid": user_id, "email": email, "n": secrets.token_hex(8)})
110 +
111 + def read_session(self, token: str) -> dict | None:
112 + try:
113 + return self.serializer.loads(token, max_age=self.settings.session_hours * 3600)
114 + except (BadSignature, SignatureExpired):
115 + return None
116 +
117 + # ---- api keys ---------------------------------------------------------------
118 + async def create_key(self, name: str, scopes: list[str]) -> tuple[str, dict]:
119 + raw = generate_key()
120 + row_id = await self.db.execute(
121 + "INSERT INTO api_keys(name, prefix, key_hash, scopes, created_at) VALUES(?,?,?,?,?)",
122 + (name, raw[:16], hash_key(raw), ",".join(sorted(set(scopes))), time.time()))
123 + row = await self.db.fetchone("SELECT * FROM api_keys WHERE id=?", (row_id,))
124 + return raw, row # type: ignore[return-value]
125 +
126 + async def lookup_key(self, raw: str) -> dict | None:
127 + row = await self.db.fetchone("SELECT * FROM api_keys WHERE key_hash=? AND revoked_at IS NULL", (hash_key(raw),))
128 + return row
129 +
130 + async def touch_key(self, key_id: int) -> None:
131 + await self.db.execute("UPDATE api_keys SET last_used_at=?, request_count=request_count+1 WHERE id=?",
132 + (time.time(), key_id))
133 +
134 +
135 +# ---------------------------------------------------------------------------
136 +# FastAPI dependencies
137 +# ---------------------------------------------------------------------------
138 +
139 +
140 +def get_auth(request: Request) -> Auth:
141 + return request.app.state.auth
142 +
143 +
144 +def _client_ip(request: Request) -> str:
145 + fwd = request.headers.get("x-forwarded-for")
146 + if fwd:
147 + return fwd.split(",")[0].strip()
148 + return request.client.host if request.client else "?"
149 +
150 +
151 +async def principal_from_request(request: Request, auth: Auth) -> Principal | None:
152 + header = request.headers.get("authorization") or ""
153 + if header.lower().startswith("bearer "):
154 + raw = header[7:].strip()
155 + if raw:
156 + row = await auth.lookup_key(raw)
157 + if not row:
158 + return None
159 + await auth.touch_key(row["id"])
160 + return Principal("api_key", row["id"], row["name"], set(row["scopes"].split(",")))
161 + api_key_header = request.headers.get("x-api-key")
162 + if api_key_header:
163 + row = await auth.lookup_key(api_key_header.strip())
164 + if not row:
165 + return None
166 + await auth.touch_key(row["id"])
167 + return Principal("api_key", row["id"], row["name"], set(row["scopes"].split(",")))
168 + tok = request.cookies.get(SESSION_COOKIE)
169 + if tok:
170 + data = auth.read_session(tok)
171 + if data:
172 + return Principal("session", data["uid"], data["email"], {"admin"})
173 + return None
174 +
175 +
176 +async def require_inference(request: Request, auth: Auth = Depends(get_auth)) -> Principal:
177 + """Bearer API key (scope inference/admin) or an admin session (playground)."""
178 + if not api_limiter.check(_client_ip(request)):
179 + raise RateLimited("Too many requests.")
180 + p = await principal_from_request(request, auth)
181 + if not p:
182 + if (request.headers.get("authorization") or request.headers.get("x-api-key")):
183 + raise AuthError("Incorrect API key provided.")
184 + raise AuthError("Missing API key. Pass it as 'Authorization: Bearer llm_live_...'.")
185 + if not p.has("inference"):
186 + raise Forbidden("This API key does not have the 'inference' scope.")
187 + request.state.principal = p
188 + return p
189 +
190 +
191 +async def require_admin(request: Request, auth: Auth = Depends(get_auth)) -> Principal:
192 + """Admin session (dashboard) or API key with the admin scope. Mutations from a session need the CSRF header."""
193 + p = await principal_from_request(request, auth)
194 + if not p:
195 + raise AuthError("Authentication required.", code="UNAUTHENTICATED")
196 + if not p.has("admin"):
197 + raise Forbidden("Admin scope required.")
198 + if p.kind == "session" and request.method not in ("GET", "HEAD", "OPTIONS"):
199 + if request.headers.get("x-llm-csrf") != "1":
200 + raise Forbidden("Missing CSRF header.", code="CSRF")
201 + origin = request.headers.get("origin")
202 + if origin:
203 + host = request.headers.get("host", "")
204 + settings = get_settings()
205 + allowed = {settings.public_url.rstrip("/"), f"http://{host}", f"https://{host}", f"http://127.0.0.1:{settings.port}",
206 + f"http://localhost:{settings.port}", "http://localhost:3000", "http://127.0.0.1:3000"}
207 + if origin.rstrip("/") not in allowed:
208 + raise Forbidden("Origin not allowed.", code="CSRF")
209 + request.state.principal = p
210 + return p
added server/llm_api/bench.py +158 −0
@@ -0,0 +1,158 @@
1 +"""Benchmark engine: load time, prompt tps, generation tps, TTFT, peak/avg memory, CPU/GPU, thermal."""
2 +
3 +from __future__ import annotations
4 +
5 +import asyncio
6 +import json
7 +import statistics
8 +import time
9 +
10 +from .hardware import GB, sample_telemetry_async
11 +from .jobs import Job
12 +
13 +BENCH_PROMPT_SHORT = "Explain in three sentences why unified memory matters for running large language models on Apple Silicon."
14 +BENCH_PROMPT_LONG = ("You are a careful technical writer. Summarize the following passage, then list five key facts.\n\n" +
15 + ("Apple Silicon systems share a single pool of unified memory between the CPU and the GPU. "
16 + "This lets a model's weights be read by the GPU without copies, but it also means the operating system, "
17 + "the application and the model compete for the same bytes. ") * 40)
18 +
19 +
20 +async def run_benchmark(state, model_id: str, job: Job, params: dict | None = None) -> dict:
21 + manager = state.manager
22 + db = state.db
23 + jobs = state.jobs
24 + params = params or {}
25 + max_tokens = int(params.get("max_tokens", 256))
26 + runs = int(params.get("runs", 2))
27 + long_prompt = bool(params.get("long_prompt", True))
28 + m = await manager.registry.get(model_id)
29 + if not m:
30 + raise ValueError("model not found")
31 +
32 + # 1. cold load timing (unload first if loaded)
33 + if manager.loaded.get(model_id):
34 + jobs.update(job, progress=0.02, stage="unloading for cold start")
35 + await manager.unload(model_id, reason="benchmark cold start")
36 + jobs.update(job, progress=0.05, stage="loading")
37 + t0 = time.time()
38 + lm = await manager.ensure_loaded(model_id, reason="benchmark")
39 + load_ms = (time.time() - t0) * 1000
40 + jobs.update(job, progress=0.25, stage="loaded", load_ms=round(load_ms))
41 +
42 + mem_samples: list[float] = []
43 + cpu_samples: list[float] = []
44 + gpu_samples: list[float] = []
45 + stop = asyncio.Event()
46 +
47 + async def sampler():
48 + while not stop.is_set():
49 + tel = await sample_telemetry_async(state.settings.models_dir)
50 + mem_samples.append(lm.handle.memory_bytes() / GB)
51 + cpu_samples.append(tel.cpu_percent)
52 + if tel.gpu_percent is not None:
53 + gpu_samples.append(tel.gpu_percent)
54 + await asyncio.sleep(0.5)
55 +
56 + samp = asyncio.create_task(sampler())
57 + results = []
58 + try:
59 + prompts = [BENCH_PROMPT_SHORT] + ([BENCH_PROMPT_LONG] if long_prompt else [])
60 + total_steps = runs * len(prompts)
61 + step = 0
62 + async with manager.use(lm):
63 + for r in range(runs):
64 + for p in prompts:
65 + if job.cancelled:
66 + break
67 + step += 1
68 + jobs.update(job, progress=0.25 + 0.7 * step / total_steps, stage=f"run {step}/{total_steps}")
69 + if m["embedding"]:
70 + t1 = time.time()
71 + resp = await manager.client.post(f"{lm.handle.base_url}/v1/embeddings",
72 + json={"model": model_id, "input": [p] * 8}, timeout=600)
73 + resp.raise_for_status()
74 + d = resp.json()
75 + dt = time.time() - t1
76 + ptoks = d.get("usage", {}).get("prompt_tokens", 0)
77 + results.append({"prompt_tokens": ptoks, "prompt_tps": round(ptoks / dt, 1) if dt else None,
78 + "generation_tokens": 0, "generation_tps": None, "ttft_ms": round(dt * 1000, 1)})
79 + continue
80 + if m["reranker"]:
81 + t1 = time.time()
82 + resp = await manager.client.post(f"{lm.handle.base_url}/v1/rerank",
83 + json={"model": model_id, "query": "unified memory", "documents": [p] * 8}, timeout=600)
84 + resp.raise_for_status()
85 + dt = time.time() - t1
86 + results.append({"prompt_tokens": None, "prompt_tps": None, "generation_tokens": 0,
87 + "generation_tps": None, "ttft_ms": round(dt * 1000, 1)})
88 + continue
89 + body = {"model": model_id, "messages": [{"role": "user", "content": p}], "max_tokens": max_tokens,
90 + "temperature": 0.0, "stream": True, "stream_options": {"include_usage": True}}
91 + t1 = time.time()
92 + first = None
93 + n = 0
94 + usage = {}
95 + timings = {}
96 + async with manager.client.stream("POST", f"{lm.handle.base_url}/v1/chat/completions", json=body, timeout=1800) as resp:
97 + async for line in resp.aiter_lines():
98 + if not line.startswith("data: ") or line.strip() == "data: [DONE]":
99 + continue
100 + try:
101 + obj = json.loads(line[6:])
102 + except json.JSONDecodeError:
103 + continue
104 + for ch in obj.get("choices") or []:
105 + if (ch.get("delta") or {}).get("content"):
106 + if first is None:
107 + first = time.time()
108 + n += 1
109 + if obj.get("usage"):
110 + usage = obj["usage"]
111 + if obj.get("timings"):
112 + timings = obj["timings"]
113 + t2 = time.time()
114 + gen_tokens = usage.get("completion_tokens") or timings.get("predicted_n") or n
115 + ttft = (first - t1) * 1000 if first else (t2 - t1) * 1000
116 + gen_s = (t2 - (first or t1))
117 + results.append({
118 + "prompt_tokens": usage.get("prompt_tokens") or timings.get("prompt_n"),
119 + "prompt_tps": timings.get("prompt_tps") or timings.get("prompt_per_second") or (
120 + round((usage.get("prompt_tokens") or 0) / (ttft / 1000), 1) if ttft else None),
121 + "generation_tokens": gen_tokens,
122 + "generation_tps": timings.get("generation_tps") or timings.get("predicted_per_second") or (
123 + round(gen_tokens / gen_s, 2) if gen_s > 0 else None),
124 + "ttft_ms": round(ttft, 1), "total_ms": round((t2 - t1) * 1000, 1),
125 + "peak_memory_gb": timings.get("peak_memory_gb"),
126 + })
127 + finally:
128 + stop.set()
129 + samp.cancel()
130 +
131 + def avg(key):
132 + vals = [r[key] for r in results if r.get(key) is not None]
133 + return round(statistics.fmean(vals), 2) if vals else None
134 +
135 + tel = await sample_telemetry_async(state.settings.models_dir)
136 + row = {
137 + "model_id": model_id, "created_at": time.time(), "load_ms": round(load_ms), "prompt_tokens": avg("prompt_tokens"),
138 + "prompt_tps": avg("prompt_tps"), "generation_tokens": avg("generation_tokens"), "generation_tps": avg("generation_tps"),
139 + "ttft_ms": avg("ttft_ms"), "peak_memory_gb": round(max(mem_samples), 2) if mem_samples else None,
140 + "avg_memory_gb": round(statistics.fmean(mem_samples), 2) if mem_samples else None,
141 + "cpu_percent": round(statistics.fmean(cpu_samples), 1) if cpu_samples else None,
142 + "gpu_percent": round(statistics.fmean(gpu_samples), 1) if gpu_samples else None,
143 + "thermal_state": tel.thermal_state, "context": lm.handle.context, "runtime": m["runtime"],
144 + "params": json.dumps({"max_tokens": max_tokens, "runs": runs, "long_prompt": long_prompt}),
145 + "notes": json.dumps({"runs": results}),
146 + }
147 + cols = ", ".join(row.keys())
148 + qs = ", ".join("?" for _ in row)
149 + bid = await db.execute(f"INSERT INTO model_benchmarks({cols}) VALUES({qs})", list(row.values()))
150 + if row["generation_tps"]:
151 + await manager.registry.update(model_id, avg_tps=row["generation_tps"])
152 + if row["ttft_ms"]:
153 + await manager.registry.update(model_id, first_token_latency_ms=row["ttft_ms"])
154 + await db.model_event(model_id, "benchmark", {k: row[k] for k in ("load_ms", "generation_tps", "prompt_tps", "ttft_ms", "peak_memory_gb")})
155 + row["id"] = bid
156 + row["runs"] = results
157 + row.pop("notes", None)
158 + return row
added server/llm_api/cli.py +72 −0
@@ -0,0 +1,72 @@
1 +"""Command line: llm-api serve | scan | create-admin | create-key | status."""
2 +
3 +from __future__ import annotations
4 +
5 +import argparse
6 +import asyncio
7 +import json
8 +import sys
9 +
10 +
11 +def main(argv: list[str] | None = None) -> None:
12 + ap = argparse.ArgumentParser(prog="llm-api")
13 + sub = ap.add_subparsers(dest="cmd", required=True)
14 + s = sub.add_parser("serve", help="run the API server")
15 + s.add_argument("--host")
16 + s.add_argument("--port", type=int)
17 + s.add_argument("--reload", action="store_true")
18 + sub.add_parser("scan", help="scan the model directory and print the registry")
19 + a = sub.add_parser("create-admin", help="create the admin user")
20 + a.add_argument("email")
21 + a.add_argument("password")
22 + k = sub.add_parser("create-key", help="create an API key")
23 + k.add_argument("name")
24 + k.add_argument("--admin", action="store_true")
25 + sub.add_parser("status", help="print hardware and registry summary")
26 + args = ap.parse_args(argv)
27 +
28 + from .config import get_settings
29 + settings = get_settings()
30 +
31 + if args.cmd == "serve":
32 + import uvicorn
33 + uvicorn.run("llm_api.main:app", host=args.host or settings.host, port=args.port or settings.port,
34 + reload=args.reload, log_level="info", access_log=False, timeout_keep_alive=75)
35 + return
36 +
37 + async def _run():
38 + from .auth import Auth
39 + from .db import Database
40 + from .models.registry import Registry
41 + settings.ensure_dirs()
42 + db = Database(settings.db_path)
43 + await db.connect()
44 + try:
45 + if args.cmd == "scan":
46 + reg = Registry(db, settings)
47 + print(json.dumps(await reg.rescan(), indent=2))
48 + for m in await reg.list_models():
49 + print(f"{m['id']:50s} {m['runtime']:8s} {m['quantization'] or '':10s} "
50 + f"{(m['estimated_ram_gb'] or 0):6.1f} GB ctx {m['recommended_context']} {m['compatibility_status']}")
51 + elif args.cmd == "create-admin":
52 + auth = Auth(db, settings)
53 + uid = await auth.create_user(args.email, args.password)
54 + print(f"admin user #{uid} created")
55 + elif args.cmd == "create-key":
56 + auth = Auth(db, settings)
57 + raw, row = await auth.create_key(args.name, ["inference", "admin"] if args.admin else ["inference"])
58 + print(raw)
59 + elif args.cmd == "status":
60 + from .hardware import detect_hardware, sample_telemetry
61 + print(json.dumps(detect_hardware(settings.models_dir).to_dict(), indent=2))
62 + print(json.dumps(sample_telemetry(settings.models_dir).to_dict(), indent=2))
63 + n = await db.scalar("SELECT COUNT(*) FROM models WHERE installed=1")
64 + print(f"installed models: {n}")
65 + finally:
66 + await db.close()
67 +
68 + asyncio.run(_run())
69 +
70 +
71 +if __name__ == "__main__":
72 + main(sys.argv[1:])
added server/llm_api/config.py +122 −0
@@ -0,0 +1,122 @@
1 +"""Application settings (environment variables, .env)."""
2 +
3 +from __future__ import annotations
4 +
5 +import os
6 +from functools import lru_cache
7 +from pathlib import Path
8 +
9 +from pydantic import Field
10 +from pydantic_settings import BaseSettings, SettingsConfigDict
11 +
12 +
13 +def _default_root() -> Path:
14 + return Path(os.environ.get("LLM_API_ROOT", str(Path.home() / "llm-api")))
15 +
16 +
17 +class Settings(BaseSettings):
18 + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
19 +
20 + app_env: str = Field(default="development", alias="APP_ENV")
21 + domain: str = Field(default="www.llm-api.io", alias="DOMAIN")
22 + public_url: str = Field(default="https://www.llm-api.io", alias="PUBLIC_URL")
23 +
24 + host: str = Field(default="127.0.0.1", alias="HOST")
25 + port: int = Field(default=8300, alias="PORT")
26 + web_url: str = Field(default="http://127.0.0.1:8301", alias="WEB_URL")
27 +
28 + # Storage
29 + root: Path = Field(default_factory=_default_root, alias="LLM_API_ROOT")
30 + model_root: Path | None = Field(default=None, alias="MODEL_ROOT")
31 + data_dir: Path | None = Field(default=None, alias="DATA_DIR")
32 + log_dir: Path | None = Field(default=None, alias="LOG_DIR")
33 + database_path: Path | None = Field(default=None, alias="DATABASE_PATH")
34 +
35 + # Memory policy (GB)
36 + max_model_memory_gb: float = Field(default=45.0, alias="MAX_MODEL_MEMORY_GB")
37 + absolute_max_memory_gb: float = Field(default=50.0, alias="ABSOLUTE_MAX_MEMORY_GB")
38 + macos_reserve_gb: float = Field(default=8.0, alias="MACOS_RESERVE_GB")
39 + min_free_disk_gb: float = Field(default=100.0, alias="MIN_FREE_DISK_GB")
40 +
41 + max_simultaneous_models: int = Field(default=1, alias="MAX_SIMULTANEOUS_MODELS")
42 + model_idle_timeout_minutes: int = Field(default=30, alias="MODEL_IDLE_TIMEOUT_MINUTES")
43 + small_model_resident_gb: float = Field(default=3.0, alias="SMALL_MODEL_RESIDENT_GB")
44 + load_timeout_seconds: int = Field(default=900, alias="LOAD_TIMEOUT_SECONDS")
45 + generation_timeout_seconds: int = Field(default=1800, alias="GENERATION_TIMEOUT_SECONDS")
46 + preload_model: str = Field(default="none", alias="PRELOAD_MODEL")
47 +
48 + enable_mlx: bool = Field(default=True, alias="ENABLE_MLX")
49 + enable_gguf: bool = Field(default=True, alias="ENABLE_GGUF")
50 + allow_downloads: bool = Field(default=True, alias="ALLOW_DOWNLOADS")
51 + log_prompts: bool = Field(default=False, alias="LOG_PROMPTS")
52 +
53 + default_max_tokens: int = Field(default=2048, alias="DEFAULT_MAX_TOKENS")
54 + default_context: int = Field(default=16384, alias="DEFAULT_CONTEXT")
55 +
56 + hf_token: str | None = Field(default=None, alias="HF_TOKEN")
57 + llama_server_bin: str = Field(default="llama-server", alias="LLAMA_SERVER_BIN")
58 + worker_python: str | None = Field(default=None, alias="WORKER_PYTHON")
59 + worker_port_start: int = Field(default=8310, alias="WORKER_PORT_START")
60 + worker_port_end: int = Field(default=8399, alias="WORKER_PORT_END")
61 +
62 + secret_key: str = Field(default="", alias="SECRET_KEY")
63 + admin_email: str | None = Field(default=None, alias="ADMIN_EMAIL")
64 + admin_password: str | None = Field(default=None, alias="ADMIN_PASSWORD")
65 + session_hours: int = Field(default=24 * 14, alias="SESSION_HOURS")
66 + secure_cookies: bool = Field(default=False, alias="SECURE_COOKIES")
67 +
68 + metrics_interval_seconds: int = Field(default=15, alias="METRICS_INTERVAL_SECONDS")
69 + metrics_retention_days: int = Field(default=30, alias="METRICS_RETENTION_DAYS")
70 + max_body_bytes: int = Field(default=20 * 1024 * 1024, alias="MAX_BODY_BYTES")
71 +
72 + # ---- derived paths -------------------------------------------------
73 + @property
74 + def models_dir(self) -> Path:
75 + return (self.model_root or self.root / "models").expanduser()
76 +
77 + @property
78 + def data_path(self) -> Path:
79 + return (self.data_dir or self.root / "data").expanduser()
80 +
81 + @property
82 + def logs_path(self) -> Path:
83 + return (self.log_dir or self.root / "logs").expanduser()
84 +
85 + @property
86 + def db_path(self) -> Path:
87 + return (self.database_path or self.data_path / "llm-api.db").expanduser()
88 +
89 + @property
90 + def secret(self) -> str:
91 + if self.secret_key:
92 + return self.secret_key
93 + # Persist a generated secret so sessions survive restarts.
94 + p = self.data_path / ".secret"
95 + p.parent.mkdir(parents=True, exist_ok=True)
96 + if p.exists():
97 + return p.read_text().strip()
98 + import secrets
99 +
100 + s = secrets.token_hex(32)
101 + p.write_text(s)
102 + os.chmod(p, 0o600)
103 + return s
104 +
105 + def ensure_dirs(self) -> None:
106 + for d in (
107 + self.models_dir,
108 + self.models_dir / "mlx",
109 + self.models_dir / "gguf",
110 + self.models_dir / "embeddings",
111 + self.models_dir / "rerankers",
112 + self.models_dir / "vision",
113 + self.models_dir / "manifests",
114 + self.data_path,
115 + self.logs_path,
116 + ):
117 + d.mkdir(parents=True, exist_ok=True)
118 +
119 +
120 +@lru_cache
121 +def get_settings() -> Settings:
122 + return Settings()
added server/llm_api/db.py +300 −0
@@ -0,0 +1,300 @@
1 +"""SQLite persistence (aiosqlite, WAL)."""
2 +
3 +from __future__ import annotations
4 +
5 +import json
6 +import time
7 +from pathlib import Path
8 +from typing import Any, Iterable
9 +
10 +import aiosqlite
11 +
12 +SCHEMA = """
13 +CREATE TABLE IF NOT EXISTS users (
14 + id INTEGER PRIMARY KEY AUTOINCREMENT,
15 + email TEXT UNIQUE NOT NULL,
16 + password_hash TEXT NOT NULL,
17 + role TEXT NOT NULL DEFAULT 'admin',
18 + created_at REAL NOT NULL,
19 + last_login_at REAL
20 +);
21 +CREATE TABLE IF NOT EXISTS api_keys (
22 + id INTEGER PRIMARY KEY AUTOINCREMENT,
23 + name TEXT NOT NULL,
24 + prefix TEXT NOT NULL,
25 + key_hash TEXT UNIQUE NOT NULL,
26 + scopes TEXT NOT NULL DEFAULT 'inference',
27 + created_at REAL NOT NULL,
28 + last_used_at REAL,
29 + request_count INTEGER NOT NULL DEFAULT 0,
30 + revoked_at REAL
31 +);
32 +CREATE TABLE IF NOT EXISTS models (
33 + id TEXT PRIMARY KEY,
34 + name TEXT NOT NULL,
35 + family TEXT,
36 + provider TEXT,
37 + architecture TEXT,
38 + model_type TEXT,
39 + parameter_count INTEGER,
40 + active_parameter_count INTEGER,
41 + quantization TEXT,
42 + quant_bits REAL,
43 + runtime TEXT NOT NULL,
44 + format TEXT NOT NULL,
45 + path TEXT NOT NULL,
46 + weights_file TEXT,
47 + mmproj_file TEXT,
48 + disk_size_bytes INTEGER NOT NULL DEFAULT 0,
49 + weights_bytes INTEGER NOT NULL DEFAULT 0,
50 + estimated_ram_gb REAL,
51 + kv_bytes_per_token INTEGER,
52 + recommended_context INTEGER,
53 + max_context INTEGER,
54 + task TEXT NOT NULL DEFAULT 'text-generation',
55 + vision INTEGER NOT NULL DEFAULT 0,
56 + embedding INTEGER NOT NULL DEFAULT 0,
57 + reranker INTEGER NOT NULL DEFAULT 0,
58 + thinking INTEGER NOT NULL DEFAULT 0,
59 + tools INTEGER NOT NULL DEFAULT 0,
60 + size_class TEXT,
61 + installed INTEGER NOT NULL DEFAULT 1,
62 + enabled INTEGER NOT NULL DEFAULT 1,
63 + favorite INTEGER NOT NULL DEFAULT 0,
64 + pinned INTEGER NOT NULL DEFAULT 0,
65 + verified INTEGER NOT NULL DEFAULT 0,
66 + compatible INTEGER NOT NULL DEFAULT 1,
67 + compatibility_status TEXT,
68 + compatibility_reason TEXT,
69 + tags TEXT NOT NULL DEFAULT '[]',
70 + repository TEXT,
71 + manifest TEXT,
72 + notes TEXT,
73 + overrides TEXT NOT NULL DEFAULT '{}',
74 + created_at REAL NOT NULL,
75 + updated_at REAL NOT NULL,
76 + last_loaded_at REAL,
77 + last_used_at REAL,
78 + load_count INTEGER NOT NULL DEFAULT 0,
79 + request_count INTEGER NOT NULL DEFAULT 0,
80 + tokens_generated INTEGER NOT NULL DEFAULT 0,
81 + min_load_ms REAL,
82 + avg_load_ms REAL,
83 + max_load_ms REAL,
84 + last_load_ms REAL,
85 + avg_tps REAL,
86 + first_token_latency_ms REAL
87 +);
88 +CREATE TABLE IF NOT EXISTS model_aliases (
89 + alias TEXT PRIMARY KEY,
90 + model_id TEXT NOT NULL,
91 + created_at REAL NOT NULL
92 +);
93 +CREATE TABLE IF NOT EXISTS model_benchmarks (
94 + id INTEGER PRIMARY KEY AUTOINCREMENT,
95 + model_id TEXT NOT NULL,
96 + created_at REAL NOT NULL,
97 + load_ms REAL,
98 + prompt_tokens INTEGER,
99 + prompt_tps REAL,
100 + generation_tokens INTEGER,
101 + generation_tps REAL,
102 + ttft_ms REAL,
103 + peak_memory_gb REAL,
104 + avg_memory_gb REAL,
105 + cpu_percent REAL,
106 + gpu_percent REAL,
107 + thermal_state TEXT,
108 + context INTEGER,
109 + runtime TEXT,
110 + params TEXT,
111 + notes TEXT
112 +);
113 +CREATE TABLE IF NOT EXISTS model_events (
114 + id INTEGER PRIMARY KEY AUTOINCREMENT,
115 + created_at REAL NOT NULL,
116 + model_id TEXT,
117 + event TEXT NOT NULL,
118 + detail TEXT
119 +);
120 +CREATE TABLE IF NOT EXISTS inference_requests (
121 + id INTEGER PRIMARY KEY AUTOINCREMENT,
122 + created_at REAL NOT NULL,
123 + model_id TEXT,
124 + requested_model TEXT,
125 + endpoint TEXT NOT NULL,
126 + api_key_id INTEGER,
127 + stream INTEGER NOT NULL DEFAULT 0,
128 + prompt_tokens INTEGER,
129 + completion_tokens INTEGER,
130 + ttft_ms REAL,
131 + total_ms REAL,
132 + tps REAL,
133 + load_wait_ms REAL,
134 + status INTEGER,
135 + error_code TEXT,
136 + prompt TEXT,
137 + completion TEXT
138 +);
139 +CREATE INDEX IF NOT EXISTS idx_inference_created ON inference_requests(created_at);
140 +CREATE TABLE IF NOT EXISTS system_metrics (
141 + ts REAL PRIMARY KEY,
142 + mem_used_gb REAL,
143 + mem_available_gb REAL,
144 + mem_pressure INTEGER,
145 + swap_used_gb REAL,
146 + cpu_percent REAL,
147 + gpu_percent REAL,
148 + disk_free_gb REAL,
149 + thermal TEXT,
150 + worker_rss_gb REAL,
151 + loaded_model TEXT
152 +);
153 +CREATE TABLE IF NOT EXISTS jobs (
154 + id TEXT PRIMARY KEY,
155 + kind TEXT NOT NULL,
156 + status TEXT NOT NULL,
157 + title TEXT,
158 + payload TEXT,
159 + progress REAL NOT NULL DEFAULT 0,
160 + detail TEXT,
161 + result TEXT,
162 + error TEXT,
163 + created_at REAL NOT NULL,
164 + started_at REAL,
165 + finished_at REAL
166 +);
167 +CREATE TABLE IF NOT EXISTS settings (
168 + key TEXT PRIMARY KEY,
169 + value TEXT NOT NULL,
170 + updated_at REAL NOT NULL
171 +);
172 +CREATE TABLE IF NOT EXISTS audit_logs (
173 + id INTEGER PRIMARY KEY AUTOINCREMENT,
174 + created_at REAL NOT NULL,
175 + actor TEXT,
176 + action TEXT NOT NULL,
177 + target TEXT,
178 + detail TEXT,
179 + ip TEXT
180 +);
181 +CREATE TABLE IF NOT EXISTS harvest_candidates (
182 + repo_id TEXT PRIMARY KEY,
183 + runtime TEXT NOT NULL,
184 + family TEXT,
185 + base_model TEXT,
186 + name TEXT,
187 + task TEXT,
188 + quantization TEXT,
189 + parameter_count INTEGER,
190 + download_bytes INTEGER,
191 + estimated_ram_gb REAL,
192 + size_class TEXT,
193 + compatibility_status TEXT,
194 + compatibility_reason TEXT,
195 + downloads INTEGER,
196 + likes INTEGER,
197 + last_modified TEXT,
198 + files TEXT,
199 + duplicate_of TEXT,
200 + installed INTEGER NOT NULL DEFAULT 0,
201 + selected INTEGER NOT NULL DEFAULT 0,
202 + dismissed INTEGER NOT NULL DEFAULT 0,
203 + score REAL,
204 + scanned_at REAL NOT NULL,
205 + raw TEXT
206 +);
207 +"""
208 +
209 +
210 +class Database:
211 + def __init__(self, path: Path):
212 + self.path = path
213 + self._conn: aiosqlite.Connection | None = None
214 +
215 + async def connect(self) -> None:
216 + self.path.parent.mkdir(parents=True, exist_ok=True)
217 + self._conn = await aiosqlite.connect(self.path)
218 + self._conn.row_factory = aiosqlite.Row
219 + await self._conn.execute("PRAGMA journal_mode=WAL")
220 + await self._conn.execute("PRAGMA synchronous=NORMAL")
221 + await self._conn.execute("PRAGMA foreign_keys=ON")
222 + await self._conn.executescript(SCHEMA)
223 + await self._conn.commit()
224 +
225 + async def close(self) -> None:
226 + if self._conn:
227 + await self._conn.commit()
228 + await self._conn.close()
229 + self._conn = None
230 +
231 + @property
232 + def conn(self) -> aiosqlite.Connection:
233 + assert self._conn is not None, "database not connected"
234 + return self._conn
235 +
236 + # -- helpers ----------------------------------------------------------
237 + async def execute(self, sql: str, params: Iterable[Any] = ()) -> int:
238 + cur = await self.conn.execute(sql, tuple(params))
239 + await self.conn.commit()
240 + return cur.lastrowid or 0
241 +
242 + async def executemany(self, sql: str, rows: Iterable[Iterable[Any]]) -> None:
243 + await self.conn.executemany(sql, [tuple(r) for r in rows])
244 + await self.conn.commit()
245 +
246 + async def fetchone(self, sql: str, params: Iterable[Any] = ()) -> dict | None:
247 + cur = await self.conn.execute(sql, tuple(params))
248 + row = await cur.fetchone()
249 + return dict(row) if row else None
250 +
251 + async def fetchall(self, sql: str, params: Iterable[Any] = ()) -> list[dict]:
252 + cur = await self.conn.execute(sql, tuple(params))
253 + rows = await cur.fetchall()
254 + return [dict(r) for r in rows]
255 +
256 + async def scalar(self, sql: str, params: Iterable[Any] = ()) -> Any:
257 + cur = await self.conn.execute(sql, tuple(params))
258 + row = await cur.fetchone()
259 + return row[0] if row else None
260 +
261 + # -- settings -----------------------------------------------------------
262 + async def get_setting(self, key: str, default: Any = None) -> Any:
263 + row = await self.fetchone("SELECT value FROM settings WHERE key=?", (key,))
264 + if not row:
265 + return default
266 + try:
267 + return json.loads(row["value"])
268 + except json.JSONDecodeError:
269 + return row["value"]
270 +
271 + async def set_setting(self, key: str, value: Any) -> None:
272 + await self.execute(
273 + "INSERT INTO settings(key, value, updated_at) VALUES(?,?,?) "
274 + "ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
275 + (key, json.dumps(value), time.time()),
276 + )
277 +
278 + async def all_settings(self) -> dict[str, Any]:
279 + rows = await self.fetchall("SELECT key, value FROM settings")
280 + out = {}
281 + for r in rows:
282 + try:
283 + out[r["key"]] = json.loads(r["value"])
284 + except json.JSONDecodeError:
285 + out[r["key"]] = r["value"]
286 + return out
287 +
288 + # -- audit / events -----------------------------------------------------
289 + async def audit(self, action: str, *, actor: str | None = None, target: str | None = None,
290 + detail: Any = None, ip: str | None = None) -> None:
291 + await self.execute(
292 + "INSERT INTO audit_logs(created_at, actor, action, target, detail, ip) VALUES(?,?,?,?,?,?)",
293 + (time.time(), actor, action, target, json.dumps(detail) if detail is not None else None, ip),
294 + )
295 +
296 + async def model_event(self, model_id: str | None, event: str, detail: Any = None) -> None:
297 + await self.execute(
298 + "INSERT INTO model_events(created_at, model_id, event, detail) VALUES(?,?,?,?)",
299 + (time.time(), model_id, event, json.dumps(detail) if detail is not None else None),
300 + )
added server/llm_api/downloads.py +341 −0
@@ -0,0 +1,341 @@
1 +"""Hugging Face inspection + downloads (with progress), model manifests, verification."""
2 +
3 +from __future__ import annotations
4 +
5 +import asyncio
6 +import json
7 +import logging
8 +import os
9 +import re
10 +import shutil
11 +import time
12 +from pathlib import Path
13 +from typing import Any
14 +
15 +from .config import Settings
16 +from .errors import APIError, DownloadError, InsufficientDisk
17 +from .jobs import Job, JobRunner
18 +from .models import compat, formats
19 +from .models.estimator import kv_bytes_per_token
20 +from .models.scanner import slugify
21 +
22 +log = logging.getLogger("llm_api.downloads")
23 +GB = 1024**3
24 +
25 +REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
26 +TRUSTED_GGUF_AUTHORS = {"unsloth", "bartowski", "ggml-org", "lmstudio-community", "Qwen", "google", "mistralai",
27 + "microsoft", "TheBloke", "mradermacher", "QuantFactory", "nomic-ai", "BAAI", "jinaai",
28 + "mixedbread-ai", "second-state", "openai", "deepseek-ai", "zai-org", "nvidia", "meta-llama"}
29 +
30 +
31 +def parse_repo(text: str) -> str:
32 + t = text.strip()
33 + m = re.match(r"^https?://huggingface\.co/([^/\s]+/[^/\s?#]+)", t)
34 + if m:
35 + t = m.group(1)
36 + t = t.removeprefix("hf.co/").removeprefix("huggingface.co/")
37 + if not REPO_RE.match(t) or ".." in t:
38 + raise APIError("Invalid Hugging Face repository id. Expected 'organization/model-name'.", param="repository")
39 + return t
40 +
41 +
42 +def _quant_rank(label: str | None) -> float:
43 + if not label:
44 + return 0
45 + _, bits = formats.parse_quant_from_name("x-" + label)
46 + return bits or 0
47 +
48 +
49 +def pick_gguf_files(files: list[dict], preferred: str | None = None) -> list[dict]:
50 + """Choose one quantization from a GGUF repo (prefer Q4_K_M/Q5_K_M/Q6_K/Q8_0, or preferred)."""
51 + ggufs = [f for f in files if f["path"].lower().endswith(".gguf")]
52 + if not ggufs:
53 + return []
54 + mmproj = [f for f in ggufs if "mmproj" in f["path"].lower()]
55 + weights = [f for f in ggufs if "mmproj" not in f["path"].lower()]
56 +
57 + def label(f):
58 + q, _ = formats.parse_quant_from_name(Path(f["path"]).stem)
59 + return (q or "").upper()
60 +
61 + groups: dict[str, list[dict]] = {}
62 + for f in weights:
63 + base = re.sub(r"-\d{5}-of-\d{5}$", "", Path(f["path"]).stem)
64 + groups.setdefault(base, []).append(f)
65 + if preferred:
66 + pref = preferred.upper()
67 + for base, fs in groups.items():
68 + if pref in base.upper():
69 + return sorted(fs, key=lambda f: f["path"]) + mmproj[:1]
70 + order = ["Q4_K_M", "Q4_K_XL", "UD-Q4_K_XL", "Q5_K_M", "Q4_K_S", "Q6_K", "Q8_0", "MXFP4", "Q5_K_S", "IQ4_XS", "Q4_0", "BF16", "F16"]
71 + for want in order:
72 + for base, fs in groups.items():
73 + if want in base.upper():
74 + return sorted(fs, key=lambda f: f["path"]) + mmproj[:1]
75 + base = sorted(groups.items(), key=lambda kv: sum(f["size"] or 0 for f in kv[1]))[0]
76 + return sorted(base[1], key=lambda f: f["path"]) + mmproj[:1]
77 +
78 +
79 +class Downloader:
80 + def __init__(self, settings: Settings, db, registry, jobs: JobRunner):
81 + self.settings = settings
82 + self.db = db
83 + self.registry = registry
84 + self.jobs = jobs
85 +
86 + def _api(self):
87 + from huggingface_hub import HfApi
88 + return HfApi(token=self.settings.hf_token or os.environ.get("HF_TOKEN") or None)
89 +
90 + # ------------------------------------------------------------------ inspect
91 + async def inspect(self, repo: str, quant: str | None = None) -> dict:
92 + repo = parse_repo(repo)
93 + api = self._api()
94 + try:
95 + info = await asyncio.to_thread(api.model_info, repo, files_metadata=True)
96 + except Exception as e:
97 + msg = str(e)
98 + if "401" in msg or "gated" in msg.lower():
99 + raise DownloadError(f"Repository '{repo}' is gated or private. Set HF_TOKEN and accept the license on Hugging Face.")
100 + if "404" in msg:
101 + raise DownloadError(f"Repository '{repo}' was not found on Hugging Face.")
102 + if "429" in msg:
103 + raise DownloadError("Hugging Face rate limit reached. Try again in a minute.")
104 + raise DownloadError(f"Could not inspect '{repo}': {msg[:200]}")
105 + files = [{"path": s.rfilename, "size": s.size or 0} for s in (info.siblings or [])]
106 + tags = list(info.tags or [])
107 + library = getattr(info, "library_name", None)
108 + cfg = getattr(info, "config", None) or {}
109 + has_st = any(f["path"].endswith(".safetensors") for f in files)
110 + has_gguf = any(f["path"].lower().endswith(".gguf") for f in files)
111 + is_mlx = "mlx" in tags or library == "mlx"
112 + runtime = None
113 + selected: list[dict] = []
114 + if has_gguf:
115 + runtime = "llamacpp"
116 + selected = pick_gguf_files(files, quant)
117 + weights_bytes = sum(f["size"] for f in selected if "mmproj" not in f["path"].lower())
118 + download_bytes = sum(f["size"] for f in selected)
119 + elif has_st and is_mlx:
120 + runtime = "mlx"
121 + selected = [f for f in files if not f["path"].endswith((".gguf", ".bin", ".pt", ".onnx", ".h5", ".msgpack"))
122 + and ".cache" not in f["path"]]
123 + weights_bytes = sum(f["size"] for f in selected if f["path"].endswith(".safetensors"))
124 + download_bytes = sum(f["size"] for f in selected)
125 + elif has_st:
126 + runtime = "mlx" # mlx_lm can load standard HF safetensors (quantizes nothing; runs bf16)
127 + selected = [f for f in files if not f["path"].endswith((".gguf", ".bin", ".pt", ".onnx", ".h5", ".msgpack"))]
128 + weights_bytes = sum(f["size"] for f in selected if f["path"].endswith(".safetensors"))
129 + download_bytes = sum(f["size"] for f in selected)
130 + else:
131 + raise DownloadError(f"Repository '{repo}' contains neither safetensors nor GGUF weights.")
132 +
133 + parsed = formats.parse_hf_config(cfg) if cfg else {}
134 + model_type = parsed.get("model_type")
135 + base_model = next((t.split(":", 1)[1] for t in tags if t.startswith("base_model:") and "quantized:" not in t
136 + and "finetune:" not in t), None)
137 + bm_quant = next((t.split(":", 2)[2] for t in tags if t.startswith("base_model:quantized:")), None)
138 + quant_label = parsed.get("quantization")
139 + bits = parsed.get("quant_bits")
140 + if runtime == "llamacpp" and selected:
141 + quant_label, bits = formats.parse_quant_from_name(Path(selected[0]["path"]).stem)
142 + if not quant_label:
143 + quant_label, bits = formats.parse_quant_from_name(repo.split("/")[-1])
144 + if not quant_label and runtime == "mlx":
145 + quant_label, bits = (parsed.get("torch_dtype") or "bf16"), 16
146 + total_p, active_p = formats.parse_param_count_from_name(repo.split("/")[-1])
147 + if not total_p and weights_bytes and bits:
148 + total_p = int(weights_bytes * 8 / bits)
149 + kv = kv_bytes_per_token(parsed.get("n_layers"), parsed.get("n_kv_heads"), parsed.get("head_dim"), 16,
150 + parsed.get("full_attention_layers"), parsed.get("sliding_window"))
151 + if not kv and total_p:
152 + # crude fallback: ~ 130 KB/token for 7-9B, scale with params^0.6
153 + kv = int(130_000 * (total_p / 8e9) ** 0.6)
154 + pipeline = getattr(info, "pipeline_tag", None)
155 + name_l = repo.lower()
156 + vision = bool(parsed.get("vision")) or pipeline == "image-text-to-text" or any("mmproj" in f["path"].lower() for f in selected)
157 + embedding = pipeline in ("feature-extraction", "sentence-similarity") or "embed" in name_l
158 + reranker = pipeline == "text-ranking" or "rerank" in name_l
159 + budget, absolute = await self.registry.budgets()
160 + from .models.scanner import llamacpp_available
161 + comp = compat.evaluate(runtime=runtime, weights_bytes=weights_bytes, kv_per_token=kv, max_context=parsed.get("max_context"),
162 + model_type=model_type, architecture=(parsed.get("architectures") or [None])[0] if runtime == "mlx" else model_type,
163 + vision=vision, embedding=embedding, reranker=reranker, budget_gb=budget, absolute_gb=absolute,
164 + llamacpp_available=llamacpp_available(self.settings.llama_server_bin), quant_bits=bits,
165 + weights_file=selected[0]["path"] if runtime == "llamacpp" and selected else None)
166 + if runtime == "llamacpp" and comp.status == compat.INCOMPATIBLE and "Architecture" in comp.reason:
167 + pass
168 + du = shutil.disk_usage(self.settings.models_dir)
169 + min_free = float(await self.db.get_setting("min_free_disk_gb", self.settings.min_free_disk_gb))
170 + free_after = (du.free - download_bytes) / GB
171 + target = self.target_dir(repo, runtime, vision, embedding, reranker)
172 + existing = await self.db.fetchone("SELECT id FROM models WHERE repository=? AND installed=1", (repo,))
173 + return {
174 + "repository": repo, "runtime": runtime, "format": "gguf" if runtime == "llamacpp" else "safetensors",
175 + "files": selected, "all_files": files, "download_bytes": download_bytes, "weights_bytes": weights_bytes,
176 + "quantization": quant_label, "quant_bits": bits, "parameter_count": total_p, "active_parameter_count": active_p,
177 + "model_type": model_type, "pipeline_tag": pipeline, "library": library, "tags": tags[:40],
178 + "base_model": base_model or bm_quant, "vision": vision, "embedding": embedding, "reranker": reranker,
179 + "max_context": parsed.get("max_context"), "kv_bytes_per_token": kv, "compatibility": comp.to_dict(),
180 + "size_class": formats.size_class(comp.estimated_ram_gb, budget),
181 + "disk": {"free_gb": round(du.free / GB, 1), "free_after_gb": round(free_after, 1), "min_free_gb": min_free,
182 + "ok": free_after >= min_free},
183 + "target_dir": str(target), "already_installed": existing["id"] if existing else None,
184 + "downloads": getattr(info, "downloads", None), "likes": getattr(info, "likes", None),
185 + "last_modified": str(getattr(info, "last_modified", "") or ""), "gated": bool(getattr(info, "gated", False)),
186 + }
187 +
188 + def target_dir(self, repo: str, runtime: str, vision: bool, embedding: bool, reranker: bool) -> Path:
189 + name = repo.split("/")[-1]
190 + root = self.settings.models_dir
191 + if embedding:
192 + sub = root / "embeddings"
193 + elif reranker:
194 + sub = root / "rerankers"
195 + elif vision:
196 + sub = root / "vision"
197 + else:
198 + sub = root / ("gguf" if runtime == "llamacpp" else "mlx")
199 + family = formats.guess_family(name)
200 + return sub / family / name
201 +
202 + # ------------------------------------------------------------------ download
203 + async def start_download(self, repo: str, quant: str | None = None, *, force: bool = False,
204 + actor: str | None = None) -> Job:
205 + if not await self.db.get_setting("allow_downloads", self.settings.allow_downloads):
206 + raise APIError("Downloads are disabled in settings.", code="DOWNLOADS_DISABLED", status_code=403)
207 + insp = await self.inspect(repo, quant)
208 + if insp["already_installed"] and not force:
209 + raise APIError(f"'{repo}' is already installed as '{insp['already_installed']}'.", code="ALREADY_INSTALLED", status_code=409)
210 + if not insp["disk"]["ok"]:
211 + raise InsufficientDisk(f"Downloading {insp['download_bytes'] / GB:.1f} GB would leave {insp['disk']['free_after_gb']} GB free, "
212 + f"below the {insp['disk']['min_free_gb']:.0f} GB reserve.")
213 + if insp["compatibility"]["status"] == compat.INCOMPATIBLE and not force:
214 + raise APIError(f"'{repo}' is incompatible: {insp['compatibility']['reason']}", code="MODEL_INCOMPATIBLE", status_code=422)
215 + target = Path(insp["target_dir"])
216 + title = f"Download {repo}"
217 + payload = {"repository": repo, "quant": quant, "target": str(target), "runtime": insp["runtime"],
218 + "download_bytes": insp["download_bytes"], "files": [f["path"] for f in insp["files"]]}
219 + await self.db.audit("download.start", actor=actor, target=repo, detail={"bytes": insp["download_bytes"]})
220 +
221 + async def run(job: Job):
222 + return await self._download_job(job, insp, target)
223 +
224 + return self.jobs.submit("download", title, payload, run, exclusive_download=True)
225 +
226 + async def _download_job(self, job: Job, insp: dict, target: Path) -> dict:
227 + from huggingface_hub import hf_hub_download
228 + repo = insp["repository"]
229 + files = insp["files"]
230 + total = max(1, insp["download_bytes"])
231 + target.mkdir(parents=True, exist_ok=True)
232 + token = self.settings.hf_token or os.environ.get("HF_TOKEN") or None
233 + t0 = time.time()
234 + done_bytes = 0
235 + # progress poller: sums sizes of files (+ partial .incomplete blobs) in target
236 + stop = asyncio.Event()
237 +
238 + def measure() -> int:
239 + n = 0
240 + for p in target.rglob("*"):
241 + if p.is_file():
242 + try:
243 + n += p.stat().st_size
244 + except OSError:
245 + pass
246 + return n
247 +
248 + async def poll():
249 + last = 0
250 + last_t = time.time()
251 + while not stop.is_set():
252 + cur = await asyncio.to_thread(measure)
253 + now = time.time()
254 + speed = (cur - last) / max(0.001, now - last_t)
255 + last, last_t = cur, now
256 + eta = (total - cur) / speed if speed > 0 else None
257 + self.jobs.update(job, progress=min(0.99, cur / total), downloaded=cur, total=total,
258 + speed_bps=round(speed), eta_seconds=round(eta) if eta else None,
259 + elapsed=round(now - t0))
260 + await asyncio.sleep(1.0)
261 +
262 + poller = asyncio.create_task(poll())
263 + try:
264 + for i, f in enumerate(files):
265 + if job.cancelled:
266 + break
267 + self.jobs.update(job, current_file=f["path"], file_index=i + 1, file_count=len(files))
268 + await asyncio.to_thread(hf_hub_download, repo, f["path"], local_dir=str(target), token=token,
269 + force_download=False)
270 + done_bytes += f["size"]
271 + finally:
272 + stop.set()
273 + poller.cancel()
274 + if job.cancelled:
275 + # remove partial download
276 + shutil.rmtree(target, ignore_errors=True)
277 + return {"cancelled": True}
278 + # cleanup HF metadata cache folder inside local_dir
279 + shutil.rmtree(target / ".cache", ignore_errors=True)
280 + # verify
281 + missing = [f["path"] for f in files if not (target / f["path"]).exists()]
282 + bad = [f["path"] for f in files if (target / f["path"]).exists() and f["size"] and (target / f["path"]).stat().st_size != f["size"]]
283 + if missing or bad:
284 + raise DownloadError(f"Download incomplete: missing {missing[:3]} size-mismatch {bad[:3]}")
285 + manifest = {
286 + "schema_version": 1, "model_id": slugify(repo.split("/")[-1]), "runtime": insp["runtime"],
287 + "quantization": insp["quantization"], "download_source": "huggingface", "repository": repo,
288 + "downloaded_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "verified": True,
289 + "files": [{"path": f["path"], "size": f["size"]} for f in files], "provider": repo.split("/")[0],
290 + "base_model": insp.get("base_model"), "task": "embedding" if insp["embedding"] else "reranking" if insp["reranker"] else
291 + "image-text-to-text" if insp["vision"] else "text-generation",
292 + }
293 + (target / "llm-api.json").write_text(json.dumps(manifest, indent=2))
294 + # keep a copy in models/manifests
295 + mdir = self.settings.models_dir / "manifests"
296 + mdir.mkdir(parents=True, exist_ok=True)
297 + (mdir / f"{slugify(repo)}.json").write_text(json.dumps(manifest, indent=2))
298 + self.jobs.update(job, progress=0.99, stage="registering")
299 + summary = await self.registry.rescan()
300 + m = await self.db.fetchone("SELECT id FROM models WHERE path=?", (str(target),))
301 + mid = m["id"] if m else None
302 + if mid:
303 + await self.registry.update(mid, repository=repo, provider=repo.split("/")[0], verified=False)
304 + if insp.get("embedding") or insp.get("reranker") or insp.get("vision"):
305 + ov = {"task": manifest["task"], "vision": insp["vision"], "embedding": insp["embedding"], "reranker": insp["reranker"]}
306 + await self.registry.update(mid, overrides=ov, task=manifest["task"], vision=insp["vision"],
307 + embedding=insp["embedding"], reranker=insp["reranker"])
308 + await self.db.model_event(mid, "downloaded", {"repository": repo, "bytes": insp["download_bytes"],
309 + "seconds": round(time.time() - t0)})
310 + return {"model_id": mid, "path": str(target), "bytes": insp["download_bytes"], "seconds": round(time.time() - t0),
311 + "scan": summary}
312 +
313 + # ------------------------------------------------------------------ delete
314 + async def delete_model(self, model_id: str, *, actor: str | None = None, keep_benchmarks: bool = True) -> dict:
315 + m = await self.registry.get(model_id)
316 + if not m:
317 + from .errors import ModelNotFound
318 + raise ModelNotFound(f"Model '{model_id}' not found.")
319 + path = Path(m["path"]).resolve()
320 + root = self.settings.models_dir.resolve()
321 + if root not in path.parents:
322 + raise APIError("Refusing to delete a path outside the model root.", code="PATH_NOT_ALLOWED", status_code=403)
323 + size = m["disk_size_bytes"]
324 + if m["format"] == "gguf" and m["weights_file"]:
325 + # delete only this quantization's files (+ mmproj if no other model uses the dir)
326 + others = await self.db.fetchall("SELECT id FROM models WHERE path=? AND id<>?", (m["path"], model_id))
327 + wf = Path(m["weights_file"])
328 + base = re.sub(r"-\d{5}-of-\d{5}$", "", wf.stem)
329 + for f in path.glob("*.gguf"):
330 + if f.stem == wf.stem or f.stem.startswith(base + "-0"):
331 + f.unlink(missing_ok=True)
332 + if not others:
333 + shutil.rmtree(path, ignore_errors=True)
334 + else:
335 + shutil.rmtree(path, ignore_errors=True)
336 + await self.registry.delete(model_id)
337 + if not keep_benchmarks:
338 + await self.db.execute("DELETE FROM model_benchmarks WHERE model_id=?", (model_id,))
339 + await self.db.audit("model.delete", actor=actor, target=model_id, detail={"bytes": size, "path": str(path)})
340 + await self.db.model_event(model_id, "deleted", {"bytes": size})
341 + return {"deleted": model_id, "bytes": size}
added server/llm_api/errors.py +122 −0
@@ -0,0 +1,122 @@
1 +"""OpenAI-style structured errors."""
2 +
3 +from __future__ import annotations
4 +
5 +from fastapi import Request
6 +from fastapi.responses import JSONResponse
7 +
8 +
9 +class APIError(Exception):
10 + status_code = 400
11 + error_type = "invalid_request_error"
12 + code = "INVALID_REQUEST"
13 +
14 + def __init__(self, message: str, *, status_code: int | None = None, error_type: str | None = None,
15 + code: str | None = None, param: str | None = None, extra: dict | None = None):
16 + super().__init__(message)
17 + self.message = message
18 + if status_code is not None:
19 + self.status_code = status_code
20 + if error_type is not None:
21 + self.error_type = error_type
22 + if code is not None:
23 + self.code = code
24 + self.param = param
25 + self.extra = extra or {}
26 +
27 + def to_dict(self) -> dict:
28 + err = {"message": self.message, "type": self.error_type, "code": self.code, "param": self.param}
29 + err.update(self.extra)
30 + return {"error": err}
31 +
32 +
33 +class ModelNotFound(APIError):
34 + status_code = 404
35 + error_type = "invalid_request_error"
36 + code = "MODEL_NOT_FOUND"
37 +
38 +
39 +class ModelTooLarge(APIError):
40 + status_code = 507
41 + error_type = "model_memory_error"
42 + code = "MODEL_TOO_LARGE"
43 +
44 +
45 +class ModelIncompatible(APIError):
46 + status_code = 422
47 + error_type = "model_compatibility_error"
48 + code = "MODEL_INCOMPATIBLE"
49 +
50 +
51 +class ModelLoadError(APIError):
52 + status_code = 503
53 + error_type = "model_load_error"
54 + code = "MODEL_LOAD_FAILED"
55 +
56 +
57 +class ModelLoadTimeout(ModelLoadError):
58 + code = "MODEL_LOAD_TIMEOUT"
59 +
60 +
61 +class WorkerCrashed(APIError):
62 + status_code = 503
63 + error_type = "runtime_error"
64 + code = "WORKER_CRASHED"
65 +
66 +
67 +class GenerationTimeout(APIError):
68 + status_code = 504
69 + error_type = "runtime_error"
70 + code = "GENERATION_TIMEOUT"
71 +
72 +
73 +class ContextTooLarge(APIError):
74 + status_code = 400
75 + error_type = "invalid_request_error"
76 + code = "CONTEXT_TOO_LARGE"
77 +
78 +
79 +class InsufficientDisk(APIError):
80 + status_code = 507
81 + error_type = "storage_error"
82 + code = "INSUFFICIENT_DISK"
83 +
84 +
85 +class DownloadError(APIError):
86 + status_code = 502
87 + error_type = "download_error"
88 + code = "DOWNLOAD_FAILED"
89 +
90 +
91 +class AuthError(APIError):
92 + status_code = 401
93 + error_type = "authentication_error"
94 + code = "INVALID_API_KEY"
95 +
96 +
97 +class Forbidden(APIError):
98 + status_code = 403
99 + error_type = "permission_error"
100 + code = "FORBIDDEN"
101 +
102 +
103 +class RateLimited(APIError):
104 + status_code = 429
105 + error_type = "rate_limit_error"
106 + code = "RATE_LIMITED"
107 +
108 +
109 +class Conflict(APIError):
110 + status_code = 409
111 + error_type = "invalid_request_error"
112 + code = "CONFLICT"
113 +
114 +
115 +class RuntimeUnsupported(APIError):
116 + status_code = 422
117 + error_type = "model_compatibility_error"
118 + code = "RUNTIME_UNSUPPORTED"
119 +
120 +
121 +async def api_error_handler(_: Request, exc: APIError) -> JSONResponse:
122 + return JSONResponse(status_code=exc.status_code, content=exc.to_dict())
added server/llm_api/events.py +48 −0
@@ -0,0 +1,48 @@
1 +"""In-process event bus for real-time dashboard updates (SSE)."""
2 +
3 +from __future__ import annotations
4 +
5 +import asyncio
6 +import json
7 +import time
8 +from collections import deque
9 +from typing import Any
10 +
11 +
12 +class EventBus:
13 + def __init__(self, history: int = 200):
14 + self._subs: set[asyncio.Queue] = set()
15 + self._history: deque[dict] = deque(maxlen=history)
16 + self._seq = 0
17 +
18 + def publish(self, kind: str, data: Any) -> None:
19 + self._seq += 1
20 + ev = {"seq": self._seq, "ts": time.time(), "type": kind, "data": data}
21 + self._history.append(ev)
22 + dead = []
23 + for q in self._subs:
24 + try:
25 + q.put_nowait(ev)
26 + except asyncio.QueueFull:
27 + dead.append(q)
28 + for q in dead:
29 + self._subs.discard(q)
30 +
31 + def subscribe(self) -> asyncio.Queue:
32 + q: asyncio.Queue = asyncio.Queue(maxsize=500)
33 + self._subs.add(q)
34 + return q
35 +
36 + def unsubscribe(self, q: asyncio.Queue) -> None:
37 + self._subs.discard(q)
38 +
39 + def recent(self, kinds: set[str] | None = None, limit: int = 50) -> list[dict]:
40 + evs = [e for e in self._history if not kinds or e["type"] in kinds]
41 + return evs[-limit:]
42 +
43 + @staticmethod
44 + def format_sse(ev: dict) -> str:
45 + return f"id: {ev['seq']}\nevent: {ev['type']}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n"
46 +
47 +
48 +bus = EventBus()
added server/llm_api/hardware.py +254 −0
@@ -0,0 +1,254 @@
1 +"""Apple Silicon hardware detection and telemetry (macOS, graceful degradation elsewhere)."""
2 +
3 +from __future__ import annotations
4 +
5 +import asyncio
6 +import platform
7 +import re
8 +import shutil
9 +import subprocess
10 +import time
11 +from dataclasses import asdict, dataclass, field
12 +from pathlib import Path
13 +
14 +import psutil
15 +
16 +GB = 1024**3
17 +
18 +
19 +def _run(cmd: list[str], timeout: float = 5.0) -> str:
20 + try:
21 + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).stdout
22 + except Exception:
23 + return ""
24 +
25 +
26 +def _sysctl(key: str) -> str:
27 + return _run(["sysctl", "-n", key]).strip()
28 +
29 +
30 +@dataclass
31 +class Hardware:
32 + chip: str
33 + memory_gb: float
34 + memory_bytes: int
35 + cpu_cores: int
36 + performance_cores: int | None
37 + efficiency_cores: int | None
38 + gpu_cores: int | None
39 + os: str
40 + os_version: str
41 + hostname: str
42 + apple_silicon: bool
43 + disk_total_gb: float
44 + python: str
45 +
46 + def to_dict(self) -> dict:
47 + return asdict(self)
48 +
49 +
50 +_HW_CACHE: Hardware | None = None
51 +
52 +
53 +def detect_hardware(models_dir: Path | None = None) -> Hardware:
54 + global _HW_CACHE
55 + if _HW_CACHE is not None:
56 + return _HW_CACHE
57 + is_mac = platform.system() == "Darwin"
58 + chip = _sysctl("machdep.cpu.brand_string") if is_mac else platform.processor() or "unknown"
59 + mem_bytes = int(_sysctl("hw.memsize") or psutil.virtual_memory().total) if is_mac else psutil.virtual_memory().total
60 + ncpu = int(_sysctl("hw.ncpu") or psutil.cpu_count() or 0) if is_mac else (psutil.cpu_count() or 0)
61 + perf = eff = None
62 + if is_mac:
63 + p0 = _sysctl("hw.perflevel0.physicalcpu")
64 + p1 = _sysctl("hw.perflevel1.physicalcpu")
65 + perf = int(p0) if p0.isdigit() else None
66 + eff = int(p1) if p1.isdigit() else None
67 + gpu_cores = None
68 + if is_mac:
69 + out = _run(["ioreg", "-r", "-c", "IOAccelerator", "-d", "1"])
70 + m = re.search(r'"gpu-core-count"\s*=\s*(\d+)', out)
71 + if m:
72 + gpu_cores = int(m.group(1))
73 + disk_path = models_dir if (models_dir and models_dir.exists()) else Path.home()
74 + du = shutil.disk_usage(disk_path)
75 + os_version = ""
76 + if is_mac:
77 + os_version = _run(["sw_vers", "-productVersion"]).strip()
78 + _HW_CACHE = Hardware(
79 + chip=chip or "unknown",
80 + memory_gb=round(mem_bytes / GB, 1),
81 + memory_bytes=mem_bytes,
82 + cpu_cores=ncpu,
83 + performance_cores=perf,
84 + efficiency_cores=eff,
85 + gpu_cores=gpu_cores,
86 + os="macOS" if is_mac else platform.system(),
87 + os_version=os_version or platform.release(),
88 + hostname=platform.node(),
89 + apple_silicon=is_mac and platform.machine() == "arm64",
90 + disk_total_gb=round(du.total / GB, 1),
91 + python=platform.python_version(),
92 + )
93 + return _HW_CACHE
94 +
95 +
96 +@dataclass
97 +class Telemetry:
98 + ts: float
99 + mem_total_gb: float
100 + mem_used_gb: float
101 + mem_available_gb: float
102 + mem_wired_gb: float | None
103 + mem_compressed_gb: float | None
104 + mem_pressure_percent: int | None # 0 = none … 100 = critical (derived from free %)
105 + mem_pressure_level: str # normal | warning | critical
106 + swap_used_gb: float
107 + swap_total_gb: float
108 + cpu_percent: float
109 + cpu_per_core: list[float] = field(default_factory=list)
110 + load_avg: list[float] = field(default_factory=list)
111 + gpu_percent: float | None = None
112 + gpu_renderer_percent: float | None = None
113 + gpu_memory_gb: float | None = None
114 + thermal_state: str = "nominal"
115 + thermal_cpu_speed_limit: int | None = None
116 + disk_total_gb: float = 0
117 + disk_used_gb: float = 0
118 + disk_free_gb: float = 0
119 + uptime_seconds: float = 0
120 + process_rss_gb: float = 0
121 +
122 + def to_dict(self) -> dict:
123 + return asdict(self)
124 +
125 +
126 +def _read_gpu() -> tuple[float | None, float | None, float | None]:
127 + out = _run(["ioreg", "-r", "-c", "IOAccelerator", "-d", "1"], timeout=3)
128 + if not out:
129 + return None, None, None
130 + dev = re.search(r'"Device Utilization %"=(\d+)', out)
131 + rend = re.search(r'"Renderer Utilization %"=(\d+)', out)
132 + mem = re.search(r'"In use system memory"=(\d+)', out)
133 + return (
134 + float(dev.group(1)) if dev else None,
135 + float(rend.group(1)) if rend else None,
136 + round(int(mem.group(1)) / GB, 2) if mem else None,
137 + )
138 +
139 +
140 +def _read_thermal() -> tuple[str, int | None]:
141 + out = _run(["pmset", "-g", "therm"], timeout=3)
142 + if not out:
143 + return "unknown", None
144 + m = re.search(r"CPU_Speed_Limit\s*=\s*(\d+)", out)
145 + limit = int(m.group(1)) if m else None
146 + if limit is not None and limit < 100:
147 + state = "throttled" if limit < 80 else "warm"
148 + elif "No thermal warning" in out or limit == 100:
149 + state = "nominal"
150 + else:
151 + state = "nominal"
152 + return state, limit
153 +
154 +
155 +def _read_vm_stat() -> dict[str, int]:
156 + out = _run(["vm_stat"], timeout=3)
157 + vals: dict[str, int] = {}
158 + if not out:
159 + return vals
160 + m = re.search(r"page size of (\d+) bytes", out)
161 + page = int(m.group(1)) if m else 16384
162 + for line in out.splitlines()[1:]:
163 + if ":" not in line:
164 + continue
165 + k, v = line.split(":", 1)
166 + v = v.strip().rstrip(".")
167 + if v.isdigit():
168 + vals[k.strip()] = int(v) * page
169 + vals["_page"] = page
170 + return vals
171 +
172 +
173 +def sample_telemetry(models_dir: Path | None = None) -> Telemetry:
174 + vm = psutil.virtual_memory()
175 + sw = psutil.swap_memory()
176 + is_mac = platform.system() == "Darwin"
177 + wired = compressed = None
178 + if is_mac:
179 + vs = _read_vm_stat()
180 + if vs:
181 + wired = round(vs.get("Pages wired down", 0) / GB, 2)
182 + compressed = round(vs.get("Pages occupied by compressor", 0) / GB, 2)
183 + gpu, gpu_r, gpu_mem = _read_gpu() if is_mac else (None, None, None)
184 + therm, limit = _read_thermal() if is_mac else ("unknown", None)
185 + disk_path = models_dir if (models_dir and models_dir.exists()) else Path.home()
186 + du = shutil.disk_usage(disk_path)
187 + avail_pct = vm.available / vm.total * 100
188 + if avail_pct > 20 and sw.used < 1 * GB:
189 + level = "normal"
190 + elif avail_pct > 8 and sw.used < 4 * GB:
191 + level = "warning"
192 + else:
193 + level = "critical"
194 + pressure = int(max(0.0, min(100.0, 100 - avail_pct)))
195 + proc = psutil.Process()
196 + try:
197 + load = list(psutil.getloadavg())
198 + except Exception:
199 + load = []
200 + return Telemetry(
201 + ts=time.time(),
202 + mem_total_gb=round(vm.total / GB, 2),
203 + mem_used_gb=round((vm.total - vm.available) / GB, 2),
204 + mem_available_gb=round(vm.available / GB, 2),
205 + mem_wired_gb=wired,
206 + mem_compressed_gb=compressed,
207 + mem_pressure_percent=pressure,
208 + mem_pressure_level=level,
209 + swap_used_gb=round(sw.used / GB, 2),
210 + swap_total_gb=round(sw.total / GB, 2),
211 + cpu_percent=psutil.cpu_percent(interval=None),
212 + cpu_per_core=psutil.cpu_percent(interval=None, percpu=True),
213 + load_avg=[round(x, 2) for x in load],
214 + gpu_percent=gpu,
215 + gpu_renderer_percent=gpu_r,
216 + gpu_memory_gb=gpu_mem,
217 + thermal_state=therm,
218 + thermal_cpu_speed_limit=limit,
219 + disk_total_gb=round(du.total / GB, 1),
220 + disk_used_gb=round(du.used / GB, 1),
221 + disk_free_gb=round(du.free / GB, 1),
222 + uptime_seconds=round(time.time() - psutil.boot_time(), 0),
223 + process_rss_gb=round(proc.memory_info().rss / GB, 3),
224 + )
225 +
226 +
227 +async def sample_telemetry_async(models_dir: Path | None = None) -> Telemetry:
228 + return await asyncio.to_thread(sample_telemetry, models_dir)
229 +
230 +
231 +def process_tree_rss_bytes(pid: int) -> int:
232 + """RSS of a process and its children (worker memory)."""
233 + try:
234 + p = psutil.Process(pid)
235 + total = p.memory_info().rss
236 + for c in p.children(recursive=True):
237 + try:
238 + total += c.memory_info().rss
239 + except psutil.Error:
240 + pass
241 + return total
242 + except psutil.Error:
243 + return 0
244 +
245 +
246 +def process_footprint_bytes(pid: int) -> int:
247 + """macOS 'physical footprint' (closest to Activity Monitor 'Memory'). Falls back to RSS."""
248 + if platform.system() == "Darwin":
249 + out = _run(["footprint", "-p", str(pid)], timeout=3) if shutil.which("footprint") else ""
250 + m = re.search(r"phys_footprint:\s*([\d.]+)\s*([KMG]B)", out)
251 + if m:
252 + mult = {"KB": 1024, "MB": 1024**2, "GB": 1024**3}[m.group(2)]
253 + return int(float(m.group(1)) * mult)
254 + return process_tree_rss_bytes(pid)
added server/llm_api/harvester.py +397 −0
@@ -0,0 +1,397 @@
1 +"""Model Harvester: explore Hugging Face for MLX/GGUF models that genuinely fit this machine,
2 +deduplicate them and propose a download queue. Never downloads blindly."""
3 +
4 +from __future__ import annotations
5 +
6 +import asyncio
7 +import json
8 +import logging
9 +import os
10 +import re
11 +import time
12 +from typing import Any
13 +
14 +from .config import Settings
15 +from .downloads import TRUSTED_GGUF_AUTHORS, pick_gguf_files
16 +from .jobs import Job, JobRunner
17 +from .models import compat, formats
18 +from .models.estimator import kv_bytes_per_token
19 +
20 +log = logging.getLogger("llm_api.harvester")
21 +GB = 1024**3
22 +
23 +DEFAULT_SOURCES = {
24 + "mlx": ["mlx-community"],
25 + "gguf": ["unsloth", "bartowski", "ggml-org", "lmstudio-community"],
26 +}
27 +
28 +TASK_HINTS = {
29 + "coding": re.compile(r"coder|code|devstral|codestral|starcoder|deepseek-coder|kat-coder", re.I),
30 + "reasoning": re.compile(r"thinking|reason|r1|qwq|deepseek-r|magistral|-think", re.I),
31 + "vision": re.compile(r"-vl|vision|pixtral|llava|gemma-?3|gemma-?4|qwen3\.[5-8]|paligemma|-omni|ocr", re.I),
32 + "embedding": re.compile(r"embed|e5-|bge|gte-|minilm|nomic", re.I),
33 + "reranker": re.compile(r"rerank", re.I),
34 + "multilingual": re.compile(r"multilingual|qwen|gemma|aya|mistral", re.I),
35 +}
36 +
37 +EXCLUDE = re.compile(r"uncensored|abliterated|heretic|nsfw|erotic|roleplay|-rp-|waifu|distill(ed)?-mlx-4bit-claude|"
38 + r"whisper|parakeet|tts|kokoro|orpheus|-asr|speech|audio|text-to-image|qwen-image|flux|stable-diffusion|"
39 + r"sd-|wan2|video|roformer|-base(-|$)|pretrain|draft|eagle|mtp-", re.I)
40 +
41 +
42 +def _base_key(repo: str, tags: list[str]) -> str:
43 + bm = next((t.split(":", 2)[2] for t in tags if t.startswith("base_model:quantized:")), None)
44 + if not bm:
45 + bm = next((t.split(":", 1)[1] for t in tags if t.startswith("base_model:") and "finetune:" not in t), None)
46 + if bm:
47 + return bm.lower()
48 + name = repo.split("/")[-1].lower()
49 + name = re.sub(r"-(\d+bit|q\d[_a-z0-9]*|iq\d[_a-z0-9]*|mxfp\d|nvfp4|bf16|fp16|f16|fp8|dwq|optiq|gguf|mlx|4bit|8bit)+$", "", name)
50 + name = re.sub(r"-(gguf|mlx)$", "", name)
51 + return name
52 +
53 +
54 +class Harvester:
55 + def __init__(self, settings: Settings, db, registry, jobs: JobRunner, downloader):
56 + self.settings = settings
57 + self.db = db
58 + self.registry = registry
59 + self.jobs = jobs
60 + self.downloader = downloader
61 +
62 + def _api(self):
63 + from huggingface_hub import HfApi
64 + return HfApi(token=self.settings.hf_token or os.environ.get("HF_TOKEN") or None)
65 +
66 + async def start_scan(self, options: dict, actor: str | None = None) -> Job:
67 + payload = {
68 + "runtimes": options.get("runtimes") or ["mlx", "gguf"],
69 + "authors": options.get("authors") or None,
70 + "limit_per_author": int(options.get("limit_per_author") or 150),
71 + "min_downloads": int(options.get("min_downloads") or 500),
72 + "max_ram_gb": float(options.get("max_ram_gb") or 0) or None,
73 + "families": options.get("families") or None,
74 + "tasks": options.get("tasks") or None,
75 + "search": options.get("search") or None,
76 + }
77 + await self.db.audit("harvest.scan", actor=actor, detail=payload)
78 +
79 + async def run(job: Job):
80 + return await self._scan(job, payload)
81 +
82 + return self.jobs.submit("harvest", "Harvest Hugging Face", payload, run)
83 +
84 + async def _scan(self, job: Job, opt: dict) -> dict:
85 + api = self._api()
86 + budget, absolute = await self.registry.budgets()
87 + max_ram = opt["max_ram_gb"] or budget
88 + installed = {r["repository"] for r in await self.db.fetchall("SELECT repository FROM models WHERE installed=1 AND repository IS NOT NULL")}
89 + installed_keys = set()
90 + for r in await self.db.fetchall("SELECT name, tags FROM models WHERE installed=1"):
91 + installed_keys.add(_base_key(r["name"], json.loads(r["tags"] or "[]")))
92 + sources: list[tuple[str, str]] = []
93 + for rt in opt["runtimes"]:
94 + authors = opt["authors"] or DEFAULT_SOURCES.get(rt, [])
95 + for a in authors:
96 + sources.append((rt, a))
97 + candidates: list[dict] = []
98 + seen: set[str] = set()
99 + total_sources = max(1, len(sources))
100 + for si, (rt, author) in enumerate(sources):
101 + if job.cancelled:
102 + break
103 + self.jobs.update(job, progress=0.05 + 0.6 * si / total_sources, stage=f"listing {author} ({rt})")
104 + kwargs: dict[str, Any] = {"author": author, "sort": "downloads", "limit": opt["limit_per_author"],
105 + "expand": ["downloads", "likes", "tags", "pipeline_tag", "lastModified", "config", "safetensors", "gated"]}
106 + if rt == "gguf":
107 + kwargs["filter"] = "gguf"
108 + if opt["search"]:
109 + kwargs["search"] = opt["search"]
110 + try:
111 + infos = await asyncio.to_thread(lambda: list(api.list_models(**kwargs)))
112 + except Exception as e:
113 + log.warning("list_models %s failed: %s", author, e)
114 + self.jobs.update(job, warning=f"{author}: {str(e)[:120]}")
115 + continue
116 + for info in infos:
117 + rid = info.id
118 + if rid in seen:
119 + continue
120 + seen.add(rid)
121 + name = rid.split("/")[-1]
122 + if EXCLUDE.search(name):
123 + continue
124 + tags = list(info.tags or [])
125 + pipeline = getattr(info, "pipeline_tag", None)
126 + if pipeline in ("automatic-speech-recognition", "text-to-speech", "text-to-image", "text-to-video", "audio-to-audio"):
127 + continue
128 + downloads = getattr(info, "downloads", 0) or 0
129 + if downloads < opt["min_downloads"]:
130 + continue
131 + if getattr(info, "gated", False):
132 + continue
133 + candidates.append({"repo": rid, "runtime": "llamacpp" if rt == "gguf" else "mlx", "tags": tags, "pipeline": pipeline,
134 + "downloads": downloads, "likes": getattr(info, "likes", 0) or 0,
135 + "last_modified": str(getattr(info, "last_modified", "") or ""),
136 + "config": getattr(info, "config", None) or {},
137 + "safetensors": getattr(info, "safetensors", None)})
138 + # family/task filters
139 + fams = set(f.lower() for f in (opt["families"] or []))
140 + tasks = set(t.lower() for t in (opt["tasks"] or []))
141 + rows: list[dict] = []
142 + n = len(candidates)
143 + for i, c in enumerate(candidates):
144 + if job.cancelled:
145 + break
146 + if i % 10 == 0:
147 + self.jobs.update(job, progress=0.65 + 0.3 * i / max(1, n), stage=f"evaluating {i}/{n}")
148 + name = c["repo"].split("/")[-1]
149 + family = formats.guess_family(name, (c["config"] or {}).get("model_type"))
150 + if fams and family not in fams:
151 + continue
152 + task_flags = {k: bool(p.search(name)) for k, p in TASK_HINTS.items()}
153 + if c["pipeline"] in ("feature-extraction", "sentence-similarity"):
154 + task_flags["embedding"] = True
155 + if c["pipeline"] == "text-ranking":
156 + task_flags["reranker"] = True
157 + if c["pipeline"] == "image-text-to-text":
158 + task_flags["vision"] = True
159 + task = ("reranker" if task_flags["reranker"] else "embedding" if task_flags["embedding"] else
160 + "vision" if task_flags["vision"] else "coding" if task_flags["coding"] else
161 + "reasoning" if task_flags["reasoning"] else "general")
162 + if tasks and task not in tasks and not (task_flags.get(next(iter(tasks), ""), False)):
163 + continue
164 + row = await self._evaluate(c, name, family, task, task_flags, budget, absolute, max_ram)
165 + if not row:
166 + continue
167 + row["installed"] = int(c["repo"] in installed or row["base_model"] in installed_keys)
168 + rows.append(row)
169 + # dedupe: same base model + runtime -> keep the best quantization for the budget
170 + rows.sort(key=lambda r: (-r["score"]))
171 + best_by_key: dict[str, dict] = {}
172 + for r in rows:
173 + key = f"{r['runtime']}::{r['base_model']}"
174 + if key in best_by_key:
175 + r["duplicate_of"] = best_by_key[key]["repo_id"]
176 + else:
177 + best_by_key[key] = r
178 + now = time.time()
179 + await self.db.execute("DELETE FROM harvest_candidates WHERE selected=0 AND dismissed=0")
180 + for r in rows:
181 + await self.db.execute(
182 + "INSERT INTO harvest_candidates(repo_id, runtime, family, base_model, name, task, quantization, parameter_count, "
183 + "download_bytes, estimated_ram_gb, size_class, compatibility_status, compatibility_reason, downloads, likes, last_modified, "
184 + "files, duplicate_of, installed, score, scanned_at, raw) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
185 + "ON CONFLICT(repo_id) DO UPDATE SET runtime=excluded.runtime, family=excluded.family, base_model=excluded.base_model, "
186 + "name=excluded.name, task=excluded.task, quantization=excluded.quantization, parameter_count=excluded.parameter_count, "
187 + "download_bytes=excluded.download_bytes, estimated_ram_gb=excluded.estimated_ram_gb, size_class=excluded.size_class, "
188 + "compatibility_status=excluded.compatibility_status, compatibility_reason=excluded.compatibility_reason, downloads=excluded.downloads, "
189 + "likes=excluded.likes, last_modified=excluded.last_modified, files=excluded.files, duplicate_of=excluded.duplicate_of, "
190 + "installed=excluded.installed, score=excluded.score, scanned_at=excluded.scanned_at, raw=excluded.raw",
191 + (r["repo_id"], r["runtime"], r["family"], r["base_model"], r["name"], r["task"], r["quantization"], r["parameter_count"],
192 + r["download_bytes"], r["estimated_ram_gb"], r["size_class"], r["compatibility_status"], r["compatibility_reason"],
193 + r["downloads"], r["likes"], r["last_modified"], json.dumps(r["files"]), r.get("duplicate_of"), r["installed"],
194 + r["score"], now, json.dumps({"tags": r["tags"][:30], "task_flags": r["task_flags"]})))
195 + summary = {"sources": len(sources), "listed": len(candidates), "candidates": len(rows),
196 + "unique": len(best_by_key), "compatible": sum(1 for r in rows if r["compatibility_status"] in (compat.COMPATIBLE, compat.RESTRICTED)),
197 + "at": now}
198 + return summary
199 +
200 + async def _evaluate(self, c: dict, name: str, family: str, task: str, flags: dict, budget: float, absolute: float,
201 + max_ram: float) -> dict | None:
202 + cfg = formats.parse_hf_config(c["config"]) if c["config"] else {}
203 + quant, bits = None, None
204 + download_bytes = 0
205 + weights_bytes = 0
206 + files: list[dict] = []
207 + if c["runtime"] == "mlx":
208 + quant, bits = cfg.get("quantization"), cfg.get("quant_bits")
209 + if not quant:
210 + quant, bits = formats.parse_quant_from_name(name)
211 + st = c.get("safetensors")
212 + total_params = None
213 + if st is not None:
214 + total_params = getattr(st, "total", None) or (st.get("total") if isinstance(st, dict) else None)
215 + params_by_dtype = getattr(st, "parameters", None) or (st.get("parameters") if isinstance(st, dict) else None)
216 + if params_by_dtype and isinstance(params_by_dtype, dict):
217 + # packed uint32 quantized weights: count * 32 / bits
218 + for dt, cnt in params_by_dtype.items():
219 + if dt in ("U32", "I32") and bits:
220 + weights_bytes += int(cnt * 4)
221 + elif dt in ("F16", "BF16"):
222 + weights_bytes += int(cnt * 2)
223 + elif dt in ("F32",):
224 + weights_bytes += int(cnt * 4)
225 + elif dt in ("U8", "I8", "F8_E4M3", "F8_E5M2"):
226 + weights_bytes += int(cnt)
227 + else:
228 + weights_bytes += int(cnt * 2)
229 + pc, ac = formats.parse_param_count_from_name(name)
230 + if bits and pc and not weights_bytes:
231 + weights_bytes = int(pc * bits / 8 * 1.06)
232 + if not bits and not quant:
233 + quant, bits = "bf16", 16
234 + if pc and not weights_bytes:
235 + weights_bytes = pc * 2
236 + if bits and total_params and bits < 16 and weights_bytes:
237 + # 'total' counts packed uint32 elements as 1 param; approximate real param count
238 + pass
239 + param_count = pc or (int(weights_bytes * 8 / bits) if bits and weights_bytes else None)
240 + download_bytes = int(weights_bytes * 1.02)
241 + if not weights_bytes:
242 + return None
243 + else:
244 + # GGUF: need the file list; use the lightweight repo tree (sizes) — one API call per repo
245 + try:
246 + api = self._api()
247 + tree = await asyncio.to_thread(lambda: list(api.list_repo_tree(c["repo"], recursive=False)))
248 + except Exception:
249 + return None
250 + files_all = [{"path": getattr(f, "path", ""), "size": getattr(f, "size", 0) or 0} for f in tree]
251 + sel = pick_gguf_files(files_all)
252 + if not sel:
253 + return None
254 + files = sel
255 + weights_bytes = sum(f["size"] for f in sel if "mmproj" not in f["path"].lower())
256 + download_bytes = sum(f["size"] for f in sel)
257 + quant, bits = formats.parse_quant_from_name(sel[0]["path"].rsplit(".", 1)[0])
258 + pc, ac = formats.parse_param_count_from_name(name)
259 + param_count = pc or (int(weights_bytes * 8 / bits) if bits else None)
260 + kv = kv_bytes_per_token(cfg.get("n_layers"), cfg.get("n_kv_heads"), cfg.get("head_dim"), 16,
261 + cfg.get("full_attention_layers"), cfg.get("sliding_window"))
262 + if not kv and param_count:
263 + kv = int(130_000 * (param_count / 8e9) ** 0.6)
264 + vision = flags.get("vision", False) or bool(cfg.get("vision"))
265 + embedding = flags.get("embedding", False)
266 + reranker = flags.get("reranker", False)
267 + from .models.scanner import llamacpp_available
268 + comp = compat.evaluate(runtime=c["runtime"], weights_bytes=weights_bytes, kv_per_token=kv, max_context=cfg.get("max_context") or 32768,
269 + model_type=cfg.get("model_type") or ("llama" if c["runtime"] == "llamacpp" else None),
270 + architecture=(cfg.get("architectures") or [None])[0], vision=vision, embedding=embedding,
271 + reranker=reranker, budget_gb=min(budget, max_ram), absolute_gb=absolute,
272 + llamacpp_available=llamacpp_available(self.settings.llama_server_bin), quant_bits=bits,
273 + weights_file=files[0]["path"] if files else None)
274 + if c["runtime"] == "mlx" and comp.status == compat.INCOMPATIBLE and "Architecture" in comp.reason and not cfg.get("model_type"):
275 + comp = compat.Compatibility(compat.EXPERIMENTAL, "Architecture unknown (no config in listing); verify before download.",
276 + True, comp.estimated_ram_gb, comp.recommended_context, comp.estimate)
277 + if comp.status == compat.INCOMPATIBLE or comp.status == compat.NOT_RECOMMENDED:
278 + return None
279 + # quantization quality policy
280 + pol = self._quant_policy(param_count, bits)
281 + score = self._score(c, comp, bits, param_count, pol, task)
282 + return {
283 + "repo_id": c["repo"], "runtime": c["runtime"], "family": family, "base_model": _base_key(c["repo"], c["tags"]),
284 + "name": name, "task": task, "quantization": quant, "parameter_count": param_count, "download_bytes": download_bytes,
285 + "estimated_ram_gb": comp.estimated_ram_gb, "size_class": formats.size_class(comp.estimated_ram_gb, budget),
286 + "compatibility_status": comp.status, "compatibility_reason": comp.reason, "downloads": c["downloads"],
287 + "likes": c["likes"], "last_modified": c["last_modified"], "files": files, "score": score, "tags": c["tags"],
288 + "task_flags": flags, "quant_policy": pol,
289 + }
290 +
291 + @staticmethod
292 + def _quant_policy(params: int | None, bits: float | None) -> str:
293 + """ok | low | too_low relative to the size-based preference table."""
294 + if not params or not bits:
295 + return "unknown"
296 + b = params / 1e9
297 + if b <= 8:
298 + return "ok" if bits >= 6 else "low" if bits >= 4 else "too_low"
299 + if b <= 20:
300 + return "ok" if bits >= 5 else "low" if bits >= 4 else "too_low"
301 + if b <= 40:
302 + return "ok" if bits >= 4 else "low" if bits >= 3 else "too_low"
303 + if b <= 80:
304 + return "ok" if bits >= 4 else "low" if bits >= 3 else "too_low"
305 + return "ok" if bits >= 3 else "too_low"
306 +
307 + @staticmethod
308 + def _score(c: dict, comp, bits, params, pol: str, task: str) -> float:
309 + import math
310 + s = math.log10(max(10, c["downloads"])) * 10
311 + s += math.log10(max(1, c["likes"])) * 3
312 + s += {"ok": 15, "low": 5, "too_low": -20, "unknown": 0}[pol]
313 + s += {compat.COMPATIBLE: 10, compat.RESTRICTED: 4, compat.EXPERIMENTAL: -5}.get(comp.status, 0)
314 + if params:
315 + b = params / 1e9
316 + s += min(15, b / 2) # bigger is (usually) better, capped
317 + if c["last_modified"] and c["last_modified"][:4].isdigit():
318 + year = int(c["last_modified"][:4])
319 + s += (year - 2024) * 4
320 + return round(s, 2)
321 +
322 + # ------------------------------------------------------------------ queue
323 + async def candidates(self, *, include_duplicates: bool = False, task: str | None = None, runtime: str | None = None,
324 + family: str | None = None, size_class: str | None = None, q: str | None = None, limit: int = 300) -> list[dict]:
325 + sql = "SELECT * FROM harvest_candidates WHERE dismissed=0"
326 + params: list[Any] = []
327 + if not include_duplicates:
328 + sql += " AND duplicate_of IS NULL"
329 + if task:
330 + sql += " AND task=?"
331 + params.append(task)
332 + if runtime:
333 + sql += " AND runtime=?"
334 + params.append(runtime)
335 + if family:
336 + sql += " AND family=?"
337 + params.append(family)
338 + if size_class:
339 + sql += " AND size_class=?"
340 + params.append(size_class)
341 + if q:
342 + sql += " AND (repo_id LIKE ? OR base_model LIKE ?)"
343 + params += [f"%{q}%", f"%{q}%"]
344 + sql += " ORDER BY selected DESC, score DESC LIMIT ?"
345 + params.append(limit)
346 + rows = await self.db.fetchall(sql, params)
347 + for r in rows:
348 + for k in ("files", "raw"):
349 + if r.get(k):
350 + try:
351 + r[k] = json.loads(r[k])
352 + except Exception:
353 + pass
354 + return rows
355 +
356 + async def select(self, repo_id: str, selected: bool) -> None:
357 + await self.db.execute("UPDATE harvest_candidates SET selected=? WHERE repo_id=?", (int(selected), repo_id))
358 +
359 + async def dismiss(self, repo_id: str) -> None:
360 + await self.db.execute("UPDATE harvest_candidates SET dismissed=1, selected=0 WHERE repo_id=?", (repo_id,))
361 +
362 + async def queue_selected(self, actor: str | None = None) -> list[dict]:
363 + rows = await self.db.fetchall("SELECT * FROM harvest_candidates WHERE selected=1 AND installed=0 ORDER BY score DESC")
364 + out = []
365 + for r in rows:
366 + try:
367 + job = await self.downloader.start_download(r["repo_id"], r["quantization"] if r["runtime"] == "llamacpp" else None, actor=actor)
368 + out.append({"repo": r["repo_id"], "job": job.id})
369 + await self.db.execute("UPDATE harvest_candidates SET selected=0 WHERE repo_id=?", (r["repo_id"],))
370 + except Exception as e:
371 + out.append({"repo": r["repo_id"], "error": str(e)})
372 + return out
373 +
374 + async def suggest_starter(self) -> list[dict]:
375 + """A curated slot list (small/medium/large general, coding, reasoning, vision, embedding, reranker)
376 + filled from the latest harvest, best score first."""
377 + rows = await self.candidates(limit=1000)
378 + slots = {
379 + "small general": lambda r: r["task"] == "general" and (r["estimated_ram_gb"] or 0) < 8,
380 + "small coding": lambda r: r["task"] == "coding" and (r["estimated_ram_gb"] or 0) < 12,
381 + "small reasoning": lambda r: r["task"] == "reasoning" and (r["estimated_ram_gb"] or 0) < 12,
382 + "medium general": lambda r: r["task"] == "general" and 8 <= (r["estimated_ram_gb"] or 0) < 22,
383 + "medium coding": lambda r: r["task"] == "coding" and 12 <= (r["estimated_ram_gb"] or 0) < 25,
384 + "large general": lambda r: r["task"] == "general" and 22 <= (r["estimated_ram_gb"] or 0) <= 45,
385 + "large reasoning": lambda r: r["task"] == "reasoning" and 12 <= (r["estimated_ram_gb"] or 0) <= 45,
386 + "vision": lambda r: r["task"] == "vision",
387 + "embedding": lambda r: r["task"] == "embedding",
388 + "reranker": lambda r: r["task"] == "reranker",
389 + }
390 + out = []
391 + used = set()
392 + for slot, pred in slots.items():
393 + pick = next((r for r in rows if pred(r) and r["repo_id"] not in used and r["compatibility_status"] in (compat.COMPATIBLE, compat.RESTRICTED)), None)
394 + if pick:
395 + used.add(pick["repo_id"])
396 + out.append({"slot": slot, "candidate": pick})
397 + return out
added server/llm_api/jobs.py +162 −0
@@ -0,0 +1,162 @@
1 +"""Small asyncio job system (downloads, scans, benchmarks, harvests)."""
2 +
3 +from __future__ import annotations
4 +
5 +import asyncio
6 +import json
7 +import logging
8 +import time
9 +import uuid
10 +from typing import Any, Awaitable, Callable
11 +
12 +from .db import Database
13 +from .events import bus
14 +
15 +log = logging.getLogger("llm_api.jobs")
16 +
17 +QUEUED, RUNNING, COMPLETED, FAILED, CANCELLED = "queued", "running", "completed", "failed", "cancelled"
18 +
19 +
20 +class Job:
21 + def __init__(self, id: str, kind: str, title: str, payload: dict):
22 + self.id = id
23 + self.kind = kind
24 + self.title = title
25 + self.payload = payload
26 + self.status = QUEUED
27 + self.progress = 0.0
28 + self.detail: dict[str, Any] = {}
29 + self.result: Any = None
30 + self.error: str | None = None
31 + self.created_at = time.time()
32 + self.started_at: float | None = None
33 + self.finished_at: float | None = None
34 + self.cancel_event = asyncio.Event()
35 + self.task: asyncio.Task | None = None
36 + self._last_flush = 0.0
37 +
38 + def to_dict(self) -> dict:
39 + return {
40 + "id": self.id, "kind": self.kind, "title": self.title, "payload": self.payload, "status": self.status,
41 + "progress": round(self.progress, 4), "detail": self.detail, "result": self.result, "error": self.error,
42 + "created_at": self.created_at, "started_at": self.started_at, "finished_at": self.finished_at,
43 + }
44 +
45 + @property
46 + def cancelled(self) -> bool:
47 + return self.cancel_event.is_set()
48 +
49 +
50 +class JobRunner:
51 + def __init__(self, db: Database, max_concurrent: int = 3):
52 + self.db = db
53 + self.jobs: dict[str, Job] = {}
54 + self.sem = asyncio.Semaphore(max_concurrent)
55 + self._download_lock = asyncio.Semaphore(1) # downloads are serialized (SSD + bandwidth)
56 +
57 + async def start(self) -> None:
58 + # Mark jobs left running by a crashed server as failed
59 + await self.db.execute("UPDATE jobs SET status=?, error=?, finished_at=? WHERE status IN (?, ?)",
60 + (FAILED, "server restarted", time.time(), QUEUED, RUNNING))
61 +
62 + def submit(self, kind: str, title: str, payload: dict,
63 + fn: Callable[[Job], Awaitable[Any]], *, exclusive_download: bool = False) -> Job:
64 + job = Job(uuid.uuid4().hex[:12], kind, title, payload)
65 + self.jobs[job.id] = job
66 + asyncio.create_task(self._persist(job))
67 +
68 + async def _run():
69 + async with self.sem:
70 + if exclusive_download:
71 + async with self._download_lock:
72 + await self._execute(job, fn)
73 + else:
74 + await self._execute(job, fn)
75 +
76 + job.task = asyncio.create_task(_run(), name=f"job-{kind}-{job.id}")
77 + bus.publish("job", job.to_dict())
78 + return job
79 +
80 + async def _execute(self, job: Job, fn) -> None:
81 + if job.cancelled:
82 + job.status = CANCELLED
83 + job.finished_at = time.time()
84 + await self._persist(job)
85 + return
86 + job.status = RUNNING
87 + job.started_at = time.time()
88 + await self._persist(job)
89 + bus.publish("job", job.to_dict())
90 + try:
91 + job.result = await fn(job)
92 + job.status = CANCELLED if job.cancelled else COMPLETED
93 + job.progress = 1.0 if job.status == COMPLETED else job.progress
94 + except asyncio.CancelledError:
95 + job.status = CANCELLED
96 + except Exception as e:
97 + log.exception("job %s failed", job.id)
98 + job.status = FAILED
99 + job.error = f"{type(e).__name__}: {e}"
100 + job.finished_at = time.time()
101 + await self._persist(job)
102 + bus.publish("job", job.to_dict())
103 +
104 + def update(self, job: Job, progress: float | None = None, **detail: Any) -> None:
105 + if progress is not None:
106 + job.progress = max(0.0, min(1.0, progress))
107 + if detail:
108 + job.detail.update(detail)
109 + now = time.time()
110 + if now - job._last_flush > 0.5:
111 + job._last_flush = now
112 + bus.publish("job", job.to_dict())
113 + asyncio.create_task(self._persist(job))
114 +
115 + async def _persist(self, job: Job) -> None:
116 + try:
117 + await self.db.execute(
118 + "INSERT INTO jobs(id, kind, status, title, payload, progress, detail, result, error, created_at, started_at, finished_at) "
119 + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status, progress=excluded.progress, "
120 + "detail=excluded.detail, result=excluded.result, error=excluded.error, started_at=excluded.started_at, finished_at=excluded.finished_at",
121 + (job.id, job.kind, job.status, job.title, json.dumps(job.payload), job.progress, json.dumps(job.detail),
122 + json.dumps(job.result, default=str) if job.result is not None else None, job.error, job.created_at,
123 + job.started_at, job.finished_at))
124 + except Exception:
125 + log.exception("persist job failed")
126 +
127 + async def cancel(self, job_id: str) -> bool:
128 + job = self.jobs.get(job_id)
129 + if not job:
130 + return False
131 + if job.status in (COMPLETED, FAILED, CANCELLED):
132 + return False
133 + job.cancel_event.set()
134 + if job.status == QUEUED and job.task:
135 + job.task.cancel()
136 + return True
137 +
138 + def list(self, kinds: set[str] | None = None, limit: int = 100) -> list[dict]:
139 + items = sorted(self.jobs.values(), key=lambda j: -j.created_at)
140 + if kinds:
141 + items = [j for j in items if j.kind in kinds]
142 + return [j.to_dict() for j in items[:limit]]
143 +
144 + async def history(self, kinds: set[str] | None = None, limit: int = 100) -> list[dict]:
145 + rows = await self.db.fetchall("SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?", (limit,))
146 + out = []
147 + for r in rows:
148 + if kinds and r["kind"] not in kinds:
149 + continue
150 + for k in ("payload", "detail", "result"):
151 + if r.get(k):
152 + try:
153 + r[k] = json.loads(r[k])
154 + except Exception:
155 + pass
156 + # prefer live state
157 + live = self.jobs.get(r["id"])
158 + out.append(live.to_dict() if live else r)
159 + return out
160 +
161 + def get(self, job_id: str) -> Job | None:
162 + return self.jobs.get(job_id)
added server/llm_api/main.py +166 −0
@@ -0,0 +1,166 @@
1 +"""LLM API application: startup sequence, routers, shutdown."""
2 +
3 +from __future__ import annotations
4 +
5 +import logging
6 +import logging.handlers
7 +import os
8 +import sys
9 +import time
10 +from contextlib import asynccontextmanager
11 +
12 +import httpx
13 +from fastapi import FastAPI, Request
14 +from fastapi.exceptions import RequestValidationError
15 +from fastapi.responses import JSONResponse
16 +
17 +from . import __version__
18 +from .api import admin_routes, openai_routes
19 +from .api.admin_routes import public_health
20 +from .auth import Auth
21 +from .config import Settings, get_settings
22 +from .db import Database
23 +from .downloads import Downloader
24 +from .errors import APIError, api_error_handler
25 +from .events import bus
26 +from .hardware import detect_hardware
27 +from .harvester import Harvester
28 +from .jobs import JobRunner
29 +from .manager import ModelManager
30 +from .metrics import MetricsCollector
31 +from .models.registry import Registry
32 +from .proxy_ui import router as ui_router
33 +
34 +log = logging.getLogger("llm_api")
35 +
36 +
37 +def setup_logging(settings: Settings) -> None:
38 + settings.logs_path.mkdir(parents=True, exist_ok=True)
39 + fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
40 + root = logging.getLogger()
41 + root.setLevel(logging.INFO)
42 + if not any(isinstance(h, logging.StreamHandler) for h in root.handlers):
43 + sh = logging.StreamHandler(sys.stdout)
44 + sh.setFormatter(fmt)
45 + root.addHandler(sh)
46 + fh = logging.handlers.RotatingFileHandler(settings.logs_path / "llm-api.log", maxBytes=20_000_000, backupCount=5)
47 + fh.setFormatter(fmt)
48 + root.addHandler(fh)
49 + logging.getLogger("httpx").setLevel(logging.WARNING)
50 + logging.getLogger("httpcore").setLevel(logging.WARNING)
51 + logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
52 +
53 +
54 +@asynccontextmanager
55 +async def lifespan(app: FastAPI):
56 + settings: Settings = app.state.settings
57 + t0 = time.time()
58 + # 1. config + dirs
59 + settings.ensure_dirs()
60 + # 2. database
61 + db = Database(settings.db_path)
62 + await db.connect()
63 + app.state.db = db
64 + # 3. hardware
65 + hw = detect_hardware(settings.models_dir)
66 + log.info("hardware: %s, %.0f GB, %s CPU cores, %s GPU cores, %s %s", hw.chip, hw.memory_gb, hw.cpu_cores, hw.gpu_cores, hw.os, hw.os_version)
67 + if settings.max_model_memory_gb > hw.memory_gb - settings.macos_reserve_gb:
68 + log.warning("MAX_MODEL_MEMORY_GB=%s is above memory minus the macOS reserve (%.0f GB)", settings.max_model_memory_gb,
69 + hw.memory_gb - settings.macos_reserve_gb)
70 + # 4. auth
71 + auth = Auth(db, settings)
72 + app.state.auth = auth
73 + if await auth.user_count() == 0 and settings.admin_email and settings.admin_password:
74 + try:
75 + await auth.create_user(settings.admin_email, settings.admin_password)
76 + log.info("seeded admin user %s", settings.admin_email)
77 + except Exception as e:
78 + log.warning("could not seed admin user: %s", e)
79 + # 5. registry + manager (clears stale state)
80 + registry = Registry(db, settings)
81 + manager = ModelManager(settings, db, registry)
82 + app.state.manager = manager
83 + app.state.registry = registry
84 + jobs = JobRunner(db)
85 + await jobs.start()
86 + app.state.jobs = jobs
87 + downloader = Downloader(settings, db, registry, jobs)
88 + app.state.downloader = downloader
89 + app.state.harvester = Harvester(settings, db, registry, jobs, downloader)
90 + metrics = MetricsCollector(settings, db, manager)
91 + app.state.metrics = metrics
92 + app.state.ui_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0, read=120.0), follow_redirects=False)
93 + # 6. scan registry
94 + try:
95 + summary = await registry.rescan()
96 + log.info("registry: %s", summary)
97 + except Exception:
98 + log.exception("initial rescan failed")
99 + # 7. start manager loops (kills stale workers, preload)
100 + await manager.start()
101 + await metrics.start()
102 + await db.audit("server.start", actor="system", detail={"version": __version__, "seconds": round(time.time() - t0, 2)})
103 + log.info("LLM API %s ready on %s:%s (%.1fs)", __version__, settings.host, settings.port, time.time() - t0)
104 + bus.publish("server", {"event": "started", "version": __version__})
105 + try:
106 + yield
107 + finally:
108 + log.info("shutting down: draining requests, unloading models")
109 + await metrics.stop()
110 + await manager.stop()
111 + await app.state.ui_client.aclose()
112 + await db.audit("server.stop", actor="system")
113 + await db.close()
114 +
115 +
116 +def create_app(settings: Settings | None = None) -> FastAPI:
117 + settings = settings or get_settings()
118 + setup_logging(settings)
119 + app = FastAPI(title="LLM API", version=__version__, docs_url="/openapi", redoc_url=None, openapi_url="/openapi.json",
120 + lifespan=lifespan)
121 + app.state.settings = settings
122 + app.add_exception_handler(APIError, api_error_handler) # type: ignore[arg-type]
123 +
124 + @app.exception_handler(RequestValidationError)
125 + async def _validation(_: Request, exc: RequestValidationError):
126 + errs = exc.errors()
127 + msg = "; ".join(f"{'.'.join(str(x) for x in e.get('loc', []))}: {e.get('msg')}" for e in errs[:3])
128 + return JSONResponse(status_code=400, content={"error": {"message": msg or "invalid request", "type": "invalid_request_error",
129 + "code": "INVALID_REQUEST", "param": None}})
130 +
131 + @app.exception_handler(Exception)
132 + async def _unhandled(_: Request, exc: Exception):
133 + log.exception("unhandled error: %s", exc)
134 + return JSONResponse(status_code=500, content={"error": {"message": "Internal server error.", "type": "server_error",
135 + "code": "INTERNAL", "param": None}})
136 +
137 + @app.middleware("http")
138 + async def _security_headers(request: Request, call_next):
139 + # Body size guard for JSON APIs
140 + cl = request.headers.get("content-length")
141 + if cl and cl.isdigit() and int(cl) > settings.max_body_bytes and request.url.path.startswith(("/v1", "/api")):
142 + return JSONResponse(status_code=413, content={"error": {"message": "Request body too large.", "type": "invalid_request_error",
143 + "code": "BODY_TOO_LARGE", "param": None}})
144 + resp = await call_next(request)
145 + resp.headers.setdefault("X-Content-Type-Options", "nosniff")
146 + resp.headers.setdefault("Referrer-Policy", "same-origin")
147 + resp.headers.setdefault("X-Frame-Options", "DENY")
148 + if request.url.path.startswith(("/v1", "/api")):
149 + resp.headers.setdefault("Cache-Control", "no-store")
150 + return resp
151 +
152 + @app.get("/health", include_in_schema=False)
153 + async def health(request: Request):
154 + return await public_health(request)
155 +
156 + @app.get("/api/status", include_in_schema=False)
157 + async def status(request: Request):
158 + return await public_health(request)
159 +
160 + app.include_router(openai_routes.router)
161 + app.include_router(admin_routes.router)
162 + app.include_router(ui_router) # must be last (catch-all)
163 + return app
164 +
165 +
166 +app = create_app()
added server/llm_api/manager.py +506 −0
@@ -0,0 +1,506 @@
1 +"""Model Manager: load/unload/evict local inference workers with a strict memory policy."""
2 +
3 +from __future__ import annotations
4 +
5 +import asyncio
6 +import contextlib
7 +import json
8 +import logging
9 +import socket
10 +import time
11 +from dataclasses import dataclass, field
12 +from pathlib import Path
13 +from typing import Any
14 +
15 +import httpx
16 +import psutil
17 +
18 +from .config import Settings
19 +from .db import Database
20 +from .errors import (Conflict, ModelIncompatible, ModelLoadError, ModelLoadTimeout, ModelNotFound, ModelTooLarge,
21 + RuntimeUnsupported, WorkerCrashed)
22 +from .events import bus
23 +from .hardware import GB, sample_telemetry_async
24 +from .models.registry import Registry
25 +from .runtimes import LlamaCppAdapter, MLXAdapter, RuntimeAdapter, WorkerHandle
26 +
27 +log = logging.getLogger("llm_api.manager")
28 +
29 +STATUS_UNLOADED = "unloaded"
30 +STATUS_QUEUED = "queued"
31 +STATUS_UNLOADING_PREVIOUS = "unloading_previous"
32 +STATUS_LOADING = "loading"
33 +STATUS_WARMING = "warming"
34 +STATUS_READY = "ready"
35 +STATUS_ERROR = "error"
36 +STATUS_UNLOADING = "unloading"
37 +
38 +
39 +@dataclass
40 +class LoadedModel:
41 + model: dict
42 + handle: WorkerHandle
43 + adapter: RuntimeAdapter
44 + status: str = STATUS_LOADING
45 + started_at: float = field(default_factory=time.time)
46 + ready_at: float | None = None
47 + last_used: float = field(default_factory=time.time)
48 + in_flight: int = 0
49 + estimate_gb: float = 0.0
50 + measured_gb: float = 0.0
51 + warm: dict = field(default_factory=dict)
52 + error: str | None = None
53 + requests: int = 0
54 +
55 + def to_dict(self) -> dict:
56 + return {
57 + "model_id": self.model["id"], "name": self.model["name"], "runtime": self.handle.runtime,
58 + "status": self.status, "port": self.handle.port, "pid": self.handle.pid, "context": self.handle.context,
59 + "started_at": self.started_at, "ready_at": self.ready_at, "last_used": self.last_used,
60 + "in_flight": self.in_flight, "estimate_gb": round(self.estimate_gb, 2),
61 + "measured_gb": round(self.measured_gb, 2), "warm": self.warm, "error": self.error,
62 + "requests": self.requests, "pinned": bool(self.model.get("pinned")),
63 + "elapsed_seconds": round(time.time() - self.started_at, 1),
64 + "load_ms": self.handle.extra.get("load_ms"),
65 + }
66 +
67 +
68 +class ModelManager:
69 + def __init__(self, settings: Settings, db: Database, registry: Registry):
70 + self.settings = settings
71 + self.db = db
72 + self.registry = registry
73 + self.adapters: dict[str, RuntimeAdapter] = {
74 + "mlx": MLXAdapter(settings, settings.logs_path / "workers"),
75 + "llamacpp": LlamaCppAdapter(settings, settings.logs_path / "workers"),
76 + }
77 + self.loaded: dict[str, LoadedModel] = {}
78 + self.switch_lock = asyncio.Lock()
79 + self.progress: dict[str, dict] = {} # model_id -> transient status while switching
80 + self.waiting: dict[str, int] = {}
81 + self.client = httpx.AsyncClient(timeout=httpx.Timeout(30.0, read=None))
82 + self._tasks: list[asyncio.Task] = []
83 + self._workers_file = settings.data_path / "workers.json"
84 + self.stats = {"requests": 0, "tokens": 0, "loads": 0, "unloads": 0, "evictions": 0, "errors": 0}
85 +
86 + # ------------------------------------------------------------------ lifecycle
87 + async def start(self) -> None:
88 + await self._recover_stale_workers()
89 + self._tasks.append(asyncio.create_task(self._monitor_loop(), name="worker-monitor"))
90 + self._tasks.append(asyncio.create_task(self._idle_loop(), name="idle-unload"))
91 + pre = (await self.db.get_setting("preload_model", self.settings.preload_model)) or "none"
92 + if pre and pre != "none":
93 + async def _pre():
94 + try:
95 + await self.ensure_loaded(pre, reason="preload")
96 + except Exception as e:
97 + log.warning("preload of %s failed: %s", pre, e)
98 + self._tasks.append(asyncio.create_task(_pre(), name="preload"))
99 +
100 + async def stop(self) -> None:
101 + for t in self._tasks:
102 + t.cancel()
103 + for mid in list(self.loaded):
104 + with contextlib.suppress(Exception):
105 + await self.unload(mid, reason="shutdown", wait_inflight=True)
106 + await self.client.aclose()
107 +
108 + async def _recover_stale_workers(self) -> None:
109 + """Kill workers left over from a previous (crashed) server and reset registry state."""
110 + if self._workers_file.exists():
111 + try:
112 + data = json.loads(self._workers_file.read_text())
113 + except Exception:
114 + data = {}
115 + for mid, w in data.items():
116 + pid = w.get("pid")
117 + if not pid:
118 + continue
119 + try:
120 + p = psutil.Process(pid)
121 + cmd = " ".join(p.cmdline())
122 + if "mlx_worker" in cmd or "llama-server" in cmd:
123 + log.warning("killing stale worker pid %s for %s", pid, mid)
124 + for c in p.children(recursive=True):
125 + with contextlib.suppress(psutil.Error):
126 + c.kill()
127 + p.kill()
128 + except psutil.Error:
129 + pass
130 + await self.db.model_event(None, "recovered_stale_workers", {"count": len(data)})
131 + self._persist_workers()
132 +
133 + def _persist_workers(self) -> None:
134 + data = {mid: {"pid": lm.handle.pid, "port": lm.handle.port, "runtime": lm.handle.runtime}
135 + for mid, lm in self.loaded.items()}
136 + try:
137 + self._workers_file.parent.mkdir(parents=True, exist_ok=True)
138 + self._workers_file.write_text(json.dumps(data))
139 + except OSError:
140 + pass
141 +
142 + # ------------------------------------------------------------------ queries
143 + def get_ready(self, model_id: str) -> LoadedModel | None:
144 + lm = self.loaded.get(model_id)
145 + return lm if lm and lm.status == STATUS_READY else None
146 +
147 + def status_of(self, model_id: str) -> str:
148 + lm = self.loaded.get(model_id)
149 + if lm:
150 + return lm.status
151 + p = self.progress.get(model_id)
152 + return p["status"] if p else STATUS_UNLOADED
153 +
154 + def current_model(self) -> LoadedModel | None:
155 + # Largest ready text model, else any ready
156 + ready = [lm for lm in self.loaded.values() if lm.status == STATUS_READY]
157 + if not ready:
158 + return None
159 + text = [lm for lm in ready if not (lm.model.get("embedding") or lm.model.get("reranker"))]
160 + pool = text or ready
161 + return max(pool, key=lambda lm: lm.estimate_gb)
162 +
163 + def snapshot(self) -> dict:
164 + return {
165 + "loaded": [lm.to_dict() for lm in self.loaded.values()],
166 + "progress": self.progress,
167 + "waiting": self.waiting,
168 + "switching": self.switch_lock.locked(),
169 + "stats": self.stats,
170 + "resident_gb": round(self.resident_gb(), 2),
171 + "runtimes": {k: v.available() for k, v in self.adapters.items()},
172 + }
173 +
174 + def resident_gb(self) -> float:
175 + return sum(max(lm.estimate_gb, lm.measured_gb) for lm in self.loaded.values())
176 +
177 + async def budgets(self) -> tuple[float, float, int]:
178 + b = float(await self.db.get_setting("max_model_memory_gb", self.settings.max_model_memory_gb))
179 + a = float(await self.db.get_setting("absolute_max_memory_gb", self.settings.absolute_max_memory_gb))
180 + n = int(await self.db.get_setting("max_simultaneous_models", self.settings.max_simultaneous_models))
181 + return b, a, n
182 +
183 + # ------------------------------------------------------------------ public ops
184 + async def ensure_loaded(self, name: str, *, reason: str = "request", context: int | None = None,
185 + force: bool = False) -> LoadedModel:
186 + model = await self.registry.resolve(name)
187 + if not model:
188 + raise ModelNotFound(f"The model '{name}' does not exist. Use GET /v1/models to list available models.")
189 + mid = model["id"]
190 + lm = self.get_ready(mid)
191 + if lm:
192 + lm.last_used = time.time()
193 + return lm
194 + self.waiting[mid] = self.waiting.get(mid, 0) + 1
195 + self._set_progress(mid, STATUS_QUEUED)
196 + try:
197 + async with self.switch_lock:
198 + lm = self.get_ready(mid)
199 + if lm:
200 + return lm
201 + return await self._load_locked(model, reason=reason, context=context, force=force)
202 + finally:
203 + self.waiting[mid] = max(0, self.waiting.get(mid, 1) - 1)
204 + if not self.waiting[mid]:
205 + self.waiting.pop(mid, None)
206 +
207 + async def load(self, name: str, *, context: int | None = None, force: bool = False) -> LoadedModel:
208 + return await self.ensure_loaded(name, reason="manual", context=context, force=force)
209 +
210 + async def unload(self, model_id: str, *, reason: str = "manual", wait_inflight: bool = True) -> bool:
211 + lm = self.loaded.get(model_id)
212 + if not lm:
213 + return False
214 + lm.status = STATUS_UNLOADING
215 + self._publish_state()
216 + if wait_inflight:
217 + t0 = time.time()
218 + while lm.in_flight > 0 and time.time() - t0 < 120:
219 + await asyncio.sleep(0.2)
220 + before = psutil.virtual_memory().available
221 + await lm.adapter.stop(lm.handle, self.client)
222 + self.loaded.pop(model_id, None)
223 + self.progress.pop(model_id, None)
224 + self._persist_workers()
225 + self.stats["unloads"] += 1
226 + released = await self._wait_memory_release(before, lm.measured_gb or lm.estimate_gb)
227 + await self.registry.update(model_id) # touch updated_at
228 + await self.db.model_event(model_id, "unloaded", {"reason": reason, "released_gb": released})
229 + bus.publish("model", {"model_id": model_id, "status": STATUS_UNLOADED, "reason": reason, "released_gb": released})
230 + self._publish_state()
231 + log.info("unloaded %s (%s), released ~%.1f GB", model_id, reason, released)
232 + return True
233 +
234 + @contextlib.asynccontextmanager
235 + async def use(self, lm: LoadedModel):
236 + lm.in_flight += 1
237 + lm.requests += 1
238 + self.stats["requests"] += 1
239 + try:
240 + yield lm
241 + finally:
242 + lm.in_flight = max(0, lm.in_flight - 1)
243 + lm.last_used = time.time()
244 +
245 + # ------------------------------------------------------------------ internals
246 + def _set_progress(self, mid: str, status: str, **extra: Any) -> None:
247 + p = self.progress.get(mid) or {"started": time.time()}
248 + p.update({"status": status, "elapsed_seconds": round(time.time() - p["started"], 1), **extra})
249 + self.progress[mid] = p
250 + bus.publish("model", {"model_id": mid, **p})
251 +
252 + def _publish_state(self) -> None:
253 + bus.publish("manager", self.snapshot())
254 +
255 + def _free_port(self) -> int:
256 + used = {lm.handle.port for lm in self.loaded.values()}
257 + for port in range(self.settings.worker_port_start, self.settings.worker_port_end + 1):
258 + if port in used:
259 + continue
260 + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
261 + try:
262 + s.bind(("127.0.0.1", port))
263 + return port
264 + except OSError:
265 + continue
266 + raise ModelLoadError("No free worker port available.")
267 +
268 + async def _load_locked(self, model: dict, *, reason: str, context: int | None, force: bool) -> LoadedModel:
269 + mid = model["id"]
270 + if not model.get("installed"):
271 + raise ModelNotFound(f"Model '{mid}' files are missing on disk.")
272 + if not model.get("enabled"):
273 + raise ModelIncompatible(f"Model '{mid}' is disabled.")
274 + rt = model["runtime"]
275 + adapter = self.adapters.get(rt)
276 + if not adapter or not adapter.available():
277 + raise RuntimeUnsupported(f"Runtime '{rt}' is not available on this machine.")
278 + if not model.get("compatible") and not force:
279 + raise ModelIncompatible(
280 + f"Model '{mid}' is marked {model.get('compatibility_status')}: {model.get('compatibility_reason')}",
281 + extra={"compatibility_status": model.get("compatibility_status")})
282 +
283 + budget, absolute, max_models = await self.budgets()
284 + ctx = int(context or (model.get("overrides") or {}).get("context") or model.get("recommended_context")
285 + or min(self.settings.default_context, model.get("max_context") or self.settings.default_context))
286 + if model.get("max_context"):
287 + ctx = min(ctx, int(model["max_context"]))
288 + est = adapter.estimate_memory(model, ctx)
289 + limit = absolute if force else budget
290 + if est.total_gb > limit:
291 + raise ModelTooLarge(
292 + f"Model '{mid}' requires approximately {est.total_gb:.1f} GB at a {ctx} context but the safe limit is "
293 + f"{limit:.0f} GB.", extra={"estimate": est.to_dict(), "limit_gb": limit})
294 +
295 + # ---- eviction ---------------------------------------------------
296 + self._set_progress(mid, STATUS_QUEUED, context=ctx, estimate_gb=round(est.total_gb, 2))
297 + small = bool(model.get("embedding") or model.get("reranker")) and est.total_gb <= self.settings.small_model_resident_gb
298 + others = [lm for lm in self.loaded.values() if lm.model["id"] != mid]
299 + big_others = [lm for lm in others if not (
300 + (lm.model.get("embedding") or lm.model.get("reranker")) and lm.estimate_gb <= self.settings.small_model_resident_gb)]
301 + to_evict: list[LoadedModel] = []
302 + # count policy: at most `max_models` large models
303 + if not small:
304 + while len(big_others) - len(to_evict) >= max_models:
305 + victim = self._pick_victim([lm for lm in big_others if lm not in to_evict])
306 + if not victim:
307 + break
308 + to_evict.append(victim)
309 + # memory policy: resident + new <= budget
310 + def resident_after() -> float:
311 + return sum(max(lm.estimate_gb, lm.measured_gb) for lm in others if lm not in to_evict)
312 + while resident_after() + est.total_gb > limit:
313 + victim = self._pick_victim([lm for lm in others if lm not in to_evict])
314 + if not victim:
315 + break
316 + to_evict.append(victim)
317 + if resident_after() + est.total_gb > limit:
318 + raise ModelTooLarge(f"Not enough memory budget for '{mid}' ({est.total_gb:.1f} GB) alongside resident models.")
319 + if to_evict:
320 + self._set_progress(mid, STATUS_UNLOADING_PREVIOUS, evicting=[lm.model["id"] for lm in to_evict])
321 + for lm in to_evict:
322 + self.stats["evictions"] += 1
323 + await self.unload(lm.model["id"], reason=f"evicted for {mid}")
324 +
325 + # ---- real free-memory check -------------------------------------
326 + tel = await sample_telemetry_async(self.settings.models_dir)
327 + needed = est.total_gb + 1.5
328 + if tel.mem_available_gb < needed:
329 + # give the OS a moment to reclaim
330 + for _ in range(20):
331 + await asyncio.sleep(0.5)
332 + tel = await sample_telemetry_async(self.settings.models_dir)
333 + if tel.mem_available_gb >= needed:
334 + break
335 + if tel.mem_available_gb < needed and not force:
336 + raise ModelTooLarge(
337 + f"Only {tel.mem_available_gb:.1f} GB of memory is available but '{mid}' needs about {est.total_gb:.1f} GB. "
338 + f"Memory pressure: {tel.mem_pressure_level}.", extra={"estimate": est.to_dict(), "available_gb": tel.mem_available_gb})
339 +
340 + # ---- spawn --------------------------------------------------------
341 + port = self._free_port()
342 + used_before = psutil.virtual_memory().total - psutil.virtual_memory().available
343 + t0 = time.time()
344 + self._set_progress(mid, STATUS_LOADING, port=port)
345 + try:
346 + handle = adapter.spawn(model, port, ctx)
347 + except Exception as e:
348 + self.progress.pop(mid, None)
349 + raise ModelLoadError(f"Failed to start worker for '{mid}': {e}")
350 + lm = LoadedModel(model=model, handle=handle, adapter=adapter, estimate_gb=est.total_gb)
351 + self.loaded[mid] = lm
352 + self._persist_workers()
353 + await self.db.model_event(mid, "loading", {"reason": reason, "context": ctx, "port": port, "estimate": est.to_dict()})
354 + try:
355 + await self._wait_ready(lm)
356 + load_ms = (time.time() - t0) * 1000
357 + handle.extra["load_ms"] = round(load_ms)
358 + lm.status = STATUS_WARMING
359 + self._set_progress(mid, STATUS_WARMING, load_ms=round(load_ms))
360 + warm = await asyncio.wait_for(adapter.warmup(handle, self.client, model), timeout=self.settings.load_timeout_seconds)
361 + lm.warm = warm
362 + lm.measured_gb = await self._measure(lm, used_before)
363 + lm.status = STATUS_READY
364 + lm.ready_at = time.time()
365 + self.progress.pop(mid, None)
366 + self.stats["loads"] += 1
367 + await self.registry.record_load(mid, load_ms)
368 + if warm.get("ttft_ms"):
369 + await self.registry.update(mid, first_token_latency_ms=warm["ttft_ms"])
370 + await self.registry.update(mid, verified=True)
371 + await self.db.model_event(mid, "ready", {"load_ms": round(load_ms), "warm": warm, "measured_gb": round(lm.measured_gb, 2)})
372 + bus.publish("model", {"model_id": mid, "status": STATUS_READY, "load_ms": round(load_ms), "warm": warm})
373 + self._publish_state()
374 + log.info("ready %s in %.0f ms (ctx %d, est %.1f GB, measured %.1f GB)", mid, load_ms, ctx, est.total_gb, lm.measured_gb)
375 + return lm
376 + except Exception as e:
377 + self.stats["errors"] += 1
378 + err = str(e)
379 + tail = self._log_tail(handle.log_path)
380 + lm.status = STATUS_ERROR
381 + lm.error = err
382 + await adapter.stop(handle, None)
383 + self.loaded.pop(mid, None)
384 + self.progress.pop(mid, None)
385 + self._persist_workers()
386 + await self.db.model_event(mid, "load_failed", {"error": err, "log": tail})
387 + bus.publish("model", {"model_id": mid, "status": STATUS_ERROR, "error": err})
388 + self._publish_state()
389 + if isinstance(e, asyncio.TimeoutError):
390 + raise ModelLoadTimeout(f"Model '{mid}' did not become ready within {self.settings.load_timeout_seconds}s.",
391 + extra={"log": tail})
392 + if isinstance(e, (ModelLoadError, WorkerCrashed)):
393 + e.extra.setdefault("log", tail)
394 + raise
395 + raise ModelLoadError(f"Model '{mid}' failed to load: {err}", extra={"log": tail})
396 +
397 + async def _measure(self, lm: LoadedModel, used_before: int | None = None) -> float:
398 + """Best available estimate of the worker's real memory: process footprint, worker-reported Metal memory,
399 + or the system-wide used-memory delta since spawn (Metal buffers of llama-server are not in RSS)."""
400 + vals = [lm.handle.memory_bytes() / GB]
401 + rep = getattr(lm.adapter, "memory_gb", None)
402 + if rep is not None:
403 + v = await rep(lm.handle, self.client)
404 + if v:
405 + vals.append(v)
406 + if used_before is not None:
407 + vm = psutil.virtual_memory()
408 + vals.append(max(0.0, ((vm.total - vm.available) - used_before) / GB))
409 + return round(max(vals), 2)
410 +
411 + def _pick_victim(self, candidates: list[LoadedModel]) -> LoadedModel | None:
412 + if not candidates:
413 + return None
414 + unpinned = [lm for lm in candidates if not lm.model.get("pinned")]
415 + pool = unpinned or candidates # pinned models are evicted only when nothing else can be
416 + return min(pool, key=lambda lm: lm.last_used)
417 +
418 + async def _wait_ready(self, lm: LoadedModel) -> None:
419 + deadline = time.time() + self.settings.load_timeout_seconds
420 + while time.time() < deadline:
421 + if not lm.handle.alive():
422 + raise WorkerCrashed(f"Worker for '{lm.model['id']}' exited during load (code {lm.handle.process.returncode}).")
423 + status, err = await lm.adapter.is_ready(lm.handle, self.client)
424 + if status == "ready":
425 + return
426 + if status == "error":
427 + raise ModelLoadError(f"Model '{lm.model['id']}' failed to load: {err}")
428 + lm.measured_gb = lm.handle.memory_bytes() / GB
429 + self._set_progress(lm.model["id"], STATUS_LOADING, measured_gb=round(lm.measured_gb, 2))
430 + await asyncio.sleep(0.5)
431 + raise asyncio.TimeoutError()
432 +
433 + async def _wait_memory_release(self, before_available: int, expected_gb: float) -> float:
434 + """Wait until the OS reports the freed memory (up to ~8 s). Returns GB released."""
435 + best = 0.0
436 + for _ in range(16):
437 + await asyncio.sleep(0.5)
438 + now = psutil.virtual_memory().available
439 + best = max(best, (now - before_available) / GB)
440 + if expected_gb and best >= expected_gb * 0.7:
441 + break
442 + return round(best, 2)
443 +
444 + @staticmethod
445 + def _log_tail(path: Path, n: int = 40) -> str:
446 + try:
447 + lines = path.read_text(errors="replace").splitlines()
448 + return "\n".join(lines[-n:])
449 + except Exception:
450 + return ""
451 +
452 + # ------------------------------------------------------------------ loops
453 + async def _monitor_loop(self) -> None:
454 + while True:
455 + try:
456 + await asyncio.sleep(5)
457 + for mid, lm in list(self.loaded.items()):
458 + if lm.status in (STATUS_LOADING, STATUS_WARMING, STATUS_UNLOADING):
459 + continue
460 + if not lm.handle.alive():
461 + log.error("worker for %s died (code %s)", mid, lm.handle.process.returncode)
462 + self.loaded.pop(mid, None)
463 + self._persist_workers()
464 + self.stats["errors"] += 1
465 + await self.db.model_event(mid, "worker_crashed", {"code": lm.handle.process.returncode,
466 + "log": self._log_tail(lm.handle.log_path)})
467 + bus.publish("model", {"model_id": mid, "status": STATUS_ERROR, "error": "worker crashed"})
468 + self._publish_state()
469 + continue
470 + lm.measured_gb = max(lm.measured_gb, await self._measure(lm))
471 + status, err = await lm.adapter.is_ready(lm.handle, self.client)
472 + if status == "error":
473 + log.error("worker for %s reports error: %s", mid, err)
474 + await self.unload(mid, reason=f"worker error: {err}", wait_inflight=False)
475 + except asyncio.CancelledError:
476 + return
477 + except Exception:
478 + log.exception("monitor loop error")
479 +
480 + async def _idle_loop(self) -> None:
481 + while True:
482 + try:
483 + await asyncio.sleep(30)
484 + minutes = int(await self.db.get_setting("model_idle_timeout_minutes", self.settings.model_idle_timeout_minutes))
485 + if minutes <= 0:
486 + continue
487 + now = time.time()
488 + for mid, lm in list(self.loaded.items()):
489 + if lm.status != STATUS_READY or lm.model.get("pinned") or lm.in_flight:
490 + continue
491 + if now - lm.last_used > minutes * 60:
492 + log.info("idle timeout: unloading %s", mid)
493 + await self.unload(mid, reason="idle timeout")
494 + except asyncio.CancelledError:
495 + return
496 + except Exception:
497 + log.exception("idle loop error")
498 +
499 + # ------------------------------------------------------------------ pin / refresh
500 + async def refresh_model(self, model_id: str) -> None:
501 + """Re-read registry row for a loaded model (pin/favorite changes)."""
502 + lm = self.loaded.get(model_id)
503 + if lm:
504 + m = await self.registry.get(model_id)
505 + if m:
506 + lm.model = m
added server/llm_api/metrics.py +98 −0
@@ -0,0 +1,98 @@
1 +"""Periodic system metrics sampling (DB + event bus) and request counters."""
2 +
3 +from __future__ import annotations
4 +
5 +import asyncio
6 +import logging
7 +import time
8 +
9 +from .config import Settings
10 +from .db import Database
11 +from .events import bus
12 +from .hardware import GB, sample_telemetry_async
13 +
14 +log = logging.getLogger("llm_api.metrics")
15 +
16 +
17 +class MetricsCollector:
18 + def __init__(self, settings: Settings, db: Database, manager):
19 + self.settings = settings
20 + self.db = db
21 + self.manager = manager
22 + self.last = None
23 + self._task: asyncio.Task | None = None
24 + self.started_at = time.time()
25 + self.window: list[dict] = [] # recent throughput samples (tps) for dashboard
26 +
27 + async def start(self) -> None:
28 + self._task = asyncio.create_task(self._loop(), name="metrics")
29 +
30 + async def stop(self) -> None:
31 + if self._task:
32 + self._task.cancel()
33 +
34 + async def sample(self) -> dict:
35 + tel = await sample_telemetry_async(self.settings.models_dir)
36 + worker = sum(lm.handle.memory_bytes() for lm in self.manager.loaded.values()) / GB
37 + cur = self.manager.current_model()
38 + d = tel.to_dict()
39 + d["worker_rss_gb"] = round(worker, 2)
40 + d["loaded_model"] = cur.model["id"] if cur else None
41 + d["loaded_models"] = [lm.model["id"] for lm in self.manager.loaded.values()]
42 + d["manager"] = self.manager.stats
43 + d["app_uptime_seconds"] = round(time.time() - self.started_at)
44 + self.last = d
45 + return d
46 +
47 + async def _loop(self) -> None:
48 + tick = 0
49 + while True:
50 + try:
51 + d = await self.sample()
52 + bus.publish("metrics", d)
53 + tick += 1
54 + interval = max(3, self.settings.metrics_interval_seconds)
55 + if tick % max(1, interval // 3) == 0:
56 + await self.db.execute(
57 + "INSERT OR REPLACE INTO system_metrics(ts, mem_used_gb, mem_available_gb, mem_pressure, swap_used_gb, "
58 + "cpu_percent, gpu_percent, disk_free_gb, thermal, worker_rss_gb, loaded_model) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
59 + (d["ts"], d["mem_used_gb"], d["mem_available_gb"], d["mem_pressure_percent"], d["swap_used_gb"],
60 + d["cpu_percent"], d["gpu_percent"], d["disk_free_gb"], d["thermal_state"], d["worker_rss_gb"],
61 + d["loaded_model"]))
62 + if tick % 200 == 0:
63 + cutoff = time.time() - self.settings.metrics_retention_days * 86400
64 + await self.db.execute("DELETE FROM system_metrics WHERE ts < ?", (cutoff,))
65 + await self.db.execute("DELETE FROM inference_requests WHERE created_at < ?", (cutoff,))
66 + if d["swap_used_gb"] > 4 or d["mem_pressure_level"] == "critical":
67 + bus.publish("alert", {"level": "warning", "message":
68 + f"Memory pressure {d['mem_pressure_level']} — swap {d['swap_used_gb']} GB. "
69 + "The loaded model exceeds the recommended operating envelope."})
70 + await asyncio.sleep(3)
71 + except asyncio.CancelledError:
72 + return
73 + except Exception:
74 + log.exception("metrics loop error")
75 + await asyncio.sleep(5)
76 +
77 + async def history(self, minutes: int = 60) -> list[dict]:
78 + since = time.time() - minutes * 60
79 + return await self.db.fetchall("SELECT * FROM system_metrics WHERE ts >= ? ORDER BY ts", (since,))
80 +
81 + async def request_stats(self, hours: int = 24) -> dict:
82 + since = time.time() - hours * 3600
83 + totals = await self.db.fetchone(
84 + "SELECT COUNT(*) AS requests, COALESCE(SUM(prompt_tokens),0) AS prompt_tokens, "
85 + "COALESCE(SUM(completion_tokens),0) AS completion_tokens, AVG(tps) AS avg_tps, AVG(ttft_ms) AS avg_ttft_ms, "
86 + "SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS errors FROM inference_requests WHERE created_at >= ?", (since,))
87 + per_model = await self.db.fetchall(
88 + "SELECT model_id, COUNT(*) AS requests, COALESCE(SUM(completion_tokens),0) AS completion_tokens, "
89 + "AVG(tps) AS avg_tps, AVG(ttft_ms) AS avg_ttft_ms FROM inference_requests WHERE created_at >= ? "
90 + "GROUP BY model_id ORDER BY requests DESC", (since,))
91 + buckets = await self.db.fetchall(
92 + "SELECT CAST(created_at / 3600 AS INTEGER) * 3600 AS hour, COUNT(*) AS requests, "
93 + "COALESCE(SUM(completion_tokens),0) AS tokens FROM inference_requests WHERE created_at >= ? GROUP BY hour ORDER BY hour",
94 + (since,))
95 + all_time = await self.db.fetchone(
96 + "SELECT COUNT(*) AS requests, COALESCE(SUM(completion_tokens),0) AS completion_tokens, "
97 + "COALESCE(SUM(prompt_tokens),0) AS prompt_tokens FROM inference_requests")
98 + return {"window_hours": hours, "totals": totals, "per_model": per_model, "hourly": buckets, "all_time": all_time}
added server/llm_api/proxy_ui.py +50 −0
@@ -0,0 +1,50 @@
1 +"""Reverse-proxy everything that is not API to the Next.js dashboard (127.0.0.1:8301)."""
2 +
3 +from __future__ import annotations
4 +
5 +import httpx
6 +from fastapi import APIRouter, Request, Response
7 +from fastapi.responses import HTMLResponse, StreamingResponse
8 +
9 +router = APIRouter()
10 +
11 +HOP_BY_HOP = {"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers",
12 + "transfer-encoding", "upgrade", "content-length", "content-encoding"}
13 +
14 +FALLBACK_HTML = """<!doctype html><html><head><meta charset="utf-8"><title>LLM API</title>
15 +<style>body{font-family:-apple-system,system-ui,sans-serif;background:#0b0d10;color:#e6e8eb;display:grid;place-items:center;height:100vh;margin:0}
16 +main{max-width:520px;padding:32px;border:1px solid #23272d;border-radius:14px;background:#11141a}code{color:#8fd3ff}</style></head>
17 +<body><main><h1>LLM API</h1><p>The API is running but the dashboard is not reachable yet.</p>
18 +<p>OpenAI-compatible endpoint: <code>/v1/chat/completions</code> · health: <code>/health</code></p></main></body></html>"""
19 +
20 +
21 +@router.api_route("/{path:path}", methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], include_in_schema=False)
22 +async def proxy(path: str, request: Request):
23 + settings = request.app.state.settings
24 + client: httpx.AsyncClient = request.app.state.ui_client
25 + url = f"{settings.web_url.rstrip('/')}/{path}"
26 + if request.url.query:
27 + url += f"?{request.url.query}"
28 + headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_BY_HOP and k.lower() != "host"}
29 + headers["x-forwarded-host"] = request.headers.get("host", "")
30 + headers["x-forwarded-proto"] = request.headers.get("x-forwarded-proto", request.url.scheme)
31 + body = await request.body()
32 + try:
33 + req = client.build_request(request.method, url, headers=headers, content=body)
34 + resp = await client.send(req, stream=True)
35 + except httpx.HTTPError:
36 + return HTMLResponse(FALLBACK_HTML, status_code=503)
37 + out_headers = {k: v for k, v in resp.headers.items() if k.lower() not in HOP_BY_HOP}
38 +
39 + async def body_iter():
40 + try:
41 + async for chunk in resp.aiter_raw():
42 + yield chunk
43 + finally:
44 + await resp.aclose()
45 +
46 + if request.method == "HEAD":
47 + await resp.aclose()
48 + return Response(status_code=resp.status_code, headers=out_headers)
49 + return StreamingResponse(body_iter(), status_code=resp.status_code, headers=out_headers,
50 + media_type=resp.headers.get("content-type"))
added server/llm_api/routing.py +70 −0
@@ -0,0 +1,70 @@
1 +"""Optional `model: auto` routing. Never used when the client names a model explicitly."""
2 +
3 +from __future__ import annotations
4 +
5 +import re
6 +
7 +CODE_RE = re.compile(r"```|\bdef \b|\bclass \b|\bimport \b|\bfunction\b|=>|#include|SELECT .* FROM", re.I)
8 +REASON_RE = re.compile(r"\b(prove|proof|step by step|reason|derive|why|analy[sz]e|plan|strategy|compare|evaluate|explain in depth)\b", re.I)
9 +
10 +
11 +def _text_of(body: dict) -> tuple[str, bool]:
12 + text = ""
13 + has_image = False
14 + for m in body.get("messages") or []:
15 + c = m.get("content")
16 + if isinstance(c, str):
17 + text += c + "\n"
18 + elif isinstance(c, list):
19 + for p in c:
20 + if isinstance(p, dict):
21 + if p.get("type") == "text":
22 + text += p.get("text", "") + "\n"
23 + elif p.get("type") in ("image_url", "input_image", "image"):
24 + has_image = True
25 + if body.get("prompt"):
26 + text += str(body["prompt"])
27 + return text, has_image
28 +
29 +
30 +async def choose_auto(state, body: dict, endpoint: str) -> str:
31 + registry = state.manager.registry
32 + manager = state.manager
33 + aliases = await registry.aliases()
34 + text, has_image = _text_of(body)
35 + if endpoint.endswith("/embeddings"):
36 + return aliases.get("embedding") or await _first(registry, lambda m: m["embedding"])
37 + if endpoint.endswith("/rerank"):
38 + return aliases.get("reranker") or await _first(registry, lambda m: m["reranker"])
39 + if has_image:
40 + return aliases.get("vision") or await _first(registry, lambda m: m["vision"] and not m["embedding"])
41 + long_prompt = len(text) > 12000
42 + if CODE_RE.search(text) and "coder" in aliases:
43 + return aliases["coder"]
44 + if (REASON_RE.search(text) or long_prompt) and "reasoning" in aliases:
45 + return aliases["reasoning"]
46 + # prefer the currently loaded text model to avoid a switch
47 + cur = manager.current_model()
48 + if cur and not (cur.model.get("embedding") or cur.model.get("reranker") or cur.model.get("vision") and not text):
49 + return cur.model["id"]
50 + if "default" in aliases:
51 + return aliases["default"]
52 + if "fast" in aliases:
53 + return aliases["fast"]
54 + default = await state.db.get_setting("default_model")
55 + if default:
56 + return default
57 + return await _first(registry, lambda m: not (m["embedding"] or m["reranker"]) and m["compatible"])
58 +
59 +
60 +async def _first(registry, pred) -> str:
61 + models = await registry.list_models()
62 + fav = [m for m in models if pred(m) and m["favorite"] and m["enabled"] and m["compatible"]]
63 + ok = fav or [m for m in models if pred(m) and m["enabled"] and m["compatible"]]
64 + if not ok:
65 + from .errors import ModelNotFound
66 + raise ModelNotFound("No suitable model installed for automatic routing.")
67 + # smallest that is reasonably capable: prefer the largest under 20 GB, else smallest
68 + mid = sorted(ok, key=lambda m: (m["estimated_ram_gb"] or 0))
69 + under = [m for m in mid if (m["estimated_ram_gb"] or 0) <= 20]
70 + return (under[-1] if under else mid[0])["id"]
added server/llm_api/runtimes/__init__.py +5 −0
@@ -0,0 +1,5 @@
1 +from .base import RuntimeAdapter, WorkerHandle
2 +from .llamacpp_adapter import LlamaCppAdapter
3 +from .mlx_adapter import MLXAdapter
4 +
5 +__all__ = ["RuntimeAdapter", "WorkerHandle", "MLXAdapter", "LlamaCppAdapter"]
added server/llm_api/runtimes/base.py +149 −0
@@ -0,0 +1,149 @@
1 +"""Runtime adapter interface. Every runtime runs a model in a dedicated local worker process that
2 +speaks the OpenAI HTTP API on 127.0.0.1:<port>. The manager talks to workers uniformly."""
3 +
4 +from __future__ import annotations
5 +
6 +import asyncio
7 +import os
8 +import signal
9 +import subprocess
10 +import time
11 +from dataclasses import dataclass, field
12 +from pathlib import Path
13 +from typing import Any
14 +
15 +import httpx
16 +import psutil
17 +
18 +from ..hardware import process_footprint_bytes
19 +from ..models.estimator import MemoryEstimate, estimate
20 +
21 +
22 +@dataclass
23 +class WorkerHandle:
24 + model_id: str
25 + runtime: str
26 + port: int
27 + process: subprocess.Popen
28 + log_path: Path
29 + started_at: float = field(default_factory=time.time)
30 + context: int = 0
31 + extra: dict[str, Any] = field(default_factory=dict)
32 +
33 + @property
34 + def base_url(self) -> str:
35 + return f"http://127.0.0.1:{self.port}"
36 +
37 + @property
38 + def pid(self) -> int:
39 + return self.process.pid
40 +
41 + def alive(self) -> bool:
42 + return self.process.poll() is None
43 +
44 + def memory_bytes(self) -> int:
45 + if not self.alive():
46 + return 0
47 + return process_footprint_bytes(self.process.pid)
48 +
49 +
50 +class RuntimeAdapter:
51 + name = "base"
52 +
53 + def __init__(self, settings, log_dir: Path):
54 + self.settings = settings
55 + self.log_dir = log_dir
56 +
57 + # ---- to implement ------------------------------------------------------
58 + def build_command(self, model: dict, port: int, context: int) -> list[str]:
59 + raise NotImplementedError
60 +
61 + async def is_ready(self, handle: WorkerHandle, client: httpx.AsyncClient) -> tuple[str, str | None]:
62 + """Return (status, error) where status in loading|ready|error."""
63 + raise NotImplementedError
64 +
65 + def available(self) -> bool:
66 + raise NotImplementedError
67 +
68 + # ---- shared -------------------------------------------------------------
69 + def estimate_memory(self, model: dict, context: int) -> MemoryEstimate:
70 + return estimate(model["weights_bytes"], self.name, model.get("kv_bytes_per_token") or 0, context,
71 + bool(model.get("vision")))
72 +
73 + def spawn(self, model: dict, port: int, context: int) -> WorkerHandle:
74 + cmd = self.build_command(model, port, context)
75 + self.log_dir.mkdir(parents=True, exist_ok=True)
76 + log_path = self.log_dir / f"worker-{model['id']}.log"
77 + env = dict(os.environ)
78 + env.setdefault("PYTHONUNBUFFERED", "1")
79 + env.setdefault("TOKENIZERS_PARALLELISM", "false")
80 + env.setdefault("HF_HUB_OFFLINE", "1")
81 + logf = open(log_path, "ab")
82 + logf.write(f"\n=== {time.strftime('%Y-%m-%d %H:%M:%S')} spawn: {' '.join(cmd)}\n".encode())
83 + proc = subprocess.Popen(cmd, stdout=logf, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, env=env,
84 + start_new_session=True, cwd=str(Path(model["path"]).parent))
85 + return WorkerHandle(model_id=model["id"], runtime=self.name, port=port, process=proc, log_path=log_path,
86 + context=context)
87 +
88 + async def warmup(self, handle: WorkerHandle, client: httpx.AsyncClient, model: dict) -> dict:
89 + """Tiny inference to prove the model works. Returns timings."""
90 + t0 = time.time()
91 + if model.get("embedding"):
92 + r = await client.post(f"{handle.base_url}/v1/embeddings", json={"model": model["id"], "input": "warm up"},
93 + timeout=600)
94 + r.raise_for_status()
95 + data = r.json()
96 + return {"ttft_ms": round((time.time() - t0) * 1000, 1), "dims": len(data["data"][0]["embedding"])}
97 + if model.get("reranker"):
98 + r = await client.post(f"{handle.base_url}/v1/rerank",
99 + json={"model": model["id"], "query": "warm", "documents": ["warm up"]}, timeout=600)
100 + r.raise_for_status()
101 + return {"ttft_ms": round((time.time() - t0) * 1000, 1)}
102 + r = await client.post(f"{handle.base_url}/v1/chat/completions",
103 + json={"model": model["id"], "messages": [{"role": "user", "content": "Say OK."}],
104 + "max_tokens": 4, "temperature": 0.0, "stream": False}, timeout=600)
105 + r.raise_for_status()
106 + data = r.json()
107 + text = (data.get("choices") or [{}])[0].get("message", {}).get("content")
108 + timings = data.get("timings") or {}
109 + ttft = timings.get("ttft_ms") or timings.get("prompt_ms") or round((time.time() - t0) * 1000, 1)
110 + return {"ttft_ms": ttft, "text": text, "total_ms": round((time.time() - t0) * 1000, 1)}
111 +
112 + async def stop(self, handle: WorkerHandle, client: httpx.AsyncClient | None = None, grace: float = 8.0) -> None:
113 + proc = handle.process
114 + if proc.poll() is not None:
115 + return
116 + # 1. polite shutdown
117 + if client is not None:
118 + try:
119 + await client.post(f"{handle.base_url}/shutdown", timeout=2)
120 + except Exception:
121 + pass
122 + # 2. SIGTERM the whole session
123 + try:
124 + os.killpg(proc.pid, signal.SIGTERM)
125 + except Exception:
126 + try:
127 + proc.terminate()
128 + except Exception:
129 + pass
130 + t0 = time.time()
131 + while proc.poll() is None and time.time() - t0 < grace:
132 + await asyncio.sleep(0.1)
133 + if proc.poll() is None:
134 + try:
135 + os.killpg(proc.pid, signal.SIGKILL)
136 + except Exception:
137 + try:
138 + proc.kill()
139 + except Exception:
140 + pass
141 + t0 = time.time()
142 + while proc.poll() is None and time.time() - t0 < 5:
143 + await asyncio.sleep(0.1)
144 + # kill stray children (llama-server forks none, but be safe)
145 + try:
146 + for c in psutil.Process(proc.pid).children(recursive=True):
147 + c.kill()
148 + except psutil.Error:
149 + pass
added server/llm_api/runtimes/llamacpp_adapter.py +69 −0
@@ -0,0 +1,69 @@
1 +"""llama.cpp runtime: spawns `llama-server` (Metal) for GGUF models."""
2 +
3 +from __future__ import annotations
4 +
5 +import os
6 +import shutil
7 +
8 +import httpx
9 +
10 +from .base import RuntimeAdapter, WorkerHandle
11 +
12 +
13 +class LlamaCppAdapter(RuntimeAdapter):
14 + name = "llamacpp"
15 +
16 + def binary(self) -> str | None:
17 + b = self.settings.llama_server_bin
18 + if os.path.isabs(b) and os.path.exists(b):
19 + return b
20 + found = shutil.which(b)
21 + if found:
22 + return found
23 + for cand in ("/opt/homebrew/bin/llama-server", "/usr/local/bin/llama-server"):
24 + if os.path.exists(cand):
25 + return cand
26 + return None
27 +
28 + def available(self) -> bool:
29 + return self.binary() is not None
30 +
31 + def build_command(self, model: dict, port: int, context: int) -> list[str]:
32 + b = self.binary()
33 + assert b, "llama-server not found"
34 + threads = max(4, (os.cpu_count() or 8) - 2)
35 + cmd = [b, "-m", model["weights_file"], "--host", "127.0.0.1", "--port", str(port), "-ngl", "999",
36 + "-c", str(context), "--alias", model["id"], "--no-webui", "-np", "1", "-fa", "auto",
37 + "-t", str(threads), "--cache-reuse", "256", "--metrics", "--slots", "--jinja"]
38 + if model.get("embedding"):
39 + cmd += ["--embeddings", "-ub", "2048", "-b", "2048"]
40 + # Pooling: model default unless overridden
41 + pooling = (model.get("overrides") or {}).get("pooling")
42 + if pooling:
43 + cmd += ["--pooling", pooling]
44 + elif model.get("reranker"):
45 + cmd += ["--reranking"]
46 + else:
47 + if model.get("thinking"):
48 + cmd += ["--reasoning-format", "deepseek"]
49 + if model.get("mmproj_file"):
50 + cmd += ["--mmproj", model["mmproj_file"]]
51 + overrides = model.get("overrides") or {}
52 + if overrides.get("kv_bits") in (4, 8):
53 + t = "q4_0" if overrides["kv_bits"] == 4 else "q8_0"
54 + cmd += ["-ctk", t, "-ctv", t]
55 + for extra in overrides.get("llama_args") or []:
56 + if isinstance(extra, str) and not extra.startswith(("|", ";", "&", "$", "`")):
57 + cmd.append(extra)
58 + return cmd
59 +
60 + async def is_ready(self, handle: WorkerHandle, client: httpx.AsyncClient) -> tuple[str, str | None]:
61 + try:
62 + r = await client.get(f"{handle.base_url}/health", timeout=3)
63 + except Exception:
64 + return "loading", None
65 + if r.status_code == 200:
66 + return "ready", None
67 + if r.status_code == 503:
68 + return "loading", None
69 + return "loading", None
added server/llm_api/runtimes/mlx_adapter.py +58 −0
@@ -0,0 +1,58 @@
1 +"""MLX runtime: spawns llm_api.worker.mlx_worker in the server's own Python environment."""
2 +
3 +from __future__ import annotations
4 +
5 +import sys
6 +
7 +import httpx
8 +
9 +from .base import RuntimeAdapter, WorkerHandle
10 +
11 +
12 +class MLXAdapter(RuntimeAdapter):
13 + name = "mlx"
14 +
15 + def available(self) -> bool:
16 + try:
17 + import mlx.core # noqa: F401
18 + import mlx_lm # noqa: F401
19 + return True
20 + except Exception:
21 + return False
22 +
23 + def build_command(self, model: dict, port: int, context: int) -> list[str]:
24 + py = self.settings.worker_python or sys.executable
25 + task = "embedding" if model.get("embedding") else "reranking" if model.get("reranker") else "text"
26 + cmd = [py, "-m", "llm_api.worker.mlx_worker", "--model-path", model["path"], "--model-id", model["id"],
27 + "--port", str(port), "--max-context", str(context),
28 + "--default-max-tokens", str(self.settings.default_max_tokens), "--task", task,
29 + "--generation-timeout", str(self.settings.generation_timeout_seconds)]
30 + if model.get("vision"):
31 + cmd.append("--vision")
32 + overrides = model.get("overrides") or {}
33 + if overrides.get("kv_bits"):
34 + cmd += ["--kv-bits", str(overrides["kv_bits"])]
35 + return cmd
36 +
37 + async def warmup(self, handle: WorkerHandle, client: httpx.AsyncClient, model: dict) -> dict:
38 + r = await client.post(f"{handle.base_url}/warmup", timeout=600)
39 + r.raise_for_status()
40 + return r.json()
41 +
42 + async def memory_gb(self, handle: WorkerHandle, client: httpx.AsyncClient) -> float | None:
43 + try:
44 + r = await client.get(f"{handle.base_url}/health", timeout=3)
45 + m = r.json().get("memory") or {}
46 + return (m.get("active_gb") or 0) + (m.get("cache_gb") or 0) or None
47 + except Exception:
48 + return None
49 +
50 + async def is_ready(self, handle: WorkerHandle, client: httpx.AsyncClient) -> tuple[str, str | None]:
51 + try:
52 + r = await client.get(f"{handle.base_url}/health", timeout=3)
53 + except Exception:
54 + return "loading", None
55 + if r.status_code != 200:
56 + return "loading", None
57 + d = r.json()
58 + return d.get("status", "loading"), d.get("error")
added server/llm_api/worker/__init__.py +0 −0
added server/llm_api/worker/mlx_worker.py +1117 −0
@@ -0,0 +1,1117 @@
1 +"""Standalone MLX inference worker: one process = one model.
2 +
3 +Started by the Model Manager:
4 + python -m llm_api.worker.mlx_worker --model-path P --port N --model-id ID [--vision] [--task text|embedding|reranking]
5 +
6 +Exposes on 127.0.0.1:
7 + GET /health loading|ready|error + memory
8 + POST /v1/chat/completions OpenAI compatible (stream or not)
9 + POST /v1/completions
10 + POST /v1/embeddings
11 + POST /v1/rerank
12 + POST /tokenize
13 + POST /warmup
14 + POST /clear-cache
15 + POST /shutdown
16 +
17 +Killing the process is the memory-release mechanism: everything (weights, KV cache, Metal heaps)
18 +goes away with it.
19 +"""
20 +
21 +from __future__ import annotations
22 +
23 +import argparse
24 +import asyncio
25 +import base64
26 +import json
27 +import logging
28 +import math
29 +import os
30 +import queue
31 +import sys
32 +import tempfile
33 +import threading
34 +import time
35 +from pathlib import Path
36 +from typing import Any
37 +
38 +import psutil
39 +from fastapi import FastAPI, Request
40 +from fastapi.responses import JSONResponse, StreamingResponse
41 +
42 +from .openai_types import (HARMONY_MARKERS, HARMONY_THINK_END, HARMONY_THINK_START, MarkerStripper, StopMatcher,
43 + ThinkSplitter, chat_chunk, new_id, normalize_messages, parse_tool_calls, sse)
44 +
45 +log = logging.getLogger("mlx_worker")
46 +GB = 1024**3
47 +
48 +# ---------------------------------------------------------------------------
49 +# State
50 +# ---------------------------------------------------------------------------
51 +
52 +
53 +class WorkerState:
54 + def __init__(self, args: argparse.Namespace):
55 + self.args = args
56 + self.status = "loading"
57 + self.error: str | None = None
58 + self.started = time.time()
59 + self.loaded_at: float | None = None
60 + self.load_ms: float | None = None
61 + self.model = None
62 + self.tokenizer = None
63 + self.processor = None # mlx_vlm
64 + self.config: dict = {}
65 + self.gen_config: dict = {}
66 + self.vision = bool(args.vision)
67 + self.task = args.task
68 + self.max_context = args.max_context
69 + self.gen_lock = threading.Lock()
70 + self.requests = 0
71 + self.tokens_generated = 0
72 + self.last_used = time.time()
73 + # prompt cache (single conversation)
74 + self.cache_tokens: list[int] = []
75 + self.cache_obj = None
76 + self.warm_ttft_ms: float | None = None
77 + self.template_text = ""
78 + self.harmony = False
79 + self.thinks = False
80 +
81 +
82 +class MLXThread:
83 + """All MLX work (import, load, generation, embeddings) runs on this single thread.
84 + MLX streams/command buffers are thread-affine and MLX is not thread-safe."""
85 +
86 + def __init__(self):
87 + self._q: "queue.Queue[tuple]" = queue.Queue()
88 + self._th = threading.Thread(target=self._run, name="mlx", daemon=True)
89 + self._th.start()
90 +
91 + def _run(self):
92 + while True:
93 + fn, args, fut = self._q.get()
94 + if fn is None:
95 + return
96 + try:
97 + res = fn(*args)
98 + if fut is not None:
99 + fut.set_result(res)
100 + except BaseException as e: # noqa: BLE001
101 + if fut is not None:
102 + fut.set_exception(e)
103 + else:
104 + log.exception("mlx thread task failed")
105 +
106 + def submit(self, fn, *args):
107 + import concurrent.futures
108 + fut: concurrent.futures.Future = concurrent.futures.Future()
109 + self._q.put((fn, args, fut))
110 + return fut
111 +
112 + def alive(self) -> bool:
113 + return self._th.is_alive()
114 +
115 +
116 +MLX_THREAD = MLXThread()
117 +STATE: WorkerState | None = None
118 +app = FastAPI(title="llm-api mlx worker")
119 +
120 +
121 +def _mx():
122 + import mlx.core as mx
123 + return mx
124 +
125 +
126 +def memory_info() -> dict:
127 + out: dict[str, Any] = {"rss_gb": round(psutil.Process().memory_info().rss / GB, 3)}
128 + try:
129 + mx = _mx()
130 + out.update({
131 + "active_gb": round(mx.get_active_memory() / GB, 3),
132 + "peak_gb": round(mx.get_peak_memory() / GB, 3),
133 + "cache_gb": round(mx.get_cache_memory() / GB, 3),
134 + })
135 + except Exception:
136 + pass
137 + return out
138 +
139 +
140 +# ---------------------------------------------------------------------------
141 +# Loading
142 +# ---------------------------------------------------------------------------
143 +
144 +
145 +def _load_model(st: WorkerState) -> None:
146 + t0 = time.time()
147 + try:
148 + mx = _mx()
149 + path = st.args.model_path
150 + cfg_p = Path(path) / "config.json"
151 + if cfg_p.exists():
152 + st.config = json.loads(cfg_p.read_text())
153 + gc_p = Path(path) / "generation_config.json"
154 + if gc_p.exists():
155 + try:
156 + st.gen_config = json.loads(gc_p.read_text())
157 + except Exception:
158 + st.gen_config = {}
159 + tpl_p = Path(path) / "chat_template.jinja"
160 + if tpl_p.exists():
161 + st.template_text = tpl_p.read_text(errors="replace")
162 + else:
163 + tc_p = Path(path) / "tokenizer_config.json"
164 + if tc_p.exists():
165 + try:
166 + t = json.loads(tc_p.read_text()).get("chat_template") or ""
167 + st.template_text = t if isinstance(t, str) else json.dumps(t)
168 + except Exception:
169 + pass
170 + st.harmony = "<|channel|>" in st.template_text
171 + st.thinks = st.harmony or "<think>" in st.template_text or "enable_thinking" in st.template_text
172 + if st.vision:
173 + try:
174 + import mlx_vlm
175 + from mlx_vlm.utils import load_config
176 + st.model, st.processor = mlx_vlm.load(path)
177 + st.config = load_config(path)
178 + tok = getattr(st.processor, "tokenizer", st.processor)
179 + st.tokenizer = tok
180 + log.info("loaded with mlx_vlm")
181 + except Exception as e: # fall back to text-only
182 + log.warning("mlx_vlm load failed (%s); falling back to mlx_lm text-only", e)
183 + st.vision = False
184 + if not st.vision:
185 + from mlx_lm import load
186 + st.model, st.tokenizer = load(path)
187 + # Touch the weights so lazily-loaded parameters are materialized
188 + try:
189 + from mlx.utils import tree_flatten
190 + params = tree_flatten(st.model.parameters())
191 + mx.eval([p for _, p in params])
192 + except Exception:
193 + pass
194 + st.load_ms = (time.time() - t0) * 1000
195 + st.loaded_at = time.time()
196 + st.status = "ready"
197 + log.info("model ready in %.0f ms (%s)", st.load_ms, memory_info())
198 + except Exception as e:
199 + st.status = "error"
200 + st.error = f"{type(e).__name__}: {e}"
201 + log.exception("model load failed")
202 +
203 +
204 +# ---------------------------------------------------------------------------
205 +# Prompt building
206 +# ---------------------------------------------------------------------------
207 +
208 +
209 +def _apply_template(st: WorkerState, messages: list[dict], tools: list | None, kwargs: dict) -> list[int]:
210 + tok = st.tokenizer
211 + if st.vision and st.processor is not None:
212 + # handled by vision path
213 + raise RuntimeError("use vision path")
214 + has_tpl = getattr(tok, "has_chat_template", True)
215 + if has_tpl:
216 + try:
217 + out = tok.apply_chat_template(messages, tools=tools, add_generation_prompt=True, tokenize=True, **kwargs)
218 + except TypeError:
219 + out = tok.apply_chat_template(messages, add_generation_prompt=True, tokenize=True)
220 + if isinstance(out, dict):
221 + out = out.get("input_ids")
222 + if hasattr(out, "tolist"):
223 + out = out.tolist()
224 + if out and isinstance(out[0], list):
225 + out = out[0]
226 + return list(out)
227 + # No chat template: simple fallback
228 + text = ""
229 + for m in messages:
230 + text += f"{m.get('role','user').capitalize()}: {m.get('content','')}\n"
231 + text += "Assistant:"
232 + return list(tok.encode(text))
233 +
234 +
235 +def _sampler_and_processors(body: dict, st: WorkerState):
236 + from mlx_lm.sample_utils import make_logits_processors, make_sampler
237 + gc = st.gen_config or {}
238 + temp = body.get("temperature")
239 + if temp is None:
240 + temp = gc.get("temperature", 0.7)
241 + top_p = body.get("top_p")
242 + if top_p is None:
243 + top_p = gc.get("top_p", 0.95)
244 + top_k = body.get("top_k")
245 + if top_k is None:
246 + top_k = gc.get("top_k", 0) or 0
247 + min_p = body.get("min_p", 0.0) or 0.0
248 + sampler = make_sampler(temp=float(temp), top_p=float(top_p) if top_p and top_p < 1.0 else 0.0,
249 + min_p=float(min_p), top_k=int(top_k))
250 + logit_bias = body.get("logit_bias")
251 + lb = None
252 + if isinstance(logit_bias, dict) and logit_bias:
253 + lb = {}
254 + for k, v in logit_bias.items():
255 + try:
256 + lb[int(k)] = float(v)
257 + except (TypeError, ValueError):
258 + pass
259 + rep = body.get("repetition_penalty")
260 + pres = body.get("presence_penalty") or None
261 + freq = body.get("frequency_penalty") or None
262 + procs = make_logits_processors(logit_bias=lb, repetition_penalty=float(rep) if rep else None,
263 + presence_penalty=float(pres) if pres else None,
264 + frequency_penalty=float(freq) if freq else None)
265 + return sampler, procs
266 +
267 +
268 +def _max_tokens(body: dict, prompt_len: int, st: WorkerState) -> int:
269 + mt = body.get("max_completion_tokens") or body.get("max_tokens")
270 + if mt is None:
271 + mt = int(st.args.default_max_tokens)
272 + ctx = st.max_context
273 + room = ctx - prompt_len
274 + if room < 16:
275 + raise ValueError(f"prompt has {prompt_len} tokens; the loaded context window is {ctx} tokens.")
276 + return max(1, min(int(mt), room))
277 +
278 +
279 +def _prepare_cache(st: WorkerState, prompt: list[int]):
280 + """Reuse KV cache for the common prefix of the previous conversation."""
281 + from mlx_lm.models.cache import can_trim_prompt_cache, make_prompt_cache, trim_prompt_cache
282 + if st.cache_obj is not None and st.cache_tokens and can_trim_prompt_cache(st.cache_obj):
283 + n = 0
284 + for a, b in zip(st.cache_tokens, prompt):
285 + if a != b:
286 + break
287 + n += 1
288 + # always leave at least one token to process
289 + n = min(n, len(prompt) - 1)
290 + if n > 0:
291 + to_trim = len(st.cache_tokens) - n
292 + if to_trim > 0:
293 + trim_prompt_cache(st.cache_obj, to_trim)
294 + st.cache_tokens = prompt[:n]
295 + return st.cache_obj, prompt[n:], n
296 + st.cache_obj = make_prompt_cache(st.model)
297 + st.cache_tokens = []
298 + return st.cache_obj, prompt, 0
299 +
300 +
301 +# ---------------------------------------------------------------------------
302 +# Generation (runs in a thread; yields events into a queue)
303 +# ---------------------------------------------------------------------------
304 +
305 +
306 +def _generate_thread(st: WorkerState, prompt: list[int], body: dict, chat: bool, out: "queue.Queue[dict]",
307 + cancel: threading.Event) -> None:
308 + mx = _mx()
309 + try:
310 + from mlx_lm import stream_generate
311 + if body.get("seed") is not None:
312 + mx.random.seed(int(body["seed"]))
313 + sampler, procs = _sampler_and_processors(body, st)
314 + max_tokens = _max_tokens(body, len(prompt), st)
315 + cache, rest, cached = _prepare_cache(st, prompt)
316 + t0 = time.time()
317 + first = None
318 + n_gen = 0
319 + gen_tokens: list[int] = []
320 + prompt_tps = 0.0
321 + gen_tps = 0.0
322 + peak = 0.0
323 + finish = "length"
324 + eos_ids = getattr(st.tokenizer, "eos_token_ids", None) or set()
325 + kwargs: dict[str, Any] = {}
326 + if st.args.kv_bits:
327 + kwargs["kv_bits"] = int(st.args.kv_bits)
328 + kwargs["quantized_kv_start"] = 4096
329 + for g in stream_generate(st.model, st.tokenizer, rest, max_tokens=max_tokens, sampler=sampler,
330 + logits_processors=procs, prompt_cache=cache, prefill_step_size=2048, **kwargs):
331 + if first is None:
332 + first = time.time()
333 + n_gen += 1
334 + gen_tokens.append(g.token)
335 + prompt_tps = g.prompt_tps
336 + gen_tps = g.generation_tps
337 + peak = g.peak_memory
338 + if g.finish_reason:
339 + finish = g.finish_reason
340 + out.put({"text": g.text, "token": g.token})
341 + if cancel.is_set():
342 + finish = "cancelled"
343 + break
344 + if g.finish_reason:
345 + break
346 + t1 = time.time()
347 + # Remember exactly the tokens the KV cache holds (the last sampled token is never fed back)
348 + all_tokens = list(prompt) + gen_tokens
349 + try:
350 + off = int(cache[0].offset)
351 + except Exception:
352 + off = len(all_tokens) - 1
353 + st.cache_tokens = all_tokens[:max(0, min(off, len(all_tokens)))]
354 + st.tokens_generated += n_gen
355 + out.put({
356 + "done": True, "finish_reason": finish, "prompt_tokens": len(prompt), "completion_tokens": n_gen,
357 + "cached_tokens": cached,
358 + "timings": {
359 + "ttft_ms": round(((first or t1) - t0) * 1000, 1),
360 + "prompt_ms": round(((first or t1) - t0) * 1000, 1),
361 + "generation_ms": round((t1 - (first or t1)) * 1000, 1),
362 + "total_ms": round((t1 - t0) * 1000, 1),
363 + "prompt_tps": round(prompt_tps, 1), "generation_tps": round(gen_tps, 2),
364 + "peak_memory_gb": round(peak, 3),
365 + },
366 + })
367 + except Exception as e:
368 + log.exception("generation failed")
369 + # A failed generation may leave the cache inconsistent
370 + st.cache_obj = None
371 + st.cache_tokens = []
372 + out.put({"error": f"{type(e).__name__}: {e}"})
373 + finally:
374 + try:
375 + mx.clear_cache()
376 + except Exception:
377 + pass
378 +
379 +
380 +def _vision_generate_thread(st: WorkerState, messages: list[dict], images: list[dict], body: dict, tk: dict,
381 + tools: list | None, out: "queue.Queue[dict]", cancel: threading.Event) -> None:
382 + mx = _mx()
383 + tmpfiles: list[str] = []
384 + try:
385 + from mlx_vlm import stream_generate as vlm_stream
386 + from mlx_vlm.prompt_utils import apply_chat_template
387 + paths: list[str] = []
388 + for part in images:
389 + url = part.get("image_url", {}).get("url") if isinstance(part.get("image_url"), dict) else part.get("image_url") or part.get("url")
390 + if not url:
391 + continue
392 + if url.startswith("data:"):
393 + header, b64 = url.split(",", 1)
394 + ext = ".png" if "png" in header else ".jpg"
395 + fd, p = tempfile.mkstemp(suffix=ext)
396 + with os.fdopen(fd, "wb") as f:
397 + f.write(base64.b64decode(b64))
398 + tmpfiles.append(p)
399 + paths.append(p)
400 + else:
401 + paths.append(url)
402 + tpl_kwargs = dict(tk or {})
403 + if tools:
404 + tpl_kwargs["tools"] = tools
405 + try:
406 + prompt = apply_chat_template(st.processor, st.config, messages, num_images=len(paths), **tpl_kwargs)
407 + except TypeError:
408 + prompt = apply_chat_template(st.processor, st.config, messages, num_images=len(paths))
409 + if body.get("seed") is not None:
410 + mx.random.seed(int(body["seed"]))
411 + mt = body.get("max_completion_tokens") or body.get("max_tokens") or int(st.args.default_max_tokens)
412 + gc = st.gen_config or {}
413 + temp = body.get("temperature")
414 + if temp is None:
415 + temp = gc.get("temperature", 0.7)
416 + top_p = body.get("top_p")
417 + if top_p is None:
418 + top_p = gc.get("top_p", 0.95)
419 + gen_kwargs: dict[str, Any] = {"max_tokens": int(mt), "temperature": float(temp), "top_p": float(top_p)}
420 + top_k = body.get("top_k", gc.get("top_k"))
421 + if top_k:
422 + gen_kwargs["top_k"] = int(top_k)
423 + if body.get("min_p"):
424 + gen_kwargs["min_p"] = float(body["min_p"])
425 + for k in ("repetition_penalty", "presence_penalty", "frequency_penalty"):
426 + if body.get(k):
427 + gen_kwargs[k] = float(body[k])
428 + if isinstance(body.get("logit_bias"), dict) and body["logit_bias"]:
429 + try:
430 + gen_kwargs["logit_bias"] = {int(k): float(v) for k, v in body["logit_bias"].items()}
431 + except (TypeError, ValueError):
432 + pass
433 + if st.args.kv_bits:
434 + gen_kwargs["kv_bits"] = int(st.args.kv_bits)
435 + t0 = time.time()
436 + first = None
437 + n = 0
438 + ptoks = 0
439 + ptps = gtps = peak = 0.0
440 + finish = "length"
441 + for g in vlm_stream(st.model, st.processor, prompt, image=paths or None, **gen_kwargs):
442 + if first is None:
443 + first = time.time()
444 + n += 1
445 + ptoks = getattr(g, "prompt_tokens", ptoks)
446 + ptps = getattr(g, "prompt_tps", ptps)
447 + gtps = getattr(g, "generation_tps", gtps)
448 + peak = getattr(g, "peak_memory", peak)
449 + fr = getattr(g, "finish_reason", None)
450 + if fr:
451 + finish = fr
452 + out.put({"text": g.text, "token": getattr(g, "token", 0)})
453 + if cancel.is_set():
454 + finish = "cancelled"
455 + break
456 + if fr:
457 + break
458 + t1 = time.time()
459 + st.tokens_generated += n
460 + out.put({"done": True, "finish_reason": finish, "prompt_tokens": int(ptoks), "completion_tokens": n,
461 + "cached_tokens": 0,
462 + "timings": {"ttft_ms": round(((first or t1) - t0) * 1000, 1), "total_ms": round((t1 - t0) * 1000, 1),
463 + "prompt_tps": round(ptps, 1), "generation_tps": round(gtps, 2),
464 + "peak_memory_gb": round(peak, 3)}})
465 + except Exception as e:
466 + log.exception("vision generation failed")
467 + out.put({"error": f"{type(e).__name__}: {e}"})
468 + finally:
469 + for p in tmpfiles:
470 + try:
471 + os.unlink(p)
472 + except OSError:
473 + pass
474 + try:
475 + mx.clear_cache()
476 + except Exception:
477 + pass
478 +
479 +
480 +class _Task:
481 + """Thread-like wrapper around a Future running on the MLX thread."""
482 +
483 + def __init__(self, fut):
484 + self.fut = fut
485 +
486 + def is_alive(self) -> bool:
487 + return not self.fut.done()
488 +
489 + def join(self, timeout: float | None = None) -> None:
490 + try:
491 + self.fut.result(timeout=timeout)
492 + except Exception:
493 + pass
494 +
495 +
496 +async def _run_generation(st: WorkerState, target, *args) -> tuple["queue.Queue[dict]", threading.Event, _Task]:
497 + out: queue.Queue[dict] = queue.Queue()
498 + cancel = threading.Event()
499 + fut = MLX_THREAD.submit(target, st, *args, out, cancel)
500 + return out, cancel, _Task(fut)
501 +
502 +
503 +async def _next_event(q: "queue.Queue[dict]", th: "_Task | None" = None) -> dict:
504 + """Next event from the generation thread; detects a dead thread instead of waiting forever."""
505 + loop = asyncio.get_running_loop()
506 +
507 + def _get():
508 + while True:
509 + try:
510 + return q.get(timeout=1.0)
511 + except queue.Empty:
512 + if th is not None and not th.is_alive():
513 + try:
514 + return q.get_nowait()
515 + except queue.Empty:
516 + return {"error": "generation thread exited unexpectedly"}
517 +
518 + return await loop.run_in_executor(None, _get)
519 +
520 +
521 +# ---------------------------------------------------------------------------
522 +# Endpoints
523 +# ---------------------------------------------------------------------------
524 +
525 +
526 +def _err(status: int, message: str, code: str = "WORKER_ERROR", etype: str = "runtime_error") -> JSONResponse:
527 + return JSONResponse(status_code=status, content={"error": {"message": message, "type": etype, "code": code}})
528 +
529 +
530 +@app.get("/health")
531 +async def health():
532 + st = STATE
533 + assert st
534 + return {
535 + "status": st.status, "error": st.error, "model_id": st.args.model_id, "runtime": "mlx",
536 + "vision": st.vision, "task": st.task, "elapsed_seconds": round(time.time() - st.started, 1),
537 + "load_ms": st.load_ms, "memory": memory_info(), "requests": st.requests, "tokens_generated": st.tokens_generated,
538 + "max_context": st.max_context, "pid": os.getpid(), "busy": st.gen_lock.locked(),
539 + "warm_ttft_ms": st.warm_ttft_ms, "mlx_thread_alive": MLX_THREAD.alive(),
540 + }
541 +
542 +
543 +@app.post("/clear-cache")
544 +async def clear_cache():
545 + st = STATE
546 + assert st
547 + st.cache_obj = None
548 + st.cache_tokens = []
549 + try:
550 + _mx().clear_cache()
551 + except Exception:
552 + pass
553 + return memory_info()
554 +
555 +
556 +@app.post("/shutdown")
557 +async def shutdown():
558 + async def _exit():
559 + await asyncio.sleep(0.2)
560 + os._exit(0)
561 + asyncio.create_task(_exit())
562 + return {"ok": True}
563 +
564 +
565 +@app.post("/tokenize")
566 +async def tokenize(req: Request):
567 + st = STATE
568 + assert st
569 + body = await req.json()
570 + if st.status != "ready":
571 + return _err(503, "model not ready", "MODEL_NOT_READY")
572 + if "messages" in body:
573 + msgs, _ = normalize_messages(body["messages"])
574 + try:
575 + toks = _apply_template(st, msgs, body.get("tools"), body.get("chat_template_kwargs") or {})
576 + except RuntimeError:
577 + toks = list(st.tokenizer.encode(" ".join(m.get("content", "") for m in msgs)))
578 + else:
579 + toks = list(st.tokenizer.encode(body.get("text", "") or body.get("prompt", "")))
580 + return {"tokens": len(toks), "max_context": st.max_context}
581 +
582 +
583 +@app.post("/warmup")
584 +async def warmup():
585 + st = STATE
586 + assert st
587 + if st.status != "ready":
588 + return _err(503, "model not ready", "MODEL_NOT_READY")
589 + t0 = time.time()
590 + if st.task in ("embedding",):
591 + r = await embeddings_impl({"input": "warm up"})
592 + st.warm_ttft_ms = round((time.time() - t0) * 1000, 1)
593 + return {"ok": True, "ttft_ms": st.warm_ttft_ms, "kind": "embedding", "dims": len(r["data"][0]["embedding"])}
594 + if st.task == "reranking":
595 + r = await rerank_impl({"query": "warm", "documents": ["warm up"]})
596 + st.warm_ttft_ms = round((time.time() - t0) * 1000, 1)
597 + return {"ok": True, "ttft_ms": st.warm_ttft_ms, "kind": "rerank"}
598 + body = {"messages": [{"role": "user", "content": "Say OK."}], "max_tokens": 4, "temperature": 0.0}
599 + resp = await chat_impl(body, stream=False)
600 + if isinstance(resp, JSONResponse):
601 + return resp
602 + st.warm_ttft_ms = resp.get("timings", {}).get("ttft_ms")
603 + # do not keep the warm-up prompt in the cache
604 + st.cache_obj = None
605 + st.cache_tokens = []
606 + return {"ok": True, "ttft_ms": st.warm_ttft_ms, "text": resp["choices"][0]["message"]["content"]}
607 +
608 +
609 +async def chat_impl(body: dict, stream: bool):
610 + st = STATE
611 + assert st
612 + if st.status != "ready":
613 + return _err(503, f"model not ready ({st.status})", "MODEL_NOT_READY")
614 + messages = body.get("messages")
615 + if not isinstance(messages, list) or not messages:
616 + return _err(400, "messages is required", "INVALID_REQUEST", "invalid_request_error")
617 + msgs, images = normalize_messages(messages)
618 + tools = body.get("tools") or None
619 + tk = dict(body.get("chat_template_kwargs") or {})
620 + # reasoning controls (Qwen3-style enable_thinking)
621 + reasoning = body.get("reasoning")
622 + if isinstance(reasoning, dict) and "effort" in reasoning:
623 + tk.setdefault("enable_thinking", reasoning["effort"] not in ("none", "minimal"))
624 + if body.get("enable_thinking") is not None:
625 + tk["enable_thinking"] = bool(body["enable_thinking"])
626 + if body.get("reasoning_effort") is not None:
627 + tk.setdefault("enable_thinking", body["reasoning_effort"] not in ("none", "minimal"))
628 + stops = body.get("stop") or []
629 + if isinstance(stops, str):
630 + stops = [stops]
631 + rid = new_id("chatcmpl")
632 + created = int(time.time())
633 + model_name = body.get("model") or st.args.model_id
634 +
635 + if not st.gen_lock.acquire(timeout=float(st.args.queue_timeout)):
636 + return _err(503, "worker busy", "WORKER_BUSY")
637 + st.requests += 1
638 + st.last_used = time.time()
639 + try:
640 + if st.vision and st.processor is not None:
641 + # mlx-vlm handles both text-only and image requests for vision-language models
642 + q, cancel, th = await _run_generation(st, _vision_generate_thread, msgs, images, body, tk, tools)
643 + think_start, think_end = "<think>", "</think>"
644 + thinking_enabled = st.thinks and tk.get("enable_thinking", True)
645 + else:
646 + if images:
647 + return _err(400, "This model does not accept images.", "VISION_UNSUPPORTED", "invalid_request_error")
648 + try:
649 + prompt = _apply_template(st, msgs, tools, tk)
650 + except Exception as e:
651 + return _err(400, f"chat template error: {e}", "TEMPLATE_ERROR", "invalid_request_error")
652 + if len(prompt) >= st.max_context - 16:
653 + return _err(400, f"Prompt has {len(prompt)} tokens but the context window is {st.max_context}.",
654 + "CONTEXT_TOO_LARGE", "invalid_request_error")
655 + q, cancel, th = await _run_generation(st, _generate_thread, prompt, body, True)
656 + think_start = getattr(st.tokenizer, "think_start", None) or "<think>"
657 + think_end = getattr(st.tokenizer, "think_end", None) or "</think>"
658 + thinking_enabled = (bool(getattr(st.tokenizer, "has_thinking", False)) or st.thinks) and tk.get("enable_thinking", True)
659 + markers: list[str] = []
660 + if st.harmony:
661 + think_start, think_end = HARMONY_THINK_START, HARMONY_THINK_END
662 + thinking_enabled = True
663 + markers = HARMONY_MARKERS
664 + splitter = ThinkSplitter(think_start, think_end) if thinking_enabled else ThinkSplitter(None, None)
665 + stripper = MarkerStripper(markers)
666 + stopper = StopMatcher(list(stops))
667 + has_tools = bool(tools)
668 +
669 + if stream:
670 + async def gen():
671 + try:
672 + yield sse(chat_chunk(rid, model_name, created, {"role": "assistant", "content": ""}))
673 + content_acc = ""
674 + reasoning_acc = ""
675 + finish = "stop"
676 + final: dict = {}
677 + while True:
678 + ev = await _next_event(q, th)
679 + if "error" in ev:
680 + yield sse({"error": {"message": ev["error"], "type": "runtime_error", "code": "GENERATION_FAILED"}})
681 + yield sse("[DONE]")
682 + return
683 + if ev.get("done"):
684 + final = ev
685 + finish = ev["finish_reason"]
686 + break
687 + r, c = splitter.feed(ev["text"])
688 + if r:
689 + reasoning_acc += r
690 + yield sse(chat_chunk(rid, model_name, created, {"reasoning_content": r}))
691 + c = stripper.feed(c) if c else c
692 + if c:
693 + c = stopper.feed(c)
694 + if c and not has_tools:
695 + content_acc += c
696 + yield sse(chat_chunk(rid, model_name, created, {"content": c}))
697 + elif c:
698 + content_acc += c
699 + if stopper.done:
700 + cancel.set()
701 + finish = "stop"
702 + # drain
703 + while True:
704 + ev2 = await _next_event(q, th)
705 + if ev2.get("done") or "error" in ev2:
706 + final = ev2 if ev2.get("done") else {}
707 + break
708 + break
709 + r, c = splitter.flush()
710 + c = (stripper.feed(c) + stripper.flush()) if not stopper.done else ""
711 + c = (stopper.flush() + c) if not stopper.done else ""
712 + if r:
713 + yield sse(chat_chunk(rid, model_name, created, {"reasoning_content": r}))
714 + tool_calls: list[dict] = []
715 + if has_tools:
716 + rest, tool_calls = parse_tool_calls(content_acc + c)
717 + if tool_calls:
718 + finish = "tool_calls"
719 + yield sse(chat_chunk(rid, model_name, created, {"tool_calls": [
720 + {"index": i, **tc} for i, tc in enumerate(tool_calls)]}))
721 + elif content_acc + c:
722 + yield sse(chat_chunk(rid, model_name, created, {"content": content_acc + c}))
723 + elif c:
724 + yield sse(chat_chunk(rid, model_name, created, {"content": c}))
725 + if finish == "cancelled":
726 + finish = "stop"
727 + if finish not in ("stop", "length", "tool_calls"):
728 + finish = "stop"
729 + usage = {"prompt_tokens": final.get("prompt_tokens", 0), "completion_tokens": final.get("completion_tokens", 0),
730 + "total_tokens": final.get("prompt_tokens", 0) + final.get("completion_tokens", 0)}
731 + if final.get("cached_tokens"):
732 + usage["prompt_tokens_details"] = {"cached_tokens": final["cached_tokens"]}
733 + yield sse(chat_chunk(rid, model_name, created, {}, finish, usage, {"timings": final.get("timings", {})}))
734 + yield sse("[DONE]")
735 + finally:
736 + cancel.set()
737 + th.join(timeout=float(st.args.generation_timeout))
738 + st.gen_lock.release()
739 + return StreamingResponse(gen(), media_type="text/event-stream",
740 + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
741 +
742 + # non-streaming
743 + try:
744 + content_acc = ""
745 + reasoning_acc = ""
746 + final = {}
747 + finish = "stop"
748 + while True:
749 + ev = await _next_event(q, th)
750 + if "error" in ev:
751 + return _err(500, ev["error"], "GENERATION_FAILED")
752 + if ev.get("done"):
753 + final = ev
754 + finish = ev["finish_reason"]
755 + break
756 + r, c = splitter.feed(ev["text"])
757 + reasoning_acc += r
758 + c = stripper.feed(c) if c else c
759 + if c:
760 + c = stopper.feed(c)
761 + content_acc += c
762 + if stopper.done:
763 + cancel.set()
764 + finish = "stop"
765 + while True:
766 + ev2 = await _next_event(q, th)
767 + if ev2.get("done") or "error" in ev2:
768 + final = ev2 if ev2.get("done") else {}
769 + break
770 + break
771 + r, c = splitter.flush()
772 + reasoning_acc += r
773 + if not stopper.done:
774 + content_acc += stopper.flush() + stripper.feed(c) + stripper.flush()
775 + content_acc = content_acc.strip("\n") if reasoning_acc else content_acc
776 + tool_calls: list[dict] = []
777 + if has_tools:
778 + content_acc, tool_calls = parse_tool_calls(content_acc)
779 + if tool_calls:
780 + finish = "tool_calls"
781 + if finish not in ("stop", "length", "tool_calls"):
782 + finish = "stop"
783 + msg: dict[str, Any] = {"role": "assistant", "content": content_acc if not tool_calls else (content_acc or None)}
784 + if reasoning_acc.strip():
785 + msg["reasoning_content"] = reasoning_acc.strip()
786 + if tool_calls:
787 + msg["tool_calls"] = tool_calls
788 + usage = {"prompt_tokens": final.get("prompt_tokens", 0), "completion_tokens": final.get("completion_tokens", 0),
789 + "total_tokens": final.get("prompt_tokens", 0) + final.get("completion_tokens", 0)}
790 + if final.get("cached_tokens"):
791 + usage["prompt_tokens_details"] = {"cached_tokens": final["cached_tokens"]}
792 + return {"id": rid, "object": "chat.completion", "created": created, "model": model_name,
793 + "choices": [{"index": 0, "message": msg, "logprobs": None, "finish_reason": finish}],
794 + "usage": usage, "timings": final.get("timings", {})}
795 + finally:
796 + cancel.set()
797 + th.join(timeout=float(st.args.generation_timeout))
798 + st.gen_lock.release()
799 + except Exception:
800 + if st.gen_lock.locked():
801 + try:
802 + st.gen_lock.release()
803 + except RuntimeError:
804 + pass
805 + raise
806 +
807 +
808 +@app.post("/v1/chat/completions")
809 +async def chat_completions(req: Request):
810 + body = await req.json()
811 + return await chat_impl(body, bool(body.get("stream")))
812 +
813 +
814 +@app.post("/v1/completions")
815 +async def completions(req: Request):
816 + st = STATE
817 + assert st
818 + body = await req.json()
819 + if st.status != "ready":
820 + return _err(503, f"model not ready ({st.status})", "MODEL_NOT_READY")
821 + prompt = body.get("prompt", "")
822 + if isinstance(prompt, list):
823 + prompt = prompt[0] if prompt and isinstance(prompt[0], str) else ""
824 + stream = bool(body.get("stream"))
825 + rid = new_id("cmpl")
826 + created = int(time.time())
827 + model_name = body.get("model") or st.args.model_id
828 + toks = list(st.tokenizer.encode(prompt))
829 + if len(toks) >= st.max_context - 16:
830 + return _err(400, f"Prompt has {len(toks)} tokens but the context window is {st.max_context}.",
831 + "CONTEXT_TOO_LARGE", "invalid_request_error")
832 + stops = body.get("stop") or []
833 + if isinstance(stops, str):
834 + stops = [stops]
835 + if not st.gen_lock.acquire(timeout=float(st.args.queue_timeout)):
836 + return _err(503, "worker busy", "WORKER_BUSY")
837 + st.requests += 1
838 + st.last_used = time.time()
839 + # completions never reuse the chat cache
840 + st.cache_obj = None
841 + st.cache_tokens = []
842 + q, cancel, th = await _run_generation(st, _generate_thread, toks, body, False)
843 + stopper = StopMatcher(list(stops))
844 + echo = bool(body.get("echo"))
845 +
846 + def chunk(text: str, finish=None, usage=None, extra=None):
847 + d: dict[str, Any] = {"id": rid, "object": "text_completion", "created": created, "model": model_name,
848 + "choices": [{"index": 0, "text": text, "logprobs": None, "finish_reason": finish}]}
849 + if usage:
850 + d["usage"] = usage
851 + if extra:
852 + d.update(extra)
853 + return d
854 +
855 + if stream:
856 + async def gen():
857 + try:
858 + if echo:
859 + yield sse(chunk(prompt))
860 + finish = "stop"
861 + final: dict = {}
862 + while True:
863 + ev = await _next_event(q, th)
864 + if "error" in ev:
865 + yield sse({"error": {"message": ev["error"], "type": "runtime_error", "code": "GENERATION_FAILED"}})
866 + break
867 + if ev.get("done"):
868 + final, finish = ev, ev["finish_reason"]
869 + break
870 + c = stopper.feed(ev["text"])
871 + if c:
872 + yield sse(chunk(c))
873 + if stopper.done:
874 + cancel.set()
875 + finish = "stop"
876 + while True:
877 + ev2 = await _next_event(q, th)
878 + if ev2.get("done") or "error" in ev2:
879 + final = ev2 if ev2.get("done") else {}
880 + break
881 + break
882 + tail = stopper.flush() if not stopper.done else ""
883 + if tail:
884 + yield sse(chunk(tail))
885 + usage = {"prompt_tokens": final.get("prompt_tokens", 0), "completion_tokens": final.get("completion_tokens", 0),
886 + "total_tokens": final.get("prompt_tokens", 0) + final.get("completion_tokens", 0)}
887 + yield sse(chunk("", finish if finish in ("stop", "length") else "stop", usage, {"timings": final.get("timings", {})}))
888 + yield sse("[DONE]")
889 + finally:
890 + cancel.set()
891 + th.join(timeout=float(st.args.generation_timeout))
892 + st.cache_obj = None
893 + st.cache_tokens = []
894 + st.gen_lock.release()
895 + return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"})
896 + try:
897 + text = ""
898 + final = {}
899 + finish = "stop"
900 + while True:
901 + ev = await _next_event(q, th)
902 + if "error" in ev:
903 + return _err(500, ev["error"], "GENERATION_FAILED")
904 + if ev.get("done"):
905 + final, finish = ev, ev["finish_reason"]
906 + break
907 + text += stopper.feed(ev["text"])
908 + if stopper.done:
909 + cancel.set()
910 + finish = "stop"
911 + while True:
912 + ev2 = await _next_event(q, th)
913 + if ev2.get("done") or "error" in ev2:
914 + final = ev2 if ev2.get("done") else {}
915 + break
916 + break
917 + if not stopper.done:
918 + text += stopper.flush()
919 + usage = {"prompt_tokens": final.get("prompt_tokens", 0), "completion_tokens": final.get("completion_tokens", 0),
920 + "total_tokens": final.get("prompt_tokens", 0) + final.get("completion_tokens", 0)}
921 + return chunk((prompt if echo else "") + text, finish if finish in ("stop", "length") else "stop", usage,
922 + {"timings": final.get("timings", {})})
923 + finally:
924 + cancel.set()
925 + th.join(timeout=float(st.args.generation_timeout))
926 + st.cache_obj = None
927 + st.cache_tokens = []
928 + st.gen_lock.release()
929 +
930 +
931 +# ---------------------------------------------------------------------------
932 +# Embeddings / rerank (causal-LM style: last-token pooling, Qwen3-Embedding & co.)
933 +# ---------------------------------------------------------------------------
934 +
935 +
936 +def _hidden_states(st: WorkerState, tokens: list[int]):
937 + mx = _mx()
938 + inner = getattr(st.model, "model", None) or getattr(st.model, "language_model", None)
939 + x = mx.array([tokens])
940 + if inner is not None and callable(inner):
941 + h = inner(x)
942 + else:
943 + h = st.model(x)
944 + mx.eval(h)
945 + return h
946 +
947 +
948 +def _embed_one(st: WorkerState, text: str, dims: int | None) -> tuple[list[float], int]:
949 + mx = _mx()
950 + tok = st.tokenizer
951 + ids = list(tok.encode(text))
952 + eos = getattr(tok, "eos_token_id", None)
953 + if eos is None:
954 + eos_ids = getattr(tok, "eos_token_ids", None) or set()
955 + eos = next(iter(eos_ids), None)
956 + if eos is not None and (not ids or ids[-1] != eos):
957 + ids.append(eos)
958 + ids = ids[: st.max_context]
959 + h = _hidden_states(st, ids)
960 + v = h[0, -1, :].astype(mx.float32)
961 + if dims and dims < v.shape[0]:
962 + v = v[:dims]
963 + norm = mx.sqrt(mx.sum(v * v)) + 1e-12
964 + v = v / norm
965 + mx.eval(v)
966 + return v.tolist(), len(ids)
967 +
968 +
969 +async def embeddings_impl(body: dict):
970 + st = STATE
971 + assert st
972 + if st.status != "ready":
973 + return _err(503, f"model not ready ({st.status})", "MODEL_NOT_READY")
974 + inp = body.get("input")
975 + if isinstance(inp, str):
976 + inputs = [inp]
977 + elif isinstance(inp, list):
978 + if inp and isinstance(inp[0], list): # token ids
979 + inputs = [st.tokenizer.decode(x) for x in inp]
980 + else:
981 + inputs = [str(x) for x in inp]
982 + else:
983 + return _err(400, "input must be a string or a list of strings", "INVALID_REQUEST", "invalid_request_error")
984 + dims = body.get("dimensions")
985 + enc = body.get("encoding_format", "float")
986 + if not st.gen_lock.acquire(timeout=float(st.args.queue_timeout)):
987 + return _err(503, "worker busy", "WORKER_BUSY")
988 + st.requests += 1
989 + st.last_used = time.time()
990 + try:
991 + t0 = time.time()
992 + data = []
993 + total = 0
994 + for i, text in enumerate(inputs):
995 + vec, n = await asyncio.wrap_future(MLX_THREAD.submit(_embed_one, st, text, int(dims) if dims else None))
996 + total += n
997 + if enc == "base64":
998 + import struct
999 + b = struct.pack(f"<{len(vec)}f", *vec)
1000 + data.append({"object": "embedding", "index": i, "embedding": base64.b64encode(b).decode()})
1001 + else:
1002 + data.append({"object": "embedding", "index": i, "embedding": vec})
1003 + return {"object": "list", "data": data, "model": body.get("model") or st.args.model_id,
1004 + "usage": {"prompt_tokens": total, "total_tokens": total},
1005 + "timings": {"total_ms": round((time.time() - t0) * 1000, 1)}}
1006 + except Exception as e:
1007 + log.exception("embedding failed")
1008 + return _err(500, f"{type(e).__name__}: {e}", "EMBEDDING_FAILED")
1009 + finally:
1010 + try:
1011 + _mx().clear_cache()
1012 + except Exception:
1013 + pass
1014 + st.gen_lock.release()
1015 +
1016 +
1017 +@app.post("/v1/embeddings")
1018 +async def embeddings(req: Request):
1019 + return await embeddings_impl(await req.json())
1020 +
1021 +
1022 +def _rerank_score(st: WorkerState, query: str, doc: str, instruction: str | None) -> float:
1023 + """Qwen3-Reranker style: P(yes) vs P(no) at the final position."""
1024 + mx = _mx()
1025 + tok = st.tokenizer
1026 + instr = instruction or "Given a web search query, retrieve relevant passages that answer the query"
1027 + prefix = ("<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the "
1028 + "Instruct provided. Note that the answer can only be \"yes\" or \"no\".<|im_end|>\n<|im_start|>user\n")
1029 + suffix = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n"
1030 + text = f"{prefix}<Instruct>: {instr}\n<Query>: {query}\n<Document>: {doc}{suffix}"
1031 + ids = list(tok.encode(text))[: st.max_context]
1032 + logits = st.model(mx.array([ids]))
1033 + last = logits[0, -1, :].astype(mx.float32)
1034 + yes_id = tok.encode("yes")[-1]
1035 + no_id = tok.encode("no")[-1]
1036 + pair = mx.stack([last[no_id], last[yes_id]])
1037 + probs = mx.softmax(pair)
1038 + mx.eval(probs)
1039 + return float(probs[1].item())
1040 +
1041 +
1042 +async def rerank_impl(body: dict):
1043 + st = STATE
1044 + assert st
1045 + if st.status != "ready":
1046 + return _err(503, f"model not ready ({st.status})", "MODEL_NOT_READY")
1047 + query = body.get("query")
1048 + docs = body.get("documents") or []
1049 + if not query or not isinstance(docs, list):
1050 + return _err(400, "query and documents are required", "INVALID_REQUEST", "invalid_request_error")
1051 + texts = [d if isinstance(d, str) else (d.get("text") or json.dumps(d)) for d in docs]
1052 + top_n = body.get("top_n") or len(texts)
1053 + if not st.gen_lock.acquire(timeout=float(st.args.queue_timeout)):
1054 + return _err(503, "worker busy", "WORKER_BUSY")
1055 + st.requests += 1
1056 + st.last_used = time.time()
1057 + try:
1058 + t0 = time.time()
1059 + scores = []
1060 + for i, d in enumerate(texts):
1061 + s = await asyncio.wrap_future(MLX_THREAD.submit(_rerank_score, st, query, d, body.get("instruction")))
1062 + scores.append({"index": i, "relevance_score": s, **({"document": {"text": d}} if body.get("return_documents") else {})})
1063 + scores.sort(key=lambda x: -x["relevance_score"])
1064 + return {"object": "list", "model": body.get("model") or st.args.model_id, "results": scores[: int(top_n)],
1065 + "usage": {"total_tokens": 0}, "timings": {"total_ms": round((time.time() - t0) * 1000, 1)}}
1066 + except Exception as e:
1067 + log.exception("rerank failed")
1068 + return _err(500, f"{type(e).__name__}: {e}", "RERANK_FAILED")
1069 + finally:
1070 + try:
1071 + _mx().clear_cache()
1072 + except Exception:
1073 + pass
1074 + st.gen_lock.release()
1075 +
1076 +
1077 +@app.post("/v1/rerank")
1078 +async def rerank(req: Request):
1079 + return await rerank_impl(await req.json())
1080 +
1081 +
1082 +# ---------------------------------------------------------------------------
1083 +# Entrypoint
1084 +# ---------------------------------------------------------------------------
1085 +
1086 +
1087 +def main(argv: list[str] | None = None) -> None:
1088 + global STATE
1089 + ap = argparse.ArgumentParser()
1090 + ap.add_argument("--model-path", required=True)
1091 + ap.add_argument("--model-id", required=True)
1092 + ap.add_argument("--port", type=int, required=True)
1093 + ap.add_argument("--host", default="127.0.0.1")
1094 + ap.add_argument("--max-context", type=int, default=16384)
1095 + ap.add_argument("--default-max-tokens", type=int, default=2048)
1096 + ap.add_argument("--vision", action="store_true")
1097 + ap.add_argument("--task", default="text", choices=["text", "embedding", "reranking"])
1098 + ap.add_argument("--kv-bits", type=int, default=0)
1099 + ap.add_argument("--queue-timeout", type=float, default=600)
1100 + ap.add_argument("--generation-timeout", type=float, default=1800)
1101 + ap.add_argument("--memory-limit-gb", type=float, default=0)
1102 + args = ap.parse_args(argv)
1103 + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
1104 + STATE = WorkerState(args)
1105 + if args.memory_limit_gb:
1106 + try:
1107 + _mx().set_memory_limit(int(args.memory_limit_gb * GB))
1108 + except Exception:
1109 + pass
1110 + MLX_THREAD.submit(_load_model, STATE)
1111 + import uvicorn
1112 + uvicorn.run(app, host=args.host, port=args.port, log_level="warning", access_log=False,
1113 + timeout_keep_alive=75)
1114 +
1115 +
1116 +if __name__ == "__main__":
1117 + main()
added server/llm_api/worker/openai_types.py +239 −0
@@ -0,0 +1,239 @@
1 +"""Helpers shared by workers to produce OpenAI-compatible payloads."""
2 +
3 +from __future__ import annotations
4 +
5 +import json
6 +import re
7 +import time
8 +import uuid
9 +from typing import Any
10 +
11 +
12 +def new_id(prefix: str) -> str:
13 + return f"{prefix}-{uuid.uuid4().hex[:24]}"
14 +
15 +
16 +def flatten_content(content: Any) -> tuple[str, list[dict]]:
17 + """Return (text, image_parts) from an OpenAI message content (str or list of parts)."""
18 + if content is None:
19 + return "", []
20 + if isinstance(content, str):
21 + return content, []
22 + texts: list[str] = []
23 + images: list[dict] = []
24 + for part in content:
25 + if not isinstance(part, dict):
26 + continue
27 + t = part.get("type")
28 + if t == "text":
29 + texts.append(part.get("text", ""))
30 + elif t in ("image_url", "input_image", "image"):
31 + images.append(part)
32 + elif t == "input_text":
33 + texts.append(part.get("text", ""))
34 + return "".join(texts), images
35 +
36 +
37 +def normalize_messages(messages: list[dict]) -> tuple[list[dict], list[dict]]:
38 + """Flatten multimodal content to text; collect image parts. Keeps tool messages."""
39 + out: list[dict] = []
40 + images: list[dict] = []
41 + for m in messages:
42 + m2 = dict(m)
43 + text, imgs = flatten_content(m.get("content"))
44 + images.extend(imgs)
45 + m2["content"] = text
46 + if m2.get("tool_calls"):
47 + # Ensure arguments are strings for jinja templates that call tojson
48 + tcs = []
49 + for tc in m2["tool_calls"]:
50 + tc = dict(tc)
51 + fn = dict(tc.get("function") or {})
52 + args = fn.get("arguments")
53 + if isinstance(args, str):
54 + try:
55 + fn["arguments"] = json.loads(args)
56 + except json.JSONDecodeError:
57 + pass
58 + tc["function"] = fn
59 + tcs.append(tc)
60 + m2["tool_calls"] = tcs
61 + out.append(m2)
62 + return out, images
63 +
64 +
65 +def chat_chunk(rid: str, model: str, created: int, delta: dict, finish_reason: str | None = None,
66 + usage: dict | None = None, extra: dict | None = None, index: int = 0) -> dict:
67 + d: dict[str, Any] = {
68 + "id": rid, "object": "chat.completion.chunk", "created": created, "model": model,
69 + "choices": [{"index": index, "delta": delta, "logprobs": None, "finish_reason": finish_reason}],
70 + }
71 + if usage is not None:
72 + d["usage"] = usage
73 + if extra:
74 + d.update(extra)
75 + return d
76 +
77 +
78 +def sse(data: dict | str) -> bytes:
79 + if isinstance(data, str):
80 + return f"data: {data}\n\n".encode()
81 + return f"data: {json.dumps(data, ensure_ascii=False)}\n\n".encode()
82 +
83 +
84 +TOOL_CALL_RE = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.S)
85 +
86 +
87 +def parse_tool_calls(text: str) -> tuple[str, list[dict]]:
88 + """Extract <tool_call>{json}</tool_call> blocks (Qwen/Hermes style). Returns (remaining_text, tool_calls)."""
89 + calls: list[dict] = []
90 + for m in TOOL_CALL_RE.finditer(text):
91 + try:
92 + obj = json.loads(m.group(1))
93 + except json.JSONDecodeError:
94 + continue
95 + name = obj.get("name")
96 + args = obj.get("arguments", obj.get("parameters", {}))
97 + if not name:
98 + continue
99 + calls.append({
100 + "id": "call_" + uuid.uuid4().hex[:24], "type": "function",
101 + "function": {"name": name, "arguments": json.dumps(args, ensure_ascii=False) if not isinstance(args, str) else args},
102 + })
103 + rest = TOOL_CALL_RE.sub("", text).strip() if calls else text
104 + return rest, calls
105 +
106 +
107 +class StopMatcher:
108 + """Holds back text that could be the start of a stop sequence."""
109 +
110 + def __init__(self, stops: list[str]):
111 + self.stops = [s for s in stops if s]
112 + self.buf = ""
113 + self.done = False
114 +
115 + def feed(self, text: str) -> str:
116 + """Return text safe to emit. Sets self.done when a stop sequence was matched."""
117 + if not self.stops:
118 + return text
119 + self.buf += text
120 + for s in self.stops:
121 + i = self.buf.find(s)
122 + if i >= 0:
123 + out = self.buf[:i]
124 + self.buf = ""
125 + self.done = True
126 + return out
127 + # hold back the longest suffix that is a prefix of any stop
128 + hold = 0
129 + for s in self.stops:
130 + for k in range(min(len(s) - 1, len(self.buf)), 0, -1):
131 + if self.buf.endswith(s[:k]):
132 + hold = max(hold, k)
133 + break
134 + if hold:
135 + out, self.buf = self.buf[:-hold], self.buf[-hold:]
136 + else:
137 + out, self.buf = self.buf, ""
138 + return out
139 +
140 + def flush(self) -> str:
141 + out, self.buf = self.buf, ""
142 + return out
143 +
144 +
145 +class ThinkSplitter:
146 + """Split streamed text into (reasoning, content) using <think> markers."""
147 +
148 + def __init__(self, start: str | None, end: str | None, starts_in_think: bool = False):
149 + self.start = start
150 + self.end = end
151 + self.in_think = starts_in_think and bool(end)
152 + self.buf = ""
153 + self.enabled = bool(start and end)
154 +
155 + def feed(self, text: str) -> tuple[str, str]:
156 + if not self.enabled:
157 + return "", text
158 + self.buf += text
159 + reasoning, content = "", ""
160 + while self.buf:
161 + if self.in_think:
162 + i = self.buf.find(self.end) # type: ignore[arg-type]
163 + if i >= 0:
164 + reasoning += self.buf[:i]
165 + self.buf = self.buf[i + len(self.end):] # type: ignore[arg-type]
166 + self.in_think = False
167 + # Strip leading newlines after </think>
168 + self.buf = self.buf.lstrip("\n")
169 + continue
170 + hold = self._partial(self.end) # type: ignore[arg-type]
171 + reasoning += self.buf[: len(self.buf) - hold]
172 + self.buf = self.buf[len(self.buf) - hold:]
173 + break
174 + else:
175 + i = self.buf.find(self.start) # type: ignore[arg-type]
176 + if i >= 0:
177 + content += self.buf[:i]
178 + self.buf = self.buf[i + len(self.start):] # type: ignore[arg-type]
179 + self.in_think = True
180 + continue
181 + hold = self._partial(self.start) # type: ignore[arg-type]
182 + content += self.buf[: len(self.buf) - hold]
183 + self.buf = self.buf[len(self.buf) - hold:]
184 + break
185 + return reasoning, content
186 +
187 + def _partial(self, marker: str) -> int:
188 + for k in range(min(len(marker) - 1, len(self.buf)), 0, -1):
189 + if self.buf.endswith(marker[:k]):
190 + return k
191 + return 0
192 +
193 + def flush(self) -> tuple[str, str]:
194 + out, self.buf = self.buf, ""
195 + return (out, "") if self.in_think else ("", out)
196 +
197 +
198 +class MarkerStripper:
199 + """Remove control markers (e.g. Harmony '<|start|>assistant<|channel|>final<|message|>') from a stream,
200 + holding back text that could be the start of a marker."""
201 +
202 + def __init__(self, markers: list[str]):
203 + self.markers = [m for m in markers if m]
204 + self.buf = ""
205 +
206 + def feed(self, text: str) -> str:
207 + if not self.markers:
208 + return text
209 + self.buf += text
210 + changed = True
211 + while changed:
212 + changed = False
213 + for m in self.markers:
214 + if m in self.buf:
215 + self.buf = self.buf.replace(m, "")
216 + changed = True
217 + hold = 0
218 + for m in self.markers:
219 + for k in range(min(len(m) - 1, len(self.buf)), 0, -1):
220 + if self.buf.endswith(m[:k]):
221 + hold = max(hold, k)
222 + break
223 + if hold:
224 + out, self.buf = self.buf[:-hold], self.buf[-hold:]
225 + else:
226 + out, self.buf = self.buf, ""
227 + return out
228 +
229 + def flush(self) -> str:
230 + out, self.buf = self.buf, ""
231 + for m in self.markers:
232 + out = out.replace(m, "")
233 + return out
234 +
235 +
236 +HARMONY_THINK_START = "<|channel|>analysis<|message|>"
237 +HARMONY_THINK_END = "<|end|>"
238 +HARMONY_MARKERS = ["<|start|>assistant<|channel|>final<|message|>", "<|start|>assistant", "<|channel|>final<|message|>",
239 + "<|return|>", "<|call|>", "<|end|>", "<|message|>"]
added server/pyproject.toml +48 −0
@@ -0,0 +1,48 @@
1 +[project]
2 +name = "llm-api"
3 +version = "0.1.0"
4 +description = "LLM API — private OpenAI-compatible local model server for Apple Silicon"
5 +requires-python = ">=3.12"
6 +dependencies = [
7 + "fastapi>=0.115",
8 + "uvicorn[standard]>=0.30",
9 + "pydantic>=2.7",
10 + "pydantic-settings>=2.3",
11 + "httpx>=0.27",
12 + "aiosqlite>=0.20",
13 + "argon2-cffi>=23.1",
14 + "itsdangerous>=2.2",
15 + "psutil>=6.0",
16 + "huggingface-hub>=0.30",
17 + "safetensors>=0.4",
18 + "python-multipart>=0.0.9",
19 +]
20 +
21 +[project.optional-dependencies]
22 +mlx = [
23 + "mlx>=0.31",
24 + "mlx-lm>=0.31",
25 +]
26 +vision = [
27 + "mlx-vlm>=0.7",
28 +]
29 +test = [
30 + "pytest>=8",
31 + "pytest-asyncio>=0.24",
32 + "openai>=1.50",
33 +]
34 +
35 +[project.scripts]
36 +llm-api = "llm_api.cli:main"
37 +
38 +[build-system]
39 +requires = ["setuptools>=68"]
40 +build-backend = "setuptools.build_meta"
41 +
42 +[tool.setuptools.packages.find]
43 +where = ["."]
44 +include = ["llm_api*"]
45 +
46 +[tool.pytest.ini_options]
47 +asyncio_mode = "auto"
48 +testpaths = ["tests"]
added server/tests/__init__.py +0 −0
added server/tests/conftest.py +294 −0
@@ -0,0 +1,294 @@
1 +"""Test fixtures: an app wired to a temp root with a fake runtime adapter (no MLX needed)."""
2 +
3 +from __future__ import annotations
4 +
5 +import asyncio
6 +import json
7 +import os
8 +import struct
9 +import threading
10 +import time
11 +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12 +from pathlib import Path
13 +
14 +import httpx
15 +import pytest
16 +import pytest_asyncio
17 +
18 +os.environ.setdefault("LLM_API_TEST", "1")
19 +
20 +
21 +# ---------------------------------------------------------------------------
22 +# Fake model files
23 +# ---------------------------------------------------------------------------
24 +
25 +def make_safetensors(path: Path, tensors: dict[str, tuple[str, list[int]]]) -> None:
26 + """Write a minimal safetensors file with zeroed data."""
27 + header = {}
28 + offset = 0
29 + bits = {"U32": 4, "F16": 2, "BF16": 2, "F32": 4}
30 + for name, (dtype, shape) in tensors.items():
31 + n = 1
32 + for s in shape:
33 + n *= s
34 + size = n * bits[dtype]
35 + header[name] = {"dtype": dtype, "shape": shape, "data_offsets": [offset, offset + size]}
36 + offset += size
37 + hb = json.dumps(header).encode()
38 + with open(path, "wb") as f:
39 + f.write(struct.pack("<Q", len(hb)))
40 + f.write(hb)
41 + # Sparse data region: the scanner only reads headers, so do not materialize gigabytes of zeros.
42 + if offset:
43 + f.seek(len(hb) + 8 + offset - 1)
44 + f.write(b"\0")
45 +
46 +
47 +def make_mlx_model(d: Path, layers: int = 4, hidden: int = 256, heads: int = 4, kv_heads: int = 2,
48 + bits: int = 4, max_pos: int = 32768, model_type: str = "qwen3") -> None:
49 + d.mkdir(parents=True, exist_ok=True)
50 + cfg = {"architectures": ["Qwen3ForCausalLM"], "model_type": model_type, "num_hidden_layers": layers,
51 + "hidden_size": hidden, "num_attention_heads": heads, "num_key_value_heads": kv_heads,
52 + "head_dim": hidden // heads, "max_position_embeddings": max_pos, "vocab_size": 1000,
53 + "quantization": {"bits": bits, "group_size": 64}, "torch_dtype": "bfloat16"}
54 + (d / "config.json").write_text(json.dumps(cfg))
55 + (d / "tokenizer_config.json").write_text(json.dumps({"chat_template": "{% for m in messages %}{{m.content}}{% endfor %}<think>"}))
56 + (d / "README.md").write_text("---\ntags:\n- mlx\n- 4-bit\nbase_model: Qwen/Qwen3-Test\n---\n# test")
57 + tensors = {}
58 + for i in range(layers):
59 + tensors[f"model.layers.{i}.mlp.up_proj.weight"] = ("U32", [hidden * 4, hidden * bits // 32])
60 + tensors[f"model.layers.{i}.mlp.up_proj.scales"] = ("F16", [hidden * 4, hidden // 64])
61 + tensors["model.embed_tokens.weight"] = ("F16", [1000, hidden])
62 + make_safetensors(d / "model.safetensors", tensors)
63 +
64 +
65 +def make_gguf(path: Path, arch: str = "llama", layers: int = 4, kv_heads: int = 2, head_dim: int = 64,
66 + ctx: int = 8192, file_type: int = 15, n_params: int = 1_000_000) -> None:
67 + """Minimal GGUF v3 with metadata + one tensor info (no data)."""
68 + def s(v: str) -> bytes:
69 + b = v.encode()
70 + return struct.pack("<Q", len(b)) + b
71 +
72 + kv = []
73 + def add(key, t, val):
74 + kv.append(s(key) + struct.pack("<I", t) + val)
75 + add("general.architecture", 8, s(arch))
76 + add("general.file_type", 4, struct.pack("<I", file_type))
77 + add(f"{arch}.block_count", 4, struct.pack("<I", layers))
78 + add(f"{arch}.attention.head_count_kv", 4, struct.pack("<I", kv_heads))
79 + add(f"{arch}.attention.key_length", 4, struct.pack("<I", head_dim))
80 + add(f"{arch}.context_length", 4, struct.pack("<I", ctx))
81 + add("tokenizer.chat_template", 8, s("{{messages}}"))
82 + with open(path, "wb") as f:
83 + f.write(b"GGUF")
84 + f.write(struct.pack("<I", 3))
85 + f.write(struct.pack("<Q", 1)) # n_tensors
86 + f.write(struct.pack("<Q", len(kv)))
87 + for k in kv:
88 + f.write(k)
89 + # tensor info: name, ndim, dims, type (Q4_K=12), offset
90 + f.write(s("blk.0.weight"))
91 + f.write(struct.pack("<I", 2))
92 + f.write(struct.pack("<QQ", n_params // 100, 100))
93 + f.write(struct.pack("<I", 12))
94 + f.write(struct.pack("<Q", 0))
95 + f.write(b"\0" * (n_params // 2)) # pretend weight data (~4.5 bpw)
96 +
97 +
98 +# ---------------------------------------------------------------------------
99 +# Fake worker (OpenAI-compatible echo server) used by the fake adapter
100 +# ---------------------------------------------------------------------------
101 +
102 +class _FakeHandler(BaseHTTPRequestHandler):
103 + ready_at = 0.0
104 + crash_on_generate = False
105 +
106 + def log_message(self, *a): # silence
107 + pass
108 +
109 + def _json(self, code, obj):
110 + b = json.dumps(obj).encode()
111 + self.send_response(code)
112 + self.send_header("Content-Type", "application/json")
113 + self.send_header("Content-Length", str(len(b)))
114 + self.end_headers()
115 + self.wfile.write(b)
116 +
117 + def do_GET(self):
118 + if self.path == "/health":
119 + st = "ready" if time.time() >= self.ready_at else "loading"
120 + return self._json(200, {"status": st, "memory": {"active_gb": 0.5, "cache_gb": 0.0}})
121 + self._json(404, {"error": {"message": "nf"}})
122 +
123 + def do_POST(self):
124 + n = int(self.headers.get("Content-Length") or 0)
125 + body = json.loads(self.rfile.read(n) or b"{}")
126 + if self.path == "/warmup":
127 + return self._json(200, {"ok": True, "ttft_ms": 12.3})
128 + if self.path == "/shutdown":
129 + self._json(200, {"ok": True})
130 + threading.Thread(target=lambda: (time.sleep(0.1), os._exit(0)), daemon=True).start()
131 + return
132 + if self.path == "/v1/chat/completions":
133 + if _FakeHandler.crash_on_generate:
134 + os._exit(3)
135 + msgs = body.get("messages", [])
136 + text = "echo: " + str(msgs[-1].get("content", ""))
137 + usage = {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}
138 + timings = {"ttft_ms": 20.0, "generation_tps": 55.5, "prompt_tps": 300.0}
139 + if body.get("stream"):
140 + self.send_response(200)
141 + self.send_header("Content-Type", "text/event-stream")
142 + self.end_headers()
143 + for tok in text.split(" "):
144 + ch = {"id": "x", "object": "chat.completion.chunk", "created": 1, "model": body.get("model"),
145 + "choices": [{"index": 0, "delta": {"content": tok + " "}, "finish_reason": None}]}
146 + self.wfile.write(f"data: {json.dumps(ch)}\n\n".encode())
147 + self.wfile.flush()
148 + time.sleep(0.01)
149 + fin = {"id": "x", "object": "chat.completion.chunk", "created": 1, "model": body.get("model"),
150 + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": usage, "timings": timings}
151 + self.wfile.write(f"data: {json.dumps(fin)}\n\ndata: [DONE]\n\n".encode())
152 + return
153 + return self._json(200, {"id": "x", "object": "chat.completion", "created": 1, "model": body.get("model"),
154 + "choices": [{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": "stop"}],
155 + "usage": usage, "timings": timings})
156 + if self.path == "/v1/completions":
157 + return self._json(200, {"id": "c", "object": "text_completion", "created": 1, "model": body.get("model"),
158 + "choices": [{"index": 0, "text": " world", "finish_reason": "stop"}],
159 + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, "timings": {"generation_tps": 10}})
160 + if self.path == "/v1/embeddings":
161 + inp = body.get("input")
162 + n = len(inp) if isinstance(inp, list) else 1
163 + return self._json(200, {"object": "list", "model": body.get("model"),
164 + "data": [{"object": "embedding", "index": i, "embedding": [0.1, 0.2, 0.3]} for i in range(n)],
165 + "usage": {"prompt_tokens": n, "total_tokens": n}})
166 + self._json(404, {"error": {"message": "nf", "type": "x", "code": "NF"}})
167 +
168 +
169 +class _Server(ThreadingHTTPServer):
170 + daemon_threads = True
171 +
172 + def server_bind(self):
173 + # HTTPServer.server_bind calls socket.getfqdn(), which can hang for a long time on Macs with slow
174 + # reverse DNS; skip it.
175 + import socketserver
176 + socketserver.TCPServer.server_bind(self)
177 + self.server_name = "localhost"
178 + self.server_port = self.server_address[1]
179 +
180 +
181 +def fake_worker_main():
182 + import sys
183 + port = int(sys.argv[1])
184 + delay = float(sys.argv[2]) if len(sys.argv) > 2 else 0.0
185 + _FakeHandler.ready_at = time.time() + delay
186 + _FakeHandler.crash_on_generate = os.environ.get("FAKE_CRASH") == "1"
187 + _Server(("127.0.0.1", port), _FakeHandler).serve_forever()
188 +
189 +
190 +if __name__ == "__main__":
191 + fake_worker_main()
192 +
193 +
194 +# ---------------------------------------------------------------------------
195 +# Fake adapter
196 +# ---------------------------------------------------------------------------
197 +
198 +def install_fake_adapters(manager, load_delay: float = 0.0, crash: bool = False):
199 + import sys
200 + from llm_api.runtimes.base import RuntimeAdapter
201 +
202 + class FakeAdapter(RuntimeAdapter):
203 + name = "mlx"
204 +
205 + def available(self):
206 + return True
207 +
208 + def build_command(self, model, port, context):
209 + return [sys.executable, __file__, str(port), str(load_delay)]
210 +
211 + async def is_ready(self, handle, client):
212 + try:
213 + r = await client.get(f"{handle.base_url}/health", timeout=2)
214 + except Exception:
215 + return "loading", None
216 + return r.json().get("status", "loading"), None
217 +
218 + def spawn(self, model, port, context):
219 + if crash:
220 + os.environ["FAKE_CRASH"] = "1"
221 + else:
222 + os.environ.pop("FAKE_CRASH", None)
223 + return super().spawn(model, port, context)
224 +
225 + class FakeLlama(FakeAdapter):
226 + name = "llamacpp"
227 +
228 + manager.adapters["mlx"] = FakeAdapter(manager.settings, manager.settings.logs_path / "workers")
229 + manager.adapters["llamacpp"] = FakeLlama(manager.settings, manager.settings.logs_path / "workers")
230 +
231 +
232 +# ---------------------------------------------------------------------------
233 +# Fixtures
234 +# ---------------------------------------------------------------------------
235 +
236 +@pytest.fixture
237 +def root(tmp_path: Path) -> Path:
238 + models = tmp_path / "models"
239 + make_mlx_model(models / "mlx" / "qwen" / "Qwen3-Test-4bit")
240 + make_mlx_model(models / "mlx" / "llama" / "Llama-Test-4bit", layers=2, model_type="llama")
241 + make_mlx_model(models / "mlx" / "big" / "Huge-Test-4bit", layers=400, hidden=8192, heads=64, kv_heads=8, model_type="llama")
242 + (models / "gguf" / "gemma").mkdir(parents=True)
243 + make_gguf(models / "gguf" / "gemma" / "gemma-test-Q4_K_M.gguf")
244 + return tmp_path
245 +
246 +
247 +@pytest_asyncio.fixture
248 +async def app(root: Path, monkeypatch):
249 + from llm_api.config import Settings, get_settings
250 + from llm_api.main import create_app
251 + from llm_api.models import compat
252 +
253 + monkeypatch.setattr(compat, "mlx_available", lambda: True)
254 + monkeypatch.setattr(compat, "mlx_lm_model_types", lambda: {"qwen3", "llama"})
255 + monkeypatch.setattr(compat, "mlx_vlm_available", lambda: False)
256 + monkeypatch.setattr(compat, "mlx_vlm_model_types", lambda: set())
257 + import llm_api.models.scanner as sc
258 + monkeypatch.setattr(sc, "llamacpp_available", lambda b: True)
259 +
260 + settings = Settings(LLM_API_ROOT=str(root), MODEL_ROOT=str(root / "models"), MAX_MODEL_MEMORY_GB=45, ABSOLUTE_MAX_MEMORY_GB=50,
261 + ADMIN_EMAIL="admin@test.local", ADMIN_PASSWORD="correct-horse-battery", SECRET_KEY="test-secret",
262 + LOAD_TIMEOUT_SECONDS=20, WORKER_PORT_START=18400, WORKER_PORT_END=18450, METRICS_INTERVAL_SECONDS=60,
263 + _env_file=None)
264 + get_settings.cache_clear()
265 + application = create_app(settings)
266 + yield application
267 +
268 +
269 +@pytest_asyncio.fixture
270 +async def client(app):
271 + from asgi_lifespan import LifespanManager
272 + async with LifespanManager(app, startup_timeout=60, shutdown_timeout=60):
273 + install_fake_adapters(app.state.manager)
274 + transport = httpx.ASGITransport(app=app)
275 + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
276 + yield c
277 +
278 +
279 +@pytest_asyncio.fixture
280 +async def admin(client):
281 + from llm_api.auth import api_limiter, login_limiter
282 + login_limiter.hits.clear()
283 + api_limiter.hits.clear()
284 + r = await client.post("/api/auth/login", json={"email": "admin@test.local", "password": "correct-horse-battery"})
285 + assert r.status_code == 200, r.text
286 + client.headers["X-LLM-CSRF"] = "1"
287 + return client
288 +
289 +
290 +@pytest_asyncio.fixture
291 +async def api_key(admin):
292 + r = await admin.post("/api/keys", json={"name": "t", "scopes": ["inference"]})
293 + assert r.status_code == 200, r.text
294 + return r.json()["key"]
added server/tests/test_api.py +298 −0
@@ -0,0 +1,298 @@
1 +"""Integration tests through the ASGI app with fake workers (no MLX required)."""
2 +
3 +from __future__ import annotations
4 +
5 +import asyncio
6 +import json
7 +
8 +import pytest
9 +
10 +from .conftest import install_fake_adapters
11 +
12 +pytestmark = pytest.mark.asyncio
13 +
14 +
15 +# --------------------------------------------------------------------------- auth
16 +
17 +async def test_auth_flow(client):
18 + r = await client.get("/api/auth/status")
19 + assert r.json()["needs_setup"] is False and r.json()["authenticated"] is False
20 + r = await client.get("/api/models")
21 + assert r.status_code == 401
22 + r = await client.post("/api/auth/login", json={"email": "admin@test.local", "password": "wrong"})
23 + assert r.status_code == 401 and r.json()["error"]["code"] == "INVALID_CREDENTIALS"
24 + r = await client.post("/api/auth/login", json={"email": "admin@test.local", "password": "correct-horse-battery"})
25 + assert r.status_code == 200
26 + r = await client.get("/api/models")
27 + assert r.status_code == 200
28 + # mutation without CSRF header is refused for sessions
29 + r = await client.post("/api/models/rescan")
30 + assert r.status_code == 403 and r.json()["error"]["code"] == "CSRF"
31 +
32 +
33 +async def test_api_keys(admin):
34 + r = await admin.post("/api/keys", json={"name": "laptop", "scopes": ["inference"]})
35 + key = r.json()["key"]
36 + assert key.startswith("llm_live_")
37 + r = await admin.get("/api/keys")
38 + assert r.json()["keys"][0]["prefix"] == key[:16] and "key_hash" not in r.json()["keys"][0]
39 + # key works for inference endpoints, not for admin
40 + bare = admin
41 + bare.cookies.clear()
42 + r = await bare.get("/v1/models", headers={"Authorization": f"Bearer {key}"})
43 + assert r.status_code == 200
44 + r = await bare.get("/api/models", headers={"Authorization": f"Bearer {key}"})
45 + assert r.status_code == 403
46 + r = await bare.get("/v1/models", headers={"Authorization": "Bearer llm_live_bogus"})
47 + assert r.status_code == 401 and r.json()["error"]["code"] == "INVALID_API_KEY"
48 + r = await bare.get("/v1/models")
49 + assert r.status_code == 401
50 +
51 +
52 +async def test_revoked_key(admin):
53 + r = await admin.post("/api/keys", json={"name": "k", "scopes": ["inference"]})
54 + key, kid = r.json()["key"], r.json()["record"]["id"]
55 + await admin.delete(f"/api/keys/{kid}")
56 + admin.cookies.clear()
57 + r = await admin.get("/v1/models", headers={"Authorization": f"Bearer {key}"})
58 + assert r.status_code == 401
59 +
60 +
61 +# --------------------------------------------------------------------------- registry
62 +
63 +async def test_registry_scan(admin):
64 + r = await admin.get("/api/models")
65 + models = {m["id"]: m for m in r.json()["models"]}
66 + assert "qwen3-test-4bit" in models and "llama-test-4bit" in models and "gemma-test-q4-k-m" in models
67 + q = models["qwen3-test-4bit"]
68 + assert q["runtime"] == "mlx" and q["quantization"] == "4bit" and q["compatibility_status"] in ("compatible", "compatible_with_restrictions")
69 + assert q["thinking"] is True and q["kv_bytes_per_token"] == 2 * 4 * 2 * 64 * 2
70 + g = models["gemma-test-q4-k-m"]
71 + assert g["runtime"] == "llamacpp" and g["format"] == "gguf" and g["quantization"] == "Q4_K_M"
72 + huge = models["huge-test-4bit"]
73 + assert huge["compatibility_status"] in ("incompatible", "not_recommended") and huge["compatible"] is False
74 +
75 +
76 +async def test_rescan_detects_missing(admin, root):
77 + import shutil
78 + shutil.rmtree(root / "models" / "mlx" / "llama")
79 + r = await admin.post("/api/models/rescan")
80 + assert r.status_code == 200
81 + r = await admin.get("/api/models?include_missing=true")
82 + m = next(x for x in r.json()["models"] if x["id"] == "llama-test-4bit")
83 + assert m["installed"] is False
84 + # files are never deleted by a scan
85 + assert (root / "models" / "mlx" / "qwen" / "Qwen3-Test-4bit" / "model.safetensors").exists()
86 +
87 +
88 +async def test_model_detail_and_patch(admin):
89 + r = await admin.get("/api/models/qwen3-test-4bit")
90 + assert r.status_code == 200 and "memory_curve" in r.json()
91 + r = await admin.patch("/api/models/qwen3-test-4bit", json={"favorite": True, "tags": ["coding"]})
92 + assert r.json()["favorite"] is True and "coding" in r.json()["tags"]
93 + r = await admin.post("/api/models/qwen3-test-4bit/pin?pinned=true")
94 + assert r.json()["pinned"] is True
95 + r = await admin.get("/api/models/nope")
96 + assert r.status_code == 404 and r.json()["error"]["code"] == "MODEL_NOT_FOUND"
97 +
98 +
99 +async def test_aliases(admin, api_key):
100 + r = await admin.put("/api/aliases", json={"alias": "fast", "model_id": "qwen3-test-4bit"})
101 + assert r.status_code == 200 and r.json()["aliases"]["fast"] == "qwen3-test-4bit"
102 + r = await admin.put("/api/aliases", json={"alias": "auto", "model_id": "qwen3-test-4bit"})
103 + assert r.status_code == 400
104 + admin.cookies.clear()
105 + r = await admin.get("/v1/models", headers={"Authorization": f"Bearer {api_key}"})
106 + ids = [m["id"] for m in r.json()["data"]]
107 + assert "fast" in ids and "qwen3-test-4bit" in ids
108 +
109 +
110 +# --------------------------------------------------------------------------- inference (fake worker)
111 +
112 +async def test_load_on_demand_chat_and_unload(admin, api_key, app):
113 + h = {"Authorization": f"Bearer {api_key}"}
114 + admin.cookies.clear()
115 + r = await admin.post("/v1/chat/completions", headers=h, json={"model": "qwen3-test-4bit", "messages": [{"role": "user", "content": "hi"}]})
116 + assert r.status_code == 200, r.text
117 + d = r.json()
118 + assert d["choices"][0]["message"]["content"] == "echo: hi" and d["usage"]["total_tokens"] == 10 and d["timings"]["generation_tps"] == 55.5
119 + assert app.state.manager.status_of("qwen3-test-4bit") == "ready"
120 + # streaming
121 + async with admin.stream("POST", "/v1/chat/completions", headers=h, json={"model": "qwen3-test-4bit", "stream": True,
122 + "messages": [{"role": "user", "content": "a b c"}]}) as s:
123 + assert s.status_code == 200
124 + lines = [l async for l in s.aiter_lines() if l.startswith("data: ")]
125 + assert lines[-1] == "data: [DONE]"
126 + chunks = [json.loads(l[6:]) for l in lines[:-1]]
127 + text = "".join((c["choices"][0]["delta"].get("content") or "") for c in chunks if c.get("choices"))
128 + assert text.strip() == "echo: a b c"
129 + assert chunks[-1]["usage"]["completion_tokens"] == 3
130 + # completions + embeddings
131 + r = await admin.post("/v1/completions", headers=h, json={"model": "qwen3-test-4bit", "prompt": "hello"})
132 + assert r.status_code == 200 and r.json()["choices"][0]["text"] == " world"
133 + r = await admin.post("/v1/embeddings", headers=h, json={"model": "qwen3-test-4bit", "input": ["a", "b"]})
134 + assert r.status_code == 200 and len(r.json()["data"]) == 2
135 + # request log recorded
136 + await asyncio.sleep(0.1)
137 + n = await app.state.db.scalar("SELECT COUNT(*) FROM inference_requests WHERE status=200")
138 + assert n >= 4
139 + # model switch: llama evicts qwen (max 1 model)
140 + r = await admin.post("/v1/chat/completions", headers=h, json={"model": "llama-test-4bit", "messages": [{"role": "user", "content": "yo"}]})
141 + assert r.status_code == 200
142 + assert app.state.manager.status_of("llama-test-4bit") == "ready"
143 + assert app.state.manager.status_of("qwen3-test-4bit") == "unloaded"
144 + # manual unload via admin
145 + await admin.post("/api/auth/login", json={"email": "admin@test.local", "password": "correct-horse-battery"})
146 + r = await admin.post("/api/models/llama-test-4bit/unload")
147 + assert r.json()["ok"] is True and app.state.manager.loaded == {}
148 +
149 +
150 +async def test_model_not_found_and_validation(admin, api_key):
151 + h = {"Authorization": f"Bearer {api_key}"}
152 + admin.cookies.clear()
153 + r = await admin.post("/v1/chat/completions", headers=h, json={"model": "ghost", "messages": [{"role": "user", "content": "x"}]})
154 + assert r.status_code == 404 and r.json()["error"]["code"] == "MODEL_NOT_FOUND"
155 + r = await admin.post("/v1/chat/completions", headers=h, json={"model": "qwen3-test-4bit", "messages": []})
156 + assert r.status_code == 400 and r.json()["error"]["type"] == "invalid_request_error"
157 + r = await admin.post("/v1/chat/completions", headers=h, json={"model": "qwen3-test-4bit", "messages": [{"role": "user", "content": "x"}], "temperature": 9})
158 + assert r.status_code == 400 and r.json()["error"]["param"] == "temperature"
159 + r = await admin.post("/v1/chat/completions", headers=h, content=b"{not json")
160 + assert r.status_code == 400
161 +
162 +
163 +async def test_memory_rejection(admin, api_key, app):
164 + h = {"Authorization": f"Bearer {api_key}"}
165 + r = await admin.post("/v1/chat/completions", headers=h, json={"model": "huge-test-4bit", "messages": [{"role": "user", "content": "x"}]})
166 + assert r.status_code in (422, 507)
167 + assert r.json()["error"]["code"] in ("MODEL_INCOMPATIBLE", "MODEL_TOO_LARGE")
168 + # lower the budget -> a normally fine model becomes too large for a manual load
169 + r = await admin.patch("/api/settings", json={"max_model_memory_gb": 1})
170 + assert r.status_code == 200
171 + r = await admin.post("/api/models/qwen3-test-4bit/load")
172 + assert r.status_code in (422, 507), r.text
173 + r = await admin.get("/api/models/qwen3-test-4bit")
174 + assert r.json()["compatible"] is False
175 + await admin.patch("/api/settings", json={"max_model_memory_gb": 45})
176 +
177 +
178 +async def test_worker_crash_is_reported(admin, api_key, app):
179 + install_fake_adapters(app.state.manager, crash=True)
180 + h = {"Authorization": f"Bearer {api_key}"}
181 + r = await admin.post("/v1/chat/completions", headers=h, json={"model": "qwen3-test-4bit", "messages": [{"role": "user", "content": "x"}]})
182 + # warm-up already generates -> crash during load -> clean structured error
183 + assert r.status_code in (502, 503), r.text
184 + assert r.json()["error"]["code"] in ("WORKER_CRASHED", "MODEL_LOAD_FAILED", "WORKER_UNREACHABLE")
185 + assert app.state.manager.loaded == {}
186 + install_fake_adapters(app.state.manager)
187 +
188 +
189 +async def test_concurrent_requests_and_switch_lock(admin, api_key, app):
190 + h = {"Authorization": f"Bearer {api_key}"}
191 + admin.cookies.clear()
192 +
193 + async def ask(model, text):
194 + return await admin.post("/v1/chat/completions", headers=h, json={"model": model, "messages": [{"role": "user", "content": text}]})
195 +
196 + rs = await asyncio.gather(*[ask("qwen3-test-4bit", f"q{i}") for i in range(5)], ask("llama-test-4bit", "l1"), ask("qwen3-test-4bit", "q9"))
197 + assert all(r.status_code == 200 for r in rs), [r.text for r in rs if r.status_code != 200]
198 + # never more than one large model resident
199 + assert len(app.state.manager.loaded) <= 1
200 + assert app.state.manager.stats["loads"] >= 2
201 +
202 +
203 +async def test_load_timeout(admin, app, monkeypatch):
204 + install_fake_adapters(app.state.manager, load_delay=60)
205 + monkeypatch.setattr(app.state.settings, "load_timeout_seconds", 2)
206 + r = await admin.post("/api/models/qwen3-test-4bit/load")
207 + assert r.status_code == 503 and r.json()["error"]["code"] == "MODEL_LOAD_TIMEOUT"
208 + assert app.state.manager.loaded == {}
209 + install_fake_adapters(app.state.manager)
210 +
211 +
212 +async def test_delete_requires_confirmation(admin, root):
213 + r = await admin.request("DELETE", "/api/models/llama-test-4bit", json={"confirm": "nope"})
214 + assert r.status_code == 400 and r.json()["error"]["code"] == "CONFIRMATION_REQUIRED"
215 + assert (root / "models" / "mlx" / "llama" / "Llama-Test-4bit").exists()
216 + r = await admin.request("DELETE", "/api/models/llama-test-4bit", json={"confirm": "llama-test-4bit"})
217 + assert r.status_code == 200
218 + assert not (root / "models" / "mlx" / "llama" / "Llama-Test-4bit").exists()
219 + r = await admin.get("/api/models/llama-test-4bit")
220 + assert r.status_code == 404
221 +
222 +
223 +async def test_system_endpoints(admin):
224 + for path in ("/api/system", "/api/system/memory", "/api/system/gpu", "/api/system/storage", "/api/system/processes", "/api/runtime/status", "/api/settings", "/api/system/metrics"):
225 + r = await admin.get(path)
226 + assert r.status_code == 200, path
227 + r = await admin.get("/health")
228 + d = r.json()
229 + assert d["status"] == "ok" and "chip" in d["hardware"] and "available_gb" in d["memory"]
230 +
231 +
232 +async def test_settings_validation(admin):
233 + r = await admin.patch("/api/settings", json={"max_model_memory_gb": 999})
234 + assert r.status_code == 400
235 + r = await admin.patch("/api/settings", json={"log_prompts": True, "unknown_key": 1})
236 + assert r.json()["changed"] == {"log_prompts": True}
237 +
238 +
239 +async def test_low_disk_warning_blocks_download(admin, app, monkeypatch):
240 + # Pretend the disk is almost full: inspect must report disk.ok False and start_download must refuse
241 + import shutil as _sh
242 + from llm_api import downloads as dl
243 +
244 + class FakeInfo:
245 + siblings = [type("S", (), {"rfilename": "model.safetensors", "size": 10 * 1024**3})(), type("S", (), {"rfilename": "config.json", "size": 100})()]
246 + tags = ["mlx", "base_model:quantized:Qwen/Qwen3-Test"]
247 + library_name = "mlx"
248 + config = {"model_type": "qwen3", "architectures": ["Qwen3ForCausalLM"], "num_hidden_layers": 4, "num_attention_heads": 4,
249 + "num_key_value_heads": 2, "hidden_size": 256, "max_position_embeddings": 8192, "quantization": {"bits": 4}}
250 + pipeline_tag = "text-generation"
251 + downloads = 1000
252 + likes = 5
253 + last_modified = "2026-01-01"
254 + gated = False
255 +
256 + class FakeApi:
257 + def model_info(self, repo, files_metadata=True):
258 + return FakeInfo()
259 +
260 + monkeypatch.setattr(app.state.downloader, "_api", lambda: FakeApi())
261 + Usage = type("U", (), {})
262 + def fake_du(path):
263 + u = Usage(); u.total = 1000 * 1024**3; u.used = 950 * 1024**3; u.free = 50 * 1024**3
264 + return u
265 + monkeypatch.setattr(dl.shutil, "disk_usage", fake_du)
266 + r = await admin.post("/api/models/inspect", json={"repository": "mlx-community/Qwen3-Test-4bit"})
267 + assert r.status_code == 200 and r.json()["disk"]["ok"] is False
268 + r = await admin.post("/api/models/download", json={"repository": "mlx-community/Qwen3-Test-4bit"})
269 + assert r.status_code == 507 and r.json()["error"]["code"] == "INSUFFICIENT_DISK"
270 + r = await admin.post("/api/models/inspect", json={"repository": "../../etc/passwd"})
271 + assert r.status_code == 400
272 +
273 +
274 +async def test_benchmark_job(admin, app):
275 + r = await admin.post("/api/models/qwen3-test-4bit/benchmark", json={"max_tokens": 8, "runs": 1, "long_prompt": False})
276 + job_id = r.json()["job"]["id"]
277 + for _ in range(100):
278 + await asyncio.sleep(0.2)
279 + j = app.state.jobs.get(job_id)
280 + if j and j.status in ("completed", "failed"):
281 + break
282 + assert j.status == "completed", j.error
283 + r = await admin.get("/api/models/qwen3-test-4bit/benchmarks")
284 + b = r.json()["benchmarks"][0]
285 + assert b["load_ms"] is not None and b["generation_tps"] is not None
286 +
287 +
288 +async def test_restart_clears_stale_state(client, app, root):
289 + """A model marked loaded in a previous life must come back as unloaded (workers file cleanup)."""
290 + from llm_api.manager import ModelManager
291 + wf = root / "data" / "workers.json"
292 + wf.parent.mkdir(parents=True, exist_ok=True)
293 + wf.write_text(json.dumps({"qwen3-test-4bit": {"pid": 999999, "port": 18499, "runtime": "mlx"}}))
294 + m = ModelManager(app.state.settings, app.state.db, app.state.manager.registry)
295 + await m._recover_stale_workers()
296 + assert json.loads(wf.read_text()) == {}
297 + assert m.status_of("qwen3-test-4bit") == "unloaded"
298 + await m.client.aclose()
added server/tests/test_units.py +140 −0
@@ -0,0 +1,140 @@
1 +"""Unit tests: formats, estimator, compatibility engine, OpenAI helpers."""
2 +
3 +from __future__ import annotations
4 +
5 +from pathlib import Path
6 +
7 +from llm_api.models import compat, formats
8 +from llm_api.models.estimator import estimate, kv_bytes_per_token, recommended_context
9 +from llm_api.worker.openai_types import StopMatcher, ThinkSplitter, parse_tool_calls
10 +
11 +from .conftest import make_gguf, make_mlx_model
12 +
13 +
14 +def test_parse_param_count():
15 + assert formats.parse_param_count_from_name("Qwen3-30B-A3B-4bit") == (30_000_000_000, 3_000_000_000)
16 + assert formats.parse_param_count_from_name("gemma-3-4b-it-qat-4bit")[0] == 4_000_000_000
17 + assert formats.parse_param_count_from_name("Qwen3-Embedding-0.6B-8bit")[0] == 600_000_000
18 + assert formats.parse_param_count_from_name("all-MiniLM-L6-v2-4bit") == (None, None)
19 + assert formats.parse_param_count_from_name("embeddinggemma-300m-4bit")[0] == 300_000_000
20 +
21 +
22 +def test_parse_quant():
23 + assert formats.parse_quant_from_name("Qwen3-4B-Instruct-2507-4bit") == ("4bit", 4.0)
24 + assert formats.parse_quant_from_name("gpt-oss-20b-MXFP4-Q8")[0] == "MXFP4"
25 + q, b = formats.parse_quant_from_name("gemma-3-1b-it-Q4_K_M")
26 + assert q == "Q4_K_M" and b == 4.5
27 + assert formats.parse_quant_from_name("Qwen3-Embedding-0.6B-4bit-DWQ") == ("DWQ-4bit", 4.0)
28 + assert formats.parse_quant_from_name("Qwen3.8-27B-bf16") == ("bf16", 16)
29 +
30 +
31 +def test_family():
32 + assert formats.guess_family("Qwen3.6-35B-A3B-4bit") == "qwen"
33 + assert formats.guess_family("gemma-4-26b-a4b-it-4bit") == "gemma"
34 + assert formats.guess_family("Devstral-Small-2-24B") == "mistral"
35 + assert formats.guess_family("gpt-oss-20b-MXFP4-Q8") == "gpt-oss"
36 + assert formats.guess_family("Something-Unknown", "phi3") == "phi"
37 +
38 +
39 +def test_size_class():
40 + assert formats.size_class(3, 45) == "TINY"
41 + assert formats.size_class(7, 45) == "SMALL"
42 + assert formats.size_class(15, 45) == "MEDIUM"
43 + assert formats.size_class(30, 45) == "LARGE"
44 + assert formats.size_class(40, 45) == "XL"
45 + assert formats.size_class(50, 45) == "TOO_LARGE"
46 +
47 +
48 +def test_kv_and_estimate():
49 + kv = kv_bytes_per_token(36, 8, 128, 16)
50 + assert kv == 2 * 36 * 8 * 128 * 2
51 + est = estimate(2_400_000_000, "mlx", kv, 32768)
52 + assert 9 < est.total_gb < 11
53 + ctx, e2 = recommended_context(2_400_000_000, "mlx", kv, 262144, 45)
54 + assert ctx == 32768 # capped at the preferred context
55 + ctx3, _ = recommended_context(40 * 1024**3, "mlx", kv * 4, 131072, 45)
56 + assert ctx3 is not None and ctx3 <= 8192
57 +
58 +
59 +def test_compat_statuses(monkeypatch):
60 + monkeypatch.setattr(compat, "mlx_available", lambda: True)
61 + monkeypatch.setattr(compat, "mlx_lm_model_types", lambda: {"qwen3"})
62 + kv = kv_bytes_per_token(36, 8, 128)
63 + ok = compat.evaluate(runtime="mlx", weights_bytes=2_400_000_000, kv_per_token=kv, max_context=131072, model_type="qwen3",
64 + architecture="Qwen3ForCausalLM", vision=False, embedding=False, reranker=False, budget_gb=45, absolute_gb=50,
65 + llamacpp_available=True)
66 + assert ok.status == compat.COMPATIBLE and ok.recommended_context == 32768
67 + too_big = compat.evaluate(runtime="mlx", weights_bytes=60 * 1024**3, kv_per_token=kv, max_context=131072, model_type="qwen3",
68 + architecture=None, vision=False, embedding=False, reranker=False, budget_gb=45, absolute_gb=50,
69 + llamacpp_available=True)
70 + assert too_big.status == compat.INCOMPATIBLE and not too_big.compatible
71 + swap = compat.evaluate(runtime="mlx", weights_bytes=44 * 1024**3, kv_per_token=kv, max_context=131072, model_type="qwen3",
72 + architecture=None, vision=False, embedding=False, reranker=False, budget_gb=45, absolute_gb=50,
73 + llamacpp_available=True)
74 + assert swap.status == compat.NOT_RECOMMENDED
75 + unknown = compat.evaluate(runtime="mlx", weights_bytes=1e9, kv_per_token=kv, max_context=8192, model_type="mystery",
76 + architecture=None, vision=False, embedding=False, reranker=False, budget_gb=45, absolute_gb=50,
77 + llamacpp_available=True)
78 + assert unknown.status == compat.INCOMPATIBLE
79 + no_llama = compat.evaluate(runtime="llamacpp", weights_bytes=1e9, kv_per_token=kv, max_context=8192, model_type="llama",
80 + architecture="llama", vision=False, embedding=False, reranker=False, budget_gb=45, absolute_gb=50,
81 + llamacpp_available=False, weights_file="x.gguf")
82 + assert no_llama.status == compat.INCOMPATIBLE
83 + exp = compat.evaluate(runtime="llamacpp", weights_bytes=1e9, kv_per_token=kv, max_context=8192, model_type="weird",
84 + architecture="weird", vision=False, embedding=False, reranker=False, budget_gb=45, absolute_gb=50,
85 + llamacpp_available=True, weights_file="x.gguf")
86 + assert exp.status == compat.EXPERIMENTAL
87 +
88 +
89 +def test_gguf_reader(tmp_path: Path):
90 + p = tmp_path / "m-Q4_K_M.gguf"
91 + make_gguf(p, arch="llama", layers=8, kv_heads=4, head_dim=64, ctx=4096)
92 + info = formats.read_gguf(p)
93 + assert info.architecture == "llama" and info.n_layers == 8 and info.n_kv_heads == 4 and info.head_dim == 64
94 + assert info.context_length == 4096 and info.file_type_label == "Q4_K_M"
95 + assert info.param_count == 1_000_000
96 +
97 +
98 +def test_safetensors_params(tmp_path: Path):
99 + d = tmp_path / "m"
100 + make_mlx_model(d, layers=2, hidden=256, bits=4)
101 + params, nbytes = formats.count_safetensors_params([d / "model.safetensors"], 4)
102 + # 2 layers * (1024 x 256) + embeddings 1000*256
103 + assert params == 2 * 1024 * 256 + 1000 * 256
104 + assert nbytes > 0
105 +
106 +
107 +def test_hf_config_parse():
108 + cfg = {"model_type": "qwen3", "architectures": ["Qwen3ForCausalLM"], "num_hidden_layers": 36, "num_attention_heads": 32,
109 + "num_key_value_heads": 8, "hidden_size": 2560, "head_dim": 128, "max_position_embeddings": 262144,
110 + "quantization": {"bits": 4, "group_size": 64}}
111 + p = formats.parse_hf_config(cfg)
112 + assert p["n_layers"] == 36 and p["n_kv_heads"] == 8 and p["head_dim"] == 128 and p["quant_bits"] == 4 and not p["vision"]
113 + v = formats.parse_hf_config({"model_type": "qwen3_vl", "text_config": {"num_hidden_layers": 2}, "vision_config": {}})
114 + assert v["vision"] and v["n_layers"] == 2
115 +
116 +
117 +def test_stop_matcher():
118 + sm = StopMatcher(["</s>", "User:"])
119 + out = sm.feed("Hello <")
120 + assert out == "Hello " and not sm.done
121 + out += sm.feed("/s> tail")
122 + assert out == "Hello " and sm.done
123 + sm2 = StopMatcher(["STOP"])
124 + assert sm2.feed("abc ST") == "abc "
125 + assert sm2.feed("art") == "STart"
126 + assert sm2.flush() == ""
127 +
128 +
129 +def test_think_splitter():
130 + ts = ThinkSplitter("<think>", "</think>")
131 + r, c = ts.feed("<think>reasoning here</thi")
132 + assert r == "reasoning here" and c == ""
133 + r2, c2 = ts.feed("nk>\n\nanswer")
134 + assert r2 == "" and c2 == "answer"
135 +
136 +
137 +def test_tool_calls():
138 + rest, calls = parse_tool_calls('Sure.\n<tool_call>\n{"name": "get_weather", "arguments": {"city": "Montreal"}}\n</tool_call>')
139 + assert rest == "Sure." and len(calls) == 1 and calls[0]["function"]["name"] == "get_weather"
140 + assert '"city": "Montreal"' in calls[0]["function"]["arguments"]
added web/.gitignore +41 −0
@@ -0,0 +1,41 @@
1 +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 +
3 +# dependencies
4 +/node_modules
5 +/.pnp
6 +.pnp.*
7 +.yarn/*
8 +!.yarn/patches
9 +!.yarn/plugins
10 +!.yarn/releases
11 +!.yarn/versions
12 +
13 +# testing
14 +/coverage
15 +
16 +# next.js
17 +/.next/
18 +/out/
19 +
20 +# production
21 +/build
22 +
23 +# misc
24 +.DS_Store
25 +*.pem
26 +
27 +# debug
28 +npm-debug.log*
29 +yarn-debug.log*
30 +yarn-error.log*
31 +.pnpm-debug.log*
32 +
33 +# env files (can opt-in for committing if needed)
34 +.env*
35 +
36 +# vercel
37 +.vercel
38 +
39 +# typescript
40 +*.tsbuildinfo
41 +next-env.d.ts
added web/README.md +36 −0
@@ -0,0 +1,36 @@
1 +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2 +
3 +## Getting Started
4 +
5 +First, run the development server:
6 +
7 +```bash
8 +npm run dev
9 +# or
10 +yarn dev
11 +# or
12 +pnpm dev
13 +# or
14 +bun dev
15 +```
16 +
17 +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 +
19 +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 +
21 +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22 +
23 +## Learn More
24 +
25 +To learn more about Next.js, take a look at the following resources:
26 +
27 +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 +
30 +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31 +
32 +## Deploy on Vercel
33 +
34 +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 +
36 +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
added web/eslint.config.mjs +18 −0
@@ -0,0 +1,18 @@
1 +import { defineConfig, globalIgnores } from "eslint/config";
2 +import nextVitals from "eslint-config-next/core-web-vitals";
3 +import nextTs from "eslint-config-next/typescript";
4 +
5 +const eslintConfig = defineConfig([
6 + ...nextVitals,
7 + ...nextTs,
8 + // Override default ignores of eslint-config-next.
9 + globalIgnores([
10 + // Default ignores of eslint-config-next:
11 + ".next/**",
12 + "out/**",
13 + "build/**",
14 + "next-env.d.ts",
15 + ]),
16 +]);
17 +
18 +export default eslintConfig;
added web/next.config.ts +20 −0
@@ -0,0 +1,20 @@
1 +import type { NextConfig } from "next";
2 +
3 +// In production the FastAPI server (port 8300) is the public entrypoint and reverse-proxies the
4 +// dashboard (this app, port 8301). In development (`pnpm dev`) we proxy /api and /v1 to the API.
5 +const API_URL = process.env.API_URL || "http://127.0.0.1:8300";
6 +
7 +const nextConfig: NextConfig = {
8 + reactStrictMode: true,
9 + poweredByHeader: false,
10 + async rewrites() {
11 + if (process.env.NODE_ENV === "production") return [];
12 + return [
13 + { source: "/api/:path*", destination: `${API_URL}/api/:path*` },
14 + { source: "/v1/:path*", destination: `${API_URL}/v1/:path*` },
15 + { source: "/health", destination: `${API_URL}/health` },
16 + ];
17 + },
18 +};
19 +
20 +export default nextConfig;
added web/package.json +27 −0
@@ -0,0 +1,27 @@
1 +{
2 + "name": "web",
3 + "version": "0.1.0",
4 + "private": true,
5 + "scripts": {
6 + "dev": "next dev",
7 + "build": "next build",
8 + "start": "next start",
9 + "lint": "eslint"
10 + },
11 + "dependencies": {
12 + "next": "16.3.4",
13 + "react": "19.2.8",
14 + "react-dom": "19.2.8"
15 + },
16 + "devDependencies": {
17 + "@tailwindcss/postcss": "^4",
18 + "@types/node": "^20",
19 + "@types/react": "^19",
20 + "@types/react-dom": "^19",
21 + "eslint": "^9",
22 + "eslint-config-next": "16.3.4",
23 + "tailwindcss": "^4",
24 + "typescript": "^5"
25 + },
26 + "packageManager": "pnpm@11.1.2"
27 +}
added web/pnpm-lock.yaml +4184 −0
@@ -0,0 +1,4184 @@
1 +lockfileVersion: '9.0'
2 +
3 +settings:
4 + autoInstallPeers: true
5 + excludeLinksFromLockfile: false
6 +
7 +importers:
8 +
9 + .:
10 + dependencies:
11 + next:
12 + specifier: 16.3.4
13 + version: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
14 + react:
15 + specifier: 19.2.8
16 + version: 19.2.8
17 + react-dom:
18 + specifier: 19.2.8
19 + version: 19.2.8(react@19.2.8)
20 + devDependencies:
21 + '@tailwindcss/postcss':
22 + specifier: ^4
23 + version: 4.3.3
24 + '@types/node':
25 + specifier: ^20
26 + version: 20.19.43
27 + '@types/react':
28 + specifier: ^19
29 + version: 19.3.0
30 + '@types/react-dom':
31 + specifier: ^19
32 + version: 19.3.0(@types/react@19.3.0)
33 + eslint:
34 + specifier: ^9
35 + version: 9.39.5(jiti@2.7.0)
36 + eslint-config-next:
37 + specifier: 16.3.4
38 + version: 16.3.4(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
39 + tailwindcss:
40 + specifier: ^4
41 + version: 4.3.3
42 + typescript:
43 + specifier: ^5
44 + version: 5.9.3
45 +
46 +packages:
47 +
48 + '@alloc/quick-lru@5.3.0':
49 + resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
50 + engines: {node: '>=10'}
51 +
52 + '@babel/code-frame@7.29.7':
53 + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
54 + engines: {node: '>=6.9.0'}
55 +
56 + '@babel/compat-data@7.29.7':
57 + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
58 + engines: {node: '>=6.9.0'}
59 +
60 + '@babel/core@7.29.7':
61 + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
62 + engines: {node: '>=6.9.0'}
63 +
64 + '@babel/generator@7.29.8':
65 + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==}
66 + engines: {node: '>=6.9.0'}
67 +
68 + '@babel/helper-compilation-targets@7.29.7':
69 + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
70 + engines: {node: '>=6.9.0'}
71 +
72 + '@babel/helper-globals@7.29.7':
73 + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
74 + engines: {node: '>=6.9.0'}
75 +
76 + '@babel/helper-module-imports@7.29.7':
77 + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
78 + engines: {node: '>=6.9.0'}
79 +
80 + '@babel/helper-module-transforms@7.29.7':
81 + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
82 + engines: {node: '>=6.9.0'}
83 + peerDependencies:
84 + '@babel/core': ^7.0.0
85 +
86 + '@babel/helper-string-parser@7.29.7':
87 + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
88 + engines: {node: '>=6.9.0'}
89 +
90 + '@babel/helper-validator-identifier@7.29.7':
91 + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
92 + engines: {node: '>=6.9.0'}
93 +
94 + '@babel/helper-validator-option@7.29.7':
95 + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
96 + engines: {node: '>=6.9.0'}
97 +
98 + '@babel/helpers@7.29.7':
99 + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
100 + engines: {node: '>=6.9.0'}
101 +
102 + '@babel/parser@7.29.8':
103 + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
104 + engines: {node: '>=6.0.0'}
105 + hasBin: true
106 +
107 + '@babel/template@7.29.7':
108 + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
109 + engines: {node: '>=6.9.0'}
110 +
111 + '@babel/traverse@7.29.8':
112 + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
113 + engines: {node: '>=6.9.0'}
114 +
115 + '@babel/types@7.29.8':
116 + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
117 + engines: {node: '>=6.9.0'}
118 +
119 + '@emnapi/core@1.10.0':
120 + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
121 +
122 + '@emnapi/runtime@1.10.0':
123 + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
124 +
125 + '@emnapi/runtime@1.11.3':
126 + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
127 +
128 + '@emnapi/wasi-threads@1.2.1':
129 + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
130 +
131 + '@eslint-community/eslint-utils@4.10.1':
132 + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
133 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
134 + peerDependencies:
135 + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
136 +
137 + '@eslint-community/eslint-utils@4.9.1':
138 + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
139 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
140 + peerDependencies:
141 + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
142 +
143 + '@eslint-community/regexpp@4.12.2':
144 + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
145 + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
146 +
147 + '@eslint/config-array@0.21.2':
148 + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
149 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
150 +
151 + '@eslint/config-helpers@0.4.2':
152 + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
153 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
154 +
155 + '@eslint/core@0.17.0':
156 + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
157 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
158 +
159 + '@eslint/eslintrc@3.3.7':
160 + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==}
161 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
162 +
163 + '@eslint/js@9.39.5':
164 + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==}
165 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
166 +
167 + '@eslint/object-schema@2.1.7':
168 + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
169 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
170 +
171 + '@eslint/plugin-kit@0.4.1':
172 + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
173 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
174 +
175 + '@humanfs/core@0.19.2':
176 + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
177 + engines: {node: '>=18.18.0'}
178 +
179 + '@humanfs/node@0.16.8':
180 + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
181 + engines: {node: '>=18.18.0'}
182 +
183 + '@humanfs/types@0.15.0':
184 + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
185 + engines: {node: '>=18.18.0'}
186 +
187 + '@humanwhocodes/module-importer@1.0.1':
188 + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
189 + engines: {node: '>=12.22'}
190 +
191 + '@humanwhocodes/retry@0.4.3':
192 + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
193 + engines: {node: '>=18.18'}
194 +
195 + '@img/colour@1.1.0':
196 + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
197 + engines: {node: '>=18'}
198 +
199 + '@img/sharp-darwin-arm64@0.35.4':
200 + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
201 + engines: {node: '>=20.9.0'}
202 + cpu: [arm64]
203 + os: [darwin]
204 +
205 + '@img/sharp-darwin-x64@0.35.4':
206 + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
207 + engines: {node: '>=20.9.0'}
208 + cpu: [x64]
209 + os: [darwin]
210 +
211 + '@img/sharp-freebsd-wasm32@0.35.4':
212 + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
213 + engines: {node: '>=20.9.0'}
214 + os: [freebsd]
215 +
216 + '@img/sharp-libvips-darwin-arm64@1.3.3':
217 + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
218 + cpu: [arm64]
219 + os: [darwin]
220 +
221 + '@img/sharp-libvips-darwin-x64@1.3.3':
222 + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
223 + cpu: [x64]
224 + os: [darwin]
225 +
226 + '@img/sharp-libvips-linux-arm64@1.3.3':
227 + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
228 + cpu: [arm64]
229 + os: [linux]
230 + libc: [glibc]
231 +
232 + '@img/sharp-libvips-linux-arm@1.3.3':
233 + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
234 + cpu: [arm]
235 + os: [linux]
236 + libc: [glibc]
237 +
238 + '@img/sharp-libvips-linux-ppc64@1.3.3':
239 + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
240 + cpu: [ppc64]
241 + os: [linux]
242 + libc: [glibc]
243 +
244 + '@img/sharp-libvips-linux-riscv64@1.3.3':
245 + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
246 + cpu: [riscv64]
247 + os: [linux]
248 + libc: [glibc]
249 +
250 + '@img/sharp-libvips-linux-s390x@1.3.3':
251 + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
252 + cpu: [s390x]
253 + os: [linux]
254 + libc: [glibc]
255 +
256 + '@img/sharp-libvips-linux-x64@1.3.3':
257 + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
258 + cpu: [x64]
259 + os: [linux]
260 + libc: [glibc]
261 +
262 + '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
263 + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
264 + cpu: [arm64]
265 + os: [linux]
266 + libc: [musl]
267 +
268 + '@img/sharp-libvips-linuxmusl-x64@1.3.3':
269 + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
270 + cpu: [x64]
271 + os: [linux]
272 + libc: [musl]
273 +
274 + '@img/sharp-linux-arm64@0.35.4':
275 + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
276 + engines: {node: '>=20.9.0'}
277 + cpu: [arm64]
278 + os: [linux]
279 + libc: [glibc]
280 +
281 + '@img/sharp-linux-arm@0.35.4':
282 + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
283 + engines: {node: '>=20.9.0'}
284 + cpu: [arm]
285 + os: [linux]
286 + libc: [glibc]
287 +
288 + '@img/sharp-linux-ppc64@0.35.4':
289 + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
290 + engines: {node: '>=20.9.0'}
291 + cpu: [ppc64]
292 + os: [linux]
293 + libc: [glibc]
294 +
295 + '@img/sharp-linux-riscv64@0.35.4':
296 + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
297 + engines: {node: '>=20.9.0'}
298 + cpu: [riscv64]
299 + os: [linux]
300 + libc: [glibc]
301 +
302 + '@img/sharp-linux-s390x@0.35.4':
303 + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
304 + engines: {node: '>=20.9.0'}
305 + cpu: [s390x]
306 + os: [linux]
307 + libc: [glibc]
308 +
309 + '@img/sharp-linux-x64@0.35.4':
310 + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
311 + engines: {node: '>=20.9.0'}
312 + cpu: [x64]
313 + os: [linux]
314 + libc: [glibc]
315 +
316 + '@img/sharp-linuxmusl-arm64@0.35.4':
317 + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
318 + engines: {node: '>=20.9.0'}
319 + cpu: [arm64]
320 + os: [linux]
321 + libc: [musl]
322 +
323 + '@img/sharp-linuxmusl-x64@0.35.4':
324 + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
325 + engines: {node: '>=20.9.0'}
326 + cpu: [x64]
327 + os: [linux]
328 + libc: [musl]
329 +
330 + '@img/sharp-wasm32@0.35.4':
331 + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
332 + engines: {node: '>=20.9.0'}
333 +
334 + '@img/sharp-webcontainers-wasm32@0.35.4':
335 + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
336 + engines: {node: '>=20.9.0'}
337 + cpu: [wasm32]
338 +
339 + '@img/sharp-win32-arm64@0.35.4':
340 + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
341 + engines: {node: '>=20.9.0'}
342 + cpu: [arm64]
343 + os: [win32]
344 +
345 + '@img/sharp-win32-ia32@0.35.4':
346 + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
347 + engines: {node: ^20.9.0}
348 + cpu: [ia32]
349 + os: [win32]
350 +
351 + '@img/sharp-win32-x64@0.35.4':
352 + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
353 + engines: {node: '>=20.9.0'}
354 + cpu: [x64]
355 + os: [win32]
356 +
357 + '@jridgewell/gen-mapping@0.3.13':
358 + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
359 +
360 + '@jridgewell/remapping@2.3.5':
361 + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
362 +
363 + '@jridgewell/resolve-uri@3.1.2':
364 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
365 + engines: {node: '>=6.0.0'}
366 +
367 + '@jridgewell/sourcemap-codec@1.6.0':
368 + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
369 +
370 + '@jridgewell/trace-mapping@0.3.31':
371 + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
372 +
373 + '@napi-rs/wasm-runtime@1.2.3':
374 + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
375 + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
376 + peerDependencies:
377 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
378 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
379 +
380 + '@next/env@16.3.4':
381 + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==}
382 +
383 + '@next/eslint-plugin-next@16.3.4':
384 + resolution: {integrity: sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA==}
385 +
386 + '@next/swc-darwin-arm64@16.3.4':
387 + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==}
388 + engines: {node: '>= 10'}
389 + cpu: [arm64]
390 + os: [darwin]
391 +
392 + '@next/swc-darwin-x64@16.3.4':
393 + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==}
394 + engines: {node: '>= 10'}
395 + cpu: [x64]
396 + os: [darwin]
397 +
398 + '@next/swc-linux-arm64-gnu@16.3.4':
399 + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==}
400 + engines: {node: '>= 10'}
401 + cpu: [arm64]
402 + os: [linux]
403 + libc: [glibc]
404 +
405 + '@next/swc-linux-arm64-musl@16.3.4':
406 + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==}
407 + engines: {node: '>= 10'}
408 + cpu: [arm64]
409 + os: [linux]
410 + libc: [musl]
411 +
412 + '@next/swc-linux-x64-gnu@16.3.4':
413 + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==}
414 + engines: {node: '>= 10'}
415 + cpu: [x64]
416 + os: [linux]
417 + libc: [glibc]
418 +
419 + '@next/swc-linux-x64-musl@16.3.4':
420 + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==}
421 + engines: {node: '>= 10'}
422 + cpu: [x64]
423 + os: [linux]
424 + libc: [musl]
425 +
426 + '@next/swc-win32-arm64-msvc@16.3.4':
427 + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==}
428 + engines: {node: '>= 10'}
429 + cpu: [arm64]
430 + os: [win32]
431 +
432 + '@next/swc-win32-x64-msvc@16.3.4':
433 + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==}
434 + engines: {node: '>= 10'}
435 + cpu: [x64]
436 + os: [win32]
437 +
438 + '@nodelib/fs.scandir@2.1.5':
439 + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
440 + engines: {node: '>= 8'}
441 +
442 + '@nodelib/fs.stat@2.0.5':
443 + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
444 + engines: {node: '>= 8'}
445 +
446 + '@nodelib/fs.walk@1.2.8':
447 + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
448 + engines: {node: '>= 8'}
449 +
450 + '@nolyfill/is-core-module@1.0.39':
451 + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
452 + engines: {node: '>=12.4.0'}
453 +
454 + '@rtsao/scc@1.1.0':
455 + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
456 +
457 + '@swc/helpers@0.5.23':
458 + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
459 +
460 + '@tailwindcss/node@4.3.3':
461 + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
462 +
463 + '@tailwindcss/oxide-android-arm64@4.3.3':
464 + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
465 + engines: {node: '>= 20'}
466 + cpu: [arm64]
467 + os: [android]
468 +
469 + '@tailwindcss/oxide-darwin-arm64@4.3.3':
470 + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
471 + engines: {node: '>= 20'}
472 + cpu: [arm64]
473 + os: [darwin]
474 +
475 + '@tailwindcss/oxide-darwin-x64@4.3.3':
476 + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
477 + engines: {node: '>= 20'}
478 + cpu: [x64]
479 + os: [darwin]
480 +
481 + '@tailwindcss/oxide-freebsd-x64@4.3.3':
482 + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
483 + engines: {node: '>= 20'}
484 + cpu: [x64]
485 + os: [freebsd]
486 +
487 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
488 + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
489 + engines: {node: '>= 20'}
490 + cpu: [arm]
491 + os: [linux]
492 +
493 + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
494 + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
495 + engines: {node: '>= 20'}
496 + cpu: [arm64]
497 + os: [linux]
498 + libc: [glibc]
499 +
500 + '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
501 + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
502 + engines: {node: '>= 20'}
503 + cpu: [arm64]
504 + os: [linux]
505 + libc: [musl]
506 +
507 + '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
508 + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
509 + engines: {node: '>= 20'}
510 + cpu: [x64]
511 + os: [linux]
512 + libc: [glibc]
513 +
514 + '@tailwindcss/oxide-linux-x64-musl@4.3.3':
515 + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
516 + engines: {node: '>= 20'}
517 + cpu: [x64]
518 + os: [linux]
519 + libc: [musl]
520 +
521 + '@tailwindcss/oxide-wasm32-wasi@4.3.3':
522 + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
523 + engines: {node: '>=14.0.0'}
524 + cpu: [wasm32]
525 + bundledDependencies:
526 + - '@napi-rs/wasm-runtime'
527 + - '@emnapi/core'
528 + - '@emnapi/runtime'
529 + - '@tybys/wasm-util'
530 + - '@emnapi/wasi-threads'
531 + - tslib
532 +
533 + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
534 + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
535 + engines: {node: '>= 20'}
536 + cpu: [arm64]
537 + os: [win32]
538 +
539 + '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
540 + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
541 + engines: {node: '>= 20'}
542 + cpu: [x64]
543 + os: [win32]
544 +
545 + '@tailwindcss/oxide@4.3.3':
546 + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
547 + engines: {node: '>= 20'}
548 +
549 + '@tailwindcss/postcss@4.3.3':
550 + resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
551 +
552 + '@tybys/wasm-util@0.10.3':
553 + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
554 +
555 + '@types/estree@1.0.9':
556 + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
557 +
558 + '@types/json-schema@7.0.15':
559 + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
560 +
561 + '@types/json5@0.0.29':
562 + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
563 +
564 + '@types/node@20.19.43':
565 + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
566 +
567 + '@types/react-dom@19.3.0':
568 + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
569 + peerDependencies:
570 + '@types/react': ^19.3.0
571 +
572 + '@types/react@19.3.0':
573 + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
574 +
575 + '@typescript-eslint/eslint-plugin@8.70.0':
576 + resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==}
577 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
578 + peerDependencies:
579 + '@typescript-eslint/parser': ^8.70.0
580 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
581 + typescript: '>=4.8.4 <6.1.0'
582 +
583 + '@typescript-eslint/parser@8.70.0':
584 + resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==}
585 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
586 + peerDependencies:
587 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
588 + typescript: '>=4.8.4 <6.1.0'
589 +
590 + '@typescript-eslint/project-service@8.70.0':
591 + resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==}
592 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
593 + peerDependencies:
594 + typescript: '>=4.8.4 <6.1.0'
595 +
596 + '@typescript-eslint/scope-manager@8.70.0':
597 + resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==}
598 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
599 +
600 + '@typescript-eslint/tsconfig-utils@8.70.0':
601 + resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==}
602 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
603 + peerDependencies:
604 + typescript: '>=4.8.4 <6.1.0'
605 +
606 + '@typescript-eslint/type-utils@8.70.0':
607 + resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==}
608 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
609 + peerDependencies:
610 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
611 + typescript: '>=4.8.4 <6.1.0'
612 +
613 + '@typescript-eslint/types@8.70.0':
614 + resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==}
615 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
616 +
617 + '@typescript-eslint/typescript-estree@8.70.0':
618 + resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==}
619 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
620 + peerDependencies:
621 + typescript: '>=4.8.4 <6.1.0'
622 +
623 + '@typescript-eslint/utils@8.70.0':
624 + resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==}
625 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
626 + peerDependencies:
627 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
628 + typescript: '>=4.8.4 <6.1.0'
629 +
630 + '@typescript-eslint/visitor-keys@8.70.0':
631 + resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==}
632 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
633 +
634 + '@unrs/resolver-binding-android-arm-eabi@1.12.2':
635 + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==}
636 + cpu: [arm]
637 + os: [android]
638 +
639 + '@unrs/resolver-binding-android-arm64@1.12.2':
640 + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==}
641 + cpu: [arm64]
642 + os: [android]
643 +
644 + '@unrs/resolver-binding-darwin-arm64@1.12.2':
645 + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==}
646 + cpu: [arm64]
647 + os: [darwin]
648 +
649 + '@unrs/resolver-binding-darwin-x64@1.12.2':
650 + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==}
651 + cpu: [x64]
652 + os: [darwin]
653 +
654 + '@unrs/resolver-binding-freebsd-x64@1.12.2':
655 + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==}
656 + cpu: [x64]
657 + os: [freebsd]
658 +
659 + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
660 + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==}
661 + cpu: [arm]
662 + os: [linux]
663 +
664 + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
665 + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==}
666 + cpu: [arm]
667 + os: [linux]
668 +
669 + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
670 + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==}
671 + cpu: [arm64]
672 + os: [linux]
673 + libc: [glibc]
674 +
675 + '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
676 + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==}
677 + cpu: [arm64]
678 + os: [linux]
679 + libc: [musl]
680 +
681 + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
682 + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==}
683 + cpu: [loong64]
684 + os: [linux]
685 + libc: [glibc]
686 +
687 + '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
688 + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==}
689 + cpu: [loong64]
690 + os: [linux]
691 + libc: [musl]
692 +
693 + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
694 + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==}
695 + cpu: [ppc64]
696 + os: [linux]
697 + libc: [glibc]
698 +
699 + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
700 + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==}
701 + cpu: [riscv64]
702 + os: [linux]
703 + libc: [glibc]
704 +
705 + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
706 + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==}
707 + cpu: [riscv64]
708 + os: [linux]
709 + libc: [musl]
710 +
711 + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
712 + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==}
713 + cpu: [s390x]
714 + os: [linux]
715 + libc: [glibc]
716 +
717 + '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
718 + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==}
719 + cpu: [x64]
720 + os: [linux]
721 + libc: [glibc]
722 +
723 + '@unrs/resolver-binding-linux-x64-musl@1.12.2':
724 + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==}
725 + cpu: [x64]
726 + os: [linux]
727 + libc: [musl]
728 +
729 + '@unrs/resolver-binding-openharmony-arm64@1.12.2':
730 + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==}
731 + cpu: [arm64]
732 + os: [openharmony]
733 +
734 + '@unrs/resolver-binding-wasm32-wasi@1.12.2':
735 + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==}
736 + engines: {node: '>=14.0.0'}
737 + cpu: [wasm32]
738 +
739 + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
740 + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==}
741 + cpu: [arm64]
742 + os: [win32]
743 +
744 + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
745 + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==}
746 + cpu: [ia32]
747 + os: [win32]
748 +
749 + '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
750 + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==}
751 + cpu: [x64]
752 + os: [win32]
753 +
754 + acorn-jsx@5.3.2:
755 + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
756 + peerDependencies:
757 + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
758 +
759 + acorn@8.18.0:
760 + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
761 + engines: {node: '>=0.4.0'}
762 + hasBin: true
763 +
764 + ajv@6.15.0:
765 + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
766 +
767 + ansi-styles@4.3.0:
768 + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
769 + engines: {node: '>=8'}
770 +
771 + argparse@2.0.1:
772 + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
773 +
774 + aria-query@5.3.2:
775 + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
776 + engines: {node: '>= 0.4'}
777 +
778 + array-buffer-byte-length@1.0.2:
779 + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
780 + engines: {node: '>= 0.4'}
781 +
782 + array-includes@3.2.0:
783 + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==}
784 + engines: {node: '>= 0.4'}
785 +
786 + array.prototype.findlast@1.2.5:
787 + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
788 + engines: {node: '>= 0.4'}
789 +
790 + array.prototype.findlastindex@1.2.6:
791 + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==}
792 + engines: {node: '>= 0.4'}
793 +
794 + array.prototype.flat@1.3.3:
795 + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
796 + engines: {node: '>= 0.4'}
797 +
798 + array.prototype.flatmap@1.3.3:
799 + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
800 + engines: {node: '>= 0.4'}
801 +
802 + array.prototype.tosorted@1.1.4:
803 + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==}
804 + engines: {node: '>= 0.4'}
805 +
806 + arraybuffer.prototype.slice@1.0.4:
807 + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
808 + engines: {node: '>= 0.4'}
809 +
810 + ast-types-flow@0.0.8:
811 + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
812 +
813 + async-function@1.0.0:
814 + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
815 + engines: {node: '>= 0.4'}
816 +
817 + available-typed-arrays@1.0.7:
818 + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
819 + engines: {node: '>= 0.4'}
820 +
821 + axe-core@4.13.0:
822 + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
823 + engines: {node: '>=4'}
824 +
825 + axobject-query@4.1.0:
826 + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
827 + engines: {node: '>= 0.4'}
828 +
829 + balanced-match@1.0.2:
830 + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
831 +
832 + balanced-match@4.0.4:
833 + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
834 + engines: {node: 18 || 20 || >=22}
835 +
836 + baseline-browser-mapping@2.11.21:
837 + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==}
838 + engines: {node: '>=6.0.0'}
839 + hasBin: true
840 +
841 + brace-expansion@1.1.18:
842 + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
843 +
844 + brace-expansion@5.0.9:
845 + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
846 + engines: {node: 20 || >=22}
847 +
848 + braces@3.0.3:
849 + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
850 + engines: {node: '>=8'}
851 +
852 + browserslist@4.28.9:
853 + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==}
854 + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
855 + hasBin: true
856 +
857 + call-bind-apply-helpers@1.0.2:
858 + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
859 + engines: {node: '>= 0.4'}
860 +
861 + call-bind@1.0.9:
862 + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
863 + engines: {node: '>= 0.4'}
864 +
865 + call-bound@1.0.4:
866 + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
867 + engines: {node: '>= 0.4'}
868 +
869 + callsites@3.1.0:
870 + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
871 + engines: {node: '>=6'}
872 +
873 + caniuse-lite@1.0.30001810:
874 + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
875 +
876 + chalk@4.1.2:
877 + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
878 + engines: {node: '>=10'}
879 +
880 + client-only@0.0.1:
881 + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
882 +
883 + color-convert@2.0.1:
884 + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
885 + engines: {node: '>=7.0.0'}
886 +
887 + color-name@1.1.4:
888 + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
889 +
890 + concat-map@0.0.1:
891 + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
892 +
893 + convert-source-map@2.0.0:
894 + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
895 +
896 + cross-spawn@7.0.6:
897 + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
898 + engines: {node: '>= 8'}
899 +
900 + csstype@3.2.3:
901 + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
902 +
903 + damerau-levenshtein@1.0.8:
904 + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
905 +
906 + data-view-buffer@1.0.2:
907 + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
908 + engines: {node: '>= 0.4'}
909 +
910 + data-view-byte-length@1.0.2:
911 + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
912 + engines: {node: '>= 0.4'}
913 +
914 + data-view-byte-offset@1.0.1:
915 + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
916 + engines: {node: '>= 0.4'}
917 +
918 + debug@3.2.7:
919 + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
920 + peerDependencies:
921 + supports-color: '*'
922 + peerDependenciesMeta:
923 + supports-color:
924 + optional: true
925 +
926 + debug@4.4.3:
927 + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
928 + engines: {node: '>=6.0'}
929 + peerDependencies:
930 + supports-color: '*'
931 + peerDependenciesMeta:
932 + supports-color:
933 + optional: true
934 +
935 + deep-is@0.1.4:
936 + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
937 +
938 + define-data-property@1.1.4:
939 + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
940 + engines: {node: '>= 0.4'}
941 +
942 + define-properties@1.2.1:
943 + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
944 + engines: {node: '>= 0.4'}
945 +
946 + detect-libc@2.1.2:
947 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
948 + engines: {node: '>=8'}
949 +
950 + doctrine@2.1.0:
951 + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
952 + engines: {node: '>=0.10.0'}
953 +
954 + dunder-proto@1.0.1:
955 + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
956 + engines: {node: '>= 0.4'}
957 +
958 + electron-to-chromium@1.5.425:
959 + resolution: {integrity: sha512-QvPtl41EUOnuT1HBvMKgxXRIaHNcagBPs50u7VULzhZXaGfqTbZyE16LQsctZ/RQHlGu+FOWeDTR4mY6YbeF1g==}
960 +
961 + emoji-regex@9.2.2:
962 + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
963 +
964 + enhanced-resolve@5.24.5:
965 + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
966 + engines: {node: '>=10.13.0'}
967 +
968 + es-abstract-get@1.0.0:
969 + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
970 + engines: {node: '>= 0.4'}
971 +
972 + es-abstract@1.24.2:
973 + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
974 + engines: {node: '>= 0.4'}
975 +
976 + es-define-property@1.0.1:
977 + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
978 + engines: {node: '>= 0.4'}
979 +
980 + es-errors@1.3.0:
981 + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
982 + engines: {node: '>= 0.4'}
983 +
984 + es-iterator-helpers@1.4.0:
985 + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==}
986 + engines: {node: '>= 0.4'}
987 +
988 + es-object-atoms@1.1.2:
989 + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
990 + engines: {node: '>= 0.4'}
991 +
992 + es-set-tostringtag@2.1.0:
993 + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
994 + engines: {node: '>= 0.4'}
995 +
996 + es-shim-unscopables@1.1.0:
997 + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
998 + engines: {node: '>= 0.4'}
999 +
1000 + es-to-primitive@1.3.4:
1001 + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
1002 + engines: {node: '>= 0.4'}
1003 +
1004 + escalade@3.2.0:
1005 + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
1006 + engines: {node: '>=6'}
1007 +
1008 + escape-string-regexp@4.0.0:
1009 + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
1010 + engines: {node: '>=10'}
1011 +
1012 + eslint-config-next@16.3.4:
1013 + resolution: {integrity: sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA==}
1014 + peerDependencies:
1015 + eslint: '>=9.0.0'
1016 + typescript: '>=3.3.1'
1017 + peerDependenciesMeta:
1018 + typescript:
1019 + optional: true
1020 +
1021 + eslint-import-resolver-node@0.3.10:
1022 + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==}
1023 +
1024 + eslint-import-resolver-typescript@3.10.1:
1025 + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==}
1026 + engines: {node: ^14.18.0 || >=16.0.0}
1027 + peerDependencies:
1028 + eslint: '*'
1029 + eslint-plugin-import: '*'
1030 + eslint-plugin-import-x: '*'
1031 + peerDependenciesMeta:
1032 + eslint-plugin-import:
1033 + optional: true
1034 + eslint-plugin-import-x:
1035 + optional: true
1036 +
1037 + eslint-module-utils@2.14.0:
1038 + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==}
1039 + engines: {node: '>=4'}
1040 + peerDependencies:
1041 + '@typescript-eslint/parser': '*'
1042 + eslint: '*'
1043 + eslint-import-resolver-node: '*'
1044 + eslint-import-resolver-typescript: '*'
1045 + eslint-import-resolver-webpack: '*'
1046 + peerDependenciesMeta:
1047 + '@typescript-eslint/parser':
1048 + optional: true
1049 + eslint:
1050 + optional: true
1051 + eslint-import-resolver-node:
1052 + optional: true
1053 + eslint-import-resolver-typescript:
1054 + optional: true
1055 + eslint-import-resolver-webpack:
1056 + optional: true
1057 +
1058 + eslint-plugin-import@2.32.0:
1059 + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
1060 + engines: {node: '>=4'}
1061 + peerDependencies:
1062 + '@typescript-eslint/parser': '*'
1063 + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9
1064 + peerDependenciesMeta:
1065 + '@typescript-eslint/parser':
1066 + optional: true
1067 +
1068 + eslint-plugin-jsx-a11y@6.10.2:
1069 + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
1070 + engines: {node: '>=4.0'}
1071 + peerDependencies:
1072 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
1073 +
1074 + eslint-plugin-react-hooks@7.1.1:
1075 + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
1076 + engines: {node: '>=18'}
1077 + peerDependencies:
1078 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
1079 +
1080 + eslint-plugin-react@7.37.5:
1081 + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
1082 + engines: {node: '>=4'}
1083 + peerDependencies:
1084 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
1085 +
1086 + eslint-scope@8.4.0:
1087 + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
1088 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1089 +
1090 + eslint-visitor-keys@3.4.3:
1091 + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
1092 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
1093 +
1094 + eslint-visitor-keys@4.2.1:
1095 + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
1096 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1097 +
1098 + eslint-visitor-keys@5.0.1:
1099 + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
1100 + engines: {node: ^20.19.0 || ^22.13.0 || >=24}
1101 +
1102 + eslint@9.39.5:
1103 + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
1104 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1105 + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
1106 + hasBin: true
1107 + peerDependencies:
1108 + jiti: '*'
1109 + peerDependenciesMeta:
1110 + jiti:
1111 + optional: true
1112 +
1113 + espree@10.4.0:
1114 + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
1115 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1116 +
1117 + esquery@1.7.0:
1118 + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
1119 + engines: {node: '>=0.10'}
1120 +
1121 + esrecurse@4.3.0:
1122 + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
1123 + engines: {node: '>=4.0'}
1124 +
1125 + estraverse@5.3.0:
1126 + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
1127 + engines: {node: '>=4.0'}
1128 +
1129 + esutils@2.0.3:
1130 + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
1131 + engines: {node: '>=0.10.0'}
1132 +
1133 + fast-deep-equal@3.1.3:
1134 + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
1135 +
1136 + fast-glob@3.3.1:
1137 + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
1138 + engines: {node: '>=8.6.0'}
1139 +
1140 + fast-json-stable-stringify@2.1.0:
1141 + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
1142 +
1143 + fast-levenshtein@2.0.6:
1144 + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
1145 +
1146 + fastq@1.20.3:
1147 + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==}
1148 +
1149 + fdir@6.5.0:
1150 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
1151 + engines: {node: '>=12.0.0'}
1152 + peerDependencies:
1153 + picomatch: ^3 || ^4
1154 + peerDependenciesMeta:
1155 + picomatch:
1156 + optional: true
1157 +
1158 + file-entry-cache@8.0.0:
1159 + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
1160 + engines: {node: '>=16.0.0'}
1161 +
1162 + fill-range@7.1.1:
1163 + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
1164 + engines: {node: '>=8'}
1165 +
1166 + find-up@5.0.0:
1167 + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
1168 + engines: {node: '>=10'}
1169 +
1170 + flat-cache@4.0.1:
1171 + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
1172 + engines: {node: '>=16'}
1173 +
1174 + flatted@3.4.4:
1175 + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
1176 +
1177 + for-each@0.3.5:
1178 + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
1179 + engines: {node: '>= 0.4'}
1180 +
1181 + function-bind@1.1.2:
1182 + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
1183 +
1184 + function.prototype.name@1.2.0:
1185 + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==}
1186 + engines: {node: '>= 0.4'}
1187 +
1188 + functions-have-names@1.2.3:
1189 + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
1190 +
1191 + generator-function@2.0.1:
1192 + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
1193 + engines: {node: '>= 0.4'}
1194 +
1195 + gensync@1.0.0-beta.2:
1196 + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
1197 + engines: {node: '>=6.9.0'}
1198 +
1199 + get-intrinsic@1.3.0:
1200 + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
1201 + engines: {node: '>= 0.4'}
1202 +
1203 + get-proto@1.0.1:
1204 + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
1205 + engines: {node: '>= 0.4'}
1206 +
1207 + get-symbol-description@1.1.0:
1208 + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
1209 + engines: {node: '>= 0.4'}
1210 +
1211 + get-tsconfig@4.14.3:
1212 + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==}
1213 +
1214 + glob-parent@5.1.2:
1215 + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
1216 + engines: {node: '>= 6'}
1217 +
1218 + glob-parent@6.0.2:
1219 + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
1220 + engines: {node: '>=10.13.0'}
1221 +
1222 + globals@14.0.0:
1223 + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
1224 + engines: {node: '>=18'}
1225 +
1226 + globals@16.4.0:
1227 + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==}
1228 + engines: {node: '>=18'}
1229 +
1230 + globalthis@1.0.4:
1231 + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
1232 + engines: {node: '>= 0.4'}
1233 +
1234 + gopd@1.2.0:
1235 + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
1236 + engines: {node: '>= 0.4'}
1237 +
1238 + graceful-fs@4.2.11:
1239 + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
1240 +
1241 + has-bigints@1.1.0:
1242 + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
1243 + engines: {node: '>= 0.4'}
1244 +
1245 + has-flag@4.0.0:
1246 + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
1247 + engines: {node: '>=8'}
1248 +
1249 + has-property-descriptors@1.0.2:
1250 + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
1251 +
1252 + has-proto@1.2.0:
1253 + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
1254 + engines: {node: '>= 0.4'}
1255 +
1256 + has-symbols@1.1.0:
1257 + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
1258 + engines: {node: '>= 0.4'}
1259 +
1260 + has-tostringtag@1.0.2:
1261 + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
1262 + engines: {node: '>= 0.4'}
1263 +
1264 + hasown@2.0.4:
1265 + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
1266 + engines: {node: '>= 0.4'}
1267 +
1268 + hermes-estree@0.25.1:
1269 + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
1270 +
1271 + hermes-parser@0.25.1:
1272 + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
1273 +
1274 + ignore@5.3.2:
1275 + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
1276 + engines: {node: '>= 4'}
1277 +
1278 + ignore@7.0.9:
1279 + resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==}
1280 + engines: {node: '>= 4'}
1281 +
1282 + import-fresh@3.3.1:
1283 + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
1284 + engines: {node: '>=6'}
1285 +
1286 + imurmurhash@0.1.4:
1287 + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
1288 + engines: {node: '>=0.8.19'}
1289 +
1290 + internal-slot@1.1.0:
1291 + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
1292 + engines: {node: '>= 0.4'}
1293 +
1294 + is-array-buffer@3.0.5:
1295 + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
1296 + engines: {node: '>= 0.4'}
1297 +
1298 + is-async-function@2.1.1:
1299 + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
1300 + engines: {node: '>= 0.4'}
1301 +
1302 + is-bigint@1.1.0:
1303 + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
1304 + engines: {node: '>= 0.4'}
1305 +
1306 + is-boolean-object@1.2.2:
1307 + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
1308 + engines: {node: '>= 0.4'}
1309 +
1310 + is-bun-module@2.0.0:
1311 + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
1312 +
1313 + is-callable@1.2.7:
1314 + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
1315 + engines: {node: '>= 0.4'}
1316 +
1317 + is-core-module@2.16.2:
1318 + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
1319 + engines: {node: '>= 0.4'}
1320 +
1321 + is-data-view@1.0.2:
1322 + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
1323 + engines: {node: '>= 0.4'}
1324 +
1325 + is-date-object@1.1.0:
1326 + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
1327 + engines: {node: '>= 0.4'}
1328 +
1329 + is-document.all@1.0.0:
1330 + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
1331 + engines: {node: '>= 0.4'}
1332 +
1333 + is-extglob@2.1.1:
1334 + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
1335 + engines: {node: '>=0.10.0'}
1336 +
1337 + is-finalizationregistry@1.1.1:
1338 + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
1339 + engines: {node: '>= 0.4'}
1340 +
1341 + is-generator-function@1.1.2:
1342 + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
1343 + engines: {node: '>= 0.4'}
1344 +
1345 + is-glob@4.0.3:
1346 + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
1347 + engines: {node: '>=0.10.0'}
1348 +
1349 + is-map@2.0.3:
1350 + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
1351 + engines: {node: '>= 0.4'}
1352 +
1353 + is-negative-zero@2.0.3:
1354 + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
1355 + engines: {node: '>= 0.4'}
1356 +
1357 + is-number-object@1.1.1:
1358 + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
1359 + engines: {node: '>= 0.4'}
1360 +
1361 + is-number@7.0.0:
1362 + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
1363 + engines: {node: '>=0.12.0'}
1364 +
1365 + is-regex@1.2.1:
1366 + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
1367 + engines: {node: '>= 0.4'}
1368 +
1369 + is-set@2.0.3:
1370 + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
1371 + engines: {node: '>= 0.4'}
1372 +
1373 + is-shared-array-buffer@1.0.4:
1374 + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
1375 + engines: {node: '>= 0.4'}
1376 +
1377 + is-string@1.1.1:
1378 + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
1379 + engines: {node: '>= 0.4'}
1380 +
1381 + is-symbol@1.1.1:
1382 + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
1383 + engines: {node: '>= 0.4'}
1384 +
1385 + is-typed-array@1.1.15:
1386 + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
1387 + engines: {node: '>= 0.4'}
1388 +
1389 + is-weakmap@2.0.2:
1390 + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
1391 + engines: {node: '>= 0.4'}
1392 +
1393 + is-weakref@1.1.1:
1394 + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
1395 + engines: {node: '>= 0.4'}
1396 +
1397 + is-weakset@2.0.4:
1398 + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
1399 + engines: {node: '>= 0.4'}
1400 +
1401 + isarray@2.0.5:
1402 + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
1403 +
1404 + isexe@2.0.0:
1405 + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
1406 +
1407 + iterator.prototype@1.1.5:
1408 + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
1409 + engines: {node: '>= 0.4'}
1410 +
1411 + jiti@2.7.0:
1412 + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
1413 + hasBin: true
1414 +
1415 + js-tokens@4.0.0:
1416 + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
1417 +
1418 + js-yaml@4.3.2:
1419 + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
1420 + hasBin: true
1421 +
1422 + jsesc@3.1.0:
1423 + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
1424 + engines: {node: '>=6'}
1425 + hasBin: true
1426 +
1427 + json-buffer@3.0.1:
1428 + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
1429 +
1430 + json-schema-traverse@0.4.1:
1431 + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
1432 +
1433 + json-stable-stringify-without-jsonify@1.0.1:
1434 + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
1435 +
1436 + json5@1.0.2:
1437 + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
1438 + hasBin: true
1439 +
1440 + json5@2.2.3:
1441 + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
1442 + engines: {node: '>=6'}
1443 + hasBin: true
1444 +
1445 + jsx-ast-utils@3.3.5:
1446 + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
1447 + engines: {node: '>=4.0'}
1448 +
1449 + keyv@4.5.4:
1450 + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
1451 +
1452 + language-subtag-registry@0.3.23:
1453 + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
1454 +
1455 + language-tags@1.0.9:
1456 + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
1457 + engines: {node: '>=0.10'}
1458 +
1459 + levn@0.4.1:
1460 + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
1461 + engines: {node: '>= 0.8.0'}
1462 +
1463 + lightningcss-android-arm64@1.32.0:
1464 + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
1465 + engines: {node: '>= 12.0.0'}
1466 + cpu: [arm64]
1467 + os: [android]
1468 +
1469 + lightningcss-darwin-arm64@1.32.0:
1470 + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
1471 + engines: {node: '>= 12.0.0'}
1472 + cpu: [arm64]
1473 + os: [darwin]
1474 +
1475 + lightningcss-darwin-x64@1.32.0:
1476 + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
1477 + engines: {node: '>= 12.0.0'}
1478 + cpu: [x64]
1479 + os: [darwin]
1480 +
1481 + lightningcss-freebsd-x64@1.32.0:
1482 + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
1483 + engines: {node: '>= 12.0.0'}
1484 + cpu: [x64]
1485 + os: [freebsd]
1486 +
1487 + lightningcss-linux-arm-gnueabihf@1.32.0:
1488 + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
1489 + engines: {node: '>= 12.0.0'}
1490 + cpu: [arm]
1491 + os: [linux]
1492 +
1493 + lightningcss-linux-arm64-gnu@1.32.0:
1494 + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
1495 + engines: {node: '>= 12.0.0'}
1496 + cpu: [arm64]
1497 + os: [linux]
1498 + libc: [glibc]
1499 +
1500 + lightningcss-linux-arm64-musl@1.32.0:
1501 + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
1502 + engines: {node: '>= 12.0.0'}
1503 + cpu: [arm64]
1504 + os: [linux]
1505 + libc: [musl]
1506 +
1507 + lightningcss-linux-x64-gnu@1.32.0:
1508 + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
1509 + engines: {node: '>= 12.0.0'}
1510 + cpu: [x64]
1511 + os: [linux]
1512 + libc: [glibc]
1513 +
1514 + lightningcss-linux-x64-musl@1.32.0:
1515 + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
1516 + engines: {node: '>= 12.0.0'}
1517 + cpu: [x64]
1518 + os: [linux]
1519 + libc: [musl]
1520 +
1521 + lightningcss-win32-arm64-msvc@1.32.0:
1522 + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
1523 + engines: {node: '>= 12.0.0'}
1524 + cpu: [arm64]
1525 + os: [win32]
1526 +
1527 + lightningcss-win32-x64-msvc@1.32.0:
1528 + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
1529 + engines: {node: '>= 12.0.0'}
1530 + cpu: [x64]
1531 + os: [win32]
1532 +
1533 + lightningcss@1.32.0:
1534 + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
1535 + engines: {node: '>= 12.0.0'}
1536 +
1537 + locate-path@6.0.0:
1538 + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
1539 + engines: {node: '>=10'}
1540 +
1541 + lodash.merge@4.6.2:
1542 + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
1543 +
1544 + loose-envify@1.4.0:
1545 + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
1546 + hasBin: true
1547 +
1548 + lru-cache@5.1.1:
1549 + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
1550 +
1551 + magic-string@0.30.21:
1552 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
1553 +
1554 + math-intrinsics@1.1.0:
1555 + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
1556 + engines: {node: '>= 0.4'}
1557 +
1558 + merge2@1.4.1:
1559 + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
1560 + engines: {node: '>= 8'}
1561 +
1562 + micromatch@4.0.8:
1563 + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
1564 + engines: {node: '>=8.6'}
1565 +
1566 + minimatch@10.2.6:
1567 + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
1568 + engines: {node: 18 || 20 || >=22}
1569 +
1570 + minimatch@3.1.5:
1571 + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
1572 +
1573 + minimist@1.2.8:
1574 + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
1575 +
1576 + ms@2.1.3:
1577 + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
1578 +
1579 + nanoid@3.3.18:
1580 + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
1581 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
1582 + hasBin: true
1583 +
1584 + napi-postinstall@0.3.4:
1585 + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
1586 + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
1587 + hasBin: true
1588 +
1589 + natural-compare@1.4.0:
1590 + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
1591 +
1592 + next@16.3.4:
1593 + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==}
1594 + engines: {node: '>=20.9.0'}
1595 + hasBin: true
1596 + peerDependencies:
1597 + '@opentelemetry/api': ^1.1.0
1598 + '@playwright/test': ^1.51.1
1599 + babel-plugin-react-compiler: '*'
1600 + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
1601 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
1602 + sass: ^1.3.0
1603 + peerDependenciesMeta:
1604 + '@opentelemetry/api':
1605 + optional: true
1606 + '@playwright/test':
1607 + optional: true
1608 + babel-plugin-react-compiler:
1609 + optional: true
1610 + sass:
1611 + optional: true
1612 +
1613 + node-exports-info@1.6.2:
1614 + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==}
1615 + engines: {node: '>= 0.4'}
1616 +
1617 + node-releases@2.0.55:
1618 + resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==}
1619 + engines: {node: '>=18'}
1620 +
1621 + object-assign@4.1.1:
1622 + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
1623 + engines: {node: '>=0.10.0'}
1624 +
1625 + object-inspect@1.13.4:
1626 + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
1627 + engines: {node: '>= 0.4'}
1628 +
1629 + object-keys@1.1.1:
1630 + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
1631 + engines: {node: '>= 0.4'}
1632 +
1633 + object.assign@4.1.7:
1634 + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
1635 + engines: {node: '>= 0.4'}
1636 +
1637 + object.entries@1.1.9:
1638 + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==}
1639 + engines: {node: '>= 0.4'}
1640 +
1641 + object.fromentries@2.0.8:
1642 + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
1643 + engines: {node: '>= 0.4'}
1644 +
1645 + object.groupby@1.0.3:
1646 + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==}
1647 + engines: {node: '>= 0.4'}
1648 +
1649 + object.values@1.2.1:
1650 + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
1651 + engines: {node: '>= 0.4'}
1652 +
1653 + optionator@0.9.4:
1654 + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
1655 + engines: {node: '>= 0.8.0'}
1656 +
1657 + own-keys@1.0.2:
1658 + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
1659 + engines: {node: '>= 0.4'}
1660 +
1661 + p-limit@3.1.0:
1662 + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
1663 + engines: {node: '>=10'}
1664 +
1665 + p-locate@5.0.0:
1666 + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
1667 + engines: {node: '>=10'}
1668 +
1669 + parent-module@1.0.1:
1670 + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
1671 + engines: {node: '>=6'}
1672 +
1673 + path-exists@4.0.0:
1674 + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
1675 + engines: {node: '>=8'}
1676 +
1677 + path-key@3.1.1:
1678 + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
1679 + engines: {node: '>=8'}
1680 +
1681 + path-parse@1.0.7:
1682 + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
1683 +
1684 + picocolors@1.1.1:
1685 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
1686 +
1687 + picomatch@2.3.2:
1688 + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
1689 + engines: {node: '>=8.6'}
1690 +
1691 + picomatch@4.0.7:
1692 + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
1693 + engines: {node: '>=12'}
1694 +
1695 + possible-typed-array-names@1.1.0:
1696 + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
1697 + engines: {node: '>= 0.4'}
1698 +
1699 + postcss@8.5.23:
1700 + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
1701 + engines: {node: ^10 || ^12 || >=14}
1702 +
1703 + postcss@8.5.28:
1704 + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
1705 + engines: {node: ^10 || ^12 || >=14}
1706 +
1707 + prelude-ls@1.2.1:
1708 + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
1709 + engines: {node: '>= 0.8.0'}
1710 +
1711 + prop-types@15.8.1:
1712 + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
1713 +
1714 + punycode@2.3.1:
1715 + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
1716 + engines: {node: '>=6'}
1717 +
1718 + queue-microtask@1.2.3:
1719 + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
1720 +
1721 + react-dom@19.2.8:
1722 + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
1723 + peerDependencies:
1724 + react: ^19.2.8
1725 +
1726 + react-is@16.13.1:
1727 + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
1728 +
1729 + react@19.2.8:
1730 + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
1731 + engines: {node: '>=0.10.0'}
1732 +
1733 + reflect.getprototypeof@1.0.10:
1734 + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
1735 + engines: {node: '>= 0.4'}
1736 +
1737 + regexp.prototype.flags@1.5.4:
1738 + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
1739 + engines: {node: '>= 0.4'}
1740 +
1741 + resolve-from@4.0.0:
1742 + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
1743 + engines: {node: '>=4'}
1744 +
1745 + resolve-pkg-maps@1.0.0:
1746 + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
1747 +
1748 + resolve@2.0.0-next.7:
1749 + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==}
1750 + engines: {node: '>= 0.4'}
1751 + hasBin: true
1752 +
1753 + reusify@1.1.0:
1754 + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
1755 + engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
1756 +
1757 + run-parallel@1.2.0:
1758 + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
1759 +
1760 + safe-array-concat@1.1.4:
1761 + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
1762 + engines: {node: '>=0.4'}
1763 +
1764 + safe-push-apply@1.0.0:
1765 + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
1766 + engines: {node: '>= 0.4'}
1767 +
1768 + safe-regex-test@1.1.0:
1769 + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
1770 + engines: {node: '>= 0.4'}
1771 +
1772 + scheduler@0.27.0:
1773 + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
1774 +
1775 + semver@6.3.1:
1776 + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
1777 + hasBin: true
1778 +
1779 + semver@7.8.5:
1780 + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
1781 + engines: {node: '>=10'}
1782 + hasBin: true
1783 +
1784 + set-function-length@1.2.2:
1785 + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
1786 + engines: {node: '>= 0.4'}
1787 +
1788 + set-function-name@2.0.2:
1789 + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
1790 + engines: {node: '>= 0.4'}
1791 +
1792 + set-proto@1.0.0:
1793 + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
1794 + engines: {node: '>= 0.4'}
1795 +
1796 + sharp@0.35.4:
1797 + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
1798 + engines: {node: '>=20.9.0'}
1799 + peerDependencies:
1800 + '@types/node': '*'
1801 + peerDependenciesMeta:
1802 + '@types/node':
1803 + optional: true
1804 +
1805 + shebang-command@2.0.0:
1806 + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
1807 + engines: {node: '>=8'}
1808 +
1809 + shebang-regex@3.0.0:
1810 + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
1811 + engines: {node: '>=8'}
1812 +
1813 + side-channel-list@1.0.1:
1814 + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
1815 + engines: {node: '>= 0.4'}
1816 +
1817 + side-channel-map@1.0.1:
1818 + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
1819 + engines: {node: '>= 0.4'}
1820 +
1821 + side-channel-weakmap@1.0.2:
1822 + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
1823 + engines: {node: '>= 0.4'}
1824 +
1825 + side-channel@1.1.1:
1826 + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
1827 + engines: {node: '>= 0.4'}
1828 +
1829 + source-map-js@1.2.1:
1830 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
1831 + engines: {node: '>=0.10.0'}
1832 +
1833 + stable-hash@0.0.5:
1834 + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
1835 +
1836 + stop-iteration-iterator@1.1.0:
1837 + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
1838 + engines: {node: '>= 0.4'}
1839 +
1840 + string.prototype.includes@2.0.1:
1841 + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
1842 + engines: {node: '>= 0.4'}
1843 +
1844 + string.prototype.matchall@4.1.0:
1845 + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==}
1846 + engines: {node: '>= 0.4'}
1847 +
1848 + string.prototype.repeat@1.0.0:
1849 + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==}
1850 +
1851 + string.prototype.trim@1.2.11:
1852 + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==}
1853 + engines: {node: '>= 0.4'}
1854 +
1855 + string.prototype.trimend@1.0.10:
1856 + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==}
1857 + engines: {node: '>= 0.4'}
1858 +
1859 + string.prototype.trimstart@1.0.8:
1860 + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
1861 + engines: {node: '>= 0.4'}
1862 +
1863 + strip-bom@3.0.0:
1864 + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
1865 + engines: {node: '>=4'}
1866 +
1867 + strip-json-comments@3.1.1:
1868 + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
1869 + engines: {node: '>=8'}
1870 +
1871 + styled-jsx@5.1.6:
1872 + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
1873 + engines: {node: '>= 12.0.0'}
1874 + peerDependencies:
1875 + '@babel/core': '*'
1876 + babel-plugin-macros: '*'
1877 + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
1878 + peerDependenciesMeta:
1879 + '@babel/core':
1880 + optional: true
1881 + babel-plugin-macros:
1882 + optional: true
1883 +
1884 + supports-color@7.2.0:
1885 + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
1886 + engines: {node: '>=8'}
1887 +
1888 + supports-preserve-symlinks-flag@1.0.0:
1889 + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
1890 + engines: {node: '>= 0.4'}
1891 +
1892 + tailwindcss@4.3.3:
1893 + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
1894 +
1895 + tapable@2.3.3:
1896 + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
1897 + engines: {node: '>=6'}
1898 +
1899 + tinyglobby@0.2.17:
1900 + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
1901 + engines: {node: '>=12.0.0'}
1902 +
1903 + to-regex-range@5.0.1:
1904 + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
1905 + engines: {node: '>=8.0'}
1906 +
1907 + ts-api-utils@2.5.0:
1908 + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
1909 + engines: {node: '>=18.12'}
1910 + peerDependencies:
1911 + typescript: '>=4.8.4'
1912 +
1913 + tsconfig-paths@3.15.0:
1914 + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
1915 +
1916 + tslib@2.8.1:
1917 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
1918 +
1919 + type-check@0.4.0:
1920 + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
1921 + engines: {node: '>= 0.8.0'}
1922 +
1923 + typed-array-buffer@1.0.3:
1924 + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
1925 + engines: {node: '>= 0.4'}
1926 +
1927 + typed-array-byte-length@1.0.3:
1928 + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
1929 + engines: {node: '>= 0.4'}
1930 +
1931 + typed-array-byte-offset@1.0.4:
1932 + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
1933 + engines: {node: '>= 0.4'}
1934 +
1935 + typed-array-length@1.0.8:
1936 + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
1937 + engines: {node: '>= 0.4'}
1938 +
1939 + typescript-eslint@8.70.0:
1940 + resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==}
1941 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1942 + peerDependencies:
1943 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
1944 + typescript: '>=4.8.4 <6.1.0'
1945 +
1946 + typescript@5.9.3:
1947 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
1948 + engines: {node: '>=14.17'}
1949 + hasBin: true
1950 +
1951 + unbox-primitive@1.1.0:
1952 + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
1953 + engines: {node: '>= 0.4'}
1954 +
1955 + undici-types@6.21.0:
1956 + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
1957 +
1958 + unrs-resolver@1.12.2:
1959 + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
1960 +
1961 + update-browserslist-db@1.3.2:
1962 + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
1963 + hasBin: true
1964 + peerDependencies:
1965 + browserslist: '>= 4.21.0'
1966 +
1967 + uri-js@4.4.1:
1968 + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
1969 +
1970 + which-boxed-primitive@1.1.1:
1971 + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
1972 + engines: {node: '>= 0.4'}
1973 +
1974 + which-builtin-type@1.2.1:
1975 + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
1976 + engines: {node: '>= 0.4'}
1977 +
1978 + which-collection@1.0.2:
1979 + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
1980 + engines: {node: '>= 0.4'}
1981 +
1982 + which-typed-array@1.1.22:
1983 + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
1984 + engines: {node: '>= 0.4'}
1985 +
1986 + which@2.0.2:
1987 + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
1988 + engines: {node: '>= 8'}
1989 + hasBin: true
1990 +
1991 + word-wrap@1.2.5:
1992 + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
1993 + engines: {node: '>=0.10.0'}
1994 +
1995 + yallist@3.1.1:
1996 + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
1997 +
1998 + yocto-queue@0.1.0:
1999 + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
2000 + engines: {node: '>=10'}
2001 +
2002 + zod-validation-error@4.0.2:
2003 + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
2004 + engines: {node: '>=18.0.0'}
2005 + peerDependencies:
2006 + zod: ^3.25.0 || ^4.0.0
2007 +
2008 + zod@4.6.1:
2009 + resolution: {integrity: sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==}
2010 +
2011 +snapshots:
2012 +
2013 + '@alloc/quick-lru@5.3.0': {}
2014 +
2015 + '@babel/code-frame@7.29.7':
2016 + dependencies:
2017 + '@babel/helper-validator-identifier': 7.29.7
2018 + js-tokens: 4.0.0
2019 + picocolors: 1.1.1
2020 +
2021 + '@babel/compat-data@7.29.7': {}
2022 +
2023 + '@babel/core@7.29.7':
2024 + dependencies:
2025 + '@babel/code-frame': 7.29.7
2026 + '@babel/generator': 7.29.8
2027 + '@babel/helper-compilation-targets': 7.29.7
2028 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
2029 + '@babel/helpers': 7.29.7
2030 + '@babel/parser': 7.29.8
2031 + '@babel/template': 7.29.7
2032 + '@babel/traverse': 7.29.8
2033 + '@babel/types': 7.29.8
2034 + '@jridgewell/remapping': 2.3.5
2035 + convert-source-map: 2.0.0
2036 + debug: 4.4.3
2037 + gensync: 1.0.0-beta.2
2038 + json5: 2.2.3
2039 + semver: 6.3.1
2040 + transitivePeerDependencies:
2041 + - supports-color
2042 +
2043 + '@babel/generator@7.29.8':
2044 + dependencies:
2045 + '@babel/parser': 7.29.8
2046 + '@babel/types': 7.29.8
2047 + '@jridgewell/gen-mapping': 0.3.13
2048 + '@jridgewell/trace-mapping': 0.3.31
2049 + jsesc: 3.1.0
2050 +
2051 + '@babel/helper-compilation-targets@7.29.7':
2052 + dependencies:
2053 + '@babel/compat-data': 7.29.7
2054 + '@babel/helper-validator-option': 7.29.7
2055 + browserslist: 4.28.9
2056 + lru-cache: 5.1.1
2057 + semver: 6.3.1
2058 +
2059 + '@babel/helper-globals@7.29.7': {}
2060 +
2061 + '@babel/helper-module-imports@7.29.7':
2062 + dependencies:
2063 + '@babel/traverse': 7.29.8
2064 + '@babel/types': 7.29.8
2065 + transitivePeerDependencies:
2066 + - supports-color
2067 +
2068 + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
2069 + dependencies:
2070 + '@babel/core': 7.29.7
2071 + '@babel/helper-module-imports': 7.29.7
2072 + '@babel/helper-validator-identifier': 7.29.7
2073 + '@babel/traverse': 7.29.8
2074 + transitivePeerDependencies:
2075 + - supports-color
2076 +
2077 + '@babel/helper-string-parser@7.29.7': {}
2078 +
2079 + '@babel/helper-validator-identifier@7.29.7': {}
2080 +
2081 + '@babel/helper-validator-option@7.29.7': {}
2082 +
2083 + '@babel/helpers@7.29.7':
2084 + dependencies:
2085 + '@babel/template': 7.29.7
2086 + '@babel/types': 7.29.8
2087 +
2088 + '@babel/parser@7.29.8':
2089 + dependencies:
2090 + '@babel/types': 7.29.8
2091 +
2092 + '@babel/template@7.29.7':
2093 + dependencies:
2094 + '@babel/code-frame': 7.29.7
2095 + '@babel/parser': 7.29.8
2096 + '@babel/types': 7.29.8
2097 +
2098 + '@babel/traverse@7.29.8':
2099 + dependencies:
2100 + '@babel/code-frame': 7.29.7
2101 + '@babel/generator': 7.29.8
2102 + '@babel/helper-globals': 7.29.7
2103 + '@babel/parser': 7.29.8
2104 + '@babel/template': 7.29.7
2105 + '@babel/types': 7.29.8
2106 + debug: 4.4.3
2107 + transitivePeerDependencies:
2108 + - supports-color
2109 +
2110 + '@babel/types@7.29.8':
2111 + dependencies:
2112 + '@babel/helper-string-parser': 7.29.7
2113 + '@babel/helper-validator-identifier': 7.29.7
2114 +
2115 + '@emnapi/core@1.10.0':
2116 + dependencies:
2117 + '@emnapi/wasi-threads': 1.2.1
2118 + tslib: 2.8.1
2119 + optional: true
2120 +
2121 + '@emnapi/runtime@1.10.0':
2122 + dependencies:
2123 + tslib: 2.8.1
2124 + optional: true
2125 +
2126 + '@emnapi/runtime@1.11.3':
2127 + dependencies:
2128 + tslib: 2.8.1
2129 + optional: true
2130 +
2131 + '@emnapi/wasi-threads@1.2.1':
2132 + dependencies:
2133 + tslib: 2.8.1
2134 + optional: true
2135 +
2136 + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))':
2137 + dependencies:
2138 + eslint: 9.39.5(jiti@2.7.0)
2139 + eslint-visitor-keys: 3.4.3
2140 +
2141 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))':
2142 + dependencies:
2143 + eslint: 9.39.5(jiti@2.7.0)
2144 + eslint-visitor-keys: 3.4.3
2145 +
2146 + '@eslint-community/regexpp@4.12.2': {}
2147 +
2148 + '@eslint/config-array@0.21.2':
2149 + dependencies:
2150 + '@eslint/object-schema': 2.1.7
2151 + debug: 4.4.3
2152 + minimatch: 3.1.5
2153 + transitivePeerDependencies:
2154 + - supports-color
2155 +
2156 + '@eslint/config-helpers@0.4.2':
2157 + dependencies:
2158 + '@eslint/core': 0.17.0
2159 +
2160 + '@eslint/core@0.17.0':
2161 + dependencies:
2162 + '@types/json-schema': 7.0.15
2163 +
2164 + '@eslint/eslintrc@3.3.7':
2165 + dependencies:
2166 + ajv: 6.15.0
2167 + debug: 4.4.3
2168 + espree: 10.4.0
2169 + globals: 14.0.0
2170 + ignore: 5.3.2
2171 + import-fresh: 3.3.1
2172 + js-yaml: 4.3.2
2173 + minimatch: 3.1.5
2174 + strip-json-comments: 3.1.1
2175 + transitivePeerDependencies:
2176 + - supports-color
2177 +
2178 + '@eslint/js@9.39.5': {}
2179 +
2180 + '@eslint/object-schema@2.1.7': {}
2181 +
2182 + '@eslint/plugin-kit@0.4.1':
2183 + dependencies:
2184 + '@eslint/core': 0.17.0
2185 + levn: 0.4.1
2186 +
2187 + '@humanfs/core@0.19.2':
2188 + dependencies:
2189 + '@humanfs/types': 0.15.0
2190 +
2191 + '@humanfs/node@0.16.8':
2192 + dependencies:
2193 + '@humanfs/core': 0.19.2
2194 + '@humanfs/types': 0.15.0
2195 + '@humanwhocodes/retry': 0.4.3
2196 +
2197 + '@humanfs/types@0.15.0': {}
2198 +
2199 + '@humanwhocodes/module-importer@1.0.1': {}
2200 +
2201 + '@humanwhocodes/retry@0.4.3': {}
2202 +
2203 + '@img/colour@1.1.0':
2204 + optional: true
2205 +
2206 + '@img/sharp-darwin-arm64@0.35.4':
2207 + optionalDependencies:
2208 + '@img/sharp-libvips-darwin-arm64': 1.3.3
2209 + optional: true
2210 +
2211 + '@img/sharp-darwin-x64@0.35.4':
2212 + optionalDependencies:
2213 + '@img/sharp-libvips-darwin-x64': 1.3.3
2214 + optional: true
2215 +
2216 + '@img/sharp-freebsd-wasm32@0.35.4':
2217 + dependencies:
2218 + '@img/sharp-wasm32': 0.35.4
2219 + optional: true
2220 +
2221 + '@img/sharp-libvips-darwin-arm64@1.3.3':
2222 + optional: true
2223 +
2224 + '@img/sharp-libvips-darwin-x64@1.3.3':
2225 + optional: true
2226 +
2227 + '@img/sharp-libvips-linux-arm64@1.3.3':
2228 + optional: true
2229 +
2230 + '@img/sharp-libvips-linux-arm@1.3.3':
2231 + optional: true
2232 +
2233 + '@img/sharp-libvips-linux-ppc64@1.3.3':
2234 + optional: true
2235 +
2236 + '@img/sharp-libvips-linux-riscv64@1.3.3':
2237 + optional: true
2238 +
2239 + '@img/sharp-libvips-linux-s390x@1.3.3':
2240 + optional: true
2241 +
2242 + '@img/sharp-libvips-linux-x64@1.3.3':
2243 + optional: true
2244 +
2245 + '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
2246 + optional: true
2247 +
2248 + '@img/sharp-libvips-linuxmusl-x64@1.3.3':
2249 + optional: true
2250 +
2251 + '@img/sharp-linux-arm64@0.35.4':
2252 + optionalDependencies:
2253 + '@img/sharp-libvips-linux-arm64': 1.3.3
2254 + optional: true
2255 +
2256 + '@img/sharp-linux-arm@0.35.4':
2257 + optionalDependencies:
2258 + '@img/sharp-libvips-linux-arm': 1.3.3
2259 + optional: true
2260 +
2261 + '@img/sharp-linux-ppc64@0.35.4':
2262 + optionalDependencies:
2263 + '@img/sharp-libvips-linux-ppc64': 1.3.3
2264 + optional: true
2265 +
2266 + '@img/sharp-linux-riscv64@0.35.4':
2267 + optionalDependencies:
2268 + '@img/sharp-libvips-linux-riscv64': 1.3.3
2269 + optional: true
2270 +
2271 + '@img/sharp-linux-s390x@0.35.4':
2272 + optionalDependencies:
2273 + '@img/sharp-libvips-linux-s390x': 1.3.3
2274 + optional: true
2275 +
2276 + '@img/sharp-linux-x64@0.35.4':
2277 + optionalDependencies:
2278 + '@img/sharp-libvips-linux-x64': 1.3.3
2279 + optional: true
2280 +
2281 + '@img/sharp-linuxmusl-arm64@0.35.4':
2282 + optionalDependencies:
2283 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
2284 + optional: true
2285 +
2286 + '@img/sharp-linuxmusl-x64@0.35.4':
2287 + optionalDependencies:
2288 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3
2289 + optional: true
2290 +
2291 + '@img/sharp-wasm32@0.35.4':
2292 + dependencies:
2293 + '@emnapi/runtime': 1.11.3
2294 + optional: true
2295 +
2296 + '@img/sharp-webcontainers-wasm32@0.35.4':
2297 + dependencies:
2298 + '@img/sharp-wasm32': 0.35.4
2299 + optional: true
2300 +
2301 + '@img/sharp-win32-arm64@0.35.4':
2302 + optional: true
2303 +
2304 + '@img/sharp-win32-ia32@0.35.4':
2305 + optional: true
2306 +
2307 + '@img/sharp-win32-x64@0.35.4':
2308 + optional: true
2309 +
2310 + '@jridgewell/gen-mapping@0.3.13':
2311 + dependencies:
2312 + '@jridgewell/sourcemap-codec': 1.6.0
2313 + '@jridgewell/trace-mapping': 0.3.31
2314 +
2315 + '@jridgewell/remapping@2.3.5':
2316 + dependencies:
2317 + '@jridgewell/gen-mapping': 0.3.13
2318 + '@jridgewell/trace-mapping': 0.3.31
2319 +
2320 + '@jridgewell/resolve-uri@3.1.2': {}
2321 +
2322 + '@jridgewell/sourcemap-codec@1.6.0': {}
2323 +
2324 + '@jridgewell/trace-mapping@0.3.31':
2325 + dependencies:
2326 + '@jridgewell/resolve-uri': 3.1.2
2327 + '@jridgewell/sourcemap-codec': 1.6.0
2328 +
2329 + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
2330 + dependencies:
2331 + '@emnapi/core': 1.10.0
2332 + '@emnapi/runtime': 1.10.0
2333 + '@tybys/wasm-util': 0.10.3
2334 + optional: true
2335 +
2336 + '@next/env@16.3.4': {}
2337 +
2338 + '@next/eslint-plugin-next@16.3.4(eslint@9.39.5(jiti@2.7.0))':
2339 + dependencies:
2340 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0))
2341 + fast-glob: 3.3.1
2342 + transitivePeerDependencies:
2343 + - eslint
2344 +
2345 + '@next/swc-darwin-arm64@16.3.4':
2346 + optional: true
2347 +
2348 + '@next/swc-darwin-x64@16.3.4':
2349 + optional: true
2350 +
2351 + '@next/swc-linux-arm64-gnu@16.3.4':
2352 + optional: true
2353 +
2354 + '@next/swc-linux-arm64-musl@16.3.4':
2355 + optional: true
2356 +
2357 + '@next/swc-linux-x64-gnu@16.3.4':
2358 + optional: true
2359 +
2360 + '@next/swc-linux-x64-musl@16.3.4':
2361 + optional: true
2362 +
2363 + '@next/swc-win32-arm64-msvc@16.3.4':
2364 + optional: true
2365 +
2366 + '@next/swc-win32-x64-msvc@16.3.4':
2367 + optional: true
2368 +
2369 + '@nodelib/fs.scandir@2.1.5':
2370 + dependencies:
2371 + '@nodelib/fs.stat': 2.0.5
2372 + run-parallel: 1.2.0
2373 +
2374 + '@nodelib/fs.stat@2.0.5': {}
2375 +
2376 + '@nodelib/fs.walk@1.2.8':
2377 + dependencies:
2378 + '@nodelib/fs.scandir': 2.1.5
2379 + fastq: 1.20.3
2380 +
2381 + '@nolyfill/is-core-module@1.0.39': {}
2382 +
2383 + '@rtsao/scc@1.1.0': {}
2384 +
2385 + '@swc/helpers@0.5.23':
2386 + dependencies:
2387 + tslib: 2.8.1
2388 +
2389 + '@tailwindcss/node@4.3.3':
2390 + dependencies:
2391 + '@jridgewell/remapping': 2.3.5
2392 + enhanced-resolve: 5.24.5
2393 + jiti: 2.7.0
2394 + lightningcss: 1.32.0
2395 + magic-string: 0.30.21
2396 + source-map-js: 1.2.1
2397 + tailwindcss: 4.3.3
2398 +
2399 + '@tailwindcss/oxide-android-arm64@4.3.3':
2400 + optional: true
2401 +
2402 + '@tailwindcss/oxide-darwin-arm64@4.3.3':
2403 + optional: true
2404 +
2405 + '@tailwindcss/oxide-darwin-x64@4.3.3':
2406 + optional: true
2407 +
2408 + '@tailwindcss/oxide-freebsd-x64@4.3.3':
2409 + optional: true
2410 +
2411 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
2412 + optional: true
2413 +
2414 + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
2415 + optional: true
2416 +
2417 + '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
2418 + optional: true
2419 +
2420 + '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
2421 + optional: true
2422 +
2423 + '@tailwindcss/oxide-linux-x64-musl@4.3.3':
2424 + optional: true
2425 +
2426 + '@tailwindcss/oxide-wasm32-wasi@4.3.3':
2427 + optional: true
2428 +
2429 + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
2430 + optional: true
2431 +
2432 + '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
2433 + optional: true
2434 +
2435 + '@tailwindcss/oxide@4.3.3':
2436 + optionalDependencies:
2437 + '@tailwindcss/oxide-android-arm64': 4.3.3
2438 + '@tailwindcss/oxide-darwin-arm64': 4.3.3
2439 + '@tailwindcss/oxide-darwin-x64': 4.3.3
2440 + '@tailwindcss/oxide-freebsd-x64': 4.3.3
2441 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
2442 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
2443 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
2444 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
2445 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3
2446 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3
2447 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
2448 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
2449 +
2450 + '@tailwindcss/postcss@4.3.3':
2451 + dependencies:
2452 + '@alloc/quick-lru': 5.3.0
2453 + '@tailwindcss/node': 4.3.3
2454 + '@tailwindcss/oxide': 4.3.3
2455 + postcss: 8.5.28
2456 + tailwindcss: 4.3.3
2457 +
2458 + '@tybys/wasm-util@0.10.3':
2459 + dependencies:
2460 + tslib: 2.8.1
2461 + optional: true
2462 +
2463 + '@types/estree@1.0.9': {}
2464 +
2465 + '@types/json-schema@7.0.15': {}
2466 +
2467 + '@types/json5@0.0.29': {}
2468 +
2469 + '@types/node@20.19.43':
2470 + dependencies:
2471 + undici-types: 6.21.0
2472 +
2473 + '@types/react-dom@19.3.0(@types/react@19.3.0)':
2474 + dependencies:
2475 + '@types/react': 19.3.0
2476 +
2477 + '@types/react@19.3.0':
2478 + dependencies:
2479 + csstype: 3.2.3
2480 +
2481 + '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
2482 + dependencies:
2483 + '@eslint-community/regexpp': 4.12.2
2484 + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
2485 + '@typescript-eslint/scope-manager': 8.70.0
2486 + '@typescript-eslint/type-utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
2487 + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
2488 + '@typescript-eslint/visitor-keys': 8.70.0
2489 + eslint: 9.39.5(jiti@2.7.0)
2490 + ignore: 7.0.9
2491 + natural-compare: 1.4.0
2492 + ts-api-utils: 2.5.0(typescript@5.9.3)
2493 + typescript: 5.9.3
2494 + transitivePeerDependencies:
2495 + - supports-color
2496 +
2497 + '@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
2498 + dependencies:
2499 + '@typescript-eslint/scope-manager': 8.70.0
2500 + '@typescript-eslint/types': 8.70.0
2501 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3)
2502 + '@typescript-eslint/visitor-keys': 8.70.0
2503 + debug: 4.4.3
2504 + eslint: 9.39.5(jiti@2.7.0)
2505 + typescript: 5.9.3
2506 + transitivePeerDependencies:
2507 + - supports-color
2508 +
2509 + '@typescript-eslint/project-service@8.70.0(typescript@5.9.3)':
2510 + dependencies:
2511 + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.9.3)
2512 + '@typescript-eslint/types': 8.70.0
2513 + debug: 4.4.3
2514 + typescript: 5.9.3
2515 + transitivePeerDependencies:
2516 + - supports-color
2517 +
2518 + '@typescript-eslint/scope-manager@8.70.0':
2519 + dependencies:
2520 + '@typescript-eslint/types': 8.70.0
2521 + '@typescript-eslint/visitor-keys': 8.70.0
2522 +
2523 + '@typescript-eslint/tsconfig-utils@8.70.0(typescript@5.9.3)':
2524 + dependencies:
2525 + typescript: 5.9.3
2526 +
2527 + '@typescript-eslint/type-utils@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
2528 + dependencies:
2529 + '@typescript-eslint/types': 8.70.0
2530 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3)
2531 + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
2532 + debug: 4.4.3
2533 + eslint: 9.39.5(jiti@2.7.0)
2534 + ts-api-utils: 2.5.0(typescript@5.9.3)
2535 + typescript: 5.9.3
2536 + transitivePeerDependencies:
2537 + - supports-color
2538 +
2539 + '@typescript-eslint/types@8.70.0': {}
2540 +
2541 + '@typescript-eslint/typescript-estree@8.70.0(typescript@5.9.3)':
2542 + dependencies:
2543 + '@typescript-eslint/project-service': 8.70.0(typescript@5.9.3)
2544 + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.9.3)
2545 + '@typescript-eslint/types': 8.70.0
2546 + '@typescript-eslint/visitor-keys': 8.70.0
2547 + debug: 4.4.3
2548 + minimatch: 10.2.6
2549 + semver: 7.8.5
2550 + tinyglobby: 0.2.17
2551 + ts-api-utils: 2.5.0(typescript@5.9.3)
2552 + typescript: 5.9.3
2553 + transitivePeerDependencies:
2554 + - supports-color
2555 +
2556 + '@typescript-eslint/utils@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
2557 + dependencies:
2558 + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
2559 + '@typescript-eslint/scope-manager': 8.70.0
2560 + '@typescript-eslint/types': 8.70.0
2561 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3)
2562 + eslint: 9.39.5(jiti@2.7.0)
2563 + typescript: 5.9.3
2564 + transitivePeerDependencies:
2565 + - supports-color
2566 +
2567 + '@typescript-eslint/visitor-keys@8.70.0':
2568 + dependencies:
2569 + '@typescript-eslint/types': 8.70.0
2570 + eslint-visitor-keys: 5.0.1
2571 +
2572 + '@unrs/resolver-binding-android-arm-eabi@1.12.2':
2573 + optional: true
2574 +
2575 + '@unrs/resolver-binding-android-arm64@1.12.2':
2576 + optional: true
2577 +
2578 + '@unrs/resolver-binding-darwin-arm64@1.12.2':
2579 + optional: true
2580 +
2581 + '@unrs/resolver-binding-darwin-x64@1.12.2':
2582 + optional: true
2583 +
2584 + '@unrs/resolver-binding-freebsd-x64@1.12.2':
2585 + optional: true
2586 +
2587 + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
2588 + optional: true
2589 +
2590 + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
2591 + optional: true
2592 +
2593 + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
2594 + optional: true
2595 +
2596 + '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
2597 + optional: true
2598 +
2599 + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
2600 + optional: true
2601 +
2602 + '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
2603 + optional: true
2604 +
2605 + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
2606 + optional: true
2607 +
2608 + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
2609 + optional: true
2610 +
2611 + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
2612 + optional: true
2613 +
2614 + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
2615 + optional: true
2616 +
2617 + '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
2618 + optional: true
2619 +
2620 + '@unrs/resolver-binding-linux-x64-musl@1.12.2':
2621 + optional: true
2622 +
2623 + '@unrs/resolver-binding-openharmony-arm64@1.12.2':
2624 + optional: true
2625 +
2626 + '@unrs/resolver-binding-wasm32-wasi@1.12.2':
2627 + dependencies:
2628 + '@emnapi/core': 1.10.0
2629 + '@emnapi/runtime': 1.10.0
2630 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
2631 + optional: true
2632 +
2633 + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
2634 + optional: true
2635 +
2636 + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
2637 + optional: true
2638 +
2639 + '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
2640 + optional: true
2641 +
2642 + acorn-jsx@5.3.2(acorn@8.18.0):
2643 + dependencies:
2644 + acorn: 8.18.0
2645 +
2646 + acorn@8.18.0: {}
2647 +
2648 + ajv@6.15.0:
2649 + dependencies:
2650 + fast-deep-equal: 3.1.3
2651 + fast-json-stable-stringify: 2.1.0
2652 + json-schema-traverse: 0.4.1
2653 + uri-js: 4.4.1
2654 +
2655 + ansi-styles@4.3.0:
2656 + dependencies:
2657 + color-convert: 2.0.1
2658 +
2659 + argparse@2.0.1: {}
2660 +
2661 + aria-query@5.3.2: {}
2662 +
2663 + array-buffer-byte-length@1.0.2:
2664 + dependencies:
2665 + call-bound: 1.0.4
2666 + is-array-buffer: 3.0.5
2667 +
2668 + array-includes@3.2.0:
2669 + dependencies:
2670 + call-bind: 1.0.9
2671 + call-bound: 1.0.4
2672 + define-properties: 1.2.1
2673 + es-abstract: 1.24.2
2674 + es-object-atoms: 1.1.2
2675 + es-shim-unscopables: 1.1.0
2676 + is-string: 1.1.1
2677 + math-intrinsics: 1.1.0
2678 +
2679 + array.prototype.findlast@1.2.5:
2680 + dependencies:
2681 + call-bind: 1.0.9
2682 + define-properties: 1.2.1
2683 + es-abstract: 1.24.2
2684 + es-errors: 1.3.0
2685 + es-object-atoms: 1.1.2
2686 + es-shim-unscopables: 1.1.0
2687 +
2688 + array.prototype.findlastindex@1.2.6:
2689 + dependencies:
2690 + call-bind: 1.0.9
2691 + call-bound: 1.0.4
2692 + define-properties: 1.2.1
2693 + es-abstract: 1.24.2
2694 + es-errors: 1.3.0
2695 + es-object-atoms: 1.1.2
2696 + es-shim-unscopables: 1.1.0
2697 +
2698 + array.prototype.flat@1.3.3:
2699 + dependencies:
2700 + call-bind: 1.0.9
2701 + define-properties: 1.2.1
2702 + es-abstract: 1.24.2
2703 + es-shim-unscopables: 1.1.0
2704 +
2705 + array.prototype.flatmap@1.3.3:
2706 + dependencies:
2707 + call-bind: 1.0.9
2708 + define-properties: 1.2.1
2709 + es-abstract: 1.24.2
2710 + es-shim-unscopables: 1.1.0
2711 +
2712 + array.prototype.tosorted@1.1.4:
2713 + dependencies:
2714 + call-bind: 1.0.9
2715 + define-properties: 1.2.1
2716 + es-abstract: 1.24.2
2717 + es-errors: 1.3.0
2718 + es-shim-unscopables: 1.1.0
2719 +
2720 + arraybuffer.prototype.slice@1.0.4:
2721 + dependencies:
2722 + array-buffer-byte-length: 1.0.2
2723 + call-bind: 1.0.9
2724 + define-properties: 1.2.1
2725 + es-abstract: 1.24.2
2726 + es-errors: 1.3.0
2727 + get-intrinsic: 1.3.0
2728 + is-array-buffer: 3.0.5
2729 +
2730 + ast-types-flow@0.0.8: {}
2731 +
2732 + async-function@1.0.0: {}
2733 +
2734 + available-typed-arrays@1.0.7:
2735 + dependencies:
2736 + possible-typed-array-names: 1.1.0
2737 +
2738 + axe-core@4.13.0: {}
2739 +
2740 + axobject-query@4.1.0: {}
2741 +
2742 + balanced-match@1.0.2: {}
2743 +
2744 + balanced-match@4.0.4: {}
2745 +
2746 + baseline-browser-mapping@2.11.21: {}
2747 +
2748 + brace-expansion@1.1.18:
2749 + dependencies:
2750 + balanced-match: 1.0.2
2751 + concat-map: 0.0.1
2752 +
2753 + brace-expansion@5.0.9:
2754 + dependencies:
2755 + balanced-match: 4.0.4
2756 +
2757 + braces@3.0.3:
2758 + dependencies:
2759 + fill-range: 7.1.1
2760 +
2761 + browserslist@4.28.9:
2762 + dependencies:
2763 + baseline-browser-mapping: 2.11.21
2764 + caniuse-lite: 1.0.30001810
2765 + electron-to-chromium: 1.5.425
2766 + node-releases: 2.0.55
2767 + update-browserslist-db: 1.3.2(browserslist@4.28.9)
2768 +
2769 + call-bind-apply-helpers@1.0.2:
2770 + dependencies:
2771 + es-errors: 1.3.0
2772 + function-bind: 1.1.2
2773 +
2774 + call-bind@1.0.9:
2775 + dependencies:
2776 + call-bind-apply-helpers: 1.0.2
2777 + es-define-property: 1.0.1
2778 + get-intrinsic: 1.3.0
2779 + set-function-length: 1.2.2
2780 +
2781 + call-bound@1.0.4:
2782 + dependencies:
2783 + call-bind-apply-helpers: 1.0.2
2784 + get-intrinsic: 1.3.0
2785 +
2786 + callsites@3.1.0: {}
2787 +
2788 + caniuse-lite@1.0.30001810: {}
2789 +
2790 + chalk@4.1.2:
2791 + dependencies:
2792 + ansi-styles: 4.3.0
2793 + supports-color: 7.2.0
2794 +
2795 + client-only@0.0.1: {}
2796 +
2797 + color-convert@2.0.1:
2798 + dependencies:
2799 + color-name: 1.1.4
2800 +
2801 + color-name@1.1.4: {}
2802 +
2803 + concat-map@0.0.1: {}
2804 +
2805 + convert-source-map@2.0.0: {}
2806 +
2807 + cross-spawn@7.0.6:
2808 + dependencies:
2809 + path-key: 3.1.1
2810 + shebang-command: 2.0.0
2811 + which: 2.0.2
2812 +
2813 + csstype@3.2.3: {}
2814 +
2815 + damerau-levenshtein@1.0.8: {}
2816 +
2817 + data-view-buffer@1.0.2:
2818 + dependencies:
2819 + call-bound: 1.0.4
2820 + es-errors: 1.3.0
2821 + is-data-view: 1.0.2
2822 +
2823 + data-view-byte-length@1.0.2:
2824 + dependencies:
2825 + call-bound: 1.0.4
2826 + es-errors: 1.3.0
2827 + is-data-view: 1.0.2
2828 +
2829 + data-view-byte-offset@1.0.1:
2830 + dependencies:
2831 + call-bound: 1.0.4
2832 + es-errors: 1.3.0
2833 + is-data-view: 1.0.2
2834 +
2835 + debug@3.2.7:
2836 + dependencies:
2837 + ms: 2.1.3
2838 +
2839 + debug@4.4.3:
2840 + dependencies:
2841 + ms: 2.1.3
2842 +
2843 + deep-is@0.1.4: {}
2844 +
2845 + define-data-property@1.1.4:
2846 + dependencies:
2847 + es-define-property: 1.0.1
2848 + es-errors: 1.3.0
2849 + gopd: 1.2.0
2850 +
2851 + define-properties@1.2.1:
2852 + dependencies:
2853 + define-data-property: 1.1.4
2854 + has-property-descriptors: 1.0.2
2855 + object-keys: 1.1.1
2856 +
2857 + detect-libc@2.1.2: {}
2858 +
2859 + doctrine@2.1.0:
2860 + dependencies:
2861 + esutils: 2.0.3
2862 +
2863 + dunder-proto@1.0.1:
2864 + dependencies:
2865 + call-bind-apply-helpers: 1.0.2
2866 + es-errors: 1.3.0
2867 + gopd: 1.2.0
2868 +
2869 + electron-to-chromium@1.5.425: {}
2870 +
2871 + emoji-regex@9.2.2: {}
2872 +
2873 + enhanced-resolve@5.24.5:
2874 + dependencies:
2875 + graceful-fs: 4.2.11
2876 + tapable: 2.3.3
2877 +
2878 + es-abstract-get@1.0.0:
2879 + dependencies:
2880 + es-errors: 1.3.0
2881 + es-object-atoms: 1.1.2
2882 + is-callable: 1.2.7
2883 + object-inspect: 1.13.4
2884 +
2885 + es-abstract@1.24.2:
2886 + dependencies:
2887 + array-buffer-byte-length: 1.0.2
2888 + arraybuffer.prototype.slice: 1.0.4
2889 + available-typed-arrays: 1.0.7
2890 + call-bind: 1.0.9
2891 + call-bound: 1.0.4
2892 + data-view-buffer: 1.0.2
2893 + data-view-byte-length: 1.0.2
2894 + data-view-byte-offset: 1.0.1
2895 + es-define-property: 1.0.1
2896 + es-errors: 1.3.0
2897 + es-object-atoms: 1.1.2
2898 + es-set-tostringtag: 2.1.0
2899 + es-to-primitive: 1.3.4
2900 + function.prototype.name: 1.2.0
2901 + get-intrinsic: 1.3.0
2902 + get-proto: 1.0.1
2903 + get-symbol-description: 1.1.0
2904 + globalthis: 1.0.4
2905 + gopd: 1.2.0
2906 + has-property-descriptors: 1.0.2
2907 + has-proto: 1.2.0
2908 + has-symbols: 1.1.0
2909 + hasown: 2.0.4
2910 + internal-slot: 1.1.0
2911 + is-array-buffer: 3.0.5
2912 + is-callable: 1.2.7
2913 + is-data-view: 1.0.2
2914 + is-negative-zero: 2.0.3
2915 + is-regex: 1.2.1
2916 + is-set: 2.0.3
2917 + is-shared-array-buffer: 1.0.4
2918 + is-string: 1.1.1
2919 + is-typed-array: 1.1.15
2920 + is-weakref: 1.1.1
2921 + math-intrinsics: 1.1.0
2922 + object-inspect: 1.13.4
2923 + object-keys: 1.1.1
2924 + object.assign: 4.1.7
2925 + own-keys: 1.0.2
2926 + regexp.prototype.flags: 1.5.4
2927 + safe-array-concat: 1.1.4
2928 + safe-push-apply: 1.0.0
2929 + safe-regex-test: 1.1.0
2930 + set-proto: 1.0.0
2931 + stop-iteration-iterator: 1.1.0
2932 + string.prototype.trim: 1.2.11
2933 + string.prototype.trimend: 1.0.10
2934 + string.prototype.trimstart: 1.0.8
2935 + typed-array-buffer: 1.0.3
2936 + typed-array-byte-length: 1.0.3
2937 + typed-array-byte-offset: 1.0.4
2938 + typed-array-length: 1.0.8
2939 + unbox-primitive: 1.1.0
2940 + which-typed-array: 1.1.22
2941 +
2942 + es-define-property@1.0.1: {}
2943 +
2944 + es-errors@1.3.0: {}
2945 +
2946 + es-iterator-helpers@1.4.0:
2947 + dependencies:
2948 + call-bind: 1.0.9
2949 + call-bound: 1.0.4
2950 + define-properties: 1.2.1
2951 + es-abstract: 1.24.2
2952 + es-errors: 1.3.0
2953 + es-set-tostringtag: 2.1.0
2954 + function-bind: 1.1.2
2955 + get-intrinsic: 1.3.0
2956 + globalthis: 1.0.4
2957 + gopd: 1.2.0
2958 + has-property-descriptors: 1.0.2
2959 + has-proto: 1.2.0
2960 + has-symbols: 1.1.0
2961 + internal-slot: 1.1.0
2962 + iterator.prototype: 1.1.5
2963 + math-intrinsics: 1.1.0
2964 +
2965 + es-object-atoms@1.1.2:
2966 + dependencies:
2967 + es-errors: 1.3.0
2968 +
2969 + es-set-tostringtag@2.1.0:
2970 + dependencies:
2971 + es-errors: 1.3.0
2972 + get-intrinsic: 1.3.0
2973 + has-tostringtag: 1.0.2
2974 + hasown: 2.0.4
2975 +
2976 + es-shim-unscopables@1.1.0:
2977 + dependencies:
2978 + hasown: 2.0.4
2979 +
2980 + es-to-primitive@1.3.4:
2981 + dependencies:
2982 + es-abstract-get: 1.0.0
2983 + es-define-property: 1.0.1
2984 + es-errors: 1.3.0
2985 + is-callable: 1.2.7
2986 + is-date-object: 1.1.0
2987 + is-symbol: 1.1.1
2988 +
2989 + escalade@3.2.0: {}
2990 +
2991 + escape-string-regexp@4.0.0: {}
2992 +
2993 + eslint-config-next@16.3.4(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3):
2994 + dependencies:
2995 + '@next/eslint-plugin-next': 16.3.4(eslint@9.39.5(jiti@2.7.0))
2996 + eslint: 9.39.5(jiti@2.7.0)
2997 + eslint-import-resolver-node: 0.3.10
2998 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0))
2999 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
3000 + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0))
3001 + eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0))
3002 + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0))
3003 + globals: 16.4.0
3004 + typescript-eslint: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
3005 + optionalDependencies:
3006 + typescript: 5.9.3
3007 + transitivePeerDependencies:
3008 + - '@typescript-eslint/parser'
3009 + - eslint-import-resolver-webpack
3010 + - eslint-plugin-import-x
3011 + - supports-color
3012 +
3013 + eslint-import-resolver-node@0.3.10:
3014 + dependencies:
3015 + debug: 3.2.7
3016 + is-core-module: 2.16.2
3017 + resolve: 2.0.0-next.7
3018 + transitivePeerDependencies:
3019 + - supports-color
3020 +
3021 + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)):
3022 + dependencies:
3023 + '@nolyfill/is-core-module': 1.0.39
3024 + debug: 4.4.3
3025 + eslint: 9.39.5(jiti@2.7.0)
3026 + get-tsconfig: 4.14.3
3027 + is-bun-module: 2.0.0
3028 + stable-hash: 0.0.5
3029 + tinyglobby: 0.2.17
3030 + unrs-resolver: 1.12.2
3031 + optionalDependencies:
3032 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
3033 + transitivePeerDependencies:
3034 + - supports-color
3035 +
3036 + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
3037 + dependencies:
3038 + debug: 3.2.7
3039 + optionalDependencies:
3040 + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
3041 + eslint: 9.39.5(jiti@2.7.0)
3042 + eslint-import-resolver-node: 0.3.10
3043 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0))
3044 + transitivePeerDependencies:
3045 + - supports-color
3046 +
3047 + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
3048 + dependencies:
3049 + '@rtsao/scc': 1.1.0
3050 + array-includes: 3.2.0
3051 + array.prototype.findlastindex: 1.2.6
3052 + array.prototype.flat: 1.3.3
3053 + array.prototype.flatmap: 1.3.3
3054 + debug: 3.2.7
3055 + doctrine: 2.1.0
3056 + eslint: 9.39.5(jiti@2.7.0)
3057 + eslint-import-resolver-node: 0.3.10
3058 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
3059 + hasown: 2.0.4
3060 + is-core-module: 2.16.2
3061 + is-glob: 4.0.3
3062 + minimatch: 3.1.5
3063 + object.fromentries: 2.0.8
3064 + object.groupby: 1.0.3
3065 + object.values: 1.2.1
3066 + semver: 6.3.1
3067 + string.prototype.trimend: 1.0.10
3068 + tsconfig-paths: 3.15.0
3069 + optionalDependencies:
3070 + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
3071 + transitivePeerDependencies:
3072 + - eslint-import-resolver-typescript
3073 + - eslint-import-resolver-webpack
3074 + - supports-color
3075 +
3076 + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)):
3077 + dependencies:
3078 + aria-query: 5.3.2
3079 + array-includes: 3.2.0
3080 + array.prototype.flatmap: 1.3.3
3081 + ast-types-flow: 0.0.8
3082 + axe-core: 4.13.0
3083 + axobject-query: 4.1.0
3084 + damerau-levenshtein: 1.0.8
3085 + emoji-regex: 9.2.2
3086 + eslint: 9.39.5(jiti@2.7.0)
3087 + hasown: 2.0.4
3088 + jsx-ast-utils: 3.3.5
3089 + language-tags: 1.0.9
3090 + minimatch: 3.1.5
3091 + object.fromentries: 2.0.8
3092 + safe-regex-test: 1.1.0
3093 + string.prototype.includes: 2.0.1
3094 +
3095 + eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)):
3096 + dependencies:
3097 + '@babel/core': 7.29.7
3098 + '@babel/parser': 7.29.8
3099 + eslint: 9.39.5(jiti@2.7.0)
3100 + hermes-parser: 0.25.1
3101 + zod: 4.6.1
3102 + zod-validation-error: 4.0.2(zod@4.6.1)
3103 + transitivePeerDependencies:
3104 + - supports-color
3105 +
3106 + eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)):
3107 + dependencies:
3108 + array-includes: 3.2.0
3109 + array.prototype.findlast: 1.2.5
3110 + array.prototype.flatmap: 1.3.3
3111 + array.prototype.tosorted: 1.1.4
3112 + doctrine: 2.1.0
3113 + es-iterator-helpers: 1.4.0
3114 + eslint: 9.39.5(jiti@2.7.0)
3115 + estraverse: 5.3.0
3116 + hasown: 2.0.4
3117 + jsx-ast-utils: 3.3.5
3118 + minimatch: 3.1.5
3119 + object.entries: 1.1.9
3120 + object.fromentries: 2.0.8
3121 + object.values: 1.2.1
3122 + prop-types: 15.8.1
3123 + resolve: 2.0.0-next.7
3124 + semver: 6.3.1
3125 + string.prototype.matchall: 4.1.0
3126 + string.prototype.repeat: 1.0.0
3127 +
3128 + eslint-scope@8.4.0:
3129 + dependencies:
3130 + esrecurse: 4.3.0
3131 + estraverse: 5.3.0
3132 +
3133 + eslint-visitor-keys@3.4.3: {}
3134 +
3135 + eslint-visitor-keys@4.2.1: {}
3136 +
3137 + eslint-visitor-keys@5.0.1: {}
3138 +
3139 + eslint@9.39.5(jiti@2.7.0):
3140 + dependencies:
3141 + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
3142 + '@eslint-community/regexpp': 4.12.2
3143 + '@eslint/config-array': 0.21.2
3144 + '@eslint/config-helpers': 0.4.2
3145 + '@eslint/core': 0.17.0
3146 + '@eslint/eslintrc': 3.3.7
3147 + '@eslint/js': 9.39.5
3148 + '@eslint/plugin-kit': 0.4.1
3149 + '@humanfs/node': 0.16.8
3150 + '@humanwhocodes/module-importer': 1.0.1
3151 + '@humanwhocodes/retry': 0.4.3
3152 + '@types/estree': 1.0.9
3153 + ajv: 6.15.0
3154 + chalk: 4.1.2
3155 + cross-spawn: 7.0.6
3156 + debug: 4.4.3
3157 + escape-string-regexp: 4.0.0
3158 + eslint-scope: 8.4.0
3159 + eslint-visitor-keys: 4.2.1
3160 + espree: 10.4.0
3161 + esquery: 1.7.0
3162 + esutils: 2.0.3
3163 + fast-deep-equal: 3.1.3
3164 + file-entry-cache: 8.0.0
3165 + find-up: 5.0.0
3166 + glob-parent: 6.0.2
3167 + ignore: 5.3.2
3168 + imurmurhash: 0.1.4
3169 + is-glob: 4.0.3
3170 + json-stable-stringify-without-jsonify: 1.0.1
3171 + lodash.merge: 4.6.2
3172 + minimatch: 3.1.5
3173 + natural-compare: 1.4.0
3174 + optionator: 0.9.4
3175 + optionalDependencies:
3176 + jiti: 2.7.0
3177 + transitivePeerDependencies:
3178 + - supports-color
3179 +
3180 + espree@10.4.0:
3181 + dependencies:
3182 + acorn: 8.18.0
3183 + acorn-jsx: 5.3.2(acorn@8.18.0)
3184 + eslint-visitor-keys: 4.2.1
3185 +
3186 + esquery@1.7.0:
3187 + dependencies:
3188 + estraverse: 5.3.0
3189 +
3190 + esrecurse@4.3.0:
3191 + dependencies:
3192 + estraverse: 5.3.0
3193 +
3194 + estraverse@5.3.0: {}
3195 +
3196 + esutils@2.0.3: {}
3197 +
3198 + fast-deep-equal@3.1.3: {}
3199 +
3200 + fast-glob@3.3.1:
3201 + dependencies:
3202 + '@nodelib/fs.stat': 2.0.5
3203 + '@nodelib/fs.walk': 1.2.8
3204 + glob-parent: 5.1.2
3205 + merge2: 1.4.1
3206 + micromatch: 4.0.8
3207 +
3208 + fast-json-stable-stringify@2.1.0: {}
3209 +
3210 + fast-levenshtein@2.0.6: {}
3211 +
3212 + fastq@1.20.3:
3213 + dependencies:
3214 + reusify: 1.1.0
3215 +
3216 + fdir@6.5.0(picomatch@4.0.7):
3217 + optionalDependencies:
3218 + picomatch: 4.0.7
3219 +
3220 + file-entry-cache@8.0.0:
3221 + dependencies:
3222 + flat-cache: 4.0.1
3223 +
3224 + fill-range@7.1.1:
3225 + dependencies:
3226 + to-regex-range: 5.0.1
3227 +
3228 + find-up@5.0.0:
3229 + dependencies:
3230 + locate-path: 6.0.0
3231 + path-exists: 4.0.0
3232 +
3233 + flat-cache@4.0.1:
3234 + dependencies:
3235 + flatted: 3.4.4
3236 + keyv: 4.5.4
3237 +
3238 + flatted@3.4.4: {}
3239 +
3240 + for-each@0.3.5:
3241 + dependencies:
3242 + is-callable: 1.2.7
3243 +
3244 + function-bind@1.1.2: {}
3245 +
3246 + function.prototype.name@1.2.0:
3247 + dependencies:
3248 + call-bind: 1.0.9
3249 + call-bound: 1.0.4
3250 + es-define-property: 1.0.1
3251 + es-errors: 1.3.0
3252 + functions-have-names: 1.2.3
3253 + has-property-descriptors: 1.0.2
3254 + hasown: 2.0.4
3255 + is-callable: 1.2.7
3256 + is-document.all: 1.0.0
3257 +
3258 + functions-have-names@1.2.3: {}
3259 +
3260 + generator-function@2.0.1: {}
3261 +
3262 + gensync@1.0.0-beta.2: {}
3263 +
3264 + get-intrinsic@1.3.0:
3265 + dependencies:
3266 + call-bind-apply-helpers: 1.0.2
3267 + es-define-property: 1.0.1
3268 + es-errors: 1.3.0
3269 + es-object-atoms: 1.1.2
3270 + function-bind: 1.1.2
3271 + get-proto: 1.0.1
3272 + gopd: 1.2.0
3273 + has-symbols: 1.1.0
3274 + hasown: 2.0.4
3275 + math-intrinsics: 1.1.0
3276 +
3277 + get-proto@1.0.1:
3278 + dependencies:
3279 + dunder-proto: 1.0.1
3280 + es-object-atoms: 1.1.2
3281 +
3282 + get-symbol-description@1.1.0:
3283 + dependencies:
3284 + call-bound: 1.0.4
3285 + es-errors: 1.3.0
3286 + get-intrinsic: 1.3.0
3287 +
3288 + get-tsconfig@4.14.3:
3289 + dependencies:
3290 + resolve-pkg-maps: 1.0.0
3291 +
3292 + glob-parent@5.1.2:
3293 + dependencies:
3294 + is-glob: 4.0.3
3295 +
3296 + glob-parent@6.0.2:
3297 + dependencies:
3298 + is-glob: 4.0.3
3299 +
3300 + globals@14.0.0: {}
3301 +
3302 + globals@16.4.0: {}
3303 +
3304 + globalthis@1.0.4:
3305 + dependencies:
3306 + define-properties: 1.2.1
3307 + gopd: 1.2.0
3308 +
3309 + gopd@1.2.0: {}
3310 +
3311 + graceful-fs@4.2.11: {}
3312 +
3313 + has-bigints@1.1.0: {}
3314 +
3315 + has-flag@4.0.0: {}
3316 +
3317 + has-property-descriptors@1.0.2:
3318 + dependencies:
3319 + es-define-property: 1.0.1
3320 +
3321 + has-proto@1.2.0:
3322 + dependencies:
3323 + dunder-proto: 1.0.1
3324 +
3325 + has-symbols@1.1.0: {}
3326 +
3327 + has-tostringtag@1.0.2:
3328 + dependencies:
3329 + has-symbols: 1.1.0
3330 +
3331 + hasown@2.0.4:
3332 + dependencies:
3333 + function-bind: 1.1.2
3334 +
3335 + hermes-estree@0.25.1: {}
3336 +
3337 + hermes-parser@0.25.1:
3338 + dependencies:
3339 + hermes-estree: 0.25.1
3340 +
3341 + ignore@5.3.2: {}
3342 +
3343 + ignore@7.0.9: {}
3344 +
3345 + import-fresh@3.3.1:
3346 + dependencies:
3347 + parent-module: 1.0.1
3348 + resolve-from: 4.0.0
3349 +
3350 + imurmurhash@0.1.4: {}
3351 +
3352 + internal-slot@1.1.0:
3353 + dependencies:
3354 + es-errors: 1.3.0
3355 + hasown: 2.0.4
3356 + side-channel: 1.1.1
3357 +
3358 + is-array-buffer@3.0.5:
3359 + dependencies:
3360 + call-bind: 1.0.9
3361 + call-bound: 1.0.4
3362 + get-intrinsic: 1.3.0
3363 +
3364 + is-async-function@2.1.1:
3365 + dependencies:
3366 + async-function: 1.0.0
3367 + call-bound: 1.0.4
3368 + get-proto: 1.0.1
3369 + has-tostringtag: 1.0.2
3370 + safe-regex-test: 1.1.0
3371 +
3372 + is-bigint@1.1.0:
3373 + dependencies:
3374 + has-bigints: 1.1.0
3375 +
3376 + is-boolean-object@1.2.2:
3377 + dependencies:
3378 + call-bound: 1.0.4
3379 + has-tostringtag: 1.0.2
3380 +
3381 + is-bun-module@2.0.0:
3382 + dependencies:
3383 + semver: 7.8.5
3384 +
3385 + is-callable@1.2.7: {}
3386 +
3387 + is-core-module@2.16.2:
3388 + dependencies:
3389 + hasown: 2.0.4
3390 +
3391 + is-data-view@1.0.2:
3392 + dependencies:
3393 + call-bound: 1.0.4
3394 + get-intrinsic: 1.3.0
3395 + is-typed-array: 1.1.15
3396 +
3397 + is-date-object@1.1.0:
3398 + dependencies:
3399 + call-bound: 1.0.4
3400 + has-tostringtag: 1.0.2
3401 +
3402 + is-document.all@1.0.0:
3403 + dependencies:
3404 + call-bound: 1.0.4
3405 +
3406 + is-extglob@2.1.1: {}
3407 +
3408 + is-finalizationregistry@1.1.1:
3409 + dependencies:
3410 + call-bound: 1.0.4
3411 +
3412 + is-generator-function@1.1.2:
3413 + dependencies:
3414 + call-bound: 1.0.4
3415 + generator-function: 2.0.1
3416 + get-proto: 1.0.1
3417 + has-tostringtag: 1.0.2
3418 + safe-regex-test: 1.1.0
3419 +
3420 + is-glob@4.0.3:
3421 + dependencies:
3422 + is-extglob: 2.1.1
3423 +
3424 + is-map@2.0.3: {}
3425 +
3426 + is-negative-zero@2.0.3: {}
3427 +
3428 + is-number-object@1.1.1:
3429 + dependencies:
3430 + call-bound: 1.0.4
3431 + has-tostringtag: 1.0.2
3432 +
3433 + is-number@7.0.0: {}
3434 +
3435 + is-regex@1.2.1:
3436 + dependencies:
3437 + call-bound: 1.0.4
3438 + gopd: 1.2.0
3439 + has-tostringtag: 1.0.2
3440 + hasown: 2.0.4
3441 +
3442 + is-set@2.0.3: {}
3443 +
3444 + is-shared-array-buffer@1.0.4:
3445 + dependencies:
3446 + call-bound: 1.0.4
3447 +
3448 + is-string@1.1.1:
3449 + dependencies:
3450 + call-bound: 1.0.4
3451 + has-tostringtag: 1.0.2
3452 +
3453 + is-symbol@1.1.1:
3454 + dependencies:
3455 + call-bound: 1.0.4
3456 + has-symbols: 1.1.0
3457 + safe-regex-test: 1.1.0
3458 +
3459 + is-typed-array@1.1.15:
3460 + dependencies:
3461 + which-typed-array: 1.1.22
3462 +
3463 + is-weakmap@2.0.2: {}
3464 +
3465 + is-weakref@1.1.1:
3466 + dependencies:
3467 + call-bound: 1.0.4
3468 +
3469 + is-weakset@2.0.4:
3470 + dependencies:
3471 + call-bound: 1.0.4
3472 + get-intrinsic: 1.3.0
3473 +
3474 + isarray@2.0.5: {}
3475 +
3476 + isexe@2.0.0: {}
3477 +
3478 + iterator.prototype@1.1.5:
3479 + dependencies:
3480 + define-data-property: 1.1.4
3481 + es-object-atoms: 1.1.2
3482 + get-intrinsic: 1.3.0
3483 + get-proto: 1.0.1
3484 + has-symbols: 1.1.0
3485 + set-function-name: 2.0.2
3486 +
3487 + jiti@2.7.0: {}
3488 +
3489 + js-tokens@4.0.0: {}
3490 +
3491 + js-yaml@4.3.2:
3492 + dependencies:
3493 + argparse: 2.0.1
3494 +
3495 + jsesc@3.1.0: {}
3496 +
3497 + json-buffer@3.0.1: {}
3498 +
3499 + json-schema-traverse@0.4.1: {}
3500 +
3501 + json-stable-stringify-without-jsonify@1.0.1: {}
3502 +
3503 + json5@1.0.2:
3504 + dependencies:
3505 + minimist: 1.2.8
3506 +
3507 + json5@2.2.3: {}
3508 +
3509 + jsx-ast-utils@3.3.5:
3510 + dependencies:
3511 + array-includes: 3.2.0
3512 + array.prototype.flat: 1.3.3
3513 + object.assign: 4.1.7
3514 + object.values: 1.2.1
3515 +
3516 + keyv@4.5.4:
3517 + dependencies:
3518 + json-buffer: 3.0.1
3519 +
3520 + language-subtag-registry@0.3.23: {}
3521 +
3522 + language-tags@1.0.9:
3523 + dependencies:
3524 + language-subtag-registry: 0.3.23
3525 +
3526 + levn@0.4.1:
3527 + dependencies:
3528 + prelude-ls: 1.2.1
3529 + type-check: 0.4.0
3530 +
3531 + lightningcss-android-arm64@1.32.0:
3532 + optional: true
3533 +
3534 + lightningcss-darwin-arm64@1.32.0:
3535 + optional: true
3536 +
3537 + lightningcss-darwin-x64@1.32.0:
3538 + optional: true
3539 +
3540 + lightningcss-freebsd-x64@1.32.0:
3541 + optional: true
3542 +
3543 + lightningcss-linux-arm-gnueabihf@1.32.0:
3544 + optional: true
3545 +
3546 + lightningcss-linux-arm64-gnu@1.32.0:
3547 + optional: true
3548 +
3549 + lightningcss-linux-arm64-musl@1.32.0:
3550 + optional: true
3551 +
3552 + lightningcss-linux-x64-gnu@1.32.0:
3553 + optional: true
3554 +
3555 + lightningcss-linux-x64-musl@1.32.0:
3556 + optional: true
3557 +
3558 + lightningcss-win32-arm64-msvc@1.32.0:
3559 + optional: true
3560 +
3561 + lightningcss-win32-x64-msvc@1.32.0:
3562 + optional: true
3563 +
3564 + lightningcss@1.32.0:
3565 + dependencies:
3566 + detect-libc: 2.1.2
3567 + optionalDependencies:
3568 + lightningcss-android-arm64: 1.32.0
3569 + lightningcss-darwin-arm64: 1.32.0
3570 + lightningcss-darwin-x64: 1.32.0
3571 + lightningcss-freebsd-x64: 1.32.0
3572 + lightningcss-linux-arm-gnueabihf: 1.32.0
3573 + lightningcss-linux-arm64-gnu: 1.32.0
3574 + lightningcss-linux-arm64-musl: 1.32.0
3575 + lightningcss-linux-x64-gnu: 1.32.0
3576 + lightningcss-linux-x64-musl: 1.32.0
3577 + lightningcss-win32-arm64-msvc: 1.32.0
3578 + lightningcss-win32-x64-msvc: 1.32.0
3579 +
3580 + locate-path@6.0.0:
3581 + dependencies:
3582 + p-locate: 5.0.0
3583 +
3584 + lodash.merge@4.6.2: {}
3585 +
3586 + loose-envify@1.4.0:
3587 + dependencies:
3588 + js-tokens: 4.0.0
3589 +
3590 + lru-cache@5.1.1:
3591 + dependencies:
3592 + yallist: 3.1.1
3593 +
3594 + magic-string@0.30.21:
3595 + dependencies:
3596 + '@jridgewell/sourcemap-codec': 1.6.0
3597 +
3598 + math-intrinsics@1.1.0: {}
3599 +
3600 + merge2@1.4.1: {}
3601 +
3602 + micromatch@4.0.8:
3603 + dependencies:
3604 + braces: 3.0.3
3605 + picomatch: 2.3.2
3606 +
3607 + minimatch@10.2.6:
3608 + dependencies:
3609 + brace-expansion: 5.0.9
3610 +
3611 + minimatch@3.1.5:
3612 + dependencies:
3613 + brace-expansion: 1.1.18
3614 +
3615 + minimist@1.2.8: {}
3616 +
3617 + ms@2.1.3: {}
3618 +
3619 + nanoid@3.3.18: {}
3620 +
3621 + napi-postinstall@0.3.4: {}
3622 +
3623 + natural-compare@1.4.0: {}
3624 +
3625 + next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
3626 + dependencies:
3627 + '@next/env': 16.3.4
3628 + '@swc/helpers': 0.5.23
3629 + baseline-browser-mapping: 2.11.21
3630 + caniuse-lite: 1.0.30001810
3631 + postcss: 8.5.23
3632 + react: 19.2.8
3633 + react-dom: 19.2.8(react@19.2.8)
3634 + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8)
3635 + optionalDependencies:
3636 + '@next/swc-darwin-arm64': 16.3.4
3637 + '@next/swc-darwin-x64': 16.3.4
3638 + '@next/swc-linux-arm64-gnu': 16.3.4
3639 + '@next/swc-linux-arm64-musl': 16.3.4
3640 + '@next/swc-linux-x64-gnu': 16.3.4
3641 + '@next/swc-linux-x64-musl': 16.3.4
3642 + '@next/swc-win32-arm64-msvc': 16.3.4
3643 + '@next/swc-win32-x64-msvc': 16.3.4
3644 + sharp: 0.35.4(@types/node@20.19.43)
3645 + transitivePeerDependencies:
3646 + - '@babel/core'
3647 + - '@types/node'
3648 + - babel-plugin-macros
3649 +
3650 + node-exports-info@1.6.2:
3651 + dependencies:
3652 + array.prototype.flatmap: 1.3.3
3653 + es-errors: 1.3.0
3654 + object.entries: 1.1.9
3655 + semver: 6.3.1
3656 +
3657 + node-releases@2.0.55: {}
3658 +
3659 + object-assign@4.1.1: {}
3660 +
3661 + object-inspect@1.13.4: {}
3662 +
3663 + object-keys@1.1.1: {}
3664 +
3665 + object.assign@4.1.7:
3666 + dependencies:
3667 + call-bind: 1.0.9
3668 + call-bound: 1.0.4
3669 + define-properties: 1.2.1
3670 + es-object-atoms: 1.1.2
3671 + has-symbols: 1.1.0
3672 + object-keys: 1.1.1
3673 +
3674 + object.entries@1.1.9:
3675 + dependencies:
3676 + call-bind: 1.0.9
3677 + call-bound: 1.0.4
3678 + define-properties: 1.2.1
3679 + es-object-atoms: 1.1.2
3680 +
3681 + object.fromentries@2.0.8:
3682 + dependencies:
3683 + call-bind: 1.0.9
3684 + define-properties: 1.2.1
3685 + es-abstract: 1.24.2
3686 + es-object-atoms: 1.1.2
3687 +
3688 + object.groupby@1.0.3:
3689 + dependencies:
3690 + call-bind: 1.0.9
3691 + define-properties: 1.2.1
3692 + es-abstract: 1.24.2
3693 +
3694 + object.values@1.2.1:
3695 + dependencies:
3696 + call-bind: 1.0.9
3697 + call-bound: 1.0.4
3698 + define-properties: 1.2.1
3699 + es-object-atoms: 1.1.2
3700 +
3701 + optionator@0.9.4:
3702 + dependencies:
3703 + deep-is: 0.1.4
3704 + fast-levenshtein: 2.0.6
3705 + levn: 0.4.1
3706 + prelude-ls: 1.2.1
3707 + type-check: 0.4.0
3708 + word-wrap: 1.2.5
3709 +
3710 + own-keys@1.0.2:
3711 + dependencies:
3712 + call-bound: 1.0.4
3713 + get-intrinsic: 1.3.0
3714 + object-keys: 1.1.1
3715 + safe-push-apply: 1.0.0
3716 +
3717 + p-limit@3.1.0:
3718 + dependencies:
3719 + yocto-queue: 0.1.0
3720 +
3721 + p-locate@5.0.0:
3722 + dependencies:
3723 + p-limit: 3.1.0
3724 +
3725 + parent-module@1.0.1:
3726 + dependencies:
3727 + callsites: 3.1.0
3728 +
3729 + path-exists@4.0.0: {}
3730 +
3731 + path-key@3.1.1: {}
3732 +
3733 + path-parse@1.0.7: {}
3734 +
3735 + picocolors@1.1.1: {}
3736 +
3737 + picomatch@2.3.2: {}
3738 +
3739 + picomatch@4.0.7: {}
3740 +
3741 + possible-typed-array-names@1.1.0: {}
3742 +
3743 + postcss@8.5.23:
3744 + dependencies:
3745 + nanoid: 3.3.18
3746 + picocolors: 1.1.1
3747 + source-map-js: 1.2.1
3748 +
3749 + postcss@8.5.28:
3750 + dependencies:
3751 + nanoid: 3.3.18
3752 + picocolors: 1.1.1
3753 + source-map-js: 1.2.1
3754 +
3755 + prelude-ls@1.2.1: {}
3756 +
3757 + prop-types@15.8.1:
3758 + dependencies:
3759 + loose-envify: 1.4.0
3760 + object-assign: 4.1.1
3761 + react-is: 16.13.1
3762 +
3763 + punycode@2.3.1: {}
3764 +
3765 + queue-microtask@1.2.3: {}
3766 +
3767 + react-dom@19.2.8(react@19.2.8):
3768 + dependencies:
3769 + react: 19.2.8
3770 + scheduler: 0.27.0
3771 +
3772 + react-is@16.13.1: {}
3773 +
3774 + react@19.2.8: {}
3775 +
3776 + reflect.getprototypeof@1.0.10:
3777 + dependencies:
3778 + call-bind: 1.0.9
3779 + define-properties: 1.2.1
3780 + es-abstract: 1.24.2
3781 + es-errors: 1.3.0
3782 + es-object-atoms: 1.1.2
3783 + get-intrinsic: 1.3.0
3784 + get-proto: 1.0.1
3785 + which-builtin-type: 1.2.1
3786 +
3787 + regexp.prototype.flags@1.5.4:
3788 + dependencies:
3789 + call-bind: 1.0.9
3790 + define-properties: 1.2.1
3791 + es-errors: 1.3.0
3792 + get-proto: 1.0.1
3793 + gopd: 1.2.0
3794 + set-function-name: 2.0.2
3795 +
3796 + resolve-from@4.0.0: {}
3797 +
3798 + resolve-pkg-maps@1.0.0: {}
3799 +
3800 + resolve@2.0.0-next.7:
3801 + dependencies:
3802 + es-errors: 1.3.0
3803 + is-core-module: 2.16.2
3804 + node-exports-info: 1.6.2
3805 + object-keys: 1.1.1
3806 + path-parse: 1.0.7
3807 + supports-preserve-symlinks-flag: 1.0.0
3808 +
3809 + reusify@1.1.0: {}
3810 +
3811 + run-parallel@1.2.0:
3812 + dependencies:
3813 + queue-microtask: 1.2.3
3814 +
3815 + safe-array-concat@1.1.4:
3816 + dependencies:
3817 + call-bind: 1.0.9
3818 + call-bound: 1.0.4
3819 + get-intrinsic: 1.3.0
3820 + has-symbols: 1.1.0
3821 + isarray: 2.0.5
3822 +
3823 + safe-push-apply@1.0.0:
3824 + dependencies:
3825 + es-errors: 1.3.0
3826 + isarray: 2.0.5
3827 +
3828 + safe-regex-test@1.1.0:
3829 + dependencies:
3830 + call-bound: 1.0.4
3831 + es-errors: 1.3.0
3832 + is-regex: 1.2.1
3833 +
3834 + scheduler@0.27.0: {}
3835 +
3836 + semver@6.3.1: {}
3837 +
3838 + semver@7.8.5: {}
3839 +
3840 + set-function-length@1.2.2:
3841 + dependencies:
3842 + define-data-property: 1.1.4
3843 + es-errors: 1.3.0
3844 + function-bind: 1.1.2
3845 + get-intrinsic: 1.3.0
3846 + gopd: 1.2.0
3847 + has-property-descriptors: 1.0.2
3848 +
3849 + set-function-name@2.0.2:
3850 + dependencies:
3851 + define-data-property: 1.1.4
3852 + es-errors: 1.3.0
3853 + functions-have-names: 1.2.3
3854 + has-property-descriptors: 1.0.2
3855 +
3856 + set-proto@1.0.0:
3857 + dependencies:
3858 + dunder-proto: 1.0.1
3859 + es-errors: 1.3.0
3860 + es-object-atoms: 1.1.2
3861 +
3862 + sharp@0.35.4(@types/node@20.19.43):
3863 + dependencies:
3864 + '@img/colour': 1.1.0
3865 + detect-libc: 2.1.2
3866 + semver: 7.8.5
3867 + optionalDependencies:
3868 + '@img/sharp-darwin-arm64': 0.35.4
3869 + '@img/sharp-darwin-x64': 0.35.4
3870 + '@img/sharp-freebsd-wasm32': 0.35.4
3871 + '@img/sharp-libvips-darwin-arm64': 1.3.3
3872 + '@img/sharp-libvips-darwin-x64': 1.3.3
3873 + '@img/sharp-libvips-linux-arm': 1.3.3
3874 + '@img/sharp-libvips-linux-arm64': 1.3.3
3875 + '@img/sharp-libvips-linux-ppc64': 1.3.3
3876 + '@img/sharp-libvips-linux-riscv64': 1.3.3
3877 + '@img/sharp-libvips-linux-s390x': 1.3.3
3878 + '@img/sharp-libvips-linux-x64': 1.3.3
3879 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
3880 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3
3881 + '@img/sharp-linux-arm': 0.35.4
3882 + '@img/sharp-linux-arm64': 0.35.4
3883 + '@img/sharp-linux-ppc64': 0.35.4
3884 + '@img/sharp-linux-riscv64': 0.35.4
3885 + '@img/sharp-linux-s390x': 0.35.4
3886 + '@img/sharp-linux-x64': 0.35.4
3887 + '@img/sharp-linuxmusl-arm64': 0.35.4
3888 + '@img/sharp-linuxmusl-x64': 0.35.4
3889 + '@img/sharp-webcontainers-wasm32': 0.35.4
3890 + '@img/sharp-win32-arm64': 0.35.4
3891 + '@img/sharp-win32-ia32': 0.35.4
3892 + '@img/sharp-win32-x64': 0.35.4
3893 + '@types/node': 20.19.43
3894 + optional: true
3895 +
3896 + shebang-command@2.0.0:
3897 + dependencies:
3898 + shebang-regex: 3.0.0
3899 +
3900 + shebang-regex@3.0.0: {}
3901 +
3902 + side-channel-list@1.0.1:
3903 + dependencies:
3904 + es-errors: 1.3.0
3905 + object-inspect: 1.13.4
3906 +
3907 + side-channel-map@1.0.1:
3908 + dependencies:
3909 + call-bound: 1.0.4
3910 + es-errors: 1.3.0
3911 + get-intrinsic: 1.3.0
3912 + object-inspect: 1.13.4
3913 +
3914 + side-channel-weakmap@1.0.2:
3915 + dependencies:
3916 + call-bound: 1.0.4
3917 + es-errors: 1.3.0
3918 + get-intrinsic: 1.3.0
3919 + object-inspect: 1.13.4
3920 + side-channel-map: 1.0.1
3921 +
3922 + side-channel@1.1.1:
3923 + dependencies:
3924 + es-errors: 1.3.0
3925 + object-inspect: 1.13.4
3926 + side-channel-list: 1.0.1
3927 + side-channel-map: 1.0.1
3928 + side-channel-weakmap: 1.0.2
3929 +
3930 + source-map-js@1.2.1: {}
3931 +
3932 + stable-hash@0.0.5: {}
3933 +
3934 + stop-iteration-iterator@1.1.0:
3935 + dependencies:
3936 + es-errors: 1.3.0
3937 + internal-slot: 1.1.0
3938 +
3939 + string.prototype.includes@2.0.1:
3940 + dependencies:
3941 + call-bind: 1.0.9
3942 + define-properties: 1.2.1
3943 + es-abstract: 1.24.2
3944 +
3945 + string.prototype.matchall@4.1.0:
3946 + dependencies:
3947 + call-bind: 1.0.9
3948 + call-bound: 1.0.4
3949 + define-properties: 1.2.1
3950 + es-abstract: 1.24.2
3951 + es-errors: 1.3.0
3952 + es-object-atoms: 1.1.2
3953 + get-intrinsic: 1.3.0
3954 + gopd: 1.2.0
3955 + has-symbols: 1.1.0
3956 + internal-slot: 1.1.0
3957 + regexp.prototype.flags: 1.5.4
3958 + set-function-name: 2.0.2
3959 + side-channel: 1.1.1
3960 +
3961 + string.prototype.repeat@1.0.0:
3962 + dependencies:
3963 + define-properties: 1.2.1
3964 + es-abstract: 1.24.2
3965 +
3966 + string.prototype.trim@1.2.11:
3967 + dependencies:
3968 + call-bind: 1.0.9
3969 + call-bound: 1.0.4
3970 + define-data-property: 1.1.4
3971 + define-properties: 1.2.1
3972 + es-abstract: 1.24.2
3973 + es-object-atoms: 1.1.2
3974 + has-property-descriptors: 1.0.2
3975 + safe-regex-test: 1.1.0
3976 +
3977 + string.prototype.trimend@1.0.10:
3978 + dependencies:
3979 + call-bind: 1.0.9
3980 + call-bound: 1.0.4
3981 + define-properties: 1.2.1
3982 + es-object-atoms: 1.1.2
3983 +
3984 + string.prototype.trimstart@1.0.8:
3985 + dependencies:
3986 + call-bind: 1.0.9
3987 + define-properties: 1.2.1
3988 + es-object-atoms: 1.1.2
3989 +
3990 + strip-bom@3.0.0: {}
3991 +
3992 + strip-json-comments@3.1.1: {}
3993 +
3994 + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.8):
3995 + dependencies:
3996 + client-only: 0.0.1
3997 + react: 19.2.8
3998 + optionalDependencies:
3999 + '@babel/core': 7.29.7
4000 +
4001 + supports-color@7.2.0:
4002 + dependencies:
4003 + has-flag: 4.0.0
4004 +
4005 + supports-preserve-symlinks-flag@1.0.0: {}
4006 +
4007 + tailwindcss@4.3.3: {}
4008 +
4009 + tapable@2.3.3: {}
4010 +
4011 + tinyglobby@0.2.17:
4012 + dependencies:
4013 + fdir: 6.5.0(picomatch@4.0.7)
4014 + picomatch: 4.0.7
4015 +
4016 + to-regex-range@5.0.1:
4017 + dependencies:
4018 + is-number: 7.0.0
4019 +
4020 + ts-api-utils@2.5.0(typescript@5.9.3):
4021 + dependencies:
4022 + typescript: 5.9.3
4023 +
4024 + tsconfig-paths@3.15.0:
4025 + dependencies:
4026 + '@types/json5': 0.0.29
4027 + json5: 1.0.2
4028 + minimist: 1.2.8
4029 + strip-bom: 3.0.0
4030 +
4031 + tslib@2.8.1: {}
4032 +
4033 + type-check@0.4.0:
4034 + dependencies:
4035 + prelude-ls: 1.2.1
4036 +
4037 + typed-array-buffer@1.0.3:
4038 + dependencies:
4039 + call-bound: 1.0.4
4040 + es-errors: 1.3.0
4041 + is-typed-array: 1.1.15
4042 +
4043 + typed-array-byte-length@1.0.3:
4044 + dependencies:
4045 + call-bind: 1.0.9
4046 + for-each: 0.3.5
4047 + gopd: 1.2.0
4048 + has-proto: 1.2.0
4049 + is-typed-array: 1.1.15
4050 +
4051 + typed-array-byte-offset@1.0.4:
4052 + dependencies:
4053 + available-typed-arrays: 1.0.7
4054 + call-bind: 1.0.9
4055 + for-each: 0.3.5
4056 + gopd: 1.2.0
4057 + has-proto: 1.2.0
4058 + is-typed-array: 1.1.15
4059 + reflect.getprototypeof: 1.0.10
4060 +
4061 + typed-array-length@1.0.8:
4062 + dependencies:
4063 + call-bind: 1.0.9
4064 + for-each: 0.3.5
4065 + gopd: 1.2.0
4066 + is-typed-array: 1.1.15
4067 + possible-typed-array-names: 1.1.0
4068 + reflect.getprototypeof: 1.0.10
4069 +
4070 + typescript-eslint@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3):
4071 + dependencies:
4072 + '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4073 + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4074 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3)
4075 + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4076 + eslint: 9.39.5(jiti@2.7.0)
4077 + typescript: 5.9.3
4078 + transitivePeerDependencies:
4079 + - supports-color
4080 +
4081 + typescript@5.9.3: {}
4082 +
4083 + unbox-primitive@1.1.0:
4084 + dependencies:
4085 + call-bound: 1.0.4
4086 + has-bigints: 1.1.0
4087 + has-symbols: 1.1.0
4088 + which-boxed-primitive: 1.1.1
4089 +
4090 + undici-types@6.21.0: {}
4091 +
4092 + unrs-resolver@1.12.2:
4093 + dependencies:
4094 + napi-postinstall: 0.3.4
4095 + optionalDependencies:
4096 + '@unrs/resolver-binding-android-arm-eabi': 1.12.2
4097 + '@unrs/resolver-binding-android-arm64': 1.12.2
4098 + '@unrs/resolver-binding-darwin-arm64': 1.12.2
4099 + '@unrs/resolver-binding-darwin-x64': 1.12.2
4100 + '@unrs/resolver-binding-freebsd-x64': 1.12.2
4101 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2
4102 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2
4103 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2
4104 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2
4105 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2
4106 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2
4107 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2
4108 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2
4109 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2
4110 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2
4111 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2
4112 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2
4113 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2
4114 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2
4115 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2
4116 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2
4117 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2
4118 +
4119 + update-browserslist-db@1.3.2(browserslist@4.28.9):
4120 + dependencies:
4121 + browserslist: 4.28.9
4122 + escalade: 3.2.0
4123 + picocolors: 1.1.1
4124 +
4125 + uri-js@4.4.1:
4126 + dependencies:
4127 + punycode: 2.3.1
4128 +
4129 + which-boxed-primitive@1.1.1:
4130 + dependencies:
4131 + is-bigint: 1.1.0
4132 + is-boolean-object: 1.2.2
4133 + is-number-object: 1.1.1
4134 + is-string: 1.1.1
4135 + is-symbol: 1.1.1
4136 +
4137 + which-builtin-type@1.2.1:
4138 + dependencies:
4139 + call-bound: 1.0.4
4140 + function.prototype.name: 1.2.0
4141 + has-tostringtag: 1.0.2
4142 + is-async-function: 2.1.1
4143 + is-date-object: 1.1.0
4144 + is-finalizationregistry: 1.1.1
4145 + is-generator-function: 1.1.2
4146 + is-regex: 1.2.1
4147 + is-weakref: 1.1.1
4148 + isarray: 2.0.5
4149 + which-boxed-primitive: 1.1.1
4150 + which-collection: 1.0.2
4151 + which-typed-array: 1.1.22
4152 +
4153 + which-collection@1.0.2:
4154 + dependencies:
4155 + is-map: 2.0.3
4156 + is-set: 2.0.3
4157 + is-weakmap: 2.0.2
4158 + is-weakset: 2.0.4
4159 +
4160 + which-typed-array@1.1.22:
4161 + dependencies:
4162 + available-typed-arrays: 1.0.7
4163 + call-bind: 1.0.9
4164 + call-bound: 1.0.4
4165 + for-each: 0.3.5
4166 + get-proto: 1.0.1
4167 + gopd: 1.2.0
4168 + has-tostringtag: 1.0.2
4169 +
4170 + which@2.0.2:
4171 + dependencies:
4172 + isexe: 2.0.0
4173 +
4174 + word-wrap@1.2.5: {}
4175 +
4176 + yallist@3.1.1: {}
4177 +
4178 + yocto-queue@0.1.0: {}
4179 +
4180 + zod-validation-error@4.0.2(zod@4.6.1):
4181 + dependencies:
4182 + zod: 4.6.1
4183 +
4184 + zod@4.6.1: {}
added web/pnpm-workspace.yaml +3 −0
@@ -0,0 +1,3 @@
1 +allowBuilds:
2 + sharp: false
3 + unrs-resolver: false
added web/postcss.config.mjs +7 −0
@@ -0,0 +1,7 @@
1 +const config = {
2 + plugins: {
3 + "@tailwindcss/postcss": {},
4 + },
5 +};
6 +
7 +export default config;
added web/src/app/docs/page.tsx +64 −0
@@ -0,0 +1,64 @@
1 +"use client";
2 +
3 +import { Code, PageHeader } from "@/components/ui";
4 +
5 +export default function DocsPage() {
6 + const origin = typeof location !== "undefined" ? location.origin : "https://www.llm-api.io";
7 + return (
8 + <div className="max-w-3xl">
9 + <PageHeader title="API" sub="OpenAI-compatible. Point any SDK at the base URL below with an API key from the Keys page." />
10 + <div className="flex flex-col gap-6 text-sm">
11 + <section className="card p-4">
12 + <div className="font-medium mb-2">Base URL</div>
13 + <Code>{`${origin}/v1`}</Code>
14 + <div className="text-xs text-ink-3 mt-2">Endpoints: <code className="mono">GET /v1/models</code>, <code className="mono">POST /v1/chat/completions</code>, <code className="mono">POST /v1/completions</code>, <code className="mono">POST /v1/embeddings</code>, <code className="mono">POST /v1/rerank</code>. Interactive OpenAPI schema at <a className="text-accent" href="/openapi" target="_blank">/openapi</a>.</div>
15 + </section>
16 + <section className="card p-4">
17 + <div className="font-medium mb-2">Python (openai SDK)</div>
18 + <Code>{`from openai import OpenAI
19 +
20 +client = OpenAI(base_url="${origin}/v1", api_key="llm_live_xxxxx")
21 +
22 +# Any installed model id, an alias (fast, coder, reasoning…) or "auto"
23 +r = client.chat.completions.create(
24 + model="default",
25 + messages=[{"role": "user", "content": "Explain monetary policy in two sentences."}],
26 +)
27 +print(r.choices[0].message.content)
28 +
29 +# Streaming
30 +for chunk in client.chat.completions.create(model="default", messages=[{"role":"user","content":"Count to 10"}], stream=True):
31 + if chunk.choices and chunk.choices[0].delta.content:
32 + print(chunk.choices[0].delta.content, end="", flush=True)
33 +
34 +# Embeddings (embedding model or alias "embedding")
35 +e = client.embeddings.create(model="embedding", input=["hello world"])`}</Code>
36 + </section>
37 + <section className="card p-4">
38 + <div className="font-medium mb-2">curl</div>
39 + <Code>{`curl ${origin}/v1/models -H "Authorization: Bearer llm_live_xxxxx"
40 +
41 +curl ${origin}/v1/chat/completions \\
42 + -H "Authorization: Bearer llm_live_xxxxx" \\
43 + -H "Content-Type: application/json" \\
44 + -d '{"model": "default", "messages": [{"role": "user", "content": "Hello"}], "stream": true}'`}</Code>
45 + </section>
46 + <section className="card p-4">
47 + <div className="font-medium mb-2">Behaviour</div>
48 + <ul className="list-disc pl-5 text-ink-2 flex flex-col gap-1">
49 + <li>If the requested model is not loaded, the request waits while it is loaded from SSD (the previous model is unloaded first if memory requires it). Load times are typically 1–30 s depending on size.</li>
50 + <li>Model switches are serialized. Concurrent requests for the same model are queued at the worker.</li>
51 + <li>Reasoning models return <code className="mono">reasoning_content</code> in the message / delta. Disable thinking with <code className="mono">{"\"chat_template_kwargs\": {\"enable_thinking\": false}"}</code>.</li>
52 + <li>Tool calling: pass <code className="mono">tools</code>; calls come back as <code className="mono">tool_calls</code> for models with a tool-capable chat template.</li>
53 + <li>Every response includes a <code className="mono">timings</code> object (TTFT, tokens/s, peak memory) in addition to <code className="mono">usage</code>.</li>
54 + <li>Errors follow the OpenAI shape: <code className="mono">{"{\"error\": {\"message\", \"type\", \"code\"}}"}</code>, e.g. <code className="mono">MODEL_NOT_FOUND</code>, <code className="mono">MODEL_TOO_LARGE</code>, <code className="mono">CONTEXT_TOO_LARGE</code>, <code className="mono">WORKER_CRASHED</code>.</li>
55 + </ul>
56 + </section>
57 + <section className="card p-4">
58 + <div className="font-medium mb-2">Management API</div>
59 + <div className="text-ink-2">Same host, prefix <code className="mono">/api</code>, requires the dashboard session or an API key with the <code className="mono">admin</code> scope: <code className="mono">GET /api/models</code>, <code className="mono">POST /api/models/:id/load|unload|benchmark|pin</code>, <code className="mono">POST /api/models/download</code>, <code className="mono">GET /api/system</code>, <code className="mono">GET /api/events</code> (SSE).</div>
60 + </section>
61 + </div>
62 + </div>
63 + );
64 +}
added web/src/app/downloads/page.tsx +149 −0
@@ -0,0 +1,149 @@
1 +"use client";
2 +
3 +import { useEffect, useMemo, useState } from "react";
4 +import { api, ApiError } from "@/lib/api";
5 +import { useLive } from "@/lib/events";
6 +import { fmtBytes, fmtCtx, fmtDate, fmtDuration, fmtGB, fmtParams, fmtSpeed } from "@/lib/format";
7 +import type { Job } from "@/lib/types";
8 +import { CompatPill, KV, PageHeader, Pill, Progress, SizePill, Spinner, useToast } from "@/components/ui";
9 +
10 +interface Inspect {
11 + repository: string; runtime: string; format: string; files: { path: string; size: number }[]; all_files: { path: string; size: number }[];
12 + download_bytes: number; weights_bytes: number; quantization: string | null; parameter_count: number | null; model_type: string | null;
13 + pipeline_tag: string | null; vision: boolean; embedding: boolean; reranker: boolean; max_context: number | null;
14 + compatibility: { status: string; reason: string; estimated_ram_gb: number | null; recommended_context: number | null }; size_class: string;
15 + disk: { free_gb: number; free_after_gb: number; min_free_gb: number; ok: boolean }; target_dir: string; already_installed: string | null;
16 + downloads: number | null; likes: number | null; gated: boolean;
17 +}
18 +
19 +export default function DownloadsPage() {
20 + const live = useLive();
21 + const toast = useToast();
22 + const [repo, setRepo] = useState("");
23 + const [quant, setQuant] = useState("");
24 + const [insp, setInsp] = useState<Inspect | null>(null);
25 + const [busy, setBusy] = useState(false);
26 + const [history, setHistory] = useState<Job[]>([]);
27 +
28 + useEffect(() => { api.get<{ downloads: Job[] }>("/api/downloads").then((r) => setHistory(r.downloads)).catch(() => {}); }, [live.version]);
29 +
30 + const jobs = useMemo(() => {
31 + const map = new Map<string, Job>();
32 + for (const j of history) map.set(j.id, j);
33 + for (const j of Object.values(live.jobs)) if (j.kind === "download") map.set(j.id, j);
34 + return [...map.values()].sort((a, b) => b.created_at - a.created_at);
35 + }, [history, live.jobs]);
36 + const active = jobs.filter((j) => j.status === "running" || j.status === "queued");
37 + const done = jobs.filter((j) => j.status !== "running" && j.status !== "queued");
38 +
39 + const inspect = async () => {
40 + if (!repo.trim()) return;
41 + setBusy(true);
42 + setInsp(null);
43 + try {
44 + setInsp(await api.post<Inspect>("/api/models/inspect", { repository: repo.trim(), quant: quant || null }));
45 + } catch (e) {
46 + toast.push(e instanceof ApiError ? e.message : "Inspect failed", "bad");
47 + } finally {
48 + setBusy(false);
49 + }
50 + };
51 + const download = async (force = false) => {
52 + if (!insp) return;
53 + setBusy(true);
54 + try {
55 + await api.post("/api/models/download", { repository: insp.repository, quant: quant || null, force });
56 + toast.push(`Download started: ${insp.repository}`, "good");
57 + setInsp(null);
58 + setRepo("");
59 + } catch (e) {
60 + toast.push(e instanceof ApiError ? e.message : "Download failed", "bad");
61 + } finally {
62 + setBusy(false);
63 + }
64 + };
65 + const ggufQuants = insp ? [...new Set(insp.all_files.filter((f) => f.path.toLowerCase().endsWith(".gguf") && !/mmproj/i.test(f.path)).map((f) => f.path.replace(/-\d{5}-of-\d{5}/, "").replace(/\.gguf$/i, "").split(/[-_.]/).slice(-2).join("_")))] : [];
66 +
67 + return (
68 + <div>
69 + {toast.view}
70 + <PageHeader title="Downloads" sub="Add models from Hugging Face. Every download is inspected for size, RAM and compatibility first." />
71 + <div className="card p-4 mb-5">
72 + <form className="flex flex-col sm:flex-row gap-2" onSubmit={(e) => { e.preventDefault(); inspect(); }}>
73 + <input className="input flex-1" placeholder="mlx-community/Qwen3-4B-Instruct-2507-4bit or unsloth/Qwen3.6-35B-A3B-GGUF or a huggingface.co URL" value={repo} onChange={(e) => setRepo(e.target.value)} />
74 + <input className="input sm:w-36" placeholder="quant (GGUF)" value={quant} onChange={(e) => setQuant(e.target.value)} title="Preferred GGUF quantization, e.g. Q4_K_M" />
75 + <button className="btn btn-primary" disabled={busy || !repo.trim()}>{busy && !insp ? <Spinner /> : "Inspect"}</button>
76 + </form>
77 + {insp && (
78 + <div className="mt-4 grid md:grid-cols-2 gap-4">
79 + <div>
80 + <div className="flex items-center gap-2 flex-wrap mb-2">
81 + <span className="font-medium">{insp.repository}</span>
82 + <Pill tone="accent">{insp.runtime === "mlx" ? "MLX" : "GGUF · llama.cpp"}</Pill>
83 + <CompatPill status={insp.compatibility.status} reason={insp.compatibility.reason} />
84 + <SizePill cls={insp.size_class} />
85 + {insp.gated && <Pill tone="warn">gated</Pill>}
86 + {insp.already_installed && <Pill tone="good">installed as {insp.already_installed}</Pill>}
87 + </div>
88 + <KV k="Download size" v={fmtBytes(insp.download_bytes)} />
89 + <KV k="Expected RAM" v={<>{fmtGB(insp.compatibility.estimated_ram_gb)} at {fmtCtx(insp.compatibility.recommended_context)} context</>} />
90 + <KV k="Parameters" v={fmtParams(insp.parameter_count)} />
91 + <KV k="Quantization" v={insp.quantization} />
92 + <KV k="Architecture" v={insp.model_type} />
93 + <KV k="Type" v={insp.embedding ? "embedding" : insp.reranker ? "reranker" : insp.vision ? "vision-language" : "text generation"} />
94 + <KV k="Max context" v={fmtCtx(insp.max_context)} />
95 + <KV k="Free disk after" v={<span className={insp.disk.ok ? "" : "text-bad"}>{insp.disk.free_after_gb} GB (reserve {insp.disk.min_free_gb} GB)</span>} />
96 + <KV k="Target" v={insp.target_dir} mono />
97 + <KV k="Popularity" v={<>{insp.downloads?.toLocaleString() ?? "—"} downloads · {insp.likes ?? "—"} likes</>} />
98 + <div className="text-xs text-ink-3 mt-2">{insp.compatibility.reason}</div>
99 + {ggufQuants.length > 1 && <div className="text-xs text-ink-3 mt-1">Available GGUF quantizations: {ggufQuants.join(", ")} — set one in the quant field and inspect again.</div>}
100 + <div className="flex gap-2 mt-4">
101 + <button className="btn btn-primary" disabled={busy || !insp.disk.ok || insp.compatibility.status === "incompatible" || !!insp.already_installed} onClick={() => download(false)}>{busy ? <Spinner /> : `Download ${fmtBytes(insp.download_bytes)}`}</button>
102 + {(insp.compatibility.status === "incompatible" || insp.already_installed) && <button className="btn" disabled={busy || !insp.disk.ok} onClick={() => download(true)}>Force</button>}
103 + </div>
104 + </div>
105 + <div className="card bg-bg p-3 max-h-72 overflow-auto">
106 + <div className="label mb-2">Files to download ({insp.files.length})</div>
107 + <table className="tbl text-xs"><tbody>{insp.files.map((f) => <tr key={f.path}><td className="mono">{f.path}</td><td className="text-right num">{fmtBytes(f.size)}</td></tr>)}</tbody></table>
108 + </div>
109 + </div>
110 + )}
111 + </div>
112 +
113 + <h2 className="text-sm font-medium mb-2">Queue</h2>
114 + <div className="flex flex-col gap-2 mb-6">
115 + {active.map((j) => <JobRow key={j.id} j={j} onCancel={() => api.post(`/api/jobs/${j.id}/cancel`)} />)}
116 + {!active.length && <div className="card p-5 text-sm text-ink-3 text-center">No active downloads.</div>}
117 + </div>
118 + <h2 className="text-sm font-medium mb-2">History</h2>
119 + <div className="card divide-y divide-border">
120 + {done.slice(0, 30).map((j) => (
121 + <div key={j.id} className="px-4 py-2.5 flex items-center gap-3 text-sm">
122 + <Pill tone={j.status === "completed" ? "good" : j.status === "failed" ? "bad" : "neutral"}>{j.status}</Pill>
123 + <div className="min-w-0 flex-1"><div className="truncate">{String(j.payload.repository)}</div><div className="text-xs text-ink-3">{fmtDate(j.created_at)} · {fmtBytes(Number(j.payload.download_bytes))}{j.error ? ` · ${j.error}` : ""}</div></div>
124 + {j.status === "failed" && <button className="btn btn-sm" onClick={() => api.post(`/api/downloads/${j.id}/retry`).then(() => toast.push("Retrying", "good")).catch((e) => toast.push(e.message, "bad"))}>Retry</button>}
125 + </div>
126 + ))}
127 + {!done.length && <div className="p-5 text-sm text-ink-3 text-center">Nothing yet.</div>}
128 + </div>
129 + </div>
130 + );
131 +}
132 +
133 +export function JobRow({ j, onCancel }: { j: Job; onCancel?: () => void }) {
134 + const d = j.detail as { downloaded?: number; total?: number; speed_bps?: number; eta_seconds?: number; current_file?: string; stage?: string; file_index?: number; file_count?: number };
135 + return (
136 + <div className="card p-4">
137 + <div className="flex items-center justify-between gap-3 mb-2">
138 + <div className="min-w-0"><div className="font-medium truncate">{j.title}</div>
139 + <div className="text-xs text-ink-3 truncate">{d.stage || d.current_file || j.status}{d.file_index ? ` · file ${d.file_index}/${d.file_count}` : ""}</div></div>
140 + <div className="text-right text-xs num shrink-0">
141 + <div>{d.downloaded != null ? `${fmtBytes(d.downloaded)} / ${fmtBytes(d.total)}` : `${Math.round(j.progress * 100)}%`}</div>
142 + <div className="text-ink-3">{d.speed_bps ? fmtSpeed(d.speed_bps) : ""}{d.eta_seconds ? ` · ETA ${fmtDuration(d.eta_seconds)}` : ""}</div>
143 + </div>
144 + {onCancel && <button className="btn btn-sm" onClick={onCancel}>Cancel</button>}
145 + </div>
146 + <Progress value={j.progress} />
147 + </div>
148 + );
149 +}
added web/src/app/favicon.ico +0 −0

Binary file not shown.

added web/src/app/globals.css +101 −0
@@ -0,0 +1,101 @@
1 +@import "tailwindcss";
2 +
3 +@theme {
4 + --color-bg: #0b0d10;
5 + --color-surface: #11141a;
6 + --color-surface-2: #161a21;
7 + --color-surface-3: #1c212a;
8 + --color-border: #232831;
9 + --color-border-strong: #2f3641;
10 + --color-ink: #e7e9ee;
11 + --color-ink-2: #aab1bd;
12 + --color-ink-3: #6f7784;
13 + --color-accent: #3987e5;
14 + --color-accent-2: #d95926;
15 + --color-accent-3: #199e70;
16 + --color-good: #22a06b;
17 + --color-warn: #c98500;
18 + --color-bad: #e66767;
19 + --color-violet: #9085e9;
20 + --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", system-ui, sans-serif;
21 + --font-mono: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
22 +}
23 +
24 +:root {
25 + color-scheme: dark;
26 + --series-1: #3987e5;
27 + --series-2: #d95926;
28 + --series-3: #199e70;
29 + --series-4: #c98500;
30 + --series-5: #d55181;
31 + --series-6: #008300;
32 + --series-7: #9085e9;
33 + --series-8: #e66767;
34 +}
35 +
36 +html, body {
37 + background: var(--color-bg);
38 + color: var(--color-ink);
39 + font-family: var(--font-sans);
40 + -webkit-font-smoothing: antialiased;
41 + text-rendering: optimizeLegibility;
42 +}
43 +
44 +* { scrollbar-width: thin; scrollbar-color: #2f3641 transparent; }
45 +
46 +.num { font-variant-numeric: tabular-nums; }
47 +
48 +.card {
49 + background: var(--color-surface);
50 + border: 1px solid var(--color-border);
51 + border-radius: 12px;
52 +}
53 +
54 +.pill {
55 + display: inline-flex; align-items: center; gap: 6px;
56 + padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600;
57 + letter-spacing: 0.01em; border: 1px solid transparent; white-space: nowrap;
58 +}
59 +
60 +.btn {
61 + display: inline-flex; align-items: center; justify-content: center; gap: 6px;
62 + height: 32px; padding: 0 12px; border-radius: 8px; font-size: 13px; font-weight: 500;
63 + border: 1px solid var(--color-border-strong); background: var(--color-surface-2); color: var(--color-ink);
64 + transition: background 120ms, border-color 120ms, opacity 120ms; cursor: pointer; white-space: nowrap;
65 +}
66 +.btn:hover { background: var(--color-surface-3); border-color: #3a4250; }
67 +.btn:disabled { opacity: 0.45; cursor: not-allowed; }
68 +.btn-primary { background: var(--color-accent); border-color: var(--color-accent); color: white; }
69 +.btn-primary:hover { background: #4a93ea; border-color: #4a93ea; }
70 +.btn-danger { background: transparent; border-color: #5a2b2b; color: var(--color-bad); }
71 +.btn-danger:hover { background: #2a1616; }
72 +.btn-ghost { background: transparent; border-color: transparent; color: var(--color-ink-2); }
73 +.btn-ghost:hover { background: var(--color-surface-2); color: var(--color-ink); }
74 +.btn-sm { height: 26px; padding: 0 9px; font-size: 12px; border-radius: 7px; }
75 +
76 +.input {
77 + height: 34px; padding: 0 10px; border-radius: 8px; font-size: 13px; width: 100%;
78 + border: 1px solid var(--color-border-strong); background: var(--color-bg); color: var(--color-ink); outline: none;
79 +}
80 +.input:focus { border-color: var(--color-accent); box-shadow: 0 0 0 3px rgba(57,135,229,0.18); }
81 +textarea.input { height: auto; padding: 8px 10px; line-height: 1.5; resize: vertical; }
82 +select.input { appearance: none; padding-right: 28px; background-image: linear-gradient(45deg, transparent 50%, #6f7784 50%), linear-gradient(135deg, #6f7784 50%, transparent 50%); background-position: calc(100% - 14px) 14px, calc(100% - 9px) 14px; background-size: 5px 5px; background-repeat: no-repeat; }
83 +
84 +.tbl { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
85 +.tbl th { text-align: left; font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-ink-3); padding: 8px 12px; border-bottom: 1px solid var(--color-border); white-space: nowrap; }
86 +.tbl td { padding: 10px 12px; border-bottom: 1px solid var(--color-border); vertical-align: middle; }
87 +.tbl tr:last-child td { border-bottom: none; }
88 +.tbl tr.row-link { cursor: pointer; }
89 +.tbl tr.row-link:hover td { background: var(--color-surface-2); }
90 +
91 +.label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-ink-3); font-weight: 500; }
92 +.mono { font-family: var(--font-mono); }
93 +.prose-sm p { margin: 0 0 8px; }
94 +.prose-sm code { font-family: var(--font-mono); font-size: 12px; background: var(--color-surface-3); padding: 1px 5px; border-radius: 4px; }
95 +.prose-sm pre { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 8px; padding: 10px 12px; overflow: auto; font-size: 12px; margin: 8px 0; }
96 +.prose-sm pre code { background: none; padding: 0; }
97 +
98 +@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
99 +.pulse { animation: pulse 1.4s ease-in-out infinite; }
100 +@keyframes spin { to { transform: rotate(360deg) } }
101 +.spin { animation: spin 0.9s linear infinite; }
added web/src/app/harvester/page.tsx +159 −0
@@ -0,0 +1,159 @@
1 +"use client";
2 +
3 +import { useEffect, useMemo, useState } from "react";
4 +import { api, ApiError } from "@/lib/api";
5 +import { useLive } from "@/lib/events";
6 +import { fmtAgo, fmtBytes, fmtGB, fmtParams } from "@/lib/format";
7 +import type { Candidate, Job } from "@/lib/types";
8 +import { CompatPill, Modal, PageHeader, Pill, Progress, SizePill, Spinner, useToast } from "@/components/ui";
9 +
10 +const TASKS = ["", "general", "coding", "reasoning", "vision", "embedding", "reranker"];
11 +const SIZES = ["", "TINY", "SMALL", "MEDIUM", "LARGE", "XL"];
12 +
13 +export default function HarvesterPage() {
14 + const live = useLive();
15 + const toast = useToast();
16 + const [rows, setRows] = useState<Candidate[]>([]);
17 + const [starter, setStarter] = useState<{ slot: string; candidate: Candidate | null }[]>([]);
18 + const [lastScan, setLastScan] = useState<number | null>(null);
19 + const [task, setTask] = useState("");
20 + const [rt, setRt] = useState("");
21 + const [size, setSize] = useState("");
22 + const [q, setQ] = useState("");
23 + const [dups, setDups] = useState(false);
24 + const [opts, setOpts] = useState(false);
25 + const [scanOpts, setScanOpts] = useState({ runtimes: ["mlx", "gguf"], min_downloads: 500, limit_per_author: 150, max_ram_gb: "", search: "" });
26 + const [busy, setBusy] = useState(false);
27 +
28 + const scanJob = Object.values(live.jobs).find((j) => j.kind === "harvest" && (j.status === "running" || j.status === "queued"));
29 +
30 + const load = () => {
31 + const p = new URLSearchParams();
32 + if (task) p.set("task", task);
33 + if (rt) p.set("runtime", rt);
34 + if (size) p.set("size_class", size);
35 + if (q) p.set("q", q);
36 + if (dups) p.set("include_duplicates", "true");
37 + api.get<{ candidates: Candidate[]; last_scan: number | null; starter: { slot: string; candidate: Candidate | null }[] }>(`/api/harvest/candidates?${p}`)
38 + .then((r) => { setRows(r.candidates); setLastScan(r.last_scan); setStarter(r.starter); }).catch(() => {});
39 + };
40 + useEffect(() => { load(); }, [task, rt, size, q, dups, live.version]); // eslint-disable-line react-hooks/exhaustive-deps
41 +
42 + const scan = async () => {
43 + setBusy(true);
44 + try {
45 + await api.post("/api/harvest/scan", { ...scanOpts, max_ram_gb: scanOpts.max_ram_gb ? Number(scanOpts.max_ram_gb) : null, search: scanOpts.search || null });
46 + toast.push("Harvest started — this takes a minute or two", "good");
47 + setOpts(false);
48 + } catch (e) {
49 + toast.push(e instanceof ApiError ? e.message : "Failed", "bad");
50 + } finally {
51 + setBusy(false);
52 + }
53 + };
54 + const toggle = async (c: Candidate) => {
55 + await api.post("/api/harvest/select", { repo_id: c.repo_id, selected: !c.selected });
56 + load();
57 + };
58 + const dismiss = async (c: Candidate) => { await api.post("/api/harvest/dismiss", { repo_id: c.repo_id }); load(); };
59 + const queue = async () => {
60 + setBusy(true);
61 + try {
62 + const r = await api.post<{ queued: { repo: string; job?: string; error?: string }[] }>("/api/harvest/queue");
63 + const ok = r.queued.filter((x) => x.job).length;
64 + const bad = r.queued.filter((x) => x.error);
65 + toast.push(`${ok} download${ok === 1 ? "" : "s"} queued${bad.length ? ` · ${bad.length} failed: ${bad[0].error}` : ""}`, bad.length ? "bad" : "good");
66 + load();
67 + } finally {
68 + setBusy(false);
69 + }
70 + };
71 + const selected = rows.filter((r) => r.selected);
72 + const selectedBytes = selected.reduce((a, r) => a + (r.download_bytes || 0), 0);
73 + const families = useMemo(() => [...new Set(rows.map((r) => r.family))].sort(), [rows]);
74 +
75 + return (
76 + <div>
77 + {toast.view}
78 + <PageHeader title="Model Harvester" sub={<>Explores Hugging Face (mlx-community, unsloth, bartowski, ggml-org…), keeps only models that fit this Mac, removes duplicate quantizations and proposes a download queue. {lastScan ? <>Last scan {fmtAgo(lastScan)}.</> : "No scan yet."}</>}
79 + actions={<>
80 + <button className="btn" onClick={() => setOpts(true)}>Options</button>
81 + <button className="btn btn-primary" onClick={scan} disabled={busy || !!scanJob}>{scanJob ? <><Spinner /> Scanning…</> : "Scan Hugging Face"}</button>
82 + </>} />
83 +
84 + {scanJob && <div className="card p-4 mb-4"><div className="text-sm mb-2">{String((scanJob.detail as { stage?: string }).stage || "starting")}</div><Progress value={scanJob.progress} /></div>}
85 +
86 + {starter.some((s) => s.candidate) && (
87 + <div className="card p-4 mb-4">
88 + <div className="flex items-center justify-between mb-2"><div className="font-medium text-sm">Suggested starter library</div><span className="text-xs text-ink-3">best candidate per slot — tick the ones you want</span></div>
89 + <div className="grid sm:grid-cols-2 lg:grid-cols-5 gap-2">
90 + {starter.map((s) => (
91 + <div key={s.slot} className={`rounded-lg border p-2.5 text-xs ${s.candidate ? "border-border bg-surface-2" : "border-dashed border-border text-ink-3"}`}>
92 + <div className="label mb-1">{s.slot}</div>
93 + {s.candidate ? (
94 + <label className="flex items-start gap-2 cursor-pointer">
95 + <input type="checkbox" className="mt-0.5" checked={!!s.candidate.selected} onChange={() => toggle(s.candidate!)} disabled={!!s.candidate.installed} />
96 + <span className="min-w-0"><span className="block truncate font-medium text-ink" title={s.candidate.repo_id}>{s.candidate.name}</span><span className="text-ink-3 num">{fmtGB(s.candidate.estimated_ram_gb)} · {s.candidate.quantization}{s.candidate.installed ? " · installed" : ""}</span></span>
97 + </label>
98 + ) : "no candidate"}
99 + </div>
100 + ))}
101 + </div>
102 + </div>
103 + )}
104 +
105 + <div className="flex flex-wrap gap-2 items-center mb-3">
106 + <input className="input max-w-xs" placeholder="Search…" value={q} onChange={(e) => setQ(e.target.value)} />
107 + <select className="input w-auto" value={task} onChange={(e) => setTask(e.target.value)}>{TASKS.map((t) => <option key={t} value={t}>{t || "All tasks"}</option>)}</select>
108 + <select className="input w-auto" value={rt} onChange={(e) => setRt(e.target.value)}><option value="">MLX + GGUF</option><option value="mlx">MLX</option><option value="llamacpp">GGUF</option></select>
109 + <select className="input w-auto" value={size} onChange={(e) => setSize(e.target.value)}>{SIZES.map((s) => <option key={s} value={s}>{s || "All sizes"}</option>)}</select>
110 + <label className="text-xs text-ink-2 flex items-center gap-1.5"><input type="checkbox" checked={dups} onChange={(e) => setDups(e.target.checked)} /> show duplicate quantizations</label>
111 + <div className="ml-auto flex items-center gap-2 text-xs text-ink-2 num">
112 + {selected.length > 0 && <span>{selected.length} selected · {fmtBytes(selectedBytes)}</span>}
113 + <button className="btn btn-primary btn-sm" disabled={!selected.length || busy} onClick={queue}>Queue downloads</button>
114 + </div>
115 + </div>
116 +
117 + <div className="card overflow-x-auto">
118 + <table className="tbl">
119 + <thead><tr><th></th><th>Model</th><th>Task</th><th>Runtime</th><th>Quant</th><th className="text-right">Params</th><th className="text-right">Download</th><th className="text-right">Est. RAM</th><th>Compat</th><th className="text-right">Downloads</th><th className="text-right">Score</th><th></th></tr></thead>
120 + <tbody>
121 + {rows.map((c) => (
122 + <tr key={c.repo_id} className={c.installed ? "opacity-60" : ""}>
123 + <td><input type="checkbox" checked={!!c.selected} disabled={!!c.installed} onChange={() => toggle(c)} /></td>
124 + <td><a className="font-medium hover:underline" href={`https://huggingface.co/${c.repo_id}`} target="_blank" rel="noreferrer">{c.name}</a><div className="text-xs text-ink-3 truncate max-w-[300px]">{c.repo_id}{c.duplicate_of ? ` · duplicate of ${c.duplicate_of.split("/")[1]}` : ""}{c.installed ? " · installed" : ""}</div></td>
125 + <td><Pill tone={c.task === "coding" ? "accent" : c.task === "reasoning" ? "violet" : c.task === "vision" ? "warn" : "neutral"}>{c.task}</Pill></td>
126 + <td className="text-ink-2">{c.runtime === "mlx" ? "MLX" : "GGUF"}</td>
127 + <td className="mono text-xs">{c.quantization || "—"}</td>
128 + <td className="text-right num">{fmtParams(c.parameter_count)}</td>
129 + <td className="text-right num">{fmtBytes(c.download_bytes)}</td>
130 + <td className="text-right num">{fmtGB(c.estimated_ram_gb)} <SizePill cls={c.size_class} /></td>
131 + <td><CompatPill status={c.compatibility_status} reason={c.compatibility_reason} /></td>
132 + <td className="text-right num text-ink-2">{c.downloads.toLocaleString()}</td>
133 + <td className="text-right num">{c.score.toFixed(0)}</td>
134 + <td><button className="btn btn-ghost btn-sm" title="Hide" onClick={() => dismiss(c)}>✕</button></td>
135 + </tr>
136 + ))}
137 + {!rows.length && <tr><td colSpan={12} className="text-center text-ink-3 py-10">{lastScan ? "No candidates match." : "Run a scan to discover compatible models."}</td></tr>}
138 + </tbody>
139 + </table>
140 + </div>
141 + {families.length > 0 && <div className="text-xs text-ink-3 mt-2">Families: {families.join(", ")}</div>}
142 +
143 + <Modal open={opts} onClose={() => setOpts(false)} title="Harvest options">
144 + <div className="flex flex-col gap-3 text-sm">
145 + <div className="flex gap-4">
146 + {["mlx", "gguf"].map((r) => <label key={r} className="flex items-center gap-2"><input type="checkbox" checked={scanOpts.runtimes.includes(r)} onChange={(e) => setScanOpts({ ...scanOpts, runtimes: e.target.checked ? [...scanOpts.runtimes, r] : scanOpts.runtimes.filter((x) => x !== r) })} /> {r.toUpperCase()}</label>)}
147 + </div>
148 + <label className="flex flex-col gap-1"><span className="label">Minimum downloads</span><input className="input" type="number" value={scanOpts.min_downloads} onChange={(e) => setScanOpts({ ...scanOpts, min_downloads: Number(e.target.value) })} /></label>
149 + <label className="flex flex-col gap-1"><span className="label">Models listed per author</span><input className="input" type="number" value={scanOpts.limit_per_author} onChange={(e) => setScanOpts({ ...scanOpts, limit_per_author: Number(e.target.value) })} /></label>
150 + <label className="flex flex-col gap-1"><span className="label">Max RAM (GB, blank = safe budget)</span><input className="input" value={scanOpts.max_ram_gb} onChange={(e) => setScanOpts({ ...scanOpts, max_ram_gb: e.target.value })} /></label>
151 + <label className="flex flex-col gap-1"><span className="label">Search term (optional)</span><input className="input" value={scanOpts.search} onChange={(e) => setScanOpts({ ...scanOpts, search: e.target.value })} placeholder="qwen, gemma, coder…" /></label>
152 + <div className="flex justify-end gap-2 mt-2"><button className="btn" onClick={() => setOpts(false)}>Cancel</button><button className="btn btn-primary" onClick={scan} disabled={busy}>Scan</button></div>
153 + </div>
154 + </Modal>
155 + </div>
156 + );
157 +}
158 +
159 +export type { Job };
added web/src/app/keys/page.tsx +89 −0
@@ -0,0 +1,89 @@
1 +"use client";
2 +
3 +import { useEffect, useState } from "react";
4 +import { api, ApiError } from "@/lib/api";
5 +import { fmtAgo, fmtDate, fmtNum } from "@/lib/format";
6 +import type { ApiKey } from "@/lib/types";
7 +import { Code, Modal, PageHeader, Pill, useToast } from "@/components/ui";
8 +
9 +export default function KeysPage() {
10 + const toast = useToast();
11 + const [keys, setKeys] = useState<ApiKey[]>([]);
12 + const [name, setName] = useState("");
13 + const [admin, setAdmin] = useState(false);
14 + const [created, setCreated] = useState<string | null>(null);
15 + const [busy, setBusy] = useState(false);
16 +
17 + const load = () => api.get<{ keys: ApiKey[] }>("/api/keys").then((r) => setKeys(r.keys)).catch(() => {});
18 + useEffect(() => { load(); }, []);
19 +
20 + const create = async (e: React.FormEvent) => {
21 + e.preventDefault();
22 + setBusy(true);
23 + try {
24 + const r = await api.post<{ key: string }>("/api/keys", { name: name.trim() || "key", scopes: admin ? ["inference", "admin"] : ["inference"] });
25 + setCreated(r.key);
26 + setName("");
27 + setAdmin(false);
28 + load();
29 + } catch (err) {
30 + toast.push(err instanceof ApiError ? err.message : "Failed", "bad");
31 + } finally {
32 + setBusy(false);
33 + }
34 + };
35 + const revoke = async (k: ApiKey) => {
36 + if (!confirm(`Revoke "${k.name}"? Clients using it will stop working.`)) return;
37 + await api.del(`/api/keys/${k.id}`);
38 + load();
39 + };
40 + const rename = async (k: ApiKey) => {
41 + const n = prompt("New name", k.name);
42 + if (!n) return;
43 + await api.patch(`/api/keys/${k.id}`, { name: n });
44 + load();
45 + };
46 + const origin = typeof location !== "undefined" ? location.origin : "https://www.llm-api.io";
47 +
48 + return (
49 + <div>
50 + {toast.view}
51 + <PageHeader title="API keys" sub="Keys are shown once at creation and stored hashed. Use them as Bearer tokens with any OpenAI SDK." />
52 + <form onSubmit={create} className="card p-4 mb-5 flex flex-col sm:flex-row gap-2 sm:items-center">
53 + <input className="input sm:max-w-xs" placeholder="Key name (e.g. laptop, cursor, n8n)" value={name} onChange={(e) => setName(e.target.value)} />
54 + <label className="text-sm flex items-center gap-2 text-ink-2"><input type="checkbox" checked={admin} onChange={(e) => setAdmin(e.target.checked)} /> admin scope (management API)</label>
55 + <button className="btn btn-primary sm:ml-auto" disabled={busy}>Create key</button>
56 + </form>
57 + <div className="card overflow-x-auto">
58 + <table className="tbl">
59 + <thead><tr><th>Name</th><th>Prefix</th><th>Scopes</th><th>Created</th><th>Last used</th><th className="text-right">Requests</th><th>Status</th><th></th></tr></thead>
60 + <tbody>
61 + {keys.map((k) => (
62 + <tr key={k.id} className={k.revoked_at ? "opacity-50" : ""}>
63 + <td className="font-medium">{k.name}</td>
64 + <td className="mono text-xs">{k.prefix}…</td>
65 + <td className="flex gap-1">{k.scopes.map((s) => <Pill key={s} tone={s === "admin" ? "violet" : "neutral"}>{s}</Pill>)}</td>
66 + <td className="text-ink-2">{fmtDate(k.created_at)}</td>
67 + <td className="text-ink-2">{fmtAgo(k.last_used_at)}</td>
68 + <td className="text-right num">{fmtNum(k.request_count)}</td>
69 + <td>{k.revoked_at ? <Pill tone="bad">revoked</Pill> : <Pill tone="good">active</Pill>}</td>
70 + <td className="text-right whitespace-nowrap">{!k.revoked_at && <><button className="btn btn-sm btn-ghost" onClick={() => rename(k)}>Rename</button> <button className="btn btn-sm btn-danger" onClick={() => revoke(k)}>Revoke</button></>}</td>
71 + </tr>
72 + ))}
73 + {!keys.length && <tr><td colSpan={8} className="text-center text-ink-3 py-8">No keys yet.</td></tr>}
74 + </tbody>
75 + </table>
76 + </div>
77 + <Modal open={!!created} onClose={() => setCreated(null)} title="Your new API key" width={640}>
78 + <p className="text-sm mb-3">Copy it now — it will not be shown again.</p>
79 + <Code>{created || ""}</Code>
80 + <p className="text-sm mt-4 mb-2 text-ink-2">Quick start:</p>
81 + <Code>{`from openai import OpenAI
82 +client = OpenAI(base_url="${origin}/v1", api_key="${created}")
83 +r = client.chat.completions.create(model="default", messages=[{"role": "user", "content": "Hello"}])
84 +print(r.choices[0].message.content)`}</Code>
85 + <div className="flex justify-end mt-4"><button className="btn btn-primary" onClick={() => setCreated(null)}>Done</button></div>
86 + </Modal>
87 + </div>
88 + );
89 +}
added web/src/app/layout.tsx +21 −0
@@ -0,0 +1,21 @@
1 +import type { Metadata, Viewport } from "next";
2 +import "./globals.css";
3 +import { Shell } from "@/components/shell";
4 +
5 +export const metadata: Metadata = {
6 + title: { default: "LLM API", template: "%s · LLM API" },
7 + description: "Private OpenAI-compatible local model API on Apple Silicon",
8 + robots: { index: false, follow: false },
9 +};
10 +
11 +export const viewport: Viewport = { themeColor: "#0b0d10", width: "device-width", initialScale: 1 };
12 +
13 +export default function RootLayout({ children }: { children: React.ReactNode }) {
14 + return (
15 + <html lang="en">
16 + <body className="min-h-screen">
17 + <Shell>{children}</Shell>
18 + </body>
19 + </html>
20 + );
21 +}
added web/src/app/login/page.tsx +73 −0
@@ -0,0 +1,73 @@
1 +"use client";
2 +
3 +import { useRouter, useSearchParams } from "next/navigation";
4 +import { Suspense, useEffect, useState } from "react";
5 +import { api, ApiError } from "@/lib/api";
6 +
7 +function LoginForm() {
8 + const router = useRouter();
9 + const params = useSearchParams();
10 + const [needsSetup, setNeedsSetup] = useState(false);
11 + const [email, setEmail] = useState("");
12 + const [password, setPassword] = useState("");
13 + const [confirm, setConfirm] = useState("");
14 + const [error, setError] = useState<string | null>(null);
15 + const [busy, setBusy] = useState(false);
16 +
17 + useEffect(() => {
18 + api.get<{ needs_setup: boolean; authenticated: boolean }>("/api/auth/status").then((s) => {
19 + setNeedsSetup(s.needs_setup);
20 + if (s.authenticated) router.replace(params.get("next") || "/");
21 + }).catch(() => {});
22 + }, [router, params]);
23 +
24 + const submit = async (e: React.FormEvent) => {
25 + e.preventDefault();
26 + setError(null);
27 + if (needsSetup && password !== confirm) return setError("Passwords do not match.");
28 + setBusy(true);
29 + try {
30 + await api.post(needsSetup ? "/api/auth/setup" : "/api/auth/login", { email, password });
31 + router.replace(params.get("next") || "/");
32 + } catch (err) {
33 + setError(err instanceof ApiError ? err.message : "Login failed");
34 + } finally {
35 + setBusy(false);
36 + }
37 + };
38 +
39 + return (
40 + <div className="min-h-screen grid place-items-center p-6">
41 + <form onSubmit={submit} className="card w-full max-w-sm p-7 flex flex-col gap-4">
42 + <div className="flex items-center gap-2.5 mb-1">
43 + <div className="h-8 w-8 rounded-lg bg-accent grid place-items-center text-white font-bold">λ</div>
44 + <div>
45 + <div className="font-semibold tracking-tight">LLM API</div>
46 + <div className="text-xs text-ink-3">{needsSetup ? "Create the administrator account" : "Sign in to the console"}</div>
47 + </div>
48 + </div>
49 + <label className="flex flex-col gap-1 text-sm">
50 + <span className="label">Email</span>
51 + <input className="input" type="email" autoComplete="username" value={email} onChange={(e) => setEmail(e.target.value)} required />
52 + </label>
53 + <label className="flex flex-col gap-1 text-sm">
54 + <span className="label">Password</span>
55 + <input className="input" type="password" autoComplete={needsSetup ? "new-password" : "current-password"} value={password} onChange={(e) => setPassword(e.target.value)} required minLength={needsSetup ? 10 : 1} />
56 + </label>
57 + {needsSetup && (
58 + <label className="flex flex-col gap-1 text-sm">
59 + <span className="label">Confirm password</span>
60 + <input className="input" type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
61 + </label>
62 + )}
63 + {error && <div className="text-sm text-bad">{error}</div>}
64 + <button className="btn btn-primary h-9" disabled={busy}>{busy ? "…" : needsSetup ? "Create account" : "Sign in"}</button>
65 + <div className="text-[11px] text-ink-3 text-center">Private system · all access is logged</div>
66 + </form>
67 + </div>
68 + );
69 +}
70 +
71 +export default function LoginPage() {
72 + return <Suspense><LoginForm /></Suspense>;
73 +}
added web/src/app/page.tsx +170 −0
@@ -0,0 +1,170 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useEffect, useState } from "react";
5 +import { api } from "@/lib/api";
6 +import { useLive } from "@/lib/events";
7 +import { fmtAgo, fmtDuration, fmtGB, fmtMs, fmtNum } from "@/lib/format";
8 +import type { Model, SystemInfo } from "@/lib/types";
9 +import { Meter, Pill, Sparkline, StatTile, StatusPill } from "@/components/ui";
10 +
11 +interface Stats { totals: { requests: number; prompt_tokens: number; completion_tokens: number; avg_tps: number | null; avg_ttft_ms: number | null; errors: number }; per_model: { model_id: string; requests: number; completion_tokens: number; avg_tps: number | null }[]; all_time: { requests: number; completion_tokens: number; prompt_tokens: number } }
12 +
13 +export default function Dashboard() {
14 + const live = useLive();
15 + const [sys, setSys] = useState<SystemInfo | null>(null);
16 + const [models, setModels] = useState<Model[]>([]);
17 + const [stats, setStats] = useState<Stats | null>(null);
18 +
19 + useEffect(() => {
20 + api.get<SystemInfo>("/api/system").then(setSys).catch(() => {});
21 + api.get<{ models: Model[] }>("/api/models").then((r) => setModels(r.models)).catch(() => {});
22 + api.get<{ requests: Stats }>("/api/system/metrics?minutes=60&hours=24").then((r) => setStats(r.requests)).catch(() => {});
23 + }, [live.version]);
24 +
25 + const t = live.metrics || sys?.telemetry;
26 + const hw = sys?.hardware;
27 + const mgr = live.manager || sys?.manager;
28 + const loaded = mgr?.loaded || [];
29 + const cur = loaded.find((w) => w.status === "ready");
30 + const memHist = live.metricsHistory.map((m) => m.mem_used_gb);
31 + const gpuHist = live.metricsHistory.map((m) => m.gpu_percent ?? 0);
32 + const cpuHist = live.metricsHistory.map((m) => m.cpu_percent);
33 + const budget = sys?.policy.max_model_memory_gb ?? 45;
34 + const installed = models.filter((m) => m.installed);
35 + const recentTps = live.requests.map((r) => Number(r.tps) || 0).filter(Boolean).reverse();
36 +
37 + return (
38 + <div className="flex flex-col gap-5">
39 + <div className="flex flex-wrap items-end justify-between gap-3">
40 + <div>
41 + <h1 className="text-xl font-semibold tracking-tight">Dashboard</h1>
42 + <div className="text-sm text-ink-3 mt-0.5">
43 + {hw ? <>{hw.chip} · {hw.memory_gb.toFixed(0)} GB unified memory · {hw.gpu_cores ?? "—"} GPU cores · {hw.cpu_cores} CPU cores · macOS {hw.os_version}</> : "…"}
44 + </div>
45 + </div>
46 + <div className="flex items-center gap-2 text-xs text-ink-3">
47 + <Pill tone={t?.mem_pressure_level === "normal" ? "good" : t?.mem_pressure_level === "warning" ? "warn" : "bad"}>Memory {t?.mem_pressure_level ?? "—"}</Pill>
48 + <Pill tone={t?.thermal_state === "nominal" ? "good" : "warn"}>Thermal {t?.thermal_state ?? "—"}</Pill>
49 + {t?.swap_used_gb ? <Pill tone="warn">Swap {fmtGB(t.swap_used_gb)}</Pill> : null}
50 + </div>
51 + </div>
52 +
53 + {live.alerts.length > 0 && (
54 + <div className="card border-[#5a4210] bg-[#1d1708] px-4 py-2.5 text-sm text-[#f0b23a]">{live.alerts[live.alerts.length - 1].message}</div>
55 + )}
56 +
57 + {/* Current model */}
58 + <section className="card p-5">
59 + <div className="flex flex-wrap items-start justify-between gap-4">
60 + <div className="min-w-0">
61 + <div className="label mb-1">Current model</div>
62 + {cur ? (
63 + <>
64 + <Link href={`/models/${cur.model_id}`} className="text-lg font-semibold tracking-tight hover:underline truncate block">{cur.model_id}</Link>
65 + <div className="text-sm text-ink-3 mt-1 flex flex-wrap gap-x-3 gap-y-1 num">
66 + <span>{cur.runtime === "mlx" ? "MLX" : "llama.cpp"}</span>
67 + <span>context {Math.round(cur.context / 1024)}K</span>
68 + <span>memory {fmtGB(cur.measured_gb || cur.estimate_gb)}</span>
69 + <span>loaded in {fmtMs(cur.load_ms)}</span>
70 + <span>warm TTFT {fmtMs(cur.warm?.ttft_ms as number)}</span>
71 + <span>{cur.requests} requests</span>
72 + {cur.in_flight > 0 && <span className="text-accent">{cur.in_flight} in flight</span>}
73 + {cur.pinned && <span className="text-violet">pinned</span>}
74 + </div>
75 + </>
76 + ) : Object.keys(mgr?.progress || {}).length ? (
77 + <div className="text-lg font-semibold tracking-tight text-accent pulse">Loading {Object.keys(mgr!.progress)[0]}… <span className="text-sm text-ink-3 font-normal">{Object.values(mgr!.progress)[0].status} · {Object.values(mgr!.progress)[0].elapsed_seconds}s</span></div>
78 + ) : (
79 + <div className="text-lg font-semibold tracking-tight text-ink-3">No model loaded <span className="text-sm font-normal">— the next API request loads it on demand</span></div>
80 + )}
81 + {loaded.length > 1 && (
82 + <div className="mt-2 flex flex-wrap gap-1.5">{loaded.filter((w) => w !== cur).map((w) => <Pill key={w.model_id} tone="accent">{w.model_id} · {fmtGB(w.measured_gb || w.estimate_gb)}</Pill>)}</div>
83 + )}
84 + </div>
85 + <div className="flex gap-2">
86 + <Link href="/playground" className="btn btn-primary">Open playground</Link>
87 + <Link href="/models" className="btn">Model library</Link>
88 + </div>
89 + </div>
90 + <div className="mt-4">
91 + <div className="flex justify-between text-xs text-ink-3 mb-1.5 num">
92 + <span>Model memory budget · {fmtGB(mgr?.resident_gb ?? 0)} resident of {fmtGB(budget, 0)}</span>
93 + <span>{t ? `${t.mem_used_gb.toFixed(1)} / ${t.mem_total_gb.toFixed(0)} GB system used` : ""}</span>
94 + </div>
95 + <Meter value={mgr?.resident_gb ?? 0} max={budget} tone={(mgr?.resident_gb ?? 0) > budget * 0.9 ? "warn" : "accent"} />
96 + </div>
97 + </section>
98 +
99 + {/* Telemetry */}
100 + <section className="grid grid-cols-2 lg:grid-cols-4 gap-3">
101 + <StatTile label="RAM used" value={t ? `${t.mem_used_gb.toFixed(1)} GB` : "—"} sub={t ? `${t.mem_available_gb.toFixed(1)} GB available · pressure ${t.mem_pressure_percent}%` : ""}>
102 + <Sparkline data={memHist} max={t?.mem_total_gb} unit=" GB" />
103 + </StatTile>
104 + <StatTile label="GPU" value={t?.gpu_percent != null ? `${t.gpu_percent.toFixed(0)}%` : "—"} sub={t?.gpu_memory_gb != null ? `${t.gpu_memory_gb.toFixed(1)} GB in use by Metal` : "Device utilization"}>
105 + <Sparkline data={gpuHist} max={100} color="var(--series-3)" unit="%" />
106 + </StatTile>
107 + <StatTile label="CPU" value={t ? `${t.cpu_percent.toFixed(0)}%` : "—"} sub={t ? `load ${t.load_avg.map((x) => x.toFixed(1)).join(" / ")}` : ""}>
108 + <Sparkline data={cpuHist} max={100} color="var(--series-2)" unit="%" />
109 + </StatTile>
110 + <StatTile label="Throughput" value={recentTps.length ? `${recentTps[recentTps.length - 1].toFixed(1)} tok/s` : cur && stats?.per_model.find((p) => p.model_id === cur.model_id)?.avg_tps ? `${stats.per_model.find((p) => p.model_id === cur.model_id)!.avg_tps!.toFixed(1)} tok/s` : "—"} sub="last requests, generation">
111 + <Sparkline data={recentTps} color="var(--series-7)" unit=" tok/s" />
112 + </StatTile>
113 + </section>
114 +
115 + <section className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
116 + <StatTile label="Requests (24h)" value={fmtNum(stats?.totals.requests ?? 0)} sub={`${fmtNum(stats?.totals.errors ?? 0)} errors`} />
117 + <StatTile label="Tokens generated (24h)" value={fmtNum(stats?.totals.completion_tokens ?? 0)} sub={`${fmtNum(stats?.all_time.completion_tokens ?? 0)} all time`} />
118 + <StatTile label="Avg TTFT" value={fmtMs(stats?.totals.avg_ttft_ms)} sub={`avg ${stats?.totals.avg_tps ? stats.totals.avg_tps.toFixed(1) : "—"} tok/s`} />
119 + <StatTile label="Installed models" value={installed.length} sub={`${installed.filter((m) => m.runtime === "mlx").length} MLX · ${installed.filter((m) => m.runtime === "llamacpp").length} GGUF`} />
120 + <StatTile label="Disk free" value={t ? `${t.disk_free_gb.toFixed(0)} GB` : "—"} sub={t ? `of ${t.disk_total_gb.toFixed(0)} GB` : ""} />
121 + <StatTile label="Uptime" value={fmtDuration(t?.app_uptime_seconds ?? 0)} sub={`system ${fmtDuration(t?.uptime_seconds ?? 0)}`} />
122 + </section>
123 +
124 + {/* Models + activity */}
125 + <section className="grid lg:grid-cols-[1.4fr_1fr] gap-4">
126 + <div className="card">
127 + <div className="flex items-center justify-between px-4 py-3 border-b border-border">
128 + <div className="font-medium text-sm">Models</div>
129 + <Link href="/models" className="text-xs text-accent hover:underline">All models →</Link>
130 + </div>
131 + <div className="overflow-x-auto">
132 + <table className="tbl">
133 + <thead><tr><th>Model</th><th>Runtime</th><th className="text-right">Est. RAM</th><th className="text-right">Tok/s</th><th>Status</th></tr></thead>
134 + <tbody>
135 + {installed.slice().sort((a, b) => Number(b.loaded) - Number(a.loaded) || Number(b.favorite) - Number(a.favorite) || (b.last_used_at || 0) - (a.last_used_at || 0)).slice(0, 8).map((m) => (
136 + <tr key={m.id} className="row-link" onClick={() => (location.href = `/models/${m.id}`)}>
137 + <td><div className="font-medium truncate max-w-[260px]">{m.favorite && <span className="text-warn mr-1">★</span>}{m.name}</div><div className="text-xs text-ink-3">{m.family} · {m.quantization}</div></td>
138 + <td className="text-ink-2">{m.runtime === "mlx" ? "MLX" : "GGUF"}</td>
139 + <td className="text-right num">{fmtGB(m.estimated_ram_gb)}</td>
140 + <td className="text-right num">{m.avg_tps ? m.avg_tps.toFixed(1) : "—"}</td>
141 + <td><StatusPill status={m.status} /></td>
142 + </tr>
143 + ))}
144 + {!installed.length && <tr><td colSpan={5} className="text-ink-3 text-center py-8">No models yet — <Link className="text-accent" href="/downloads">download one</Link></td></tr>}
145 + </tbody>
146 + </table>
147 + </div>
148 + </div>
149 + <div className="card">
150 + <div className="px-4 py-3 border-b border-border font-medium text-sm">Live activity</div>
151 + <ul className="divide-y divide-border text-sm max-h-[420px] overflow-auto">
152 + {live.requests.slice(0, 20).map((r, i) => (
153 + <li key={i} className="px-4 py-2.5 flex items-center justify-between gap-3">
154 + <div className="min-w-0">
155 + <div className="truncate">{String(r.model_id)}</div>
156 + <div className="text-xs text-ink-3">{String(r.endpoint).replace("/v1/", "")}{r.stream ? " · stream" : ""}</div>
157 + </div>
158 + <div className="text-right text-xs num shrink-0">
159 + <div className={Number(r.status) >= 400 ? "text-bad" : "text-ink-2"}>{Number(r.status) >= 400 ? `error ${r.status}` : `${fmtNum(Number(r.completion_tokens) || 0)} tok · ${r.tps ? Number(r.tps).toFixed(1) : "—"} tok/s`}</div>
160 + <div className="text-ink-3">TTFT {fmtMs(Number(r.ttft_ms))} · {fmtMs(Number(r.total_ms))}</div>
161 + </div>
162 + </li>
163 + ))}
164 + {!live.requests.length && <li className="px-4 py-8 text-center text-ink-3 text-xs">Requests appear here in real time{cur ? "" : ` · last used ${fmtAgo(installed[0]?.last_used_at)}`}</li>}
165 + </ul>
166 + </div>
167 + </section>
168 + </div>
169 + );
170 +}
added web/src/app/playground/page.tsx +199 −0
@@ -0,0 +1,199 @@
1 +"use client";
2 +
3 +import { useSearchParams } from "next/navigation";
4 +import { Suspense, useEffect, useMemo, useRef, useState } from "react";
5 +import { api, ApiError, streamChat } from "@/lib/api";
6 +import { useLive } from "@/lib/events";
7 +import { fmtMs } from "@/lib/format";
8 +import type { Model } from "@/lib/types";
9 +import { Code, Modal, Pill, StatusPill } from "@/components/ui";
10 +
11 +interface Msg { role: "user" | "assistant" | "system"; content: string; reasoning?: string; stats?: { tps?: number; ttft?: number; tokens?: number; prompt?: number; total?: number } }
12 +
13 +function Playground() {
14 + const params = useSearchParams();
15 + const live = useLive();
16 + const [models, setModels] = useState<Model[]>([]);
17 + const [model, setModel] = useState(params.get("model") || "");
18 + const [system, setSystem] = useState("");
19 + const [temp, setTemp] = useState(0.7);
20 + const [topP, setTopP] = useState(0.95);
21 + const [maxTokens, setMaxTokens] = useState(1024);
22 + const [thinking, setThinking] = useState(true);
23 + const [msgs, setMsgs] = useState<Msg[]>([]);
24 + const [input, setInput] = useState("");
25 + const [busy, setBusy] = useState(false);
26 + const [phase, setPhase] = useState<string>("");
27 + const [showRaw, setShowRaw] = useState(false);
28 + const [showSettings, setShowSettings] = useState(false);
29 + const abortRef = useRef<AbortController | null>(null);
30 + const endRef = useRef<HTMLDivElement>(null);
31 +
32 + useEffect(() => {
33 + api.get<{ models: Model[] }>("/api/models").then((r) => {
34 + const ok = r.models.filter((m) => m.installed && m.enabled && !m.embedding && !m.reranker);
35 + setModels(ok);
36 + if (!model && ok.length) {
37 + const cur = ok.find((m) => m.status === "ready") || ok.find((m) => m.favorite) || ok[0];
38 + setModel(cur.id);
39 + }
40 + }).catch(() => {});
41 + // eslint-disable-next-line react-hooks/exhaustive-deps
42 + }, [live.version]);
43 +
44 + useEffect(() => { endRef.current?.scrollIntoView({ block: "end" }); }, [msgs, phase]);
45 +
46 + const current = models.find((m) => m.id === model);
47 + const status = live.manager?.loaded.find((w) => w.model_id === model)?.status || live.manager?.progress[model]?.status || current?.status || "unloaded";
48 +
49 + const body = useMemo(() => {
50 + const messages: { role: string; content: string }[] = [];
51 + if (system.trim()) messages.push({ role: "system", content: system });
52 + for (const m of msgs) if (m.role !== "system") messages.push({ role: m.role, content: m.content });
53 + const b: Record<string, unknown> = { model, messages, temperature: temp, top_p: topP, max_tokens: maxTokens, stream: true };
54 + if (current?.thinking) b.chat_template_kwargs = { enable_thinking: thinking };
55 + return b;
56 + }, [model, system, msgs, temp, topP, maxTokens, thinking, current]);
57 +
58 + const send = async (text?: string, regenerate = false) => {
59 + const userText = text ?? input.trim();
60 + if (!model || busy) return;
61 + let history = msgs;
62 + if (regenerate) {
63 + history = msgs.slice(0, msgs.length - 1);
64 + } else {
65 + if (!userText) return;
66 + history = [...msgs, { role: "user", content: userText }];
67 + setInput("");
68 + }
69 + const assistant: Msg = { role: "assistant", content: "", reasoning: "" };
70 + setMsgs([...history, assistant]);
71 + setBusy(true);
72 + setPhase(status === "ready" ? "Generating…" : "Loading model…");
73 + const ac = new AbortController();
74 + abortRef.current = ac;
75 + const t0 = performance.now();
76 + let first = 0;
77 + try {
78 + const messages: { role: string; content: string }[] = [];
79 + if (system.trim()) messages.push({ role: "system", content: system });
80 + for (const m of history) messages.push({ role: m.role, content: m.content });
81 + const req: Record<string, unknown> = { model, messages, temperature: temp, top_p: topP, max_tokens: maxTokens };
82 + if (current?.thinking) req.chat_template_kwargs = { enable_thinking: thinking };
83 + for await (const chunk of streamChat(req, ac.signal)) {
84 + if ((chunk as { error?: { message: string } }).error) throw new Error((chunk as { error: { message: string } }).error.message);
85 + const choices = (chunk.choices as { delta?: { content?: string; reasoning_content?: string } }[]) || [];
86 + const d = choices[0]?.delta;
87 + if (d?.content || d?.reasoning_content) {
88 + if (!first) { first = performance.now(); setPhase("Generating…"); }
89 + assistant.content += d.content || "";
90 + assistant.reasoning = (assistant.reasoning || "") + (d.reasoning_content || "");
91 + setMsgs([...history, { ...assistant }]);
92 + }
93 + const usage = chunk.usage as { completion_tokens?: number; prompt_tokens?: number } | undefined;
94 + const timings = chunk.timings as { generation_tps?: number; ttft_ms?: number } | undefined;
95 + if (usage || timings) {
96 + assistant.stats = {
97 + tokens: usage?.completion_tokens, prompt: usage?.prompt_tokens,
98 + tps: timings?.generation_tps, ttft: timings?.ttft_ms ?? (first ? first - t0 : undefined), total: performance.now() - t0,
99 + };
100 + setMsgs([...history, { ...assistant }]);
101 + }
102 + }
103 + } catch (e) {
104 + if ((e as Error).name !== "AbortError") {
105 + assistant.content += `\n\n⚠️ ${e instanceof ApiError ? e.message : (e as Error).message}`;
106 + setMsgs([...history, { ...assistant }]);
107 + }
108 + } finally {
109 + if (!assistant.stats) assistant.stats = { total: performance.now() - t0, ttft: first ? first - t0 : undefined };
110 + setMsgs([...history, { ...assistant }]);
111 + setBusy(false);
112 + setPhase("");
113 + abortRef.current = null;
114 + }
115 + };
116 +
117 + return (
118 + <div className="flex flex-col h-[calc(100vh-56px)] md:h-[calc(100vh-64px)] -mb-20 md:mb-0">
119 + <div className="flex flex-wrap items-center gap-2 pb-3 border-b border-border">
120 + <select className="input w-auto max-w-[280px]" value={model} onChange={(e) => setModel(e.target.value)}>
121 + {!models.length && <option value="">No models installed</option>}
122 + {models.map((m) => <option key={m.id} value={m.id}>{m.favorite ? "★ " : ""}{m.name}{m.status === "ready" ? " · loaded" : ""}</option>)}
123 + </select>
124 + <StatusPill status={status} />
125 + {current && <span className="text-xs text-ink-3 hidden sm:inline">{current.runtime === "mlx" ? "MLX" : "llama.cpp"} · {current.quantization} · ctx {Math.round((current.recommended_context || 0) / 1024)}K</span>}
126 + <div className="ml-auto flex gap-2">
127 + <button className="btn btn-sm" onClick={() => setShowSettings(true)}>Parameters</button>
128 + <button className="btn btn-sm" onClick={() => setShowRaw(true)}>Raw request</button>
129 + <button className="btn btn-sm btn-ghost" onClick={() => setMsgs([])} disabled={busy}>Clear</button>
130 + </div>
131 + </div>
132 +
133 + <div className="flex-1 overflow-auto py-4 flex flex-col gap-4">
134 + {!msgs.length && (
135 + <div className="m-auto text-center text-ink-3 text-sm max-w-md">
136 + <div className="text-2xl mb-2">λ</div>
137 + Send a message. If <span className="text-ink">{current?.name || "the model"}</span> is not loaded, it is loaded from SSD first — you will see the status change above.
138 + </div>
139 + )}
140 + {msgs.map((m, i) => (
141 + <div key={i} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
142 + <div className={`max-w-[85%] md:max-w-[75%] rounded-2xl px-4 py-2.5 text-[14px] leading-relaxed whitespace-pre-wrap ${m.role === "user" ? "bg-accent text-white rounded-br-md" : "card rounded-bl-md"}`}>
143 + {m.reasoning && (
144 + <details className="mb-2 text-xs text-ink-3">
145 + <summary className="cursor-pointer select-none">Reasoning ({m.reasoning.length} chars)</summary>
146 + <div className="mt-1 whitespace-pre-wrap border-l-2 border-border pl-2 max-h-60 overflow-auto">{m.reasoning}</div>
147 + </details>
148 + )}
149 + {m.content || (busy && i === msgs.length - 1 ? <span className="text-ink-3 pulse">{phase || "…"}</span> : "")}
150 + {m.role === "assistant" && m.stats && !(busy && i === msgs.length - 1) && (
151 + <div className="mt-2 pt-2 border-t border-border flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-ink-3 num">
152 + {m.stats.tokens != null && <span>{m.stats.tokens} tokens</span>}
153 + {m.stats.tps && <span>{m.stats.tps.toFixed(1)} tok/s</span>}
154 + {m.stats.ttft != null && <span>TTFT {fmtMs(m.stats.ttft)}</span>}
155 + {m.stats.total != null && <span>{fmtMs(m.stats.total)} total</span>}
156 + <button className="hover:text-ink" onClick={() => navigator.clipboard.writeText(m.content)}>copy</button>
157 + {i === msgs.length - 1 && <button className="hover:text-ink" onClick={() => send(undefined, true)}>regenerate</button>}
158 + </div>
159 + )}
160 + </div>
161 + </div>
162 + ))}
163 + <div ref={endRef} />
164 + </div>
165 +
166 + <form className="pt-3 border-t border-border flex gap-2 items-end" onSubmit={(e) => { e.preventDefault(); send(); }}>
167 + <textarea className="input flex-1 min-h-[44px] max-h-40" rows={1} placeholder={`Message ${current?.name || "model"}… (⌘/Ctrl+Enter to send)`} value={input}
168 + onChange={(e) => setInput(e.target.value)}
169 + onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); send(); } }} />
170 + {busy ? (
171 + <button type="button" className="btn btn-danger h-[44px]" onClick={() => abortRef.current?.abort()}>Stop</button>
172 + ) : (
173 + <button className="btn btn-primary h-[44px]" disabled={!input.trim() || !model}>Send</button>
174 + )}
175 + </form>
176 +
177 + <Modal open={showSettings} onClose={() => setShowSettings(false)} title="Parameters">
178 + <div className="flex flex-col gap-4 text-sm">
179 + <label className="flex flex-col gap-1"><span className="label">System prompt</span><textarea className="input" rows={3} value={system} onChange={(e) => setSystem(e.target.value)} placeholder="You are a helpful assistant." /></label>
180 + <label className="flex flex-col gap-1"><span className="label flex justify-between">Temperature <span className="num">{temp.toFixed(2)}</span></span><input type="range" min={0} max={2} step={0.05} value={temp} onChange={(e) => setTemp(Number(e.target.value))} /></label>
181 + <label className="flex flex-col gap-1"><span className="label flex justify-between">Top-p <span className="num">{topP.toFixed(2)}</span></span><input type="range" min={0.05} max={1} step={0.05} value={topP} onChange={(e) => setTopP(Number(e.target.value))} /></label>
182 + <label className="flex flex-col gap-1"><span className="label">Max tokens</span><input className="input" type="number" min={1} max={32768} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} /></label>
183 + {current?.thinking && <label className="flex items-center gap-2"><input type="checkbox" checked={thinking} onChange={(e) => setThinking(e.target.checked)} /> Enable thinking (reasoning models)</label>}
184 + <div className="text-xs text-ink-3">Context window of the loaded worker: {current?.recommended_context ? `${Math.round(current.recommended_context / 1024)}K tokens` : "—"} <Pill>{current?.runtime}</Pill></div>
185 + </div>
186 + </Modal>
187 + <Modal open={showRaw} onClose={() => setShowRaw(false)} title="Raw API request" width={680}>
188 + <Code>{`curl ${typeof location !== "undefined" ? location.origin : ""}/v1/chat/completions \\
189 + -H "Authorization: Bearer llm_live_..." \\
190 + -H "Content-Type: application/json" \\
191 + -d '${JSON.stringify(body, null, 2)}'`}</Code>
192 + </Modal>
193 + </div>
194 + );
195 +}
196 +
197 +export default function PlaygroundPage() {
198 + return <Suspense><Playground /></Suspense>;
199 +}
added web/src/app/settings/page.tsx +134 −0
@@ -0,0 +1,134 @@
1 +"use client";
2 +
3 +import { useEffect, useState } from "react";
4 +import { api, ApiError } from "@/lib/api";
5 +import { useLive } from "@/lib/events";
6 +import type { Model } from "@/lib/types";
7 +import { PageHeader, Toggle, useToast } from "@/components/ui";
8 +
9 +type S = Record<string, string | number | boolean | null>;
10 +
11 +export default function SettingsPage() {
12 + const live = useLive();
13 + const toast = useToast();
14 + const [s, setS] = useState<S | null>(null);
15 + const [defaults, setDefaults] = useState<S>({});
16 + const [paths, setPaths] = useState<Record<string, string>>({});
17 + const [hfToken, setHfToken] = useState(false);
18 + const [models, setModels] = useState<Model[]>([]);
19 + const [aliases, setAliases] = useState<Record<string, string>>({});
20 + const [alias, setAlias] = useState("");
21 + const [aliasModel, setAliasModel] = useState("");
22 + const [pw, setPw] = useState({ current: "", next: "", confirm: "" });
23 + const [dirty, setDirty] = useState<S>({});
24 +
25 + const load = () => {
26 + api.get<{ settings: S; defaults: S; paths: Record<string, string>; hf_token_set: boolean }>("/api/settings").then((r) => { setS(r.settings); setDefaults(r.defaults); setPaths(r.paths); setHfToken(r.hf_token_set); });
27 + api.get<{ models: Model[]; aliases: Record<string, string> }>("/api/models").then((r) => { setModels(r.models); setAliases(r.aliases); });
28 + };
29 + useEffect(() => { load(); }, [live.version]);
30 +
31 + const set = (k: string, v: string | number | boolean | null) => { setS((o) => ({ ...(o || {}), [k]: v })); setDirty((d) => ({ ...d, [k]: v })); };
32 + const save = async () => {
33 + try {
34 + await api.patch("/api/settings", dirty);
35 + toast.push("Settings saved", "good");
36 + setDirty({});
37 + load();
38 + } catch (e) {
39 + toast.push(e instanceof ApiError ? e.message : "Failed", "bad");
40 + }
41 + };
42 + const addAlias = async () => {
43 + try {
44 + await api.put("/api/aliases", { alias: alias.trim(), model_id: aliasModel });
45 + setAlias("");
46 + load();
47 + } catch (e) {
48 + toast.push(e instanceof ApiError ? e.message : "Failed", "bad");
49 + }
50 + };
51 + const changePw = async (e: React.FormEvent) => {
52 + e.preventDefault();
53 + if (pw.next !== pw.confirm) return toast.push("Passwords do not match", "bad");
54 + try {
55 + await api.post("/api/auth/password", { current_password: pw.current, new_password: pw.next });
56 + toast.push("Password changed", "good");
57 + setPw({ current: "", next: "", confirm: "" });
58 + } catch (err) {
59 + toast.push(err instanceof ApiError ? err.message : "Failed", "bad");
60 + }
61 + };
62 + if (!s) return <div className="text-ink-3 text-sm">Loading…</div>;
63 + const textModels = models.filter((m) => m.installed && !m.embedding && !m.reranker);
64 + const Num = ({ k, label, hint, step = 1 }: { k: string; label: string; hint?: string; step?: number }) => (
65 + <label className="flex flex-col gap-1 text-sm"><span className="label">{label}</span>
66 + <input className="input" type="number" step={step} value={s[k] as number ?? ""} onChange={(e) => set(k, e.target.value === "" ? null : Number(e.target.value))} />
67 + {hint && <span className="text-xs text-ink-3">{hint} · default {String(defaults[k])}</span>}</label>
68 + );
69 + const Sel = ({ k, label, hint }: { k: string; label: string; hint?: string }) => (
70 + <label className="flex flex-col gap-1 text-sm"><span className="label">{label}</span>
71 + <select className="input" value={(s[k] as string) || ""} onChange={(e) => set(k, e.target.value || null)}>
72 + <option value="">none</option>{textModels.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}</select>
73 + {hint && <span className="text-xs text-ink-3">{hint}</span>}</label>
74 + );
75 +
76 + return (
77 + <div>
78 + {toast.view}
79 + <PageHeader title="Settings" actions={<button className="btn btn-primary" disabled={!Object.keys(dirty).length} onClick={save}>Save changes</button>} />
80 + <div className="grid lg:grid-cols-2 gap-4">
81 + <div className="card p-4 flex flex-col gap-4">
82 + <div className="font-medium text-sm">Memory policy</div>
83 + <Num k="max_model_memory_gb" label="Max model memory (GB)" hint="Safe budget for weights + KV cache + runtime. Models above it are refused." step={0.5} />
84 + <Num k="absolute_max_memory_gb" label="Absolute limit (GB)" hint="Hard ceiling even with force-load." step={0.5} />
85 + <Num k="max_simultaneous_models" label="Simultaneous large models" hint="Small embedding/reranker models can stay resident alongside." />
86 + <Num k="model_idle_timeout_minutes" label="Idle unload (minutes)" hint="0 disables. Pinned models are never idle-unloaded." />
87 + <Num k="default_context" label="Default context (tokens)" hint="Used when a model has no recommended context." />
88 + <Num k="default_max_tokens" label="Default max_tokens" hint="When a request does not specify one." />
89 + </div>
90 + <div className="card p-4 flex flex-col gap-4">
91 + <div className="font-medium text-sm">Models & runtimes</div>
92 + <Sel k="default_model" label="Default model" hint="Used when a request omits `model`." />
93 + <Sel k="preload_model" label="Preload at startup" hint="Default none — conserve RAM." />
94 + <div className="flex flex-col gap-3 pt-1">
95 + <Toggle checked={!!s.allow_mlx} onChange={(v) => set("allow_mlx", v)} label="Allow MLX models" />
96 + <Toggle checked={!!s.allow_gguf} onChange={(v) => set("allow_gguf", v)} label="Allow GGUF (llama.cpp) models" />
97 + <Toggle checked={!!s.allow_downloads} onChange={(v) => set("allow_downloads", v)} label="Allow downloads from Hugging Face" />
98 + <Toggle checked={!!s.log_prompts} onChange={(v) => set("log_prompts", v)} label="Log prompts and completions (privacy: off by default)" />
99 + </div>
100 + <Num k="min_free_disk_gb" label="Minimum free disk (GB)" hint="Downloads that would go below this are refused." />
101 + <div className="text-xs text-ink-3">Hugging Face token: {hfToken ? <span className="text-good">configured (HF_TOKEN)</span> : <span>not set — gated repos will fail. Set HF_TOKEN in .env.</span>}</div>
102 + </div>
103 + <div className="card p-4">
104 + <div className="font-medium text-sm mb-3">Model aliases</div>
105 + <p className="text-xs text-ink-3 mb-3">Clients can request <code className="mono">fast</code>, <code className="mono">coder</code>, <code className="mono">reasoning</code>, <code className="mono">vision</code>, <code className="mono">embedding</code>, <code className="mono">default</code>… <code className="mono">auto</code> picks a model from the prompt (code, images, length) using these aliases.</p>
106 + <div className="flex flex-col gap-1.5 mb-3">
107 + {Object.entries(aliases).map(([a, mid]) => (
108 + <div key={a} className="flex items-center gap-2 text-sm"><span className="mono text-xs bg-surface-2 px-1.5 py-0.5 rounded text-accent w-28 truncate">{a}</span><span className="text-ink-3">→</span><span className="truncate flex-1">{mid}</span><button className="btn btn-ghost btn-sm" onClick={() => api.del(`/api/aliases/${a}`).then(load)}>✕</button></div>
109 + ))}
110 + {!Object.keys(aliases).length && <div className="text-xs text-ink-3">No aliases yet.</div>}
111 + </div>
112 + <div className="flex gap-2">
113 + <input className="input w-32" placeholder="alias" value={alias} onChange={(e) => setAlias(e.target.value)} list="alias-suggest" />
114 + <datalist id="alias-suggest">{["default", "fast", "coder", "reasoning", "vision", "embedding", "reranker"].map((a) => <option key={a} value={a} />)}</datalist>
115 + <select className="input flex-1" value={aliasModel} onChange={(e) => setAliasModel(e.target.value)}><option value="">model…</option>{models.filter((m) => m.installed).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}</select>
116 + <button className="btn" disabled={!alias.trim() || !aliasModel} onClick={addAlias}>Add</button>
117 + </div>
118 + </div>
119 + <div className="card p-4 flex flex-col gap-4">
120 + <div className="font-medium text-sm">Account & paths</div>
121 + <form onSubmit={changePw} className="flex flex-col gap-2">
122 + <input className="input" type="password" placeholder="Current password" autoComplete="current-password" value={pw.current} onChange={(e) => setPw({ ...pw, current: e.target.value })} required />
123 + <input className="input" type="password" placeholder="New password (min 10)" autoComplete="new-password" value={pw.next} onChange={(e) => setPw({ ...pw, next: e.target.value })} required minLength={10} />
124 + <input className="input" type="password" placeholder="Confirm new password" autoComplete="new-password" value={pw.confirm} onChange={(e) => setPw({ ...pw, confirm: e.target.value })} required />
125 + <button className="btn self-start">Change password</button>
126 + </form>
127 + <div className="text-xs text-ink-3 flex flex-col gap-1 mono">
128 + {Object.entries(paths).map(([k, v]) => <div key={k}><span className="text-ink-2">{k}:</span> {v}</div>)}
129 + </div>
130 + </div>
131 + </div>
132 + </div>
133 + );
134 +}
added web/src/app/system/page.tsx +132 −0
@@ -0,0 +1,132 @@
1 +"use client";
2 +
3 +import { useEffect, useState } from "react";
4 +import { api } from "@/lib/api";
5 +import { useLive } from "@/lib/events";
6 +import { fmtBytes, fmtDate, fmtDuration, fmtGB, fmtMs } from "@/lib/format";
7 +import type { SystemInfo } from "@/lib/types";
8 +import { KV, Meter, PageHeader, Pill, Sparkline, StatTile, Tabs } from "@/components/ui";
9 +
10 +interface Storage { total_gb: number; used_gb: number; free_gb: number; models_gb: number; logs_gb: number; database_gb: number; cache_gb: number; other_gb: number; model_root: string; min_free_gb: number; models: { id: string; name: string; disk_size_bytes: number; runtime: string; last_used_at: number | null }[] }
11 +interface Procs { workers: { model_id: string; pid: number; port: number; runtime: string; rss_gb: number; cpu_percent: number; status: string; threads: number }[]; server: { pid: number; rss_gb: number; cpu_percent: number; threads: number } }
12 +interface Hist { ts: number; mem_used_gb: number; gpu_percent: number | null; cpu_percent: number; swap_used_gb: number; worker_rss_gb: number }
13 +
14 +export default function SystemPage() {
15 + const live = useLive();
16 + const [sys, setSys] = useState<SystemInfo | null>(null);
17 + const [storage, setStorage] = useState<Storage | null>(null);
18 + const [procs, setProcs] = useState<Procs | null>(null);
19 + const [hist, setHist] = useState<Hist[]>([]);
20 + const [range, setRange] = useState(60);
21 + const [tab, setTab] = useState<"overview" | "storage" | "processes" | "logs">("overview");
22 + const [audit, setAudit] = useState<{ created_at: number; actor: string; action: string; target: string | null; detail: string | null }[]>([]);
23 + const [events, setEvents] = useState<{ created_at: number; model_id: string | null; event: string; detail: string | null }[]>([]);
24 +
25 + useEffect(() => {
26 + api.get<SystemInfo>("/api/system").then(setSys).catch(() => {});
27 + api.get<{ history: Hist[] }>(`/api/system/metrics?minutes=${range}`).then((r) => setHist(r.history)).catch(() => {});
28 + }, [range, live.version]);
29 + useEffect(() => {
30 + if (tab === "storage") api.get<Storage>("/api/system/storage").then(setStorage).catch(() => {});
31 + if (tab === "processes") api.get<Procs>("/api/system/processes").then(setProcs).catch(() => {});
32 + if (tab === "logs") { api.get<{ logs: typeof audit }>("/api/logs/audit?limit=100").then((r) => setAudit(r.logs)); api.get<{ events: typeof events }>("/api/logs/events?limit=100").then((r) => setEvents(r.events)); }
33 + }, [tab, live.version]);
34 +
35 + const t = live.metrics || sys?.telemetry;
36 + const hw = sys?.hardware;
37 + const series = hist.length > 3 ? hist : live.metricsHistory.map((m) => ({ ts: m.ts, mem_used_gb: m.mem_used_gb, gpu_percent: m.gpu_percent, cpu_percent: m.cpu_percent, swap_used_gb: m.swap_used_gb, worker_rss_gb: m.worker_rss_gb || 0 }));
38 +
39 + return (
40 + <div>
41 + <PageHeader title="System" sub={hw ? `${hw.hostname} · ${hw.chip} · ${hw.os} ${hw.os_version} · Python ${hw.python} · LLM API ${sys?.version}` : "…"} />
42 + <Tabs value={tab} onChange={setTab} tabs={[{ id: "overview", label: "Overview" }, { id: "storage", label: "Storage" }, { id: "processes", label: "Processes" }, { id: "logs", label: "Audit & events" }]} />
43 +
44 + {tab === "overview" && t && hw && (
45 + <div className="flex flex-col gap-4 mt-4">
46 + <div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
47 + <StatTile label="Unified memory" value={`${t.mem_used_gb.toFixed(1)} / ${t.mem_total_gb.toFixed(0)} GB`} sub={`${t.mem_available_gb.toFixed(1)} GB available · wired ${fmtGB(t.mem_wired_gb)} · compressed ${fmtGB(t.mem_compressed_gb)}`}>
48 + <Meter value={t.mem_used_gb} max={t.mem_total_gb} tone={t.mem_pressure_level === "normal" ? "accent" : t.mem_pressure_level === "warning" ? "warn" : "bad"} />
49 + </StatTile>
50 + <StatTile label="Memory pressure" value={<span className={t.mem_pressure_level === "normal" ? "text-good" : t.mem_pressure_level === "warning" ? "text-warn" : "text-bad"}>{t.mem_pressure_level}</span>} sub={`${t.mem_pressure_percent}% · swap ${fmtGB(t.swap_used_gb)} / ${fmtGB(t.swap_total_gb)}`} />
51 + <StatTile label="GPU" value={t.gpu_percent != null ? `${t.gpu_percent.toFixed(0)}%` : "n/a"} sub={`${hw.gpu_cores ?? "—"} cores · renderer ${t.gpu_renderer_percent ?? "—"}% · ${fmtGB(t.gpu_memory_gb)} Metal`} />
52 + <StatTile label="Thermal" value={t.thermal_state} sub={t.thermal_cpu_speed_limit != null ? `CPU speed limit ${t.thermal_cpu_speed_limit}%` : "no throttling recorded"} />
53 + </div>
54 + <div className="card p-4">
55 + <div className="flex items-center justify-between mb-3">
56 + <div className="font-medium text-sm">History</div>
57 + <div className="flex gap-1">{[15, 60, 360, 1440].map((m) => <button key={m} onClick={() => setRange(m)} className={`btn btn-sm ${range === m ? "btn-primary" : ""}`}>{m < 60 ? `${m}m` : `${m / 60}h`}</button>)}</div>
58 + </div>
59 + <div className="grid md:grid-cols-2 gap-4">
60 + <div><div className="label mb-1">Memory used (GB)</div><Sparkline data={series.map((h) => h.mem_used_gb)} max={t.mem_total_gb} height={80} unit=" GB" /></div>
61 + <div><div className="label mb-1">Worker memory (GB)</div><Sparkline data={series.map((h) => h.worker_rss_gb || 0)} max={t.mem_total_gb} height={80} color="var(--series-7)" unit=" GB" /></div>
62 + <div><div className="label mb-1">GPU utilization (%)</div><Sparkline data={series.map((h) => h.gpu_percent ?? 0)} max={100} height={80} color="var(--series-3)" unit="%" /></div>
63 + <div><div className="label mb-1">CPU (%)</div><Sparkline data={series.map((h) => h.cpu_percent)} max={100} height={80} color="var(--series-2)" unit="%" /></div>
64 + </div>
65 + </div>
66 + <div className="grid md:grid-cols-2 gap-4">
67 + <div className="card p-4"><div className="label mb-2">Hardware</div>
68 + <KV k="Chip" v={hw.chip} /><KV k="Memory" v={`${hw.memory_gb} GB unified`} /><KV k="CPU" v={`${hw.cpu_cores} cores (${hw.performance_cores ?? "?"} performance + ${hw.efficiency_cores ?? "?"} efficiency)`} /><KV k="GPU" v={`${hw.gpu_cores ?? "?"} cores`} /><KV k="Disk" v={`${hw.disk_total_gb} GB`} /><KV k="OS" v={`${hw.os} ${hw.os_version}`} /><KV k="Uptime" v={fmtDuration(t.uptime_seconds)} /><KV k="Load average" v={t.load_avg.join(" / ")} />
69 + </div>
70 + <div className="card p-4"><div className="label mb-2">Runtimes & policy</div>
71 + <KV k="MLX" v={sys?.runtimes.mlx ? `mlx ${sys.runtimes.mlx} · mlx-lm ${sys.runtimes.mlx_lm}` : <Pill tone="bad">missing</Pill>} />
72 + <KV k="mlx-vlm (vision)" v={sys?.runtimes.mlx_vlm || <Pill tone="warn">not installed</Pill>} />
73 + <KV k="llama.cpp" v={sys?.runtimes.llama_cpp || <Pill tone="warn">not installed</Pill>} />
74 + <KV k="Model memory budget" v={`${sys?.policy.max_model_memory_gb} GB (absolute ${sys?.policy.absolute_max_memory_gb} GB)`} />
75 + <KV k="macOS reserve" v={`${sys?.policy.macos_reserve_gb} GB`} />
76 + <KV k="Simultaneous models" v={sys?.policy.max_simultaneous_models} />
77 + <KV k="Disk reserve" v={`${sys?.policy.min_free_disk_gb} GB`} />
78 + <KV k="Server started" v={fmtDate(sys?.started_at)} />
79 + </div>
80 + </div>
81 + </div>
82 + )}
83 +
84 + {tab === "storage" && storage && (
85 + <div className="flex flex-col gap-4 mt-4">
86 + <div className="card p-4">
87 + <div className="flex justify-between text-sm mb-2"><span>{storage.used_gb.toFixed(0)} GB used of {storage.total_gb.toFixed(0)} GB</span><span className="text-ink-3">{storage.free_gb.toFixed(0)} GB free · reserve {storage.min_free_gb} GB</span></div>
88 + <div className="flex h-3 rounded-full overflow-hidden bg-surface-3 gap-px">
89 + {[["models_gb", "var(--series-1)"], ["cache_gb", "var(--series-4)"], ["logs_gb", "var(--series-2)"], ["database_gb", "var(--series-3)"], ["other_gb", "var(--color-border-strong)"]].map(([k, c]) => (
90 + <div key={k} title={k} style={{ width: `${(storage[k as keyof Storage] as number) / storage.total_gb * 100}%`, background: c }} />
91 + ))}
92 + </div>
93 + <div className="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-xs text-ink-2 num">
94 + <span><i className="inline-block w-2 h-2 rounded-sm mr-1" style={{ background: "var(--series-1)" }} />Models {storage.models_gb.toFixed(1)} GB</span>
95 + <span><i className="inline-block w-2 h-2 rounded-sm mr-1" style={{ background: "var(--series-4)" }} />HF cache {storage.cache_gb.toFixed(1)} GB</span>
96 + <span><i className="inline-block w-2 h-2 rounded-sm mr-1" style={{ background: "var(--series-2)" }} />Logs {storage.logs_gb.toFixed(2)} GB</span>
97 + <span><i className="inline-block w-2 h-2 rounded-sm mr-1" style={{ background: "var(--series-3)" }} />Database {storage.database_gb.toFixed(2)} GB</span>
98 + <span><i className="inline-block w-2 h-2 rounded-sm mr-1" style={{ background: "var(--color-border-strong)" }} />Other {storage.other_gb.toFixed(0)} GB</span>
99 + </div>
100 + <div className="text-xs text-ink-3 mt-2 mono">{storage.model_root}</div>
101 + </div>
102 + <div className="card overflow-x-auto">
103 + <table className="tbl"><thead><tr><th>Model</th><th>Runtime</th><th className="text-right">Size</th><th>Last used</th></tr></thead>
104 + <tbody>{storage.models.map((m) => <tr key={m.id} className="row-link" onClick={() => (location.href = `/models/${m.id}`)}><td className="font-medium">{m.name}</td><td className="text-ink-2">{m.runtime}</td><td className="text-right num">{fmtBytes(m.disk_size_bytes)}</td><td className="text-ink-2">{m.last_used_at ? fmtDate(m.last_used_at) : "never"}</td></tr>)}</tbody></table>
105 + </div>
106 + <div className="text-xs text-ink-3">Cleanup is manual by design: delete a model from its page (confirmation required). Nothing is removed automatically.</div>
107 + </div>
108 + )}
109 +
110 + {tab === "processes" && procs && (
111 + <div className="card mt-4 overflow-x-auto">
112 + <table className="tbl"><thead><tr><th>Process</th><th>PID</th><th>Port</th><th>Runtime</th><th className="text-right">Memory</th><th className="text-right">CPU</th><th className="text-right">Threads</th><th>Status</th></tr></thead>
113 + <tbody>
114 + <tr><td className="font-medium">llm-api server</td><td className="num">{procs.server.pid}</td><td></td><td>python</td><td className="text-right num">{fmtGB(procs.server.rss_gb, 2)}</td><td className="text-right num">{procs.server.cpu_percent}%</td><td className="text-right num">{procs.server.threads}</td><td><Pill tone="good">running</Pill></td></tr>
115 + {procs.workers.map((w) => <tr key={w.pid}><td className="font-medium">worker · {w.model_id}</td><td className="num">{w.pid}</td><td className="num">{w.port}</td><td>{w.runtime}</td><td className="text-right num">{fmtGB(w.rss_gb, 2)}</td><td className="text-right num">{w.cpu_percent}%</td><td className="text-right num">{w.threads}</td><td><Pill tone={w.status === "ready" ? "good" : "accent"}>{w.status}</Pill></td></tr>)}
116 + {!procs.workers.length && <tr><td colSpan={8} className="text-center text-ink-3 py-6">No inference worker running.</td></tr>}
117 + </tbody></table>
118 + </div>
119 + )}
120 +
121 + {tab === "logs" && (
122 + <div className="grid lg:grid-cols-2 gap-4 mt-4">
123 + <div className="card"><div className="px-4 py-2.5 border-b border-border font-medium text-sm">Model events</div>
124 + <div className="divide-y divide-border text-xs max-h-[60vh] overflow-auto">{events.map((e, i) => <div key={i} className="px-4 py-2 flex gap-3"><span className="text-ink-3 shrink-0 w-36">{fmtDate(e.created_at)}</span><span className="font-medium w-28 shrink-0">{e.event}</span><span className="truncate text-ink-2">{e.model_id}{e.detail ? ` · ${e.detail}` : ""}</span></div>)}</div></div>
125 + <div className="card"><div className="px-4 py-2.5 border-b border-border font-medium text-sm">Audit log</div>
126 + <div className="divide-y divide-border text-xs max-h-[60vh] overflow-auto">{audit.map((a, i) => <div key={i} className="px-4 py-2 flex gap-3"><span className="text-ink-3 shrink-0 w-36">{fmtDate(a.created_at)}</span><span className="font-medium w-32 shrink-0">{a.action}</span><span className="truncate text-ink-2">{a.actor} {a.target ? `→ ${a.target}` : ""} {a.detail || ""}</span></div>)}</div></div>
127 + </div>
128 + )}
129 + <div className="hidden">{fmtMs(0)}</div>
130 + </div>
131 + );
132 +}
added web/src/components/shell.tsx +157 −0
@@ -0,0 +1,157 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { usePathname, useRouter } from "next/navigation";
5 +import { useEffect, useState } from "react";
6 +import { api } from "@/lib/api";
7 +import { LiveProvider, useLive } from "@/lib/events";
8 +import { fmtGB } from "@/lib/format";
9 +import { Dot } from "./ui";
10 +
11 +const NAV = [
12 + { href: "/", label: "Dashboard", icon: "◫" },
13 + { href: "/models", label: "Models", icon: "▤" },
14 + { href: "/playground", label: "Playground", icon: "▷" },
15 + { href: "/downloads", label: "Downloads", icon: "⇩" },
16 + { href: "/harvester", label: "Harvester", icon: "✦" },
17 + { href: "/keys", label: "API Keys", icon: "⚿" },
18 + { href: "/system", label: "System", icon: "◉" },
19 + { href: "/settings", label: "Settings", icon: "⚙" },
20 + { href: "/docs", label: "API Docs", icon: "❯" },
21 +];
22 +
23 +type AuthState = { checked: boolean; authenticated: boolean; needsSetup: boolean; email?: string };
24 +
25 +export function Shell({ children }: { children: React.ReactNode }) {
26 + const path = usePathname();
27 + const router = useRouter();
28 + const [auth, setAuth] = useState<AuthState>({ checked: false, authenticated: false, needsSetup: false });
29 + const isLogin = path === "/login";
30 +
31 + useEffect(() => {
32 + let alive = true;
33 + api.get<{ needs_setup: boolean; authenticated: boolean; principal: { name: string } | null }>("/api/auth/status")
34 + .then((s) => {
35 + if (!alive) return;
36 + setAuth({ checked: true, authenticated: s.authenticated, needsSetup: s.needs_setup, email: s.principal?.name });
37 + if (!s.authenticated && !isLogin) router.replace(`/login?next=${encodeURIComponent(path)}`);
38 + if (s.authenticated && isLogin) router.replace("/");
39 + })
40 + .catch(() => alive && setAuth({ checked: true, authenticated: false, needsSetup: false }));
41 + return () => { alive = false; };
42 + // eslint-disable-next-line react-hooks/exhaustive-deps
43 + }, [path]);
44 +
45 + if (isLogin) return <LiveProvider enabled={false}>{children}</LiveProvider>;
46 + if (!auth.checked || !auth.authenticated) {
47 + return <div className="min-h-screen grid place-items-center text-ink-3 text-sm">Loading…</div>;
48 + }
49 + return (
50 + <LiveProvider enabled>
51 + <div className="min-h-screen md:grid md:grid-cols-[232px_1fr]">
52 + <Sidebar path={path} email={auth.email} />
53 + <main className="min-w-0 px-4 py-5 md:px-8 md:py-7 pb-24 md:pb-8 max-w-[1400px]">{children}</main>
54 + <MobileNav path={path} />
55 + </div>
56 + </LiveProvider>
57 + );
58 +}
59 +
60 +function Sidebar({ path, email }: { path: string; email?: string }) {
61 + const live = useLive();
62 + const router = useRouter();
63 + const cur = live.manager?.loaded.find((w) => w.status === "ready");
64 + const loading = live.manager && Object.values(live.manager.progress).length > 0;
65 + return (
66 + <aside className="hidden md:flex flex-col border-r border-border bg-surface/60 sticky top-0 h-screen">
67 + <div className="px-5 pt-5 pb-4 flex items-center gap-2.5">
68 + <div className="h-7 w-7 rounded-lg bg-accent grid place-items-center text-white font-bold text-sm">λ</div>
69 + <div>
70 + <div className="font-semibold tracking-tight leading-none">LLM API</div>
71 + <div className="text-[11px] text-ink-3 mt-0.5">private inference</div>
72 + </div>
73 + </div>
74 + <nav className="px-3 flex flex-col gap-0.5">
75 + {NAV.map((n) => {
76 + const active = n.href === "/" ? path === "/" : path.startsWith(n.href);
77 + return (
78 + <Link key={n.href} href={n.href}
79 + className={`flex items-center gap-2.5 px-2.5 py-1.5 rounded-lg text-[13px] transition-colors ${active ? "bg-surface-3 text-ink" : "text-ink-2 hover:bg-surface-2 hover:text-ink"}`}>
80 + <span className="w-4 text-center text-ink-3">{n.icon}</span>{n.label}
81 + </Link>
82 + );
83 + })}
84 + </nav>
85 + <div className="mt-auto px-4 pb-4 flex flex-col gap-3">
86 + <div className="card p-3 text-xs">
87 + <div className="flex items-center justify-between mb-1.5">
88 + <span className="label">Current model</span>
89 + <span className={`flex items-center gap-1 text-[11px] ${live.connected ? "text-good" : "text-ink-3"}`}><Dot className={live.connected ? "bg-good" : "bg-ink-3"} />{live.connected ? "live" : "offline"}</span>
90 + </div>
91 + {cur ? (
92 + <>
93 + <div className="font-medium truncate" title={cur.model_id}>{cur.model_id}</div>
94 + <div className="text-ink-3 mt-0.5 num">{fmtGB(cur.measured_gb || cur.estimate_gb)} · {cur.runtime} · ctx {Math.round(cur.context / 1024)}K</div>
95 + </>
96 + ) : loading ? (
97 + <div className="text-accent pulse">Loading model…</div>
98 + ) : (
99 + <div className="text-ink-3">No model loaded</div>
100 + )}
101 + {live.metrics && (
102 + <div className="mt-2 text-ink-3 num">RAM {live.metrics.mem_used_gb.toFixed(1)} / {live.metrics.mem_total_gb.toFixed(0)} GB · GPU {live.metrics.gpu_percent ?? "—"}%</div>
103 + )}
104 + </div>
105 + <div className="flex items-center justify-between text-xs text-ink-3 px-1">
106 + <span className="truncate" title={email}>{email}</span>
107 + <button className="btn btn-ghost btn-sm" onClick={async () => { await api.post("/api/auth/logout"); router.replace("/login"); }}>Sign out</button>
108 + </div>
109 + </div>
110 + </aside>
111 + );
112 +}
113 +
114 +function MobileNav({ path }: { path: string }) {
115 + const items = NAV.slice(0, 5);
116 + return (
117 + <nav className="md:hidden fixed bottom-0 inset-x-0 z-40 bg-surface/95 backdrop-blur border-t border-border grid grid-cols-6 pb-[env(safe-area-inset-bottom)]">
118 + {items.map((n) => {
119 + const active = n.href === "/" ? path === "/" : path.startsWith(n.href);
120 + return (
121 + <Link key={n.href} href={n.href} className={`flex flex-col items-center gap-0.5 py-2 text-[10px] ${active ? "text-ink" : "text-ink-3"}`}>
122 + <span className="text-base leading-none">{n.icon}</span>{n.label}
123 + </Link>
124 + );
125 + })}
126 + <MoreMenu path={path} />
127 + </nav>
128 + );
129 +}
130 +
131 +function MoreMenu({ path }: { path: string }) {
132 + const [open, setOpen] = useState(false);
133 + const rest = NAV.slice(5);
134 + const active = rest.some((n) => path.startsWith(n.href));
135 + return (
136 + <>
137 + <button onClick={() => setOpen((o) => !o)} className={`flex flex-col items-center gap-0.5 py-2 text-[10px] ${active ? "text-ink" : "text-ink-3"}`}>
138 + <span className="text-base leading-none">⋯</span>More
139 + </button>
140 + {open && (
141 + <div className="fixed inset-0 z-50 bg-black/50" onClick={() => setOpen(false)}>
142 + <div className="absolute bottom-0 inset-x-0 card rounded-b-none p-2 pb-[calc(env(safe-area-inset-bottom)+8px)]" onClick={(e) => e.stopPropagation()}>
143 + {rest.map((n) => (
144 + <Link key={n.href} href={n.href} onClick={() => setOpen(false)} className="flex items-center gap-3 px-3 py-3 text-sm rounded-lg hover:bg-surface-2">
145 + <span className="w-5 text-center text-ink-3">{n.icon}</span>{n.label}
146 + </Link>
147 + ))}
148 + <button className="flex items-center gap-3 px-3 py-3 text-sm rounded-lg hover:bg-surface-2 w-full text-left text-ink-2"
149 + onClick={async () => { await api.post("/api/auth/logout"); location.href = "/login"; }}>
150 + <span className="w-5 text-center text-ink-3">⏻</span>Sign out
151 + </button>
152 + </div>
153 + </div>
154 + )}
155 + </>
156 + );
157 +}
added web/src/components/ui.tsx +233 −0
@@ -0,0 +1,233 @@
1 +"use client";
2 +
3 +import { useEffect, useState } from "react";
4 +import type { Compat } from "@/lib/types";
5 +
6 +export function Pill({ tone = "neutral", children, title }: { tone?: "neutral" | "good" | "warn" | "bad" | "accent" | "violet"; children: React.ReactNode; title?: string }) {
7 + const map: Record<string, string> = {
8 + neutral: "bg-surface-3 text-ink-2 border-border-strong",
9 + good: "bg-[#12301f] text-[#5fd39a] border-[#1f4a31]",
10 + warn: "bg-[#33260a] text-[#f0b23a] border-[#5a4210]",
11 + bad: "bg-[#3a1717] text-[#ff8a8a] border-[#5a2b2b]",
12 + accent: "bg-[#12294a] text-[#7db3ff] border-[#1e3f6e]",
13 + violet: "bg-[#241f4a] text-[#b9b0ff] border-[#3a3380]",
14 + };
15 + return (
16 + <span className={`pill ${map[tone]}`} title={title}>
17 + {children}
18 + </span>
19 + );
20 +}
21 +
22 +export function StatusPill({ status }: { status: string }) {
23 + const s = status || "unloaded";
24 + if (s === "ready") return <Pill tone="good"><Dot className="bg-good" /> Loaded</Pill>;
25 + if (s === "unloaded") return <Pill tone="neutral"><Dot className="bg-ink-3" /> Unloaded</Pill>;
26 + if (s === "error") return <Pill tone="bad"><Dot className="bg-bad" /> Error</Pill>;
27 + if (s === "unloading" || s === "unloading_previous") return <Pill tone="warn"><Dot className="bg-warn pulse" /> Unloading…</Pill>;
28 + return <Pill tone="accent"><Dot className="bg-accent pulse" /> {s === "queued" ? "Queued" : s === "warming" ? "Warming up" : "Loading…"}</Pill>;
29 +}
30 +
31 +export function CompatPill({ status, reason }: { status: Compat | string | null; reason?: string | null }) {
32 + const t = reason || undefined;
33 + switch (status) {
34 + case "compatible": return <Pill tone="good" title={t}>Compatible</Pill>;
35 + case "compatible_with_restrictions": return <Pill tone="accent" title={t}>Restricted</Pill>;
36 + case "experimental": return <Pill tone="violet" title={t}>Experimental</Pill>;
37 + case "not_recommended": return <Pill tone="warn" title={t}>Not recommended</Pill>;
38 + case "incompatible": return <Pill tone="bad" title={t}>Incompatible</Pill>;
39 + default: return <Pill title={t}>Unknown</Pill>;
40 + }
41 +}
42 +
43 +export function SizePill({ cls }: { cls: string | null }) {
44 + const tone = cls === "TOO_LARGE" ? "bad" : cls === "XL" ? "warn" : cls === "LARGE" ? "accent" : "neutral";
45 + return <Pill tone={tone}>{cls || "—"}</Pill>;
46 +}
47 +
48 +export function Dot({ className = "" }: { className?: string }) {
49 + return <span className={`inline-block h-1.5 w-1.5 rounded-full ${className}`} />;
50 +}
51 +
52 +export function StatTile({ label, value, sub, accent, children }: { label: string; value: React.ReactNode; sub?: React.ReactNode; accent?: string; children?: React.ReactNode }) {
53 + return (
54 + <div className="card p-4 flex flex-col gap-1 min-w-0">
55 + <div className="label">{label}</div>
56 + <div className="text-[22px] font-semibold tracking-tight num truncate" style={accent ? { color: accent } : undefined}>{value}</div>
57 + {sub && <div className="text-xs text-ink-3 truncate">{sub}</div>}
58 + {children}
59 + </div>
60 + );
61 +}
62 +
63 +export function Meter({ value, max, tone = "accent", height = 6 }: { value: number; max: number; tone?: "accent" | "good" | "warn" | "bad"; height?: number }) {
64 + const pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0;
65 + const color = { accent: "var(--color-accent)", good: "var(--color-good)", warn: "var(--color-warn)", bad: "var(--color-bad)" }[tone];
66 + return (
67 + <div className="w-full rounded-full bg-surface-3 overflow-hidden" style={{ height }} role="meter" aria-valuenow={value} aria-valuemin={0} aria-valuemax={max}>
68 + <div className="h-full rounded-full transition-[width] duration-500" style={{ width: `${pct}%`, background: color }} />
69 + </div>
70 + );
71 +}
72 +
73 +export function Progress({ value }: { value: number }) {
74 + return <Meter value={Math.round(value * 100)} max={100} />;
75 +}
76 +
77 +/** Tiny SVG sparkline (single series, no axes) with a hover tooltip. */
78 +export function Sparkline({ data, color = "var(--series-1)", height = 44, max, unit = "" }: { data: number[]; color?: string; height?: number; max?: number; unit?: string }) {
79 + const [hover, setHover] = useState<number | null>(null);
80 + const w = 240;
81 + if (!data.length) return <div style={{ height }} className="text-xs text-ink-3 flex items-center">no data yet</div>;
82 + const mx = max ?? Math.max(...data, 1);
83 + const mn = 0;
84 + const pts = data.map((v, i) => {
85 + const x = (i / Math.max(1, data.length - 1)) * w;
86 + const y = height - 3 - ((v - mn) / (mx - mn || 1)) * (height - 6);
87 + return [x, y];
88 + });
89 + const path = pts.map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" ");
90 + const area = `${path} L${w},${height} L0,${height} Z`;
91 + return (
92 + <div className="relative w-full" style={{ height }}>
93 + <svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className="w-full h-full block"
94 + onMouseMove={(e) => {
95 + const r = (e.target as SVGElement).closest("svg")!.getBoundingClientRect();
96 + const i = Math.round(((e.clientX - r.left) / r.width) * (data.length - 1));
97 + setHover(Math.max(0, Math.min(data.length - 1, i)));
98 + }}
99 + onMouseLeave={() => setHover(null)}>
100 + <path d={area} fill={color} opacity={0.12} />
101 + <path d={path} fill="none" stroke={color} strokeWidth={2} vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
102 + {hover != null && (
103 + <>
104 + <line x1={pts[hover][0]} x2={pts[hover][0]} y1={0} y2={height} stroke="var(--color-border-strong)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
105 + <circle cx={pts[hover][0]} cy={pts[hover][1]} r={3.5} fill={color} stroke="var(--color-surface)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
106 + </>
107 + )}
108 + </svg>
109 + {hover != null && (
110 + <div className="absolute -top-1 right-0 text-[11px] num text-ink-2 bg-surface-2 border border-border px-1.5 py-0.5 rounded">
111 + {data[hover].toFixed(unit === "%" ? 0 : 1)}{unit}
112 + </div>
113 + )}
114 + </div>
115 + );
116 +}
117 +
118 +export function Spinner({ size = 14 }: { size?: number }) {
119 + return (
120 + <svg className="spin" width={size} height={size} viewBox="0 0 24 24" fill="none" aria-label="loading">
121 + <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
122 + <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
123 + </svg>
124 + );
125 +}
126 +
127 +export function PageHeader({ title, sub, actions }: { title: string; sub?: React.ReactNode; actions?: React.ReactNode }) {
128 + return (
129 + <div className="flex flex-wrap items-start justify-between gap-3 mb-5">
130 + <div>
131 + <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
132 + {sub && <div className="text-sm text-ink-3 mt-0.5">{sub}</div>}
133 + </div>
134 + {actions && <div className="flex items-center gap-2 flex-wrap">{actions}</div>}
135 + </div>
136 + );
137 +}
138 +
139 +export function Empty({ title, sub, action }: { title: string; sub?: string; action?: React.ReactNode }) {
140 + return (
141 + <div className="card p-10 text-center">
142 + <div className="text-sm font-medium">{title}</div>
143 + {sub && <div className="text-xs text-ink-3 mt-1">{sub}</div>}
144 + {action && <div className="mt-4">{action}</div>}
145 + </div>
146 + );
147 +}
148 +
149 +export function Modal({ open, onClose, title, children, width = 520 }: { open: boolean; onClose: () => void; title: string; children: React.ReactNode; width?: number }) {
150 + useEffect(() => {
151 + if (!open) return;
152 + const h = (e: KeyboardEvent) => e.key === "Escape" && onClose();
153 + window.addEventListener("keydown", h);
154 + return () => window.removeEventListener("keydown", h);
155 + }, [open, onClose]);
156 + if (!open) return null;
157 + return (
158 + <div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 p-0 sm:p-6" onClick={onClose} role="dialog" aria-modal="true">
159 + <div className="card w-full sm:max-w-[var(--w)] max-h-[90vh] overflow-auto rounded-b-none sm:rounded-b-[12px]" style={{ ["--w" as string]: `${width}px` }} onClick={(e) => e.stopPropagation()}>
160 + <div className="flex items-center justify-between px-5 py-3 border-b border-border">
161 + <div className="font-medium">{title}</div>
162 + <button className="btn btn-ghost btn-sm" onClick={onClose} aria-label="Close">✕</button>
163 + </div>
164 + <div className="p-5">{children}</div>
165 + </div>
166 + </div>
167 + );
168 +}
169 +
170 +export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
171 + return (
172 + <label className="inline-flex items-center gap-2 cursor-pointer select-none">
173 + <span role="switch" aria-checked={checked} tabIndex={0} onKeyDown={(e) => (e.key === " " || e.key === "Enter") && onChange(!checked)}
174 + onClick={() => onChange(!checked)}
175 + className={`relative inline-block w-9 h-5 rounded-full transition-colors ${checked ? "bg-accent" : "bg-surface-3 border border-border-strong"}`}>
176 + <span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${checked ? "translate-x-4" : "translate-x-0.5"}`} />
177 + </span>
178 + {label && <span className="text-sm">{label}</span>}
179 + </label>
180 + );
181 +}
182 +
183 +export function Tabs<T extends string>({ tabs, value, onChange }: { tabs: { id: T; label: string; count?: number }[]; value: T; onChange: (v: T) => void }) {
184 + return (
185 + <div className="flex gap-1 border-b border-border overflow-x-auto">
186 + {tabs.map((t) => (
187 + <button key={t.id} onClick={() => onChange(t.id)}
188 + className={`px-3 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${value === t.id ? "border-accent text-ink" : "border-transparent text-ink-3 hover:text-ink-2"}`}>
189 + {t.label}{t.count != null && <span className="ml-1.5 text-xs text-ink-3 num">{t.count}</span>}
190 + </button>
191 + ))}
192 + </div>
193 + );
194 +}
195 +
196 +export function useToast() {
197 + const [toasts, setToasts] = useState<{ id: number; text: string; tone: "good" | "bad" | "neutral" }[]>([]);
198 + const push = (text: string, tone: "good" | "bad" | "neutral" = "neutral") => {
199 + const id = Date.now() + Math.random();
200 + setToasts((t) => [...t, { id, text, tone }]);
201 + setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4500);
202 + };
203 + const view = (
204 + <div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
205 + {toasts.map((t) => (
206 + <div key={t.id} className={`card px-4 py-2.5 text-sm shadow-xl ${t.tone === "bad" ? "border-[#5a2b2b]" : t.tone === "good" ? "border-[#1f4a31]" : ""}`}>{t.text}</div>
207 + ))}
208 + </div>
209 + );
210 + return { push, view };
211 +}
212 +
213 +export function KV({ k, v, mono }: { k: string; v: React.ReactNode; mono?: boolean }) {
214 + return (
215 + <div className="flex justify-between gap-4 py-1.5 border-b border-border last:border-0 text-sm">
216 + <span className="text-ink-3 shrink-0">{k}</span>
217 + <span className={`text-right truncate ${mono ? "mono text-xs" : ""}`}>{v ?? "—"}</span>
218 + </div>
219 + );
220 +}
221 +
222 +export function Code({ children }: { children: string }) {
223 + const [copied, setCopied] = useState(false);
224 + return (
225 + <div className="relative group">
226 + <pre className="bg-bg border border-border rounded-lg p-3 text-xs overflow-auto mono leading-relaxed">{children}</pre>
227 + <button className="btn btn-sm btn-ghost absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100"
228 + onClick={() => { navigator.clipboard.writeText(children); setCopied(true); setTimeout(() => setCopied(false), 1200); }}>
229 + {copied ? "Copied" : "Copy"}
230 + </button>
231 + </div>
232 + );
233 +}
added web/src/lib/api.ts +89 −0
@@ -0,0 +1,89 @@
1 +"use client";
2 +
3 +export class ApiError extends Error {
4 + status: number;
5 + code: string;
6 + detail: unknown;
7 + constructor(status: number, message: string, code = "ERROR", detail?: unknown) {
8 + super(message);
9 + this.status = status;
10 + this.code = code;
11 + this.detail = detail;
12 + }
13 +}
14 +
15 +async function request<T>(method: string, path: string, body?: unknown, init?: RequestInit): Promise<T> {
16 + const headers: Record<string, string> = { Accept: "application/json" };
17 + if (method !== "GET" && method !== "HEAD") headers["X-LLM-CSRF"] = "1";
18 + if (body !== undefined) headers["Content-Type"] = "application/json";
19 + const res = await fetch(path, {
20 + method,
21 + headers,
22 + credentials: "same-origin",
23 + body: body !== undefined ? JSON.stringify(body) : undefined,
24 + ...init,
25 + });
26 + const text = await res.text();
27 + let data: unknown = null;
28 + try {
29 + data = text ? JSON.parse(text) : null;
30 + } catch {
31 + data = text;
32 + }
33 + if (!res.ok) {
34 + const err = (data as { error?: { message?: string; code?: string } })?.error;
35 + if (res.status === 401 && typeof window !== "undefined" && !location.pathname.startsWith("/login")) {
36 + location.href = `/login?next=${encodeURIComponent(location.pathname)}`;
37 + }
38 + throw new ApiError(res.status, err?.message || `HTTP ${res.status}`, err?.code || "ERROR", data);
39 + }
40 + return data as T;
41 +}
42 +
43 +export const api = {
44 + get: <T,>(path: string) => request<T>("GET", path),
45 + post: <T,>(path: string, body?: unknown) => request<T>("POST", path, body),
46 + put: <T,>(path: string, body?: unknown) => request<T>("PUT", path, body),
47 + patch: <T,>(path: string, body?: unknown) => request<T>("PATCH", path, body),
48 + del: <T,>(path: string, body?: unknown) => request<T>("DELETE", path, body),
49 +};
50 +
51 +/** Stream an OpenAI chat completion from the local API using the admin session. */
52 +export async function* streamChat(body: Record<string, unknown>, signal?: AbortSignal): AsyncGenerator<Record<string, unknown>> {
53 + const res = await fetch("/v1/chat/completions", {
54 + method: "POST",
55 + headers: { "Content-Type": "application/json", "X-LLM-CSRF": "1" },
56 + credentials: "same-origin",
57 + body: JSON.stringify({ ...body, stream: true }),
58 + signal,
59 + });
60 + if (!res.ok || !res.body) {
61 + const t = await res.text();
62 + let msg = t;
63 + try {
64 + msg = JSON.parse(t).error?.message || t;
65 + } catch {}
66 + throw new ApiError(res.status, msg);
67 + }
68 + const reader = res.body.getReader();
69 + const dec = new TextDecoder();
70 + let buf = "";
71 + while (true) {
72 + const { value, done } = await reader.read();
73 + if (done) break;
74 + buf += dec.decode(value, { stream: true });
75 + let idx: number;
76 + while ((idx = buf.indexOf("\n\n")) >= 0) {
77 + const chunk = buf.slice(0, idx);
78 + buf = buf.slice(idx + 2);
79 + for (const line of chunk.split("\n")) {
80 + if (!line.startsWith("data: ")) continue;
81 + const payload = line.slice(6).trim();
82 + if (payload === "[DONE]") return;
83 + try {
84 + yield JSON.parse(payload);
85 + } catch {}
86 + }
87 + }
88 + }
89 +}
added web/src/lib/events.tsx +130 −0
@@ -0,0 +1,130 @@
1 +"use client";
2 +
3 +import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
4 +import type { Job, ManagerState, Telemetry } from "./types";
5 +
6 +interface LiveEvent {
7 + seq: number;
8 + ts: number;
9 + type: string;
10 + data: Record<string, unknown>;
11 +}
12 +
13 +interface LiveState {
14 + connected: boolean;
15 + manager: ManagerState | null;
16 + metrics: Telemetry | null;
17 + metricsHistory: Telemetry[];
18 + jobs: Record<string, Job>;
19 + modelEvents: Record<string, Record<string, unknown>>;
20 + alerts: { ts: number; level: string; message: string }[];
21 + requests: Record<string, unknown>[];
22 + version: number; // bumps on model/manager/settings changes so pages can refetch
23 +}
24 +
25 +const Ctx = createContext<LiveState>({
26 + connected: false,
27 + manager: null,
28 + metrics: null,
29 + metricsHistory: [],
30 + jobs: {},
31 + modelEvents: {},
32 + alerts: [],
33 + requests: [],
34 + version: 0,
35 +});
36 +
37 +export function LiveProvider({ children, enabled }: { children: React.ReactNode; enabled: boolean }) {
38 + const [state, setState] = useState<LiveState>({
39 + connected: false, manager: null, metrics: null, metricsHistory: [], jobs: {}, modelEvents: {}, alerts: [], requests: [], version: 0,
40 + });
41 + const esRef = useRef<EventSource | null>(null);
42 +
43 + useEffect(() => {
44 + if (!enabled) return;
45 + let stopped = false;
46 + let retry = 1000;
47 + const connect = () => {
48 + if (stopped) return;
49 + const es = new EventSource("/api/events");
50 + esRef.current = es;
51 + es.onopen = () => {
52 + retry = 1000;
53 + setState((s) => ({ ...s, connected: true }));
54 + };
55 + es.onerror = () => {
56 + es.close();
57 + setState((s) => ({ ...s, connected: false }));
58 + if (!stopped) setTimeout(connect, (retry = Math.min(retry * 1.6, 15000)));
59 + };
60 + const handle = (ev: MessageEvent) => {
61 + let e: LiveEvent;
62 + try {
63 + e = JSON.parse(ev.data);
64 + } catch {
65 + return;
66 + }
67 + setState((s) => {
68 + const n = { ...s };
69 + switch (e.type) {
70 + case "snapshot": {
71 + const d = e.data as { manager: ManagerState; metrics: Telemetry | null; jobs: Job[] };
72 + n.manager = d.manager;
73 + if (d.metrics) n.metrics = d.metrics;
74 + n.jobs = Object.fromEntries((d.jobs || []).map((j) => [j.id, j]));
75 + n.version++;
76 + break;
77 + }
78 + case "manager":
79 + n.manager = e.data as unknown as ManagerState;
80 + n.version++;
81 + break;
82 + case "metrics": {
83 + const t = e.data as unknown as Telemetry;
84 + n.metrics = t;
85 + n.metricsHistory = [...s.metricsHistory.slice(-119), t];
86 + break;
87 + }
88 + case "job": {
89 + const j = e.data as unknown as Job;
90 + n.jobs = { ...s.jobs, [j.id]: j };
91 + if (j.status === "completed" && (j.kind === "download" || j.kind === "scan" || j.kind === "benchmark")) n.version++;
92 + break;
93 + }
94 + case "model":
95 + n.modelEvents = { ...s.modelEvents, [String(e.data.model_id)]: e.data };
96 + n.version++;
97 + break;
98 + case "models":
99 + case "settings":
100 + case "server":
101 + n.version++;
102 + break;
103 + case "alert":
104 + n.alerts = [...s.alerts.slice(-9), { ts: e.ts, level: String(e.data.level), message: String(e.data.message) }];
105 + break;
106 + case "request":
107 + n.requests = [e.data, ...s.requests.slice(0, 49)];
108 + break;
109 + }
110 + return n;
111 + });
112 + };
113 + for (const t of ["snapshot", "manager", "metrics", "job", "model", "models", "settings", "server", "alert", "request"]) {
114 + es.addEventListener(t, handle as EventListener);
115 + }
116 + };
117 + connect();
118 + return () => {
119 + stopped = true;
120 + esRef.current?.close();
121 + };
122 + }, [enabled]);
123 +
124 + const value = useMemo(() => state, [state]);
125 + return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
126 +}
127 +
128 +export function useLive() {
129 + return useContext(Ctx);
130 +}
added web/src/lib/format.ts +73 −0
@@ -0,0 +1,73 @@
1 +export function fmtBytes(n?: number | null, digits = 1): string {
2 + if (n == null || isNaN(n)) return "—";
3 + const units = ["B", "KB", "MB", "GB", "TB"];
4 + let i = 0;
5 + let v = n;
6 + while (v >= 1024 && i < units.length - 1) {
7 + v /= 1024;
8 + i++;
9 + }
10 + return `${v.toFixed(i === 0 ? 0 : digits)} ${units[i]}`;
11 +}
12 +
13 +export function fmtGB(n?: number | null, digits = 1): string {
14 + if (n == null || isNaN(n)) return "—";
15 + return `${n.toFixed(digits)} GB`;
16 +}
17 +
18 +export function fmtParams(n?: number | null): string {
19 + if (!n) return "—";
20 + if (n >= 1e12) return `${(n / 1e12).toFixed(1)}T`;
21 + if (n >= 1e9) return `${(n / 1e9).toFixed(n >= 1e10 ? 0 : 1)}B`;
22 + if (n >= 1e6) return `${(n / 1e6).toFixed(0)}M`;
23 + return String(n);
24 +}
25 +
26 +export function fmtCtx(n?: number | null): string {
27 + if (!n) return "—";
28 + if (n >= 1024) return `${Math.round(n / 1024)}K`;
29 + return String(n);
30 +}
31 +
32 +export function fmtMs(n?: number | null): string {
33 + if (n == null || isNaN(n)) return "—";
34 + if (n >= 60000) return `${(n / 60000).toFixed(1)} min`;
35 + if (n >= 1000) return `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)} s`;
36 + return `${Math.round(n)} ms`;
37 +}
38 +
39 +export function fmtNum(n?: number | null, digits = 0): string {
40 + if (n == null || isNaN(n)) return "—";
41 + return n.toLocaleString("en-US", { maximumFractionDigits: digits, minimumFractionDigits: digits });
42 +}
43 +
44 +export function fmtDate(ts?: number | string | null): string {
45 + if (!ts) return "—";
46 + const d = typeof ts === "number" ? new Date(ts * 1000) : new Date(ts);
47 + if (isNaN(d.getTime())) return "—";
48 + return d.toLocaleString("en-CA", { dateStyle: "medium", timeStyle: "short" });
49 +}
50 +
51 +export function fmtAgo(ts?: number | null): string {
52 + if (!ts) return "never";
53 + const s = Math.max(0, Date.now() / 1000 - ts);
54 + if (s < 60) return `${Math.round(s)}s ago`;
55 + if (s < 3600) return `${Math.round(s / 60)} min ago`;
56 + if (s < 86400) return `${Math.round(s / 3600)} h ago`;
57 + return `${Math.round(s / 86400)} d ago`;
58 +}
59 +
60 +export function fmtDuration(s?: number | null): string {
61 + if (s == null) return "—";
62 + const d = Math.floor(s / 86400);
63 + const h = Math.floor((s % 86400) / 3600);
64 + const m = Math.floor((s % 3600) / 60);
65 + if (d) return `${d}d ${h}h`;
66 + if (h) return `${h}h ${m}m`;
67 + return `${m}m ${Math.floor(s % 60)}s`;
68 +}
69 +
70 +export function fmtSpeed(bps?: number | null): string {
71 + if (!bps) return "—";
72 + return `${fmtBytes(bps)}/s`;
73 +}
added web/src/lib/types.ts +225 −0
@@ -0,0 +1,225 @@
1 +export type Compat = "compatible" | "compatible_with_restrictions" | "experimental" | "not_recommended" | "incompatible";
2 +
3 +export interface Model {
4 + id: string;
5 + name: string;
6 + family: string | null;
7 + provider: string | null;
8 + architecture: string | null;
9 + model_type: string | null;
10 + parameter_count: number | null;
11 + active_parameter_count: number | null;
12 + quantization: string | null;
13 + quant_bits: number | null;
14 + runtime: "mlx" | "llamacpp";
15 + format: string;
16 + path: string;
17 + weights_file: string | null;
18 + disk_size_bytes: number;
19 + weights_bytes: number;
20 + estimated_ram_gb: number | null;
21 + kv_bytes_per_token: number | null;
22 + recommended_context: number | null;
23 + max_context: number | null;
24 + task: string;
25 + vision: boolean;
26 + embedding: boolean;
27 + reranker: boolean;
28 + thinking: boolean;
29 + tools: boolean;
30 + size_class: string | null;
31 + installed: boolean;
32 + enabled: boolean;
33 + favorite: boolean;
34 + pinned: boolean;
35 + verified: boolean;
36 + compatible: boolean;
37 + compatibility_status: Compat;
38 + compatibility_reason: string | null;
39 + tags: string[];
40 + repository: string | null;
41 + notes: string | null;
42 + overrides: Record<string, unknown>;
43 + created_at: number;
44 + updated_at: number;
45 + last_loaded_at: number | null;
46 + last_used_at: number | null;
47 + load_count: number;
48 + request_count: number;
49 + tokens_generated: number;
50 + min_load_ms: number | null;
51 + avg_load_ms: number | null;
52 + max_load_ms: number | null;
53 + last_load_ms: number | null;
54 + avg_tps: number | null;
55 + first_token_latency_ms: number | null;
56 + status: string;
57 + loaded: boolean;
58 + aliases?: string[];
59 + worker?: Worker;
60 + progress?: Progress;
61 +}
62 +
63 +export interface Worker {
64 + model_id: string;
65 + name: string;
66 + runtime: string;
67 + status: string;
68 + port: number;
69 + pid: number;
70 + context: number;
71 + started_at: number;
72 + ready_at: number | null;
73 + last_used: number;
74 + in_flight: number;
75 + estimate_gb: number;
76 + measured_gb: number;
77 + warm: Record<string, unknown>;
78 + error: string | null;
79 + requests: number;
80 + pinned: boolean;
81 + elapsed_seconds: number;
82 + load_ms?: number;
83 +}
84 +
85 +export interface Progress {
86 + status: string;
87 + started: number;
88 + elapsed_seconds: number;
89 + [k: string]: unknown;
90 +}
91 +
92 +export interface ManagerState {
93 + loaded: Worker[];
94 + progress: Record<string, Progress>;
95 + waiting: Record<string, number>;
96 + switching: boolean;
97 + stats: { requests: number; tokens: number; loads: number; unloads: number; evictions: number; errors: number };
98 + resident_gb: number;
99 + runtimes: Record<string, boolean>;
100 +}
101 +
102 +export interface Telemetry {
103 + ts: number;
104 + mem_total_gb: number;
105 + mem_used_gb: number;
106 + mem_available_gb: number;
107 + mem_wired_gb: number | null;
108 + mem_compressed_gb: number | null;
109 + mem_pressure_percent: number | null;
110 + mem_pressure_level: string;
111 + swap_used_gb: number;
112 + swap_total_gb: number;
113 + cpu_percent: number;
114 + cpu_per_core: number[];
115 + load_avg: number[];
116 + gpu_percent: number | null;
117 + gpu_renderer_percent: number | null;
118 + gpu_memory_gb: number | null;
119 + thermal_state: string;
120 + thermal_cpu_speed_limit: number | null;
121 + disk_total_gb: number;
122 + disk_used_gb: number;
123 + disk_free_gb: number;
124 + uptime_seconds: number;
125 + process_rss_gb: number;
126 + worker_rss_gb?: number;
127 + loaded_model?: string | null;
128 + loaded_models?: string[];
129 + app_uptime_seconds?: number;
130 +}
131 +
132 +export interface Hardware {
133 + chip: string;
134 + memory_gb: number;
135 + cpu_cores: number;
136 + performance_cores: number | null;
137 + efficiency_cores: number | null;
138 + gpu_cores: number | null;
139 + os: string;
140 + os_version: string;
141 + hostname: string;
142 + apple_silicon: boolean;
143 + disk_total_gb: number;
144 + python: string;
145 +}
146 +
147 +export interface Job {
148 + id: string;
149 + kind: string;
150 + title: string;
151 + payload: Record<string, unknown>;
152 + status: "queued" | "running" | "completed" | "failed" | "cancelled";
153 + progress: number;
154 + detail: Record<string, unknown>;
155 + result: unknown;
156 + error: string | null;
157 + created_at: number;
158 + started_at: number | null;
159 + finished_at: number | null;
160 +}
161 +
162 +export interface ApiKey {
163 + id: number;
164 + name: string;
165 + prefix: string;
166 + scopes: string[];
167 + created_at: number;
168 + last_used_at: number | null;
169 + request_count: number;
170 + revoked_at: number | null;
171 +}
172 +
173 +export interface Benchmark {
174 + id: number;
175 + model_id: string;
176 + created_at: number;
177 + load_ms: number | null;
178 + prompt_tokens: number | null;
179 + prompt_tps: number | null;
180 + generation_tokens: number | null;
181 + generation_tps: number | null;
182 + ttft_ms: number | null;
183 + peak_memory_gb: number | null;
184 + avg_memory_gb: number | null;
185 + cpu_percent: number | null;
186 + gpu_percent: number | null;
187 + thermal_state: string | null;
188 + context: number | null;
189 + runtime: string | null;
190 + params: Record<string, unknown> | string | null;
191 +}
192 +
193 +export interface Candidate {
194 + repo_id: string;
195 + runtime: string;
196 + family: string;
197 + base_model: string;
198 + name: string;
199 + task: string;
200 + quantization: string | null;
201 + parameter_count: number | null;
202 + download_bytes: number | null;
203 + estimated_ram_gb: number | null;
204 + size_class: string | null;
205 + compatibility_status: Compat;
206 + compatibility_reason: string | null;
207 + downloads: number;
208 + likes: number;
209 + last_modified: string;
210 + duplicate_of: string | null;
211 + installed: number;
212 + selected: number;
213 + dismissed: number;
214 + score: number;
215 +}
216 +
217 +export interface SystemInfo {
218 + hardware: Hardware;
219 + telemetry: Telemetry;
220 + manager: ManagerState;
221 + policy: { max_model_memory_gb: number; absolute_max_memory_gb: number; max_simultaneous_models: number; macos_reserve_gb: number; min_free_disk_gb: number };
222 + version: string;
223 + started_at: number;
224 + runtimes: Record<string, string | null>;
225 +}
added web/tsconfig.json +34 −0
@@ -0,0 +1,34 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2017",
4 + "lib": ["dom", "dom.iterable", "esnext"],
5 + "allowJs": true,
6 + "skipLibCheck": true,
7 + "strict": true,
8 + "noEmit": true,
9 + "esModuleInterop": true,
10 + "module": "esnext",
11 + "moduleResolution": "bundler",
12 + "resolveJsonModule": true,
13 + "isolatedModules": true,
14 + "jsx": "react-jsx",
15 + "incremental": true,
16 + "plugins": [
17 + {
18 + "name": "next"
19 + }
20 + ],
21 + "paths": {
22 + "@/*": ["./src/*"]
23 + }
24 + },
25 + "include": [
26 + "next-env.d.ts",
27 + "**/*.ts",
28 + "**/*.tsx",
29 + ".next/types/**/*.ts",
30 + ".next/dev/types/**/*.ts",
31 + "**/*.mts"
32 + ],
33 + "exclude": ["node_modules"]
34 +}
35