Skip to content

refactor(core)!: add #[non_exhaustive] to the receive-side structs that grew fields this cycle - #2330

Closed
gold-silver-copper wants to merge 3 commits into
mainfrom
refactor/non-exhaustive-listing-types
Closed

refactor(core)!: add #[non_exhaustive] to the receive-side structs that grew fields this cycle#2330
gold-silver-copper wants to merge 3 commits into
mainfrom
refactor/non-exhaustive-listing-types

Conversation

@gold-silver-copper

@gold-silver-copper gold-silver-copper commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Closes #2325

Why now

#[non_exhaustive] forbids struct-literal construction from other crates, so adding it is itself a semver break — it can only land inside a breaking release window. That window is open: release PR #2221 (v0.42.0) already flags rig-core as "⚠ API breaking changes", and Model just took exactly the break the attribute prevents — #2324 added Model.max_output_tokens and broke a downstream struct literal, while the four rig-agent types in the same PR (CompletionCall, PromptResponse, ModelTurn, StreamedTurn) gained fields harmlessly because they carry it. Outside a breaking window the attribute cannot be added at all, and #2321/#2079 will keep producing new listing fields in the meantime. #2045 made the same "there is an open window" argument for the error enums and was accepted.

Scope: the release window's census, not a general sweep

#2221's cargo-semver-checks output is a ready-made census of every externally-constructible rig-core struct that gained a field this cycle — i.e. every type that already demonstrated it grows fields while lacking the attribute. That census is the scope line here, plus ModelList (named by #2325; it shares the module and the receive-mostly usage). Deduplicated it is 19 rows; every one is accounted for below, and each was verified to still exist on origin/main at the cited path before acting.

Note the raw output contains four rows beyond the list in the issue threadresponses_api/streaming.rs::StreamingCompletionResponse, cohere/streaming.rs::StreamingCompletionResponse, message.rs::ToolResult, and anthropic's ToolDefinition. They are included.

Decision rule. Apply the attribute when the type is a value the library hands out and callers overwhelmingly read rather than build. Do not apply it where external construction is the type's contract — concretely: is the struct literal the documented/actual construction idiom, with no ergonomic alternative? That test, not "is it request-shaped", is what separates the two skips below from the 18.

Sweep table

# type path gained this cycle verdict
1 model::Model model/listing.rs max_output_tokens attribute
2 model::ModelList model/listing.rs — (per #2325) attribute
3 completion::message::ToolCall completion/message.rs provider attribute
4 streaming::RawStreamingToolCall streaming/mod.rs tool_id attribute
5 anthropic::completion::CompletionResponse providers/anthropic/completion.rs provider_request_id attribute
6 anthropic::completion::Usage providers/anthropic/completion.rs cache_creation attribute
7 anthropic::completion::ToolDefinition providers/anthropic/completion.rs strict attribute
8 anthropic::streaming::StreamingCompletionResponse providers/anthropic/streaming.rs stop_reason, message_id, model, provider_request_id attribute
9 anthropic::streaming::PartialUsage providers/anthropic/streaming.rs cache_creation attribute
10 openai::completion::streaming::StreamingCompletionResponse providers/openai/completion/streaming.rs finish_reason, response_id, model, provider_request_id attribute
11 openai::responses_api::CompletionResponse providers/openai/responses_api/mod.rs ~12 fields attribute
12 openai::responses_api::streaming::StreamingCompletionResponse providers/openai/responses_api/streaming.rs status, incomplete_details, message_id, response_id, model, provider_request_id attribute
13 gemini::streaming::StreamingCompletionResponse providers/gemini/streaming.rs response_id attribute
14 ollama::ToolCall providers/ollama.rs id attribute
15 ollama::StreamingCompletionResponse providers/ollama.rs model attribute
16 cohere::embeddings::BilledUnits providers/cohere/embeddings.rs images attribute
17 cohere::completion::Usage providers/cohere/completion.rs cached_tokens attribute
18 cohere::streaming::StreamingCompletionResponse providers/cohere/streaming.rs finish_reason, message_id attribute
19 completion::message::ToolResult completion/message.rs call, provider, name skip
20 anthropic::completion::AnthropicRequestParams providers/anthropic/completion.rs static_prefix_cache_ttl skip

No row is unaccounted for; no row had ceased to exist.

Why the 18 qualify

They are all values rig hands out and callers read: model listings, provider-native unary responses, terminal stream records, and usage/billing metadata. Every one is produced inside rig-core (by serde from a provider body, or by rig's own adapter) and reaches callers through list_models() / raw_completion / raw_stream. Across the whole workspace — every other crate, the root tests/ crate, examples/, and every rustdoc example — they had a combined three external construction sites, all migrated below.

Two deserve a note because they are not obviously "receive" types:

  • streaming::RawStreamingToolCall is a producer-side primitive that out-of-tree wire adapters build to feed into rig, so it looks like the carve-out. It isn't, under the rule as stated: the struct literal is not its idiom. All three out-of-crate authors (rig-gemini-grpc, rig-candle, and a rig-agent example) already use ::new(...) plus field assignment, and every field combination stays reachable — tool_id is a bare pub field and WireId::new is public and re-exported, so an external adapter can reproduce the in-crate gemini pattern that sets id and tool_id independently.
  • anthropic::completion::ToolDefinition is request-shaped but rig-produced: it is built only from rig_core::completion::ToolDefinition inside a pub(super) fn and serialized before leaving. Its one public exposure, AnthropicCompatibleProvider::enable_strict_tool_use, hands out &mut — and #[non_exhaustive] keeps public fields writable.

Why the 2 are skipped

  • completion::message::ToolResult — no constructor at all (impl ToolResult exposes only wire_call_id; the tool_result helpers all return UserContent/Message, never the struct), while the public StreamedUserContent::tool_result takes one by value. Attributing it would leave serde_json::from_value as the only way to build one outside rig-core — acceptable for a fixture, not for production code, and there are 14 external literal sites today across rig-vertexai, rig-memory, rig-gemini-grpc, rig-agent and the root test suite. It wants a ToolResult::new/with_provider pair first, which is its own change.
  • anthropic::completion::AnthropicRequestParams — request-side input with no Default, no new and no builder, so the struct literal is its only construction path and attributing would leave it with none. It also buys nothing today: its only consumer, AnthropicCompletionRequest, is pub(super), so no downstream crate can use the type at all. (That mismatch — a pub type whose consumer is not public — is worth its own cleanup; not this PR.)

The recorded reasons are the point as much as the attributes are, so the question stops being re-litigated per PR (#2325's closing note).

Deliberately out of scope

The census is the scope line. Adjacent rig-core types with the same receive-only profile that did not gain a field this cycle — cohere::completion::BilledUnits, cohere::embeddings::{EmbeddingResponse, Meta, ApiVersion}, cohere::completion::{CompletionResponse, Tokens} — are left alone, per #2325's own "a wider sweep is worth its own issue rather than expanding this one". Worth filing if maintainers want them inside this window.

One knock-on effect is documented in MIGRATING.md: cohere::embeddings::Meta holds billed_units: BilledUnits by value and is not itself attributed, so a cross-crate literal of Meta now needs its BilledUnits from serde. It is the only place in rig-core where an attributed type sits in a public by-value field of an unattributed one, and no in-tree caller does it.

Migration recipe

Only cross-crate construction changes. Reading is untouched, public fields stay public and writable, derived Deserialize still works from any crate, and cross-crate exhaustive destructuring just needs a trailing ...

// Was — breaks whenever rig adds a field
let model = Model {
    id: "gpt-4".to_string(),
    name: Some("GPT-4".to_string()),
    context_length: Some(8192),
    // …every other field, including ones added since you wrote this
};

// Now — survives future field additions
let mut model = Model::from_id("gpt-4");
model.name = Some("GPT-4".to_string());
model.context_length = Some(8192);

MIGRATING.md carries the per-type table of which constructor to start from. In-tree this broke exactly five sites, all migrated, none deleted:

  • crates/rig-core/src/model/listing.rs — the Model rustdoc taught the full-literal form under "Create a model with all fields", i.e. the breakable form anyone following the docs would have written. Rewritten to the assignment form; since doctests compile as external crates, cargo test --doc now guards it.
  • tests/providers/{gemini,ollama}/cassette/streaming_grammar.rs — two cross-provider message::ToolCall literals, now ToolCall::new(id, function), which is byte-identical (new = Self { id, ..Self::assemble(None, function) }, and assemble(None, …) yields provider: None, signature: None, additional_params: None).
  • tests/providers/anthropic/cassette/empty_stop_sequence_matrix.rs — an anthropic::CompletionResponse + Usage fixture, now built by deserializing a body, which is how the provider path builds one anyway. Neither type has a constructor, and per this PR's scope note no builder is grown here; serde_json::from_value is the honest fit for a wire-response fixture.
  • tests/providers/anthropic/cassette/stop_sequence_terminal_matrix.rs — two anthropic::streaming::StreamingCompletionResponse functional updates (..Default::default()), now Default::default() plus field assignment. Worth noting because functional update is the break that is easy to forget: #[non_exhaustive] blocks ..Default::default() cross-crate just as it blocks a full literal.

The last two sites are the argument for this PR, in miniature

They did not exist when this branch was cut. They arrived with #2329, merged while this PR was open, and this branch has been rebased onto it. Within a single day, ordinary provider work added two fresh external construction sites to types that were about to grow fields again — which is precisely the churn the attribute converts from "breaking change" into "no change at all". After this PR, the same #2329 could have added those fixtures using Default::default() + assignment and no future field addition would have touched them.

This is a behavioral no-op: the diff is attributes, five construction-site rewrites, and docs. No serde output changes, no runtime behavior changes.

Verification

Mechanical — all green on this branch:

check result
cargo fmt --all clean
RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets --all-features clean
cargo test --workspace --all-features exit 0 — 4,533 tests, 163 suites, 0 failures
cargo test --doc --workspace --all-features exit 0 — 24 suites, 0 failures (the pass that actually exercises cross-crate construction)
cargo check -p rig-core --target wasm32-unknown-unknown clean
cargo doc --workspace --no-deps exit 0 (one pre-existing unrelated intra-doc-link warning)

cargo semver-checks v0.48.0 (same version #2221 reports), three runs:

1. This diff reports only the expected new break. Baseline main, current HEAD:

Checked 196 checks: 195 pass, 1 fail, 0 warn, 57 skip
--- failure struct_marked_non_exhaustive: struct marked #[non_exhaustive] ---
Failed in:
  struct RawStreamingToolCall … struct ToolCall (ollama) … struct StreamingCompletionResponse (gemini)
  struct ToolCall (message) … struct StreamingCompletionResponse (ollama) … struct CompletionResponse (anthropic)
  struct StreamingCompletionResponse (anthropic) … struct Model … struct BilledUnits … struct Usage (anthropic)
  struct StreamingCompletionResponse (openai) … struct PartialUsage … struct Usage (cohere)
  struct CompletionResponse (responses_api) … struct ToolDefinition … struct StreamingCompletionResponse (responses_api)
  struct StreamingCompletionResponse (cohere) … struct ModelList
Summary semver requires new major version: 1 major and 0 minor checks failed

Exactly the 18 attributed types, exactly one lint, nothing else — the break this PR intends and no other.

2. The attribute does what it claims: a future field addition is no longer breaking. On a scratch branch off this one, a dummy pub dummy_semver_probe: Option<u32> added to Model, baselined against this branch:

Checked 196 checks: 196 pass, 57 skip
Summary no semver update required

3. The control — the identical field addition without the attribute is breaking. Same dummy field applied to main instead, baselined against main:

Checked 196 checks: 195 pass, 1 fail, 0 warn, 57 skip
--- failure constructible_struct_adds_field: externally-constructible struct adds field ---
Failed in:
  field Model.dummy_semver_probe in crates/rig-core/src/model/listing.rs:96
Summary semver requires new major version: 1 major and 0 minor checks failed

Both scratch branches were discarded. Together (2) and (3) are the A/B: same field, same tool, breaking without the attribute and non-breaking with it.

Independent review. A reviewer given only the diff, cold, swept every external compilation unit for missed literal / functional-update / exhaustive-destructuring sites across all 18 types (accounting for the ToolCall/CompletionResponse/StreamingCompletionResponse/Usage/ToolDefinition/BilledUnits name collisions), re-derived the census independently from a v0.41.0 diff, and verified every MIGRATING.md table row against the code. It found no P1 or P2 and returned SHIP; its four documentation findings — an overstated "last window" claim, a wrong provider-conversion count, a skip rationale that contradicted the paragraph above it, and the undocumented cohere::embeddings::Meta knock-on — are fixed in the docs commit.

`#[non_exhaustive]` forbids struct-literal construction from other crates,
so it can only be added inside a breaking window. This one is open (#2221,
v0.42.0) and `Model` just took exactly the break the attribute prevents:
#2324 added `max_output_tokens` and broke a downstream struct literal,
while the four rig-agent types in the same PR gained fields harmlessly
because they carry it.

`Model` and `ModelList` are values `list_models()` hands out; callers read
them rather than build them. Public fields stay readable and writable, and
`Model::from_id`/`Model::new`/`ModelList::new` already cover construction,
so the migration is a constructor call plus field assignment.

The only construction site the attribute breaks in-tree is this module's own
rustdoc, which taught the full-literal form under "Create a model with all
fields" — the form anyone following the docs would have written. Doctests
compile as external crates, so that example is rewritten to the assignment
form and `cargo test --doc` now guards it. The two literals in the in-crate
test module are unaffected.

Refs #2325
#2221's `cargo-semver-checks` output is a ready-made census of every
externally-constructible `rig-core` struct that gained a field this cycle —
i.e. every type that already demonstrated it grows fields while lacking
`#[non_exhaustive]`. Sixteen of those rows are values the library hands out
and callers only read, so they take the attribute too:

- `completion::message::ToolCall` (gained `provider`)
- `streaming::RawStreamingToolCall` (gained `tool_id`)
- anthropic: `CompletionResponse`, `Usage`, `ToolDefinition`,
  `streaming::{StreamingCompletionResponse, PartialUsage}`
- openai: `completion::streaming::StreamingCompletionResponse`,
  `responses_api::CompletionResponse`,
  `responses_api::streaming::StreamingCompletionResponse`
- gemini: `streaming::StreamingCompletionResponse`
- ollama: `ToolCall`, `StreamingCompletionResponse`
- cohere: `completion::Usage`, `embeddings::BilledUnits`,
  `streaming::StreamingCompletionResponse`

Two census rows are deliberately skipped, because for them the struct
literal *is* the construction contract and no ergonomic alternative exists:
`message::ToolResult` has no constructor at all and the public
`StreamedUserContent::tool_result` takes one by value, so attributing it
would make it unconstructible outside rig-core (14 external literal sites
today); `anthropic::completion::AnthropicRequestParams` is request-side
input with no `Default`, no `new` and no builder. Both want a constructor
first, which is its own change.

Only two construction sites break in-tree, both root cassette tests
building a cross-provider `message::ToolCall`; `ToolCall::new(id, function)`
is exactly equivalent to the literal they used. Every other attributed type
already had zero struct-literal sites outside `crates/rig-core/src`.

Refs #2325
CHANGELOG gets one breaking entry under Unreleased; MIGRATING gets the
literal-to-constructor recipe, a per-type table of the constructor to start
from, and the recorded reasons for the two skipped types — so the question
stops being re-litigated per PR.

Refs #2325
@gold-silver-copper

Copy link
Copy Markdown
Contributor Author

Superseded by #2335, which removes #[non_exhaustive] from the whole workspace instead of extending it. Closing rather than merging so the attribute is not added and then immediately stripped.

The analysis here is still on record if the decision is ever revisited: #2221's semver output is the census of every externally-constructible rig-core struct that gained a field this cycle, and the per-type verdicts (18 attributed, 2 skipped with reasons) are in the description above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(core)!: add #[non_exhaustive] to Model and ModelList so adding a listing field is not a breaking change

1 participant