Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The

### Changed

- **Undeclared provider fields move into an `extras` container on the runtime configs** (proposal 0122, llm-provider §6 + retrieval-provider §6 / §8.4, spec v0.117.0). `RuntimeConfig`, `EmbeddingRuntimeConfig` and `RerankRuntimeConfig` (and `SamplingConfig`, which derives from the first) gain an `extras` mapping field and move from `extra="allow"` to `extra="forbid"`. **Breaking, in the pre-1.0 sense:** `RuntimeConfig(temperature=0.2, guided_decoding={...})` now raises, and the same call is written `RuntimeConfig(temperature=0.2, extras={"guided_decoding": {...}})`. Declared fields are unchanged. §6 had left the extras surface's shape unstated, and read flat it made one of 0108's collision arms unreachable: a caller could not set a declared field and a same-named extras key at once, because the same-named key bound the field. We read it that way, reported the arm as unreachable, and 0122 settles it the other way. The container is separately addressable and its name is normative, so a caller moving between implementations writes the same key. The practical gain is that a provider-specific override of a field OA already models is now expressible, and every arm of the managed-field collision rule is reachable on every mapping. `from_partial` still only drops `None`-valued entries and does not route undeclared names, so there is one spelling rather than two. Conformance fixtures already nested their `config.extras:` sub-block, which the harness used to flatten; it now passes through. Two fixtures come off the deferred list as a direct result: llm-provider 075 and retrieval-provider 052, both held because the same-name reject looked unreachable through the real caller path. Both now run and are mutation-verified against the reject. Two smaller behaviors move with the container. A Langfuse `prompt.config` now lifts an `extras` sub-object onto `Prompt.sampling`, so a vendor knob reaches it from that source as it already did from a filesystem sidecar; the full config still rides `Prompt.metadata` either way. And a filesystem sidecar's unrecognized top-level key is now filtered rather than fatal, matching what the `token_budget` path and the Langfuse backend already did: with the config rejecting undeclared names, splatting the sidecar verbatim would turn one stray key in an operator-authored file into an error escaping `fetch()`, which is neither of the two documented error types and so would bypass the manager's multi-backend fallback. A vendor knob written flat in a sidecar is one such key, so it is filtered and does not reach `sampling.extras`. Spec v0.117.0 is beyond the current v0.112.0 pin, so the behavior ships ahead of the pin (unit-tested); the `conformance.toml` entry and fixtures 054 / 055 / 056 ride the pin bump.
- **Managed wire fields now reject a conflicting extras key instead of silently losing it** (proposals 0105 + 0108, llm-provider §6, spec v0.100.0 / v0.103.0). **Breaking for a managed-key collision only.** A caller's undeclared extras key (`RuntimeConfig` / `EmbeddingRuntimeConfig` accepting extra fields) is forwarded to the wire body untouched, except when it names a field the mapping *manages*: one it sets for its own correctness (0105), or produces as the wire realization of a declared config field (0108). On such a collision the field's shape now decides. An additive list field (`stop` from `stop_sequences`; `embedding_types`) **merges** the caller's value(s) onto the managed value(s), managed-first, de-duplicated. A non-additive scalar or object (`model`, `messages`, `truncate` / `truncation`, `dimensions` / `output_dimension`, `input_type`, `response_format`, Jina `task`, …) takes a value **equal** to the managed one as a no-op and **rejects a conflicting** one pre-send with `ProviderInvalidRequest`. Previously such a collision was silently dropped (the OpenAI llm mapping used `setdefault`) or silently overrode the managed value (the retrieval mappings spread extras first), either of which could re-route the model, defeat a fail-loud `truncate` flag, or break structured-output validation. A field the mapping does not manage keeps untouched pass-through, and a conditionally-managed field is only managed while produced, so the escape hatches hold: an extras `response_format` on a free-form or prompt-augmentation-fallback call rides untouched (0105 §3.5, previously stripped on the fallback path), and an extras Jina `task` with no `input_type` rides untouched (the model-specific-task escape hatch). The rule spans one OpenAI llm mapping and seven retrieval mappings via a shared resolver (`apply_managed_extras`). Spec v0.100.0 / v0.103.0 are beyond the current v0.88.0 pin, so this ships ahead of the pin (unit-tested); the reject / merge fixtures ride the pin bump.
- **Cohere `/v2/embed` recognizes `classification` and `clustering`** (proposal 0099, retrieval-provider §8.4, spec v0.94.0). **Breaking for these two values.** `EmbeddingRuntimeConfig.input_type` is an extensible string, and §2 names `classification` and `clustering` as well-known values a mapping may recognize when its backend supports them. Cohere's does, so the mapping now identity-maps both onto the wire instead of rejecting them. Previously either value raised `ProviderInvalidRequest` before the request was sent, so a caller who relied on that rejection as a guard (catching it to fall back to `document`, say) silently changes behavior. `query` / `document` / absent / unrecognized are all unchanged, and `image` stays out: it names an input modality rather than a purpose for embedded text, and `embed()` consumes strings. The widening is deliberately per-mapping and not portable. Jina keeps its closed `{query, document}` set, because its `task` support varies by model version (v3 accepts `classification` but not `clustering`, v4 neither, v5 both) and a provider is bound to a model identifier with no capability registry to consult, so that mapping cannot promise the values and declines them pre-send rather than letting the wire reject them later. Spec v0.94.0 is beyond the current v0.88.0 pin, so this ships ahead of the pin (unit-tested); the `conformance.toml` entry and fixture 033's new cases ride the pin bump.
- **Cohere `/v2/embed` `embedding_types` merge is now deterministic** (proposal 0099, retrieval-provider §8.4, spec v0.94.0). The mapping manages `embedding_types` as an explicit exception to untouched extras pass-through, because it must request `"float"` for its own response consumer (it reads `embeddings.float`). A caller-supplied `embedding_types` is merged with that mandatory `"float"` rather than replacing it, which was already the behavior; an override that dropped `float` would strip the key the mapping itself reads and fail the call. What changes is the shape of the merged list, which 0099 pins so the outbound body is reproducible and exact-match assertable: `"float"` first, then the caller's precisions in the order supplied, de-duplicated with the first occurrence winning. Previously the caller's precisions came first with `"float"` appended, and a repeated precision was sent twice, so `["int8"]` now yields `["float", "int8"]` rather than `["int8", "float"]`, and `["int8", "uint8", "int8"]` yields `["float", "int8", "uint8"]` rather than passing the duplicate through. The wire is order-insensitive here, so no request semantics change; callers still read their extra precisions off the verbatim response on `raw`. A malformed or empty extra still falls back to `["float"]`.
Expand Down
22 changes: 14 additions & 8 deletions docs/concepts/llms.md
Original file line number Diff line number Diff line change
Expand Up @@ -635,18 +635,24 @@ is a one-node change.
## Provider-specific extras

`RuntimeConfig` (and the retrieval `EmbeddingRuntimeConfig` /
`RerankRuntimeConfig`) accept fields beyond the declared set, and any
undeclared field is forwarded to the wire request untouched. This is how
you reach a backend-specific knob the portable config does not model, for
example a vLLM `guided_decoding`:
`RerankRuntimeConfig`) carry undeclared fields in an `extras` container,
and everything in it is forwarded to the wire request untouched. Passing
an undeclared name directly is rejected. This is how you reach a
backend-specific knob the portable config does not model, for example a
vLLM `guided_decoding`:

```python
config = RuntimeConfig.model_validate({
"temperature": 0.2,
"guided_decoding": {"grammar": "..."}, # forwarded as-is
})
config = RuntimeConfig(
temperature=0.2,
extras={"guided_decoding": {"grammar": "..."}}, # forwarded as-is
)
```

Undeclared knobs go in `extras`, a container separate from the declared
fields. That separation is what lets you set a declared field and an
extras key of the same name in one call, which is how a provider-specific
override of a field OA already models is expressed.

The value itself is never translated or renamed. One caveat for
byte-level consumers: the OpenAI Chat Completions mapping canonicalizes
the request body for reproducibility, so a dict-valued extra keeps its
Expand Down
17 changes: 15 additions & 2 deletions docs/concepts/prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,24 @@ behavior; most callers just pass the prompt back into `render()`.
A `Prompt` carries an optional `sampling` field: a `SamplingConfig`
sub-record mirroring `RuntimeConfig`'s seven declared fields
(`temperature`, `max_tokens`, `top_p`, `seed`, `frequency_penalty`,
`presence_penalty`, `stop_sequences`) plus the extras pass-through
bag. Backends that source per-prompt config (Langfuse's
`presence_penalty`, `stop_sequences`) plus the `extras` container for
vendor knobs. Backends that source per-prompt config (Langfuse's
`prompt.config`, a filesystem sidecar) populate it; backends that
don't leave it `None`.

Both sources spell vendor knobs the same way, as an `extras` sub-object
beside the declared keys:

```json
{"temperature": 0.0, "max_tokens": 256, "extras": {"repetition_penalty": 1.05}}
```

An unrecognized key beside them is ignored rather than failing the
fetch, so a stray or future key does not invalidate a well-formed
config. A vendor knob written flat, next to the declared keys rather
than inside `extras`, is one such key: it is filtered out and does not
reach `sampling.extras`.

```python
prompt = await manager.fetch("classify", "production")
if prompt.sampling is not None:
Expand Down
23 changes: 23 additions & 0 deletions docs/concepts/retrieval.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,29 @@ providers, will quietly index unprefixed vectors and cost you recall with
no signal. So treat `query` / `document` as the portable pair, and reach
for the other purposes only when you know which provider you are on.

## Provider-specific extras

`EmbeddingRuntimeConfig` and `RerankRuntimeConfig` carry undeclared
fields in an `extras` container, forwarded to the wire request
untouched. Passing an undeclared name directly is rejected.

```python
await provider.embed(
passages,
config=EmbeddingRuntimeConfig(
input_type="document",
extras={"output_dimension": 256},
),
)
```

The container is separate from the declared fields, so you can set a
declared field and an extras key of the same name in one call. That is
how you override a wire field OA already models from the declared side.
Where the mapping itself produces that wire field, the conflicting
extras key is rejected before the request is sent rather than silently
winning or losing.

## Long input lists are chunked for you

Every hosted embedding API caps how many inputs one request may carry.
Expand Down
17 changes: 13 additions & 4 deletions src/openarmature/llm/providers/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,16 @@ def _config_for_attempt(
base_or_empty = base if base is not None else RuntimeConfig()
# exclude_none (not exclude_unset): a None override field inherits the
# base per §6 null-skip, rather than an explicit None clobbering it.
return base_or_empty.model_copy(update=override.model_dump(exclude_none=True))
#
# `extras` is excluded from that dump and merged per key below. It is a
# declared field whose default is `{}`, which `exclude_none` keeps, so
# leaving it in the update would replace the base container on every
# attempt and drop the caller's vendor knobs from the retry body.
update = override.model_dump(exclude_none=True, exclude={"extras"})
merged_extras = {**base_or_empty.extras, **override.extras}
if merged_extras:
update["extras"] = merged_extras
return base_or_empty.model_copy(update=update)

@staticmethod
def _append_reask_pair(
Expand Down Expand Up @@ -1182,8 +1191,8 @@ def _build_request_body(
# mapping actually produced it (present in the body), which realizes the
# "while producing it" semantics for the declared-field realizations and
# the conditionally-managed response_format.
if config is not None and config.model_extra:
extras = {k: _canonicalize_dict_keys(v) for k, v in config.model_extra.items()}
if config is not None and config.extras:
extras = {k: _canonicalize_dict_keys(v) for k, v in config.extras.items()}
managed: dict[str, ManagedArm] = {
key: arm
for key, arm in _OPENAI_MANAGED_ARMS.items()
Expand Down Expand Up @@ -1968,7 +1977,7 @@ def _request_extras_from_config(config: RuntimeConfig | None) -> dict[str, Any]:
dict; empty when no extras are set or when ``config`` is None."""
if config is None:
return {}
return dict(config.model_extra or {})
return dict(config.extras)


__all__ = [
Expand Down
27 changes: 17 additions & 10 deletions src/openarmature/llm/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,21 @@ class Response(BaseModel):
response_model: str | None = None


# Spec §6 declared-field surface: seven optional fields. Undeclared
# fields supplied by callers MUST be forwarded to the wire body
# untouched (extras pass-through); declared fields with value ``None``
# MUST be omitted from the wire body (null-skip). Both rules are
# enforced by the §8 wire-format mapping, not by RuntimeConfig itself.
# Spec §6 declared-field surface: seven optional fields. Undeclared fields go in
# the `extras` container and are forwarded to the wire body untouched; declared
# fields with value ``None`` are omitted (null-skip). Both rules are enforced by
# the §8 wire-format mapping, not by RuntimeConfig itself.
class RuntimeConfig(BaseModel):
"""Per-call sampling parameters and budget hints."""

model_config = ConfigDict(extra="allow")
# §6 (0122): undeclared fields live in a container that is separately
# addressable from the declared ones, so a caller can set a declared field
# AND an extras key of the same name in one call. That collision is what
# 0108 clause (b) governs, and it is unreachable if undeclared keys land
# flat on the record. The container's name is normative.
model_config = ConfigDict(extra="forbid")

extras: dict[str, Any] = Field(default_factory=dict)

temperature: float | None = None
max_tokens: int | None = None
Expand All @@ -156,10 +162,11 @@ class RuntimeConfig(BaseModel):
# name; the declared layer matches the cross-vendor norm.
stop_sequences: list[str] | None = None

# Pure Python ergonomic, not a spec contract. The wire-layer
# null-skip rule already drops ``None``-valued declared fields, so
# this helper exists solely to let callers splat a dict whose
# entries may be ``None`` without filtering at the call site.
# Pure Python ergonomic, not a spec contract. The wire-layer null-skip rule
# already drops ``None``-valued declared fields, so this exists solely to let
# callers splat a dict whose entries may be ``None`` without filtering at the
# call site. It does NOT route undeclared names: those go in ``extras`` like
# anywhere else, so there is one spelling rather than two.
@classmethod
def from_partial(cls, **kwargs: Any) -> RuntimeConfig:
"""Construct a config, dropping kwargs whose value is ``None``.
Expand Down
29 changes: 17 additions & 12 deletions src/openarmature/prompts/backends/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,19 +257,24 @@ async def fetch(


def _sampling_from_dict(data: dict[str, Any]) -> SamplingConfig:
# Top-level `extras` is flattened so caller-supplied vendor knobs
# end up in SamplingConfig's extras-allow bag rather than as a
# single literal `extras` key. Matches the YAML conformance-fixture
# convention from llm-provider/032 + the spec §5 sidecar example.
# `token_budget` (proposal 0083) is a sibling sub-object read by
# `_token_budget_from_dict`, not a sampling field, so it is excluded
# here alongside `extras`.
flat: dict[str, Any] = {k: v for k, v in data.items() if k not in ("extras", "token_budget")}
# The sidecar's `extras` sub-object maps onto the config's own extras
# container (0122). `token_budget` (0083) is a sibling sub-object read by
# `_token_budget_from_dict`, not a sampling field.
#
# UNRECOGNIZED top-level keys are ignored rather than raising (0109
# tolerate-and-filter, as `_token_budget_from_dict` and the langfuse backend
# already do). The config rejects undeclared names, so splatting the sidecar
# verbatim would turn one stray key in an operator-authored file into a
# pydantic error escaping `fetch()`, which is not one of the two documented
# error types and so bypasses the manager's multi-backend fallback.
declared: dict[str, Any] = {
k: v for k, v in data.items() if k in SamplingConfig.model_fields and k != "extras"
}
extras = data.get("extras")
if isinstance(extras, dict):
for k, v in cast(dict[str, Any], extras).items():
flat.setdefault(k, v)
return SamplingConfig(**flat)
return SamplingConfig(
**declared,
extras=dict(cast(dict[str, Any], extras)) if isinstance(extras, dict) else {},
)


def _token_budget_from_dict(data: dict[str, Any]) -> TokenBudget | None:
Expand Down
10 changes: 8 additions & 2 deletions src/openarmature/prompts/backends/langfuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,15 @@ def _sampling_from_config(config: dict[str, Any] | None) -> SamplingConfig | Non
if not config:
return None
declared = {k: config[k] for k in _SAMPLING_FIELDS if k in config}
if not declared:
# The `extras` sub-object maps onto the config's extras container (0122),
# so a vendor knob reaches `Prompt.sampling` from this source as it does
# from the filesystem sidecar. The full config still rides
# `Prompt.metadata`, so nothing is lost either way.
raw_extras = config.get("extras")
extras = dict(cast("dict[str, Any]", raw_extras)) if isinstance(raw_extras, dict) else {}
if not declared and not extras:
return None
return SamplingConfig(**declared)
return SamplingConfig(**declared, extras=extras)


def _token_budget_from_config(config: dict[str, Any] | None) -> TokenBudget | None:
Expand Down
11 changes: 9 additions & 2 deletions src/openarmature/prompts/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,8 +540,15 @@ def _build_result(
variables=variables,
fetched_at=prompt.fetched_at,
rendered_at=datetime.now(UTC),
# Defensive copy of the mutable propagated fields.
sampling=prompt.sampling.model_copy() if prompt.sampling is not None else None,
# Defensive copy of the mutable propagated fields. `extras` is
# copied explicitly: `model_copy` shares the container by reference,
# so mutating a result's extras would reach back into the Prompt and
# every other result rendered from it.
sampling=(
prompt.sampling.model_copy(update={"extras": dict(prompt.sampling.extras)})
if prompt.sampling is not None
else None
),
# Proposal 0083: advisory token budget propagated verbatim (defensive
# copy), mirroring sampling -- rendering does not modify it.
token_budget=prompt.token_budget.model_copy() if prompt.token_budget is not None else None,
Expand Down
Loading