ADR-100: Response-shape guardrails for cloud LLM providers¶
- Status: Accepted
- Date: 2026-06-15
- Authors: Podcast Scraper Team
- Related ADRs: ADR-105 (the self-hosted sibling; this ADR extends the same pattern to cloud APIs)
- Related issues: #1003 (this implementation), #999 (the self-hosted precedent), #1002 (threshold fine-tuning follow-up, shared with ADR-099)
Context¶
ADR-099 shipped response-shape guardrails for self-hosted inference services
(Ollama, vLLM, whisper-openai, pyannote), closing the gap that
providers/resilience (connection-level) cannot detect: a successful HTTP
response whose content is semantically corrupted.
The operator observed during the ADR-099 implementation:
"as we work on this I am wondering if we should also implement this type of guardrails on my API LLM providers. you never know when one can hallucinate?"
The answer is yes. The cloud LLM providers we use (OpenAI, Anthropic, Gemini, DeepSeek, Mistral, Grok) have the same structurally-invalid-response failure modes as the self-hosted ones — we have direct evidence in our own eval data:
- Gemini-2.5-flash with thinking mode: wildly inconsistent short outputs
during
EVAL_PROMPT_LONG_V2_CROSS_PROVIDER_2026_06_14— had to switch togemini-2.5-flash-lite(production default) for stable measurements. - OpenAI / gpt-4o: hits
finish_reason="length"when prompts spill the budget. Surfaces as malformed JSON / mid-truncation at the next stage. - DeepSeek: 3-2 split on
long_v2in the cross-provider sweep with inconsistent shape — possible underlying response-shape variability.
The guardrail pattern from ADR-099 is provider-agnostic. The
check_chat_response(content, *, service, finish_reason=None, expect_json=False)
helper introduced in the precursor refactor was deliberately written for any
chat-completion-shaped API. The failure modes (empty, thinking-prose,
finish-reason-length, malformed JSON) are the same across self-hosted Ollama
and cloud OpenAI. Only the SDK extraction differs per provider.
This ADR captures the decisions for extending the pattern to cloud.
Decision¶
Wire the same providers.guardrails.check_chat_response() helper at the
content-producing call sites of the four mainstream cloud LLM providers —
OpenAI, Anthropic, Gemini, DeepSeek — using the failure-handling pattern
established in ADR-099 (raise GuardrailViolation, caller's existing
exception path triggers fallback via the configured
degradation_policy.fallback_provider_on_failure).
1. One generic helper, kwarg-based service identifier¶
check_chat_response(content, *, service="openai") works for every
chat-completion provider. The service kwarg becomes the Prometheus label
and the GuardrailViolation.service attribute. Pick a fixed short string
per provider — "openai" / "anthropic" / "gemini" / "deepseek" /
"mistral" / "grok" — and never embed deployment details
(no "openai-via-azure", no "gemini-prod").
No per-provider helper functions get added. Same code, different SDK extraction at the call site.
2. SDK extraction stays at the call site¶
Each cloud provider's SDK has its own response shape:
| Provider | Content path | Finish reason path |
|---|---|---|
| OpenAI | response.choices[0].message.content |
response.choices[0].finish_reason |
| Anthropic | response.content[0].text |
response.stop_reason |
| Gemini | response.text |
response.candidates[0].finish_reason (when present) |
| DeepSeek | response.choices[0].message.content |
response.choices[0].finish_reason |
| Mistral / Grok | OpenAI-compatible | same |
The caller extracts content and finish_reason first, then passes them to
the helper. Keeps the helper free of SDK dependencies (and free of the
breakage risk that SDK updates introduce).
3. Failure handling per stage, not per provider¶
ADR-099 routes self-hosted violations to the existing cloud fallback path (transparent, single policy). Cloud calls differ — falling up to "the next cloud provider" costs the operator money. The policy is per-stage:
| Pipeline stage | Default behaviour on guardrail violation |
|---|---|
Summary (summarize) |
Fail-up to FallbackAwareSummarizationProvider's configured degradation_policy.fallback_provider_on_failure. Same path already triggered by connection-level failures. |
Cleaning (clean_transcript) |
Graceful degradation — return the original transcript. Mirrors the existing "if not cleaned: return text" pattern. Empty content is fine; thinking-prose markers in cleaning output are caught and fall to the original. |
| GI insights | Fail-up. The stage either produces structured insight data or skips with a logged degradation. |
| KG extraction | Fail-up. Same rationale as GI. |
| Speaker detection | Fail-up to spaCy local fallback if configured; otherwise skip the LLM-detected speaker enrichment and rely on transcript-side defaults. |
Per-provider overrides are explicitly out of scope. The failure behaviour is owned by the stage's contract (does the downstream need non-empty output?), not by who answered the call.
4. Cost attribution via llm_cost log event extension¶
Cloud-side guardrail violations have a property self-hosted ones don't: we paid for the response we then rejected. Tracking that is operationally material because:
- A cloud provider trending toward more guardrail violations is silently burning the operator's API budget.
- A fallback chain (Gemini fails → OpenAI takes over) means a single episode can incur charges from multiple providers; the operator wants to see that.
The existing llm_cost JSON log event already carries per-call cost data.
Extend it with a triggered_guardrail boolean field (default false;
set true only on the call that just raised GuardrailViolation). The
existing corpus-cost rollup picks this up automatically; new dashboards
can split paid-but-rejected spend without any new infrastructure.
No new Prometheus counter for cost-tracking. The cost data is already in the log pipeline; pivoting on the new field is a downstream concern.
5. Telemetry: extend the existing counter, careful with cardinality¶
The Prometheus counter introduced in ADR-099 is
inference_guardrail_violations_total{service, reason}. Cloud violations
land on the same counter with service set per-provider. Cardinality math:
| Source | service values | reason values per service | Series added |
|---|---|---|---|
| Self-hosted (ADR-099) | 4 (whisper, ollama, vllm, pyannote) | ~2-3 each | ~10 |
| Cloud (this ADR) | 4 mainstream (openai, anthropic, gemini, deepseek) | ~4 each (empty, thinking-prose, finish-length, bad-json) | ~16 |
| Total active series | ~26 | well under the 175-series DGX-side budget; <1% of Grafana free-tier 10k cap |
The Mistral / Grok providers get the same helper when they next get
PR work; estimated +3 × 4 = 12 series later, still well within budget.
Cardinality discipline enforced by code review (same as ADR-099): only
service and reason are labels, both drawn from a fixed per-provider
enum. Never add high-cardinality fields (request ID, model name with
revision, response content) as Prometheus labels — those go in the
structured log body / Sentry context.
6. Refusal detection deferred to Phase 2¶
A potential 5th failure mode — cloud LLMs returning refusal text ("I can't help with that", "I'm not able to...", "As an AI...") — was considered for Layer 1. Deferred.
- The false-positive risk is meaningful. A summary of a podcast about AI ethics legitimately includes phrases like "as an AI" without the response being a refusal.
- We have no measured cases of refusals tripping downstream issues today.
- Pattern matching the refusal openers requires per-provider tuning that ADR-099's "fixed thresholds, no configurability" rule discourages.
Filed as Phase 2 scope on #1003. If an operator-observed case surfaces where a cloud refusal silently propagated downstream, that's the trigger to revisit.
Why this layer, not a different one¶
Considered alternatives:
| Alternative | Why rejected |
|---|---|
Per-provider check helpers (check_openai_response, etc.) |
Duplicate code; the failure modes are identical across all chat-completion APIs. The kwarg-service pattern keeps the helper count at 1. |
New separate cloud_guardrails/ module |
Splits a tiny amount of logic across two modules. The check is provider-agnostic — providers.guardrails.chat is its right home. |
| Configurable thresholds per provider | Same fail-closed argument as ADR-099. Threshold fine-tuning lives in #1002 (evidence-based, in PR, with rate-data screenshots). |
| Provider-side proxies that rewrite bad responses | Out of scope; we don't control cloud providers and shouldn't pretend to. |
Consequences¶
Positive¶
- The guardrail layer covers every LLM call (self-hosted and cloud) under one mental model + one helper.
- Adding the next cloud provider becomes mechanical: 4 call-site changes, pick a service string, done. No new helper code.
- The same Prometheus counter, the same log shape, the same Sentry exception class. Downstream dashboards don't fork by provider.
- The cost-attribution extension makes cloud-side guardrail spend visible with no new infrastructure.
Negative¶
- Cloud-fallback-on-violation costs money. A provider trending toward frequent violations doubles its bill (paid for the bad response, then paid the fallback provider for the retry). Mitigated by the cost log field — operator can spot the trend and switch defaults.
- Adding
providercardinality to the counter takes us from ~10 to ~26 series. Still tiny but worth pinning at this size; #1002 owns the per-(service, reason) firing-rate evaluation.
Risk¶
- Risk: the cleaning-stage graceful-degradation pattern misses a case where cloud returns thinking-prose-content as the "cleaned" result. Same risk we faced with Ollama in ADR-099, handled the same way: the guardrail call happens AFTER the empty-handling early return, catching the thinking-prose case without false-firing on legitimate empty.
- Risk: Anthropic's
content[0].textshape may not exist if the response has no content blocks (rare, but possible). The caller defends withgetattr(...)and treats absence as empty content — the helper handlesNonecorrectly via the empty-content path.
Non-goals¶
- No refusal detection in this ADR. Phase 2 of #1003.
- No reasoning-token tracking (some thinking models return reasoning in a separate field; that's a different observability question, not a guardrail concern).
- No provider-specific threshold knobs. Per-stage failure-handling is the configurability axis; thresholds remain hardcoded per ADR-099.
- No SDK-version compatibility shims. If a cloud provider's SDK changes its response shape, the call-site extraction breaks loudly rather than silently. That's preferred — silent guardrail bypass is the failure mode this whole pattern exists to prevent.
Initial thresholds (locked at ship time)¶
Same as ADR-099 — the helper is the same. Per-mode thresholds:
| Failure mode | Threshold | Notes |
|---|---|---|
| Empty content | content is None or content == "" |
Hard fail across all providers. |
| Thinking-prose markers | content[:200] contains <think> / Okay, so I need to / Let me think |
First 200 chars only — avoids false-positive on transcripts that quote the marker later in the body. |
| Finish reason length | finish_reason == "length" |
Indicates the model truncated mid-output. Structurally incomplete. |
| Bad JSON when expected | expect_json=True + json.loads(content) raises |
Only when caller passes expect_json=True. |
Fine-tuning these against observed firing-rate data is tracked in #1002 (shared with ADR-099 — same thresholds apply, same evidence loop).
Acceptance for the implementing PR (#1003) — closed 2026-06-15¶
- [x] OpenAI, Anthropic, Gemini, DeepSeek provider call sites wire
providers.guardrails.check_chat_response(content, service=...)at the content-producing stages (summary, cleaning, GI, KG, speaker as applicable). Shipped in#1003(b0ee6c58). - [x] Mistral, Grok providers wired the same pattern (2026-06-15
close-out,
5942c637). - [x] Cost-attribution:
llm_costlog event extended withtriggered_guardrailboolean field. Lives atworkflow/cost_monitoring.py:61and is set in both happy and violation branches per provider. - [x] Tests at the unit tier confirm the helper fires per-service for the
4 failure modes (
tests/unit/podcast_scraper/providers/test_cloud_guardrails_wiring.py); E2E tests confirm the consumer-side fallback triggers per stage (tests/e2e/test_cloud_guardrails_fallback_e2e.py). - [x] Mock-server injection (from #999) reused for cloud E2E
(
tests/e2e/test_cloud_guardrails_e2e.py). - [x] ADR-100 referenced from the implementation site (Gemini / DeepSeek / OpenAI / Anthropic / Mistral / Grok provider modules).
Post-implementation updates (2026-06-15)¶
The implementing PR (#1003, commit b0ee6c58) closed the design as
specified above, but two facts surfaced during E2E validation that the
original draft assumed away. Both are now in the code; this section is
what a future reader needs to know.
A. The except GuardrailViolation: raise clause¶
The design assumed GuardrailViolation would propagate naturally to the
FallbackAwareSummarizationProvider layer because "callers don't catch
unknown exception types." That assumption was wrong for the cloud
providers as written. Each provider's summarize() has a broad
except Exception as exc that wraps everything into
ProviderAuthError / ProviderRuntimeError for the
operator-facing error-classification system. GuardrailViolation got
silently re-typed and never reached the fallback layer.
The fix is one clause at every call site that calls
check_chat_response:
except _guardrails.GuardrailViolation:
raise # ADR-100: let FallbackAware see the raw type, don't wrap
except Exception as exc:
# existing error-classification block
Applied at: summarize on OpenAI / Anthropic / Gemini / DeepSeek. The
test that caught it was test_cloud_guardrails_e2e.py — the unit-level
wiring tests passed because they call the helper directly, not through
the provider's broad except. The cloud E2E was the only test that
exercised the full SDK → broad except → caller path.
The self-hosted whisper / diarize providers from ADR-099 already had this clause (they were written knowing the wrap risk); the cloud providers were not, because cloud providers' existing exception path predates the guardrail design.
B. Cleaning policy: catch-and-degrade, not propagate¶
Section 3 above specifies cleaning as "graceful degradation — return
the original transcript." The implementation lands that with an
explicit except _guardrails.GuardrailViolation: return text clause
in each provider's clean_transcript. The summarize and
GI / KG sites take the opposite policy (re-raise) because their
contracts demand non-empty output. Don't paste-and-modify between
the two patterns — they look identical but the trailing action
(raise vs return text) is the contract.
C. Mock-server injection: extended to per-provider routes¶
#999 shipped inject_violation for /v1/chat/completions,
/v1/audio/transcriptions, /v1/diarize, /api/generate. The
Anthropic and Gemini cloud providers use their own paths, so #1003
extended the injection vocabulary:
| Route | Violation types |
|---|---|
/v1/messages (Anthropic) |
anthropic:empty_content, anthropic:thinking_prose, anthropic:max_tokens |
/v1beta/generateContent (Gemini) |
gemini:empty_content, gemini:thinking_prose, gemini:max_tokens |
The *:max_tokens entries fed the per-SDK normalization that landed
in the close-out: Anthropic's "max_tokens" and Gemini's "MAX_TOKENS"
are now normalised to "length" at the per-SDK boundary
(_anthropic_finish_reason() and _gemini_finish_reason()). The
helper stays service-neutral. Unit tests
(test_cloud_guardrails_wiring.py::TestAnthropicWiring::test_max_tokens_stop_reason_normalised_to_length
and TestGeminiWiring::test_finish_length_fires_gemini_violation)
prove the trip.
D. Cost-attribution: emitted at every summarize site¶
emit_llm_cost_event(... triggered_guardrail: bool = False) lands in
workflow/cost_monitoring.py. The plumbing flows through
record_provider_call_cost so each provider's summarize site emits
the cost event in BOTH branches: happy path
(triggered_guardrail=False) and the except GuardrailViolation
block (triggered_guardrail=True) before re-raising. Token data is
captured up-front so the cost shape is identical between branches.
Wired at: OpenAI, Anthropic, Gemini, DeepSeek, Ollama summarize.
Scoped deliberately to summarize, not bundled / GI / KG / cleaning —
summarize is the load-bearing call for paid-but-rejected spend
attribution (largest cost per call); the smaller call sites have the
guardrail wired and the cost field exists for them, they just don't
emit-with-flag in their except blocks. Marked as a known scope
decision, not a deferred follow-up, in the original resilience-gap
audit (retired WIP doc; findings folded into this ADR).
E. Resilience-coverage audit + close-out¶
A second user-requested follow-up audit during the same review
flagged that prior to #1003, no cloud LLM provider had E2E
resilience coverage via the mock server's set_error_behavior
hooks. The matrix and the original gaps are documented in the
retired resilience-gap audit (WIP doc, now removed). All 5 gaps in
that doc closed on 2026-06-15:
| Gap | Closed by |
|---|---|
| Transient-5xx retry-recovery, cloud | TestTransient5xxRetryRecovery in test_cloud_resilience_e2e.py (OpenAI / Anthropic / DeepSeek). |
| Request timeout, all 4 cloud providers | TestRequestTimeoutE2E in test_cloud_resilience_e2e.py; Gemini's HttpOptions.timeout plumbing landed in the same batch. |
| FallbackAware under real GuardrailViolation | test_cloud_guardrails_fallback_e2e.py — primary trips guardrail, configured fallback's response reaches the caller. |
Cost-event triggered_guardrail=True integration |
See §D above. |
| Ollama resilience coverage | test_ollama_guardrails_and_resilience_e2e.py — guardrail empty / thinking-prose, cleaning catch-and-degrade, permanent 5xx, transient 5xx, request timeout. |
F. retry_with_metrics no longer retries on GuardrailViolation¶
Surfaced during close-out: bundled / GI / KG call sites wrap their
SDK call in retry_with_metrics(_make_api_call, ...). The default
retryable_exceptions=(Exception,) would have made the closure's
GuardrailViolation retry 3× before propagating — wasteful (the
response will not become non-empty by retrying) and semantically
wrong. utils/retryable_errors.py::is_retryable_error now returns
False for GuardrailViolation early, so retry loops surface the
type immediately to the caller's except clause.
G. Ollama cleaning passthrough¶
Surfaced during close-out: Ollama's clean_transcript had no
except _guardrails.GuardrailViolation clause (the cloud providers'
cleaning paths did, after the original #1003 wiring). The same
wrap-into-ProviderRuntimeError trap applied. Fixed; new E2E
test_cleaning_thinking_prose_degrades_gracefully proves the
catch-and-degrade path returns the original transcript.
References¶
- ADR-099 — the self-hosted precedent
- ADR-096 — the cloud fallback contract this builds on
- Originating eval reports:
docs/guides/eval-reports/EVAL_PROMPT_LONG_V2_CROSS_PROVIDER_2026_06_14.md(Gemini flash inconsistency, DeepSeek mixed)docs/guides/eval-reports/EVAL_REAL90_2026_06.md(qwen3.5 thinking-budget; Ollama precedent but same shape across cloud thinking models)- Code:
providers/guardrails/chat.py(the helper)providers/guardrails/exceptions.py(GuardrailViolation)summarization/fallback.py(FallbackAwareSummarizationProvider)- Tracker: #1002 (threshold fine-tuning; shared) EOF