v0.42.0
0xPlaygrounds/rigv0.42.0Aug 17, 2026by github-actions[bot]
AI Summary
This release adds support for the Venice AI and Cohere image embedding providers, introduces strict tool use for Anthropic, and addresses multiple security vulnerabilities by bumping dependencies and removing unused code.
Key Highlights
- New Venice AI provider with chat completions, embeddings, and image generation support
- Cohere image embeddings support and Cohere model identifier updates
- Security updates clearing RustSec advisories and bumping lopdf to 0.44
- Breaking changes to response identity metadata and provider request ID handling
Breaking Changes
- Exposing portable model-turn termination metadata to hooks
- Preserving response headers on non-success HTTP errors
- Breaking: lopdf moves 0.41 -> 0.44
- Breaking: OneOrMany becomes Vec
- Breaking: assistant content is tagged and provider extras are a named field
New Features
- VoyageAI embedding request options
- Groq model listing support
- Anthropic per-breakpoint cache TTL
- Doubleword embedding width reporting
- Bedrock guardrail support
Full Release Notes
### Added
- *(voyageai)* expose embedding request options ([#2343](https://github.com/0xPlaygrounds/rig/pull/2343)) (by [sergiomeneses](https://github.com/sergiomeneses))
- *(agent)* [**breaking**] expose portable model-turn termination metadata to hooks ([#2341](https://github.com/0xPlaygrounds/rig/pull/2341)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- carry the provider transport request id on completion errors ([#2314](https://github.com/0xPlaygrounds/rig/pull/2314)) ([#2315](https://github.com/0xPlaygrounds/rig/pull/2315)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2315
- response identity metadata — native response id + provider transport request id, to every completion observer ([#2265](https://github.com/0xPlaygrounds/rig/pull/2265)) ([#2313](https://github.com/0xPlaygrounds/rig/pull/2313)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2313
- *(anthropic)* per-breakpoint cache TTL — static prefix independent of conversation tail ([#2266](https://github.com/0xPlaygrounds/rig/pull/2266)) ([#2312](https://github.com/0xPlaygrounds/rig/pull/2312)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(venice)* add Venice AI provider with live-recorded cassette coverage ([#2306](https://github.com/0xPlaygrounds/rig/pull/2306)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(cohere)* add image embeddings ([#2304](https://github.com/0xPlaygrounds/rig/pull/2304)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(anthropic)* support strict tool use ([#2296](https://github.com/0xPlaygrounds/rig/pull/2296)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
### Fixed
- *(doubleword)* report the embedding width Doubleword actually returns ([#2356](https://github.com/0xPlaygrounds/rig/pull/2356)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(openrouter)* surface chat-completions refusals, and map the reasoning share of usage ([#2358](https://github.com/0xPlaygrounds/rig/pull/2358)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(rig)* exclude test cassettes from the published crate ([#2350](https://github.com/0xPlaygrounds/rig/pull/2350)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(embeddings)* keep a document's embeddings in text order across batch boundaries ([#2348](https://github.com/0xPlaygrounds/rig/pull/2348)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(embeddings)* builder input order ([#2344](https://github.com/0xPlaygrounds/rig/pull/2344)) (by [sergiomeneses](https://github.com/sergiomeneses))
- *(mistral)* eight more provider bugs found by live cassette recording ([#2337](https://github.com/0xPlaygrounds/rig/pull/2337)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(openai)* preserve the provider's response when a websocket upgrade is rejected ([#2338](https://github.com/0xPlaygrounds/rig/pull/2338)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(core)* [**breaking**] preserve response headers on non-success HTTP errors ([#2333](https://github.com/0xPlaygrounds/rig/pull/2333)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- three model-listing and usage bugs found by live cassette recording (anthropic, gemini) ([#2334](https://github.com/0xPlaygrounds/rig/pull/2334)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2334
- *(openai)* six wire-level defects found by live cassette recording ([#2332](https://github.com/0xPlaygrounds/rig/pull/2332)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(mistral)* four provider bugs found by live cassette recording ([#2331](https://github.com/0xPlaygrounds/rig/pull/2331)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(anthropic)* two stop_sequence bugs found by live cassette recording ([#2329](https://github.com/0xPlaygrounds/rig/pull/2329)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(gemini)* four response-mapping bugs found by live cassette recording ([#2328](https://github.com/0xPlaygrounds/rig/pull/2328)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(gemini, agent)* close the whole output-budget truncation chain, not just the 4096 cap ([#2324](https://github.com/0xPlaygrounds/rig/pull/2324)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(rig-core)* enable all-feature wasm builds ([#2319](https://github.com/0xPlaygrounds/rig/pull/2319)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(bedrock)* raw_completion discards provider-only response data (guardrail trace, request id, service tier) ([#2311](https://github.com/0xPlaygrounds/rig/pull/2311)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(bedrock)* ship invocable model identifiers, and keep provider error bodies the SDK cannot classify ([#2309](https://github.com/0xPlaygrounds/rig/pull/2309)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(cohere)* validate required tool choice ([#2302](https://github.com/0xPlaygrounds/rig/pull/2302)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(cohere)* Cohere provider sends request shapes the v2 chat API rejects, and ships removed model IDs ([#2263](https://github.com/0xPlaygrounds/rig/pull/2263)) (by [rleisti](https://github.com/rleisti))
- *(openai)* preserve Responses message phase across stateless replay ([#2269](https://github.com/0xPlaygrounds/rig/pull/2269)) ([#2295](https://github.com/0xPlaygrounds/rig/pull/2295)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(openai)* merge additional_params tools into chat completions tool list ([#1890](https://github.com/0xPlaygrounds/rig/pull/1890)) ([#2294](https://github.com/0xPlaygrounds/rig/pull/2294)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(gemini)* send temperature and max_tokens; add live regression cassettes and a cache-prefix guard ([#2283](https://github.com/0xPlaygrounds/rig/pull/2283)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(anthropic)* prefer `message_delta` usage.input_tokens when the provider sends it there ([#2279](https://github.com/0xPlaygrounds/rig/pull/2279)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(release)* isolate macro hygiene fixture ([#2227](https://github.com/0xPlaygrounds/rig/pull/2227)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
### Other
- reconcile the changelogs and the migration guide with what actually merged ([#2353](https://github.com/0xPlaygrounds/rig/pull/2353)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2353
- *(rig-core)* make a live tracing capture provable, not assumed ([#2347](https://github.com/0xPlaygrounds/rig/pull/2347)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(providers)* share the paginated model-listing loop, and add Groq listing ([#2339](https://github.com/0xPlaygrounds/rig/pull/2339)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(deps)* clear the outstanding security advisories ([#2342](https://github.com/0xPlaygrounds/rig/pull/2342)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- remove #[non_exhaustive] from the workspace ([#2335](https://github.com/0xPlaygrounds/rig/pull/2335)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2335
- *(rig-agent)* share AgentConfig across builder, agent, and runner ([#2326](https://github.com/0xPlaygrounds/rig/pull/2326)) ([#2327](https://github.com/0xPlaygrounds/rig/pull/2327)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- workspace-wide LOC consolidation pass 8 (net −1,353 production lines) ([#2320](https://github.com/0xPlaygrounds/rig/pull/2320)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2320
- *(rig-core)* consolidate provider boilerplate ([#2317](https://github.com/0xPlaygrounds/rig/pull/2317)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- share xAI Responses and audio drivers ([#2316](https://github.com/0xPlaygrounds/rig/pull/2316)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2316
- *(deps)* bump trybuild from 1.0.117 to 1.0.120 ([#2131](https://github.com/0xPlaygrounds/rig/pull/2131)) (by [dependabot[bot]](https://github.com/dependabot[bot]))
- *(deps)* bump surrealdb from 3.0.5 to 3.2.3 ([#2129](https://github.com/0xPlaygrounds/rig/pull/2129)) (by [dependabot[bot]](https://github.com/dependabot[bot]))
- *(deps)* bump tokio-tungstenite from 0.28.0 to 0.29.0 ([#2020](https://github.com/0xPlaygrounds/rig/pull/2020)) (by [dependabot[bot]](https://github.com/dependabot[bot]))
- workspace-wide LOC consolidation pass 7 (net −366 production lines) ([#2310](https://github.com/0xPlaygrounds/rig/pull/2310)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2310
- workspace-wide LOC consolidation pass 6 (net −3,424 lines) ([#2308](https://github.com/0xPlaygrounds/rig/pull/2308)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2308
- reduce Rust cache churn and test rebuilds ([#2307](https://github.com/0xPlaygrounds/rig/pull/2307)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2307
- consolidate provider/loader/agent plumbing (net −566 production LOC) ([#2305](https://github.com/0xPlaygrounds/rig/pull/2305)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2305
- *(deps)* upgrade lopdf to 0.44.0 ([#2297](https://github.com/0xPlaygrounds/rig/pull/2297)) (by [mccormickt](https://github.com/mccormickt))
- remove dead API surface and consolidate provider/agent plumbing (net −794 production LOC) ([#2301](https://github.com/0xPlaygrounds/rig/pull/2301)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2301
- Revert "fix(openai): preserve Responses message phase across stateless replay ([#2269](https://github.com/0xPlaygrounds/rig/pull/2269)) ([#2295](https://github.com/0xPlaygrounds/rig/pull/2295))" ([#2300](https://github.com/0xPlaygrounds/rig/pull/2300)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2300
- consolidate provider schema/normalization and agent plumbing (net −365 production LOC) ([#2299](https://github.com/0xPlaygrounds/rig/pull/2299)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2299
- pin sccache and make its install best-effort ([#2292](https://github.com/0xPlaygrounds/rig/pull/2292)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2292
- consolidate provider scaffolding and agent-runner plumbing (net −439 production LOC) ([#2289](https://github.com/0xPlaygrounds/rig/pull/2289)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2289
- consolidate provider and agent plumbing ([#2288](https://github.com/0xPlaygrounds/rig/pull/2288)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2288
- Fix Neo4j rustdoc link warning ([#2287](https://github.com/0xPlaygrounds/rig/pull/2287)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2287
- Consolidate provider and agent boilerplate ([#2285](https://github.com/0xPlaygrounds/rig/pull/2285)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2285
- consolidate provider plumbing and agent boilerplate (−365 production LOC, 5 defect fixes) ([#2286](https://github.com/0xPlaygrounds/rig/pull/2286)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2286
- Add reasoning delta agent hook ([#2282](https://github.com/0xPlaygrounds/rig/pull/2282)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2282
- *(bedrock)* cover multi-tool_use streaming with a cassette ([#2251](https://github.com/0xPlaygrounds/rig/pull/2251)) (by [kjones](https://github.com/kjones))
- [**breaking**] assistant content is tagged and provider extras are a named field ([#2277](https://github.com/0xPlaygrounds/rig/pull/2277)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2277
- post-Vec-migration precision and the pre-Vec serde accommodations go ([#2276](https://github.com/0xPlaygrounds/rig/pull/2276)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2276
- [**breaking**] `OneOrMany<T>` becomes `Vec<T>` — the fake is deleted, the enforcement moves ([#2273](https://github.com/0xPlaygrounds/rig/pull/2273)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2273
- run rig-derive's test suite, which ran in no job at all ([#2275](https://github.com/0xPlaygrounds/rig/pull/2275)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2275
- single-source the rig setup, drop every API key, and finish the deferred hardening ([#2271](https://github.com/0xPlaygrounds/rig/pull/2271)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2271
- split the PR gate from the full --all-features suite ([#2268](https://github.com/0xPlaygrounds/rig/pull/2268)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2268
- Tool identity holds at every boundary: legacy lift, honest constructors, and the drains the siblings already had (2262 round-7 follow-up) ([#2267](https://github.com/0xPlaygrounds/rig/pull/2267)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2267
- Stream parts become entities: lifecycle grammar, opaque keys, and tool names as data (the 84a43e9e C→B→A program) ([#2262](https://github.com/0xPlaygrounds/rig/pull/2262)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2262
- *(migrating)* document release guide generation ([#2260](https://github.com/0xPlaygrounds/rig/pull/2260)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- Canonical stream grammar: mandatory identity, one accumulator, decode-then-validate, and a wire-conformance corpus ([#2258](https://github.com/0xPlaygrounds/rig/pull/2258)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2258
- Normalize completion responses at the provider boundary and erase the model type at agent construction ([#2257](https://github.com/0xPlaygrounds/rig/pull/2257)) (by [gold-silver-copper](https://github.com/gold-silver-copper)) - #2257
- *(migrating)* extend the guide back to 0.30 ([#2226](https://github.com/0xPlaygrounds/rig/pull/2226)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
- *(changelog)* credit every 0.41 contributor, not just merged-PR authors ([#2223](https://github.com/0xPlaygrounds/rig/pull/2223)) (by [gold-silver-copper](https://github.com/gold-silver-copper))
### Contributors
* [gold-silver-copper](https://github.com/gold-silver-copper)
* [sergiomeneses](https://github.com/sergiomeneses)
* [dependabot[bot]](https://github.com/dependabot[bot])
* [mccormickt](https://github.com/mccormickt)
* [rleisti](https://github.com/rleisti)
* [kjones](https://github.com/kjones)
### Security
- clear the outstanding RustSec / GitHub advisories against the dependency graph. Every one is on a transitive dependency, so Dependabot's version PRs could not reach them and the oldest had been open five months. Ten of the thirteen open GitHub alerts are resolved by lockfile bumps: `openssl` 0.10.78 → 0.10.81, closing the `X509Ref::ocsp_responders` undefined behavior and both AES-KW-PAD memory-safety advisories; `quinn-proto` → 0.11.16; `serde_with` → 3.22.0; `tar` → 0.4.46; `astral-tokio-tar` → 0.6.4; `cmov` → 0.5.4; and `opentelemetry_sdk` 0.31.0, which leaves the graph entirely once the Google Cloud SDK that pinned it moves. The `cargo audit` set adds `ammonia`, `crossbeam-epoch`, `rkyv` 0.8, `anyhow`, `event-listener`, `memmap2` and `spin`. `quick-xml` moves 0.40.1 → 0.41.0, which needs a manifest bump rather than a lockfile one because cargo treats a 0.x minor as a major — rig reaches neither affected code path there (the epub loader uses a plain `Reader`, never `NsReader` or `.attributes()`), so that one is hygiene rather than remediation. Not fixed: the four `rustls-webpki` 0.102.8 advisories, reachable only through rig-agent's optional `discord-bot` feature via `serenity` 0.12.5, which is the newest published release and pins a rustls major with no patched version — rig's default `reqwest`/`rustls` path is on the patched 0.103.x and is unaffected ([#2342](https://github.com/0xPlaygrounds/rig/pull/2342))
- *(deps)* [**breaking**] `lopdf` moves 0.41 → 0.44, clearing [RUSTSEC-2026-0187](https://rustsec.org/advisories/RUSTSEC-2026-0187.html) — a stack overflow on deeply nested PDF objects, reached by `loaders::pdf` on any PDF an agent is handed. Unlike the advisories above, this one sits on a *direct* dependency whose types are in rig-core's public API under the opt-in `pdf` feature: `PdfLoaderError::PdfError` wraps `lopdf::Error`, and `PdfFileLoader::load`/`load_with_path` yield `lopdf::Document`, so a downstream crate that names either type moves to 0.44 in lockstep or ends up with two incompatible `lopdf` in the graph. The bump also turns default features off (`rayon` only), dropping the unused `chrono`, `jiff` and `time` integrations ([#2297](https://github.com/0xPlaygrounds/rig/pull/2297))
### Added
- *(voyageai)* `voyageai::EmbeddingOptions` and `EmbeddingModel::with_options` — Voyage's per-request `input_type` (`"document"` / `"query"`), `truncation` and `output_dimension` had no route through rig at all: the request body was `{model, input}` and nothing else, so a corpus and the queries searching it were embedded with the same prompt-free encoding. Each field is an `Option` and an unset one is omitted rather than sent as a default, so a model built the existing way puts exactly the same bytes on the wire. `ndims()` still reports the width passed at construction, so a narrowed `output_dimension` wants the matching `ndims` ([#2343](https://github.com/0xPlaygrounds/rig/pull/2343))
- *(groq)* model listing: Groq serves `GET /models` — the path its own `VERIFY_PATH` already used — but declared no `model_listing` capability, so `Client::list_models()` was unavailable. Recorded against the live API rather than assumed from Groq being OpenAI-compatible ([#2079](https://github.com/0xPlaygrounds/rig/issues/2079))
- *(moonshot, minimax)* model listing: both serve `GET /models` with the OpenAI-style `{"object":"list","data":[…]}` envelope their own API references document, and neither declared a `model_listing` capability. No credentials were available for either, so each ships the `#[ignore]` live smoke test xiaomimimo and mira already use for this case rather than a cassette — mistral used it too until this same change recorded its listing against the real API ([#2079](https://github.com/0xPlaygrounds/rig/issues/2079))
- *(completion)* `FinishReason::truncated_output()` — whether the provider cut a turn short (`Length`/`ContentFilter`) rather than letting it finish. This is the one home for a rule that decides both whether normalization tolerates a contentless turn and whether rig-agent has a remedy to name for one ([#2332](https://github.com/0xPlaygrounds/rig/pull/2332))
- *(openai)* `OpenAICompatibleProvider::requires_modern_output_cap()` — per-model opt-in to the `max_completion_tokens` spelling, defaulted to `false` so no compatible provider has to change ([#2332](https://github.com/0xPlaygrounds/rig/pull/2332))
- *(openai)* [**breaking**] `openai::TranscriptionUsage` (with `DurationTag`, `TokensTag` and `TranscriptionInputTokenDetails`), reached through the new `openai::TranscriptionResponse::usage` field. `TranscriptionResponse` has public fields, no constructor and no `#[non_exhaustive]`, so code building one with a struct literal — a test double, a hand-made response — must add `usage`; the field is `#[serde(default)]` and deserialize-only in this repo, so every provider that shares the type (Groq, Azure OpenAI, Venice, HuggingFace) keeps decoding responses that omit `usage` ([#2332](https://github.com/0xPlaygrounds/rig/pull/2332))
- *(gemini)* `gemini::completion::attach_trailing_signature(&mut Vec<AssistantContent>, String)` — the one home for where Gemini 3's trailing `thoughtSignature` lands: on the last reasoning block still awaiting a signature, or as a signature-only reasoning part when there is no such block. Public because `rig-gemini-grpc`'s unary mapper answers the same question about the same wire, so both transports normalize the same bytes to the same choice ([#2328](https://github.com/0xPlaygrounds/rig/pull/2328))
- *(azure)* the image-generation capability is declared on the client: `AzureExt` left its `ImageGeneration` slot at `Nothing`, so `ImageGenerationClient::image_generation_model` did not resolve for an Azure client and generic code bounded on `ImageGenerationClient` could not accept one — the `azure::ImageGenerationModel` type itself was already public and constructible through `ImageGenerationModel::make`. The slot is now `Capable`, and the shared JSON image-generation driver it moves onto stops sending the redundant `"model"` key that Azure already names in the deployment path ([#2317](https://github.com/0xPlaygrounds/rig/pull/2317))
- *(vector-store)* `vector_store::request::DynamicSearchFilter` — the single conversion from the canonical `Filter<serde_json::Value>` a type-erased search carries into a backend's native filter type (`from_dynamic_filter`), plus an overridable `normalize_dynamic_document`. `VectorStoreIndexDyn`'s blanket impl now bounds `F: DynamicSearchFilter + WasmCompatSend + WasmCompatSync + 'static` instead of spelling out `Debug + Clone + SearchFilter<Value = serde_json::Value> + Serialize + Deserialize`, which is a widening — a blanket `DynamicSearchFilter` impl covers every JSON-valued filter that already qualified — and it lets a backend with a native value type reach the dynamic surface for the first time: `rig_s3vectors::S3VectorsVectorStore` now implements `VectorStoreIndexDyn` through `impl DynamicSearchFilter for S3SearchFilter`, which its `aws_smithy_types::Document` filter previously blocked. rig-milvus, rig-mongodb, rig-scylladb and rig-surrealdb each replace a hand-written `VectorStoreIndexDyn` impl with one `DynamicSearchFilter` impl, leaving rig-core's blanket impl the only one in the workspace. The dynamic surface's historical payload pruning (arrays over 400 elements dropped) now rides `normalize_dynamic_document`, so it applies to JSON-valued filters only and native-filter backends return their documents verbatim ([#2317](https://github.com/0xPlaygrounds/rig/pull/2317))
- *(completion)* [**breaking**] the provider's transport request id now survives onto **errors**: `ProviderResponseError` gains a `provider_request_id` field — set through `with_provider_request_id` alongside the existing `new`/`without_status` constructors — read via a new `provider_request_id()` accessor on every capability error enum, forwarded through rig-agent's `PromptError`/`StructuredOutputError`, and appended to the error's `Display` as ` (request id: …)`. The unary driver reads it off a failed response through `http_client::Error::InvalidStatusCodeWithDetails`, a new arm on the exhaustive transport `Error` enum whose `Display` is identical to `InvalidStatusCodeWithMessage`; in-band SSE provider error envelopes are stamped with the delivering connection's id, and Bedrock attaches its SDK metadata id. Breaking beyond the new variant: a provider with a request-id contract now classifies **every** non-success completion response as `CompletionError::ProviderResponse` rather than `HttpError` — classification follows the provider's declared contract, never whether a particular response carried the header — so a `match` arm on `CompletionError::HttpError(_)` for those providers' 4xx/5xx stops firing; the `provider_response_*` accessors are shape-independent and keep working, and contract-less providers (gemini, cohere, ollama, the OpenAI-compatible defaults) are unchanged. Census recorded live and pinned by tests: Groq sends `x-request-id` on errors too, xAI sends it on successes but omits it on 4xx, and a failure with no HTTP response at all (connect failure, timeout) has nothing to capture and stays `None` (rig#2314) ([#2315](https://github.com/0xPlaygrounds/rig/pull/2315))
- *(completion, agent)* [**breaking**] response identity metadata reaches every completed model call's observers: `completion::ResponseIdentity { message_id, response_id, provider_request_id }` is the shared carrier for the three distinct id axes — message-scoped, response-scoped, and the provider's transport request id — built by `CompletionResponse::identity()` and `StreamFinal::identity()`, both of which also expose `provider_request_id` directly. Capture is a per-provider contract rather than a header allowlist: Anthropic `request-id` (inherited by its Anthropic-dialect gateway clients), OpenAI on both APIs, xAI, ChatGPT, Groq and Copilot `x-request-id`, and Bedrock's SDK `x-amzn-RequestId` on both the unary and converse-stream surfaces; Gemini, Cohere, OpenRouter and DeepSeek report none and yield `None` — a documented outcome, never an error. In rig-agent the `CompletionResponse`, `StreamResponseFinish` and `ModelTurnFinished` hook events gain `identity: &ResponseIdentity`, and `ModelTurnFinished` fires for every accepted turn on both surfaces, so one observer records identity for every completed call, with each retry reporting its own attempt's ids. Source breaks: `PromptResponse`'s `CompletionCall` is no longer `Copy` (it carries owned identity strings — use `.cloned()` in place of `.copied()`; its new `message_id`/`response_id`/`provider_request_id` fields are serde-defaulted, so pre-identity run JSON still loads), `AgentRun::record_streamed_completion_call` takes the identity as a second argument, `ModelTurn` gains `response_id`/`provider_request_id` with a `with_identity` builder, and hand-constructed hook events must supply the new field (`&ResponseIdentity::default()` preserves the old behavior). See `MIGRATING.md` for each (rig#2265) ([#2313](https://github.com/0xPlaygrounds/rig/pull/2313))
- *(anthropic)* `CompletionModel::with_static_prefix_cache_ttl(CacheTtl)` sets the cache TTL of the static prefix (tool definitions + system prompt) independently of the moving conversation-tail breakpoint, so the mixed configuration Anthropic's pricing rewards is expressible: `1h` on the prefix that is byte-identical across sessions, the 5-minute default on the tail that changes every turn. Composes with `with_prompt_caching`, `with_automatic_caching` and a raw top-level `cache_control`; unset, every existing constructor's request bytes are unchanged, and setting the prefix to `FiveMinutes` under a 1h top-level TTL fails client-side with an error naming both knobs ([#2312](https://github.com/0xPlaygrounds/rig/pull/2312))
- *(anthropic)* [**breaking**] `anthropic::completion::Usage` and `anthropic::streaming::PartialUsage` parse the per-TTL `cache_creation` breakdown (`CacheCreation { ephemeral_5m_input_tokens, ephemeral_1h_input_tokens }`) alongside the preserved `cache_creation_input_tokens` aggregate, and the streaming adapter carries the split from `message_start` — the only frame Anthropic reports it on — onto the terminal record. Both structs gain a public field and neither is `#[non_exhaustive]`, so code constructing them with a full struct literal must add it ([#2312](https://github.com/0xPlaygrounds/rig/pull/2312))
- *(bedrock)* `CompletionModel::with_guardrail` attaches a Bedrock guardrail (identifier, version, trace mode) to every Converse request the model issues; requests previously had no way to carry `guardrailConfig` at all, which also made the response-side trace unreachable. `types::converse_output` and `types::assistant_content` are public modules, so a caller can finally name `AwsConverseOutput` — the type `raw_completion` hands back ([#2311](https://github.com/0xPlaygrounds/rig/pull/2311))
- *(venice)* new provider for the [Venice](https://docs.venice.ai) API (`providers::venice`): chat completions and streaming — tools, vision, structured output — over the shared OpenAI-compatible path, plus embeddings, `GET /models` listing, transcription, Venice's native `POST /image/generate` (feature `image`) and `POST /audio/speech` (feature `audio`). Configure with `VENICE_API_KEY` and the optional `VENICE_BASE_URL`. Venice's own `venice_parameters` request block is the serializable `VeniceParameters` (web search via `WebSearchMode`, thinking control, characters, a prompt-cache routing key); the response type flattens OpenAI's payload and adds `venice_parameters: Option<VeniceParametersEcho>` — carrying the resolved block and its `WebSearchCitation` list — and `cost: Option<Cost>`, both reachable through `raw_completion` ([#2306](https://github.com/0xPlaygrounds/rig/pull/2306))
- *(embeddings, cohere)* image embeddings: `embeddings::ImageEmbeddingModel` is a new public trait (`MAX_DOCUMENTS`, `ndims()`, `embed_images()` over encoded file bytes, and a defaulted `embed_image()`), and `cohere::ImageEmbeddingModel` — built with `cohere::Client::image_embedding_model()` — is its first implementation: `embed-english-v3.0` with `MAX_DOCUMENTS = 1`, because Cohere takes one image per request, so a batch is sent as ordered individual calls, and `ndims() == 1024`. PNG, JPEG, GIF and WebP are recognized from their leading magic bytes, and anything else — or anything over 5,000,000 bytes — is rejected as `EmbeddingError::DocumentError` before a request goes out. The returned `Embedding::document` is `"{media_type};sha256={base64url}"` rather than the image, widening that field's contract: for a non-text embedding it holds a non-sensitive identifier for the input, so an embedding store built from these never holds a reversible copy. `cohere::embeddings::BilledUnits` gains a public `images: u32` to carry what Cohere bills for them; the struct has public fields and no constructor, so code building one with a struct literal must add the field, and its `Display` now appends `Images: {n}` on its own line when the count is non-zero ([#2304](https://github.com/0xPlaygrounds/rig/pull/2304))
- *(anthropic)* [**breaking**] opt-in strict tool use: `anthropic::completion::CompletionModel::with_strict_tools()` marks every Rig-generated tool `strict: true`, so Anthropic constrains the model's tool arguments to the declared `input_schema` instead of merely being prompted with it. `anthropic::completion::ToolDefinition` gains a public `strict: bool` — `#[serde(default, skip_serializing_if = "is_false")]`, so the request body is byte-identical while the flag is off and 0.41-persisted tool definitions still load, but the type has all-public fields and no `#[non_exhaustive]`, so external code building one with a full struct literal must add the field. Anthropic compiles a strict schema for constrained decoding and therefore accepts only a subset of JSON Schema, so rig rewrites each generated schema to fit: `additionalProperties: false` on every object, a local root `$ref` inlined, a root `allOf` flattened, string `format` kept only for the ten Anthropic supports (`date-time`, `time`, `date`, `duration`, `email`, `hostname`, `uri`, `ipv4`, `ipv6`, `uuid`), and `minItems` kept only when it is 0 or 1. Every other validation keyword is moved into the property's `description` as model guidance and is enforced by neither side, so keep validating tool inputs before execution when those constraints matter. Tools supplied through `additional_params` pass through untouched, and the new `AnthropicCompatibleProvider::enable_strict_tool_use` hook defaults to a no-op, so the Anthropic-compatible gateways sharing this model (minimax, moonshot, xiaomimimo, zai) send exactly the bytes they always did ([#2296](https://github.com/0xPlaygrounds/rig/pull/2296))
- *(cohere)* the model identifiers Cohere actually serves: `COMMAND_A_PLUS_05_2026`, `COMMAND_A_03_2025`, `COMMAND_A_REASONING_08_2025`, `COMMAND_A_VISION_07_2025`, `COMMAND_A_TRANSLATE_08_2025`, `COMMAND_R7B_12_2024`, `COMMAND_R_PLUS_08_2024` and `COMMAND_R_08_2024` for completions, plus `EMBED_V4` (`embed-v4.0`) for embeddings — the last wired into `model_dimensions_from_identifier` at 1536, so an `EMBED_V4` model reports its real width instead of the zero every unlisted identifier defaults to. The crate's own doc example moves from `cohere::COMMAND_R` to `cohere::COMMAND_A_03_2025` ([#2263](https://github.com/0xPlaygrounds/rig/pull/2263))
### Fixed
- *(openrouter)* a structured-output **refusal** no longer fails the turn. OpenRouter forwards OpenAI's chat-completions spelling verbatim — the refusal is a *sibling* of `content` (`{"content": null, "refusal": "I'm sorry, I can't assist with that request."}`), not a content part — and OpenRouter re-implements `NormalizeCompletionResponse` by hand rather than going through the shared OpenAI normalizer, so its destructure absorbed `refusal` into `..`. The turn then normalized to zero content and failed with the opaque `Response contained no message or tool call (empty)`. Two surfaces already disagreed with that on the same bytes: `ProviderResponseExt::get_text_response` routes through `assistant_message_text_response`, which applies the fallback, and the *streaming* path uses the shared `delta_text`, which prefers a non-empty `refusal` — so the same request streamed the refusal fine and only its blocking twin failed. OpenRouter now shares the one whole-message rule the OpenAI chat paths already share (`assistant_refusal_fallback`, [#2332](https://github.com/0xPlaygrounds/rig/pull/2332)) instead of growing a second one. A turn that carries real content is unchanged, byte for byte ([#2358](https://github.com/0xPlaygrounds/rig/pull/2358))
- *(openrouter)* [**breaking**] `Usage.reasoning_tokens` is no longer a hardcoded zero. OpenRouter documents usage accounting as always included, and every reasoning route reports `usage.completion_tokens_details.reasoning_tokens` — 1,984 of 1,984 completion tokens on a recorded `openai/o4-mini` turn, 531 of 540 on an `anthropic/claude-haiku-4.5` one, 1,702 of 2,000 on a `deepseek/deepseek-r1-0528` one. `openrouter::Usage` modeled no `completion_tokens_details` field at all, so the object was dropped at deserialization and `From<&Usage> for completion::Usage` ended with a literal `reasoning_tokens: 0`; because the same type is `OpenRouterExt::StreamingUsage`, the streaming terminal record reported the same zero, which is why the transports agreed and nothing caught it. rig's normalized `Usage` has a first-class `reasoning_tokens` slot that openai, deepseek, gemini and anthropic all fill, and it is recorded onto the `gen_ai.usage.reasoning_tokens` telemetry span, so on OpenRouter that span and every caller reading the field saw zero no matter what the route billed. The new `openrouter::CompletionTokensDetails` is deserialize-tolerant (absent, `null`, `{}` and unmodeled siblings all read as zero) and is skipped on serialization when absent, so nothing rig sends changes. Breaking only at the source level: `openrouter::Usage` has all-public fields and no `#[non_exhaustive]`, so code building one with a *full* struct literal — or destructuring one with an exhaustive `let Usage { .. }` pattern — must account for `completion_tokens_details`. The type derives `Default`, so `..Default::default()` keeps working on the construction side and `..` on the pattern side, and every decode path is unchanged ([#2358](https://github.com/0xPlaygrounds/rig/pull/2358))
- *(doubleword)* embedding models now report the width Doubleword actually returns, and a caller-requested width actually reaches the wire. `Qwen/Qwen3-Embedding-8B` — the provider's only embedding model, and a public const — was absent from OpenAI's dimension table and Doubleword implemented no `default_ndims`, so `client.embedding_model(QWEN3_EMBEDDING_8B).ndims()` was `0` against 4096-wide vectors; a vector store sized from that number (`rig-neo4j` validates and creates its index with it, `rig-sqlite` sizes its table from it) got a zero-width index. In the other direction `embedding_dimensions` returned `Ok(None)` unconditionally, so `embedding_model_with_ndims(QWEN3_EMBEDDING_8B, 512)` reported 512 and received 4096: the `dimensions` field was never sent, though Doubleword's model page documents "Output Dimensions: 32-4096 Configurable" and the live API honours it exactly. Both are now driven by one table of documented widths. Asking for the native width still sends no `dimensions`, so every already-recorded default-width request is byte-identical. An explicit `ndims` of `0` is rejected before sending for every Doubleword model; previously the shared OpenAI-compatible path erased the fact that zero was explicit, sent no field, and left `ndims()` reporting `0` against the native 4096-wide response. For the known Qwen model, every other width outside 32-4096 is also an `EmbeddingError::InvalidParameterValue` raised before the request is built, because Doubleword answers an over-wide request `200 OK` with a silently clamped 4096-wide vector and is unreliable below the floor. A positive width for an embedding model Rig has no table for is still sent unvalidated for the API to rule on, where previously no Doubleword model sent `dimensions` at all ([#2356](https://github.com/0xPlaygrounds/rig/pull/2356))
- *(providers)* a `max_tokens`-truncated tool call no longer destroys the whole blocking response. An OpenAI-compatible provider still emits the tool call when the budget runs out mid-arguments: the turn comes back with `finish_reason: "length"` and `tool_calls[].function.arguments` cut off partway through the JSON object — reproduced live against DeepSeek at 24, 32, 48 and 64-token budgets (`{"summary": `, `{"summary": "Log this incident: the`, …), and previously against Mistral at 24/32/48/64/96. `openai::Function`, `deepseek::Function` and `mistral::Function` all parsed that strictly, so the *whole* `CompletionResponse` failed to decode and the turn's text, usage, id, model and finish reason went with it, while the streaming path kept the turn and dropped the unusable call — the two transports disagreeing about identical wire bytes. On a parallel turn it lost a *complete* call too: at a 56-token cap DeepSeek returns `page_oncall` with full arguments beside a truncated `file_report`, and blocking lost both. [#2337](https://github.com/0xPlaygrounds/rig/pull/2337) fixed Mistral's copy and deferred the shared one as "a wider change than this PR should carry"; this is that change. The shared response-choice decoder now authorizes tolerance only when the outer `finish_reason` maps to `Length`; it drops calls whose argument string is empty or unparseable, while preserving complete siblings and the rest of the turn. An ordinary completed `tool_calls` response with malformed JSON remains a decode error, as do compound defects such as a missing `id` or unknown `type` beside truncated arguments. Streaming applies the same boundary: an empty argument slot under `Length` is incomplete and cannot dispatch a zero-argument side-effect tool, while an empty slot under `ToolCalls` remains the deliberate parameterless invocation `{}`. Groq's parameterless `"arguments": "null"` remains a real call on either path. Mistral's provider-local `deserialize_truncatable_arguments` is removed in favour of the shared policy. The blast radius is every provider whose `Response` is `openai::CompletionResponse` — openai, azure, groq, together, huggingface, hyperbolic, perplexity, moonshot, minimax, llamafile, xiaomimimo, doubleword, zai, Copilot's chat route and (by delegation) venice — plus openrouter, deepseek and mistral ([#2359](https://github.com/0xPlaygrounds/rig/pull/2359))
- *(deepseek)* the reasoning block now leads a normalized blocking choice instead of trailing it. `NormalizeCompletionResponse` built `[text?, tool_call…]` and then pushed the `reasoning_content` block onto the end, so a reasoner turn that called a tool normalized to `[text, tool_call, reasoning]` — while DeepSeek's own stream delivers every `reasoning_content` delta before the first `content` delta and before the tool call (27 reasoning chunks then 12 tool-call chunks in the recorded `reasoning_tool_roundtrip/streaming` fixture), and the shared canonical chunk lifecycle fixes that same order. The two transports returned differently ordered choices for the same turn. No data was lost, only reordered — but a caller rendering blocks in order saw the model's reasoning after the action it explained ([#2359](https://github.com/0xPlaygrounds/rig/pull/2359))
- *(openrouter)* blocking reasoning and `reasoning_details` now precede text and tool calls, matching the shared streaming lifecycle. The provider-local normalizer previously appended reasoning last, so an Anthropic-routed turn carrying reasoning beside one or more tool calls normalized as `[tool_call…, reasoning]` while the streamed twin was `[reasoning, tool_call…]`. Anthropic routes also finish streamed plaintext reasoning with a signature-only `reasoning.text` detail; that signature was ignored, so the tool turn replayed unsigned even though blocking preserved it. Streaming now attaches the signature to the accumulated reasoning block before the tool call, and the signed block serializes into the live follow-up request. A six-cell blocking/streaming matrix pins the routed provider, reasoning/signature wire premises, single/parallel call order and cardinality, plus blocking/streaming two-turn agent replay ([#2359](https://github.com/0xPlaygrounds/rig/pull/2359))
- *(deepseek)* a non-text user content part is no longer silently deleted from the request. DeepSeek takes message `content` as a plain string, so `finalize_request_body` flattens content-part arrays — and it passed `only_if_all_text = false`, which *drops* every non-text part. An attached image, audio clip or PDF simply vanished and DeepSeek answered the question from the remaining text alone, with nothing anywhere reporting the loss. It now passes `true`, matching Perplexity, the tree's other plain-text-only provider: an all-text array still flattens to the same plain string (no recorded request body moved), and an array carrying a non-text part rides the wire so DeepSeek's own rejection reaches the caller — `400 Failed to deserialize the JSON body into the target type: messages[0]: unknown variant \`image_url\`, expected \`text\``, verified live ([#2359](https://github.com/0xPlaygrounds/rig/pull/2359))
- *(providers)* [**breaking**] raw OpenAI-compatible streams retain token log probabilities instead of discarding every chunk's `choices[0].logprobs`. The blocking native response types already model the field, but the shared streaming `StreamingChoice` did not, so serde ignored DeepSeek's recorded `content` and `reasoning_content` probability arrays before `raw_stream` could expose them. `openai::completion::StreamingCompletionResponse` now gains `logprobs: Option<serde_json::Value>` and the compatible adapter deep-merges every primary-choice object in arrival order, concatenating nested token arrays; `null` and `{}` canonicalize to absence, while non-object values remain response errors. Normalized streaming remains unchanged, matching the blocking normalizer, which likewise leaves provider-native log probabilities on its raw response. The public-field addition is the source break for code constructing or exhaustively destructuring the terminal type. Recorded as the complete 24-cell transport × thinking × termination × top-candidate matrix against DeepSeek ([#2359](https://github.com/0xPlaygrounds/rig/pull/2359))
- *(openai, openrouter, mistral)* [**breaking**] provider-native Chat Completions responses retain the top-level metadata their live APIs return. The shared raw-stream terminal gains `additional_params: Option<AdditionalParams>` and accumulates otherwise-unmodeled top-level chunk fields instead of discarding them, preserving OpenAI's and OpenRouter's `service_tier`/`system_fingerprint` and OpenRouter's routed `provider`. Blocking OpenAI gains `CompletionResponse::service_tier`; blocking OpenRouter gains `CompletionResponse::{provider, service_tier}`; and Mistral gains `Usage::service_tier`, matching its live `usage.service_tier` extension even though the generated API schema currently omits it. Each field is optional, serde-defaulted, and omitted when absent, so old persisted payloads still load and normalized responses do not change. The break is limited to full struct literals and exhaustive destructures of these public types ([#2359](https://github.com/0xPlaygrounds/rig/pull/2359))
- *(openrouter)* [**breaking**] blocking `raw_completion` retains `choices[].logprobs`. OpenRouter already returned the object live and the raw streaming path now preserves its chunked twin, but OpenRouter's provider-local blocking `Choice` did not model the field, so serde silently deleted it. `openrouter::completion::Choice` gains an optional `logprobs` field; normalized completion remains unchanged. Existing payloads still deserialize, while a full struct literal or exhaustive destructure must account for the new field ([#2359](https://github.com/0xPlaygrounds/rig/pull/2359))
- *(rig)* exclude `tests/cassettes/**` from the published crate. The root manifest *is* the `rig` facade package, so every sibling path was swept into the tarball — 1,100+ provider replay cassettes among them, which had grown the upload to 9.51 MiB against crates.io's 10 MiB ceiling. That ceiling is enforced server-side, so `cargo publish --dry-run` reports success right up until the real upload fails, and a partially published 22-crate release burns versions that cannot be reused. The tarball drops to 3.33 MiB and the file count from 1,704 to 589; the fixtures are read at runtime from `CARGO_MANIFEST_DIR`, never embedded with `include_str!`/`include_bytes!`, so nothing in the package needed them ([#2350](https://github.com/0xPlaygrounds/rig/pull/2350))
- *(openrouter)* model listings now report `context_length` and `max_output_tokens`. A stray `#[serde(rename_all = "camelCase")]` on the listing entry made serde look for `contextLength`, which OpenRouter never sends, so every model's context window decoded as `None` while the response carried a real one (262144 for the first entry in the recorded fixture). The output ceiling, which OpenRouter reports under `top_provider.max_completion_tokens`, was not read at all — the same class of drop [#2322](https://github.com/0xPlaygrounds/rig/issues/2322) added `max_output_tokens` to prevent. Found by recording the listing matrix ([#2079](https://github.com/0xPlaygrounds/rig/issues/2079))
- *(providers)* a model listing no longer fails outright because one entry omits `created` or `owned_by`. Four providers hand-wrote near-identical listing entries with those fields required — OpenAI's and Mistral's demanded `created` and `owned_by`, DeepSeek's and Xiaomi MiMo's demanded `owned_by` — and the `{"data": [...]}` envelope decodes as a single value, so one entry missing one key failed the entire `list_models()` call with a serde error instead of returning the models the response did describe. The four are replaced by one shared entry in which `id` is the only required field, pinned by a decodes-from-`{"id":"…"}`-alone test. (Mistral has since taken its own entry back, under the same all-optional rule, to keep the `description`/`max_context_length`/`type` keys its listing carries.) The shared entry also reads `name` and `created`, which DeepSeek's and Xiaomi MiMo's own entries never modeled, so those keys now reach `Model::name`/`Model::created_at` when a listing sends them rather than being unconditionally `None` — DeepSeek's live listing sends neither, so its models are unchanged in practice ([#2289](https://github.com/0xPlaygrounds/rig/pull/2289))
- *(gemini)* model-listing failures now classify as `ModelListingError::ApiError` with the provider label, path, status and body preview, instead of a bare `RequestError`. Gemini built its own request and let a transport-level send error convert directly; because the reqwest transport reports a non-2xx *as an error* before returning a response, that was the path every real listing failure took, and Gemini's own status-check branch was dead — the same dead-arm shape [#2315](https://github.com/0xPlaygrounds/rig/pull/2315) fixed for `verify`. Routing through the shared fetch aligns Gemini with every other lister. Callers matching `RequestError` on a failed Gemini listing must match `ModelListingError::ApiError { status_code, message }` instead — the enum exposes no accessors, only the `api_error`/`request_error`/`parse_error` constructors, and the status previously buried in the message string is now a field ([#2079](https://github.com/0xPlaygrounds/rig/issues/2079))
- *(embeddings)* `EmbeddingsBuilder` now returns each document's embeddings in text order. [#2344](https://github.com/0xPlaygrounds/rig/pull/2344) fixed the order of the returned pairs; the embeddings *within* a document were still shuffled whenever its texts straddled a `MAX_DOCUMENTS` batch boundary, because concurrent batches were appended in completion order — a six-text document came back as `[t5, t0, t1, t2, t3, t4]`. Since the derived `Embed` impl emits fields in declaration order, a caller reading `embeddings[j]` as "field j" got a neighbour's vector. Every text now carries a slot index through batching, so completion order cannot reorder anything. A provider returning fewer embeddings than the texts sent is also now a located error rather than a silently short list, and both that error and the pre-existing "document embedded no text" error now name the offending document — the latter's wording changed from `missing embedding for document after batch merge`, so anything matching on that string needs updating ([#2345](https://github.com/0xPlaygrounds/rig/issues/2345))
- *(embeddings)* `EmbeddingsBuilder` returns its `(document, embeddings)` pairs in the order the documents were added. The builder collected documents into a `HashMap<usize, T>` and built the result by iterating it, so the sequence came back in arbitrary hash order — each pair was internally correct, but a caller zipping the result against a parallel list of its own (ids, labels, metadata) lined up the wrong rows, and `InMemoryVectorStore::add_documents`, which mints `doc{n}` ids from this sequence's position, handed the same document a different id on every run. Documents are now held in a `Vec` and merged by position ([#2344](https://github.com/0xPlaygrounds/rig/pull/2344))
- *(mistral)* stop sending a forced `tool_choice` beside a *structured* `response_format`: Mistral rejects that combination outright (`"`json_schema` response type with tools is only compatible with `tool_choice: auto`"`), and rig built it by itself — a structured-output agent defers `response_format` until a tool result exists, then emits it beside the caller's standing `tool_choice`, so the turn *after* the first tool call died with a 400, once the tool had already run. Finalization now relaxes the choice to `auto` for that turn, keeping the caller's schema. The relaxation is keyed on the format's `type`, not its presence: it fires only for `json_schema` or `json_object` alongside a non-empty `tools` array, so an explicit `{"type": "text"}` — the API default — keeps the caller's forced choice ([#2337](https://github.com/0xPlaygrounds/rig/pull/2337))
- *(mistral)* stop losing a whole response to a truncated tool call: when `max_tokens` runs out mid-arguments Mistral still emits the tool call, with `arguments` cut off partway through the JSON. Parsing it strictly failed the entire response — text, usage, id and finish reason with it — while the streaming path kept the turn and dropped the unusable call. The blocking path now agrees: the turn survives and its `Length` finish reason reports the truncation ([#2337](https://github.com/0xPlaygrounds/rig/pull/2337))
- *(providers)* stop merging candidates on OpenAI-compatible streams: with `n > 1` the wire interleaves chunks distinguished only by `choices[].index`, and the adapter took each *chunk's* first choice — concatenating every candidate into one answer the model never produced, while the blocking path answered the same request from candidate 0 alone. The stream now selects candidate 0 too, so the two transports agree. Single-candidate streams are unaffected ([#2337](https://github.com/0xPlaygrounds/rig/pull/2337))
- *(mistral)* count audio tokens as prompt input: Mistral's audio models report `prompt_tokens_details.audio_tokens` *alongside* `prompt_tokens` rather than inside it, so a Voxtral turn's normalized usage had `input_tokens + output_tokens` short of `total_tokens` by the entire audio payload (6 + 2 against a reported total of 383). `Usage::input_tokens()` now includes them ([#2337](https://github.com/0xPlaygrounds/rig/pull/2337))
- *(mistral)* chunk embedding jobs at Mistral's real batch cap: `EmbeddingsBuilder` used the shared OpenAI cap of 1024 inputs per request, but Mistral rejects anything over 256 with `"Too many inputs in request, split into more batches."` — so any job over 256 documents failed outright. `OpenAIEmbeddingsCompatible` gains a `MAX_DOCUMENTS` associated const (still 1024 by default) and Mistral sets it to 256 ([#2337](https://github.com/0xPlaygrounds/rig/pull/2337))
- *(mistral)* report real embedding dimensions: `mistral-embed` is 1024-wide, but the default-dimension lookup consulted OpenAI's table only, so every Mistral embedding model reported `ndims() == 0` unless the caller passed the width explicitly. `OpenAIEmbeddingsCompatible` gains a `default_ndims` hook; asking `mistral-embed` for its own native width is now a no-op rather than an `UnsupportedParameter` error, since the width is not a request field for that model ([#2337](https://github.com/0xPlaygrounds/rig/pull/2337))
- *(mistral)* keep `description`, `max_context_length` and `type` when listing models: the shared OpenAI-shaped listing entry models none of them, so `Model::description`, `Model::context_length` and `Model::type` were always `None` even though Mistral sends all three ([#2337](https://github.com/0xPlaygrounds/rig/pull/2337))
- *(core)* [**breaking**] preserve a failed response's headers so `Retry-After` survives onto the error: the transport already captured the header map (rig#2314 needed it to read each provider's request-id header), but every normalization path then discarded it, leaving rate-limit metadata structurally unrecoverable — including by Rig's own `http_client::retry::RetryPolicy::retry`, which is handed the error. Headers now ride through all four shared request drivers (completion, transcription, image generation, audio generation), the SSE handshake conversion, and `VerifyClient::verify`, reachable via a new `provider_response_headers()` on every capability error, `PromptError`/`StructuredOutputError`, and `http_client::Error::non_success_headers()`. `None` means "not captured", never "the response had no headers". Note for callers matching the transport error directly: contract-less providers' non-success completions and all three `verify()` branches now yield `http_client::Error::InvalidStatusCodeWithDetails` rather than `InvalidStatusCodeWithMessage` — both are `HttpError`, both `Display` identically, and every `provider_response_*` helper reads both, but a `match` arm naming the old variant will stop firing. Breaking for one further reason: `ProviderResponseError` gains a public `headers` field, and since #2335 removed `#[non_exhaustive]` workspace-wide, external code building it with a full struct literal must add the field — the `new`/`without_status` constructors and `with_*` setters are unaffected ([#2333](https://github.com/0xPlaygrounds/rig/pull/2333))
- *(anthropic)* [**breaking**] carry extended-thinking tokens into normalized usage: Anthropic reports the tokens Claude spent thinking as `usage.output_tokens_details.thinking_tokens`, and neither `Usage` nor the streaming `PartialUsage` modeled the field — so serde dropped it and `completion::Usage::reasoning_tokens` stayed `0` on every turn, blocking and streaming alike, while that field's own docs name "Anthropic extended thinking" and both Gemini and DeepSeek populate theirs. The value is a breakdown of `output_tokens` rather than a sibling of it, so it populates `reasoning_tokens` without entering `total_tokens`. `anthropic::completion::Usage` gains `output_tokens_details` and `anthropic::streaming::PartialUsage` gains the same field; neither is `#[non_exhaustive]`, so code constructing them with a full struct literal must add it ([#2334](https://github.com/0xPlaygrounds/rig/pull/2334))
- *(anthropic)* stop the model-listing pagination loop from spinning forever: `list_all` ended its loop on `has_more` and then assigned the page's optional `last_id` to the cursor, so a page reporting more pages with no cursor re-requested the uncursored first page indefinitely, appending its models on every pass — an unbounded loop rather than a truncated list. Termination now follows the cursor, as Gemini's lister already did — an empty cursor reads as absent, and a cursor that repeats ends the listing too, since the next request would be byte-identical to the one just answered. The cursor is also percent-encoded into the query string rather than interpolated ([#2334](https://github.com/0xPlaygrounds/rig/pull/2334))
- *(gemini)* stop the model-listing pagination loop on an empty cursor: `parse_models_page` returned `nextPageToken` verbatim, so `""` read as a cursor rather than as "no more pages" — the loop then re-sent an empty `pageToken`, received the same page, and never returned. An empty cursor is now read as absent, matching the Anthropic lister and how every other provider-reported identifier in rig is read; a cursor that repeats ends the listing too ([#2334](https://github.com/0xPlaygrounds/rig/pull/2334))
- *(openai)* preserve the provider's response when a Responses **websocket** upgrade is rejected: the upgrade is a plain HTTP request until the provider accepts it, and a rejected one answers with a status, an `x-request-id` and a JSON error body — a live handshake with an invalid key returns `401` with `{"error":{"code":"invalid_api_key",…}}`. `tungstenite` hands all three back, and rig flattened them to `ProviderError("HTTP error: 401 Unauthorized")`, so `provider_response_status()`, `provider_response_body()` and `provider_request_id()` were all `None` and a bad key was indistinguishable from a network fault. A rejected upgrade now classifies as `CompletionError::ProviderResponse` with the body, the id and the rejection's headers attached, so a `429` upgrade's `Retry-After` reaches `provider_response_headers()` the same way [#2333](https://github.com/0xPlaygrounds/rig/pull/2333) routed it on the shared request drivers and the SSE handshake — the contract the crate's other two completion transports have kept since [#2314](https://github.com/0xPlaygrounds/rig/issues/2314)/[#2315](https://github.com/0xPlaygrounds/rig/pull/2315) (the blocking path through `send_completion`, the SSE connect path through `sse_transport`), and which the websocket never had; failures that never reached the provider stay `ProviderError` ([#2338](https://github.com/0xPlaygrounds/rig/pull/2338))
- *(providers)* map the `model_length` finish reason onto `FinishReason::Length` in the shared OpenAI-compatible mapper. Mistral spells context-window exhaustion `model_length`; it previously fell through to `FinishReason::Other("model_length")`, so a turn truncated by the context window was indistinguishable from one that simply stopped — and `rig-agent`'s truncation detection, which deliberately excludes `Other`, never fired for it. OpenRouter's own mapper already folded the same spelling in ([#2331](https://github.com/0xPlaygrounds/rig/pull/2331))
- *(mistral)* `Client::verify()` now resolves against `/v1/models` instead of `/models`. Mistral's client base URL is the bare host, so the unversioned path was a gateway 404 (`no Route matched with those values`) and verification failed for every key, valid or not ([#2331](https://github.com/0xPlaygrounds/rig/pull/2331))
- *(mistral)* capture the provider transport request id: Mistral reports one as `mistral-correlation-id` on every response, but `REQUEST_ID_HEADER` was left at its conservative `None` default, so `provider_request_id` was always `None` on completions, streams and errors alike ([#2331](https://github.com/0xPlaygrounds/rig/pull/2331))
- *(mistral)* stop silently dropping every non-text content part: `finalize_request_body` flattened each message's content with the text-only helper, which keeps only parts carrying a `text`/`refusal` key, so an attached image, audio clip or document was removed from the request and the caller got an ordinary completion answering a prompt it never sent. Mistral's content chunks are now emitted for real — images and audio forward as `image_url` and `input_audio`, and documents map onto `document_url` (inline base64, with the filename in `document_name`) or Mistral's `file` chunk (an uploaded `file_id`), which makes vision and document Q&A reachable through rig for the first time. Text-only content still flattens to Mistral's plain string, and content Mistral has no chunk for — video, and any part type a future conversion adds — now fails with a `CompletionError::RequestError` (wrapping `MessageError::ConversionError`) instead of being removed. Note the other side of the same change: because attachments now actually reach the API, prompting a model without the matching capability surfaces Mistral's rejection where it previously returned a plausible answer to a request the attachment never reached ([#2290](https://github.com/0xPlaygrounds/rig/issues/2290), [#2291](https://github.com/0xPlaygrounds/rig/issues/2291))
- *(anthropic)* [**breaking**] carry `stop_sequence` on the streamed terminal record: Anthropic's terminal `message_delta` reports which of the caller's `stop_sequences` matched, and the adapter parsed that field and then dropped it — so a streamed turn could report only that *a* sequence fired while its blocking twin (`CompletionResponse::stop_sequence`) named it, and Anthropic strips the matched sequence from the text so the frame was the only source. `anthropic::streaming::StreamingCompletionResponse` gains a `stop_sequence` field stamped from that frame; it is not `#[non_exhaustive]`, so code constructing it with a full struct literal must add the field or switch to `..Default::default()`. The Anthropic-compatible gateways sharing this adapter (minimax, moonshot, xiaomimimo, zai) get the field too ([#2329](https://github.com/0xPlaygrounds/rig/pull/2329))
- *(anthropic)* stop turning a completed stop-sequence turn into an error: when the matched sequence is the first thing the model emits, Anthropic strips it and returns `content: []` with a 200, and the empty-content carve-out in `CompletionResponse::normalize` covered only `end_turn` — so that turn became `ResponseError("Response contained no message or tool call (empty)")`, discarding the usage, message id, transport request id and finish reason it carried. The streamed twin of the same request already finished cleanly with an empty choice, making this a blocking/streaming divergence. `stop_sequence` now joins `end_turn` as a legal empty case, but only when the response names the sequence that fired; every other empty response stays guarded ([#2329](https://github.com/0xPlaygrounds/rig/pull/2329))
- *(openai)* stop dropping Chat Completions refusals: OpenAI spells a structured-output refusal as a *sibling* of `content` (`{"content": null, "refusal": "…"}`) and streams it on `delta.refusal`, not as the `refusal` content *part* rig modeled — which is the Responses API's shape. A refused turn therefore failed with the opaque `Response contained no message or tool call (empty)` on the blocking path, streamed no text at all, and could not be converted back into rig history, while `get_text_response` (which already fell back to the field) reported it and the Responses surface handled the same request fine. The three unary paths now share one whole-message rule; the streaming path applies the same intent per delta, which it must, since it cannot know whether text arrives later. Affects every OpenAI-compatible provider, which reuses these wire types ([#2332](https://github.com/0xPlaygrounds/rig/pull/2332))
- *(openai)* stop turning a truncated Chat Completions turn into an opaque error: a reasoning model whose output-token cap is consumed entirely by hidden reasoning answers with an empty message and `finish_reason: "length"`, which normalization rejected — discarding both the reason and the usage. A turn the provider *cut short* (`length`, `content_filter`) may now be contentless and reaches the caller with its finish reason; a turn that ran to completion with nothing in it is still an error. This matches the Responses API's `status: incomplete` rule and the streaming path, which already behaved this way, and applies to every OpenAI-compatible provider. Note the consequence for callers of `CompletionModel::completion` directly: such a turn now returns `Ok` with an empty `choice` where it returned `Err` — see `MIGRATING.md` ([#2332](https://github.com/0xPlaygrounds/rig/pull/2332))
- *(openai)* send the output-token cap as `max_completion_tokens` for reasoning models on Chat Completions: every `gpt-5`-and-up and `o`-series model rejects the legacy `max_tokens` outright (`Unsupported parameter: 'max_tokens' is not supported with this model`), so `agent.max_tokens(n)` — or any capped request — could not succeed at all against them. The new spelling is scoped to those families through `OpenAICompatibleProvider::requires_modern_output_cap`, so OpenAI's older models and every OpenAI-compatible server reached through the same client send exactly the bytes they always did ([#2332](https://github.com/0xPlaygrounds/rig/pull/2332))
- *(openai)* fix the image-generation request body: rig added `"response_format": "b64_json"` for every model outside a hardcoded `gpt-image-1`/`1.5`/`2` allowlist, but the endpoint now rejects that field for *all* models (`400 Unknown parameter: 'response_format'`), so `gpt-image-1-mini`, `chatgpt-image-latest`, and dated snapshots such as `gpt-image-2-2026-04-21` could not generate an image at all. The field is gone — the models this endpoint serves answer with `b64_json` regardless; an OpenAI-*compatible* images endpoint that still needs it can pass it explicitly now that `ImageGenerationRequest::additional_params` — silently dropped for OpenAI while xAI and Gemini honored it — is merged into the body, so `quality`, `background`, `output_format`, and the rest reach the API ([#2332](https://github.com/0xPlaygrounds/rig/pull/2332))
- *(providers)* honor `AudioGenerationRequest::additional_params` in the shared text-to-speech body: the *default* `RawAudioGenerationProvider::audio_generation_request_body` never merged the field, so it was silently inert for whoever inherited it — OpenAI included — while every provider that overrides the body already merged it (xAI, OpenRouter, Venice). The parameters demonstrably change the response: `response_format: "wav"` returns a RIFF payload where the default returns MP3, and `instructions` steers delivery on the `gpt-4o-mini-tts` family. Azure OpenAI overrides this body and drops the field too; that one needs a change recordable against Azure ([#2332](https://github.com/0xPlaygrounds/rig/pull/2332))
- *(openai)* keep the transcription endpoint's `usage`: both live model families report what a transcription cost — `whisper-1` by audio duration, the `gpt-4o-transcribe` family by token — and rig's response type modeled only `{ text }`, so the accounting was dropped even from the raw provider response that exists to carry provider-specific fields. Each modeled shape pins the wire's own `type`, so a future payload reporting both a duration and token counts cannot decode as a duration and silently drop the counts, and an unmodeled shape is carried through verbatim instead of failing the transcription. The token shape also keeps `input_token_details`, which the endpoint reports and which matters because audio and text input tokens bill at different rates. Shared with Groq, Azure OpenAI, Venice and HuggingFace, which use the same response type ([#2332](https://github.com/0xPlaygrounds/rig/pull/2332))
- *(gemini)* stop ending a `streamGenerateContent` stream on the first `finishReason`: Gemini emits an *intermediate* `finishReason` when a built-in tool (code execution) runs a round and then keeps streaming, so the whole answer after it was dropped while the stream still reported a clean `STOP`. The terminal record is now deferred to EOF — a truncated stream (EOF with no `finishReason` at all) still yields no terminal record. Note the deliberate consequence: a stream whose transport fails *after* a `finishReason` now surfaces the error and no terminal record, where it previously reported a completed turn, because on this wire a `finishReason` is not proof the turn finished ([#2328](https://github.com/0xPlaygrounds/rig/pull/2328))
- *(gemini)* preserve Gemini 3's trailing `thoughtSignature`: the wire attaches it to a text part carrying no `thought` flag, and the blocking mapper dropped that replay-required state while the streaming adapter kept it. Blocking now places it exactly where the streaming accumulator does — on the last chain-of-thought block still awaiting a signature, or as a signature-only block when there is no such block (a turn with no reasoning at all, or one whose reasoning already carries its own `thoughtSignature`) — so the same bytes normalize to the same choice and a signed turn replays identically from either transport. `rig-gemini-grpc`'s unary mapper had the same drop against its own streaming adapter and shares the fix ([#2328](https://github.com/0xPlaygrounds/rig/pull/2328))
- *(gemini)* stop failing a whole `generateContent` response on `executableCode`/`codeExecutionResult` parts: enabling Gemini's built-in code-execution tool through `additional_params.tools` made every blocking turn fail with `ResponseError("Response did not contain a message or tool call")`, discarding the model's text answer. Those parts now contribute no assistant content instead of failing the response, matching the streaming path ([#2328](https://github.com/0xPlaygrounds/rig/pull/2328))
- *(gemini)* stop reporting the model's chain-of-thought as output text: with `thinkingConfig.includeThoughts`, `TranscriptionResponse::text` returned the reasoning from `parts[0]` and dropped the transcript, and `ProviderResponseExt::get_text_response` concatenated reasoning onto the answer. Both now skip `thought: true` parts, and the transcript is every visible text part rather than only the first. `rig-gemini-grpc`'s `get_text_response` carried the same defect and is fixed with it ([#2328](https://github.com/0xPlaygrounds/rig/pull/2328))
- *(core)* the `pdf` feature builds for `wasm32-unknown-unknown`. `lopdf` reaches `getrandom` through its PDF-encryption support, and `getrandom` refuses to compile for browser wasm until a backend is selected — the target triple alone cannot pick one — so any browser build that enabled `pdf`, directly or through the facade, failed outright with getrandom's "not supported by default" error. `rig-core` now enables `lopdf`'s `wasm_js` backend under `cfg(all(target_arch = "wasm32", target_os = "unknown"))` only, leaving the native and WASI graphs untouched; the runtime condition is that the host provides the Web Crypto API's `Crypto.getRandomValues`, as browsers, Web Workers and Node.js 19+ do. CI now runs `cargo check --package rig-core --all-features --target wasm32-unknown-unknown` so an optional loader cannot reintroduce a browser-incompatible transitive dependency ([#2319](https://github.com/0xPlaygrounds/rig/pull/2319))
- *(azure)* text-to-speech reaches the deployment it names: the model passed the literal `"/audio/speech"` where `post_audio_generation` expects a deployment id, so every Azure TTS request went to `{endpoint}/openai/deployments/audio/speech/audio/speech?api-version=…` — a deployment that cannot exist, which made the declared `AudioGeneration` capability fail for every caller and every key. The model name is now the deployment segment, the request body drops the redundant `"model"` key (Azure names the model in the path), and Azure text-to-speech carries its own API version — `2025-04-01-preview`, the first deployment-scoped Azure release exposing the route, overridable with the new `ClientBuilder::audio_api_version` — rather than the GA `api_version` (`2024-10-21`) the other Azure routes share ([#2317](https://github.com/0xPlaygrounds/rig/pull/2317))
- *(openai)* the Chat Completions client's non-completion capabilities resolve: `OpenAICompletionsExt` declared `Transcription`, `ModelListing`, `ImageGeneration` and `AudioGeneration` as `Capable`, but each named a model whose `Client` associated type is the *Responses* `openai::Client`, and the capability blanket impls require `M: …<Client = Self>` — so `openai::CompletionsClient::{transcription_model, list_models, image_generation_model, audio_generation_model}` failed to compile and the only route to those endpoints was `.responses_api()`. Each slot now names a Completions-client model (`CompletionsTranscriptionModel`, `OpenAICompletionsModelLister`, `CompletionsImageGenerationModel`, `audio_generation::CompletionsAudioGenerationModel`), so switching APIs preserves every capability ([#2317](https://github.com/0xPlaygrounds/rig/pull/2317))
- *(streaming)* preserve body and request id when an SSE handshake fails: a streaming connect 4xx/401 on a request-id-contract provider (anthropic and its Anthropic-dialect gateway clients, openai, chatgpt, xai, groq, copilot, and — since [#2331](https://github.com/0xPlaygrounds/rig/pull/2331) — mistral) now classifies as `CompletionError::ProviderResponse` with the response body and provider request id, matching the blocking path, instead of a bare status ([#2315](https://github.com/0xPlaygrounds/rig/pull/2315))
- *(client)* `VerifyClient::verify` now maps 401/403 to `VerifyError::InvalidAuthentication` again under the reqwest transport; the transport reports non-success as an error before the status match, which had made those arms unreachable ([#2315](https://github.com/0xPlaygrounds/rig/pull/2315))
- *(providers)* the shared Chat Completions response decodes a missing or explicitly `null` `finish_reason`, `index`, `object` and `created` instead of failing the turn. `Choice::index` and `Choice::finish_reason` carried no serde attribute at all, so either one absent or `null` was a hard deserialization error that took the whole response — text, tool calls and usage — with it; `object`/`created` were `#[serde(default)]`, which tolerates a missing key but still rejects an explicit `null`. All four now read through `json_utils::null_or_default`, and an absent reason arrives as `finish_reason() == None` because normalization filters the empty string. This is the one wire type behind every OpenAI-compatible provider, so it changes decoding for all of them; Copilot's multi-vendor chat route is the wire that sends these shapes ([#2308](https://github.com/0xPlaygrounds/rig/pull/2308))
- *(milvus)* send the bearer token as `Authorization`, not `Authentication`: every Milvus REST call built its auth header under a name the server does not read, so a store configured through `MilvusVectorStore::auth(username, password)` reached Milvus unauthenticated and any instance with authentication enabled rejected every search and insert — token auth had never worked. Only the header name changed; the value is still `Bearer {username}:{password}` ([#2308](https://github.com/0xPlaygrounds/rig/pull/2308))
- *(mistral)* stop JSON-quoting string `additional_params` on the transcription multipart form: Mistral's transcription built its own form and wrote every value with `serde_json::Value::to_string()`, so `{"response_format": "verbose_json"}` reached the endpoint as the field value `"verbose_json"` — quotes included — which providers reject or ignore. The form is now built by the shared `providers::internal::transcription::transcription_form`, which sends string values verbatim and leaves non-strings JSON-encoded. Two consequences of sharing it: a non-object `additional_params` now fails with `additional transcription parameters must be a JSON object` instead of `Additional Parameters to Mistral Transcription should be a map`, so anything matching that string needs updating; and `TranscriptionRequest::prompt` is now explicitly cleared rather than incidentally ignored — Mistral's endpoint has no `prompt` field and never received one ([#2305](https://github.com/0xPlaygrounds/rig/pull/2305))
- *(cohere)* reject `ToolChoice::Required` with no tools before the request leaves the process: building the Cohere chat body now fails with `CompletionError::RequestError("Cohere requires at least one tool when tool_choice is REQUIRED")` when the choice is `Required` and neither `CompletionRequest::tools` nor a non-empty `additional_params["tools"]` array supplies one. Previously the body was built and sent for Cohere to reject, so the caller got a provider failure after a round trip; the error is now a local `RequestError` and the HTTP client is never touched. Tools passed through the raw-parameter escape hatch count toward the check, so `REQUIRED` stays usable with Cohere-specific tool schemas ([#2302](https://github.com/0xPlaygrounds/rig/pull/2302))
- *(deepseek)* fall back to DeepSeek's native `prompt_cache_hit_tokens` when a response omits the OpenAI-style details object: `From<&deepseek::Usage> for completion::Usage` sourced `cached_input_tokens` from `prompt_tokens_details.cached_tokens` alone and defaulted to `0` otherwise, even though `deepseek::Usage` already models the provider's own top-level counter. Every live-recorded DeepSeek response carries both fields with the same value, so normalized cache accounting is unchanged in practice — this closes the gap only for a response that reports the native counter without `prompt_tokens_details` ([#2301](https://github.com/0xPlaygrounds/rig/pull/2301))
- *(openai)* stop letting `additional_params.tools` replace the builder's tools on Chat Completions: `additional_params` is `#[serde(flatten)]`ed into the request struct *after* the typed `tools` field and the body is built with `serde_json::to_value`, so a raw `tools` array left in the params overwrote the typed list entirely — a turn with `tool_choice: "required"` and a registered builder tool carried only the params tool on the wire and the model called the wrong one. The shared chat-completions conversion now splits the array: `{"type": "function"}` entries merge onto the typed list (builder tools first), non-function entries stay in `additional_params` for the provider's `prepare_request` hook (Groq folds its native tools into `compound_custom` there), and the key is removed entirely when nothing is left behind. A `tools` value that is not an array, or a `"function"` entry that is not a valid tool definition, now fails locally as `CompletionError::RequestError` naming the key. The Responses, Anthropic and Gemini paths already merged; OpenRouter builds its own request and is unchanged ([#1890](https://github.com/0xPlaygrounds/rig/issues/1890), [#2294](https://github.com/0xPlaygrounds/rig/pull/2294))
- *(gemini)* send `temperature` and `max_tokens`: `create_request_body` applied both through `generation_config.map(..)`, and `Option::map` is a no-op on `None`, so a request that did not already carry a `generationConfig` — from `additional_params` or from an `output_schema` turn — dropped both and serialized `"generationConfig": null`. `.max_tokens(8)` never reached `maxOutputTokens` (the turn ran to the model's own limit and reported `Stop` where the wire sends `MAX_TOKENS`) and `.temperature(0.0)` never reached the wire at all. The config is now created whenever either field is set, on the blocking and streaming surfaces alike since both build the body through the same function; `GenerationConfig::default()` is all-`None`, so setting one field does not put the other on the wire. Pinned by live regression cassettes ([#2283](https://github.com/0xPlaygrounds/rig/pull/2283))
- *(cohere)* fix the four request shapes `/v2/chat` rejects. `documents` now serializes as Cohere's own `{id, data}` document instead of rig's `completion::Document` (`{id, text, additional_props}`); `tool_choice` goes out through a dedicated `cohere::completion::CohereToolChoice` whose `SCREAMING_SNAKE_CASE` serde produces `"REQUIRED"`/`"NONE"`, where rig's snake_case `ToolChoice` had been sending `"required"`; `max_tokens` is now a field on the Cohere request populated from the caller's, having previously been dropped before the body was built and never reaching the API at all; and `ToolResultContent` gains `#[serde(tag = "type", rename_all = "lowercase")]`, so a tool result serializes as `{"type":"text","text":"…"}` rather than the externally-tagged `{"Text":{"text":"…"}}` Cohere answers with a 422 — which is why Cohere tool calling died on the second turn. Two more in the same pass: normalized usage is sourced from `usage.tokens` on every surface (`billed_units` excludes cached input and system overhead, so counts step up on the input side), and the transport error is no longer boxed into `http_client::Error::Instance`, so `provider_response_status()` and `provider_response_body()` return the status and body instead of `None` on every real HTTP failure. See `MIGRATING.md` ([#2263](https://github.com/0xPlaygrounds/rig/pull/2263))
### Deprecated
- *(mistral)* `PIXTRAL_LARGE`, `PIXTRAL_SMALL`, `MISTRAL_SABA`, `MISTRAL_NEMO` and `CODESTRAL_MAMBA` are deprecated: none of these identifiers appear in Mistral's `GET /v1/models` catalog any more, and a request naming one fails with `400 Invalid model`. Pixtral's vision role is covered by `MISTRAL_SMALL` and `MINISTRAL_3B`, which are vision-capable ([#2337](https://github.com/0xPlaygrounds/rig/pull/2337))
- *(cohere)* `COMMAND_R_PLUS`, `COMMAND_R`, `COMMAND`, `COMMAND_LIGHT` and `COMMAND_LIGHT_NIGHTLY` are deprecated: Cohere removed the first four on 2025-09-15 and no longer serves `command-light-nightly`, which answers 404, so a request naming any of the five fails. `COMMAND_NIGHTLY` is deprecated for a softer reason — it still resolves, but it is absent from Cohere's published model catalogue, so it carries no compatibility or availability guarantee. Each `#[deprecated]` note names its own replacement among the dated identifiers added in the same change, so the compiler warning tells you what to move to ([#2263](https://github.com/0xPlaygrounds/rig/pull/2263))
### Changed
- *(deps)* dependency requirements are now floors — the lowest version rig's own code needs (a bare major, or the version that introduced an API rig relies on) — instead of the latest patch at the time of release; Dependabot only moves `Cargo.lock` for in-range releases, and `scripts/check-dependency-floors.py` (CI `dependency-floors`) builds the workspace against the declared floors. The `deranged = "=0.5.8"` exact pin is gone. Downstream users no longer have to `cargo update` unrelated crates to take a rig release ([#2195](https://github.com/0xPlaygrounds/rig/issues/2195)) - #2369
- *(providers)* the cursor-paginated model listers (Anthropic, Gemini) share one loop. Each had hand-rolled its own and each shipped the same unbounded-loop bug in [#2334](https://github.com/0xPlaygrounds/rig/pull/2334); the termination rules — no next cursor ends the listing, a repeated cursor ends it, and a hard page ceiling ends it — now live once in `internal::model_listing::paginate_models`, with each provider supplying only how it spells a cursor and how it reads one out of a page. The ceiling is the new rule: both listers were bare `loop`s, so a cursor that keeps *changing* without advancing — a gateway alternating `c1, c2, c1, …`, or minting a fresh one per request — never terminated, where the shared loop now stops after `MAX_LISTING_PAGES` (1000), warns, and returns the pages fetched so far. Venice's lister, which was the shared macro's output written by hand, uses the macro. No public type changed ([#2079](https://github.com/0xPlaygrounds/rig/issues/2079))
- *(workspace)* remove `#[non_exhaustive]` from every type in the workspace — 53 attributes across `rig-core`, `rig-agent`, `rig-bedrock` and `rig-candle`. Struct literals, functional update (`..Default::default()`) and exhaustive `match` now work from any crate, which is the point: these types read as plain data again. This is a permissive change, so it is **not** breaking and nothing stops compiling because of it. Two consequences to know about. First, downstream `match` arms that exist only to satisfy a previously non-exhaustive enum may now warn `unreachable_patterns`, which is an error under `-D warnings` — delete the wildcard (two in-tree matches over `ReasoningContent` needed this). Second, the reverse of the old bargain now applies: adding a field to any of these structs, or a variant to any of these enums, is a breaking change from here on, so it must wait for a breaking window. `#[non_exhaustive]` cannot be reintroduced outside one either. See `MIGRATING.md` for the superseded guidance and the one invariant this widens ([#2335](https://github.com/0xPlaygrounds/rig/pull/2335))
- *(agent)* [**breaking**] `ToolSetBuilder` and `ToolSet::builder()` are removed, and the `rig` facade drops `ToolSetBuilder` from its `rig::tool` re-export. A tool set is populated in place instead: `ToolSet::default()` (or `from_tools`/`from_dynamic_tools`) plus `add_tool`, `add_dynamic_tool`, `add_portable_dynamic_tool` and the new `add_retrieved_tool`, so `ToolSet::builder().retrieved_tool(t).build()` becomes `let mut set = ToolSet::default(); set.add_retrieved_tool(t);` ([#2320](https://github.com/0xPlaygrounds/rig/pull/2320))
- *(core)* [**breaking**] twenty public items go with the same consolidation pass. `telemetry::ProviderResponseExt` loses `type OutputMessage` and `get_output_messages` — nothing ever read them (`SpanCombinator::record_response_metadata` records only the response id and model name), so an out-of-tree impl that still defines them now fails with E0437/E0407 and should delete both; `get_text_response` stays. `client::ImageGenerationClient::custom_image_generation_model` is deleted — it was a defaulted alias whose body was `Self::ImageGenerationModel::make(self, model)`, which is exactly what the trait's blanket impl resolves `image_generation_model` to, so `client.custom_image_generation_model(m)` becomes `client.image_generation_model(m)` and nothing else moves. `json_utils::null_or_vec` folds into `null_or_default`, the drop-in for a `Vec<T>` field. Anthropic's `completion::apply_cache_control` is deleted with no public successor — its replacement `apply_prompt_cache_control` is `pub(super)`. And gemini's `interactions_api::interactions_api_types` drops fifteen `*Delta` structs (`ImageDelta`, `AudioDelta`, `DocumentDelta`, `VideoDelta`, `FunctionCallDelta`, `FunctionResultDelta`, `CodeExecutionCallDelta`, `CodeExecutionResultDelta`, `UrlContextCallDelta`, `UrlContextResultDelta`, `GoogleSearchCallDelta`, `GoogleSearchResultDelta`, `McpServerToolCallDelta`, `McpServerToolResultDelta`, `FileSearchResultDelta`) because `ContentDelta` now carries the identically-shaped `*Content` payloads directly — the JSON is unchanged, a `match` naming the old payload types is not ([#2320](https://github.com/0xPlaygrounds/rig/pull/2320))
- *(anthropic)* [**breaking**] every locator variant of `anthropic::completion::Citation` is now a newtype over a payload struct of its own: `CharLocation(CharLocationCitation)`, `PageLocation(PageLocationCitation)`, `ContentBlockLocation(ContentBlockLocationCitation)`, `SearchResultLocation(SearchResultLocationCitation)` and `WebSearchResultLocation(WebSearchResultLocationCitation)`, where each spelled its fields inline as a struct variant. The five payload structs are new public types with the same field names, types and optionality the variants carried, and the wire shape is untouched — both refs hand-write `Serialize`/`Deserialize` around the same `char_location`/`page_location`/`content_block_location`/`search_result_location`/`web_search_result_location` `type` tags — so persisted citations still load and a serialized one carries exactly the keys and values it always did. What breaks is source: a `match` arm or struct literal spelling `Citation::CharLocation { cited_text, .. }` becomes `Citation::CharLocation(CharLocationCitation { cited_text, .. })`, and likewise for the other four. `Citation::Unknown(serde_json::Value)` is unchanged. Both routes to the type are provider-native ones the escape hatches lead to: `Content::Text { citations, .. }` off `raw_completion`, and `anthropic::streaming::ContentDelta::CitationsDelta` off `raw_stream` ([#2320](https://github.com/0xPlaygrounds/rig/pull/2320))
- *(bedrock, s3vectors)* [**breaking**] the mirror→AWS-SDK conversion family is deleted: the 38 `TryFrom` impls in `types::converse_output` that rebuilt an SDK value out of the mirror just built from it — 17 written by hand, 21 generated by the mirror macros' `reverse` arm — are gone (hand-written targets: `aws_bedrock::Message`, `DocumentSource`, `DocumentBlock`, `S3Location`, `ImageBlock`, `VideoBlock`, `ToolUseBlock`, `ToolResultBlock`, `ToolResultContentBlock`, `ReasoningTextBlock`, `CachePointBlock`, `CitationsConfig`, `CitationsContentBlock`, `Citation`, `GuardrailConverseImageBlock`, `GuardrailConverseTextBlock`, and `aws_sdk_bedrockruntime::primitives::Blob`), so there is no supported way back from a mirror value to its `aws_sdk_bedrockruntime` counterpart. The surviving Rig-facing conversions read the mirror instead: `RigAssistantContent`/`RigUserContent` convert from `converse_output::ContentBlock`, `RigMessage` from `converse_output::Message`, `RigImage` from `ImageBlock`, `RigDocument` from `DocumentBlock`, `RigToolResultContent` from `ToolResultContentBlock` — each took the `aws_bedrock::` type before. rig-s3vectors' exported `document!` macro goes with it: it existed to spell `aws_smithy_types::Document` filter literals, `S3SearchFilter`'s constructors now build those values through private helpers, and there is no public replacement ([#2320](https://github.com/0xPlaygrounds/rig/pull/2320))
- *(image, audio)* `image_generation::ImageGenerationModel` and `audio_generation::AudioGenerationModel` state their bounds as `WasmCompatSend`/`WasmCompatSync` instead of `Send`/`Sync`: `ImageGenerationModel`'s own supertraits, and on both traits the associated `Response` and the future the generation method returns. `CompletionModel`, `EmbeddingModel` and `TranscriptionModel` were already written this way, so these two were the last capability traits stating a hard `Send`. On native targets nothing moves — `WasmCompatSend: Send` and `WasmCompatSync: Sync`, blanket-implemented for every qualifying type — so existing implementors compile unchanged and generic code still gets `Send`/`Sync` out of the bound. On `wasm32-unknown-unknown` both markers are empty, so a browser-wasm model whose HTTP future is not `Send` can implement either trait, which the old `+ Send` future bound ruled out ([#2317](https://github.com/0xPlaygrounds/rig/pull/2317))
- *(providers)* [**breaking**] xAI completion and streaming now use the shared OpenAI-compatible Responses driver. `xai::completion::CompletionResponse` is the shared Responses wire type, and unknown response statuses deserialize as `ResponseStatus::Other` instead of rejecting the response. xAI and OpenRouter audio generation also use the existing shared raw-audio request driver ([#2316](https://github.com/0xPlaygrounds/rig/pull/2316))
- *(providers)* workspace-wide consolidation pass 7 (net −366 production LOC): every provider's unary completion tail (send → decode → telemetry → error preservation) routes through one `internal::completion_send::send_completion` driver across 10 sites, so an undecodable 2xx body now logs the error and the offending body for **all** providers instead of gemini alone; six SSE stream-open preambles collapse into `internal::sse_transport::open_wire_stream`. [**breaking**] rig-candle drops seven dead public items (`from_artifacts{,_async}`, `from_gguf_async`, `from_gguf_bytes_async`, the `LlamaModelBuilder` alias, both `model_family` accessors) ([#2310](https://github.com/0xPlaygrounds/rig/pull/2310))
- *(copilot)* [**breaking**] `copilot::CopilotCompletionResponse::Chat` carries `openai::completion::CompletionResponse`, and the duplicate `copilot::ChatCompletionResponse` / `copilot::ChatChoice` are deleted — they were a field-for-field copy of the shared OpenAI chat wire types. `CompletionModel::raw_completion` returns that enum, so anything naming either type, or destructuring the `Chat` variant's payload, moves to the shared type. Optionality moves with it: `object`, `created` and the choice's `finish_reason` are `String`, `u64` and `String` rather than `Option`, so a value that used to serialize as `null` now serializes as `""` or `0`; persisted JSON still loads, because the shared type accepts a missing key or an explicit `null` for all three ([#2308](https://github.com/0xPlaygrounds/rig/pull/2308))
- *(bedrock)* [**breaking**] `rig_bedrock::streaming::BedrockUsage` is deleted and `BedrockStreamingResponse::usage` is `Option<types::converse_output::TokenUsage>`. The two were field-for-field identical (`input_tokens`, `output_tokens`, `total_tokens`, `cache_read_input_tokens`, `cache_write_input_tokens`, the last two skipped when absent), so no serialized shape moves and no accounting changes — only code that named `BedrockUsage`, which `streaming` exported as a public type, has to switch to `TokenUsage`, reachable now that `types::converse_output` is a public module ([#2308](https://github.com/0xPlaygrounds/rig/pull/2308))
- *(core, providers)* [**breaking**] four public items are gone and one constructor changed shape. `http_client::with_bearer_auth` is deleted — call `http_client::bearer_auth_header` on the builder's own header map. `InMemoryVectorStoreBuilder::documents_with_id_f` is deleted; the identically named store methods `InMemoryVectorStore::from_documents_with_id_f`/`add_documents_with_id_f` are untouched, and `documents_with_ids` covers the builder case. `mira::MiraError` and mira's inherent `Client::list_models` are deleted: mira rides the shared lister now, so listing goes through `ModelListingClient::list_models`, returning `ModelList` and `ModelListingError` instead of `Vec<String>` and `MiraError`. And `azure::EmbeddingModel` is a type alias for `openai::embedding::GenericEmbeddingModel<AzureExt, T>`, whose `new`/`with_model` take `ndims: usize` rather than the old inherent `Option<usize>`; `EmbeddingModel::make(&client, model, None)` and the client's `embedding_model` helpers keep the infer-dimensions-from-the-model-identifier behavior. See `MIGRATING.md` ([#2305](https://github.com/0xPlaygrounds/rig/pull/2305))
- *(core)* [**breaking**] remove verified-dead public API. `ModelListingError` loses its `RateLimitError`, `ServiceUnavailable` and `UnknownError` variants and the `auth_error`/`rate_limit_error`/`service_unavailable`/`unknown_error` constructors — the enum derives `Serialize`/`Deserialize`, so a persisted value naming a dropped variant no longer loads, while every real listing failure already classified as `ApiError`, `RequestError` or `ParseError`. Also gone: `message::Reasoning::optional_id`, `message::Image::try_into_url`, `message::DocumentSourceKind::{raw, unknown}` (the `Raw`/`Unknown` variants stay — only the constructors went), `message::Message::assistant_with_id`, `CompletionRequest::{with_provider_tool, with_provider_tools}`, `streaming::RawStreamingToolCall::with_internal_call_id`, `InMemoryVectorStore::get_document` (use `iter()`), the dead wire types `azure::{EmbeddingResponse, EmbeddingData, Usage}` and `ollama::{AssistantContent, UserContent, ImageUrl}` plus ollama's `SystemContent`/`SystemContentType` re-export, `deepseek::Message`'s `System`, `User` and `ToolResult` variants (that enum is the response shape, where only `Assistant` ever appeared), the whole `doubleword::client::doubleword_api_types` module, `openai::responses_api::OutputReasoning`, and separately that module's `TryFrom<message::Message> for Vec<Message>` converter, and rig-agent's `MultiTurnStreamItem::final_response_with_history`. See `MIGRATING.md` for the table ([#2301](https://github.com/0xPlaygrounds/rig/pull/2301))
- *(huggingface)* [**breaking**] `huggingface::transcription::TranscriptionResponse` is a re-export of `openai::TranscriptionResponse` instead of HuggingFace's own one-field copy of it — the two decoded the same `{"text": …}` body, and HuggingFace's transcription rides the shared OpenAI-wire model now. The wire is unchanged, but the *type* is a different type: an out-of-tree impl written for both paths is now a conflicting implementation, and because [#2332](https://github.com/0xPlaygrounds/rig/pull/2332) later added `usage` to the OpenAI type, a struct literal `TranscriptionResponse { text }` on the HuggingFace path must supply `usage` too (it is `#[serde(default)]`, so decoding a response without it is unaffected) ([#2289](https://github.com/0xPlaygrounds/rig/pull/2289))
- *(completion)* [**breaking**] `OneOrMany<T>` and `EmptyListError` are removed: message content, `CompletionResponse::choice`, `CompletionRequest::chat_history`, `ToolResult::content` and `EmbeddingsBuilder` output are plain `Vec<T>`, and non-emptiness is enforced where it actually matters — `CompletionRequest::validate_message_content` rejects a content-less message, `ToolOutput::content` is fallible — instead of by the container type. The serialized form is unchanged (the container already wrote a plain sequence), so persisted histories and stored embeddings need no migration; decoding widens in two places that used to be parse errors: `[]`, and `null` on the fields that moved onto `json_utils::string_or_vec` (OpenAI's tool-calls-only `"content": null`). A tool with `type Output = Vec<ToolResultContent>` compiles unchanged but now takes `IntoToolOutput`'s rich-content path — N ordered blocks instead of one JSON array ([#2273](https://github.com/0xPlaygrounds/rig/pull/2273))
- *(streaming, completion)* [**breaking**] stream parts become entities and tool-call identity is typed: the raw grammar gains `ReasoningStart`/`ReasoningEnd`/`TextEnd` (a trailing signature is an end arriving late), the raw-event key is the opaque `StreamPartId` — no `Serialize`, no rendering, no accessor — with the durable provider handle carried separately as `WireId`, and `message::ToolCall`/`message::ToolResult` become `{ id: ToolCallId, provider: Option<ProviderCallId>, .. }` / `{ call: ToolCallId, provider: Option<ProviderCallId>, name: String, .. }`, where `ToolCallId` is non-empty by construction and minted at the boundary when the wire issued no id. Persisted-history serde is breaking: pre-provider-split `ToolCall` JSON is no longer lifted on load — see `MIGRATING.md` ([#2262](https://github.com/0xPlaygrounds/rig/pull/2262), [#2267](https://github.com/0xPlaygrounds/rig/pull/2267))
- *(streaming)* [**breaking**] one canonical stream grammar: reasoning and text raw events carry mandatory identity (`RawStreamingChoice::{Reasoning, ReasoningDelta, TextStart}` and `StreamedAssistantContent::ReasoningDelta` take an `id`, so two distinct wire items can no longer concatenate into one part), choice aggregation moves into a single shared accumulator, and parse policy is decode-then-validate stated once per wire family — a known event whose payload is defective surfaces an `Err` rather than being absorbed, while an unrecognized event type warns and skips for forward compatibility ([#2258](https://github.com/0xPlaygrounds/rig/pull/2258))
- *(completion)* [**breaking**] completion responses are normalized at the provider boundary: `CompletionResponse<T>` becomes the concrete `CompletionResponse` carrying `finish_reason`/`provider`/`model`/`message_id`/`response_id`, `raw_response` is gone, and `CompletionModel` drops its `Response`/`StreamingResponse`/`Client`/`make` associated items along with its `Clone` supertrait — a provider's native wire type is reached through the new inherent `raw_completion`/`raw_stream` escape hatches. Agents erase the model type in the same change: `Agent<M>`, `AgentBuilder<M>` after `new()`, `AgentRunner<M>`, the prompt/stream request types and `Extractor<M, T>` lose their model parameter and store a `ModelHandle`, which is what makes `Agent::set_model` and per-run `using_model(..)` possible. Streams normalize too: a corrupt frame surfaces as an `Err` item and the stream continues (a later genuine terminal still completes it) instead of being logged and dropped, and a bare `[DONE]` after only unparseable frames no longer fabricates a zero-usage terminal record ([#2257](https://github.com/0xPlaygrounds/rig/pull/2257))