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