Adopt proposal 0122: the extras surface is a container - #294
Adopt proposal 0122: the extras surface is a container#294chris-colinsky wants to merge 5 commits into
Conversation
Proposal 0122 tightens retrieval section 8.4: the malformed test on a merge-extra is structural, never a vocabulary check. A well-typed string the provider does not recognize merges, including the empty string, and the provider rejects it if unsupported. The Cohere gate carried an `and t` truthiness clause that dropped the whole list to ["float"] on an empty element. Its own comment already said malformation was structural only, so the code and the comment disagreed. Fixture 053 gains a case pinning ["banana", ""]. The structural arm is unchanged: a non-string element still drops the whole list with no partial salvage. This was raised as a spec question rather than changed unilaterally, and the ruling went the way the comment described.
Proposal 0122 settles the shape of the extras surface: undeclared fields
live in a container on the config record that is separately addressable
from the declared ones, and its name is normative.
We had the flat reading. RuntimeConfig, EmbeddingRuntimeConfig and
RerankRuntimeConfig accepted undeclared names as attributes on the
record, which meant a key whose name matched a declared field bound the
field instead of landing in extras. That made one arm of 0108's
managed-field collision rule unreachable, and we reported it as such.
0122 rules the other way, so the arm is real and the reading was the
defect.
Breaking in the pre-1.0 sense: an undeclared name passed flat now raises,
and the same call is written with extras={...}. Declared fields are
unchanged. from_partial still only drops None-valued entries and does not
route undeclared names, so there is one spelling rather than two.
The conformance fixtures already nested their config.extras sub-block and
four harnesses were flattening it to match our model. They now pass it
through, which is the change that makes the fixture and the code agree
about what the fixture always said.
There was a problem hiding this comment.
🟡 Changes recommended
A new unit test does not close its provider (missing await provider.aclose()), and there are small cleanup issues (a leftover type-ignore and an incomplete comment) that should be fixed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adopts accepted spec proposal 0122 by changing runtime configs to treat provider-specific extras as a dedicated extras container (with extra="forbid"), and aligns provider/test/documentation surfaces accordingly. It also fixes the Cohere /v2/embed embedding_types gate to be structural-only (no truthiness or vocabulary checks), matching the tightened spec wording.
Changes:
- Introduce an
extras: dict[str, Any]container onRuntimeConfig,EmbeddingRuntimeConfig, andRerankRuntimeConfig, and update harnesses/tests to pass nestedextrasthrough instead of flattening. - Update LLM and retrieval provider mappings to read request extras from
config.extras(notmodel_extra) and adjust managed-extra collision tests for same-name collisions. - Fix Cohere
embedding_typesvalidation to accept any strings (including"") and add unit coverage for the empty-string merge case.
File summaries
| File | Description |
|---|---|
| tests/unit/test_structured_output.py | Updates structured-output test to pass response_format via RuntimeConfig.extras. |
| tests/unit/test_retrieval_provider.py | Migrates retrieval config construction to nested extras and adds a unit test for Cohere embedding_types empty-string merge behavior. |
| tests/unit/test_prompts.py | Updates prompt sidecar assertions to read vendor knobs from SamplingConfig.extras. |
| tests/unit/test_llm_provider.py | Updates LLM unit tests to use the extras container and adds coverage ensuring undeclared fields must be nested under extras. |
| tests/conformance/test_retrieval_provider.py | Stops flattening config.extras into top-level config kwargs for conformance embedding and rerank configs. |
| tests/conformance/test_prompt_management.py | Updates prompt-management conformance adapter to treat sampling.extras as a nested container and compare dumps without flattening. |
| tests/conformance/test_observability.py | Builds RuntimeConfig by passing provider-specific request params through the extras container. |
| tests/conformance/test_llm_provider.py | Stops flattening fixture config.extras into RuntimeConfig declared fields. |
| src/openarmature/retrieval/response.py | Changes embedding/rerank runtime configs to extra="forbid" and adds an explicit extras container. |
| src/openarmature/retrieval/providers/tei.py | Reads provider request extras from config.extras. |
| src/openarmature/retrieval/providers/openai.py | Reads provider request extras from config.extras. |
| src/openarmature/retrieval/providers/jina.py | Reads provider request extras from config.extras. |
| src/openarmature/retrieval/providers/cohere.py | Reads provider request extras from config.extras and fixes embedding_types structural gating to allow empty strings. |
| src/openarmature/prompts/backends/filesystem.py | Parses sampling sidecar dict into SamplingConfig with a nested extras container. |
| src/openarmature/llm/response.py | Changes RuntimeConfig to extra="forbid" and adds an explicit extras container plus updated from_partial semantics documentation. |
| src/openarmature/llm/providers/openai.py | Reads request extras from config.extras when building OpenAI request bodies. |
| docs/concepts/llms.md | Updates documentation examples to use RuntimeConfig(..., extras={...}). |
| CHANGELOG.md | Documents the breaking pre-1.0 surface change to nested extras and the Cohere embedding_types clarification. |
Review details
- Files reviewed: 18/18 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
llm-provider 075 and retrieval-provider 052 were held because the coded reject looked unreachable: a declared-name key bound the declared field instead of landing in extras, so nothing could construct the collision. The container makes it constructible, and both fixtures pass. Mutation-verified rather than taken on a green run: making the reject arm never fire turns both red. Also corrects the comments that asserted the unreachability, and two module comments still describing the configs as extra="allow".
Two were real defects this branch introduced. A retry with a per-attempt override silently dropped the base config's extras. `extras` is a declared field defaulting to an empty dict, which exclude_none keeps, so the generic dump carried an empty container into the merge and replaced the caller's vendor knobs on every attempt. It was invisible in the trace too: request_params is projected once from the base config before the retry loop, so the emitted event reported extras the attempt never sent. The merge is now per key, with the override winning on a collision. A filesystem sidecar carrying an unrecognized top-level key raised a pydantic error out of fetch(). That is neither of the two documented error types, so PromptManager's multi-backend fallback never ran and one stray key in one operator-authored file took down every fetch for that prompt. Unrecognized keys are now filtered, matching what the token_budget path and the Langfuse backend already did. A Langfuse prompt.config now lifts an extras sub-object as well, so the container is honored on both documented sources rather than one. Also: a fifth conformance harness was still flattening the fixture's extras block, PromptManager's defensive copy shared the container by reference, and an assertion presented as covering the None-dropping was a tautology under the new strictness. Comment and docs corrections, including two module comments still describing the configs as extra="allow", a comment mangled into a half-sentence, and one I wrote narrating a mutation result.
The new cohere test left its provider open, so a failing assertion mid-test leaked the transport. Wrapped in try/finally, verified by forcing the assertion red and confirming no unclosed-transport warning. The type ignore on a RuntimeConfig construction predated the container: extras is a typed declared field now, so pyright accepts it. The sibling ignore on RuntimeConfig(top_k=None) stays, since that name is deliberately undeclared. Also drops a comment clause describing what the gate used to do.
Implements proposal 0122, accepted at spec v0.117.0. Two halves: a shape change to the runtime configs, and a one-line fix to the Cohere
embedding_typesgate.Both were open items in the v0.17.0 batched spec review. We raised them as questions rather than changing anything unilaterally, and the ruling went our way on one and against our reading on the other.
The extras surface is a container
§6 never stated what the extras surface is. Read one way it is a named container on the config record alongside the declared fields; read the other it is undeclared fields on the record itself. We had the second reading:
extra="allow", undeclared names landing as attributes.That reading has a consequence we reported to spec as a defect in the fixtures. A key whose name matches a declared field binds the field rather than landing in extras, so a caller cannot set both at once, and one arm of 0108's managed-field collision rule becomes unreachable. We concluded shipped fixtures pinned an unreachable case. 0122 rules the other way: the arm is real, and the flat reading was the defect.
So
RuntimeConfig,EmbeddingRuntimeConfigandRerankRuntimeConfig(andSamplingConfig, which derives from the first) gain anextrasmapping field and move toextra="forbid".Breaking, in the pre-1.0 sense. An undeclared name passed flat now raises:
Declared fields are unchanged.
from_partialstill only dropsNone-valued entries and does not route undeclared names, so there is one spelling rather than two.The fixtures already agreed with 0122. Their
config.extras:sub-block has always been nested; four of our harnesses were flattening it to match our model. They now pass it through, which is what makes the fixture and the code agree about what the fixture always said.embedding_typesis not a vocabulary checkThe Cohere gate carried an
and ttruthiness clause that treated an empty-string element as malformed and dropped the whole list to["float"]. 0122 tightens §8.4's wording from "not a precision string" to "not a string", because the former reads as a vocabulary check and the general rule it inherits explicitly is not one.The gate's own comment already said malformation was structural only, so the code and its comment disagreed. Fixture 053 gains a case pinning
["banana", ""]→["float", "banana", ""]. The structural arm is unchanged: a non-string element still drops the whole list with no partial salvage.Testing
Four mutants, all killed:
and t) and too permissive (accept non-strings) — the test pins the boundary, not one sideextra="forbid"reverted to"allow"on the LLM config, and on the two retrieval configsThe last two survived at first. The container worked and every test passed, but nothing pinned the single-spelling half, so a regression to
extra="allow"would have gone unnoticed.test_undeclared_fields_must_go_in_the_extras_containercloses that across all four classes and also covers the same-name arm that the flat reading could not express.Ahead of the pin
Spec v0.117.0 is beyond the current v0.112.0 pin, so this ships unit-tested and the
conformance.tomlentry plus fixtures 054 / 055 / 056 ride the pin bump. Until then the empty-string merge case and the same-name collision arm have unit coverage only.Migration
31 construction sites rewritten across three test files, 9
model_extrareads migrated across five providers, the filesystem prompt sidecar and four conformance harnesses updated, anddocs/concepts/llms.mdrewritten for the new spelling.