Model Discovery - hugalafutro/model-hotel GitHub Wiki
Model discovery is the process by which Model Hotel learns about available models from configured providers. Discovery fetches model lists from provider APIs, enriches them with metadata from built-in catalogs and the models.dev community database, and writes the results into the PostgreSQL database, inserting rows it has not seen before and updating the ones it has (an upsert).

Model catalog with provider, pricing, context length, and enabled toggle columns

Model detail panel with configuration, pricing, capabilities, and test button
- Discovery Triggers
- The Model Discrepancy Modal
- Provider-Specific Discovery
- Models.dev Enrichment
- Model Metadata Fields
- Database Schema
- Model ID Construction
- Enabling/Disabling Models
- Model CRUD API
- Model Caching
- Provider Metadata Comparison
- Additional Provider APIs
- Related Documentation
Discovery runs are triggered by four mechanisms:
When discovery_on_startup is true (the default), the server runs discovery for all enabled providers immediately after boot. A 5-minute deduplication guard prevents redundant runs - if any provider's last_discovered_at is within 5 minutes of the current time, startup discovery is skipped entirely. This avoids double-discovery when the server is restarted quickly (e.g., during a rolling deploy).
if settingsRepo.GetBool(ctx, "discovery_on_startup", true) {
// check if any provider was discovered within 5 minutes
// if not, run discovery in a background goroutine
}When discovery_on_provider_create is true (the default), discovery is triggered immediately after a new provider is created. This trigger is client-side: after the POST /api/providers call succeeds, the frontend checks the setting and calls POST /api/providers/{id}/discover. For keyless providers (e.g., OpenCode Zen free models), this still works - the discovery service handles empty API keys.
// Frontend (web/src/pages/Providers/AddProviderModal.tsx):
const shouldDiscover = settings?.discovery_on_provider_create !== "false";
if (shouldDiscover) {
const result = await api.providers.discover(newProvider.id);
}A background goroutine runs discovery on a configurable interval (discovery_interval, default 6h). The timer reacts immediately to setting changes via a subscription channel - there is no need to wait for the current timer to expire when the interval is changed at runtime. Setting the interval to 0 (or "0s") disables periodic discovery entirely; the goroutine blocks on the settings subscription channel until a non-zero value arrives.
select {
case <-timerC:
result := runDiscovery()
publishDiscoveryEvent("Scheduled", result)
interval = readInterval()
applyInterval(interval)
case <-settingsSub.Events():
newInterval := readInterval()
if newInterval != interval { applyInterval(newInterval) }
case <-ctx.Done():
return
}Two manual discovery endpoints are available:
| Endpoint | Method | Scope | Description |
|---|---|---|---|
/api/providers/{id}/discover |
POST | Single provider | Discover and import models for one provider |
/api/providers/discover-all |
POST | All providers | Discover and import models for every enabled provider; skips disabled providers |
Both endpoints upsert discovered models and re-sync failover groups for what they saw. They do not disable missing models: disabling requires two consecutive confirmed-missing scans, so a single on-demand click can never reach that threshold, and running the in-scan confirmation probes (up to ~70s of backoff) on a request would overrun the route's 60s HTTP timeout - which also made the HA config-sync import look like it failed. Miss-recording and disabling are therefore owned exclusively by the scheduled/startup background sweep, see Missing models: three layers of proof before a disable. When the background sweep does disable a model, its failover group is re-synced in the same scan so the model is pruned instead of lingering as a stale entry.
Both endpoints also return a diff describing what the scan changed: models added or re-enabled (with machine-readable reason codes new_model, reappeared), plus any failover groups updated as a result. The background sweep's diff can also carry disabled entries with reason not_listed; manual scans never disable, so theirs will not.
The diff also reports updated models whose pricing or context-length metadata moved since the previous scan. Each entry carries per-field changes (codes input_price, output_price, input_price_cache, search_price, context_length, each with old and new numbers).
What the diff reports is what actually persisted:
- Price moves are reported from any source (live API, catalog, models.dev), because prices follow their source on unpinned rows, but suppressed on operator-pinned rows, where the upsert keeps the stored values. Whichever source wrote a price is recorded beside it in
price_sources(see the schema table), which is what the dashboard's price hints show. - Context-length moves are reported only when the provider's own live API supplied the value (tracked via transient per-field live provenance), since a non-live context value is fill-only.
- OpenRouter's sub-tolerance price jitter is damped to the stored value before the upsert, so it neither persists nor reports.
The dashboard renders this diff as a post-scan summary modal after manual Discover and Discover All runs. An all-empty diff still confirms "scanned, nothing changed".
Scheduled/startup background discovery does not pop the modal (SSE events cover it). Instead, any changes it records are persisted to a discovery_changes store (migration 047) and surfaced as a badge on the Models sidebar item; clicking the badge opens the Model Discrepancy Modal. Two SSE events come out of a background sweep: discovery.changes_pending when it records any change, and discovery.models_disabled (severity warning) naming the provider and count whenever it disables models. The modal reads GET /api/discovery/status; POST /api/discovery/changes/ack marks the journal seen. See the API Reference for both.
{
"discovered": 14,
"models": [...],
"diff": {
"added": [{"model_id": "gpt-4o-2024-11", "reason": "new_model"}],
"disabled": [{"model_id": "gpt-4o-2024-05", "reason": "not_listed"}],
"updated": [
{"model_id": "gpt-4o", "changes": [{"field": "input_price", "old": 2.5, "new": 2.0}]}
],
"failover_updated_groups": [
{"display_model": "gpt-4o", "removed_model_ids": ["uuid-old"]}
]
}
}In discover-all responses the same diff object appears per provider inside each results[] entry (omitted for providers whose scan failed).
The badge on the Models sidebar item opens this. It answers one question: what does discovery currently believe is wrong, and what can you do about it.

- Every row is computed from live state (
models,model_failover_groups) on each request. Nothing is read back from thediscovery_changesjournal, so a claim cannot drift from reality and a rescan always corrects it. - The only persisted operator intent is three columns:
models.discovery_dismissed_at(migration061),models.manually_enabled_at(070), andmodel_failover_groups.auto_disabled_at(062). -
ClaimWindow(30 days) bounds three things that must agree: how far back flap counts are computed, how long journal rows are retained, and how long a quiet gone model waits before it stops counting. Auto-dismiss at that horizon is a predicate, not a write, so changing the constant re-derives every claim with no backfill. - Providers render as collapsed pills carrying their actionable counts. Click a pill to reveal its bucket lines, click a line to reveal the models. Only one provider and only one bucket line are ever open, so opening a second collapses the first.
- Rows are mounted only while their bucket is open. That is a performance decision: a fleet with eight providers and 179 discrepancies would otherwise hold every row in the DOM at once, and animating a 52-row list open forces a full-subtree relayout on every frame.
- Scroll past the open provider's header and a return-to-top control floats in. It scrolls without collapsing what you are reading.
- Opening the modal does not clear the badge. Nothing is acked until the operator actually expands the Recent changes section, because a badge that clears on a glance is a badge that hides things.
- Dismissing is one-way. There is no undo and the endpoint has no un-dismiss direction: a dismissal reverses itself when discovery next sights the model, which is the only reversal the feature needs.
- Dismissing never stops discovery. It clears rows from this list; discovery keeps sweeping, and
models.Upsertclearsdiscovery_dismissed_aton any sighting, so a dismissed model returns as a fresh claim if its provider lists it again and it later goes missing again. - A refused (traffic-retired) model is the exception: it never leaves the listing, so it is sighted on every scan and its dismissal is deliberately kept. Only enabling it by hand brings it back.
- Suspect models cannot be dismissed at all.
setModelsDismissedonly touchesenabled = falserows, because pre-dismissing a still-enabled model would silently hide the claim the next time it genuinely went missing. - Nothing vanishes when you act on it. A dismissed or resolved row stays struck through where it sat, and a cleared provider keeps its buckets as the log of what you did, until you clear the provider.
- dismissed and back mean opposite things and are tracked separately: dismissed means you acknowledged it and the model is still gone, back means the provider is listing it again and it fixed itself. Both are absent from the next fetch, which is why the distinction has to be tracked client-side rather than inferred from absence.
| Bucket | Meaning | Counted by the badge? |
|---|---|---|
| Gone | The provider stopped listing it and discovery disabled it here. | Yes |
| Refused | The provider still lists it, but it refused every request, so the gateway retired it (models.auto_retired_at). |
Yes |
| Suspect | Still enabled, but absent from recent scans. One more miss and it goes. | No, early warning only |
| Stale | Missing over 30 days with no flapping, so almost certainly retired rather than broken. | No |
| Pinned by you | You enabled it by hand while the provider's listing still omits it. Shown so a forgotten pin stays visible. | No |
| Control | Scope | Effect |
|---|---|---|
| Retest | one provider | Re-runs discovery for it and re-checks its models. Not offered when a provider's only rows are refused ones: a retest asks what the provider lists, and those models are listed. |
| Retest all | header | Walks every listed provider in turn, cancellable. |
| Dismiss | one model | Clears that row from the list. |
| Unpin | one model | Hands a pinned model back to automatic management with a clean miss streak (POST /api/discovery/{provider_id}/unpin). |
| Dismiss all | one provider | Clears every gone, refused and stale model on it, in one request, after a confirm. |
| Dismiss all | header | Same, across every listed provider. This is the "I saw the badge, I do not need the detail" path: confirm once and the badge clears. |
| Clear this provider (broom) | one provider | Appears only once nothing on that provider is actionable. Drops the pill from the view and writes nothing. |
In a fleet, pins are managed by the primary and replaced on the next config sync, so a member's modal points you at the primary instead of offering Unpin.
A model the provider stopped listing stays in the table as a disabled row so you can see what went away, retest it, or pin it. It does not stay forever. The rules:
- After each scheduled discovery pass, rows that discovery retired more than
model_prune_daysago (default 7, maximum 180,0to keep everything) are deleted and their failover groups resynced. - The pass only touches providers it just scanned successfully, skips anything that flapped (was re-listed) in the last 30 days, and deletes at most 500 rows per pass.
- A row's own retirement does not count as a flap, so a horizon shorter than the 30-day flap window is honoured as written.
- Manual discovery from the dashboard never prunes. Only the scheduled and startup passes do.
- Never pruned: models you disabled or enabled by hand, models the proxy retired for refusing requests (the provider still lists those), and models of disabled providers (those wait, with their pins, prices and failover memberships, for the provider to come back).
- Each fleet member prunes on its own schedule with the same horizon, so members converge without any deletion sync. If a pruned model is listed again later, discovery creates a fresh row.
Failover groups that discovery disabled appear in their own section, with live member and routable counts (1 of 3 members routable points at a specific broken member where a bare "group disabled" would not). They carry no Retest (a retest is provider-scoped, a group is not) and no dismiss: the claim clears itself once the group is routable again. Deleted groups are deliberately not represented, since both deletion reasons are downstream of gone-model claims that are already counted.
The informational journal, newest first. It never holds the badge count open, only its dot, and collapsing or expanding it is what marks it seen. Price and context-length moves land here rather than as claims: a price change is news, not a fault.
The badge is passive, and an operator who does not open the dashboard never learns that models stopped working. So when the oldest still-counted claim has been sitting there longer than discovery_claim_alert_days (default 7, minimum 1, maximum 29), discovery publishes a discovery.claims_outstanding warning event. The rules:
- The ceiling is deliberately one day below the 30-day claim window. Set equal, a gone claim would age out of the count at exactly the instant the alert would fire, and the alert would look configured while being silently dead.
- The alert is edge-triggered like the circuit breaker, and the edge is persisted (
_discovery_claim_alert_fired_at) because the evaluation runs once per discovery cycle by a process that may restart between cycles. - The latch stores the count it last alerted at, not a flag. It rises only by firing, and falls silently whenever the live total drops below it, so a fixed backlog followed by a fresh one alerts again. A boolean latch would be held open forever by a refused model or an accepted dead failover group, neither of which ever ages out.
Each provider type has its own discovery implementation in internal/provider/discovery_*.go. Discovery reads the type stored on the provider row, which is the one the operator picked when adding it. A row without a type (created before the column existed) falls back to the legacy URL derivation. Types with no dedicated implementation, including custom, use OpenAI-compatible discovery.
Z.AI, xAI, OpenAI, DeepSeek, and OpenCode (Go & Zen) combine a live /models listing with a built-in catalog: the API supplies the authoritative model list (plus live pricing and modalities for xAI) and the catalog backfills the fields the API leaves out (context window, max output, capability flags, and a price only where models.dev has none or has it wrong). For Z.AI, xAI, and DeepSeek the catalog also surfaces models the listing doesn't advertise but that still work - a freshly released GLM the listing hasn't caught up to, older Grok models xAI keeps callable without listing them, or DeepSeek's deepseek-chat and deepseek-reasoner aliases. OpenAI and OpenCode Zen backfill only: a model Zen drops from its listing is never resurrected, and the OpenAI path doubles as the generic OpenAI-compatible type, so its catalog must not add models to a host that does not serve them. The OpenCode Go catalog is an override channel that ships empty. Live values always win; the catalog only fills gaps. xAI (on 403) and OpenCode Go (on 404) fall back to the catalog when the account or endpoint can't list; the others abort the scan on error so a transient failure never disables existing models. Google AI Studio provides rich metadata (context, thinking support) from its native API, with prices from models.dev. Cohere uses its native API with full pagination for model discovery; capability flags (tool calling, vision, structured output, reasoning) and modalities come from the listing's feature list, and a built-in catalog supplies per-token and per-search prices. NanoGPT and Anthropic expose richer model metadata through their own APIs; Anthropic takes its prices and its reasoning flag from models.dev because the Anthropic listing advertises neither. Kimi Code's listing carries no prices and names its models by subscription route (k3, k3-256k, kimi-for-coding-highspeed) rather than by the API names models.dev's moonshotai entry prices, so discovery maps the routes to their underlying models (kimi-k3, kimi-k2.7-code-highspeed) before the lookup; kimi-for-coding, the K2.8 preview, has no models.dev counterpart yet and meters at zero until it does, which discovery says in the log. Ollama and Ollama Cloud enrich models via the /api/show endpoint; Ollama Cloud's own models.dev entry carries no cost data, so its prices come from the cross-provider index when a match exists.
Every discovered model carries three classification fields with closed vocabularies. input_modalities lists what the model accepts (text, image, audio, video, pdf); output_modalities lists what it produces (text, image, audio, video, plus embedding and rerank for those endpoint families); and modality is an endpoint class derived from the arrays (chat, embedding, rerank, image, video, tts, or stt). The class is never hand-set per provider: one central deriver computes it after enrichment, so a vision chat model ("understands images") can't be confused with an image-generation model ("produces images"), and non-chat models are reliably kept out of the chat and arena pickers while remaining visible on /v1/models and in failover groups.
models.modality is a derived endpoint class with a closed vocabulary: chat, embedding, rerank, image, video, tts, stt. Discovery never hand-writes it. input_modalities and output_modalities are the source of truth, and NormalizeModelClassification (internal/provider/model_class.go) derives the class from them once, after models.dev enrichment and immediately before the upsert.
The one exception is explicit endpoint knowledge. Where a provider's API states the endpoint (Cohere's rerank and embed listings, xAI's image-generation listing, an Ollama completion capability, an LM Studio llm or vlm type) discovery sets the class and it is final. OpenRouter and NanoGPT copy the provider's own architecture.modality string through as it came, so a provider that wrote chat there is taken at its word. video is deliberately never explicit: the legacy vocabulary used it for video-input chat models, so video generation must be signalled through output_modalities instead.
Whichever way the class is reached, an embedding or rerank model carries none of the chat-only capabilities (streaming, structured output, tool calling, parallel tool calls, reasoning): discovery stubs default to streaming and enrichment merges flags by name, and neither endpoint produces a chat completion. Speech and image generation keep theirs, since they can stream.
The rest is derivation:
- A
rerankorembeddingoutput wins outright. - A text (or
code) output meanschat, unless the model id names a transcriber beside an audio input (stt), an embedding/rerank family (embed,rerank,bge,gte,e5,minilm), or an image- or video-generation family whose output array also lists that medium (gpt-image,dall-e/dallewith animageoutput deriveimage; asorasegment with avideooutput derivesvideo). A chat model that also draws (gemini-2.5-flash-image) matches no such name and stayschat. - An
embedding,rerank,imageorvideoclass has the text entries dropped from its output array and the class named first, so the array says what the endpoint serves. - Otherwise the output array decides:
video, thenimage, then audio out astts. - With no modality information at all, the model id is the only signal, and anything unrecognized derives
chatso a new modality never silently disappears from the pickers.
Legacy modality words a catalog still carries (vision, audio, multimodal) and arrow strings (text+image->text) are read as hints, folded into the modality arrays, and the class is then re-derived. Input-flavoured capability flags (vision, audio_input, video_input, pdf_upload) and the input array are kept in sync in both directions, so a Vision pill and a stored input array can never contradict each other.
Providers that expose a live model list and ship a built-in catalog are combined through a shared helper, mergeLiveAndCatalog (internal/provider/catalog_merge.go), instead of picking one source or the other. The merge has three rules:
- Union of models. The result is every model the live API returned plus every catalog model the API did not list. This surfaces models the provider keeps callable without advertising them in its listing endpoint (a freshly released GLM the listing hasn't caught up to, or older Grok models xAI keeps serving), and it means new models the provider adds are picked up automatically with no catalog edit.
-
Live wins per field. For a model present in both sources the live value is authoritative. The catalog only fills fields the live result left empty, nil, or a known placeholder (a
display_nameequal to themodel_id, or"[]"modalities). A stale catalog can therefore never mask fresh live data - at worst it supplies slightly outdated gap-fill. - Capabilities are OR-merged. A capability flag is enabled in the result if either source reports it.
models.dev enrichment runs after the merge and fills anything still empty, so the final precedence per field is live → catalog → models.dev → zero value. There is no general "live failed, use the catalog" rule; what a failed live fetch does is decided per provider. Z.AI and DeepSeek abort the scan (the stored models are kept), because their catalogs are subsets of the live listing and a catalog-only result would let the missing-model sweep disable every live-only model. OpenCode Go falls back to its catalog only on a 404 and aborts on any other failure. xAI falls back to its catalog only on a 403 (no-access key); any other failure of its rich listing retries the minimal /models listing, and a second non-403 failure aborts. A catalog fallback is flagged (ErrCatalogFallback) so the sweep records no misses from it. A successful but empty listing returns nothing rather than the catalog, for the same reason.
Providers on the merge (union): Z.AI, xAI, DeepSeek, OpenCode Go. OpenAI and OpenCode Zen use the same live-first model but backfill-only (no union) via backfillLiveFromCatalog: OpenAI because discoverOpenAI is the fallback for unknown/custom hosts and must not attach catalog-only gpt-5.x models to them, Zen because a model Zen drops from its listing must go with it. Cohere keeps its own discoverer with a pricing-override catalog: the live API is already the rich model-list source and the catalog only supplies prices the API and models.dev lack, so there is nothing to union. Pure-live providers (Anthropic, Google AI Studio, NanoGPT, OpenRouter, Ollama, LM Studio, KoboldCPP, NeuralWatt, Kimi Code, AWS Bedrock, Azure AI Foundry) have no catalog; Anthropic and Google take their prices from models.dev (Google also filters a shipped retired-model list, which is not a catalog). MiniMax is a third, narrower case - call it live-stub + models.dev: discoverMiniMax has no catalog either, but unlike Kimi Code its live listing is metadata-bare (id and owner only), so every other field (context, pricing, capabilities) comes from models.dev enrichment rather than a rich live payload. Vertex AI express is the inverse case: Google exposes no listing route for express keys, so discovery starts from a shipped candidate catalog and validates each entry live (see its section below).
A provider's type is chosen by the operator in the add dialog and stored on the
row (providers.provider_type); nothing re-derives it from the URL afterwards.
For the six self-hosted server families (Ollama, LM Studio, KoboldCPP, LocalAI, SGLang, TabbyAPI) the address says nothing about what is listening on it, so the choice is verified before the provider is saved: Model Hotel probes the identifying endpoint of the chosen family and refuses the save if another server answers, naming what it found. The same check runs when an existing provider's base URL is changed. That means the server has to be running when it is added.
A create request that omits provider_type (an API client rather than the
dashboard) falls back to the vendor hostname, using the table below; a host that
matches nothing is a generic OpenAI-compatible endpoint. Rows created before the
column existed are backfilled once at startup, using those same hostname rules
plus the default-port rules that were in force when they were written
(LegacyTypeFromURL in internal/provider/discovery.go).
Hostname rules (detectByHost, exact and suffix matching):
| Hostname Pattern | Path Pattern | Provider Type |
|---|---|---|
api.openai.com, *.openai.com
|
- |
openai (fallback) |
api.anthropic.com, *.anthropic.com
|
- | anthropic |
api.deepseek.com, *.deepseek.com
|
- | deepseek |
api.nano-gpt.com, nano-gpt.com, *.nano-gpt.com
|
- | nanogpt |
api.z.ai, z.ai, *.z.ai
|
- | zai-coding |
api.kimi.com, kimi.com, *.kimi.com
|
- | kimi-code |
api.minimax.io, minimax.io, *.minimax.io
|
- | minimax |
ollama.com, *.ollama.com
|
- | ollama-cloud |
opencode.ai, *.opencode.ai
|
/zen/go/ |
opencode-go |
opencode.ai, *.opencode.ai
|
/zen/ |
opencode-zen |
openrouter.ai, *.openrouter.ai
|
- | openrouter |
api.x.ai, x.ai, *.x.ai
|
- | xai |
generativelanguage.googleapis.com (and *generativelanguage*.googleapis.com) |
- | google |
aiplatform.googleapis.com (incl. regional {region}-aiplatform...) |
- | vertex-express |
bedrock-mantle.{region}.api.aws |
- | bedrock |
*.services.ai.azure.com, *.openai.azure.com
|
- | azure |
api.cohere.com, api.cohere.ai, *.cohere.com, *.cohere.ai
|
- | cohere |
api.neuralwatt.com, neuralwatt.com, *.neuralwatt.com
|
- | neuralwatt |
| Any other host | - |
openai (fallback) |
Self-hosted servers have no entry here: ollama, lmstudio, koboldcpp,
localai, sglang and tabbyapi are chosen, not matched, and run on whatever address and port the operator gave.
anthropic-messages has none either, and for the same reason: it names a wire
format rather than a vendor, so no host implies it. api.anthropic.com resolves
to anthropic as it always has.
Identifying endpoints used to verify a self-hosted choice:
| Type | Endpoint | Match |
|---|---|---|
tabbyapi |
GET {origin}/.well-known/serviceinfo |
software.name equals TabbyAPI (answers without a key and without a model loaded) |
koboldcpp |
GET {origin}/api/extra/version |
result equals KoboldCpp (the reply also carries the version and the modality flags) |
lmstudio |
GET {origin}/api/v0/models |
a data array with LM Studio's native model fields |
localai |
GET {origin}/v1/models/capabilities |
a data array whose entries carry a capabilities member |
sglang |
GET {origin}/get_model_info |
a model_path string beside an is_generation boolean |
ollama |
GET {origin}/api/tags |
a models array |
The match is on the body, never on the status: LM Studio answers routes it does
not serve with HTTP 200 and an {"error": ...} body, so a status-only check
would identify it as whichever family was probed first. LocalAI's fingerprint
sits ahead of Ollama's in the fixed order because LocalAI also answers
/api/tags in Ollama's shape, and so does SGLang; when ollama is the chosen
type and its fingerprint matches, both of theirs are asked as well, so a LocalAI
or SGLang added as ollama is refused naming the real family rather than
discovered as a lesser Ollama. TabbyAPI's sits ahead of KoboldCPP's for the
same reason: TabbyAPI answers /api/extra/version as KoboldCpp for Kobold
clients, so a TabbyAPI added as koboldcpp gets the serviceinfo check and is
refused naming tabbyapi (a real KoboldCPP serves its own serviceinfo, which
names KoboldCpp and passes).
The chosen type's endpoint is asked first, so a server added as the type it
really is sees only its own endpoint (LM Studio logs every route it does not
serve as an ERROR). The other endpoints follow, in the table's order, only
when that one does not match (apart from the emulator checks above), which is
how a mismatch names the family that did answer. An emulator check that gets
a 5xx or a transport fault refuses the add as unconfirmed, to be retried,
rather than saving the emulator under the family it imitates; a 4xx is the
route not being there, and the chosen family stands.
Each probe is bounded at 5 seconds. The operator is waiting on the add dialog and the server is on the LAN or the same box, so a slow answer is a wrong answer. If no probe reaches the server at all the save is rejected as unreachable, which is a different failure from "something answered but it is not the family you picked".
Source files: discovery_openai.go, openai_catalog.go, catalog_merge.go
Method: Calls GET /v1/models, converts the listing to clean stubs (id + owner), and backfills matching models from the built-in openaiCatalog via backfillLiveFromCatalog (not a union). The catalog is small on purpose: two rows, gpt-5.5-pro and gpt-5.4-pro. discoverOpenAI is also the fallback for unknown and custom hosts, so the catalog must never add catalog-only models (that would attach phantom OpenAI models to a custom OpenAI-compatible provider); for real OpenAI the catalog is a subset of the live listing anyway. Every uncatalogued model (gpt-4o, the o-series, and anything new) is enriched by models.dev.
For a custom provider (any other OpenAI-compatible server, as the dashboard stores it) neither the catalog nor models.dev is applied, and pricing is the operator's: a hosted API added as custom meters at zero until its prices are set by hand, so cost budgets do not bite on it before then. The rule follows the type the operator chose: a provider stored under the generic openai type on another host (an API client that named no type) keeps enrichment, as that type always has. For a custom provider, a model it names gpt-5.1 is not OpenAI's. Only what the listing itself reports is taken. Beyond id and owned_by, that is the optional fields self-hosted servers add, read leniently so a server that sends them in a shape of its own is still discovered: architecture.input_modalities, so a model with an image input gets vision, and the context the server runs the model with, stored as a live value: llama.cpp's meta.n_ctx (present while the model is loaded) or vLLM's max_model_len, with a valid meta.n_ctx taking precedence when both are present. architecture.output_modalities is not read, since llama.cpp reports text output for its embedding and reranking models too; those are classified by name, so they stay out of the chat pickers.
- Models covered by the catalog receive full metadata: display name, description, context length, max output tokens, input/output modalities, streaming/reasoning/tool-calling/structured-output/vision flags, pricing (including cache-hit pricing).
- Models not in the catalog pass through as clean stubs (
Streaming: true, empty modalities) for models.dev to fill.
Catalog fields provided:
| Field | Source |
|---|---|
| Display name | Catalog |
| Description | Catalog |
| Context length | Catalog |
| Max output tokens | Catalog |
| Input/Output modalities | Catalog |
| Streaming | Catalog |
| Reasoning | Catalog |
| Tool calling | Catalog |
| Structured output | Catalog |
| Vision | Catalog |
| Input price / cache-hit price / output price | Catalog |
| Owned by | API (/v1/models) |
Source files: discovery_anthropic.go
Method: Calls GET /v1/models?limit=100 with pagination (using an after_id cursor) to list all models. The Anthropic API returns rich capability metadata per model but no prices; those come from models.dev's canonical anthropic entry at enrichment, which matches a date-suffixed id (e.g. claude-sonnet-4-5-20250514) to its undated family row. There is no Anthropic price catalog.
API-provided fields:
| Field | Source |
|---|---|
| Display name | API (display_name) |
| Max input tokens (→ context length) | API (max_input_tokens) |
| Max output tokens | API (max_tokens) |
| Vision | API (capabilities.image_input.supported) |
| PDF upload | API (capabilities.pdf_input.supported) |
| Structured output | API (capabilities.structured_outputs.supported) |
| Input modalities | Derived from API capabilities: PDF → ["text","image","pdf"], vision → ["text","image"], else ["text"]
|
| Streaming | Hardcoded true
|
| Tool calling | Hardcoded true
|
| Output modalities | Hardcoded ["text"]
|
Prices (input, cache-hit, output) come from models.dev enrichment.
Source files: discovery_anthropic.go (shared with anthropic)
Method: Identical to Anthropic above, and deliberately so: the models listing is part of the Messages API surface, so any endpoint serving that API answers GET /v1/models in the same shape. The type exists for an endpoint that speaks Anthropic's Messages API but is not Anthropic's own: an operator types the base URL, nothing is inferred from the host, and no host rule ever resolves to this type.
Model metadata, owned_by included, is produced exactly as for anthropic: the listing carries no ownership field, and reporting a different owner per provider type would make the same model look like two different things. (Leaving it empty for this type was tried and reverted, because models.dev enrichment then fills owned_by from the model family: claude-fable-5 came back owned by anthropic under one provider and claude-fable under the other.) Prices come from models.dev enrichment, as for anthropic.
The one real difference from anthropic: every chat request is translated, not just the ones carrying a document. An anthropic provider defaults to Anthropic's OpenAI-compat /v1/chat/completions and only re-routes through internal/anthropicegress for content that endpoint cannot express; an anthropic-messages provider has no compat endpoint at all, so all of its chat traffic goes to /v1/messages through the same adapter. A client that speaks Anthropic natively (/v1/messages in, see anthropic_native.go) is forwarded verbatim in both directions, so cache_control and thinking blocks survive.
Because everything is translated, reasoning_effort is honoured on this type (and only this type: anthropic strips it, since its default route has no thinking control and honouring it on document requests alone would make one request reason and the next not). See Extended thinking below.
Discovery fails loudly if the endpoint does not serve the listing, rather than adding a provider whose models were never confirmed to exist.
This is proxy behaviour rather than discovery, kept here because it is specific to this provider type.
An OpenAI client asks for reasoning with reasoning_effort, and Anthropic takes that request in one of two mutually exclusive shapes:
| Shape | Request | Models |
|---|---|---|
| adaptive |
thinking: {type: "adaptive"} + output_config: {effort}
|
the newer ones; the model chooses how much to think under the ceiling |
| budget | thinking: {type: "enabled", budget_tokens} |
the older ones; a fixed token allowance |
Nothing in a model id says which it takes, and the split is not generational. Measured live on 2026-08-20: claude-opus-5 and claude-sonnet-5 accept adaptive only, claude-opus-4-5 and claude-haiku-4-5 accept budget only, and claude-sonnet-4-6 accepts both. A third-party Messages endpoint may serve model ids that follow no Anthropic naming convention at all.
So the dialect is learned rather than guessed. Model Hotel asks in the adaptive shape (what current models want), and if the upstream refuses with the 400 that names the other shape, it records the dialect for that provider+model and re-issues the request once. The caller sees the answer, not the 400, and no later request to that model pays the extra round-trip. The cache is in memory and per instance, like the learned param caches: relearning after a restart costs one 400, and the alternative is a stored fact that goes stale when Anthropic moves a model between dialects. See internal/proxy/anthropic_thinking_retry.go.
The same self-heal covers the other per-model fact no id reveals: a param the model has retired. claude-sonnet-5 and claude-opus-5 answer `temperature` is deprecated for this model while every 4.x model accepts it, and OpenAI clients send temperature as a matter of course, so this is the more common of the two. A Messages 400 naming a rejected param is learned into the same deprecationCache the compat path uses and the request re-issued without it. Learning is deliberately restricted to the candidates whose only chat route is Messages, anthropic-messages providers and the Claude models OpenCode Zen and Go serve over Messages alone: the cache is keyed by provider+model, so learning a strip from a Messages 400 on an anthropic provider could remove a param from that model's compat traffic, which accepts it. A hedged race learns the same facts from a 400 without re-sending the request, so the next request omits the param.
One retry covers both, because they cannot co-occur: a thinking request has already had its sampling params dropped, and a request without thinking cannot earn a dialect complaint. A third learned fact rides the same retry: a Messages endpoint that rejects the cache_control prompt-cache breakpoints the translator places (Anthropic itself never does) is recorded per provider+model and served without them from then on, so an operator-entered endpoint that does not know the field costs one round-trip once rather than a 400 on every request. Only a 400 that names the field and refuses it as unknown counts; Anthropic's body-specific complaints about a breakpoint (an empty block, more than four) never switch caching off. The retry is single-shot: a body that needs two repairs (say, a cache refusal and the other thinking dialect) gets its second 400 back, with the fact already learned for the next request. See API Reference.
Two consequences worth knowing:
-
Sampling params are dropped from a thinking request. Anthropic rejects any
temperaturebut 1 when thinking is on, in either shape, and treatstop_p/top_kthe same way. A caller who asks to think is asking about reasoning depth, so the reasoning request wins and the sampling knobs go. -
A small
max_tokensis raised. Thinking tokens come out of the same allowance as the answer, so a tight budget can be spent entirely on thinking: a liveclaude-sonnet-5givenmax_tokens: 30returned 29 thinking tokens, no text, andfinish_reason: "length". An adaptive request with a smaller allowance is raised to the default, and a budget request is raised above its budget.
Effort maps across directly: low/medium/high are common to both scales, OpenAI's minimal floors to Anthropic's low, and Anthropic's two extra levels (xhigh, max) are honoured if a client names them. Anything else (none, absent) leaves thinking off and the sampling params intact.
Source files: discovery_bedrock.go
Method: Calls GET /models on Bedrock's OpenAI-optimized bedrock-mantle endpoint (https://bedrock-mantle.{region}.api.aws/v1, e.g. us-east-1), authenticated with a Bedrock API key as a bearer token. The listing is OpenAI-shaped; entries become clean stubs enriched by models.dev (the enrichment lookup strips Bedrock's vendor. ID prefix, so openai.gpt-oss-120b matches the models.dev gpt-oss-120b entry).
Only mantle is supported: the classic bedrock-runtime endpoint serves chat solely under /openai/v1 and exposes no models listing at all, so discovery cannot work against it. Point the provider at a mantle URL.
Anthropic models are skipped at discovery. On Bedrock, anthropic.* models reject /v1/chat/completions (they are served only through the Anthropic Messages dialect at {base}/anthropic/v1/messages, which the proxy does not forward to). Exposing them would list models that fail on every chat request, so the discoverer drops them (logged at debug level, with an aggregate skipped_messages_dialect count in the completion log line).
Account prerequisites for Bedrock itself (not Model Hotel specific): most non-Anthropic models (GPT-OSS, GPT-5.x, Qwen, Kimi, GLM, DeepSeek, Mistral, Gemma, ...) work as soon as you generate an API key in the Bedrock console. Anthropic models additionally require a valid payment method, the Anthropic first-time-use form, a per-model Marketplace agreement (aws bedrock create-foundation-model-agreement), and on new or low-usage accounts may still be gated behind an AWS support request.
Source files: discovery_azure.go
Method: Azure is deployment-based: GET /openai/v1/models returns the full Azure model catalog (300+ entries), but only deployments the user created are invokable, and requests must name the deployment, not the base model. Discovery therefore enumerates deployments, via one of two routes depending on the base URL:
-
Foundry project endpoint (
https://{resource}.services.ai.azure.com/api/projects/{project}, exactly the string the Foundry portal hands you; recommended): lists via the project data-plane ({root}/api/projects/{project}/deployments?api-version=v1), which also carries the underlying model name, version, and publisher. -
Anything else on an Azure AI host (a bare resource root or an
/openai/v1base, including classic{resource}.openai.azure.comresources): lists via the classic data-plane route ({root}/openai/deployments?api-version=2023-03-15-preview, the only api-version that still serves the listing, since GA versions dropped it). Non-succeededdeployments are skipped.
Both routes accept the resource API key as a bearer token (the legacy api-key header also works but Model Hotel does not need it). Whatever base URL shape is configured, the proxy sends chat traffic to the one real inference surface, https://{host}/openai/v1/chat/completions (no api-version parameter needed on the v1 surface).
Enrichment for aliased deployments: the deployment name becomes the model ID (it is the invokable identifier) and the underlying base-model name is kept as the model's internal name. models.dev enrichment matches the deployment name first and falls back to the base-model name, so a deployment called my-fast-gpt backing gpt-4.1-mini still gets context/pricing metadata.
Account prerequisites for Azure itself (not Model Hotel specific): create an Azure AI Foundry resource (or classic Azure OpenAI resource) in the Azure portal, then deploy at least one model (Foundry portal → Deployments → Deploy model). A resource with zero deployments discovers zero models (Model Hotel logs a warning telling you to deploy first). Azure-sold models (OpenAI family) need no extra agreement; partner/community models (Meta, Mistral, xAI, Anthropic, ...) may require an Azure Marketplace subscription accepted during deployment.
Source files: discovery_vertex.go, vertex_catalog.go, catalogs/vertex_express.json
Method: Vertex AI express-mode API keys (free-tier keys from express mode, no billing account needed) only work on Google's native publisher routes: every OpenAI-compatible Google surface rejects them, and no model-listing route accepts them (the publishers listing wants OAuth). Discovery therefore starts from a shipped candidate list (catalogs/vertex_express.json) and validates each entry with a free POST .../models/{id}:countTokens probe (parallel, bounded concurrency):
- 200 → the key can invoke the model; it is kept as a clean stub for models.dev enrichment (context, pricing, modalities).
- 404 → not express-eligible (or retired); dropped silently. Not-yet-eligible candidates stay in the catalog so they light up automatically once Google enables them for express mode.
- 401/403 → discovery fails loudly, so a bad key reads as an error instead of "zero models".
Chat traffic is translated, not proxied. Gemini's native generateContent dialect is not OpenAI-shaped, so requests to a vertex-express provider go through Model Hotel's Gemini egress adapter (internal/gemini): the chat-completions body is rewritten to generateContent on the way out (system → systemInstruction, tools → functionDeclarations with full JSON Schema, images → inlineData, reasoning_effort → thinking budget, JSON response formats → responseJsonSchema, penalties/seed → generationConfig) and the response (including SSE streams, tool calls, and thinking-token usage) is translated back to the chat-completions shape before the rest of the pipeline sees it. Failover groups can therefore mix vertex-express with OpenAI-compatible providers transparently. Auth uses the x-goog-api-key header.
Account prerequisites for Vertex itself (not Model Hotel specific): sign up for Vertex AI express mode with a Google account and copy the express API key (AQ.-prefixed). Free-tier keys expire after 90 days and cover a subset of Gemini models under pre-GA terms; a paid Vertex key on the same routes works identically.
Source files: discovery_nanogpt.go
Method: Calls GET /models?detailed=true - a single request returns complete model metadata. No dedicated catalog is used. The API provides all information directly.
All fields from API:
| Field | Source |
|---|---|
| Display name / name | API (name) |
| Description | API (description) |
| Context length | API (context_length) |
| Max output tokens | API (max_output_tokens) |
| Modality | API (architecture.modality) |
| Input modalities | API (architecture.input_modalities) |
| Output modalities | API (architecture.output_modalities) |
| Vision | API (capabilities.vision) |
| Video input | API (capabilities.video_input) |
| Audio input | API (capabilities.audio_input) |
| Reasoning | API (capabilities.reasoning) |
| Tool calling | API (capabilities.tool_calling) |
| Parallel tool calls | API (capabilities.parallel_tool_calls) |
| Structured output | API (capabilities.structured_output) |
| PDF upload | API (capabilities.pdf_upload) |
| Streaming | Hardcoded true
|
| Input price / output price | API (pricing.prompt, pricing.completion) |
| Subscription info | API (subscription.included, subscription.note) → stored in params
|
| Owned by | API (owned_by) |
Source files: none of its own for discovery; discovery_neuralwatt.go covers quota only
Method: NeuralWatt has no dedicated discoverer. It is an OpenAI-compatible endpoint, so it falls through to discoverOpenAI: GET /v1/models produces clean stubs (id + owner) and models.dev fills in everything else from its neuralwatt entry, with the cross-provider index as gap coverage. The built-in OpenAI catalog cannot attach to it, because that backfill only matches ids the listing already returned.
Its quota endpoint is the part that needed code: see Additional Provider APIs. A 404 there means a free-tier key with no quota endpoint, which is reported as "no data" rather than as an error.
Source files: discovery_deepseek.go, deepseek_catalog.go, catalog_merge.go
Method: Calls GET /models (OpenAI-compatible list endpoint), converts the listing to clean stubs, and merges them with the built-in deepseekCatalog (6 rows) via mergeLiveAndCatalog. The catalog backfills context length, max output, reasoning flag, input modalities, and pricing (cache-miss maps to the standard input price; cache-hit is carried separately). Uncatalogued models are clean stubs filled by models.dev; there is no hardcoded context default.
Two of the six rows are not price overrides but the only source of the model at all: deepseek-chat and deepseek-reasoner are permanent aliases (thinking off and on) and are absent from the live listing and from models.dev entirely, so their rows also carry the only price they get. deepseek-flash is V4.1 Flash and the only Flash id DeepSeek documents; deepseek-chat, deepseek-reasoner, deepseek-v4-flash and deepseek-v4-flash-vision-exp all resolve to it upstream, so every Flash-family row carries its vision flag: the live API answers an image on each of those ids. The deepseek-flash, deepseek-v4-flash and deepseek-v4-flash-vision-exp rows carry no price, because models.dev prices them correctly and a duplicate could only go stale. deepseek-v4-pro keeps its own price because models.dev has that one wrong (0.435/0.87 where DeepSeek's pricing page says 0.66/1.98). Catalog prices are DeepSeek's off-peak rates, which apply for 17 of every 24 hours; peak hours (01:00-04:00 and 06:00-10:00 UTC) bill at exactly double, and a model row holds one figure, so metering under-reports during that window.
Catalog provides:
| Field | Source |
|---|---|
| Context length | Catalog |
| Max output tokens | Catalog |
| Reasoning | Catalog |
| Vision | Catalog (vision, set on the five Flash-family rows; deepseek-v4-pro has none) |
| Input modalities | Catalog (["text"] unless the row names more) |
| Input price (cache miss) | Catalog |
| Input price (cache hit) | Catalog |
| Output price | Catalog |
Hardcoded / missing:
| Field | Value |
|---|---|
| Output modalities | Hardcoded ["text"]
|
| Streaming | Hardcoded true
|
| Tool calling | Hardcoded true
|
Source files: discovery_ollama.go
Method: Two-step discovery. First calls GET /api/tags to list all locally available models. Then, for each model, calls POST /api/show (with the model name) to retrieve detailed metadata. The /api/show calls run concurrently (max 5 parallel) with a 120-second overall timeout.
Fields from /api/show:
| Field | Source |
|---|---|
| Capabilities (tools, thinking, vision) | API (capabilities array) |
| Context length | API (model_info → *.context_length) |
| Model family (→ owned_by) | API (details.family) |
| Format | API (details.format) - not stored in model |
| Parameter size | API (details.parameter_size) - not stored in model |
| Quantization level | API (details.quantization_level) - not stored in model |
Derived from capabilities:
| Field | Logic |
|---|---|
| Tool calling |
"tools" in capabilities array |
| Reasoning |
"thinking" in capabilities array |
| Vision |
"vision" in capabilities array |
| Input modalities | Vision → ["text","image"], else ["text"]
|
| Output modalities | As discovered: "embedding" with no "completion" → ["embedding"], otherwise ["text"]. What is stored follows the class the model is filed under, so a model listing both whose name reads as an embedder is stored as ["embedding"]
|
| Endpoint class |
"completion" with no "embedding" beside it is stated as an explicit chat class, so the central name heuristics cannot reclassify a chat model whose name merely contains "embed". A listing naming both, or neither (older Ollama), leaves the class to be derived. |
| Streaming, structured output | Set on exactly the models filed as chat (the class above, or the one derived from the same arrays and the name), since a local Ollama streams and constrains decoding to a response_format JSON schema on every chat model. An embeddings model carries neither. A model Ollama's cloud answers (a cloud-tagged local model such as gpt-oss:120b-cloud, and every Ollama Cloud model) keeps streaming but not structured output: see Ollama Cloud below. |
Ollama reports capabilities authoritatively, so an embedding-only model is kept out of the chat pickers rather than guessed at.
Context length is the model's trained context (*.context_length). A local Ollama serves a smaller window unless it is configured otherwise: it picks 4k below 24 GiB of VRAM, 32k up to 48 GiB and 256k above, overridden by OLLAMA_CONTEXT_LENGTH on the server. Set that if clients rely on the advertised figure.
Hardcoded / missing:
| Field | Value |
|---|---|
| Streaming | Hardcoded true
|
| Pricing | None (Ollama is local, no pricing) |
| Max output tokens | None |
| Structured output | Not set |
Source files: discovery_ollama.go (shared with local Ollama)
Method: Identical to local Ollama above: ollama.com serves the same /api/tags plus /api/show pair, so the same code path runs against it. The type is a separate one only because the host is known (ollama.com and its subdomains resolve to ollama-cloud) whereas a self-hosted Ollama runs on whatever address the operator gave and has to be chosen and probed.
Three differences follow from it being a hosted service rather than a local one:
- The provider carries an API key, and an extra account endpoint is available (
POST /api/me, see Additional Provider APIs). - Structured output is never advertised. The cloud accepts a
response_formatJSON schema and ignores it, answering in free text (ollama/ollama#12362, verified on three cloud models), so a client choosing a mode fromcapabilities.structured_outputwould get unvalidated output. The same holds for a local Ollama's cloud-tagged models (-cloud/:cloud), which it forwards to the cloud, and models.dev enrichment is not allowed to merge the flag back in. -
ollama-cloudis deliberately absent from the canonical models.dev map. The models.devollama-cloudentry is subscription-shaped and carries no cost data at all, so mapping it would return canonical specs whose empty prices block the cross-provider index, which is this provider's only pricing source.
Source files: discovery_zai.go, zai_catalog.go, catalog_merge.go
Method: Fetches the live OpenAI-compatible model list from GET /models on the coding-plan base URL, then merges it with the built-in zaiCatalog (19 rows) via mergeLiveAndCatalog. The live listing supplies the authoritative model set and owned_by; the catalog backfills context length, max output, capability flags, and modalities, and unions in catalog models the listing omits (a freshly released GLM, or the vision/turbo variants the coding plan serves but does not advertise). If the /models fetch fails, the scan aborts rather than falling back to the pure catalog: the catalog is a subset of the live listing, so a catalog-only result would let RecordMissingModels disable every live-only model on a transient outage.
Live API provides:
| Field | Source |
|---|---|
| Model list | API (GET /models) |
| Owned by | API (owned_by; "z-ai" normalized to "zhipu") |
Catalog backfills (live wins where present):
| Field | Source |
|---|---|
| Context length | Catalog |
| Max output tokens | Catalog |
| Reasoning | Catalog |
| Tool calling | Catalog |
| Structured output | Catalog |
| Input modalities | Catalog (via its text / vision hint) |
| Pricing | Catalog overrides only (see below); otherwise models.dev (canonical zai entry) |
Pricing: most Z.AI prices come from models.dev enrichment via its canonical zai provider entry, which tracks the official pricing page. The catalog carries per-model price overrides only for models that entry lacks - currently glm-4.5-x and glm-4.5-airx (official prices restated from the pricing page). Do not duplicate a models.dev-covered price into the catalog, and do not guess a price for a model whose official price is not yet published (glm-5.3 at its release): the catalog wins over models.dev, so a duplicate or a guess keeps enforcing itself after the real price lands. An unpriced model meters at zero and is named in the discovery warning log until models.dev lists it, at which point price-follows-source propagates the real price to existing rows on the next scan.
Derived from the catalog's text / vision hint:
| Field | Logic |
|---|---|
| Vision | hint is vision
|
| Video input | hint is vision
|
| Input modalities | Vision → ["text","image","video","pdf"], else ["text"]
|
Hardcoded / missing:
| Field | Value |
|---|---|
| Streaming | Hardcoded true (catalog entries) |
| Output modalities | Hardcoded ["text"]
|
Source files: discovery_kimi.go
Method: Moonshot's coding-subscription endpoint (base URL https://api.kimi.com/coding/v1, API keys sk-kimi-... from the Kimi Code console). discoverKimiCode fetches GET {base}/models, an OpenAI-shaped listing with rich extras that are mapped directly onto the model rather than routed through a catalog. There is no embedded catalog, and in practice nothing comes from models.dev either: the type maps exclusively to the canonical moonshotai entry, which does not carry the coding-plan model ids, so the lookup finds nothing and the live API supplies everything.
Subscription keys only work against api.kimi.com/coding - they 401 on Moonshot's pay-per-token platform (api.moonshot.ai) and vice versa, since the two are isolated key namespaces. A platform key pointed at api.moonshot.ai is handled by the generic OpenAI-compatible discoverer instead.
Live API provides:
| Field | Source |
|---|---|
| Model list | API (GET /models) |
| Display name | API (display_name) |
| Context length | API (context_length, e.g. 262144) |
| Reasoning | API (supports_reasoning) |
| Vision / image input | API (supports_image_in) |
| Video input | API (supports_video_in) |
| Owned by | API (recorded as moonshotai) |
Known models: k3 (K3, up to 1M context), k3-256k (K3 at 256K), kimi-for-coding (K2.8 preview), kimi-for-coding-highspeed (K2.7 Code HighSpeed). All are thinking-only - reasoning cannot be disabled, and responses carry DeepSeek-style reasoning_content.
Pricing: the listing carries no prices. Enrichment maps the route names onto models.dev's moonshotai entry (k3 and k3-256k to kimi-k3, kimi-for-coding-highspeed to kimi-k2.7-code-highspeed, see modelsDevIDAliases in modelsdev_providers.go); the alias carries prices only, the route keeps its own listing's context and capability flags and takes no output cap from the underlying model, so each model meters at Moonshot's pay-per-token list price, the shadow cost the subscription saves. kimi-for-coding is the K2.8 preview, which models.dev does not list, so it stays unpriced until it does; the kimi-code-plan-global entry is never consulted because it prices every model at $0.
Source files: discovery_minimax.go
Method: MiniMax's international platform (base URL https://api.minimax.io/v1). Token Plan subscription keys (sk-cp-...) and pay-as-you-go keys (sk-api-...) share the same OpenAI-compatible endpoint, but only Token Plan keys have quota data to report (see below). discoverMiniMax fetches GET {base}/models, a metadata-bare OpenAI-shaped listing (id and owner only) - there is no embedded catalog for this provider, so every model becomes a live stub and models.dev fills in context length, pricing, capabilities, and reasoning support.
The CN twin (api.minimaxi.com) is a separate key namespace and is left to the generic OpenAI-compatible discoverer.
Live API provides:
| Field | Source |
|---|---|
| Model list | API (GET /models) |
| Owned by | API (recorded as minimax) |
Everything else - context length, pricing, capabilities, reasoning - is backfilled by models.dev enrichment rather than the live listing.
Known models: MiniMax-M3, MiniMax-M2.7 (+ MiniMax-M2.7-highspeed), MiniMax-M2.5 (+ MiniMax-M2.5-highspeed), MiniMax-M2.1 (+ MiniMax-M2.1-highspeed), and MiniMax-M2.
Chat-completion HTTP-200 business errors (proxy behaviour, documented here because it is MiniMax-specific): MiniMax reports chat-completion failures (rate limit, exhausted Token Plan balance, auth rejection) inside a real HTTP 200 whose JSON body carries base_resp.status_code != 0 (e.g. 1008 "insufficient balance"). The proxy's failover, circuit-breaker, and error-forwarding paths are all keyed on resp.StatusCode, so an unmodified 200 would be treated as success - the client gets an empty completion and no failover fires. For minimax-typed providers, the proxy inspects each non-streaming 200 and remaps the business code to the HTTP status it stands for (1002/1039/1008 rate/token/balance to 429, 1004 auth to 401, anything else to 502), restoring the original body so the error message still forwards. Genuine successes (base_resp.status_code == 0), streaming SSE responses, and unparseable bodies are passed through untouched. This is a proxy concern only; the quota endpoint (GetMiniMaxQuota, below) still re-serializes base_resp inside the modelled response for the dashboard.
Source files: discovery_opencode_go.go, opencode_go_catalog.go, opencode_catalog_types.go, catalog_merge.go
Method: Calls GET /models (OpenAI-compatible list endpoint), converts the listing to clean stubs, and merges them with the built-in catalog via mergeLiveAndCatalog. The catalog is an override channel, and it currently ships empty (catalogs/opencode_go.json has no rows): every live model's metadata and per-token prices come from models.dev's opencode-go entry (with the cross-provider index as gap coverage). Those prices are not what a Go subscriber pays per request - they are the shadow cost that Go's dollar-based quotas (404 (endpoint gone) falls back to the catalog - normally empty, so the scan yields no models and nothing gets disabled; other non-200s abort the scan so a transient outage can't disable live-only models. (Quota overrun does not gate the listing - it still returns 200.)
Catalog rows, when present, are overrides: a row wins over models.dev for every field it sets. The price fields are optional: a row that omits them leaves the model unpriced for models.dev to fill, so a row can fix a context length or a modality list without pinning a price. A row states a price only where models.dev has none or has it wrong, because a duplicated price freezes at the day it was written.
Source files: discovery_opencode_zen.go, opencode_zen_catalog.go, opencode_catalog_types.go, catalog_merge.go
Method: GET /models, converted to clean stubs that models.dev's opencode entry then enriches with metadata and pricing. Nothing is unioned in: a model Zen drops from its listing is gone with it (OpenCode Zen rotates free models aggressively). For keyless providers (no API key), only the live models models.dev's opencode entry prices at zero are kept, since a keyless caller must not be shown models it cannot reach; that decision consults the canonical entry alone, never the cross-provider index, so an unrelated zero-priced model elsewhere on models.dev cannot make a paid Zen model look free. Without the models.dev cache a keyless provider keeps nothing and logs why. A free model is written with an explicit zero price on both paths, because enrichment reads a models.dev zero as "no figure" (the meaning it has on the subscription entries).
The catalog and model conversion logic is shared with OpenCode Go via OpenCodeModelSpec and OpenCodeCatalogToModel. The Zen catalog is a metadata override channel that backfills live models and surfaces none: today it holds two rows that restrict the input modalities models.dev over-advertises for free models whose deployment rejects audio or video. Rows carry no price.
Source files: discovery_xai.go, xai_catalog.go, xai_types.go, catalog_merge.go
Method: Live-plus-catalog merge via mergeLiveAndCatalog. The live model list is obtained with a tiered strategy, then merged with the catalog:
-
Funded accounts: Calls
GET /language-models- a proprietary endpoint that returns rich data including pricing (cents per 100M tokens, converted to USD per 1M) and input/output modalities. These live fields are kept as-is. - No-access accounts (403): xAI returns 403 for keys that cannot list (unauthorized, or a zero-balance account). Discovery falls back to the pure static catalog, flagged as a fallback so the missing-model sweep records nothing from it.
-
Other failures / empty list: Falls back to
GET /v1/models(minimal OpenAI-compatible: id + owner). A 403 there also takes the catalog; any other failure aborts the scan (a429included), keeping the stored models.
The live result is then merged with the catalog. The 6-row catalog backfills the fields xAI's API does not report (context window, max output, reasoning flag, friendly display name) and unions in catalog grok models the listing endpoints do not advertise but that remain callable (verified: all catalog grok ids return 200). Its rows carry no price: /language-models reports prices when the key can list, and models.dev's xai entry prices every row otherwise. Live values always win: the catalog never overrides live data, and no placeholder description or modality is fabricated, so a real catalog description is never masked.
Image-generation models come from a separate listing, GET /image-generation-models, and are the one place xAI discovery states an endpoint class outright (image). Their per-image price, which has no per-token equivalent, is carried in params.image_price.
Live API provides (from /language-models):
| Field | Source |
|---|---|
| Input modalities | API (input_modalities) |
| Output modalities | API (output_modalities) |
| Input price | API (prompt_text_token_price) - converted from cents per 100M tokens to USD per 1M, set only when > 0 |
| Cache-hit price | API (cached_prompt_text_token_price) - converted |
| Output price | API (completion_text_token_price) - converted, set only when > 0 |
| Owned by | API (owned_by) |
| Streaming / Tool calling / Structured output | Hardcoded true
|
| Vision | Derived from API input modalities (image present) |
Catalog backfills (live wins where present):
| Field | Source |
|---|---|
| Display name | Catalog (live emits the raw id as a placeholder) |
| Description | Catalog |
| Context length | Catalog (API does not report it) |
| Max output tokens | Catalog (API does not report it) |
| Reasoning | Catalog (OR-merged into live capabilities) |
Pricing conversion: xAI reports prices in cents per 100 million tokens, so 20000 is $2 per 1M. Conversion: $per_1M = cents_per_100M / 10000 (one factor of 100 for cents, one for the token scale). A conversion short by the second factor once metered every xAI request at 100x its list price.
Source file: discovery_openrouter.go
Method: Calls GET /models to list available models from OpenRouter's unified API. Responses are parsed into OpenRouterModelsResponse which provides rich metadata per model.
API-provided fields:
| Field | Source |
|---|---|
| Display name | API (name) |
| Description | API (description) |
| Context length | API (context_length), falls back to top_provider.context_length
|
| Max output tokens | API (top_provider.max_completion_tokens) |
| Modality | API (architecture.modality) |
| Input modalities | API (architecture.input_modalities) |
| Output modalities | API (architecture.output_modalities) |
| Input price (per 1M tokens) | API (pricing.prompt) - converted from per-token |
| Cache-hit price (per 1M tokens) | API (pricing.input_cache_read) - converted from per-token |
| Output price (per 1M tokens) | API (pricing.completion) - converted from per-token |
| Owned by | Derived from model ID prefix (e.g., openai from openai/gpt-4.1) |
Capability mapping: OpenRouter models report supported_parameters which are mapped to capabilities:
| Parameter | Capability |
|---|---|
tools |
Tool calling |
reasoning |
Reasoning |
structured_outputs |
Structured output |
| (all models) | Streaming (hardcoded true) |
Model filtering: Models with IDs starting with ~ (auto-routing aliases) are skipped. Models whose output modalities exclude text or code (image-only, embedding-only) are also skipped.
Pricing conversion: OpenRouter reports prices as per-token strings (e.g., "0.000002"). These are converted to $/1M tokens by multiplying by 1,000,000.
Source files: discovery_google.go, google_catalog.go (the retired-model list), google_types.go
Method: Uses Google's native Gemini API (GET /v1beta/models, key in the x-goog-api-key header) for discovery, which provides rich metadata including context windows, max output tokens, supported generation methods, and thinking support. The base URL is configured for the OpenAI-compatible proxy endpoint (/v1beta/openai), but discovery internally converts to the native API URL.
Model IDs from the native API have a models/ prefix (e.g., models/gemini-2.5-flash) which is stripped for internal use.
API-provided fields (from /v1beta/models):
| Field | Source |
|---|---|
| Display name | API (displayName) |
| Description | API (description) |
| Context length | API (inputTokenLimit) |
| Max output tokens | API (outputTokenLimit) |
| Reasoning (thinking) | API (thinking) |
| Generation methods | API (supportedGenerationMethods) |
| Streaming | Derived (has generateContent method) |
Prices (input, cache-hit, output) come from models.dev enrichment (see Pricing below).
Derived from model name:
| Field | Logic |
|---|---|
| Vision | Name contains gemini-2, gemini-3, or gemma (excluding embedding/live and a tts name segment) |
| Tool calling | Not embedding/imagen/veo/lyria/aqa/live, nor a tts name segment |
| Structured output | Same as tool calling, minus the image-output models: Google answers JSON mode on those with a 400 whatever its docs list (google-gemini/cookbook#1028), and models.dev enrichment does not put the flag back for them |
| Modality | Derived from the input/output lists by the shared classifier: chat whenever text is an output (Gemini image models included, since they emit text beside the image), tts for text in and audio out, embedding for an embedding output |
| Input modalities | Vision → ["text","image"]; live/native-audio → ["text","image","audio","video"]; TTS (tts name segment) and embedding → ["text"]
|
| Output modalities | Default ["text"]; image gen → ["text","image"]; live/native-audio → ["text","audio"]; TTS → ["audio"]; embedding → ["embedding"]
|
Model filtering: Only models supporting generateContent or embedContent are included. AQA-only models are excluded. A shipped retired-model list (catalogs/google_retired.json) is filtered out on top of that.
The retired list. Google publishes shutdown dates as the earliest possible date and does not always prune its own listing on time: it kept gemini-2.0-flash listed for two months after retirement. Neither the listing nor the date can be trusted alone, so the file holds ids that were each confirmed to answer a real request with 404 This model is no longer available. Without the filter those models are upserted as enabled, offered to callers, and fail every request. The list currently holds the four gemini-2.0-flash and gemini-2.0-flash-lite ids, stored without the models/ prefix.
Pricing. Google prices come from models.dev's canonical google entry; there is no Google price catalog.
Auth: Discovery sends the key in the x-goog-api-key header (native API). Proxy uses Authorization: Bearer API_KEY (OpenAI-compatible endpoint). Google API keys are simple alphanumeric strings starting with AIzaSy....
Source files: discovery_cohere.go, cohere_catalog.go
Method: Calls Cohere's native /v1/models with pagination (?endpoint=chat, then ?endpoint=rerank, 100 per page), and skips models the API marks is_deprecated: true. Rerank models are listed so the Models page can show them and the proxy's /v1/rerank passthrough can route them; a rerank fetch that fails leaves the chat models in place rather than failing the scan. The listing carries no prices. The built-in cohere.json catalog is a pricing override channel: 2 per-token rows (c4ai-aya-expanse-32b, c4ai-aya-vision-32b) and the 5 rerank models, which models.dev does not price, at their per-search price; every other model takes its price from models.dev enrichment.
API-provided fields:
| Field | Source |
|---|---|
| Model ID | API (name) |
| Context length | API (context_length) |
| Tool calling | API (features contains tools or tool_choice) |
| Structured output | API (features contains json_mode or json_schema) |
| Reasoning | API (features contains reasoning) |
| Vision | API (features contains vision) |
| Streaming | Hardcoded true for chat models |
| Input modalities | Derived: vision → ["text","image"], else ["text"]
|
| Output modalities |
["rerank"] for a rerank model (the endpoint listing is authoritative, so the derived class can never mistake one for chat), else ["text"]
|
| Owned by | Hardcoded cohere
|
Rerank models get no capability flags: features is read for chat models only.
Catalog-provided fields (catalogued models only):
| Field | Source |
|---|---|
| Display name | Catalog (an uncatalogued model uses its id) |
| Description | Catalog |
| Max output tokens | Catalog |
| Input price per million | Catalog |
| Output price per million | Catalog |
| Search price per thousand | Catalog (rerank models only) |
Rerank models are billed per search unit rather than per token, so their per-token price fields stay unset and the catalog supplies search_price_per_thousand instead (USD per 1,000 searches: 2.00 for the v3 family and v4.0-fast, 2.50 for v4.0-pro, as Cohere lists them). Every catalog figure is optional, so a row states only what the API and models.dev lack. The proxy prices a rerank request from the search units the answer reports (see Request Logging).
Host detection: api.cohere.com, api.cohere.ai, and all subdomains of cohere.com and cohere.ai
Source files: discovery_lmstudio.go
Method: LMStudio is a self-hosted server exposing an OpenAI-compatible API on whatever address it was started on. Discovery reads its native listing (GET /api/v0/models), which reports context length and capabilities, and falls back to GET /v1/models when that endpoint is unavailable. No built-in catalog is used.
Detection: Chosen by the operator, confirmed by probing /api/v0/models when the provider is added or its URL changed.
Hardcoded / missing:
| Field | Value |
|---|---|
| Context length | From the native listing (max_context_length); not set on the /v1/models fallback |
| Max output tokens | Not set |
| Pricing | None (self-hosted) |
| Capabilities | Chat models: streaming and structured output always, tool calling when the native listing's capabilities contains tool_use (native tool support in the chat template). A model is a chat model when its type is llm or vlm, or when an absent or unknown type is filed as chat by the central class derivation (so an unknown type named like a reranker is not). Embeddings and every other class: none, since they stream nothing and take no response_format. The /v1/models fallback cannot tell the types apart and gives every model streaming and structured output, which the central classification then clears on the embedding and rerank models. models.dev does not enrich a self-hosted server's models. LM Studio also runs tool calls on models without native tool_use, through its default prompt-based tool format, so a model without the tool-calling flag may still answer a request that sends tools |
| Modalities | From the native listing's model type: embeddings produces ["embedding"], vlm takes ["text","image"] in and states the chat class, llm states chat. The /v1/models fallback carries no type, so the class is derived from the model id there. |
Source files: discovery_localai.go
Method: LocalAI is a self-hosted server that runs many backends (llama.cpp, whisper, piper, stable-diffusion, ...) behind one OpenAI-compatible API. Discovery reads GET /v1/models/capabilities, LocalAI's own superset of /v1/models, which names per model the usecases its configuration serves, the input and output modalities, and the context size the server will honour. A LocalAI too old for that route is read from the plain /v1/models listing instead, which carries no class.
Every model is filed under the class its usecases name, stated explicitly so no name heuristic can move it. LocalAI tags every model on its llama.cpp backend as chat as well, so a reranker reports chat and rerank; the dedicated endpoint wins, in this order:
| Usecase | Class |
|---|---|
rerank |
rerank |
embeddings |
embedding |
transcript |
stt |
tts |
tts |
image |
image |
chat or completion
|
chat |
A model whose usecases name only endpoints Model Hotel does not route (video, vad, detection, sound_generation, ...) is skipped and logged. An entry with no capabilities at all is a model file without a config, which LocalAI serves with its chat defaults; it gets the chat capabilities but no explicit class, so the central classification keeps a plain file as chat and still reads an embedding or reranker out of a name that says so, which clears those capabilities again.
Detection: Chosen by the operator, confirmed by probing /v1/models/capabilities when the provider is added or its URL changed.
Image generation: LocalAI's default response_format is url, and the URL names LocalAI's own /generated-images route, which answers only with LocalAI's key and from its own network. A request that leaves response_format unset is forwarded as b64_json; one that asks for url explicitly is left alone.
Hardcoded / missing:
| Field | Value |
|---|---|
| Context length |
context_size from the listing, marked live, so a config edit propagates on the next scan |
| Max output tokens | Not set |
| Pricing | None (self-hosted) |
| Capabilities | Chat models: streaming and structured output always (LocalAI constrains any llama.cpp model with a grammar built from the schema), tool calling when the listing carries tools, reasoning when it carries thinking, vision when it carries vision or lists image among the inputs. LocalAI detects tools and thinking from the loaded model's template, so a model that has never been loaded may gain them on a later scan; the operator can also pin them. Every other class: none. models.dev does not enrich a self-hosted server's models |
| Modalities | Chat models: input_modalities from the listing (text, image, audio, video), text out. Every other class: derived centrally from the class |
Source files: discovery_sglang.go
Method: SGLang serves one model per process behind an OpenAI-compatible API. Discovery reads GET /v1/models for the served names and max_model_len (the context the server runs with, marked live), and GET /get_model_info at the origin for what the answering process's model can do: is_generation (a server that does not generate serves embeddings, or reranking when its architecture is a sequence classifier), the reasoning_parser and tool_call_parser the server was launched with, and has_image_understanding / has_audio_understanding from the model's own config. The info applies to the listed entry of its served_model_name and to an adapter whose parent is that name; behind the SGLang router, which merges several workers' listings, any other listed model is read from the listing alone.
| Info | Effect |
|---|---|
is_generation: false |
class embedding, or rerank when an architecture ends in ForSequenceClassification (a reward or classifier model shares that architecture and is filed the same way; the model probe then shows it answers no rerank); no chat capability |
reasoning_parser set |
reasoning |
tool_call_parser set |
tool calling |
has_image_understanding |
vision, image among the inputs |
has_audio_understanding |
audio input, audio among the inputs |
Structured output is always on for a generation model: SGLang constrains any of them through its grammar backend. A server whose info route does not answer (an older SGLang, a proxy in front of it) is read from the listing alone, which carries no class and only streaming, as custom reads it.
Detection: Chosen by the operator, confirmed by probing /get_model_info when the provider is added or its URL changed.
Hardcoded / missing:
| Field | Value |
|---|---|
| Context length |
max_model_len from the listing, marked live |
| Max output tokens | Not set |
| Pricing | None (self-hosted) |
| Capabilities | Chat models: streaming and structured output always, the rest from the info as above. Embedding servers: none. models.dev does not enrich a self-hosted server's models |
| Modalities | Chat models: text plus image and audio as the info reports, text out. Embedding servers: derived centrally from the class |
Source files: discovery_tabbyapi.go
Method: TabbyAPI (the ExLlamaV3 server) loads one chat model at a time and, in a second container, one embedding model. Discovery reads GET /v1/models for the names, with llama-server's meta on the loaded entry (n_ctx, the loaded max_seq_len, marked live); GET /v1/model for the loaded chat model's card, whose parameters block says what it was loaded with; and GET /v1/model/embedding for the embedding model's card. The chat card applies to the listed entry of its id. A key without admin rights lists the loaded chat model alone, so the embedding model is added from its card; an admin key lists the whole model directory, the embedding model's folder among it (filed once, as the embedding model, while that container holds it; as an unloaded chat entry otherwise), where every other entry is a model that is not loaded (or a configured dummy name) and is filed as a plain chat model with streaming only, as custom would read it, since nothing says what it can do until it is loaded. An empty container answers its card route with TabbyAPI's own {"detail": ...} on a 503 (400 on a build before April 2025; a 404 from an older build without the route), which is no card and not a fault; any other answer (another status, a proxy's 503 or 404 page, a 200 whose body is not a card, a transport fault) fails the scan, so the capabilities the last good scan stored are kept rather than overwritten with streaming only.
| Parameter | Effect |
|---|---|
use_vision |
vision, image among the inputs (a vision projector was loaded with the model) |
prompt_template_content renders a tools block |
tool calling (what TabbyAPI itself reads the template for) |
prompt_template_content opens a <think> block |
reasoning (TabbyAPI parses the block out as reasoning_content) |
max_seq_len |
context length when the listing's meta.n_ctx is absent (the directory listing has none) |
Structured output is on for the loaded model: TabbyAPI constrains generation to a JSON schema through its grammar filter. The embedding model is filed as embedding, served on /v1/embeddings.
Detection: Chosen by the operator, confirmed by probing /.well-known/serviceinfo when the provider is added or its URL changed. TabbyAPI also impersonates KoboldCpp on /api/extra/version, so a TabbyAPI added as koboldcpp is refused naming the real family.
Hardcoded / missing:
| Field | Value |
|---|---|
| Context length |
meta.n_ctx from the listing, else the card's max_seq_len; marked live |
| Max output tokens | Not set |
| Pricing | None (self-hosted) |
| Capabilities | Loaded chat model: streaming and structured output always, the rest from the card as above. Unloaded entries: streaming only. Embedding model: none. models.dev does not enrich a self-hosted server's models |
| Modalities | Chat models: text plus image when use_vision, text out. Embedding model: derived centrally from the class |
Source files: discovery_koboldcpp.go
Method: KoboldCPP is a self-hosted server exposing an OpenAI-compatible API on whatever address it was started on. Discovery confirms the server via GET /api/extra/version, reads the loaded chat model from GET /v1/models when the version endpoint's llm flag says one is loaded, and takes the context size from GET /api/extra/true_max_context_length. Image and audio input come from the version endpoint's vision and audio flags, which describe the adapters the loaded chat model was given. A KoboldCPP started without a chat model still answers /v1/models with a placeholder named inactive; it is never listed, whether the llm flag says so or the build is too old to report the flag.
KoboldCPP also serves one side model per endpoint, and the version endpoint's flags say which are loaded. Each becomes a model of its own class, so it shows in the matching picker:
| Flag | Model ID | Class |
|---|---|---|
txt2img |
koboldcpp/<name> from GET /sdapi/v1/sd-models, or koboldcpp/image when the server has no such listing or it names nothing. Any other failed or unreadable answer may be transient, so the image model sits that scan out rather than change its ID |
image |
tts |
koboldcpp/tts |
tts |
transcribe |
koboldcpp/whisper |
stt |
embeddings |
koboldcpp/embeddings |
embedding |
KoboldCPP names only its image model; the others carry fixed IDs, which also stay the same when the file behind them is swapped. When two models would share an ID (a chat file named tts.gguf), the one discovered first keeps it (chat, then image, text-to-speech, Whisper, embeddings) and the other is left out. Because automatic failover groups form on the part of the ID after the last /, two KoboldCPP servers that both load a text-to-speech, Whisper or embeddings model end up in one group (hotel/tts, hotel/whisper, hotel/embeddings) even when the files differ. For embeddings that matters: vectors from two different models cannot be compared, so if the servers load different embedding models, call each by its provider name rather than through the group. Discovery does not run a model to learn its name; KoboldCPP reports the embeddings model's name only in an embeddings response. KoboldCPP ignores the request's model field on these endpoints, so the ID only has to route. KoboldCPP has no rerank endpoint. No built-in catalog is used.
Detection: Chosen by the operator, confirmed by probing /api/extra/version when the provider is added or its URL changed.
Hardcoded / missing:
| Field | Value |
|---|---|
| Context length | From /api/extra/true_max_context_length; left unset when that endpoint is unavailable |
| Max output tokens | Not set |
| Pricing | None (self-hosted) |
| Capabilities | Hardcoded: streaming on, tool calling off (KoboldCPP uses its own tool format) |
| Modalities | Chat model: input modalities from the version endpoint's vision and audio flags, so a vision or audio KoboldCPP is not filed as text-only, and the endpoint class is derived centrally. Side models: the class is stated explicitly (see the table above) and the modality arrays follow from it. The transcribe flag means a Whisper side model, never audio input on the chat model |
Source files: internal/provider/modelsdev.go, internal/provider/modelsdev_providers.go (provider-type map and ID aliases)
In addition to provider-specific discovery and built-in catalogs, Model Hotel can enrich models using the models.dev open-source model catalogue. This community-maintained database covers 40+ providers and provides pricing, context limits, capabilities, and modality data for thousands of models.
- On server startup, a blocking call in
main.gofetcheshttps://models.dev/api.jsonwith a 15-second timeout, through the SafeDialer, so a redirect from models.dev to a private or reserved address cannot be turned into an SSRF. - The response is parsed into two in-memory indexes: a per-provider index (models.dev provider ID → model ID → spec) and a cross-provider index keyed by bare model ID.
- During every discovery run (after the provider-specific discovery function returns its model list), each model is passed through the enrichment layer along with the provider's detected type. Except for a
customprovider and the self-hosted types (ollama,lmstudio,koboldcpp,localai,sglang): a server the operator runs can load any file under any name, so models.dev cannot speak for its models. What such a server reports is all that is known, and the operator fills in the rest by hand. -
EnrichModelfills only empty or zero-value fields: it never overwrites data already populated by the provider API or built-in catalog. Capability flags are OR-merged, never cleared, with one exception noted in the table below. - If the models.dev fetch fails (network error, timeout, invalid JSON), the failure is logged and a background loop retries it after 1, 2, 4 and 8 minutes, then every 15 minutes, until one load succeeds. Discovery reads the cache on every scan, so the first scan after a successful retry is enriched again; until then a scan runs without enrichment and the stored prices survive (the upsert keeps a stored price when the incoming one is absent). Existing catalogue data is never at risk.
The same bare model ID appears under dozens of models.dev providers - the official vendor plus resellers, each publishing its own prices (glm-5.2 is listed by 26 providers). Enrichment therefore resolves specs in two steps:
-
Canonical provider first.
modelsDevProviderForTypemaps each Model Hotel provider type to the models.dev provider entry that carries the vendor's own official metadata (zai-coding→zai,minimax→minimax,kimi-code→moonshotai, ...). That entry's models are consulted first, so a reseller's price can never shadow the official one. Coding-plan types deliberately map to the pay-per-token vendor entry, not the-coding-planentry (which prices everything at $0): Model Hotel meters the shadow cost a request would have had at list price. - Cross-provider fallback. On a miss (or for unmapped provider types) the lookup falls back to the cross-provider index. That index is built deterministically, canonical vendor entries ranked ahead of all other providers and each group sorted by provider ID, so a colliding bare ID always resolves to the same spec across restarts. (Previously the winner was whichever provider a Go map iteration happened to visit first, which let random reseller prices land on official models.)
Single-vendor types never take that fallback. A type whose API serves only its own models is marked exclusive: anthropic, deepseek, xai, google, vertex-express, cohere, minimax, kimi-code, zai-coding. If their canonical entry misses, enrichment gives up rather than borrowing another provider's data for the same bare ID, because that data is by definition secondhand: OpenCode Go lists glm-5.3 with a guessed price before Z.ai publishes one, and that guess must not become the metered price on a Z.ai provider. Aggregators and the unknown-host catch-all (openai, nanogpt, openrouter, opencode-go, opencode-zen, bedrock, azure, neuralwatt) stay non-exclusive: their listings genuinely span many vendors, so the cross-provider index is legitimate gap coverage. ollama-cloud is mapped to nothing at all, for the reason given in its section above.
Within each index, models are matched by their model_id using progressive fallback:
-
Exact match -
gpt-4o→gpt-4o -
Strip date suffix -
gpt-4o-2024-08-06→gpt-4o -
Strip version suffix -
claude-sonnet-4-5-20250514→claude-sonnet-4-5 - Longest prefix match - finds the models.dev entry with the longest matching prefix
The lookupFuzzyIn helper implements this logic (the canonical-provider and cross-provider passes run it against their respective maps), handling date patterns like YYYY-MM-DD, YYYYMMDD, and version suffixes.
| Field | Condition |
|---|---|
| Display name | Only if empty or same as model_id
|
| Context length | Only if nil/zero |
| Max output tokens | Only if nil/zero |
| Input price per million | Only if nil/zero |
| Output price per million | Only if nil/zero |
| Input price per million (cache hit) | Only if nil/zero |
| Reasoning capability | Only if false |
| Tool calling capability | Only if false |
| Structured output capability | Only if false, and never for an image-output model on a provider that reaches Google's own route (Google AI Studio, Vertex AI express, and OpenCode Zen for its gemini-* ids), whose JSON mode the API refuses (google-gemini/cookbook#1028) |
| Vision capability | Only if false, and only when the attachment flag is corroborated by an image input modality (or by no input list at all). models.dev sets attachment on models whose only input is text, so the flag on its own would advertise vision a provider can refuse. |
| Input modalities | Only if empty or "[]"
|
| Output modalities | Only if empty or "[]"
|
| Owned by / family | Only if empty |
Enrichment never sets the modality class. It fills the modality arrays only; NormalizeModelClassification derives models.modality from them afterwards. See The modality class is derived, not written.
Enrichment also matches a second key for deployment-based providers: when the model ID misses, the underlying base-model name in name is tried, so an Azure deployment called my-fast-gpt backing gpt-4.1-mini still gets metadata.
Any model that finishes discovery with no per-token price on either side is named in a warning log line, because it meters at zero and that is otherwise invisible until someone reconciles a bill.
| Variable | Default | Description |
|---|---|---|
MODELSDEV_ENABLED |
true |
Enable or disable models.dev enrichment. When false, the fetch is skipped entirely. |
Models.dev is particularly valuable for providers that lack built-in catalogs or have incomplete ones:
| Provider | Built-in Catalog | What models.dev adds |
|---|---|---|
| OpenAI (generic) | 2 rows (gpt-5.5-pro, gpt-5.4-pro) |
Pricing and specs for older GPT-4.x, the o-series, and any new models |
| Anthropic | None | Pricing for Claude models (the live API supplies the rest) |
| Google AI Studio | None | Pricing for Gemini models |
| DeepSeek | 6 rows | Specs for older DeepSeek models and any not yet in the catalog |
| Ollama | None | Pricing, capabilities for well-known models available through Ollama |
| OpenRouter | None (API-driven) | Pricing and specs for any OpenRouter-hosted model not covered by the API |
| Any unknown provider | None | Full metadata for any model that exists in the models.dev database |
Each discovered model is stored in the models database table with the following fields:
| Field | Type | Description |
|---|---|---|
id |
UUID | Primary key (generated on discovery) |
provider_id |
UUID | Foreign key to the owning provider |
model_id |
string | Provider-unique model identifier (e.g., gpt-5.5, claude-sonnet-4-5) |
name |
string | Model name (often same as model_id) |
description |
string | Human-readable description |
display_name |
string | Friendly display name |
capabilities |
JSONB |
Capability struct serialized as JSON |
params |
JSONB | Provider-specific parameters (e.g., NanoGPT subscription info) |
modality |
string | Derived endpoint class: "chat", "embedding", "rerank", "image", "video", "tts", or "stt"
|
input_modalities |
JSONB array | Input modality list (e.g., ["text","image"]) |
output_modalities |
JSONB array | Output modality list (e.g., ["text"] or []) |
context_length |
int (nullable) | Maximum context window in tokens |
max_output_tokens |
int (nullable) | Maximum output tokens |
input_price_per_million |
float (nullable) | Input price per million tokens (USD) |
input_price_per_million_cache_hit |
float (nullable) | Per-million-token price for cache hits (e.g., DeepSeek) |
output_price_per_million |
float (nullable) | Output price per million tokens (USD) |
search_price_per_thousand |
float (nullable) | Price per 1,000 search units (USD) for a rerank model, which bills per search rather than per token (migration 091); unset on every other model |
owned_by |
string | Model creator/owner |
enabled |
bool | Whether the model is active for routing |
disabled_manually |
bool | Whether the model was disabled by a user (not discovery) |
display_name_customized |
bool | The operator renamed it, so discovery leaves display_name alone (migration 033) |
price_customized |
bool | The operator pinned the prices, so no source overwrites them (071) |
limits_customized |
bool | The operator pinned context_length / max_output_tokens, so no source overwrites them (092) |
capabilities_customized |
bool | The operator set a custom or self-hosted provider's model capabilities by hand, so no scan overwrites them (095) |
price_sources |
jsonb | Where each stored price came from, keyed input / cache_hit / output / search, one of provider (the provider's own listing), catalog (an embedded override), modelsdev (enrichment) or manual (an operator edit); a key is absent while that price is unset or was stored before 084. Merged key by key in the same direction as the prices, so a kept price keeps its source. The dashboard shows it as a hint next to each price. |
missing_scans |
int | Consecutive confirmed-missing scans; 2 disables the model (054) |
discovery_dismissed_at |
timestamptz (nullable) | The operator dismissed this model's discrepancy claim (061) |
auto_retired_at |
timestamptz (nullable) | The proxy retired it from traffic after a verifying probe (063) |
manually_enabled_at |
timestamptz (nullable) | The operator enabled it by hand while the listing omits it: the manual-enable pin (070) |
created_at |
timestamptz | When the model was first discovered |
last_seen_at |
timestamptz | When the model was last seen during discovery |
provider_name |
string | Denormalized provider name (from JOIN) |
provider_enabled |
bool | Denormalized provider enabled state (from JOIN) |
The capabilities JSONB field stores a Capability struct with boolean flags:
{
"streaming": true,
"vision": false,
"video_input": false,
"audio_input": false,
"reasoning": false,
"tool_calling": false,
"parallel_tool_calls": false,
"structured_output": false,
"pdf_upload": false
}Not all providers populate every field. Unsupported flags are simply left as false.
CREATE TABLE IF NOT EXISTS models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID REFERENCES providers(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
display_name TEXT,
name TEXT,
description TEXT,
capabilities JSONB,
params JSONB,
modality TEXT,
input_modalities JSONB,
output_modalities JSONB,
context_length INTEGER,
max_output_tokens INTEGER,
input_price_per_million REAL,
input_price_per_million_cache_hit REAL,
output_price_per_million REAL,
search_price_per_thousand REAL,
owned_by TEXT,
enabled BOOLEAN DEFAULT true,
disabled_manually BOOLEAN DEFAULT false,
display_name_customized BOOLEAN DEFAULT false,
price_customized BOOLEAN NOT NULL DEFAULT false,
limits_customized BOOLEAN NOT NULL DEFAULT false,
capabilities_customized BOOLEAN NOT NULL DEFAULT false,
price_sources JSONB NOT NULL DEFAULT '{}'::jsonb,
missing_scans INTEGER NOT NULL DEFAULT 0,
discovery_dismissed_at TIMESTAMPTZ,
auto_retired_at TIMESTAMPTZ,
manually_enabled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
last_seen_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(provider_id, model_id)
);Indexes:
- Unique index on
(provider_id, model_id)- created implicitly by the UNIQUE constraint - No separate index on
provider_idalone is needed as the unique index covers it
Migrations:
-
001_init.sql- Initial table creation -
002_model_seen_and_settings.sql- Addedlast_seen_at,owned_by,context_length,input_price_per_million,output_price_per_million -
003_model_details.sql- Addedname,description,max_output_tokens,modality,input_modalities,output_modalities -
017_deepseek_pricing.sql- Backfilled DeepSeek pricing -
021_model_disabled_manually.sql- Addeddisabled_manually -
033_display_name_customized.sql- Addeddisplay_name_customized(the display-name pin) -
054_model_missing_scans.sql- Added themissing_scansconsecutive-miss counter -
061_model_discovery_dismissed.sql- Addeddiscovery_dismissed_at -
063_model_auto_retired.sql- Addedauto_retired_at -
070_model_manual_enable_pin.sql- Addedmanually_enabled_at(the manual-enable pin) -
071_model_price_pin.sql- Addedprice_customized(the price pin) -
084_model_price_sources.sql- Addedprice_sources(where each stored price came from) -
091_rerank_search_price.sql- Addedsearch_price_per_thousand(the per-search price a rerank model bills at) -
092_model_limits_customized.sql- Addedlimits_customized(the limits pin) -
095_model_capabilities_customized.sql- Addedcapabilities_customized(the capabilities pin)
Two related migrations live on other tables: 047_discovery_changes.sql creates the background-discovery journal, and 062_failover_group_auto_disabled.sql adds model_failover_groups.auto_disabled_at.
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now()
);Default settings:
-
discovery_interval="6h" -
discovery_on_startup="true" -
discovery_on_provider_create="true" -
discovery_claim_alert_days="7" -
model_prune_days="7"
Model IDs are provider-specific identifiers returned by the provider's API. They are used to uniquely identify a model within a provider's catalog.
Model IDs follow the format provided by each provider:
| Provider | Example Model IDs |
|---|---|
| OpenAI |
gpt-4o, gpt-4o-2024-08-06, o1-preview
|
| Anthropic |
claude-sonnet-4-5-20250514, claude-3-5-haiku-20241022
|
| DeepSeek |
deepseek-flash, deepseek-chat, deepseek-reasoner
|
| xAI |
grok-4.3, grok-4.5
|
gemini-2.5-flash, gemini-3.6-flash (stripped from models/gemini-3.6-flash) |
|
| Ollama |
llama3.2:3b, gemma3:4b
|
| OpenRouter |
openai/gpt-4.1, anthropic/claude-3-5-sonnet
|
Model IDs are used in:
-
Database lookups -
GetByProviderAndModelID()finds one provider's model by its model ID; the failover group for a model ID is what spans providers -
Failover groups - Models with the same
model_idfrom different providers can be grouped for failover -
Proxy requests - The proxy endpoint accepts model IDs in the format
{provider_name}/{model_id}for admin chat, or just{model_id}for virtual key auth
The unique constraint (provider_id, model_id) ensures that each provider can only have one entry per model ID. This composite key is also used for:
- Cache lookups (composite key cache in
internal/model/cache.go) - Upsert operations during discovery
When discovery upserts a model, it uses an ON CONFLICT strategy:
-
New models are inserted with
enabled = true. -
Existing models (matched by
provider_id + model_idunique constraint) are updated with all new metadata. Theenabledflag is set based on thedisabled_manuallyflag:- If
disabled_manually = falseandauto_retired_atis unset, the model is re-enabled (it may have been previously disabled byRecordMissingModelsbut is now back). - If
disabled_manually = true, the model stays disabled - the user's manual override is respected even if the model reappears in the provider API. - If
auto_retired_atis set, the model stays disabled too: the proxy retired it while the provider kept listing it, so a sighting is no new evidence. Only an operator enable or disable clearsauto_retired_at.
- If
-
last_seen_atis always updated tonow().
enabled = CASE
WHEN models.disabled_manually = false AND models.auto_retired_at IS NULL THEN true
ELSE models.enabled
ENDThe same ON CONFLICT update merges pricing/context per field rather than blindly overwriting:
-
Context length / max output tokens are fill-only unless the value came from the provider's own live API this scan (tracked via transient per-field live provenance): a live value overwrites, a catalog/models.dev value only fills a gap. An operator-pinned limit (
limits_customized = true) is never overwritten. This keeps stored metadata stable when sources disagree or a probe is flaky. -
Prices follow their source (
price_customized = false, the default): the scan's price - live API, embedded catalog, or models.dev enrichment, already merged in that precedence - overwrites the stored one; only a scan that carries no price at all keeps the stored value. Vendor price changes and corrected enrichment data therefore propagate to existing rows on the next scan. Installs that upgraded past the random-reseller-price bug (see Canonical Provider Preference) heal automatically on their first scan, with no migration or manual reset. -
Operator-pinned prices (
price_customized = true) are untouchable: no source, live included, overwrites them. Editing any price viaPATCH /api/models/{id}sets the pin implicitly; sending"price_customized": falseclears it AND nulls the price columns so the next scan re-derives them (the dashboard's model detail modal surfaces this as "Reset to source" on the pin banner). -
Capabilities follow the scan unless the discoverer marked its reading a placeholder (it could not fetch the model's details this scan) or the operator pinned a custom or self-hosted provider's capabilities (
capabilities_customized = true); either way the stored flags stand.
A model absent from one listing is never disabled immediately. Discovery
requires three independent layers of evidence, so one DNS flap, transient 5xx,
or partial upstream listing cannot disable models (which, in an HA fleet,
would then propagate to every member). Only the scheduled/startup background
sweep applies these layers and disables; manual Discover scans just import
what they see (see Manual (API)).
-
Transport retries. Every discovery HTTP call (listings and per-model
detail probes) retries transient network errors and 429/5xx responses up to
3 attempts with linear backoff and jitter. Ollama-family per-model
/api/showfailures no longer drop the model from the results either: the model was listed by/api/tags, so it is kept with default metadata. -
In-scan confirmation probes (
ConfirmMissingModels). If the listing is missing any previously enabled model, discovery re-runs the full listing up to 2 more times (after ~15s and ~45s backoff plus jitter) and takes the union of all probes' model IDs as the scan's membership. A model only counts as missing this scan if every probe missed it. If a confirmation probe itself fails, or if the confirmed-missing set is implausibly large (more than 5 models AND more than half the provider's enabled models - the mass-vanish guard, which emits adiscovery.suspect_scanwarning event), the whole scan is treated as suspect and records no misses at all. Operator-pinned models leave that ratio entirely, numerator and denominator both: a pinned model is permanently absent from the listing by the operator's own decision, so it is neither evidence that the listing is broken nor part of the population the listing is judged against. Left in, enough pins would mark every scan suspect and freeze listing-based auto-disable for the whole provider. A total blackout (the initial listing and every confirmation probe returning nothing while the provider still has enabled models) is treated as suspect regardless of the floor, since a small provider would otherwise slip past it and stay stale indefinitely with no operator signal. A provider that genuinely retires that many models would otherwise trip the guard on every scan and stay stale forever, so a per-providersuspect_scanscounter tracks consecutive mass-vanish scans; once it reaches 3 (and every 3 after), discovery raises a distinct high-severitydiscovery.bulk_removal_suspectedevent asking an operator to review and disable the retired models by hand. It never auto-disables on this signal - disabling half a provider's catalog is too destructive to do unattended when it fans out across an HA fleet. A healthy scan (the listing recovered) resets the counter to 0. -
Cross-scan miss streak (
RecordMissingModels). A confirmed miss increments the model'smissing_scanscounter. Only when the streak reaches 2 consecutive scans is the model disabled (enabled = false). Any sighting (a scheduled scan, a manual re-test, even a confirmation probe) resets the streak to 0. A model carrying the manual-enable pin keeps accumulating misses but is never disabled by them: see The pins.
RecordMissingModels returns the newly disabled models, which the discovery
handlers use to (a) re-sync the failover groups those models belonged to,
pruning their stale entries, and (b) report them in the scan's diff with
reason not_listed. Models missing but below the threshold are only logged
("pending"); they appear nowhere in the diff and stay fully routable.
Note: RecordMissingModels sets enabled = false but does not set disabled_manually = true. This means the model will be automatically re-enabled on the next discovery run if it reappears. On the Models page, discovery-disabled models (enabled = false, disabled_manually = false) show a tooltip on the status badge: "Not listed by the provider since {date}" (based on last_seen_at).
Miss recording can only run after a successful listing, and it is skipped entirely when the discovered list is empty, when the pre-scan snapshot could not be read, or when any model upsert failed - a provider outage, auth error, or DB hiccup aborts the scan before anything is counted missing, so "disabled by discovery" always means "the provider repeatedly responded and did not list this model".
In summary:
-
Auto-disabled models (removed from the provider API) have
disabled_manually = falseand are re-enabled if they reappear. -
Manually disabled models have
disabled_manually = trueand stay disabled even if the model reappears in the provider API.
Discovery can only act when a model leaves a provider's listing. Some providers keep serving a listing entry for a model they have already shut down (Google kept gemini-2.0-flash listed for two months after retirement, OpenCode Zen lists claude-sonnet-4 and refuses it), so the only thing that knows such a model is dead is a real request to it. The proxy therefore also retires models from traffic, and every one of those retirements is verified with an upstream request before anything is written.
Nomination: three strikes
- A request the provider refuses as a retirement counts one strike. Three strikes nominate the model, with no successful request in between and no more than 30 minutes between one strike and the next. That is a gap, not a deadline: refusals at 0, 29 and 58 minutes are one streak of three, while a model refused once an hour never accumulates one.
- Strikes are in memory and per gateway instance. They are not persisted, and each fleet member reaches its own conclusion from its own traffic.
- In prose, the model's own id has to sit beside the phrase that retires it, with no clause break between them. A phrase that only refuses one capability ("not supported for this endpoint") does not count.
- In fields, a model-scoped error code (
model_not_found,model_not_supported) is a retirement on its own, because it names its own subject. A genericnot_found_errorcounts only when the error's message also names the model. - The name may be a dated snapshot, since providers resolve an alias like
claude-sonnet-4and answer aboutclaude-sonnet-4-20250514. Only dash-separated digit runs are accepted as that kind of suffix, sogpt-4.1still cannot retiregpt-4. - Strikes are counted per surface, and a refusal only counts on a surface the model is known to serve. Chat and embeddings keep separate counts: sending a chat model to
/v1/embeddingsdraws a capability error naming the model that reads exactly like a retirement, and a misconfigured client must not be able to disable a model that serves chat perfectly. - A refusal on
/v1/embeddingscounts only when the model'soutput_modalitiessay it produces embeddings, so an embeddings model whose catalog entry declares nothing is never auto-retired. - A refusal on chat counts unless the entry positively describes something a chat completion cannot be about: an image, video, audio, embedding or rerank output, or an input that admits no text. (A speech-to-text model produces text like any chat model and gives itself away on the input side.) Chat is where most refusals arrive, so requiring a declared modality there would switch traffic-driven retirement off for every uncatalogued model at once.
- A success clears the surface it arrived on and only that one, for the same reason the counts are separate: a provider can retire a model's chat surface while still serving its embeddings, and a global clear would let the healthy surface hold the dead one open forever. Traffic on a surface that is never auto-retired (images, speech, rerank) clears nothing at all.
- A model the catalog says serves both chat and embeddings is never auto-retired. Disabling turns off the model row, so it cannot express "gone on chat, still serving embeddings", and no probe can catch that. Such a model stays enabled until discovery drops it or you disable it by hand. It needs a provider serving one model id on both surfaces, which is rare.
Adjudication: one probe
- At the threshold the gateway sends a real, minimal request to the model itself (a 64-token chat completion, or a one-input embedding), off the request path.
- Content coming back means the model works: the strike count is cleared, nothing is disabled, and a warning is logged, because a model that refuses real traffic and answers a probe is worth a look. It needs three fresh strikes and the probe cooldown before it is asked again, which matters because a provider whose prose disagrees with its own behaviour keeps producing this outcome.
- Only the provider refusing the model by name writes the disable. A 429, a 5xx, an entitlement failure, a timeout or an unreadable answer establishes nothing and postpones.
- The rate is bounded on two axes: a model is probed at most once every 5 minutes however hard it is being retried, and at most 4 probes are in flight against any one provider at a time.
- Probes deliberately skip rate limiting and circuit-breaker accounting (a verification must not be able to sideline a healthy provider), but they respect an already-open circuit and postpone rather than calling a provider the gateway has sidelined.
- A model whose probe can never answer keeps its strikes and keeps paying that cost. After three postponements in a row the log line escalates from info to warning (
proxy: auto-disable postponed repeatedly, carryinginconclusive_probes). Nothing is retired on the strength of it; the run ends as soon as the model answers. - Only chat, messages and embeddings models are auto-retired from traffic at all. Image, TTS, STT and rerank models never are, because a chat probe against one fails for reasons that have nothing to do with retirement and that failure would read as confirmation. Their refusals are logged at debug level and no strikes are kept, so such a model stays enabled until discovery drops it from the listing or you disable it by hand.
The write
- A confirmed retirement sets
enabled = false, stampsauto_retired_at(migration063), revalidates the custom failover groups the model belonged to, and publishes amodel.auto_disabled_goneevent. - Reappearing in a listing does not revive it. The provider was refusing it while still listing it, so a sighting says nothing new, and reviving it would put it back into routing to fail, re-alert and churn failover groups on every scan. Only an operator clears the stamp, by enabling the model by hand.
- If the strike count was cleared (the gateway restarted, 30 minutes passed with no further refusal, or a request succeeded) the model re-earns three strikes before the next probe. If the count is still parked at the threshold in the same running process, the first refusal past the probe cooldown claims a probe directly; that is also how a disable that failed to write is retried. Either way nothing is retired without a probe confirming it, and the cooldown applies to every route in. A success resets what the model is accused of, not the rate at which the gateway may ask about it.
Watching the cost
- Every retirement decision costs one upstream request, and it is logged as one. The
proxy: auto-disabled retired modelline and themodel.auto_disabled_goneevent both carryprobe_verdictand the endpoint family, so a retirement withoutprobe_verdict: refuseddid not come from this path. - Every finished probe is counted as
modelhotel_retirement_probes_total{provider,model,verdict}, whereverdictisrefused,servedorinconclusive. - Read the ratio rather than any single series. A rising
servedcount means the classifier keeps nominating models that are alive; a risinginconclusivecount means those nominations are not being settled, so the model keeps its strikes and comes back every cooldown. - Most inconclusive verdicts cost an upstream request but not all do (one that cannot be built for that provider is counted without anything being sent), so read the series as unanswered questions rather than as a spend figure.
-
refusedcounts verdicts, not completed retirements: the write that follows can still be called off by a late success or refused by the database. Count retirements with themodel.auto_disabled_goneevent instead. -
modelhere is the provider-side model id, whereasmodelhotel_requests_totalcarries the name the client asked for. Those are the same string for directprovider/modeltraffic and part company on group-routed requests, whichmodelhotel_requests_totallabelshotel/<group>, so a join covers direct traffic and silently drops the rest. (Validation failures are labelledunresolvedthere and are the other place the two label spaces differ, but they never reached a provider, so there is no probe to join to.)
Users can manually enable or disable a model via:
PATCH /api/models/{id}
Content-Type: application/json
{"enabled": false}This sets both enabled and disabled_manually:
-
enabled = false,disabled_manually = true- model is disabled and stays disabled across discovery runs. -
enabled = true,disabled_manually = false- model is re-enabled and will stay enabled.
Either direction also clears auto_retired_at and discovery_dismissed_at: operator intent supersedes a traffic retirement and their own earlier dismissal, and it has to happen in the same statement rather than on the next sighting, since a model retired again before that scan would keep a dismissal nothing could clear.
The Update endpoint also supports editing display_name, context_length, max_output_tokens, input_price_per_million, input_price_per_million_cache_hit, output_price_per_million, search_price_per_thousand, price_customized, limits_customized, capabilities and capabilities_customized (the last two on a custom or self-hosted provider's models only).
Editing a model arms a pin that stops discovery from writing over the operator's decision: a value set by hand is not rediscovered until the operator resets it. All five are independent, and only a write that touches the field in question moves its pin, with one exception: re-typing a provider to a type outside custom and the self-hosted ones (through the admin API or a Front Desk config sync) releases its models' capabilities pins. The model detail modal marks each pinned group with a pin banner ("Prices pinned by operator", "Context and output limits pinned by operator", "Capabilities set by operator") carrying a Reset to source action, and an edited price also shows the source hint manual. The model table does not mark pinned values.
The display-name pin (display_name_customized, migration 033). Setting display_name marks the row customized, and the upsert then keeps the stored name on every later scan: display_name = CASE WHEN models.display_name_customized THEN models.display_name ELSE EXCLUDED.display_name END. Clearing the name clears the pin, and discovery goes back to supplying it.
The price pin (price_customized, migration 071). Editing any price sets it implicitly. A pinned row's stored prices are untouchable: no source, live included, replaces them. A NULL price on a pinned row still fills from the scan, because the pin protects values rather than vetoing gap-fill. Sending "price_customized": false clears the pin and nulls all four price columns (search price included) and their sources, so the next scan re-derives them; the dashboard's model detail modal surfaces that as "Reset to source" on the pin banner.
The limits pin (limits_customized, migration 092). Editing context_length or max_output_tokens sets it implicitly, and the upsert then keeps the stored limits even when the provider's live listing reports different ones (a NULL limit still fills from the scan). Sending "limits_customized": false clears the pin and nulls both columns so the next scan refills them.
The capabilities pin (capabilities_customized, migration 095). Capabilities can be set by hand on the models of a custom or self-hosted provider (custom, Ollama, LM Studio, KoboldCpp), the same way prices can: discovery takes only what such a server reports, often streaming alone, and the operator fills in the rest. In the model detail modal's edit mode those capabilities become toggles. Sending capabilities replaces the stored flags whole and sets the pin (the dashboard has no streaming toggle and sends streaming back as stored), and the upsert then keeps them on every later scan. Sending "capabilities_customized": false clears the pin, and the next scan writes the server's reading again. Each model reports capabilities_editable, which the dashboard follows to show the toggles. The API refuses an edit or a pin for any other provider type (400, code capabilities_not_editable): those capabilities come from the vendor's own API or data, and a pin would freeze them against the next genuine change. The unpin is taken on any type, and re-typing a provider to a type outside custom and the self-hosted ones releases its models' pins in the same statement, so the new type's discovery owns their capabilities from the next scan.
The manual-enable pin (manually_enabled_at, migration 070). Enabling a model by hand stamps it, disabling clears it. The pin exists for one situation: the provider's listing omits a model the operator has verified works, and without it the listing-based auto-disable would turn the model straight back off. While the pin is set:
- The model is exempt from the miss-streak auto-disable.
- Its row appears in the discrepancy modal's Pinned by you bucket, informational and never counted, so a forgotten pin stays visible.
- It is excluded from the mass-vanish guard's ratio, numerator and denominator both. A pinned model is permanently absent from the listing by the operator's own decision, so it is not evidence that the listing is broken. Counted, enough pins would mark every scan suspect and freeze listing-based auto-disable for the whole provider.
- It is still probed by the confirmation probes, so a pin the provider starts listing again resolves on the very next scan.
The pin clears itself on any sighting: the listing naming the model again ends the disagreement and hands it back to automatic management. The one case neither an enable nor a sighting reaches is the operator changing their mind about a model the provider still does not list, and POST /api/discovery/{provider_id}/unpin covers exactly that, returning the models to listing-based auto-disable with a clean miss streak. There is no pin direction on that endpoint, because enabling the model is the pin.
A provider can carry scheduled_disable_on (a YYYY-MM-DD date, migration 068), which is useful when a subscription or trial has a known end and its models should stop being routed to on that day rather than start failing.
- A minute-tick loop fires every due schedule, and also runs once at startup so a restart that straddled midnight still fires.
- The comparison date is the app server's clock, the same clock the update validation uses when it rejects a date in the past, because the database session's timezone can differ.
- Firing sets
enabled = falseand clearsscheduled_disable_onin the same statement, so a disable happens once. - It then does what a manual disable does: invalidates the provider and model caches, resyncs failover groups so the provider's models leave the auto-created ones, and publishes one
provider.scheduled_disablewarning event per provider. - A sweep that has begun finishes even while the server is shutting down. The schedule is already cleared by then, so a cancellation would lose the operator's event permanently and leave failover groups carrying a disabled provider's models.
A disabled provider is skipped by discovery entirely, and its models are never pruned: they wait, with their pins, prices and failover memberships, for the provider to come back.
| Scenario | enabled |
disabled_manually |
Behavior on next discovery |
|---|---|---|---|
| New model discovered | true |
false |
Normal |
| Model disappears from API | false |
false |
Will be re-enabled if it reappears |
| User manually disables | false |
true |
Stays disabled even if it reappears |
| User manually re-enables | true |
false |
Normal |
Proxy retires it from traffic (auto_retired_at set) |
false |
false |
Stays disabled even though the provider still lists it; only an operator enable or disable clears auto_retired_at
|
GET /api/models?provider_id={uuid}Returns all models, optionally filtered by provider ID.
Response:
[
{
"id": "uuid",
"model_id": "gpt-4o",
"name": "gpt-4o",
"display_name": "GPT-4o",
"provider_id": "uuid",
"provider_name": "OpenAI",
"capabilities": "{\"streaming\":true,\"vision\":true}",
"modality": "chat",
"input_modalities": "[\"text\",\"image\"]",
"output_modalities": "[\"text\"]",
"context_length": 128000,
"enabled": true,
"created_at": "2024-01-01T00:00:00Z",
"last_seen_at": "2024-01-01T00:00:00Z"
}
]PATCH /api/models/{id}
Content-Type: application/json
{
"display_name": "Custom Name",
"context_length": 64000,
"max_output_tokens": 4096,
"input_price_per_million": 2.5,
"input_price_per_million_cache_hit": 0.25,
"output_price_per_million": 10.0,
"price_customized": true,
"enabled": true
}All fields are optional, but at least one must be present: a body that changes nothing is a 400. Updates the model and returns the updated record.
Validation:
-
display_name: 1-128 characters, or empty to clear the pin and hand the name back to discovery -
context_length: 256-2,000,000 -
max_output_tokens: 1-128,000 -
input_price_per_million: 0-1000 -
input_price_per_million_cache_hit: 0-1000 -
output_price_per_million: 0-1000 -
search_price_per_thousand: 0-1000
"price_customized": false is the unpin: it clears the pin and nulls all four price columns (input_price_per_million, input_price_per_million_cache_hit, output_price_per_million, search_price_per_thousand) and their price_sources, so the next scan re-derives them. Any price edit sets the pin implicitly. "limits_customized": false does the same for context_length and max_output_tokens. See The pins.
DELETE /api/models/{id}Removes the model record entirely from the database. Deleted models are not tracked - if discovery runs again and the model is still available at the provider, it will be re-discovered as a new entry with a new UUID.
Response: 204 No Content
POST /api/models/{id}/testTests a model by making a minimal chat completion request ("Respond only with 'Hi'") and returns latency metrics. A rerank model is sent a one-document rerank request on its own route instead and answers with ranked_results (see the API Reference); zero ranked results counts as a failure.
Request: No body required.
Response:
{
"success": true,
"streaming": false,
"response_header_ms": 450,
"duration_ms": 450,
"response": "Hi"
}Or on error:
{
"success": false,
"streaming": false,
"duration_ms": 5000,
"error": "connection timeout"
}ttft_ms is reserved for a streaming probe and is omitted from this non-streaming one. Any upstream error text is scrubbed of the provider key, by regex and by exact match against the key just decrypted for the probe, before it is returned or persisted: an auth failure is exactly where an upstream quotes the key back.
The test request is logged to the request_logs table with a full timing breakdown. A disabled model is normally not testable; ?allow_disabled=true opts in, which is how the failover page re-checks members that went N/A.
The internal/model/cache.go module provides in-memory caching for model lookups with a 5-minute TTL.
-
UUID cache -
GetCachedByUUID(id)- Returns a single model by its UUID -
Composite key cache -
GetCachedByCompositeKey(providerID, modelID)- Returns a model by provider + model ID
| Function | Description |
|---|---|
CacheGen() |
The generation a read-through captures before its query |
cacheModelByUUIDAt(model, gen) |
Cache a single model by UUID, if no invalidation landed since gen
|
cacheModelByCompositeKeyAt(providerID, modelID, model, gen) |
Cache by composite key, same guard |
GetCachedByUUID(id) |
Lookup by UUID |
GetCachedByCompositeKey(providerID, modelID) |
Lookup by composite key |
InvalidateModelCache() |
Clear all cache entries and advance the generation (called on every write) |
WarmModelCacheAt(models, gen) |
Populate both caches with rows listed at gen (startup warm, GetByIDs) |
Every read-through captures CacheGen() before its SELECT and installs only if it is
unchanged, so a read that overlapped a write cannot reinstall the pre-write row for the
TTL. No write path installs its RETURNING row: it invalidates, the next reader refills.
The cache is invalidated on every write operation:
-
Upsert()- Called during discovery -
SetEnabled()- Manual enable/disable -
Update()- Partial updates -
DeleteByID()- Model deletion -
RecordMissingModels()- Miss-streak recording / threshold disable
This ensures cache consistency at the cost of cache hit rate during active discovery runs.
The table below summarizes what each provider type supplies during model discovery:
| Provider | Context Length | Pricing | Capabilities | Modalities | Source |
|---|---|---|---|---|---|
| OpenAI | Catalog, else models.dev | Catalog, else models.dev | Catalog, else models.dev | Catalog, else models.dev | Live API + 2-row catalog (backfill only) |
| Anthropic | API | models.dev | API | API | Live API (no catalog) |
| Anthropic (Messages API) | API | models.dev | API | API | Live API, same discoverer as Anthropic |
| DeepSeek | Catalog | Catalog | Catalog | Catalog | Live API + catalog (merge) |
| Google AI Studio | API | models.dev | API | Derived | Live API (no catalog; a shipped retired-model list filters it) |
| Vertex AI express | models.dev | models.dev | models.dev | models.dev | Shipped candidate catalog, each entry validated live by a countTokens probe |
| xAI | Catalog | API | Catalog | API | Live API + catalog (merge) |
| Cohere | API | Catalog (2 per-token rows + 5 rerank rows), else models.dev | API (features) |
Derived from features
|
Live API + pricing-override catalog |
| NanoGPT | API | API | API | API | Live API |
| Z.AI | Catalog | models.dev (catalog overrides only) | Catalog | Catalog | Live API + catalog (merge) |
| OpenCode Go | models.dev | models.dev | models.dev | models.dev | Live API + catalog (merge); the catalog is an empty override channel |
| OpenCode Zen | models.dev | models.dev (explicit zero for free models) | models.dev | models.dev, input restricted by 2 catalog rows | Live API + catalog (backfill only, no union) |
| OpenRouter | API | API | API (supported_parameters) |
API | Live API |
| Ollama | API | - | API | API | Live API |
| Ollama Cloud | API | models.dev (cross-provider index) | API | API | Live API |
| LMStudio | API | - | API | API | Live API |
| KoboldCPP | API | - | Hardcoded | API (vision/audio flags) |
Live API |
| NeuralWatt | models.dev | models.dev | models.dev | models.dev | OpenAI-compatible GET /v1/models (no dedicated discovery; enriched via models.dev) |
| Kimi Code | API | models.dev (moonshotai, via route-name aliases; kimi-for-coding unpriced) |
API | API | Live API + models.dev |
| MiniMax | models.dev | models.dev | models.dev | models.dev | Live API (metadata-bare GET /models; no catalog, enriched via models.dev) |
| AWS Bedrock | models.dev | models.dev | models.dev | models.dev | Live API (mantle GET /models stubs, enriched via models.dev; anthropic.* skipped) |
| Azure AI Foundry | models.dev | models.dev | models.dev | models.dev | Live deployments listing (stubs, enriched via models.dev by deployment then base-model name) |
Some providers offer supplementary APIs that are accessible outside of model discovery:
| Provider | Endpoint | API | Description |
|---|---|---|---|
| NanoGPT | GET /usage |
GetNanoGPTUsage |
Account usage: daily/weekly token counts, image limits, subscription status |
| Z.AI | GET /api/monitor/usage/quota/limit |
GetZAICodingQuota |
Quota limits and usage per model |
| DeepSeek | GET /user/balance |
GetDeepSeekBalance |
Account balance (total, granted, topped-up) |
| OpenRouter |
GET /api/v1/credits, GET /api/v1/key
|
GetOpenRouterBalance |
Account credits, rate limits, usage limits, free tier status |
| Ollama Cloud | POST /api/me |
GetOllamaCloudAccount |
Account information |
| NeuralWatt | GET /quota |
GetNeuralWattQuota |
Quota/balance (a 404 means a free-tier key with no quota endpoint - treated as "no data", not an error) |
| Kimi Code | GET /usages |
GetKimiCodeQuota |
5-hour and weekly quota (limit/remaining/reset time), parallel-request limit, and membership tier |
| MiniMax | GET /token_plan/remains |
GetMiniMaxQuota |
5-hour and weekly Token Plan quota per model class (status and remaining percent), re-serialized from the modelled fields including base_resp
|
| OpenCode Go | GET /usage |
GetOpenCodeGoUsage |
Rolling (5h), weekly and monthly subscription usage (percent consumed, status, reset time). A 403 EntitlementError means a key with no active Go subscription, treated as "no data", not an error; any other 403 is a rejected key |
These are exposed via:
-
GET /api/providers/{id}/usage- for NanoGPT, Z.AI, OpenRouter, NeuralWatt, Kimi Code, MiniMax, and OpenCode Go -
GET /api/providers/{id}/balance- for DeepSeek -
GET /api/providers/{id}/account- for Ollama Cloud -
POST /api/providers/refresh-quotas- refreshes usage/balance for all supported providers
The dashboard renders each of these as a sidebar pill with a detail modal. The OpenCode Go modal shows the three subscription windows the endpoint reports, each with its percent consumed and reset time:
Quota/balance fetches use a circuit breaker with 5 consecutive failure threshold and 5-minute cooldown. The gateway keeps one discovery service for the life of the process, so this state persists across polls: five failures spread over five separate poll passes open the circuit exactly as five failures inside one pass would, and the counter only resets on a successful fetch, a restart, or the provider being deleted.
- Failover and Hotel Routing - How discovered models are grouped for automatic failover
- Security - Provider key encryption and virtual key hashing
- Home - Architecture overview and feature summary
