test(providers): a conformance suite every adapter must pass, plus a monthly canary (#359) #493

Merged
claude-bot merged 4 commits from feat/359-provider-conformance into main 2026-09-05 02:25:21 +00:00
Contributor

Closes #359.

An abstraction with one real implementation is not an abstraction. This adds the suite that defines the bar for an adapter, runs it in CI against deterministic stubs and the local WhisperX adapter, documents a live mode, and adds a monthly canary through the configured (self-hosted fallback) path.

What

  • tests/conformance/ — parametrised over adapter cases (cases.py), not copied per provider: 15 ASR cases (submit→poll track_id passthrough, association by id not order, no speaker field anywhere, monotonic word spans inside their segment, word_timestamps declaration agrees with the words and the degraded flag, vocabulary accepted and truncated to max_vocabulary_terms in the actual request, declared byte/duration limits enforced with zero requests made, needs_chunking consistent, 5xx → typed error, 429 → typed rate-limit error with Retry-After parsed, empty transcript is not an error, a concurrency limit is declared) and 10 LLM cases (well-formed capabilities, schema spelling matches _apply_json_schema, read_usage normalises each provider's shape, truncated structured raises / truncated prose returns flagged, oversized prompt warned-and-still-sent pinning #351's deliberate choice, provider error surfaces, prose round trip). Adapters passing in CI: local_whisper against a Whisper wire fake, stub, stub_async, and all four LLM providers against their wire fakes. 88 passed / 10 skipped (all named and intentional) / 1 xfail — the xfail is the #356 enforcement case, left visible rather than absent.
  • What the suite found and fixed in the adapters: LocalWhisperProvider raised a bare RuntimeError for every failure (429 indistinguishable from 503) → new app/providers/errors.py with ProviderError(RuntimeError), ProviderRateLimitError(retry_after), ProviderInputTooLargeError, derived from RuntimeError so existing handlers keep catching them; max_audio_seconds/max_bytes were declared and never checked → enforce_input_limits() runs first in every submit. A second adapter, StubAsrProvider (app/providers/stub.py), takes the opposite branch wherever the contract allows (measured word timestamps, keyterm vocabulary, declared limits, async poll), so the contract is no longer a description of one implementation. Deliberately not registered as selectable.
  • Live mode: pytest tests/conformance --live with QB_CONFORMANCE_* env vars (skips, never fails, when unset; refuses to run under CI). make conformance-live.
  • Monthly canary: app/tasks/canary_tasks.py::provider_canary, beat every 30 days, a minimal round trip through the configured ASR and LLM, result written to app_settings key provider_canary ({ran_at, asr_ok, llm_ok, asr_provider, llm_provider, error}; None = not configured, so an unconfigured side is not a red light). WARNING on failure. 10 tests.
  • Docs: docs/DEVELOPMENT.md "Adding a provider adapter"; OPERATIONS notes the canary.

Rebased onto main after #355 and #358 (changelog conflict, all entries kept).

Known gaps, stated

  • The four _structured_* LLM transports still raise a bare RuntimeError; the case carries that expectation with a comment and tightens by editing one line once they are typed (out of the file whitelist for this build).
  • Live-mode word assertions skip against a silence probe; real coverage there wants a committed speech fixture (#433 territory).
  • Canary status is not on the bot-settings response yet; the constant is exported for the capability panel to read.

Verification

  • Full backend suite: 1814 passed, 10 skipped, 1 xfailed. ruff clean. Version sync OK.

🤖 Generated with Claude Code

Closes #359. An abstraction with one real implementation is not an abstraction. This adds the suite that defines the bar for an adapter, runs it in CI against deterministic stubs and the local WhisperX adapter, documents a live mode, and adds a monthly canary through the configured (self-hosted fallback) path. ## What - **`tests/conformance/`** — parametrised over adapter *cases* (`cases.py`), not copied per provider: 15 ASR cases (submit→poll `track_id` passthrough, association by id not order, no speaker field anywhere, monotonic word spans inside their segment, `word_timestamps` declaration agrees with the words and the degraded flag, vocabulary accepted and truncated to `max_vocabulary_terms` in the actual request, declared byte/duration limits enforced with zero requests made, `needs_chunking` consistent, 5xx → typed error, 429 → typed rate-limit error with `Retry-After` parsed, empty transcript is not an error, a concurrency limit is declared) and 10 LLM cases (well-formed capabilities, schema spelling matches `_apply_json_schema`, `read_usage` normalises each provider's shape, truncated structured raises / truncated prose returns flagged, oversized prompt warned-and-still-sent pinning #351's deliberate choice, provider error surfaces, prose round trip). Adapters passing in CI: `local_whisper` against a Whisper wire fake, `stub`, `stub_async`, and all four LLM providers against their wire fakes. **88 passed / 10 skipped (all named and intentional) / 1 xfail** — the xfail is the #356 enforcement case, left visible rather than absent. - **What the suite found and fixed in the adapters**: `LocalWhisperProvider` raised a bare `RuntimeError` for every failure (429 indistinguishable from 503) → new `app/providers/errors.py` with `ProviderError(RuntimeError)`, `ProviderRateLimitError(retry_after)`, `ProviderInputTooLargeError`, derived from `RuntimeError` so existing handlers keep catching them; `max_audio_seconds`/`max_bytes` were declared and never checked → `enforce_input_limits()` runs first in every `submit`. A second adapter, `StubAsrProvider` (`app/providers/stub.py`), takes the opposite branch wherever the contract allows (measured word timestamps, keyterm vocabulary, declared limits, async `poll`), so the contract is no longer a description of one implementation. Deliberately **not** registered as selectable. - **Live mode**: `pytest tests/conformance --live` with `QB_CONFORMANCE_*` env vars (skips, never fails, when unset; refuses to run under `CI`). `make conformance-live`. - **Monthly canary**: `app/tasks/canary_tasks.py::provider_canary`, beat every 30 days, a minimal round trip through the configured ASR and LLM, result written to app_settings key `provider_canary` (`{ran_at, asr_ok, llm_ok, asr_provider, llm_provider, error}`; `None` = not configured, so an unconfigured side is not a red light). WARNING on failure. 10 tests. - Docs: `docs/DEVELOPMENT.md` "Adding a provider adapter"; OPERATIONS notes the canary. Rebased onto main after #355 and #358 (changelog conflict, all entries kept). ## Known gaps, stated - The four `_structured_*` LLM transports still raise a bare `RuntimeError`; the case carries that expectation with a comment and tightens by editing one line once they are typed (out of the file whitelist for this build). - Live-mode word assertions skip against a silence probe; real coverage there wants a committed speech fixture (#433 territory). - Canary status is not on the bot-settings response yet; the constant is exported for the capability panel to read. ## Verification - Full backend suite: **1814 passed, 10 skipped, 1 xfailed**. ruff clean. Version sync OK. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Three gaps the conformance suite could not have tested around.

Typed errors. Every adapter failure was a bare RuntimeError, so a caller
could not tell "the model is overloaded, wait 30 seconds" from "this file
is 400 MB and never will be accepted" without reading the message.
app/providers/errors.py draws the three distinctions a caller acts on
differently — throttled, input too large, everything else — and derives
them from RuntimeError so the existing handlers keep catching them.

Declared limits are now enforced. max_audio_seconds and max_bytes existed
so the caller could chunk (#350), and nothing checked them: an adapter
that declares a limit and forwards an oversized file anyway turns a free
local failure into a slow remote one, or on some providers into a silent
truncation of the tail of a session. enforce_input_limits is called
before the socket opens, and the suite fails an adapter that skips it.

StubAsrProvider. A suite with one implementation cannot tell a contract
from a description of that implementation, so the stub takes the opposite
branch wherever the contract allows: measured word timestamps rather than
interpolated, keyterms rather than initial_prompt, declared limits rather
than none, and — in its pending_polls form — asynchronous completion,
which no adapter in CI otherwise exercises. Deliberately not registered
in ASR_PROVIDERS: a selectable provider that invents transcripts is the
failure mode this milestone removes, not a feature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An abstraction with one implementation is not an abstraction. The v4.2.0
promise is that a provider can be swapped without a rewrite, and the only
thing that keeps that true as the code moves is a suite each adapter has
to pass — which is also what makes the fallback story real, since a
self-hosted adapter that is deployed and CI-tested turns a managed
provider's price rise or outage into an adapter switch.

Parametrised over adapter *cases* rather than written per provider:
adding a provider is a case in cases.py plus, if it speaks HTTP, a fake
for its wire format. Every existing test then runs against it. Anything a
case genuinely cannot answer skips with a reason — an in-process adapter
has no HTTP status to map, a live endpoint cannot be told to return a
429 — so an untested property shows up in the report rather than looking
like a passing one.

The fakes are httpx.MockTransport handlers installed at the AsyncClient
constructor, not monkeypatched post() methods. The difference is that the
request is really encoded, so "the vocabulary reached the request,
truncated to the declared limit" is asserted against multipart bytes
rather than against the kwargs the caller passed.

Covered: track_id passthrough and association by id rather than order
(#342); no speaker field anywhere; monotonic words inside their segments;
the word_timestamps declaration agreeing with the words; vocabulary
accepted everywhere and truncated where declared; declared limits
enforced with zero requests made; 5xx and 429 mapped to their own types.
On the LLM side: well-formed capabilities; the schema spelled the way
_apply_json_schema spells it and actually present in the body; usage
normalised from each provider's own shape; the deliberate asymmetry where
truncated structured output raises and truncated prose returns flagged;
and preflight_prompt warning without refusing, which #351 chose on
purpose and this pins.

Concurrency enforcement is #356. The case is written for real and marked
xfail rather than omitted, so the gap is in the report.

--live runs the same suite against QB_CONFORMANCE_* endpoints, adding
cases rather than replacing them; unset variables skip and name the
variable, and the suite refuses --live outright when CI is set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The conformance suite proves the adapters are correct against fakes. It
cannot prove the box at the other end of the operator's endpoint URL is
still there, still speaks the same dialect, and still accepts the key it
was given six months ago — and nothing else does either, because every
provider call in this product happens on a path a GM triggered. A Whisper
server that died in April is found by a failed session in July, with the
recording already made.

provider_canary transcribes one second of synthesized silence through the
configured ASR adapter and asks the configured LLM to reply with "OK",
then writes {ran_at, asr_ok, llm_ok, asr_provider, llm_provider, error}
to the app_settings key `provider_canary` and logs at WARNING when either
side fails. Monthly, matching prune-audit-log, because the failure it
catches is measured in months and because it is the one scheduled task
that makes an outbound call to a possibly metered provider.

It grades nothing, retries nothing, and alerts nobody: a canary that
retried would hide exactly the intermittent failure worth seeing. "Not
configured" records None rather than False — a deployment that never set
up transcription is not broken, and a red light for it would train the
operator to ignore the field. The result is written in its own session
after both checks, so a failure is still recorded; the months it says
"no" are the point.

No UI. Exposing it on the bot-settings response is not the one-line
additive change the brief allowed for — it needs a schema field and two
handler changes — so the read belongs with #358's capability panel, which
imports CANARY_SETTING_KEY rather than repeating the string.

Tested through the conformance fakes rather than by mocking the adapters:
a canary built on mocked provider calls keeps passing after the adapter
it exercises stops being reachable, which is the failure it exists to
detect.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs: adding a provider adapter, and what the canary does (#359)
All checks were successful
CI / Bot/backend version sync (pull_request) Successful in 26s
CI / Backend lint (ruff) (pull_request) Successful in 48s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m22s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m47s
CI / Bot tests and audit (pull_request) Successful in 2m3s
CI / Docker image build (pull_request) Successful in 4m31s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m27s
0b49cf2e13
DEVELOPMENT.md gains "Adding a provider adapter", naming the conformance
suite as the bar rather than the developer's own endpoint, and spelling
out both run modes: the CI one (fakes, no network, no database) and the
live one, including what live deliberately skips and why unset
QB_CONFORMANCE_* variables skip rather than fail.

OPERATIONS.md gains the canary under Alerting, next to the metrics that
exist for the same reason — a failure nobody is watching at 2am. Includes
the psql read for the app_settings row and how to run it on demand, since
there is no admin panel until #358.

make conformance / make conformance-live, and the changelog entry: the
operator-facing part is the canary; the suite is the clause that explains
why it can be trusted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude-bot scheduled this pull request to auto merge when all checks succeed 2026-09-05 02:16:27 +00:00
claude-bot deleted branch feat/359-provider-conformance 2026-09-05 02:25:21 +00:00
Sign in to join this conversation.
No description provided.