The problem this solves
A provider’s/v1/models endpoint returns every model the account
can access — chat, reasoning, embeddings, audio transcription, speech
synthesis, image generation, realtime voice, and legacy completion-
only. OpenAI returns ~130 today; Anthropic returns ~40; OpenRouter
returns 300+ routed slugs. Without a filter, the model picker is
unusable: pick the wrong id and the next request to
/chat/completions comes back 400.
FERAL’s answer is a small deterministic classifier,
feral-core/providers/model_classes.py, that tags every known id
with one of ten classes and lets callers ask for the slice they want.
The ten classes
reasoning is a strict subset of chat. A reasoning model’s
classify() returns "reasoning" AND it appears when you ask for
model_class="chat". Vision is additive to chat — ask the adapter’s
_capabilities_for_model(id) for the vision flag rather than using a
class filter.
Filtering the picker
BaseProvider.list_models, so every adapter
that inherits from it — OpenAI, Anthropic, DeepSeek, Gemini, Groq,
OpenRouter, Together, Fireworks, Ollama, LM Studio — gets the filter
for free.
How the classifier decides
The rules are regex-driven and per-provider, so"gpt-5.5" on OpenAI
and "openai/gpt-5.5" on OpenRouter both resolve to reasoning:
- OpenAI:
gpt-5{,.x},o1,o3,o4→ reasoning.gpt-4o,gpt-4.1,gpt-3.5-turbo→ chat.babbage-*,*-instruct→ completion-only.text-embedding-*→ embedding.whisper-*,*-transcribe,*-tts→ audio.dall-e-*,gpt-image-*→ image.gpt-realtime-*,gpt-4o-realtime-*→ realtime. - Anthropic: every
claude-4-*model is reasoning (Opus 4.7 uses adaptive thinking; Sonnet 4.6 / Haiku 4.5 use extended thinking). Olderclaude-3-*is chat.claude-instant-*is chat. - DeepSeek:
deepseek-v4-proanddeepseek-reasoner→ reasoning.deepseek-v4-flashanddeepseek-chat→ chat. - Gemini: any id ending
-thinking→ reasoning.*-image→ image. Everything else that looks like a chat model name → chat. - Groq:
deepseek-r1-distill-*,qwen-qwq-*→ reasoning.whisper-*→ audio. Llama / Mixtral / Gemma → chat. - OpenRouter: peel the
<vendor>/prefix and delegate — OR is a router, so class semantics match the routed target.
model_classes.py plus one case
in tests/test_provider_model_classes.py.
Refreshing the catalog
When a provider ships a new model id, run the refresh script once so the bundled catalog (the list that ships in wheels and on first boot) is re-seeded from the live/v1/models endpoint:
skipped (no key) line. OpenRouter’s /api/v1/models endpoint is
public — the refresh works without a key and is the main way new
routed slugs reach the catalog automatically.
After a refresh the printed drift = N tells you how many ids
changed. drift = 0 means the bundled list is already at parity
with the live APIs.
Extending for a new provider
When you add a new adapter:- Extend
_RULES_BY_PROVIDERinproviders/model_classes.pywith a regex table covering the provider’s ids. - Make sure the chat-class filter test (
test_chat_only_filter.py) has at least one(chat, non-chat)pair from your adapter. - Add a fixture
tests/fixtures/<provider>_models.jsoncapturing a representative/v1/modelsresponse. The classifier tests walk the fixture and assert every id classifies away fromunknown. - Register the adapter in
scripts/refresh_provider_catalog.pyso--dry-runsurfaces drift for the new provider.
