Model selection: per-step pins
Status: Active. Each pipeline step reads its own*_model app_settings
pin and fails loud when it’s unset. The earlier cost_tier.<tier>.model
indirection (a five-tier free/budget/standard/premium/flagship ladder
resolved by resolve_tier_model()) was removed in PR #1907 in favour of
granular per-step control. The filename is kept for URL stability; the doc now
describes the per-step-pin model that replaced it.
Migrating from the tier API? Replaceresolve_tier_model(pool, "standard")at a call site with a direct read of that step’s*_modelpin (see the inventory below), and seed the pin insettings_defaults.py/0000_baseline.seeds.sql. Thecost_tier.*.modelrows are deleted by migration20260623_210500_drop_cost_tier_model_seeds.py.
Why this exists
Pre-2026-05, ~22 production call sites embedded specific LLM model identifiers as Python literals —"gemma3:27b", "llama3:latest", "qwen3-coder:30b", etc.
Each was hardcoded for the model that worked best at that call site at that time.
The cost: every operator running a fork had to either accept Matt’s specific
model selection or fork the code. There was no DB-tunable seam.
Per-step pins make model selection a configuration concern, not a code concern.
Each call site reads a named *_model setting scoped to that step, and the
operator’s app_settings row decides which concrete model fires there. Tuning
one step never moves another.
The tier API was an intermediate step toward this (it replaced the literals with
a five-tier ladder), but the ladder coupled unrelated steps: changing
cost_tier.standard.model moved the writer, the critic, title-gen, podcast
scripts, social copy, and image-search-query gen all at once. Per-step pins
decouple them — the granularity the ladder gave up.
Two decoupled axes — only one was removed
Model selection and provider selection are separate namespaces. Removing the tier→model bridge did not touch the tier→provider one:| Axis | Key shape | Status |
|---|---|---|
| tier → model | cost_tier.<tier>.model | Removed (PR #1907). Each step reads its own *_model pin instead. |
| tier → provider | plugin.llm_provider.primary.<tier> | Kept. dispatch_complete(..., tier="standard") still selects the provider for a call. |
dispatch_complete still takes a tier= kwarg — it routes to the provider
(ollama_native / litellm / …) configured for that tier. The model passed to
it now comes from the call site’s own pin, not from a tier→model lookup.
The contract: fail loud, no silent default
Every step resolves its pin and does not fall back to a hardcoded literal. Perfeedback_no_silent_defaults.md, an unset pin is a
configuration bug, not a quiet default. The reaction depends on whether the step
is on the critical content path:
- Critical steps (writer, critic, the LLM-judge QA rails, hygiene-summary
jobs) →
notify_operator(critical=True)+ raise. The pipeline halts loudly rather than degrade output quality silently. - Non-critical enhancers (title regen, the media/video director stages, the
image-gen prompt synthesiser) → log + soft-skip or return
None, so a missing optional pin degrades that one enhancement without killing the post. Several stillnotify_operator(critical=False)so the gap is visible.
Per-step pin inventory
Each model-selecting call site and the pin it reads. To move a step to a different model, set its pin — nothing else is affected.| Call site | Pin (app_settings key) | On unset |
|---|---|---|
ai_content_generator (writer) | pipeline_writer_model → pipeline_fallback_model | critic rejects everything → check the pin |
multi_model_qa critic + gate critics | pipeline_critic_model → qa_fallback_critic_model | notify(critical) + raise |
self_review.self_review_and_revise | writer_self_review_model | notify + raise |
title_generation (SEO title regen) | pipeline_writer_model | notify(advisory) + return None |
podcast_service (script generation) | podcast_script_model → default_ollama_model | notify + regex-script fallback |
image_service (search-query gen) | image_search_query_model | notify + raise |
image_decision_agent (image direction) | model_role_image_decision | notify + page |
image_providers.ai_generation (image-gen prompt synth) | image_prompt_model | soft fallback prompt |
video_service (image-gen slideshow prompt) | video_slideshow_prompt_model | notify(critical) + raise |
stages.generate_media_scripts | video_scene_model → default_ollama_model | skip stage (non-critical) |
stages.generate_video_shot_list / review_… | video_director_model → video_scene_model | skip stage (non-critical) |
jobs.collapse_old_embeddings | embedding_collapse_summary_model | notify(critical) + raise |
handlers.retention_summarize_to_table | memory_compression_summary_model | notify(critical) + raise |
social_poster (social copy gen) | social_poster_fallback_model | notify(critical) + raise |
ragas_eval (judge model) | ragas_judge_model | notify(critical) + raise |
deepeval_rails (judge model) | deepeval_judge_model | notify(critical) + raise |
ragas_eval, deepeval_rails) and the two
hygiene-summary jobs (collapse_old_embeddings, retention_summarize_to_table)
should point at a sub-writer-size model (e.g. ollama/phi4:14b, ~8 GB) so
background/advisory work doesn’t load the ~17 GB writer into VRAM. That contract
is pinned by tests/unit/services/migrations/test_hygiene_summary_model_rightsize.py.
What didn’t migrate (and why)
Some model references are deliberately not per-step pins:- Model-class detection — sites doing
is_thinking_model(model)branch on whether a model emits<think>…</think>blocks (a capability test), not a model choice. Routed throughservices.llm_providers.thinking_models, which readsapp_settings.thinking_model_substrings(a JSON array). - Reference / canonical-default tables —
cost_guard.py’s energy-per-1K-Wh table, each provider plugin’sdefault_model, andsettings_defaults.pyseed defaults. Already overridable per-row; not model-selection codepaths.
How operators tune the system
”I want the QA critic on a heavier local model”
”I want image-decision on a more accurate model"
"I want a cloud model on a pin (e.g. premium QA for high-stakes critic calls)”
Two steps — the model pin and the paid-endpoint opt-in. Both are required; the pin alone gets refused.-
Point the pin at a paid model.
cost_guard.py’s daily/monthly cap fires before the call, so you can’t accidentally blow the budget: -
Authorise paid endpoints. The LiteLLM router default-denies any non-local
model prefix (
anthropic/,openai/,gemini/, …) or non-localapi_baseperfeedback_no_paid_apis— so step 1 on its own raisesLiteLLMProvider refuses paid model prefix …. Open the gate by flipping the flat operator key:One effective key.
allow_paid_base_urlresolves from the flatplugin.llm_provider.<provider>.allow_paid_base_urlrow — what the seed, the refusal message, andpoindexter settings setall reference. The dispatcher’sget_provider_configfolds that row into the provider’s config, so you never have to hand-edit the nested JSONconfigblob. A value already inside that nested blob still wins if present (backcompat), but the flat row is the supported path. Swaplitellmforopenai_compatif that’s the provider configured for the tier.
plugin.llm_provider.primary.<tier> (the tier→provider axis, still in place).
Pointing a pin at a paid model is necessary but not sufficient — see the
checklist below for the two other switches a cloud call needs.
”I want a cloud model on any pin” — full enablement checklist
A*_model pin like anthropic/claude-sonnet-5 needs three things before the
first call succeeds. All three live in app_settings; nothing goes in
container env files.
-
Credential. Put the API key in the matching
is_secret=truerow —anthropic_api_key/openai_api_key/gemini_api_key. At startup the worker AND the Prefect flow subprocess runconfigure_cloud_api_keys(services/llm_providers/litellm_provider.py), which stamps the decrypted value into the*_API_KEYenv var LiteLLM auto-discovers. Empty rows are the normal local-only state and stamp nothing. -
Paid-endpoint gate. The provider refuses non-local model prefixes by
default. Opt in on the plugin config row’s nested
configobject (the flatplugin.llm_provider.litellm.allow_paid_base_urlrow is seeded for discoverability, but the provider reads the JSON row): -
Budget headroom.
daily_spend_limit_usd/monthly_spend_limit_usdare enforced before every paid dispatch (fails closed). Size them for the expected traffic or the first over-cap call raisesCostGuardExhausted.
max_tokens
get the cloud_max_tokens floor (default 8192, tunable on the same nested
config object). LiteLLM’s own Anthropic default is 4096, and
adaptive-thinking Claude models (Sonnet 5+) spend thinking + visible text from
that one budget — 4096 truncates long-form drafts mid-word. Local prefixes are
never capped by this.
”I want to see which model fired for which call”
Every call goes throughcost_logs (the LiteLLM cost-tracking path), which
records model, prompt_tokens, completion_tokens, cost_usd, and a
purpose column. The Cost dashboard in Grafana groups by purpose × model.
Reasoning models and structured extraction
Independent of model selection, two guards handle reasoning models (e.g.glm-4.7-5090) that can emit all their tokens into a thinking channel and return
an empty content field under JSON mode — which used to crash structured
extraction (json.loads("")):
-
Separate model for structured extraction.
resolve_structured_model()readsstructured_extraction_model(defaultgemma3:27b, a JSON-reliable instruct model) instead of the writer pin, so a reasoning writer doesn’t break extraction: -
Reasoning-content fallback in the provider. When
LiteLLMProvidergets an emptycontentit recovers the payload from the response’sreasoning_content(stripping any<think>wrapper). Toggle viaplugin.llm_provider.litellm.config.reasoning_content_fallback(defaulttrue). It triggers on the observed empty-content symptom, so it catches new reasoning models even before they’re added tothinking_model_substrings.
Related docs
docs/reference/app-settings.md— every settings key, including the per-step*_modelpinsdocs/architecture/services/litellm_provider.md— the provider that consumes the resolved model + tracks costdocs/architecture/anti-hallucination.md— the QA rails whose judge models are per-step pins