[Backend] Define the LLM provider contract with declared context window and schema output #351
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found in the August 2026 session lifecycle review (#319).
Why
Two of this project's most damaging pipeline bugs come from the absence of a declared context window: prompts are sent without knowing the budget, and truncation is never detected. The abstraction has to make the window a first-class, queryable property rather than a thing each call site assumes.
Proposed contract
json_schemaconstraintSchema-constrained decoding is the important capability. On llama.cpp it maps to GBNF grammar via
json_schema(see #281); on Anthropic and OpenAI to structured outputs.response_format: {"type":"json_object"}alone is advisory and demonstrably not enforced on the current deployment, which is why the tolerant parser exists. Providers that cannot enforce a schema must declare that, so the pipeline can fall back to the tolerant parser and mark the result as degraded rather than silently trusting it.Acceptance criteria
Picking this up alongside #350 on
feat/350-351-provider-contracts.Grounding: most of this already exists, unowned
The v4.0.0 context-budget work built much of what this issue asks for, and two comments in
llm_service.pyexplicitly name #351 as the issue that finishes them:LLMConfig.context_tokens(settings_service.py:517),resolve_window,DEFAULT_CONTEXT_TOKENS = 32_768prompt_budget_tokens/fits_in_context,PROMPT_BUDGET_FRACTION = 0.7_apply_json_schemahandles four provider spellings, including llama.cpp's both-forms workaround (#281)_warn_if_prompt_truncatedreads them, but only warns; nothing returns them to a callerSo the substance of this issue is narrower and sharper than its body suggests: the machinery is there, but nothing declares anything. The gap is a provider object to hang it on.
The actual defect: provider identity is guessed from the URL
generate_structured_text(llm_service.py:406) dispatches on URL substrings:This is the root of the "no commitment to any single provider" problem. A self-hoster behind a reverse proxy on their own domain is classified as llama.cpp regardless of what is actually behind it; an OpenAI-compatible gateway on a custom host is too. The four
_qa_*functions inaudio_service.pyrepeat the same sniffing independently — which is a large part of why #426 exists as a separate bug.Replacing the sniff with an explicit, configured provider selection is the load-bearing change here. Everything else in this issue hangs off having a provider object at all.
Two decisions already settled, recorded so they are not reopened
_reject_if_truncated(llm_service.py:240-280). The capability object does not get to re-decide this.preflight_promptstays a warning, not a raise — a departure from #336 recorded in its docstring, becauseestimate_tokensis deliberately pessimistic and a raise would refuse prompts that would have worked. This issue is the one that can change that: a provider declaring a real tokenizer and whether it front-truncates is what turns both soft checks hard. I am carrying the declaration but not flipping either check to a raise in this issue — that wants the conformance suite (#359) proving the tokenizer first.Landed in PR #483 (merged, CI green on all 7 jobs). Leaving this open — three of five criteria are fully met, two are partial and both partials are deliberate.
Criteria
LlmCapabilities+LlmProviderinapp/providers/llm.py. The provider declares and spells;llm_servicekeeps the transports, soapp.providersimports nothing fromapp.servicesand a provider builds in a test with no DB, settings or event loop.resolve_llm_provider. An explicitcontext_tokensstill wins; only an undeclared one now takes the provider's own window, which for Anthropic and OpenAI is the model's documented figure rather than the universal conservative 32k. Ollama and llama.cpp declare that same 32k, so the local paths are unchanged.SchemaEnforcementis a three-state property and all four spellings are pinned by tests to the proven_apply_json_schema(#281). But nothing yet consumes the declaration to mark a result degraded.SummarisationRun.schema_degradedexists and is set when the tolerant parser repairs, which catches the symptom rather than the declared capability. Closing this properly means a provider declaringADVISORY/NONEmarking the run degraded up front — that belongs with #358, which is what renders it.prompt_cachingandbatch_modeare declared, andprompt_cachingis already load-bearing (see below). There is no hint API to pass yet, so "no-op cleanly" is currently true only vacuously. Batch mode wants #357's cost work to have a reason to exist.The defect this issue exposed
Mapping the four providers' usage fields to write
read_usageshowed that_warn_if_prompt_truncatedhad exactly one caller — the Ollama path. llama.cpp, Anthropic and OpenAI all parsedusagefor completion tokens and never looked at the prompt side, so a front-truncated prompt on three of four providers produced no signal at all. That is the input-side half of the failure #331/#336 exist to prevent, live on the majority of deployments._check_usagenow runs both checks in one place, so a new transport cannot forget one.prompt_cachingis what makes the check honest rather than noisy: Anthropic and OpenAI report only newly evaluated tokens, so "truncated" and "cache hit" are indistinguishable and the check is skipped; Ollama and llama.cpp neither cache nor reject an over-long prompt, so there a low count has one meaning. This is exactly the declaration_warn_if_prompt_truncated's docstring had been asking for since #331, and the docstring is updated to say so.Provider identity is no longer guessed
URL-substring dispatch is replaced by
app/providers/registry.py, with a validated select in Admin → Bot Settings. The old heuristic survives as a named, logged fallback so nothing changes on upgrade, and the log line names the field to set. Blank preserves; anautosentinel clears — without it a selection could be set but never undone, since blank already means preserve everywhere on that endpoint.Recorded so it is not reopened
preflight_promptis still a warning, not a raise. This issue makes the hard check possible — a declaredfront_truncates, and eventually a declared tokenizer — but flipping it should wait for #359 proving the tokenizer against a real endpoint.estimate_tokensis deliberately pessimistic, so raising today would refuse prompts that would have worked.The threading is a known weak point, not an oversight.
llm_providertravels alongsidecontext_tokensthrough 19 signatures, 25 pass-throughs and 14 origin sites, which is the patternDEFAULT_CONTEXT_TOKENS' comment warns about. It holds only because omitting it falls back to the sniff. Filed as #484 (v4.6.0): stop destructuringLLMConfiginto loose parameters, which kills both threading problems permanently. It gets cheaper the sooner it is done.Closing with v4.2.0. The two criteria left partial on 1 September were each finished by the issue they were deferred to:
_stamp_provider_limitsinaudio_service.pyreads the resolved provider'sSchemaEnforcementbefore the run and setsschema_degradedfrom the declaration, not from the tolerant parser tripping.capability_servicealso turns a non-ENFORCEDdeclaration into theGAP_SCHEMA_NOT_ENFORCEDgap (severity high) on the Admin capability panel and on the run record, so the summary is marked as produced under a limited configuration up front.prompt_cachingis load-bearing and no-ops where absent: on a caching provider_check_usageskips the truncation check outright; on the others it runs.batch_modeis declared, reported by/health-driven discovery and shown on the capability panel. There is deliberately no batch hint API: no call site batches (the pipeline is one session at a time and the usage work in #357 gave no reason to change that), so an API with nothing to no-op would be speculative surface. When a batching consumer appears, add the hint then.Still recorded so it is not reopened:
preflight_promptremains a warning, not a raise. The conformance suite (#359) now proves the endpoint contract live, but not the tokenizer; flipping to a raise still wants that proof first.