Per-area reference
The two endpoints with real surface get a page each.
Base URL
https://api-llm-specs.axium-lab.com is the
address the free instance will answer on. Until it is deployed, run your own — the examples below use
localhost:8080, which is what bun start gives you.
Everything that queries the catalog lives under /v1. /health sits outside
the version prefix, on purpose: it has to answer while the dataset is still loading, and a
liveness probe should not have to know about API versions.
https://api-llm-specs.axium-lab.com/v1/models # hosted (not live yet)
http://localhost:8080/v1/models # bun start
http://localhost:8080/health # note: no /v1
While the dataset is loading, every /v1 route answers 503 with
not-ready and /health answers 503 with
{"status":"starting","ready":false}. In practice the window is short — the file is read from
disk, not downloaded — but a container orchestrator should wait for /health to return
200 before sending traffic.
Authentication
There is none. The API is free and read-only: it serves a public dataset and computes arithmetic over it. No keys, no accounts, no quotas.
Errors
Every failure is RFC 9457
application/problem+json. The type slug is the stable part — branch on that, not
on the title.
{
"type": "about:blank#model-not-found",
"title": "Model not found.",
"status": 404,
"detail": "There is no \"claude-sonnet-9-9\" entry in the dataset.",
"suggestions": ["claude-sonnet-5", "claude-sonnet-4-5", "claude-sonnet-4-6"]
}
type, title and status are always present. Anything else is
extra context for that specific failure, listed below.
| Status | type slug | When | Extra members |
|---|---|---|---|
| 400 | invalid-query | A query parameter is malformed: non-numeric limit, limit < 1, a bad sort direction, a non-boolean supports_*, a missing id or ids. | field |
| 400 | invalid-request | The /v1/estimate body fails validation, or reasoning_tokens exceeds output_tokens. | errors[] or detail |
| 404 | model-not-found | No entry with that id. On GET it comes with suggestions; on POST /v1/estimate it does not. | detail, suggestions[] (GET only) |
| 404 | not-found | The route itself does not exist. | path |
| 409 | ambiguous-model | The id matches several entries that differ only by case. | candidates[] |
| 422 | model-not-priced | The model exists but carries no pricing key at all — 97 entries are like this. | model |
| 422 | limits-exceeded | Usage exceeds a limit the model declares, and options.limit_policy is "error". | max_input_tokens / max_output_tokens, provided |
| 503 | not-ready | The dataset is still loading. Only on /v1 routes. | — |
| 500 | internal-error | Anything unhandled. | detail |
{"type":"about:blank#internal-error","title":"Internal error.","status":500,"detail":"JSON Parse error: Unexpected EOF"}.
That is a known rough edge, not a designed response.
Response conventions
Pagination
Only /v1/models paginates, with limit and offset.
total is the size of the filtered result, before the page is cut — so it is
the number you divide by limit to know how many pages there are.
{ "total": 2419, "limit": 3, "offset": 0, "data": [ … ] }
limit defaults to 50 and is clamped to 500 rather than rejected:
?limit=100000 answers 200 with 500 rows. A limit below 1 is an error.
The id field
The upstream catalog is a JSON object keyed by model id, so the id is not a field of the entry. This API
injects it as id on the way out, which is why it appears last in a model object.
Naming
Upstream calls the provider field litellm_provider; it is renamed to provider at
the single normalization boundary, so that is the name in the filter, in the responses and in the facets.
Why the rename happens on the way in →
Caching
No Cache-Control, ETag or Last-Modified headers are set on API
responses. The dataset's own ETag and sha256 are exposed as data, in
/v1/meta, so you can tell two answers apart by the snapshot that produced
them.
Health
Liveness and readiness in one. Outside the /v1 prefix, and the only endpoint that answers
before the dataset is loaded.
curl localhost:8080/health
{
"status": "ok",
"ready": true,
"models": 3214,
"dataset": {
"source": "local",
"etag": "W/\"74e5328441bbeb0259c44db6ce9a333d7871356ea505ca8190ca23d112fdf801\"",
"sha256": "a5682d2fb71a4e40cd2030dc71afbce28b1272cfe4f546c28f9aaa29d5472501",
"loaded_at": "2026-08-27T06:23:42.602Z"
},
"startup_error": null
}
| Field | Meaning |
|---|---|
source | "local" — the copy on disk was current (a 304, or upstream was unreachable). "upstream" — a newer version was downloaded at boot. |
etag | The upstream ETag the local copy was last validated against. null when it is unknown or was discarded. |
sha256 | Digest of the normalized JSON being served. Two instances serving the same digest are serving the same prices. |
startup_error | null normally. A string when upstream could not be reached at boot and the local copy carried the service — worth alerting on, but not an outage. |
While loading, it answers 503 with a deliberately minimal body — note it is plain JSON, not problem+json:
{ "status": "starting", "ready": false }
Dataset metadata
Counts and provenance. /health answers "can I serve?"; this answers "what am I serving?".
curl localhost:8080/v1/meta
{
"models": 3214,
"providers": 127,
"modes": 16,
"attributes": 153,
"dataset": {
"source": "local",
"upstream_url": "https://raw.githubusercontent.com/BerriAI/litellm/refs/heads/litellm_internal_staging/model_prices_and_context_window.json",
"etag": "W/\"74e5328441bbeb0259c44db6ce9a333d7871356ea505ca8190ca23d112fdf801\"",
"sha256": "a5682d2fb71a4e40cd2030dc71afbce28b1272cfe4f546c28f9aaa29d5472501",
"loaded_at": "2026-08-27T06:23:42.602Z",
"startup_error": null
}
}
upstream_url is worth reading before you trust the numbers: the default points at an
internal LiteLLM branch. Why, and what the risk is →
Providers
The 127 providers with their model counts, ordered by count descending, ties broken alphabetically.
curl localhost:8080/v1/providers
{
"total": 127,
"data": [
{ "name": "fireworks_ai", "models": 313 },
{ "name": "bedrock", "models": 268 },
{ "name": "openai", "models": 226 },
{ "name": "azure", "models": 221 },
… 123 more
]
}
These names are exactly what ?provider= accepts on the listing endpoint.
Modes
A mode is what the model does: chat, embedding, transcription, image generation. Eight entries declare none, which is why the count is reported separately instead of being folded in as a bucket.
{
"total": 16,
"models_without_mode": 8,
"data": [
{ "name": "chat", "models": 2419 },
{ "name": "image_generation", "models": 269 },
{ "name": "embedding", "models": 133 },
{ "name": "responses", "models": 89 },
… 12 more
]
}
The full set: audio_speech, audio_transcription, chat,
completion, embedding, guardrail, image_edit,
image_generation, moderation, ocr, realtime,
rerank, responses, search, vector_store,
video_generation.
Attributes
Every key that appears on at least one model, with how many models carry it and whether it is a pricing
key. This is the endpoint to hit before writing a fields= projection or a sort=,
because it tells you how many models actually declare what you are about to sort by.
{
"total": 153,
"pricing_keys": 86,
"data": [
{ "name": "provider", "models": 3214, "pricing": false },
{ "name": "mode", "models": 3206, "pricing": false },
{ "name": "max_input_tokens", "models": 2713, "pricing": false },
{ "name": "input_cost_per_token", "models": 2663, "pricing": true },
… 149 more
]
}
pricing: true means the key name contains cost, or is tiered_pricing.
It is the same test the estimator uses to decide whether a model is billable at all — a model with zero
pricing keys answers 422 model-not-priced.
37 of these keys are supports_* capability flags, and every one of them
works as a filter without being enumerated anywhere in the code.
Next
| Page | What it covers |
|---|---|
| Models | Filters, sorting, projection, id lookup and its three failure modes, comparison. |
| Estimate | Every usage field, every option, the breakdown, the warnings, and what it cannot price. |
| Dataset | Where the prices come from, how a boot revalidates them, and the decisions behind the numbers. |