ADR-147: First-class vllm provider — real model ids + fail-closed served-model verification¶
- Status: Accepted
- Date: 2026-08-02
- Authors: Marko Dragoljevic, Claude (Opus 4.8)
- Related: ADR-044 (local LLM backend
abstraction), ADR-143 (reproducible
single-variable corpus arc — the reason this matters now), ADR-124
(registry-sanctioned model governance); agentic-ai-homelab
infra/vllm/autoresearch/(the compose that owns--served-model-name)
Context¶
DGX-served models reach the pipeline over vLLM's OpenAI-compatible HTTP API on :8003. Rather
than a first-class provider, that path was bolted onto the existing openai provider: a
StageOption sets provider="openai" with the endpoint pointed at vLLM. The homelab starts vLLM
with --served-model-name autoresearch — a stable alias so repo config need not change when
the slot's weights swap — so every DGX StageOption sets model="autoresearch", and the real
weights survive only in a comment / extra_settings.underlying_hf_model.
Three concrete defects (verified against the tree, branch feat/naming-arc-and-corpus-prep):
- The registry does not govern the wire today — renaming it alone changes nothing. The
provider consumes
openai_api_base/openai_summary_model, and in the DGX profiles those are hand-authored above theregistry-materialized — do not hand-editdivider (config/profiles/prod_dgx_full.yaml). The materializer emitssummary_model/summary_endpoint(model_registry.pyresolver,_emit), butsummary_endpointis not a Config field andmaterialize_profiles.pydeliberately drops non-Config resolver keys; the materializedsummary_model: autoresearchis decorative — read only by metadata provenance (workflow/metadata_generation.py). So the wire model + endpoint are governed by nothing*. - The alias hides identity → reproducibility is violated. Even the hand-authored key reads
autoresearch, so you cannot read a profile and know what produced the corpus; the only pin is a human loading the right compose. Directly violates ADR-143's single-variable rule. - The abstraction is asymmetric. Ollama options self-describe (
provider="ollama",model="qwen3.5:35b"); vLLM options name a transport (openai) and an alias (autoresearch). The layer types by wire protocol, not serving stack, so vLLM has no identity.
autoresearch is the historical name of the single GB10 vLLM slot (born for judge/scoring runs),
leaking into production model identity. The slot is GPU-mode-swapped to serve whatever weights are
loaded — sometimes a judge, sometimes the production naming/summarization model. The v2.5 corpus
arc (Gemini → DGX-local LLM swap) makes a reproducible, self-describing, governed representation
a prerequisite (Step 0), not a nicety.
Decision¶
Make the serving stack a first-class provider dimension. Name + govern real models on the wire.
Verify the served model fail-closed at init. Take every DGX LLM stage local together. Delete
autoresearch from production config via a phased migration.
- New
vllmprovider — a distinct sibling ofopenai, not a subclass. With vLLM we serve a wide family of non-OpenAI open models (Qwen, DeepSeek, Llama, …);openaiis reserved for OpenAI-native models. They share only a wire protocol, so the OpenAI-compatible transport is factored into a common base (OpenAICompatibleProvider) that bothOpenAIProviderandVLLMProviderextend as siblings.VLLMProviderowns avllm_*config namespace. Sequencing (the extraction is a real refactor, not a lift):OpenAIProvideris ~2,780 lines with theopenai_*namespace +provider="openai"telemetry baked into nearly every method, plus OpenAI-native heuristics (sk-key check,o1/o3/gpt-5temperature rules). Land the config-namespace parameterization (self._ns→f"{ns}_summary_model", threadedprovider_nametelemetry) as its own zero-behaviour-change commit with existing tests green, then introduceVLLMProvider. - Registry governs the wire with real ids (closes defect 1). Add Config fields
vllm_api_base,vllm_summary_model,vllm_speaker_model,vllm_api_key_env; add them toREGISTRY_GOVERNED_FIELDS; add_emit_summary_model/_emit_speaker_model-style routing (precedent:_emit_transcription_model) so the materializer writes the provider-consumed keys, killing the hand-authored block. Materialize the endpoint in template form (http://${DGX_TAILNET_HOST:-…}:8003/v1), neverresolve_endpoint()output (which bakes the materializer-runner's env or a fail-fast sentinel into the YAML). DGX StageOptions becomeprovider="vllm",model="<real HF id>"(e.g.NVFP4/Qwen3-30B-A3B-Instruct-2507-FP4);underlying_hf_model+autoresearchare removed. - Every producing LLM call goes DGX-local — one model choice across the whole series (closes
B1, the corpus-corrupting one). Single-variable (ADR-143) means one LLM choice applied
across every producing call, not one call: the single DGX model replaces Gemini for
summary, naming/labeling (
ner), GI insight, KG extraction, and quote/entailment grounding — no cloud LLM is left in a DGX-local corpus. Summary, GI-insight, and KG already ride the summary provider (gi_insight_source/kg_extraction_source: provider), so they follow automatically; the stages that must be explicitly flipped in the DGX profiles arespeaker_detector_provider(naming, todaygemini),quote_extraction_provider, andentailment_provider(grounding, todayopenai). Left unflipped, the grounding stages build a separateOpenAIProviderwith noopenai_api_base→ api.openai.com +gpt-4o-minisilently produces the "DGX-local" grounding (gi/deps.pyinstance-reuse breaks under the split). Addvllmto the evidence-provider enum +GIL_EVIDENCE_ALIGN_SUMMARY_PROVIDERS+ thedeps.pymatch; add a test that a vllm-summary profile builds zero cloud-OpenAI clients. The in-pipeline value-gate judge also goes local (operator, 2026-08-03 — see the As-built amendment; this REVERSES the earlier "cross-vendor cloud ≥Sonnet judge" decision). A DGX profile must consume nothing from the internet, so the value gate self-grades with the same local model as the extractor (vllm/ollamajoin_LOCAL_ONLY_LLM; no cloud judge is pinned). The #939 self-grading leniency is the accepted cost of airgapping — the gate still trims the clear filler, just conservatively; a distinct second local judge (still airgapped) is a future autoresearch evaluation point. Because one model now drives many stages, the parity gate (the separate autoresearch scorer, not this in-pipeline gate) must evaluate per stage / multi-perspective (summary, naming, GI, KG), not summary alone. - Fail-closed verification at provider init (closes B3). The cost-telemetry
served_modelcapture never fires on the DGX path (the summary cost event is gated oncost>0and the vLLM slot is priced$0). Instead, onVLLMProviderconstruction,GET /v1/modelsand assert the configured id ∈ the served-id set — reusing the existing pattern inonboard_model_smoke.pyand the normalization inverify_served_model(casefold + dated-suffix tolerance; do not strip the org prefix). Raise a config error on mismatch. A cheap per-response check may follow, but the startup set-membership check is the robust seam. - Pricing + governance renamed in the same commit (closes B4). A CI guard fails PRs that
reference a model with no pricing row. Add a
vllmsection (real ids,$0) to bothconfig/pricing_assumptions.yamlandsrc/podcast_scraper/data/pricing_assumptions.yaml, and to bothknown_models.yamlcopies; state the governance decision for local weights (whethervllmjoinsgoverned_providers); removeautoresearchfrom theopenailists. - Naming (
ner) becomes provider-symmetric (S4).speaker_detector_provideracceptsvllm; add avllm_speaker_modelfield + emission (the OpenAI speaker path readsopenai_speaker_model, notner_model, so a bare StageOption reaches nothing) and avllm_speaker_detectorStageOption pointing at the DGX real model. Representation-only until the naming bake-off picks the model.
Full enum/dispatch surface (S2) — every site that must learn vllm:
_validate_summary_provider, _validate_evidence_providers, and the speaker_detector_provider Literal (config.py);
summarization/factory.py (+ its error string); speaker_detectors/factory.py; the two
llm_providers sets in workflow/helpers.py; _cleaning_model_for_summary_provider
(with vllm_cleaning_model defaulting to the served model, not gpt-4o-mini);
resolve_value_gate(sm.provider); the metadata-provenance branch (so the sidecar records the model —
non-negotiable for a reproducibility ADR); run-suffix naming (cosmetic).
Migration plan (phased — mandatory, S3)¶
A hard cutover breaks reruns forever: frozen data/eval/configs/ (never-mutate) and live sweep
drivers / judges (evaluation/judges/vllm_chat.py) reference autoresearch.
- Phase A. Homelab serves both names
[<real-id>, autoresearch], real id first (/v1/models data[0].idis the first entry; the init check keys off set-membership so order is safe, but old smoke configs assertingdata[0]will fail during transition — expected). - Phase B. Land this repo's change requesting the real id; migrate live (non-frozen) eval
tooling off the alias (
onboard_model_smoke.pydefault, sweep drivers, judges). - Phase C. Drop the alias. Frozen
data/eval/configs/referencingautoresearchare accepted as retired (never-mutate → not rewritten); new real-id configs are created downstream. We do not keep serving the alias to preserve old reruns. - Unverified, verify on the DGX first: what
response.modelechoes under multi-name serving on the pinnedvllm:26.05build. If per-call equality is chosen it must match empirically; the startup set-membership check does not depend on it.
Consequences¶
Positive: a profile is self-describing and governed — summary_provider: vllm +
vllm_summary_model: <real id> is the wire truth, enforced (fail-closed) not trusted; grounding
stays local (no silent cloud leak); vllm/ollama are symmetric stacks; openai stops
double-serving as a covert vLLM shim.
Negative: large surface — a namespace refactor of a 2,780-line provider, ~9 dispatch/enum
sites, 4 pricing/governance files, endpoint-materialization plumbing, and the GI grounding flip.
Cross-repo phased migration (homelab + repo, three phases); losing the floating alias means a
homelab model swap now requires a matching registry/profile edit (the point, but friction); frozen
eval configs pin autoresearch, so the alias can only be retired for live tooling, not deleted.
Neutral: the OpenAICompatibleProvider extraction is a config-namespace parameterization
threaded through every cfg read, sequenced as its own no-behaviour-change commit; the existing
non-DGX openai (cloud) path is untouched.
Alternatives considered¶
- Keep
provider="openai", only replace theautoresearchstring with the real id. Less code — but keeps the transport-vs-stack conflation the operator rejected; the YAML still lies about the stack, and (per defect 1) the string is hand-authored, so it still isn't governed. - Keep the floating alias, record the real id in a comment + verify at runtime. The wire still cannot name the model and a reader still cannot trust the profile. Rejected — the convenience is exactly what broke reproducibility.
- A generic
openai_compatibleprovider keyed by base_url (covers vLLM + Ollama-OpenAI + cloud in one branch). Erases the stack identity the operator wants surfaced; muddies per-stack model governance. Rejected. - Composition instead of a shared base (a
vllmclass translatingvllm_*→openai_*into an internal transport). Materially cheaper than the base extraction, but contradicts the locked "common base, siblings" decision. Noted as a cost, not re-litigated.
References¶
model_registry.py—StageOption, DGX options, resolver/_emit*,REGISTRY_GOVERNED_FIELDS,resolve_endpointopenai_provider.py— the transportVLLMProviderextraction is factored from; OpenAI-native heuristics to keep out of the basesummarization/factory.py,speaker_detectors/factory.py— provider dispatchgi/deps.py— the quote/entailment instance-reuse that B1 turns onconfig.py— provider enums, env-presence validators,vllm_*fieldsconfig/+src/podcast_scraper/data/—pricing_assumptions.yaml,known_models.yaml(both copies)workflow/helpers.py,workflow/metadata_generation.py,scripts/eval/onboard_model_smoke.py,evaluation/judges/vllm_chat.py,materialize_profiles.py- agentic-ai-homelab
infra/vllm/autoresearch/docker-compose.yml— owns--served-model-name
As-built amendment (2026-08-03)¶
Implemented across commits (ADR 56fa7bc4 → B3 763ab4d0). Two operator decisions during
implementation extended/changed the plan:
- Fully airgapped DGX profiles. Beyond swapping the producing LLMs, the operator required the DGX profiles to consume nothing from the internet — every LLM stage AND every fallback is DGX-local. As built (prod_dgx_full, eval_default):
- summary / naming / GI / KG / quote / entailment →
vllm(real Qwen id); - summary fallback → DGX-local ollama (
:11434), not cloud gemini; - transcription → DGX-whisper + local in-process whisper + MOSS coverage failover (no cloud Whisper); diarization → local pyannote (no cloud deepgram);
- a test asserts all three load with zero cloud API keys and hold no cloud provider anywhere.
- Value gate self-grades local — reverses the §3 cross-vendor-cloud-judge decision (see §3).
Ollama symmetry (now FULL). vllm and ollama are the two DGX-local serving stacks and are
fully symmetric: both first-class providers, both self-grade the value gate (_LOCAL_ONLY_LLM),
both price at $0, neither cloud-governed, ollama is the airgapped summary fallback, AND ollama's
wire config (ollama_summary_model / ollama_speaker_model / ollama_api_base) is now
registry-governed + materialized like vllm's — whether ollama is the primary (experiment_dgx_only)
or the airgapped summary fallback (_emit_fallback_chains emits the fallback ollama option's wire
config). The blocker that had deferred this — an LLM naming StageOption's model being a spaCy
id, not the LLM tag — is resolved by splitting the two: model = the spaCy ner_model, and the
LLM tag lives in extra_settings['speaker_llm_model'] → {ns}_speaker_model, identically for vllm
and ollama. This also fixed a latent bug: the vllm naming option had been leaking its Qwen HF
id into ner_model (a spaCy field) — spacy.load() would have crashed if the entity stage ran.
Phased homelab migration (S3) — NOT YET DONE. The registry/profiles now request the real HF id
on the wire; the homelab vLLM must serve under that --served-model-name (dual-name transition)
before a live run passes the B3 check. Prepared as a handoff, not deployed — see the session
handover in docs/wip/.