| Name | Modified | Size | Downloads / Week |
|---|---|---|---|
| Parent folder | |||
| README.md | 2026-08-17 | 102.1 kB | |
| rig-v0.42.0 source code.tar.gz | 2026-08-17 | 12.3 MB | |
| rig-v0.42.0 source code.zip | 2026-08-17 | 14.7 MB | |
| Totals: 3 Items | 27.0 MB | 0 | |
Added
- (voyageai) expose embedding request options (#2343) (by sergiomeneses)
- (agent) [breaking] expose portable model-turn termination metadata to hooks (#2341) (by gold-silver-copper)
- carry the provider transport request id on completion errors (#2314) (#2315) (by gold-silver-copper) - [#2315]
- response identity metadata — native response id + provider transport request id, to every completion observer (#2265) (#2313) (by gold-silver-copper) - [#2313]
- (anthropic) per-breakpoint cache TTL — static prefix independent of conversation tail (#2266) (#2312) (by gold-silver-copper)
- (venice) add Venice AI provider with live-recorded cassette coverage (#2306) (by gold-silver-copper)
- (cohere) add image embeddings (#2304) (by gold-silver-copper)
- (anthropic) support strict tool use (#2296) (by gold-silver-copper)
Fixed
- (doubleword) report the embedding width Doubleword actually returns (#2356) (by gold-silver-copper)
- (openrouter) surface chat-completions refusals, and map the reasoning share of usage (#2358) (by gold-silver-copper)
- (rig) exclude test cassettes from the published crate (#2350) (by gold-silver-copper)
- (embeddings) keep a document's embeddings in text order across batch boundaries (#2348) (by gold-silver-copper)
- (embeddings) builder input order (#2344) (by sergiomeneses)
- (mistral) eight more provider bugs found by live cassette recording (#2337) (by gold-silver-copper)
- (openai) preserve the provider's response when a websocket upgrade is rejected (#2338) (by gold-silver-copper)
- (core) [breaking] preserve response headers on non-success HTTP errors (#2333) (by gold-silver-copper)
- three model-listing and usage bugs found by live cassette recording (anthropic, gemini) (#2334) (by gold-silver-copper) - [#2334]
- (openai) six wire-level defects found by live cassette recording (#2332) (by gold-silver-copper)
- (mistral) four provider bugs found by live cassette recording (#2331) (by gold-silver-copper)
- (anthropic) two stop_sequence bugs found by live cassette recording (#2329) (by gold-silver-copper)
- (gemini) four response-mapping bugs found by live cassette recording (#2328) (by gold-silver-copper)
- (gemini, agent) close the whole output-budget truncation chain, not just the 4096 cap (#2324) (by gold-silver-copper)
- (rig-core) enable all-feature wasm builds (#2319) (by gold-silver-copper)
- (bedrock) raw_completion discards provider-only response data (guardrail trace, request id, service tier) (#2311) (by gold-silver-copper)
- (bedrock) ship invocable model identifiers, and keep provider error bodies the SDK cannot classify (#2309) (by gold-silver-copper)
- (cohere) validate required tool choice (#2302) (by gold-silver-copper)
- (cohere) Cohere provider sends request shapes the v2 chat API rejects, and ships removed model IDs (#2263) (by rleisti)
- (openai) preserve Responses message phase across stateless replay (#2269) (#2295) (by gold-silver-copper)
- (openai) merge additional_params tools into chat completions tool list (#1890) (#2294) (by gold-silver-copper)
- (gemini) send temperature and max_tokens; add live regression cassettes and a cache-prefix guard (#2283) (by gold-silver-copper)
- (anthropic) prefer
message_deltausage.input_tokens when the provider sends it there (#2279) (by gold-silver-copper) - (release) isolate macro hygiene fixture (#2227) (by gold-silver-copper)
Other
- reconcile the changelogs and the migration guide with what actually merged (#2353) (by gold-silver-copper) - [#2353]
- (rig-core) make a live tracing capture provable, not assumed (#2347) (by gold-silver-copper)
- (providers) share the paginated model-listing loop, and add Groq listing (#2339) (by gold-silver-copper)
- (deps) clear the outstanding security advisories (#2342) (by gold-silver-copper)
- remove #[non_exhaustive] from the workspace (#2335) (by gold-silver-copper) - [#2335]
- (rig-agent) share AgentConfig across builder, agent, and runner (#2326) (#2327) (by gold-silver-copper)
- workspace-wide LOC consolidation pass 8 (net −1,353 production lines) (#2320) (by gold-silver-copper) - [#2320]
- (rig-core) consolidate provider boilerplate (#2317) (by gold-silver-copper)
- share xAI Responses and audio drivers (#2316) (by gold-silver-copper) - [#2316]
- (deps) bump trybuild from 1.0.117 to 1.0.120 (#2131) (by dependabot[bot])
- (deps) bump surrealdb from 3.0.5 to 3.2.3 (#2129) (by dependabot[bot])
- (deps) bump tokio-tungstenite from 0.28.0 to 0.29.0 (#2020) (by dependabot[bot])
- workspace-wide LOC consolidation pass 7 (net −366 production lines) (#2310) (by gold-silver-copper) - [#2310]
- workspace-wide LOC consolidation pass 6 (net −3,424 lines) (#2308) (by gold-silver-copper) - [#2308]
- reduce Rust cache churn and test rebuilds (#2307) (by gold-silver-copper) - [#2307]
- consolidate provider/loader/agent plumbing (net −566 production LOC) (#2305) (by gold-silver-copper) - [#2305]
- (deps) upgrade lopdf to 0.44.0 (#2297) (by mccormickt)
- remove dead API surface and consolidate provider/agent plumbing (net −794 production LOC) (#2301) (by gold-silver-copper) - [#2301]
- Revert "fix(openai): preserve Responses message phase across stateless replay (#2269) (#2295)" (#2300) (by gold-silver-copper) - [#2300]
- consolidate provider schema/normalization and agent plumbing (net −365 production LOC) (#2299) (by gold-silver-copper) - [#2299]
- pin sccache and make its install best-effort (#2292) (by gold-silver-copper) - [#2292]
- consolidate provider scaffolding and agent-runner plumbing (net −439 production LOC) (#2289) (by gold-silver-copper) - [#2289]
- consolidate provider and agent plumbing (#2288) (by gold-silver-copper) - [#2288]
- Fix Neo4j rustdoc link warning (#2287) (by gold-silver-copper) - [#2287]
- Consolidate provider and agent boilerplate (#2285) (by gold-silver-copper) - [#2285]
- consolidate provider plumbing and agent boilerplate (−365 production LOC, 5 defect fixes) (#2286) (by gold-silver-copper) - [#2286]
- Add reasoning delta agent hook (#2282) (by gold-silver-copper) - [#2282]
- (bedrock) cover multi-tool_use streaming with a cassette (#2251) (by kjones)
- [breaking] assistant content is tagged and provider extras are a named field (#2277) (by gold-silver-copper) - [#2277]
- post-Vec-migration precision and the pre-Vec serde accommodations go (#2276) (by gold-silver-copper) - [#2276]
- [breaking]
OneOrMany<T>becomesVec<T>— the fake is deleted, the enforcement moves (#2273) (by gold-silver-copper) - [#2273] - run rig-derive's test suite, which ran in no job at all (#2275) (by gold-silver-copper) - [#2275]
- single-source the rig setup, drop every API key, and finish the deferred hardening (#2271) (by gold-silver-copper) - [#2271]
- split the PR gate from the full --all-features suite (#2268) (by 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) (by gold-silver-copper) - [#2267]
- Stream parts become entities: lifecycle grammar, opaque keys, and tool names as data (the 84a43e9e C→B→A program) (#2262) (by gold-silver-copper) - [#2262]
- (migrating) document release guide generation (#2260) (by gold-silver-copper)
- Canonical stream grammar: mandatory identity, one accumulator, decode-then-validate, and a wire-conformance corpus (#2258) (by gold-silver-copper) - [#2258]
- Normalize completion responses at the provider boundary and erase the model type at agent construction (#2257) (by gold-silver-copper) - [#2257]
- (migrating) extend the guide back to 0.30 (#2226) (by gold-silver-copper)
- (changelog) credit every 0.41 contributor, not just merged-PR authors (#2223) (by gold-silver-copper)
Contributors
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:
openssl0.10.78 → 0.10.81, closing theX509Ref::ocsp_respondersundefined 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; andopentelemetry_sdk0.31.0, which leaves the graph entirely once the Google Cloud SDK that pinned it moves. Thecargo auditset addsammonia,crossbeam-epoch,rkyv0.8,anyhow,event-listener,memmap2andspin.quick-xmlmoves 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 plainReader, neverNsReaderor.attributes()), so that one is hygiene rather than remediation. Not fixed: the fourrustls-webpki0.102.8 advisories, reachable only through rig-agent's optionaldiscord-botfeature viaserenity0.12.5, which is the newest published release and pins a rustls major with no patched version — rig's defaultreqwest/rustlspath is on the patched 0.103.x and is unaffected (#2342) - (deps) [breaking]
lopdfmoves 0.41 → 0.44, clearing RUSTSEC-2026-0187 — a stack overflow on deeply nested PDF objects, reached byloaders::pdfon 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-inpdffeature:PdfLoaderError::PdfErrorwrapslopdf::Error, andPdfFileLoader::load/load_with_pathyieldlopdf::Document, so a downstream crate that names either type moves to 0.44 in lockstep or ends up with two incompatiblelopdfin the graph. The bump also turns default features off (rayononly), dropping the unusedchrono,jiffandtimeintegrations (#2297)
Added
- (voyageai)
voyageai::EmbeddingOptionsandEmbeddingModel::with_options— Voyage's per-requestinput_type("document"/"query"),truncationandoutput_dimensionhad 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 anOptionand 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 narrowedoutput_dimensionwants the matchingndims(#2343) - (groq) model listing: Groq serves
GET /models— the path its ownVERIFY_PATHalready used — but declared nomodel_listingcapability, soClient::list_models()was unavailable. Recorded against the live API rather than assumed from Groq being OpenAI-compatible (#2079) - (moonshot, minimax) model listing: both serve
GET /modelswith the OpenAI-style{"object":"list","data":[…]}envelope their own API references document, and neither declared amodel_listingcapability. 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) - (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) - (openai)
OpenAICompatibleProvider::requires_modern_output_cap()— per-model opt-in to themax_completion_tokensspelling, defaulted tofalseso no compatible provider has to change (#2332) - (openai) [breaking]
openai::TranscriptionUsage(withDurationTag,TokensTagandTranscriptionInputTokenDetails), reached through the newopenai::TranscriptionResponse::usagefield.TranscriptionResponsehas public fields, no constructor and no#[non_exhaustive], so code building one with a struct literal — a test double, a hand-made response — must addusage; 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 omitusage(#2332) - (gemini)
gemini::completion::attach_trailing_signature(&mut Vec<AssistantContent>, String)— the one home for where Gemini 3's trailingthoughtSignaturelands: on the last reasoning block still awaiting a signature, or as a signature-only reasoning part when there is no such block. Public becauserig-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) - (azure) the image-generation capability is declared on the client:
AzureExtleft itsImageGenerationslot atNothing, soImageGenerationClient::image_generation_modeldid not resolve for an Azure client and generic code bounded onImageGenerationClientcould not accept one — theazure::ImageGenerationModeltype itself was already public and constructible throughImageGenerationModel::make. The slot is nowCapable, 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) - (vector-store)
vector_store::request::DynamicSearchFilter— the single conversion from the canonicalFilter<serde_json::Value>a type-erased search carries into a backend's native filter type (from_dynamic_filter), plus an overridablenormalize_dynamic_document.VectorStoreIndexDyn's blanket impl now boundsF: DynamicSearchFilter + WasmCompatSend + WasmCompatSync + 'staticinstead of spelling outDebug + Clone + SearchFilter<Value = serde_json::Value> + Serialize + Deserialize, which is a widening — a blanketDynamicSearchFilterimpl 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::S3VectorsVectorStorenow implementsVectorStoreIndexDynthroughimpl DynamicSearchFilter for S3SearchFilter, which itsaws_smithy_types::Documentfilter previously blocked. rig-milvus, rig-mongodb, rig-scylladb and rig-surrealdb each replace a hand-writtenVectorStoreIndexDynimpl with oneDynamicSearchFilterimpl, 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 ridesnormalize_dynamic_document, so it applies to JSON-valued filters only and native-filter backends return their documents verbatim (#2317) - (completion) [breaking] the provider's transport request id now survives onto errors:
ProviderResponseErrorgains aprovider_request_idfield — set throughwith_provider_request_idalongside the existingnew/without_statusconstructors — read via a newprovider_request_id()accessor on every capability error enum, forwarded through rig-agent'sPromptError/StructuredOutputError, and appended to the error'sDisplayas(request id: …). The unary driver reads it off a failed response throughhttp_client::Error::InvalidStatusCodeWithDetails, a new arm on the exhaustive transportErrorenum whoseDisplayis identical toInvalidStatusCodeWithMessage; 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 asCompletionError::ProviderResponserather thanHttpError— classification follows the provider's declared contract, never whether a particular response carried the header — so amatcharm onCompletionError::HttpError(_)for those providers' 4xx/5xx stops firing; theprovider_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 sendsx-request-idon 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 staysNone(rig#2314) (#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 byCompletionResponse::identity()andStreamFinal::identity(), both of which also exposeprovider_request_iddirectly. Capture is a per-provider contract rather than a header allowlist: Anthropicrequest-id(inherited by its Anthropic-dialect gateway clients), OpenAI on both APIs, xAI, ChatGPT, Groq and Copilotx-request-id, and Bedrock's SDKx-amzn-RequestIdon both the unary and converse-stream surfaces; Gemini, Cohere, OpenRouter and DeepSeek report none and yieldNone— a documented outcome, never an error. In rig-agent theCompletionResponse,StreamResponseFinishandModelTurnFinishedhook events gainidentity: &ResponseIdentity, andModelTurnFinishedfires 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'sCompletionCallis no longerCopy(it carries owned identity strings — use.cloned()in place of.copied(); its newmessage_id/response_id/provider_request_idfields are serde-defaulted, so pre-identity run JSON still loads),AgentRun::record_streamed_completion_calltakes the identity as a second argument,ModelTurngainsresponse_id/provider_request_idwith awith_identitybuilder, and hand-constructed hook events must supply the new field (&ResponseIdentity::default()preserves the old behavior). SeeMIGRATING.mdfor each (rig#2265) (#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:1hon the prefix that is byte-identical across sessions, the 5-minute default on the tail that changes every turn. Composes withwith_prompt_caching,with_automatic_cachingand a raw top-levelcache_control; unset, every existing constructor's request bytes are unchanged, and setting the prefix toFiveMinutesunder a 1h top-level TTL fails client-side with an error naming both knobs (#2312) - (anthropic) [breaking]
anthropic::completion::Usageandanthropic::streaming::PartialUsageparse the per-TTLcache_creationbreakdown (CacheCreation { ephemeral_5m_input_tokens, ephemeral_1h_input_tokens }) alongside the preservedcache_creation_input_tokensaggregate, and the streaming adapter carries the split frommessage_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) - (bedrock)
CompletionModel::with_guardrailattaches a Bedrock guardrail (identifier, version, trace mode) to every Converse request the model issues; requests previously had no way to carryguardrailConfigat all, which also made the response-side trace unreachable.types::converse_outputandtypes::assistant_contentare public modules, so a caller can finally nameAwsConverseOutput— the typeraw_completionhands back (#2311) - (venice) new provider for the Venice API (
providers::venice): chat completions and streaming — tools, vision, structured output — over the shared OpenAI-compatible path, plus embeddings,GET /modelslisting, transcription, Venice's nativePOST /image/generate(featureimage) andPOST /audio/speech(featureaudio). Configure withVENICE_API_KEYand the optionalVENICE_BASE_URL. Venice's ownvenice_parametersrequest block is the serializableVeniceParameters(web search viaWebSearchMode, thinking control, characters, a prompt-cache routing key); the response type flattens OpenAI's payload and addsvenice_parameters: Option<VeniceParametersEcho>— carrying the resolved block and itsWebSearchCitationlist — andcost: Option<Cost>, both reachable throughraw_completion(#2306) - (embeddings, cohere) image embeddings:
embeddings::ImageEmbeddingModelis a new public trait (MAX_DOCUMENTS,ndims(),embed_images()over encoded file bytes, and a defaultedembed_image()), andcohere::ImageEmbeddingModel— built withcohere::Client::image_embedding_model()— is its first implementation:embed-english-v3.0withMAX_DOCUMENTS = 1, because Cohere takes one image per request, so a batch is sent as ordered individual calls, andndims() == 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 asEmbeddingError::DocumentErrorbefore a request goes out. The returnedEmbedding::documentis"{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::BilledUnitsgains a publicimages: u32to 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 itsDisplaynow appendsImages: {n}on its own line when the count is non-zero (#2304) - (anthropic) [breaking] opt-in strict tool use:
anthropic::completion::CompletionModel::with_strict_tools()marks every Rig-generated toolstrict: true, so Anthropic constrains the model's tool arguments to the declaredinput_schemainstead of merely being prompted with it.anthropic::completion::ToolDefinitiongains a publicstrict: 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: falseon every object, a local root$refinlined, a rootallOfflattened, stringformatkept only for the ten Anthropic supports (date-time,time,date,duration,email,hostname,uri,ipv4,ipv6,uuid), andminItemskept only when it is 0 or 1. Every other validation keyword is moved into the property'sdescriptionas model guidance and is enforced by neither side, so keep validating tool inputs before execution when those constraints matter. Tools supplied throughadditional_paramspass through untouched, and the newAnthropicCompatibleProvider::enable_strict_tool_usehook 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) - (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_2024andCOMMAND_R_08_2024for completions, plusEMBED_V4(embed-v4.0) for embeddings — the last wired intomodel_dimensions_from_identifierat 1536, so anEMBED_V4model reports its real width instead of the zero every unlisted identifier defaults to. The crate's own doc example moves fromcohere::COMMAND_Rtocohere::COMMAND_A_03_2025(#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-implementsNormalizeCompletionResponseby hand rather than going through the shared OpenAI normalizer, so its destructure absorbedrefusalinto... The turn then normalized to zero content and failed with the opaqueResponse contained no message or tool call (empty). Two surfaces already disagreed with that on the same bytes:ProviderResponseExt::get_text_responseroutes throughassistant_message_text_response, which applies the fallback, and the streaming path uses the shareddelta_text, which prefers a non-emptyrefusal— 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) instead of growing a second one. A turn that carries real content is unchanged, byte for byte (#2358) - (openrouter) [breaking]
Usage.reasoning_tokensis no longer a hardcoded zero. OpenRouter documents usage accounting as always included, and every reasoning route reportsusage.completion_tokens_details.reasoning_tokens— 1,984 of 1,984 completion tokens on a recordedopenai/o4-miniturn, 531 of 540 on ananthropic/claude-haiku-4.5one, 1,702 of 2,000 on adeepseek/deepseek-r1-0528one.openrouter::Usagemodeled nocompletion_tokens_detailsfield at all, so the object was dropped at deserialization andFrom<&Usage> for completion::Usageended with a literalreasoning_tokens: 0; because the same type isOpenRouterExt::StreamingUsage, the streaming terminal record reported the same zero, which is why the transports agreed and nothing caught it. rig's normalizedUsagehas a first-classreasoning_tokensslot that openai, deepseek, gemini and anthropic all fill, and it is recorded onto thegen_ai.usage.reasoning_tokenstelemetry span, so on OpenRouter that span and every caller reading the field saw zero no matter what the route billed. The newopenrouter::CompletionTokensDetailsis 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::Usagehas all-public fields and no#[non_exhaustive], so code building one with a full struct literal — or destructuring one with an exhaustivelet Usage { .. }pattern — must account forcompletion_tokens_details. The type derivesDefault, so..Default::default()keeps working on the construction side and..on the pattern side, and every decode path is unchanged (#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 nodefault_ndims, soclient.embedding_model(QWEN3_EMBEDDING_8B).ndims()was0against 4096-wide vectors; a vector store sized from that number (rig-neo4jvalidates and creates its index with it,rig-sqlitesizes its table from it) got a zero-width index. In the other directionembedding_dimensionsreturnedOk(None)unconditionally, soembedding_model_with_ndims(QWEN3_EMBEDDING_8B, 512)reported 512 and received 4096: thedimensionsfield 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 nodimensions, so every already-recorded default-width request is byte-identical. An explicitndimsof0is rejected before sending for every Doubleword model; previously the shared OpenAI-compatible path erased the fact that zero was explicit, sent no field, and leftndims()reporting0against the native 4096-wide response. For the known Qwen model, every other width outside 32-4096 is also anEmbeddingError::InvalidParameterValueraised before the request is built, because Doubleword answers an over-wide request200 OKwith 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 sentdimensionsat all (#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 withfinish_reason: "length"andtool_calls[].function.argumentscut 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::Functionandmistral::Functionall parsed that strictly, so the wholeCompletionResponsefailed 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 returnspage_oncallwith full arguments beside a truncatedfile_report, and blocking lost both. #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 outerfinish_reasonmaps toLength; it drops calls whose argument string is empty or unparseable, while preserving complete siblings and the rest of the turn. An ordinary completedtool_callsresponse with malformed JSON remains a decode error, as do compound defects such as a missingidor unknowntypebeside truncated arguments. Streaming applies the same boundary: an empty argument slot underLengthis incomplete and cannot dispatch a zero-argument side-effect tool, while an empty slot underToolCallsremains the deliberate parameterless invocation{}. Groq's parameterless"arguments": "null"remains a real call on either path. Mistral's provider-localdeserialize_truncatable_argumentsis removed in favour of the shared policy. The blast radius is every provider whoseResponseisopenai::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) - (deepseek) the reasoning block now leads a normalized blocking choice instead of trailing it.
NormalizeCompletionResponsebuilt[text?, tool_call…]and then pushed thereasoning_contentblock onto the end, so a reasoner turn that called a tool normalized to[text, tool_call, reasoning]— while DeepSeek's own stream delivers everyreasoning_contentdelta before the firstcontentdelta and before the tool call (27 reasoning chunks then 12 tool-call chunks in the recordedreasoning_tool_roundtrip/streamingfixture), 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) - (openrouter) blocking reasoning and
reasoning_detailsnow 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-onlyreasoning.textdetail; 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) - (deepseek) a non-text user content part is no longer silently deleted from the request. DeepSeek takes message
contentas a plain string, sofinalize_request_bodyflattens content-part arrays — and it passedonly_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 passestrue, 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) - (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 streamingStreamingChoicedid not, so serde ignored DeepSeek's recordedcontentandreasoning_contentprobability arrays beforeraw_streamcould expose them.openai::completion::StreamingCompletionResponsenow gainslogprobs: Option<serde_json::Value>and the compatible adapter deep-merges every primary-choice object in arrival order, concatenating nested token arrays;nulland{}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) - (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'sservice_tier/system_fingerprintand OpenRouter's routedprovider. Blocking OpenAI gainsCompletionResponse::service_tier; blocking OpenRouter gainsCompletionResponse::{provider, service_tier}; and Mistral gainsUsage::service_tier, matching its liveusage.service_tierextension 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) - (openrouter) [breaking] blocking
raw_completionretainschoices[].logprobs. OpenRouter already returned the object live and the raw streaming path now preserves its chunked twin, but OpenRouter's provider-local blockingChoicedid not model the field, so serde silently deleted it.openrouter::completion::Choicegains an optionallogprobsfield; normalized completion remains unchanged. Existing payloads still deserialize, while a full struct literal or exhaustive destructure must account for the new field (#2359) - (rig) exclude
tests/cassettes/**from the published crate. The root manifest is therigfacade 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, socargo publish --dry-runreports 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 fromCARGO_MANIFEST_DIR, never embedded withinclude_str!/include_bytes!, so nothing in the package needed them (#2350) - (openrouter) model listings now report
context_lengthandmax_output_tokens. A stray#[serde(rename_all = "camelCase")]on the listing entry made serde look forcontextLength, which OpenRouter never sends, so every model's context window decoded asNonewhile the response carried a real one (262144 for the first entry in the recorded fixture). The output ceiling, which OpenRouter reports undertop_provider.max_completion_tokens, was not read at all — the same class of drop #2322 addedmax_output_tokensto prevent. Found by recording the listing matrix (#2079) - (providers) a model listing no longer fails outright because one entry omits
createdorowned_by. Four providers hand-wrote near-identical listing entries with those fields required — OpenAI's and Mistral's demandedcreatedandowned_by, DeepSeek's and Xiaomi MiMo's demandedowned_by— and the{"data": [...]}envelope decodes as a single value, so one entry missing one key failed the entirelist_models()call with a serde error instead of returning the models the response did describe. The four are replaced by one shared entry in whichidis 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 thedescription/max_context_length/typekeys its listing carries.) The shared entry also readsnameandcreated, which DeepSeek's and Xiaomi MiMo's own entries never modeled, so those keys now reachModel::name/Model::created_atwhen a listing sends them rather than being unconditionallyNone— DeepSeek's live listing sends neither, so its models are unchanged in practice (#2289) - (gemini) model-listing failures now classify as
ModelListingError::ApiErrorwith the provider label, path, status and body preview, instead of a bareRequestError. 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 fixed forverify. Routing through the shared fetch aligns Gemini with every other lister. Callers matchingRequestErroron a failed Gemini listing must matchModelListingError::ApiError { status_code, message }instead — the enum exposes no accessors, only theapi_error/request_error/parse_errorconstructors, and the status previously buried in the message string is now a field (#2079) - (embeddings)
EmbeddingsBuildernow returns each document's embeddings in text order. #2344 fixed the order of the returned pairs; the embeddings within a document were still shuffled whenever its texts straddled aMAX_DOCUMENTSbatch boundary, because concurrent batches were appended in completion order — a six-text document came back as[t5, t0, t1, t2, t3, t4]. Since the derivedEmbedimpl emits fields in declaration order, a caller readingembeddings[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 frommissing embedding for document after batch merge, so anything matching on that string needs updating (#2345) - (embeddings)
EmbeddingsBuilderreturns its(document, embeddings)pairs in the order the documents were added. The builder collected documents into aHashMap<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, andInMemoryVectorStore::add_documents, which mintsdoc{n}ids from this sequence's position, handed the same document a different id on every run. Documents are now held in aVecand merged by position (#2344) - (mistral) stop sending a forced
tool_choicebeside a structuredresponse_format: Mistral rejects that combination outright ("json_schemaresponse type with tools is only compatible withtool_choice: auto"), and rig built it by itself — a structured-output agent defersresponse_formatuntil a tool result exists, then emits it beside the caller's standingtool_choice, so the turn after the first tool call died with a 400, once the tool had already run. Finalization now relaxes the choice toautofor that turn, keeping the caller's schema. The relaxation is keyed on the format'stype, not its presence: it fires only forjson_schemaorjson_objectalongside a non-emptytoolsarray, so an explicit{"type": "text"}— the API default — keeps the caller's forced choice (#2337) - (mistral) stop losing a whole response to a truncated tool call: when
max_tokensruns out mid-arguments Mistral still emits the tool call, withargumentscut 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 itsLengthfinish reason reports the truncation (#2337) - (providers) stop merging candidates on OpenAI-compatible streams: with
n > 1the wire interleaves chunks distinguished only bychoices[].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) - (mistral) count audio tokens as prompt input: Mistral's audio models report
prompt_tokens_details.audio_tokensalongsideprompt_tokensrather than inside it, so a Voxtral turn's normalized usage hadinput_tokens + output_tokensshort oftotal_tokensby the entire audio payload (6 + 2 against a reported total of 383).Usage::input_tokens()now includes them (#2337) - (mistral) chunk embedding jobs at Mistral's real batch cap:
EmbeddingsBuilderused 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.OpenAIEmbeddingsCompatiblegains aMAX_DOCUMENTSassociated const (still 1024 by default) and Mistral sets it to 256 (#2337) - (mistral) report real embedding dimensions:
mistral-embedis 1024-wide, but the default-dimension lookup consulted OpenAI's table only, so every Mistral embedding model reportedndims() == 0unless the caller passed the width explicitly.OpenAIEmbeddingsCompatiblegains adefault_ndimshook; askingmistral-embedfor its own native width is now a no-op rather than anUnsupportedParametererror, since the width is not a request field for that model (#2337) - (mistral) keep
description,max_context_lengthandtypewhen listing models: the shared OpenAI-shaped listing entry models none of them, soModel::description,Model::context_lengthandModel::typewere alwaysNoneeven though Mistral sends all three (#2337) - (core) [breaking] preserve a failed response's headers so
Retry-Aftersurvives 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 ownhttp_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, andVerifyClient::verify, reachable via a newprovider_response_headers()on every capability error,PromptError/StructuredOutputError, andhttp_client::Error::non_success_headers().Nonemeans "not captured", never "the response had no headers". Note for callers matching the transport error directly: contract-less providers' non-success completions and all threeverify()branches now yieldhttp_client::Error::InvalidStatusCodeWithDetailsrather thanInvalidStatusCodeWithMessage— both areHttpError, bothDisplayidentically, and everyprovider_response_*helper reads both, but amatcharm naming the old variant will stop firing. Breaking for one further reason:ProviderResponseErrorgains a publicheadersfield, and since [#2335] removed#[non_exhaustive]workspace-wide, external code building it with a full struct literal must add the field — thenew/without_statusconstructors andwith_*setters are unaffected (#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 neitherUsagenor the streamingPartialUsagemodeled the field — so serde dropped it andcompletion::Usage::reasoning_tokensstayed0on 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 ofoutput_tokensrather than a sibling of it, so it populatesreasoning_tokenswithout enteringtotal_tokens.anthropic::completion::Usagegainsoutput_tokens_detailsandanthropic::streaming::PartialUsagegains the same field; neither is#[non_exhaustive], so code constructing them with a full struct literal must add it (#2334) - (anthropic) stop the model-listing pagination loop from spinning forever:
list_allended its loop onhas_moreand then assigned the page's optionallast_idto 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) - (gemini) stop the model-listing pagination loop on an empty cursor:
parse_models_pagereturnednextPageTokenverbatim, so""read as a cursor rather than as "no more pages" — the loop then re-sent an emptypageToken, 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) - (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-idand a JSON error body — a live handshake with an invalid key returns401with{"error":{"code":"invalid_api_key",…}}.tungstenitehands all three back, and rig flattened them toProviderError("HTTP error: 401 Unauthorized"), soprovider_response_status(),provider_response_body()andprovider_request_id()were allNoneand a bad key was indistinguishable from a network fault. A rejected upgrade now classifies asCompletionError::ProviderResponsewith the body, the id and the rejection's headers attached, so a429upgrade'sRetry-Afterreachesprovider_response_headers()the same way #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/2314//issues/2315) (the blocking path throughsend_completion, the SSE connect path throughsse_transport), and which the websocket never had; failures that never reached the provider stayProviderError(#2338) - (providers) map the
model_lengthfinish reason ontoFinishReason::Lengthin the shared OpenAI-compatible mapper. Mistral spells context-window exhaustionmodel_length; it previously fell through toFinishReason::Other("model_length"), so a turn truncated by the context window was indistinguishable from one that simply stopped — andrig-agent's truncation detection, which deliberately excludesOther, never fired for it. OpenRouter's own mapper already folded the same spelling in (#2331) - (mistral)
Client::verify()now resolves against/v1/modelsinstead 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) - (mistral) capture the provider transport request id: Mistral reports one as
mistral-correlation-idon every response, butREQUEST_ID_HEADERwas left at its conservativeNonedefault, soprovider_request_idwas alwaysNoneon completions, streams and errors alike (#2331) - (mistral) stop silently dropping every non-text content part:
finalize_request_bodyflattened each message's content with the text-only helper, which keeps only parts carrying atext/refusalkey, 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 asimage_urlandinput_audio, and documents map ontodocument_url(inline base64, with the filename indocument_name) or Mistral'sfilechunk (an uploadedfile_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 aCompletionError::RequestError(wrappingMessageError::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, #2291) - (anthropic) [breaking] carry
stop_sequenceon the streamed terminal record: Anthropic's terminalmessage_deltareports which of the caller'sstop_sequencesmatched, 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::StreamingCompletionResponsegains astop_sequencefield 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) - (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 inCompletionResponse::normalizecovered onlyend_turn— so that turn becameResponseError("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_sequencenow joinsend_turnas a legal empty case, but only when the response names the sequence that fired; every other empty response stays guarded (#2329) - (openai) stop dropping Chat Completions refusals: OpenAI spells a structured-output refusal as a sibling of
content({"content": null, "refusal": "…"}) and streams it ondelta.refusal, not as therefusalcontent part rig modeled — which is the Responses API's shape. A refused turn therefore failed with the opaqueResponse 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, whileget_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) - (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'sstatus: incompleterule and the streaming path, which already behaved this way, and applies to every OpenAI-compatible provider. Note the consequence for callers ofCompletionModel::completiondirectly: such a turn now returnsOkwith an emptychoicewhere it returnedErr— seeMIGRATING.md(#2332) - (openai) send the output-token cap as
max_completion_tokensfor reasoning models on Chat Completions: everygpt-5-and-up ando-series model rejects the legacymax_tokensoutright (Unsupported parameter: 'max_tokens' is not supported with this model), soagent.max_tokens(n)— or any capped request — could not succeed at all against them. The new spelling is scoped to those families throughOpenAICompatibleProvider::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) - (openai) fix the image-generation request body: rig added
"response_format": "b64_json"for every model outside a hardcodedgpt-image-1/1.5/2allowlist, but the endpoint now rejects that field for all models (400 Unknown parameter: 'response_format'), sogpt-image-1-mini,chatgpt-image-latest, and dated snapshots such asgpt-image-2-2026-04-21could not generate an image at all. The field is gone — the models this endpoint serves answer withb64_jsonregardless; an OpenAI-compatible images endpoint that still needs it can pass it explicitly now thatImageGenerationRequest::additional_params— silently dropped for OpenAI while xAI and Gemini honored it — is merged into the body, soquality,background,output_format, and the rest reach the API (#2332) - (providers) honor
AudioGenerationRequest::additional_paramsin the shared text-to-speech body: the defaultRawAudioGenerationProvider::audio_generation_request_bodynever 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, andinstructionssteers delivery on thegpt-4o-mini-ttsfamily. Azure OpenAI overrides this body and drops the field too; that one needs a change recordable against Azure (#2332) - (openai) keep the transcription endpoint's
usage: both live model families report what a transcription cost —whisper-1by audio duration, thegpt-4o-transcribefamily 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 owntype, 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 keepsinput_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) - (gemini) stop ending a
streamGenerateContentstream on the firstfinishReason: Gemini emits an intermediatefinishReasonwhen 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 cleanSTOP. The terminal record is now deferred to EOF — a truncated stream (EOF with nofinishReasonat all) still yields no terminal record. Note the deliberate consequence: a stream whose transport fails after afinishReasonnow surfaces the error and no terminal record, where it previously reported a completed turn, because on this wire afinishReasonis not proof the turn finished (#2328) - (gemini) preserve Gemini 3's trailing
thoughtSignature: the wire attaches it to a text part carrying nothoughtflag, 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 ownthoughtSignature) — 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) - (gemini) stop failing a whole
generateContentresponse onexecutableCode/codeExecutionResultparts: enabling Gemini's built-in code-execution tool throughadditional_params.toolsmade every blocking turn fail withResponseError("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) - (gemini) stop reporting the model's chain-of-thought as output text: with
thinkingConfig.includeThoughts,TranscriptionResponse::textreturned the reasoning fromparts[0]and dropped the transcript, andProviderResponseExt::get_text_responseconcatenated reasoning onto the answer. Both now skipthought: trueparts, and the transcript is every visible text part rather than only the first.rig-gemini-grpc'sget_text_responsecarried the same defect and is fixed with it (#2328) - (core) the
pdffeature builds forwasm32-unknown-unknown.lopdfreachesgetrandomthrough its PDF-encryption support, andgetrandomrefuses to compile for browser wasm until a backend is selected — the target triple alone cannot pick one — so any browser build that enabledpdf, directly or through the facade, failed outright with getrandom's "not supported by default" error.rig-corenow enableslopdf'swasm_jsbackend undercfg(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'sCrypto.getRandomValues, as browsers, Web Workers and Node.js 19+ do. CI now runscargo check --package rig-core --all-features --target wasm32-unknown-unknownso an optional loader cannot reintroduce a browser-incompatible transitive dependency (#2319) - (azure) text-to-speech reaches the deployment it names: the model passed the literal
"/audio/speech"wherepost_audio_generationexpects 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 declaredAudioGenerationcapability 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 newClientBuilder::audio_api_version— rather than the GAapi_version(2024-10-21) the other Azure routes share (#2317) - (openai) the Chat Completions client's non-completion capabilities resolve:
OpenAICompletionsExtdeclaredTranscription,ModelListing,ImageGenerationandAudioGenerationasCapable, but each named a model whoseClientassociated type is the Responsesopenai::Client, and the capability blanket impls requireM: …<Client = Self>— soopenai::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) - (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 — mistral) now classifies as
CompletionError::ProviderResponsewith the response body and provider request id, matching the blocking path, instead of a bare status (#2315) - (client)
VerifyClient::verifynow maps 401/403 toVerifyError::InvalidAuthenticationagain under the reqwest transport; the transport reports non-success as an error before the status match, which had made those arms unreachable (#2315) - (providers) the shared Chat Completions response decodes a missing or explicitly
nullfinish_reason,index,objectandcreatedinstead of failing the turn.Choice::indexandChoice::finish_reasoncarried no serde attribute at all, so either one absent ornullwas a hard deserialization error that took the whole response — text, tool calls and usage — with it;object/createdwere#[serde(default)], which tolerates a missing key but still rejects an explicitnull. All four now read throughjson_utils::null_or_default, and an absent reason arrives asfinish_reason() == Nonebecause 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) - (milvus) send the bearer token as
Authorization, notAuthentication: every Milvus REST call built its auth header under a name the server does not read, so a store configured throughMilvusVectorStore::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 stillBearer {username}:{password}(#2308) - (mistral) stop JSON-quoting string
additional_paramson the transcription multipart form: Mistral's transcription built its own form and wrote every value withserde_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 sharedproviders::internal::transcription::transcription_form, which sends string values verbatim and leaves non-strings JSON-encoded. Two consequences of sharing it: a non-objectadditional_paramsnow fails withadditional transcription parameters must be a JSON objectinstead ofAdditional Parameters to Mistral Transcription should be a map, so anything matching that string needs updating; andTranscriptionRequest::promptis now explicitly cleared rather than incidentally ignored — Mistral's endpoint has nopromptfield and never received one (#2305) - (cohere) reject
ToolChoice::Requiredwith no tools before the request leaves the process: building the Cohere chat body now fails withCompletionError::RequestError("Cohere requires at least one tool when tool_choice is REQUIRED")when the choice isRequiredand neitherCompletionRequest::toolsnor a non-emptyadditional_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 localRequestErrorand the HTTP client is never touched. Tools passed through the raw-parameter escape hatch count toward the check, soREQUIREDstays usable with Cohere-specific tool schemas (#2302) - (deepseek) fall back to DeepSeek's native
prompt_cache_hit_tokenswhen a response omits the OpenAI-style details object:From<&deepseek::Usage> for completion::Usagesourcedcached_input_tokensfromprompt_tokens_details.cached_tokensalone and defaulted to0otherwise, even thoughdeepseek::Usagealready 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 withoutprompt_tokens_details(#2301) - (openai) stop letting
additional_params.toolsreplace the builder's tools on Chat Completions:additional_paramsis#[serde(flatten)]ed into the request struct after the typedtoolsfield and the body is built withserde_json::to_value, so a rawtoolsarray left in the params overwrote the typed list entirely — a turn withtool_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 inadditional_paramsfor the provider'sprepare_requesthook (Groq folds its native tools intocompound_customthere), and the key is removed entirely when nothing is left behind. Atoolsvalue that is not an array, or a"function"entry that is not a valid tool definition, now fails locally asCompletionError::RequestErrornaming the key. The Responses, Anthropic and Gemini paths already merged; OpenRouter builds its own request and is unchanged (#1890, #2294) - (gemini) send
temperatureandmax_tokens:create_request_bodyapplied both throughgeneration_config.map(..), andOption::mapis a no-op onNone, so a request that did not already carry agenerationConfig— fromadditional_paramsor from anoutput_schematurn — dropped both and serialized"generationConfig": null..max_tokens(8)never reachedmaxOutputTokens(the turn ran to the model's own limit and reportedStopwhere the wire sendsMAX_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) - (cohere) fix the four request shapes
/v2/chatrejects.documentsnow serializes as Cohere's own{id, data}document instead of rig'scompletion::Document({id, text, additional_props});tool_choicegoes out through a dedicatedcohere::completion::CohereToolChoicewhoseSCREAMING_SNAKE_CASEserde produces"REQUIRED"/"NONE", where rig's snake_caseToolChoicehad been sending"required";max_tokensis 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; andToolResultContentgains#[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 fromusage.tokenson every surface (billed_unitsexcludes cached input and system overhead, so counts step up on the input side), and the transport error is no longer boxed intohttp_client::Error::Instance, soprovider_response_status()andprovider_response_body()return the status and body instead ofNoneon every real HTTP failure. SeeMIGRATING.md(#2263)
Deprecated
- (mistral)
PIXTRAL_LARGE,PIXTRAL_SMALL,MISTRAL_SABA,MISTRAL_NEMOandCODESTRAL_MAMBAare deprecated: none of these identifiers appear in Mistral'sGET /v1/modelscatalog any more, and a request naming one fails with400 Invalid model. Pixtral's vision role is covered byMISTRAL_SMALLandMINISTRAL_3B, which are vision-capable (#2337) - (cohere)
COMMAND_R_PLUS,COMMAND_R,COMMAND,COMMAND_LIGHTandCOMMAND_LIGHT_NIGHTLYare deprecated: Cohere removed the first four on 2025-09-15 and no longer servescommand-light-nightly, which answers 404, so a request naming any of the five fails.COMMAND_NIGHTLYis 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)
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.lockfor in-range releases, andscripts/check-dependency-floors.py(CIdependency-floors) builds the workspace against the declared floors. Thederanged = "=0.5.8"exact pin is gone. Downstream users no longer have tocargo updateunrelated crates to take a rig release (#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; 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 bareloops, so a cursor that keeps changing without advancing — a gateway alternatingc1, c2, c1, …, or minting a fresh one per request — never terminated, where the shared loop now stops afterMAX_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) - (workspace) remove
#[non_exhaustive]from every type in the workspace — 53 attributes acrossrig-core,rig-agent,rig-bedrockandrig-candle. Struct literals, functional update (..Default::default()) and exhaustivematchnow 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, downstreammatcharms that exist only to satisfy a previously non-exhaustive enum may now warnunreachable_patterns, which is an error under-D warnings— delete the wildcard (two in-tree matches overReasoningContentneeded 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. SeeMIGRATING.mdfor the superseded guidance and the one invariant this widens (#2335) - (agent) [breaking]
ToolSetBuilderandToolSet::builder()are removed, and therigfacade dropsToolSetBuilderfrom itsrig::toolre-export. A tool set is populated in place instead:ToolSet::default()(orfrom_tools/from_dynamic_tools) plusadd_tool,add_dynamic_tool,add_portable_dynamic_tooland the newadd_retrieved_tool, soToolSet::builder().retrieved_tool(t).build()becomeslet mut set = ToolSet::default(); set.add_retrieved_tool(t);(#2320) - (core) [breaking] twenty public items go with the same consolidation pass.
telemetry::ProviderResponseExtlosestype OutputMessageandget_output_messages— nothing ever read them (SpanCombinator::record_response_metadatarecords 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_responsestays.client::ImageGenerationClient::custom_image_generation_modelis deleted — it was a defaulted alias whose body wasSelf::ImageGenerationModel::make(self, model), which is exactly what the trait's blanket impl resolvesimage_generation_modelto, soclient.custom_image_generation_model(m)becomesclient.image_generation_model(m)and nothing else moves.json_utils::null_or_vecfolds intonull_or_default, the drop-in for aVec<T>field. Anthropic'scompletion::apply_cache_controlis deleted with no public successor — its replacementapply_prompt_cache_controlispub(super). And gemini'sinteractions_api::interactions_api_typesdrops fifteen*Deltastructs (ImageDelta,AudioDelta,DocumentDelta,VideoDelta,FunctionCallDelta,FunctionResultDelta,CodeExecutionCallDelta,CodeExecutionResultDelta,UrlContextCallDelta,UrlContextResultDelta,GoogleSearchCallDelta,GoogleSearchResultDelta,McpServerToolCallDelta,McpServerToolResultDelta,FileSearchResultDelta) becauseContentDeltanow carries the identically-shaped*Contentpayloads directly — the JSON is unchanged, amatchnaming the old payload types is not (#2320) - (anthropic) [breaking] every locator variant of
anthropic::completion::Citationis now a newtype over a payload struct of its own:CharLocation(CharLocationCitation),PageLocation(PageLocationCitation),ContentBlockLocation(ContentBlockLocationCitation),SearchResultLocation(SearchResultLocationCitation)andWebSearchResultLocation(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-writeSerialize/Deserializearound the samechar_location/page_location/content_block_location/search_result_location/web_search_result_locationtypetags — so persisted citations still load and a serialized one carries exactly the keys and values it always did. What breaks is source: amatcharm or struct literal spellingCitation::CharLocation { cited_text, .. }becomesCitation::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, .. }offraw_completion, andanthropic::streaming::ContentDelta::CitationsDeltaoffraw_stream(#2320) - (bedrock, s3vectors) [breaking] the mirror→AWS-SDK conversion family is deleted: the 38
TryFromimpls intypes::converse_outputthat rebuilt an SDK value out of the mirror just built from it — 17 written by hand, 21 generated by the mirror macros'reversearm — are gone (hand-written targets:aws_bedrock::Message,DocumentSource,DocumentBlock,S3Location,ImageBlock,VideoBlock,ToolUseBlock,ToolResultBlock,ToolResultContentBlock,ReasoningTextBlock,CachePointBlock,CitationsConfig,CitationsContentBlock,Citation,GuardrailConverseImageBlock,GuardrailConverseTextBlock, andaws_sdk_bedrockruntime::primitives::Blob), so there is no supported way back from a mirror value to itsaws_sdk_bedrockruntimecounterpart. The surviving Rig-facing conversions read the mirror instead:RigAssistantContent/RigUserContentconvert fromconverse_output::ContentBlock,RigMessagefromconverse_output::Message,RigImagefromImageBlock,RigDocumentfromDocumentBlock,RigToolResultContentfromToolResultContentBlock— each took theaws_bedrock::type before. rig-s3vectors' exporteddocument!macro goes with it: it existed to spellaws_smithy_types::Documentfilter literals,S3SearchFilter's constructors now build those values through private helpers, and there is no public replacement (#2320) - (image, audio)
image_generation::ImageGenerationModelandaudio_generation::AudioGenerationModelstate their bounds asWasmCompatSend/WasmCompatSyncinstead ofSend/Sync:ImageGenerationModel's own supertraits, and on both traits the associatedResponseand the future the generation method returns.CompletionModel,EmbeddingModelandTranscriptionModelwere already written this way, so these two were the last capability traits stating a hardSend. On native targets nothing moves —WasmCompatSend: SendandWasmCompatSync: Sync, blanket-implemented for every qualifying type — so existing implementors compile unchanged and generic code still getsSend/Syncout of the bound. Onwasm32-unknown-unknownboth markers are empty, so a browser-wasm model whose HTTP future is notSendcan implement either trait, which the old+ Sendfuture bound ruled out (#2317) - (providers) [breaking] xAI completion and streaming now use the shared OpenAI-compatible Responses driver.
xai::completion::CompletionResponseis the shared Responses wire type, and unknown response statuses deserialize asResponseStatus::Otherinstead of rejecting the response. xAI and OpenRouter audio generation also use the existing shared raw-audio request driver (#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_completiondriver 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 intointernal::sse_transport::open_wire_stream. [breaking] rig-candle drops seven dead public items (from_artifacts{,_async},from_gguf_async,from_gguf_bytes_async, theLlamaModelBuilderalias, bothmodel_familyaccessors) (#2310) - (copilot) [breaking]
copilot::CopilotCompletionResponse::Chatcarriesopenai::completion::CompletionResponse, and the duplicatecopilot::ChatCompletionResponse/copilot::ChatChoiceare deleted — they were a field-for-field copy of the shared OpenAI chat wire types.CompletionModel::raw_completionreturns that enum, so anything naming either type, or destructuring theChatvariant's payload, moves to the shared type. Optionality moves with it:object,createdand the choice'sfinish_reasonareString,u64andStringrather thanOption, so a value that used to serialize asnullnow serializes as""or0; persisted JSON still loads, because the shared type accepts a missing key or an explicitnullfor all three (#2308) - (bedrock) [breaking]
rig_bedrock::streaming::BedrockUsageis deleted andBedrockStreamingResponse::usageisOption<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 namedBedrockUsage, whichstreamingexported as a public type, has to switch toTokenUsage, reachable now thattypes::converse_outputis a public module (#2308) - (core, providers) [breaking] four public items are gone and one constructor changed shape.
http_client::with_bearer_authis deleted — callhttp_client::bearer_auth_headeron the builder's own header map.InMemoryVectorStoreBuilder::documents_with_id_fis deleted; the identically named store methodsInMemoryVectorStore::from_documents_with_id_f/add_documents_with_id_fare untouched, anddocuments_with_idscovers the builder case.mira::MiraErrorand mira's inherentClient::list_modelsare deleted: mira rides the shared lister now, so listing goes throughModelListingClient::list_models, returningModelListandModelListingErrorinstead ofVec<String>andMiraError. Andazure::EmbeddingModelis a type alias foropenai::embedding::GenericEmbeddingModel<AzureExt, T>, whosenew/with_modeltakendims: usizerather than the old inherentOption<usize>;EmbeddingModel::make(&client, model, None)and the client'sembedding_modelhelpers keep the infer-dimensions-from-the-model-identifier behavior. SeeMIGRATING.md(#2305) - (core) [breaking] remove verified-dead public API.
ModelListingErrorloses itsRateLimitError,ServiceUnavailableandUnknownErrorvariants and theauth_error/rate_limit_error/service_unavailable/unknown_errorconstructors — the enum derivesSerialize/Deserialize, so a persisted value naming a dropped variant no longer loads, while every real listing failure already classified asApiError,RequestErrororParseError. Also gone:message::Reasoning::optional_id,message::Image::try_into_url,message::DocumentSourceKind::{raw, unknown}(theRaw/Unknownvariants 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(useiter()), the dead wire typesazure::{EmbeddingResponse, EmbeddingData, Usage}andollama::{AssistantContent, UserContent, ImageUrl}plus ollama'sSystemContent/SystemContentTypere-export,deepseek::Message'sSystem,UserandToolResultvariants (that enum is the response shape, where onlyAssistantever appeared), the wholedoubleword::client::doubleword_api_typesmodule,openai::responses_api::OutputReasoning, and separately that module'sTryFrom<message::Message> for Vec<Message>converter, and rig-agent'sMultiTurnStreamItem::final_response_with_history. SeeMIGRATING.mdfor the table (#2301) - (huggingface) [breaking]
huggingface::transcription::TranscriptionResponseis a re-export ofopenai::TranscriptionResponseinstead 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 later addedusageto the OpenAI type, a struct literalTranscriptionResponse { text }on the HuggingFace path must supplyusagetoo (it is#[serde(default)], so decoding a response without it is unaffected) (#2289) - (completion) [breaking]
OneOrMany<T>andEmptyListErrorare removed: message content,CompletionResponse::choice,CompletionRequest::chat_history,ToolResult::contentandEmbeddingsBuilderoutput are plainVec<T>, and non-emptiness is enforced where it actually matters —CompletionRequest::validate_message_contentrejects a content-less message,ToolOutput::contentis 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:[], andnullon the fields that moved ontojson_utils::string_or_vec(OpenAI's tool-calls-only"content": null). A tool withtype Output = Vec<ToolResultContent>compiles unchanged but now takesIntoToolOutput's rich-content path — N ordered blocks instead of one JSON array (#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 opaqueStreamPartId— noSerialize, no rendering, no accessor — with the durable provider handle carried separately asWireId, andmessage::ToolCall/message::ToolResultbecome{ id: ToolCallId, provider: Option<ProviderCallId>, .. }/{ call: ToolCallId, provider: Option<ProviderCallId>, name: String, .. }, whereToolCallIdis non-empty by construction and minted at the boundary when the wire issued no id. Persisted-history serde is breaking: pre-provider-splitToolCallJSON is no longer lifted on load — seeMIGRATING.md(#2262, #2267) - (streaming) [breaking] one canonical stream grammar: reasoning and text raw events carry mandatory identity (
RawStreamingChoice::{Reasoning, ReasoningDelta, TextStart}andStreamedAssistantContent::ReasoningDeltatake anid, 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 anErrrather than being absorbed, while an unrecognized event type warns and skips for forward compatibility (#2258) - (completion) [breaking] completion responses are normalized at the provider boundary:
CompletionResponse<T>becomes the concreteCompletionResponsecarryingfinish_reason/provider/model/message_id/response_id,raw_responseis gone, andCompletionModeldrops itsResponse/StreamingResponse/Client/makeassociated items along with itsClonesupertrait — a provider's native wire type is reached through the new inherentraw_completion/raw_streamescape hatches. Agents erase the model type in the same change:Agent<M>,AgentBuilder<M>afternew(),AgentRunner<M>, the prompt/stream request types andExtractor<M, T>lose their model parameter and store aModelHandle, which is what makesAgent::set_modeland per-runusing_model(..)possible. Streams normalize too: a corrupt frame surfaces as anErritem 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)