# QuantTrail API: integration guide

QuantTrail Picker, the Local AI Model Finder by NorthPanda Labs.

For developers and AI agents connecting an application to the QuantTrail API, and for every QuantTrail account
holder who makes API keys. It covers access and authentication, levels and limits, every route, every hardware
field and filter, and how to read the answers. It also lists what was tested and how. You do not need the
QuantTrail source code to use it.

Revision 1, 2026-10-11. Live address: `https://quanttrailpicker.com`. Where this guide and the interactive
reference (`/docs`, `/openapi.json`) disagree, the live API wins; please report the difference.

---

## Contents

1. Quick start
2. Three ways to call the API
3. Authentication
4. Levels, limits and the controls on them
5. Response headers, CORS and caching
6. Routes (full reference)
7. Hardware input: ask for as much as you can
8. Filters and preferences (every option)
9. Reading the answer
10. Errors
11. Privacy
12. Building it into an application (checklist and client code)
13. What was tested
14. Known gaps and quirks
15. Troubleshooting

---

## 1. Quick start

1. Make a QuantTrail account on https://quanttrailpicker.com (**Sign in**, then **Create account**) and confirm
   your email address.
2. Open the **API** page (`/api`), give the key a name, choose its model family (Free level: one family) and
   press **Create key**. Copy the key at once. It is shown only this once.
3. Store it on your server as an environment variable, for example `QUANTTRAIL_API_KEY`.
4. Ask for picks:

```bash
curl -s https://quanttrailpicker.com/v1/picks \
  -H "Content-Type: application/json" \
  -H "User-Agent: MyApp/1.0 (+https://example.com)" \
  -H "X-QuantTrail-Key: $QUANTTRAIL_API_KEY" \
  -d '{"use":"coding","hardware":{"platform":"windows","ram_gb":32,"gpu":"nvidia","gpu_name":"NVIDIA GeForce RTX 4070","vram_gb":12,"cpu_cores":12},"context_tokens":16384,"install":true,"store_device":false}'
```

You get two ranked lists, `reasoning` (smartest that fits) and `speed` (fastest that fits). Each item has the
format to download, the memory it needs, the largest context that fits and, with `install: true`, ready-made
`ollama pull` and `hf download` commands.

Always send your own `User-Agent`. Cloudflare refuses some default library agents (see section 14).

---

## 2. Three ways to call the API

| | Anonymous | Account key | Application key |
| --- | --- | --- | --- |
| Who gets it | Anyone, no sign-up | Any signed-in, confirmed QuantTrail account, on the `/api` page | The operator's own applications, made by the operator |
| Header | none | `X-QuantTrail-Key: qt_u..._...` | `X-QuantTrail-Key: qt_<app>_...` |
| Limits | Per client address: a gentle hold on fast repeats, then timeouts (section 4.5) | The account's level: per minute per key, per week across all the account's keys | Set per application: per minute, per day and an optional allowance |
| Model families | All | Only the families the key is opened to (Free: 1, Basic 5: 3, higher: all) | All |
| Best for | Trying the API, a personal script, low traffic | Your own apps and services | NorthPanda Labs apps (LLMManager, 2LMC, Hearthlight) |

Every level can use every filter. Levels differ only in keys, request volume and model families.

---

## 3. Authentication

QuantTrail has two separate credentials. Do not mix them up.

| Credential | What it is | What it opens | Where it is sent |
| --- | --- | --- | --- |
| **API key** | `qt_<id>_<40 hex characters>` | Every public `/v1/...` route, with your level's limits and families | `X-QuantTrail-Key: <key>` (preferred) or `Authorization: Bearer <key>` |
| **Session token** | A Supabase Auth access token (a JWT) from signing in with email and password | Your account routes: `/v1/me`, `/v1/me/keys...`, `/v1/me/presets...`, voting | `Authorization: Bearer <token>` |

### 3.1 API keys

- **Format**: `qt_<key id>_<secret>`. Keys made on the `/api` page have an id like `u3f9a0c12b4d5`, so a key
  looks like `qt_u3f9a0c12b4d5_` followed by 40 hex characters.
- **Shown once.** QuantTrail keeps only a SHA-256 hash and a short hint (`qt_u..._3f9a...b7c1`). A lost key
  cannot be read back. Roll it to get a new one.
- **Sent as** `X-QuantTrail-Key: <key>`, or `Authorization: Bearer <key>` when your HTTP library only does
  bearer tokens. A Supabase session token in `Authorization` is never mistaken for a key.
- **A wrong key fails loudly.** A malformed, unknown, rolled or revoked key gets `401 invalid_key`. The request is
  not served anonymously instead, so a misconfigured app notices at once.
- **Keys are server secrets.** Keep them on a machine you control: your server, a desktop app's local settings
  file, a home server. Never put a key in a web page, a browser bundle, a mobile app package or a public
  repository. A browser or phone client asks your server, and your server calls QuantTrail (section 12.3).
- **Keys do nothing on `/v1/me` routes.** Managing keys needs the session token (3.2). A stolen key cannot make
  more keys, change families or see the account.

### 3.2 Session tokens (account routes)

The website signs you in with Supabase Auth (email and password; the email must be confirmed). The session's
access token is what `/v1/me...` routes check: the Worker asks Supabase who the token belongs to on each request.

- No token, a bad or expired token, or an unconfirmed email: `401 sign_in_required`.
- Supabase unreachable: `503 sign_in_unavailable`. Retry later.
- Tokens expire (Supabase's default is one hour) and the website refreshes them for you.

Most people manage keys on the `/api` page and never handle a session token. If a script must manage keys, it
can sign in the same way the website does. `GET /v1/meta` returns `auth: {url, key}`, the public Supabase address
and publishable key:

```js
import {createClient} from '@supabase/supabase-js';
const {auth} = await (await fetch('https://quanttrailpicker.com/v1/meta')).json();
const supabase = createClient(auth.url, auth.key);
const {data, error} = await supabase.auth.signInWithPassword({email, password});
const token = data.session.access_token; // Authorization: Bearer <token> on /v1/me routes
```

(Not run for this guide; see section 13.)

### 3.3 Managing keys over the API (session token required)

| Call | Body | What it does | Refusals |
| --- | --- | --- | --- |
| `GET /v1/me` | | Your email, level, `is_admin`, this week's use `{starts, used, limit}`, your keys and every level | 401 |
| `GET /v1/me/keys` | | The same summary without the level list | 401 |
| `POST /v1/me/keys` | `{"name":"My app","families":["qwen"],"limits":{"quants":["Q4_K_M"]}}` | Makes a key and returns it once in `key` | 409 `key_limit` (level's key count reached), 400 `families` (missing, unknown or too many) |
| `POST /v1/me/keys/{id}` | `{"name":"..."}`, `{"families":[...]}` and/or `{"limits":{...}}` | Renames the key or changes its families or limits (4.3) | 429 `families_locked` (families changed less than 7 days ago, on levels with a family limit) |
| `POST /v1/me/keys/{id}/roll` | `{}` | New secret; the old key stops working at once. Rolling a revoked key makes it active again if the level allows another active key | 409 `key_limit` |
| `POST /v1/me/keys/{id}/revoke` | `{}` | Turns the key off (kept in the list as `revoked`) | |

`families` is required on levels with a family limit. On Basic 10 and higher it is optional: empty means every
family. Family ids come from `GET /v1/families`. `limits` is optional on every level (4.3); `null` clears it.

Each key in the summary has: `id`, `name`, `hint`, `status` (`active` or `revoked`), `families` (`null` = every
family), `limits` (`null` = none), `requests_this_week`, `last_used_at`, `created_at`, `rolled_at` and `families_changeable_at` (when its
families can next change, or `null`).

### 3.4 Application keys (operator only)

The operator makes these in the management area for NorthPanda Labs' own apps. Each has its own per-minute and
per-day limits and an optional allowance of recommendation calls. Refusals: `429 rate_limited` (minute or day),
`403 quota_exhausted` (allowance used up until the operator rolls the key). Headers: `X-QuantTrail-App`,
`X-RateLimit-Remaining-Minute`, `X-RateLimit-Remaining-Day`, `X-Quota-Remaining`. Everything else in this guide
applies to them too.

### 3.5 The administrator

Only the operator's account can open the management area (`/admin`, `/backend`). It needs a short-lived signed
cookie that only that account can obtain, plus a separate username and password. Account levels and keys give no
path into it.

---

## 4. Levels, limits and the controls on them

### 4.1 Levels

Live values from `GET /v1/plans` on 2026-10-11. The operator can change them at any time; read `/v1/plans`
instead of hard-coding them.

| Level (`id`) | Price | API keys | Requests a week (all keys together) | Requests a minute (per key) | Model families per key |
| --- | --- | --- | --- | --- | --- |
| Free (`free`) | 0 | 1 | 1,000 | 10 | 1 |
| Basic 5 (`basic_5`) | 5 USD a month, 54 a year | 2 | 10,000 | 30 | 3 |
| Basic 10 (`basic_10`) | 10 USD a month, 108 a year | 3 | 50,000 | 60 | all |
| Basic 15 (`basic_15`) | 15 USD a month, 162 a year | 5 | 150,000 | 120 | all |
| Basic Lifetime (`lifetime`) | 250 USD once | 10 | no limit | 120 | all |

Paid levels are not on sale yet (`"payments": "Paid levels are not on sale yet."`); the operator sets a level by
hand. Every new account is Free. When a paid level's end date passes, the account is Free again at once.

### 4.2 How account-key requests are counted

- **Every `/v1/...` request sent with the key counts**, catalog reads included (`/v1/meta`, `/v1/models`, ...).
  `/v1/me...` routes never count. Catalog reads also work without a key, so you can leave the key off them.
- **Per minute, per key.** Counted in UTC calendar minutes (12:04:00 to 12:04:59), not a sliding window. Over the
  limit: `429 rate_limited` with `Retry-After` set to the seconds left in the minute.
- **Per week, all of the account's keys together.** Weeks run Monday 00:00 UTC to Sunday 23:59 UTC. Over the
  limit: `429 weekly_limit`, with no `Retry-After` (the wait can be days). On Free, requests 1 to 1,000 are
  answered and 1,001 is refused.
- **Refused requests still count.** A request is counted before it is checked, so retrying in a loop after a
  refusal uses up your week. Honor `Retry-After` and stop on `weekly_limit`.
- **Level changes apply on the next request.** Limits are read from the owner's level on every request.
- With a key, the anonymous hold and timeout (4.5) do not apply. Your key's limits replace them.

### 4.3 Model families and limits on a key

A key on Free or Basic 5 is opened to specific model families (`qwen`, `gemma`, `llama`, `mistral`, ...; the
list is `GET /v1/families`). On every level, any key can also be limited to what one app needs, on the API page
("Limits for this key") or with `limits`:

| Limit | Keeps |
| --- | --- |
| `families` | Models in these families. Required on Free (1) and Basic 5 (up to 3) and locked for 7 days after a change; optional on higher levels and changeable at any time |
| `quants` | Only these formats; others are not listed, ranked or estimated. A known (untracked) format is used when the request names it in `quants` |
| `tasks` | Models supporting at least one of these tasks |
| `support_levels` | Models in these release-date levels |
| `origins`, `vendors` | Models from these origins or companies |
| `license: "permissive"` | Models with a permissive license |
| `allow_gated: false` | Models without a Hugging Face click-through |

A request can narrow further with its own filters, never widen. When an account moves from Free or Basic 5 to a
level without a family limit, the family its level forced stops applying; families chosen as a limit keep
applying. For a request made with the key the whole catalog is narrowed to its families and limits:

- `/v1/recommend`, `/v1/picks` and `/v1/requirements` only rank models from those families.
- `/v1/models` lists only them. `/v1/models/{id}`, `/fit` and `/votes` answer `404 not_found` for any other model.
- `candidates` naming a model outside them come back in `unknown_candidates`.
- `/v1/families` still lists every family (its `model_count` is the narrowed count).

Choose the family your app needs most; on Free a coding app might pick `qwen`. Families can be changed once every
7 days per key (`families_changeable_at`).

### 4.4 Controls available to an account holder

| Control | Where | Effect |
| --- | --- | --- |
| Create a key | `/api` page or `POST /v1/me/keys` | Up to the level's key count |
| Rename | `/api` or `POST /v1/me/keys/{id}` | Name only, any time |
| Change families | `/api` or `POST /v1/me/keys/{id}` | Once every 7 days per key on Free and Basic 5; any time on higher levels |
| Set limits | `/api` ("Limits for this key") or `limits` | Families, formats, tasks, release levels, origins, companies, licenses; any time, any level |
| Roll | `/api` or `/roll` | New secret, old one dead at once. Use it when a key leaks |
| Revoke | `/api` or `/revoke` | Key off; frees a key slot |
| See use | `/api` or `GET /v1/me` | `week.used` of `week.limit`, `requests_this_week` per key, `last_used_at` |
| Opt out of the device survey | every call | `store_device: false` (section 11) |

The operator can also change level numbers, set an account's level, and see per-key daily requests and refusals.

### 4.5 Anonymous limits (no key)

They apply only to `/v1/recommend`, `/v1/picks` and `/v1/requirements`, per client address. Only a hash of the
address names the counter; the address itself is not stored.

- A request less than 1 second after the previous one is **held about 2 seconds, then answered** (measured live:
  0.3 s normally, 2.3 s when held).
- Each early request is a strike. Every 10th strike starts a timeout: 60 seconds, then 120, then 180 (one more
  step each time). During a timeout these routes answer `429 rate_limited` with `Retry-After`,
  `retry_after_seconds` and `timeout_seconds`.
- After 30 quiet minutes the client is forgotten.
- Catalog routes and `/v1/models/{id}/fit` are not limited this way.

The operator can change all of these numbers or switch the limiter off.

---

## 5. Response headers, CORS and caching

| Header | When | Meaning |
| --- | --- | --- |
| `X-QuantTrail-App` | Any keyed request | The key id |
| `X-QuantTrail-Level` | Account keys | Your level id |
| `X-RateLimit-Remaining-Minute` | Any keyed request | Requests left this minute for this key |
| `X-RateLimit-Remaining-Week` | Account keys on a level with a weekly limit | Requests left this week, all keys |
| `X-RateLimit-Remaining-Day`, `X-Quota-Remaining` | Application keys | Day and allowance left |
| `Retry-After` | 429 responses (not `weekly_limit`) | Seconds to wait |

- **CORS**: open to every origin for `/v1/...`; methods GET, POST, OPTIONS; request headers `Content-Type`,
  `Authorization`, `X-QuantTrail-Key`. A browser script can read `X-QuantTrail-App`,
  `X-RateLimit-Remaining-Minute`, `X-RateLimit-Remaining-Day`, `X-Quota-Remaining` and `Retry-After`, but not
  `X-QuantTrail-Level` or `X-RateLimit-Remaining-Week` (section 14). Server code can read them all.
- **Caching**: `/v1/recommend`, `/v1/picks`, votes and `/v1/me...` are `no-store`. `/v1/hardware/gpus`,
  `/devices`, `/chips`, `/v1/survey` and `/v1/highlights` are cached up to 5 minutes; other catalog GETs up to
  one hour. POST answers and errors are never cached.
- **Your own cache**: an answer only changes when the catalog changes (`/v1/meta` `updated_at`, a few times a day)
  or the hardware changes. Caching each answer for an hour per machine is plenty.

---

## 6. Routes (full reference)

Base URL `https://quanttrailpicker.com`. JSON in, JSON out. POST bodies must be `Content-Type: application/json`
and at most 16,384 bytes. Unknown fields, unknown query parameters and duplicate query parameters are refused with
`400` naming the field.

| Route | Method | Key counts | Purpose |
| --- | --- | --- | --- |
| `/v1/picks` | GET, POST | yes | Top picks for one use: `reasoning` and `speed` lists |
| `/v1/recommend` | GET, POST | yes | One ranked list with every filter |
| `/v1/models/{id}/fit` | POST | yes | Every format of one model on given hardware |
| `/v1/requirements` | POST | yes | Hardware needed for named models or a filter set |
| `/v1/models` | GET | yes | The catalog, filterable |
| `/v1/models/{id}` | GET | yes | One model with links |
| `/v1/models/{id}/votes` | GET, POST | yes | Votes by hardware class; POST needs a session token |
| `/v1/families`, `/v1/vendors` | GET | yes | Lists with model counts |
| `/v1/hardware/gpus`, `/devices`, `/chips`, `/tiers` | GET | yes | Hardware lookups |
| `/v1/formats` | GET | yes | GGUF formats with bits per weight |
| `/v1/meta` | GET | yes | Freshness, option lists, support levels, auth config |
| `/v1/plans` | GET | yes | The levels |
| `/v1/survey`, `/v1/highlights`, `/v1/site` | GET | yes | Website data |
| `/v1/me`, `/v1/me/keys...` | GET, POST | no | Account and keys (session token) |
| `/v1/me/presets...` | GET, POST | no | Saved computers (session token) |
| `/health` | GET | no | `{"ok":true,"service":"QuantTrail","version":"0.2.0"}` |

Coming, not live on 2026-10-11 (they answer 404 today): `/v1/top-models` (strongest models per size class) and
`/v1/me/watches` (follow model families and see new releases).

### 6.1 POST /v1/picks

The route most apps need. Fixed profiles per use; no filters (use `/v1/recommend` for those).

| Field | Required | Values | Meaning |
| --- | --- | --- | --- |
| `use` | yes | `coding`, `chat`, `dnd_dm` (the list can grow; a wrong value's error lists the current ones) | The job |
| `hardware` | yes | section 7 | The machine |
| `context_tokens` | no | 512 to 2,000,000; default per use: coding 16384, chat 8192, dnd_dm 16384 | Context to plan memory for |
| `limit` | no | 1 to 10, default 3 | Items per list |
| `install` | no | default `false` | Add download commands |
| `store_device` | no | default `true` | Send `false` unless you want the phone survey (section 11) |

How the lists are made:

- **`reasoning`**: ranked with priority `quality`, anything from `slow` speed up.
- **`speed`**: ranked with priority `speed`, `moderate` speed or better. If nothing reaches moderate, the fastest
  that fit are used and a hint says so.
- Each list leaves out base models whose `-instruct`, `-chat` or `-it` version is also listed, and re-quantized
  copies (`nvfp4`, `mxfp4`, `fp8`, `awq`, `gptq`) that have no GGUF or Ollama download.
- `dnd_dm` keeps general chat models of 3B or more and leaves out coder, math, vision and similar models. That is
  a rule, not a benchmark.
- Picks never include CPU-only fallbacks for a GPU machine (`include_cpu` is off).
- The same model can be in both lists.

GET works with flat names: `/v1/picks?use=chat&platform=windows&ram_gb=16&gpu=nvidia&vram_gb=8&install=true&store_device=false`.

Response: `use`, `profile {label, tasks, min_params_b}`, `context_tokens`, `catalog`, `hardware_resolved`,
`capacity`, `reasoning[]`, `speed[]`, `hints[]`, `survey`. Items are described in section 9.

### 6.2 POST /v1/recommend

One ranked list with full control.

```json
{
  "hardware": {"platform": "linux", "ram_gb": 64, "gpu": "nvidia", "vram_gb": 24, "cpu_cores": 16},
  "preferences": {"tasks": ["coding"], "priority": "quality", "context_tokens": 32768,
                  "license": "permissive", "quants": ["Q4_K_M", "Q5_K_M", "Q6_K"], "families_exclude": ["llama"]},
  "candidates": [],
  "include_cpu": true,
  "limit": 5,
  "install": false,
  "store_device": false
}
```

Top-level fields: `hardware` (required), `preferences` (section 8), `candidates`, `include_cpu`, `detected`,
`limit` (1 to 25, default 5), `install`, `store_device`.

GET takes every hardware and preference field flat, with arrays comma-separated:
`/v1/recommend?platform=linux&ram_gb=64&gpu=nvidia&vram_gb=24&tasks=coding&quants=Q4_K_M,Q6_K&limit=3`.

Response: `catalog`, `hardware_resolved`, `capacity`, `recommendations[]` (by score), `popular[]` (the same models
by downloads), `cpu_recommendations[]` (the same search on CPU only; `null` without a discrete GPU), `excluded`
(counts by reason), `unknown_candidates[]`, `hints[]`, `survey`.

### 6.3 POST /v1/models/{id}/fit

One model, every format, on given hardware. Body: `{"hardware": {...}, "context_tokens": 8192}` (only these two
fields).

Returns `model`, `context_tokens`, `hardware_resolved`, `recommended` (the format the ranking would choose, or
`null`), `notes` (plain English) and `formats[]`: `quant`, `label`, `quality_level`, `file_gb`, `est_memory_gb`,
`fit`, `speed`, `est_tokens_per_second` and `est_words_per_second` (when memory bandwidth is known),
`max_context_tokens`, `short_by_gb` (0 when it fits) and `install`.

Live example, `qwen3-8b` on 16 GB RAM + 8 GB NVIDIA at 8192 tokens: IQ2_M to Q5_0 fit on the GPU (`fast`), Q5_K_M
to Q8_0 split GPU and RAM (`moderate`), `recommended: Q4_K_M`.

### 6.4 POST /v1/requirements

The reverse question: what hardware does this need? Send exactly one of `models` (catalog ids) or `preferences`,
plus optional `context_tokens` (default 8192) and `limit` (1 to 25, default 5).

```json
{"models": ["qwen3-8b"], "context_tokens": 8192}
```

Returns `models[]` with each format's `file_gb`, `est_memory_gb`, `min_vram_gb`, `min_ram_gb_cpu_only` and
`min_unified_ram_gb` (`null` when no common size is enough), `minimum_hardware` (Q4_K_M of the first model: VRAM,
CPU-only RAM, unified RAM, disk) and `unknown_models[]`.

### 6.5 Catalog routes

| Route | Parameters | Returns |
| --- | --- | --- |
| `GET /v1/models` | `family`, `generation`, `vendor`, `origin`, `task`, `q` (text in id or name), `include_retired=1` | `{catalog, models[]}`; each model has id, name, family, vendor, origin, country, `hf_repo`, `gguf_repo`, `ollama`, `params_b`, downloads, likes, `released_at`, `support`, papers, `base_model`, `context_tokens`, license, `license_class`, `gated`, `tasks`, `smartness`, `min_tier`, `variants[]` (`quant_id`, `file_gb`, `size_source`) |
| `GET /v1/models/{id}` | | One model plus `links` (model card, files, discussions, GGUF, Ollama, papers, base model, demos) |
| `GET /v1/families` | | `id`, label, vendor, `model_count` |
| `GET /v1/vendors` | | `id`, name, origin, `model_count` |
| `GET /v1/formats` | | Every tracked and known format: `id`, `label`, `quality_level`, `bits_per_weight`, `smartness_penalty`, `position`, `status`, `size_pct`, `compression_pct` |
| `GET /v1/meta` | | `updated_at`, `model_count`, `tasks`, `origins`, tracked `quantizations`, `tiers`, `support_levels`, `platforms`, `gpu_kinds`, `vote_thresholds`, `auth` |
| `GET /v1/survey` | | Most recommended models per phone and board class (no counts, names or RAM) |
| `GET /v1/highlights` | | Newest releases mixed with the most downloaded |

### 6.6 Hardware lookups

| Route | Parameters | Returns |
| --- | --- | --- |
| `GET /v1/hardware/gpus` | none: whole list; `q=rtx 40`: up to 20; `renderer=<WebGL renderer string>`: one best match | `id`, `name`, `gpu_vendor`, `kind` (discrete, integrated, apple, unified), `vram_gb`, `match[]` |
| `GET /v1/hardware/devices` | none: all (129 phones, tablets and boards on 2026-10-11); `q=pixel`: up to 10; `model=SM-S928B` or `renderer=...`: one match | `id`, `name`, `kind`, `platform`, `soc`, `ram_options_gb[]`, `bandwidth_gbps`, `cpu_cores`, `speed_class` |
| `GET /v1/hardware/chips` | none: all; `q=`: up to 10; `name=Apple A18 Pro`: one match | `id`, `name`, `memory_type`, `bandwidth_gbps`, `cpu_cores` |
| `GET /v1/hardware/tiers` | | `low`, `mid`, `high`, `max` with `preset` and `minimums` |

Matching uses the longest pattern contained in what you send ("pixel 8 pro" beats "pixel 8"), then exact name or
id with or without the brand word.

### 6.7 Votes

`GET /v1/models/{id}/votes`: `total {up, down}` and one row per hardware class. Never who voted.
`POST` with a session token and `{"value":"up"|"down","tier":"mid"}`: one vote per account per model, changeable
48 hours after it was cast or last changed (`429 vote_locked` before that). Without a token: `401
sign_in_required`. Votes nudge the ranking a little for that hardware class.

### 6.8 Saved computers (session token)

An account can save up to 10 computers. QuantTrail re-checks them as new models arrive and flags when a better
one fits.

| Call | Body / effect |
| --- | --- |
| `GET /v1/me/presets` | `{presets[], limit}` |
| `POST /v1/me/presets` | `{"name":"Desk PC","hardware":{...},"task":"coding","context_tokens":16384,"preferences":{"priority":"quality","min_speed":"moderate","quants":["Q4_K_M"]}}` (only these three preferences are kept). `409 preset_limit` at 10 |
| `GET /v1/me/presets/{id}` | One computer with `best[]`, `largest[]`, `previous_best[]`, `checked_at`, `better_since` |
| `POST /v1/me/presets/{id}` | Change any of the fields above |
| `POST /v1/me/presets/{id}/check` | Re-check now |
| `POST /v1/me/presets/{id}/remove` | Remove it |
| `POST /v1/me/presets/refresh` | Re-check every computer not checked for a day |

---

## 7. Hardware input: ask for as much as you can

The more you send, the better the answer. Every field except the basics is optional, and the API says what it
assumed in `hardware_resolved.estimate.notes`. Good integrations detect what they can, then show the user a form
to confirm or correct it.

### 7.1 Every hardware field

| Field | Type and range | What it changes | How to get it |
| --- | --- | --- | --- |
| `platform` | `windows`, `macos`, `linux`, `android`, `ios` | Memory budgets (phones keep 35 to 40% of RAM), allowed GPU kinds | OS API |
| `ram_gb` | 1 to 2048 | System RAM budget; unified memory budget on Macs | Installed RAM (round to the marketed size: 32, not 31.7) |
| `gpu` | `none`, `integrated`, `nvidia`, `amd`, `intel`, `apple`, `unified` | Whether VRAM is used at all. `apple` needs `macos`. `unified` = shared-memory PCs (AMD Strix Halo, NVIDIA DGX Spark and similar) | Vendor of the strongest graphics adapter |
| `vram_gb` | 0 to 1024 | GPU budget (90% of it) for `nvidia`, `amd`, `intel` only | `nvidia-smi`, `rocm-smi`, driver registry |
| `gpu_name` | text | If it matches `/v1/hardware/gpus`, fills `gpu` and `vram_gb` you did not send. Unknown name without `vram_gb`: treated as no GPU, with a hint | Adapter name or WebGL renderer |
| `cpu_cores` | 1 to 512, integer | Fewer than 6 slows CPU estimates one step; used for tier | Logical processors |
| `disk_free_gb` | 0 to 100000 | Formats larger than this are dropped (`insufficient_disk`) | Free space where models will be stored |
| `memory_type` | `ddr3`, `ddr4`, `ddr5`, `lpddr3`, `lpddr4`, `lpddr4x`, `lpddr5`, `lpddr5x` | With speed and bus, gives memory bandwidth, which gives tokens per second for CPU and phone fits | SMBIOS / `dmidecode` |
| `memory_speed_mts` | 400 to 20000 | As above; a typical speed is assumed when only the type is sent | Configured memory speed |
| `memory_bus_bits` | 16 to 1024, integer | As above; assumed 128 for DDR (two channels), 64 for LPDDR and phones | Channels x 64 (desktop), spec sheet |
| `memory_bandwidth_gbps` | 1 to 2000 | Used directly; beats everything above | Spec sheet or a benchmark |
| `device_model` | text | Phones, tablets, boards: fills platform, RAM (smallest option), cores and bandwidth not sent | `Build.MODEL`, device-tree model, marketing name |
| `chip` | text | Fills cores and bandwidth not sent | SoC name ("Snapdragon 8 Gen 3", "Apple A18 Pro") |
| `tier` | `low`, `mid`, `high`, `max` | Fills everything from a preset; for "I don't know" | User choice |

Required: `platform` and `ram_gb`, unless `tier` or a `device_model` found in `/v1/hardware/devices` is sent.

Resolution order: values you sent, then a matched device, then a matched chip, then memory type and speed. What
you send always wins.

### 7.2 How the API uses it

Memory needed by one model in one format at your context:

```
weights  = file_gb * 1.05
context  = min(context_tokens, model context) / 8192 * (0.25 + 0.05 * params_b)
overhead = 0.5 GB (+0.6 GB for vision models)
est_memory_gb = weights + context + overhead
```

Budgets:

| Machine | GPU budget | System budget |
| --- | --- | --- |
| Discrete GPU (`nvidia`, `amd`, `intel` with `vram_gb` > 0, not a phone) | 90% of VRAM | RAM minus 25% for the OS (at least 3 GB, at most 8 GB) |
| Mac (`apple`) or `unified` PC | 70% of RAM (75% from 36 GB) | 0 |
| No discrete GPU | 0 | RAM minus 25% for the OS (3 to 8 GB) |
| Android / iOS | 0 | 40% / 35% of RAM |

Where it runs (`fit`) and how fast (`speed`):

| `fit` | When | `speed` |
| --- | --- | --- |
| `gpu` | Fits the GPU budget | `fast` |
| `unified` | Mac or unified PC, fits the unified budget | Apple: up to 8B fast, up to 32B moderate, else slow. Other unified: up to 8B moderate, else slow |
| `split` | Discrete GPU, fits GPU + system | `moderate` if the GPU holds 60% or more, else `slow` |
| `cpu` / `mobile` | Fits system memory | With memory bandwidth: tokens/s = bandwidth x 0.5 / file_gb; 20+ fast, 8+ moderate, 3+ slow, else very slow. Without: CPU up to 4B moderate, up to 9B slow, else very slow (one step slower under 6 cores); phones up to 2B moderate, up to 4B slow |
| `none` | Fits nowhere | dropped (`insufficient_memory`) |

`max_context_tokens` is the most context that still fits the same budget, rounded down to 1,024. Use it for
llama.cpp `-c` or Ollama `num_ctx`.

Hardware class (`tier`, used for votes and minimums): Low, Mid, High, Max from `/v1/hardware/tiers` minimums.
Phones are never above Mid, and only flagships (12 GB RAM and 50 GB/s bandwidth or more) reach Mid.

### 7.3 A form that asks for everything

Detect first, then let the user confirm. Suggested fields, in order:

1. **Device type**: Desktop/laptop, Mac, Phone/tablet, Single-board computer, "I don't know" (send `tier`).
2. **Operating system**: `platform`.
3. **RAM**: number, GB. Note "installed RAM, for example 16 or 32".
4. **Graphics**: kind (`none`, `integrated`, `nvidia`, `amd`, `intel`, `apple`, `unified`) and a searchable name
   field backed by `GET /v1/hardware/gpus?q=...`. Fill `vram_gb` from the match and let the user change it.
5. **VRAM**: GB, shown only for nvidia/amd/intel.
6. **CPU threads**: `cpu_cores`.
7. **Free disk where models go**: `disk_free_gb`.
8. **Memory details (optional, "for speed estimates")**: type, speed in MT/s, or bandwidth in GB/s.
9. **Phone/board**: model name with autocomplete from `GET /v1/hardware/devices?q=...`; if it has several RAM
   options, ask which; chip name from `GET /v1/hardware/chips?q=...` when the device is not found.
10. **Context needed**: tokens, with presets 4K, 8K, 16K, 32K, 128K.
11. **What for**: `use` for picks, or `tasks` for recommend, plus the filters in section 8.

Show `hardware_resolved.estimate.notes` back to the user: it says what was matched and assumed.

### 7.4 Detection scripts

Each prints the `hardware` object as JSON. Section 13 says which were run.

**Windows (PowerShell 5.1 or 7):**

```powershell
# Prints the QuantTrail "hardware" object for this Windows PC as JSON.
$ErrorActionPreference = 'SilentlyContinue'
$dimms = @(Get-CimInstance Win32_PhysicalMemory)
$hw = [ordered]@{ platform = 'windows' }
$hw.ram_gb = [math]::Round((($dimms | Measure-Object Capacity -Sum).Sum) / 1GB)
if (-not $hw.ram_gb) { $hw.ram_gb = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB) }
$hw.cpu_cores = [int]((Get-CimInstance Win32_Processor | Measure-Object NumberOfLogicalProcessors -Sum).Sum)
# SMBIOS memory type codes; LPDDR4X reports as LPDDR4.
$types = @{ 24 = 'ddr3'; 26 = 'ddr4'; 29 = 'lpddr3'; 30 = 'lpddr4'; 34 = 'ddr5'; 35 = 'lpddr5' }
if ($dimms.Count) {
  $type = $types[[int]$dimms[0].SMBIOSMemoryType]; if ($type) { $hw.memory_type = $type }
  $speed = [int]$dimms[0].ConfiguredClockSpeed; if (-not $speed) { $speed = [int]$dimms[0].Speed }
  if ($speed -ge 400) { $hw.memory_speed_mts = $speed }
}
$hw.gpu = 'none'
$nv = & nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits 2>$null | Select-Object -First 1
if ($nv) {
  $name, $mib = $nv -split ',\s*'
  $hw.gpu = 'nvidia'; $hw.gpu_name = $name.Trim(); $hw.vram_gb = [math]::Round([double]$mib / 1024, 1)
} else {
  # Win32_VideoController.AdapterRAM stops at 4 GB, so VRAM comes from the driver's registry entry when it has one.
  $class = 'HKLM:\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}'
  $reg = @(Get-ChildItem $class | ForEach-Object { Get-ItemProperty $_.PSPath } | Where-Object DriverDesc)
  foreach ($c in Get-CimInstance Win32_VideoController | Where-Object { $_.Name -notmatch 'DisplayLink|Basic Display|Remote|Virtual' }) {
    $kind = if ($c.Name -match 'NVIDIA|GeForce|Quadro') { 'nvidia' }
      elseif ($c.Name -match 'Radeon (RX|Pro)|FirePro') { 'amd' }
      elseif ($c.Name -match 'Intel.*Arc\b') { 'intel' } else { 'integrated' }
    $q = ($reg | Where-Object { $_.DriverDesc -eq $c.Name } | Select-Object -First 1).'HardwareInformation.qwMemorySize'
    $vram = if ($q) { [math]::Round([int64]$q / 1GB, 1) } else { [math]::Round([int64]$c.AdapterRAM / 1GB, 1) }
    if ($hw.gpu -in 'none', 'integrated' -or ($kind -ne 'integrated' -and $vram -gt $hw.vram_gb)) {
      $hw.gpu = $kind; $hw.gpu_name = $c.Name; $hw.vram_gb = if ($kind -eq 'integrated') { 0 } else { $vram }
    }
  }
}
$drive = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$($env:SystemDrive)'"
if ($drive) { $hw.disk_free_gb = [math]::Floor($drive.FreeSpace / 1GB) }
$hw | ConvertTo-Json -Compress
```

Output on the test PC (Intel Iris Xe laptop):
`{"platform":"windows","ram_gb":32,"cpu_cores":16,"memory_type":"ddr4","memory_speed_mts":2667,"gpu":"integrated","gpu_name":"Intel(R) Iris(R) Xe Graphics","vram_gb":0,"disk_free_gb":148}`.
Sent to `/v1/picks`, it resolved to 42.7 GB/s bandwidth, class Low, and CPU picks with speed estimates
(Qwen3.5-9B Q5_K_M, about 3.1 tokens per second).

**Linux (bash; run with sudo for memory type and speed):**

```bash
#!/usr/bin/env bash
# Prints the QuantTrail "hardware" object for this Linux machine as JSON. Run with sudo for memory type and speed.
ram_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
ram_gb=$(( (ram_kb + 524288) / 1048576 ))
cores=$(nproc)
json="\"platform\":\"linux\",\"ram_gb\":$ram_gb,\"cpu_cores\":$cores"
if command -v dmidecode >/dev/null && [ "$(id -u)" = 0 ]; then
  type=$(dmidecode -t memory | awk -F': ' '/^\s*Type: (DDR|LPDDR)/ {print tolower($2); exit}')
  speed=$(dmidecode -t memory | awk -F': ' '/Configured (Memory|Clock) Speed: [0-9]/ {print $2+0; exit}')
  case "$type" in ddr3|ddr4|ddr5|lpddr3|lpddr4|lpddr4x|lpddr5|lpddr5x) json="$json,\"memory_type\":\"$type\"";; esac
  [ -n "$speed" ] && [ "$speed" -ge 400 ] && json="$json,\"memory_speed_mts\":$speed"
fi
if command -v nvidia-smi >/dev/null && line=$(nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits | head -n1) && [ -n "$line" ]; then
  name=${line%%,*}; mib=${line##*, }
  json="$json,\"gpu\":\"nvidia\",\"gpu_name\":\"$name\",\"vram_gb\":$(awk "BEGIN{printf \"%.1f\", $mib/1024}")"
elif command -v rocm-smi >/dev/null && bytes=$(rocm-smi --showmeminfo vram --csv | awk -F, 'NR==2 {print $2}') && [ -n "$bytes" ]; then
  name=$(rocm-smi --showproductname --csv | awk -F, 'NR==2 {print $3}')
  json="$json,\"gpu\":\"amd\",\"gpu_name\":\"$name\",\"vram_gb\":$(awk "BEGIN{printf \"%.1f\", $bytes/1073741824}")"
elif command -v lspci >/dev/null && lspci | grep -Eqi 'vga|3d|display'; then
  json="$json,\"gpu\":\"integrated\""
else
  json="$json,\"gpu\":\"none\""
fi
if model=$(tr -d '\0' 2>/dev/null </proc/device-tree/model) && [ -n "$model" ]; then
  json="$json,\"device_model\":\"$model\""
fi
free_gb=$(df -Pk "${MODEL_DIR:-$HOME}" | awk 'NR==2 {print int($4/1048576)}')
echo "{$json,\"disk_free_gb\":$free_gb}"
```

`MemTotal` is slightly below installed RAM, so a 32 GB machine can print 31. Let the user correct it. On a
Raspberry Pi the device-tree model ("Raspberry Pi 5 Model B Rev 1.0") matches the device list; still send
`ram_gb`, because a matched board otherwise gets its smallest RAM option.

**macOS (bash, not run):**

```bash
ram_gb=$(( $(sysctl -n hw.memsize) / 1073741824 ))
cores=$(sysctl -n hw.ncpu)
free_gb=$(df -Pk "$HOME" | awk 'NR==2 {print int($4/1048576)}')
if [ "$(uname -m)" = arm64 ]; then
  chip=$(sysctl -n machdep.cpu.brand_string)   # for example "Apple M3 Pro"
  echo "{\"platform\":\"macos\",\"ram_gb\":$ram_gb,\"cpu_cores\":$cores,\"gpu\":\"apple\",\"gpu_name\":\"$chip\",\"disk_free_gb\":$free_gb}"
else
  vram=$(system_profiler SPDisplaysDataType | awk -F': ' '/VRAM/ {v=$2+0; if ($2 ~ /MB/) v=v/1024; printf "%d", v; exit}')
  gpu=$( [ "${vram:-0}" -gt 0 ] && echo amd || echo integrated )
  echo "{\"platform\":\"macos\",\"ram_gb\":$ram_gb,\"cpu_cores\":$cores,\"gpu\":\"$gpu\",\"vram_gb\":${vram:-0},\"disk_free_gb\":$free_gb}"
fi
```

**Browser (what the QuantTrail website does):**

```js
const hw = {platform: /android/i.test(navigator.userAgent) ? 'android' : /iphone|ipad/i.test(navigator.userAgent) ? 'ios'
  : /mac/i.test(navigator.platform) ? 'macos' : /linux/i.test(navigator.platform) ? 'linux' : 'windows'};
if (navigator.hardwareConcurrency) hw.cpu_cores = navigator.hardwareConcurrency;
// navigator.deviceMemory stops at 8 (Chromium only): treat 8 as "8 or more" and ask the user.
if (navigator.deviceMemory) hw.ram_gb = navigator.deviceMemory;
const gl = document.createElement('canvas').getContext('webgl');
const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
const renderer = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : '';
// Then: GET /v1/hardware/gpus?renderer=<renderer> -> match {gpu_vendor, kind, vram_gb}
// Chromium phones: navigator.userAgentData.getHighEntropyValues(['model']) -> device_model
```

Firefox reports a stand-in renderer ("..., or similar"), so only the vendor can be trusted there. A browser can
never prove the hardware is real; always let the user edit it.

**Android (Kotlin, not run):**

```kotlin
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val mem = ActivityManager.MemoryInfo().also { am.getMemoryInfo(it) }
val hardware = mapOf(
    "platform" to "android",
    "device_model" to Build.MODEL,                                      // "SM-S928B" matches Galaxy S24 Ultra
    "ram_gb" to Math.ceil(mem.totalMem / 1073741824.0).toInt(),        // totalMem is below the marketed size
    "cpu_cores" to Runtime.getRuntime().availableProcessors(),
)
```

**iOS (Swift, not run):**

```swift
let ramGb = Int((Double(ProcessInfo.processInfo.physicalMemory) / 1_073_741_824).rounded(.up))
// The hardware identifier ("iPhone17,1") is NOT in the device list. Map it to the marketing name
// ("iPhone 16 Pro") in your app, or send the chip instead: "chip": "Apple A18 Pro".
let hardware: [String: Any] = ["platform": "ios", "ram_gb": ramGb, "chip": "Apple A18 Pro"]
```

---

## 8. Filters and preferences (every option)

Filters live in `preferences` on `/v1/recommend` (and partly on saved computers). `/v1/picks` uses fixed
profiles. Every level may use every filter.

| Preference | Values (default) | Effect | Excluded as |
| --- | --- | --- | --- |
| `tasks` | ids from `/v1/meta` (`chat`, `coding`, `reasoning`, `vision`, `tool_use`, `multilingual`) (`["chat"]`) | A model must support **every** listed task | `task` |
| `context_tokens` | 512 to 2,000,000 (8192) | Memory planned for this context; models with a smaller window are dropped | `context` |
| `priority` | `balanced`, `quality`, `speed`, `small` (`balanced`) | Ranking weights (below) | |
| `min_speed` | `fast`, `moderate`, `slow`, `very_slow` (`slow`) | Drops formats slower than this | `too_slow` |
| `smartness_min` | 1 to 10 (1) | Drops formats whose smartness (model score minus the format's penalty) is lower | `below_smartness_min` |
| `size_max_gb` | above 0 (none) | Drops formats with a larger download | `too_large` |
| `quant_min` | a tracked format id (`Q4_K_M`) | Considers tracked formats at this quality or higher (order from `/v1/formats` `position`) | |
| `quants` | format ids from `/v1/formats` (`[]`) | Exactly these formats; replaces `quant_min`. Known (untracked) formats are sized from the file where seen, else estimated, and may not exist in the GGUF repository | |
| `license` | `any`, `permissive` (`any`) | `permissive` keeps Apache, MIT and similar | `license` |
| `allow_gated` | true/false (true) | `false` drops models that need a license click-through on Hugging Face | `license` |
| `support_levels` | ids from `/v1/meta` (`[]` = all) | Release-date levels: `current` (from 2026-04-01), `limited_1` (2025 to March 2026), `limited_b2` (2024), `limited_b1` (2023), `limited_a2` (2022), `limited_a1` (before 2022) | `support` |
| `origins` | `china`, `usa`, `other` (`[]`) | Vendor origin | `origin` |
| `families_include` / `families_exclude` | ids from `/v1/families` | Model family | `family` |
| `family_kind` | `any`, `brand`, `specialized` (`any`) | `brand`: general-purpose families (Gemma, Qwen, Llama). `specialized`: families built for one job or one size band (code completion, small devices). Each family in `/v1/families` carries its `kind` | `family` |
| `vendors_include` / `vendors_exclude` | ids from `/v1/vendors` | Company | `vendor` |

Top-level options on `/v1/recommend`:

| Field | Default | Effect |
| --- | --- | --- |
| `candidates` | `[]` | Only these models. Matches a catalog id, a Hugging Face repo or an Ollama tag (case-insensitive), so `["qwen3:8b"]` works. Unmatched names come back in `unknown_candidates`. Use it to rank the models a user already has installed |
| `include_cpu` | `true` | Adds `cpu_recommendations` (the same search with the GPU ignored) when a discrete GPU is present |
| `detected` | `[]` | Which of `platform`, `cpu_cores`, `ram_gb`, `gpu` your app detected rather than the user typed. Accepted and validated; it does not change the ranking today |
| `limit` | 5 | 1 to 25 |
| `install` | `false` | Add the `install` block |
| `store_device` | `true` | Phone survey opt-out (section 11) |

Ranking. Each model keeps its best-scoring format, then models are sorted by score (ties: downloads, then id):

| `priority` | Smartness | Speed | Small download | Recent release | Memory headroom |
| --- | --- | --- | --- | --- | --- |
| `balanced` | 50% | 30% | 5% | 10% | 5% |
| `quality` | 70% | 15% | 0% | 10% | 5% |
| `speed` | 30% | 55% | 5% | 5% | 5% |
| `small` | 30% | 20% | 40% | 5% | 5% |

Votes from accounts with the same hardware class add or take up to 5 points. `smartness` is a heuristic from
size, generation and benchmarks, not a measurement.

Formats on 2026-10-11 (tracked ones are used by default; known ones only when named in `quants`):

| Tracked | IQ2_M (2.7 bits), Q3_K_M (4.0), IQ4_XS (4.43), Q4_K_M (4.92), Q5_K_M (5.71), Q6_K (6.56), Q8_0 (8.5) |
| --- | --- |
| Known | IQ2_XXS, Q2_K_XL, IQ3_XXS, Q2_K, IQ3_S, IQ3_M, Q3_K_XL, Q3_K_L, IQ4_NL, Q4_0, Q4_0_4_4, Q4_0_4_8, Q4_0_8_8, Q4_K_S, Q4_K_XL, Q4_K_L, Q5_0, Q5_K_S, Q5_K_L, Q5_K_XL, Q6_K_L, Q6_K_XL, Q8_K_XL, F16 |

With the default `quant_min: Q4_K_M`, the formats below it (IQ2_M, Q3_K_M, IQ4_XS) are not considered. To allow
small formats on small machines send `"quant_min":"IQ2_M"` or list them in `quants`.

Filter recipes:

| Need | Preferences |
| --- | --- |
| Commercial product, no click-through licenses | `"license":"permissive","allow_gated":false` |
| Only new models | `"support_levels":["current"]` |
| Fast answers on a laptop | `"priority":"speed","min_speed":"moderate"` |
| Best quality, slow is fine | `"priority":"quality","min_speed":"very_slow"` |
| Small download (mobile data) | `"priority":"small","size_max_gb":3` |
| Vision models | `"tasks":["vision"]` |
| Exact formats your runtime supports | `"quants":["Q4_K_M","Q8_0"]` |
| Avoid a vendor's models | `"vendors_exclude":["meta"]` or `"origins":["usa","other"]` |
| Rank what is already installed | top-level `"candidates":["qwen3:8b","gemma3:12b"]` |

If a list is empty, `excluded` tells you which filter removed the models. Live example, 64 GB + 24 GB NVIDIA,
coding, permissive, `support_levels:["current"]`, `origins:["usa","other"]`, `size_max_gb:20`, `smartness_min:5`:
nothing left, `excluded: {"task":70,"origin":165,"context":16,"family":10,"support":3,"license":2}`.

---

## 9. Reading the answer

### 9.1 A pick or recommendation

| Field | Meaning |
| --- | --- |
| `rank` | 1 = best in its list |
| `id`, `name`, `family`, `generation`, `vendor`, `vendor_name`, `origin`, `country` | Identity |
| `quant`, `format {id, label, quality_level, size_pct, compression_pct, description}` | The chosen format |
| `params_b` | Billions of parameters |
| `file_gb`, `size_source` | Download size; `measured` from the repository or `estimated` |
| `est_memory_gb`, `memory_breakdown {weights_gb, context_gb, overhead_gb}` | Memory at your `context_tokens` |
| `max_context_tokens` | The most context that fits the same memory |
| `fit` | `gpu`, `unified`, `split`, `cpu`, `mobile` |
| `speed`, `est_tokens_per_second` | Speed class; tokens per second when memory bandwidth is known (CPU and phone fits) |
| `smartness`, `effective_smartness`, `score` | Heuristic quality (1 to 10), after the format penalty, ranking score (0 to 100) |
| `tasks`, `context_tokens`, `license`, `gated` | What the model is for, its window, its license |
| `support {id, label, limited}` | Release-date level |
| `min_tier` | The lowest hardware class that runs it (Q4_K_M, slow or better) |
| `votes`, `vote_nudge` | Votes for your hardware class and their effect on the score |
| `downloads` | Hugging Face downloads, last 30 days |
| `reasons[]`, `warnings[]` | Plain English. Show warnings to the user |
| `links {huggingface, gguf, ollama}` | Where it lives |
| `install` | Only with `install: true` (below) |

### 9.2 install

```json
"install": {
  "name": "Qwen3-8B",
  "ollama": {"tag": "qwen3:8b", "pull": "ollama pull qwen3:8b", "run": "ollama run qwen3:8b", "url": "https://ollama.com/library/qwen3:8b"},
  "huggingface": {"repo": "Qwen/Qwen3-8B", "url": "https://huggingface.co/Qwen/Qwen3-8B"},
  "gguf": {"repo": "Qwen/Qwen3-8B-GGUF", "url": "...", "quant": "Q4_K_M", "file": "Qwen3-8B-Q4_K_M.gguf", "parts": 1,
           "file_url": "https://huggingface.co/.../resolve/main/Qwen3-8B-Q4_K_M.gguf",
           "cli": "hf download Qwen/Qwen3-8B-GGUF --include \"*Q4_K_M*.gguf\" --local-dir \"models/Qwen3-8B\"",
           "curl": "curl -L -o \"Qwen3-8B-Q4_K_M.gguf\" \"https://huggingface.co/...\""}
}
```

- `ollama` is `null` when Ollama has no tag. Ollama picks its own format for a tag (usually Q4_K_M), which may
  differ from `quant`.
- `gguf` is `null` without a GGUF repository. `file`, `file_url` and `curl` are `null` until the crawler has
  listed the repository; `cli` always works (needs `pip install -U huggingface_hub`) and fetches every part of a
  split file (`parts` > 1).
- Gated models need the license accepted on Hugging Face and `HF_TOKEN` set on the downloading machine.

### 9.3 Around the lists

| Field | Meaning |
| --- | --- |
| `hardware_resolved` | The machine as the API understood it: every field, `tier`, `gpu_budget_gb`, `system_budget_gb`, `estimate {device, chip, bandwidth_source, notes[]}`. Show `notes` to the user |
| `capacity` | `fast` (GPU or unified budget) and `total` budgets, with the largest model in billions of parameters that fits at Q4 and Q8 |
| `hints[]` | Why a list is empty or degraded. Show them |
| `excluded` | Counts of models dropped per filter (`/v1/recommend`) |
| `catalog {updated_at, model_count}` | Catalog freshness |
| `survey` | `null` unless a phone or board `device_model` was sent; then `{stored, message}` |

---

## 10. Errors

Every error is JSON: `{"error": "<code>", "field": "...", "message": "..."}` (`field` and `message` when useful).

| Status | `error` | Cause | What to do |
| --- | --- | --- | --- |
| 400 | `invalid_request` | A field is wrong, unknown, missing or duplicated; `field` names it, for example `hardware.foo: unknown field`, `use: must be one of coding, chat, dnd_dm`, `hardware.gpu: apple requires macos` | Fix that field. Do not retry unchanged |
| 401 | `invalid_key` | Malformed, unknown, rolled or revoked key | Get the current key from `/api`; do not retry |
| 401 | `sign_in_required` | `/v1/me...` or voting without a valid session token | Sign in again |
| 403 | `quota_exhausted` | Application key allowance used up | Operator rolls the key |
| 403 | `not_admin` | Admin-only action | |
| 403 | (HTML, "error code: 1010") | Cloudflare blocked your User-Agent | Send your own `User-Agent` |
| 404 | `not_found` | Unknown route, or a model that is hidden or outside your key's families | |
| 405 | `method_not_allowed` | Wrong method (HEAD included) | |
| 409 | `key_limit`, `preset_limit` | Too many keys or saved computers | Revoke or remove one |
| 410 | `gone` | Retired `POST /v1/votes` | Use `/v1/models/{id}/votes` |
| 413 | `payload_too_large` | Body over 16,384 bytes (`limit_bytes` in the answer) | |
| 415 | `unsupported_media_type` | POST without `Content-Type: application/json` | Add the header |
| 429 | `rate_limited` | Per-minute key limit, or anonymous timeout | Wait `Retry-After` seconds, once |
| 429 | `weekly_limit` | The account's weekly requests are used | Stop until Monday 00:00 UTC, or a higher level |
| 429 | `families_locked`, `vote_locked` | Changed too recently | Wait until the time in `message` |
| 500 | `internal_error` | Server fault | Retry later with backoff; report it |
| 503 | `sign_in_unavailable`, `plans_unavailable`, `votes_disabled` | A dependency is down or a feature is off | Retry later |

---

## 11. Privacy

- Requests, hardware and addresses are not stored or logged, with two exceptions the caller controls:
  1. **Phone and board survey.** When `hardware.device_model` names a phone, tablet or board and `store_device` is
     not `false`, the model name as sent, the RAM and the hardware class are saved for the operator to review (at
     most three per client session), and `survey.message` thanks the caller. Desktops are never recorded. **Send
     `store_device: false` on every call** unless your users agreed to take part.
  2. **Saved computers**, only when a signed-in account saves one.
- Votes keep only the hardware class.
- Keys are stored as hashes. Account emails are kept only to find an account.
- The rate limiter names its counter with a hash of the address.

---

## 12. Building it into an application

### 12.1 Checklist

1. Make a key on `/api`. Pick the model family your app needs (Free) or move to a level with more families.
2. Store the key in a server-side secret or a git-ignored local settings file. Never in client code.
3. Send `Content-Type: application/json`, `X-QuantTrail-Key`, and a `User-Agent` naming your app.
4. Detect hardware (7.4), show the form (7.3), send everything you have.
5. Send `store_device: false` unless your users agreed to the survey.
6. Use `/v1/picks` for "what should I run for X"; `/v1/recommend` for filters; `/fit` for "will this model run";
   `/v1/requirements` for "what do I need to buy".
7. Cache answers (an hour, or until the hardware or `/v1/meta` `updated_at` changes).
8. Handle `429 rate_limited` by waiting `Retry-After` once; stop on `weekly_limit`, `invalid_key` and `400`.
9. Show `hints`, `warnings` and `hardware_resolved.estimate.notes` to the user.
10. Use `max_context_tokens` to set the runtime's context size, and `install` for one-click downloads.
11. Watch `X-RateLimit-Remaining-Week` and warn yourself before it reaches 0.
12. If the key leaks, roll it on `/api` and update the server.

### 12.2 Client code

**Python (standard library only; run live, section 13):**

```python
import json, os, time, urllib.error, urllib.request

BASE_URL = os.environ.get("QUANTTRAIL_BASE_URL", "https://quanttrailpicker.com")
# Cloudflare refuses the default "Python-urllib" User-Agent (403, error code 1010); name your app instead.
USER_AGENT = "MyApp/1.0 (+https://example.com)"


class QuantTrailError(Exception):
    def __init__(self, status, body, retry_after=None):
        super().__init__(f"QuantTrail HTTP {status}: {body.get('error')} {body.get('field', '')} {body.get('message', '')}".strip())
        self.status, self.body, self.retry_after = status, body, retry_after


class QuantTrail:
    def __init__(self, key=None, cache_seconds=3600):
        self.key = key if key is not None else os.environ.get("QUANTTRAIL_API_KEY")
        self.cache_seconds, self._cache, self.last_headers = cache_seconds, {}, {}

    def _call(self, method, path, body=None):
        cache_key = (method, path, json.dumps(body, sort_keys=True))
        hit = self._cache.get(cache_key)
        if hit and time.time() - hit[0] < self.cache_seconds:
            return hit[1]
        headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
        if self.key:
            headers["X-QuantTrail-Key"] = self.key
        data = None
        if body is not None:
            headers["Content-Type"] = "application/json"
            data = json.dumps(body).encode()
        for attempt in range(2):
            req = urllib.request.Request(BASE_URL + path, data=data, method=method, headers=headers)
            try:
                with urllib.request.urlopen(req, timeout=20) as r:
                    self.last_headers = {k.lower(): v for k, v in r.headers.items()}
                    result = json.loads(r.read())
                    self._cache[cache_key] = (time.time(), result)
                    return result
            except urllib.error.HTTPError as e:
                raw = e.read()
                try:
                    payload = json.loads(raw or b"{}")
                except ValueError:
                    payload = {"error": "not_json", "message": raw[:200].decode(errors="replace")}
                retry = int(e.headers.get("Retry-After") or 0) or None
                # One polite retry for a short per-minute wait; weekly limits and bad keys are not retried.
                if e.code == 429 and payload.get("error") == "rate_limited" and retry and retry <= 60 and attempt == 0:
                    time.sleep(retry)
                    continue
                raise QuantTrailError(e.code, payload, retry)

    def picks(self, use, hardware, context_tokens=None, limit=3, install=True):
        body = {"use": use, "hardware": hardware, "limit": limit, "install": install, "store_device": False}
        if context_tokens:
            body["context_tokens"] = context_tokens
        return self._call("POST", "/v1/picks", body)

    def recommend(self, hardware, preferences=None, limit=5, install=False, candidates=None):
        body = {"hardware": hardware, "preferences": preferences or {}, "limit": limit, "install": install, "store_device": False}
        if candidates:
            body["candidates"] = candidates
        return self._call("POST", "/v1/recommend", body)

    def fit(self, model_id, hardware, context_tokens=8192):
        return self._call("POST", f"/v1/models/{urllib.request.quote(model_id, safe='')}/fit", {"hardware": hardware, "context_tokens": context_tokens})

    def meta(self):
        return self._call("GET", "/v1/meta")
```

```python
qt = QuantTrail()  # reads QUANTTRAIL_API_KEY
r = qt.picks("coding", {"platform": "linux", "ram_gb": 64, "gpu": "nvidia", "vram_gb": 24}, context_tokens=32768)
for p in r["reasoning"]:
    print(p["name"], p["quant"], p["fit"], p["max_context_tokens"], (p["install"]["ollama"] or {}).get("pull"))
print("left this week:", qt.last_headers.get("x-ratelimit-remaining-week"))
```

**TypeScript / Node 18+ (built-in fetch; run live, section 13):**

```ts
const BASE_URL = process.env.QUANTTRAIL_BASE_URL ?? 'https://quanttrailpicker.com';

export class QuantTrailError extends Error {
  constructor(public status: number, public body: Record<string, any>, public retryAfter: number | null) {
    super(`QuantTrail HTTP ${status}: ${body.error ?? ''} ${body.field ?? ''} ${body.message ?? ''}`.trim());
  }
}

export async function quanttrail(path: string, body?: unknown,
    {key = process.env.QUANTTRAIL_API_KEY, userAgent = 'MyApp/1.0 (+https://example.com)'} = {}) {
  const headers: Record<string, string> = {Accept: 'application/json', 'User-Agent': userAgent,
    ...(key ? {'X-QuantTrail-Key': key} : {}), ...(body ? {'Content-Type': 'application/json'} : {})};
  for (let attempt = 0; ; attempt++) {
    const r = await fetch(BASE_URL + path, {method: body ? 'POST' : 'GET', headers,
      body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(20000)});
    if (r.ok) return {data: await r.json(), headers: r.headers};
    const text = await r.text();
    let payload: Record<string, any>;
    try { payload = JSON.parse(text); } catch { payload = {error: 'not_json', message: text.slice(0, 200)}; }
    const retryAfter = Number(r.headers.get('Retry-After')) || null;
    if (r.status === 429 && payload.error === 'rate_limited' && retryAfter && retryAfter <= 60 && attempt === 0) {
      await new Promise(res => setTimeout(res, retryAfter * 1000));
      continue;
    }
    throw new QuantTrailError(r.status, payload, retryAfter);
  }
}

// const {data} = await quanttrail('/v1/picks', {use: 'chat', hardware: {platform: 'windows', ram_gb: 16, gpu: 'nvidia', vram_gb: 8}, install: true, store_device: false});
```

### 12.3 Browser and mobile apps: call through your server

```
browser / phone app  --(your own auth)-->  your server  --(X-QuantTrail-Key)-->  quanttrailpicker.com
```

Your server receives the hardware from the client, adds the key, calls QuantTrail, caches the answer and returns
it. Keep your own per-user limits so one user cannot spend your weekly allowance. Without a key, a browser may
call QuantTrail directly (CORS is open) within the anonymous limits.

### 12.4 Apps already connected

| App | What it calls |
| --- | --- |
| LLMManager (server dashboard) | `/v1/picks` `use: coding` and `use: chat` with the server's GPU and RAM, `install: true`; sets the backend context from `max_context_tokens` |
| 2LMC (coding gateway) | Station: `use: coding`, the station's GPU, `context_tokens: 32768`, the `speed` list. Hub: the hub's GPU, the `reasoning` list. Prefers `install.gguf` (llama.cpp) |
| Hearthlight (D&D game) | `use: dnd_dm`, `install: true`; on phones `device_model`, with a player opt-out to `store_device: false` |

A new app: add a row here with the use, hardware source, list and install fields it takes.

---

## 13. What was tested

Done on 2026-10-11 against the code in the repository and the live site.

### 13.1 Automated tests (repository)

- `npm test`: **35 test files, 354 tests, all passed** (embedded Postgres; no live database).
- `npx tsc --noEmit`: no type errors.
- Tests that cover this guide include:
  - **Keys**: read from either header; Supabase tokens are not keys; shown once, stored as a hash; use counted;
    allowance enforced until rolled; unknown, malformed and revoked keys refused; per-minute limit.
  - **Levels**: levels public, every account starts Free; Free gets one key opened to one family and every answer
    is narrowed to it; weekly and per-minute limits shared across the account's keys; families locked for 7 days
    after a change; higher levels open every family and more keys; an ended level falls back to Free; only the
    administrator account opens the management area.
  - **Picks**: up to three reasoning and three speed picks with the context that fits; install details only when
    asked; coder and small models kept out of D&D picks; context amount and flat GET; unknown uses refused.
  - **Rate limiting**: spaced requests pass; early ones are held; timeout on the 10th early request plus a step
    every 10 more; clients forgotten after the quiet period; 429 with the timeout note; catalog routes untouched.
  - **Hardware**: device names, model codes and chips resolved; platform, RAM, cores and bandwidth filled from a
    device name; every sent value kept; unknown device falls back to chip, then memory type and speed; platform
    and RAM required when the device is unknown; tokens per second from bandwidth near measured Raspberry Pi 5,
    Snapdragon 8 Elite and A18 Pro figures; CPU step-down below 6 cores.
  - **Filters**: only chosen formats with `quants`, fallback to `quant_min`; known formats estimated or measured;
    disk, license, gated, speed, candidates and hidden status; support levels label, warn and filter.
  - **Contracts**: runtime defaults match the published schema; every public route is published; JSON required,
    malformed JSON and body size refused; CORS preflight; GET and POST agree.
  - **Saved computers, votes, survey**: limits, session checks, 48-hour vote lock, survey opt-out by body and query,
    three reports per session, desktops never recorded.

### 13.2 App contract check against production

`npm run check:apps -- --base-url https://quanttrailpicker.com` makes the same calls as LLMManager, 2LMC and
Hearthlight (with `store_device: false`): **198 checks, 0 failed**.

### 13.3 Manual live checks (anonymous, `store_device: false`)

| Check | Result |
| --- | --- |
| `GET /health` | `{"ok":true,"service":"QuantTrail","version":"0.2.0"}` |
| `GET /v1/plans` | The five levels in 4.1 |
| `GET /v1/meta` | 266 active models; tasks, origins, formats, support levels, `auth {url, key}` |
| Picks, Windows + RTX 4070 12 GB + DDR5-6000 + disk | `tier: high`, bandwidth 96 GB/s worked out from DDR5 (note says the 128-bit bus was assumed), reasoning Devstral-Small-2 24B Q6_K split, speed list on GPU |
| Picks, phone by model code `SM-S928B` | Matched Galaxy S24 Ultra, filled Android, 12 GB, 8 cores, 76.8 GB/s; Qwen3.5-2B Q6_K about 24.7 tokens/s; `survey.stored: false` |
| Recommend, Android + chip "Snapdragon 8 Gen 3" | Chip matched, bandwidth filled |
| Unknown phone + `memory_type: lpddr5` | Note: not in list; bandwidth 51.2 GB/s from LPDDR5 typical speed, 64-bit bus |
| Raspberry Pi 5 by name, no RAM | Filled 1 GB (smallest option), nothing fits, two hints |
| Mac, 36 GB, `apple` | `tier: high`, unified budget 27 GB, Gemma 4 31B Q5_K_M unified |
| Recommend with many filters | Results, `popular`, `cpu_recommendations`, `excluded` counts as described |
| Over-strict filters | Empty list, `excluded` counts, hint |
| `candidates: ["qwen3:8b","not-a-model"]` | Matched by Ollama tag; `unknown_candidates: ["not-a-model"]` |
| `/v1/models/qwen3-8b/fit` | Every format with fit, speed, `max_context_tokens`; `recommended: Q4_K_M` |
| `/v1/requirements` by model and by preferences | Variants with minimums; `unknown_models` |
| `/v1/models?family=qwen&task=coding`, `?bogus=1` | 15 models; `400 unknown filter` |
| Lookups: GPU renderer string, devices `q=pixel`, chip by name, iPhone names | RTX 3060 matched from an ANGLE string; "iPhone 16 Pro" matched; identifier "iPhone17,1" not matched |
| Errors | unknown field, no Content-Type (415), bad `use`, `apple` on Windows, duplicate query field, `limit=26`, malformed key, unknown key (401 `invalid_key`), `/v1/me` without or with a bad token (401 `sign_in_required`), 405, 404: all as in section 10 |
| Anonymous hold | Three calls back to back: 0.31 s, 2.30 s (held), 0.26 s |
| Headers | CORS headers present; OPTIONS 204; picks `no-store`; tiers `public, max-age=3600` |
| User-Agent | `Python-urllib/3.13` gets 403 (Cloudflare 1010); `python-requests`, curl, a custom agent and none get 200 |
| Windows detection script (7.4) | Run on an Intel Iris Xe laptop; output accepted by `/v1/picks` |
| Linux detection script | Syntax checked; run under Git Bash (RAM, cores, no GPU, disk). The `nvidia-smi`, `rocm-smi`, `dmidecode` and device-tree branches were **not** run |
| Python client (12.2) | Picks, cache hit, recommend with filters, fit, meta, 400 and 401 handling: all as expected |
| TypeScript client (12.2), Node | Picks with install, 401 and 400 handling: as expected |

### 13.4 Not tested here

- Account keys and session tokens against production (creating a key needs a signed-in session). Covered by the
  automated tests in 13.1 only.
- Application keys against production.
- The 60-second anonymous timeout (it would lock the tester's address).
- The macOS, browser, Android and iOS snippets in 7.4, and the Supabase sign-in snippet in 3.2.

---

## 14. Known gaps and quirks

1. **Cloudflare blocks `Python-urllib`** (403, HTML "error code: 1010"). Send your own `User-Agent`.
2. **CORS does not expose `X-QuantTrail-Level` or `X-RateLimit-Remaining-Week`.** Browser scripts cannot read
   them; server code can. Keys belong on servers anyway.
3. **Refused requests count toward the week and the minute.** Do not retry in a loop.
4. **`detected` is accepted but has no effect** on the ranking.
5. **Picks take no filters.** For licenses, formats, origins and so on, use `/v1/recommend`.
6. **A matched device with several RAM options gets the smallest** unless `ram_gb` is sent (a Raspberry Pi 5
   becomes 1 GB).
7. **iOS hardware identifiers ("iPhone17,1") are not matched.** Send the marketing name or the chip.
8. **Browser limits**: `navigator.deviceMemory` stops at 8 and Firefox hides the exact GPU.
9. **`Win32_VideoController.AdapterRAM` stops at 4 GB.** The Windows script reads the driver registry instead.
10. **HEAD requests answer 405.** Use GET.
11. **The default `quant_min` is Q4_K_M**, so tiny machines see nothing until smaller formats are allowed.
12. **`/v1/top-models` and `/v1/me/watches` are not live yet** (404).
13. Speeds and smartness are estimates from file size, memory bandwidth and published scores, not benchmarks on
    your machine.

---

## 15. Troubleshooting

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| 403 with HTML "error code: 1010" | Default library User-Agent | Set `User-Agent: YourApp/1.0` |
| 400 naming a field | Request shape | Fix that field; unknown fields are refused on purpose |
| 415 | No JSON header | `Content-Type: application/json` |
| 401 `invalid_key` | Key rolled, revoked or mistyped | Copy the current key from `/api` |
| 404 for a model that exists | Your key is not opened to its family | Change the key's families or level |
| 429 `rate_limited` | Over the per-minute limit, or anonymous timeout | Wait `Retry-After`; space calls; cache |
| 429 `weekly_limit` | Week used up | Wait for Monday 00:00 UTC or move up a level |
| Slow answers (about 2 s) without a key | Calls less than 1 s apart | Space calls, or use a key |
| Empty lists with a hint | Nothing fits at that context, or filters too strict | Read `excluded`; lower `context_tokens`; send accurate RAM/VRAM; allow smaller formats |
| Phone answers look generic | `device_model` not matched | Check `/v1/hardware/devices?model=...`; send `platform`, `ram_gb` and `chip` too |
| GPU shows as none | `gpu_name` unknown and no `vram_gb` | Send `gpu` and `vram_gb` explicitly |
| `ollama: null` | Ollama has no tag | Use `install.gguf.cli` |
| `file_url: null` | Repository not listed yet | Use `install.gguf.cli` |
| 500 `internal_error` | Server fault | Retry later; report it with the time and route |
| Everything fails | Service down | `GET /health` |
