You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 84a43e9 C→B→A program) (#2262) (by gold-silver-copper) - #2262
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
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)
(deps) [breaking] lopdf moves 0.41 → 0.44, clearing RUSTSEC-2026-0187 — 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)
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)
(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)
(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)
(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 the max_completion_tokens spelling, defaulted to false so no compatible provider has to change (#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)
(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)
(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)
(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)
(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)
(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)
(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)
(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)
(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)
(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 /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)
(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)
(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)
(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)
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) instead of growing a second one. A turn that carries real content is unchanged, byte for byte (#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)
(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)
(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 wholeCompletionResponse 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 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)
(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)
(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)
(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)
(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)
(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)
(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)
(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)
(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 added max_output_tokens to prevent. Found by recording the listing matrix (#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)
(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 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)
(embeddings)EmbeddingsBuilder now 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 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)
(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)
(mistral) stop sending a forced tool_choice beside 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 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)
(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)
(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)
(mistral) count audio tokens as prompt input: Mistral's audio models report prompt_tokens_details.audio_tokensalongsideprompt_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)
(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)
(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)
(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)
(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)
(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)
(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)
(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)
(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 routed it on the shared request drivers and the SSE handshake — the contract the crate's other two completion transports have kept since #2314/#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)
(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)
(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)
(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)
(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, #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)
(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)
(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)
(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)
(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)
(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)
(providers) honor AudioGenerationRequest::additional_params in the shared text-to-speech body: the defaultRawAudioGenerationProvider::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)
(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)
(gemini) stop ending a streamGenerateContent stream on the first finishReason: Gemini emits an intermediatefinishReason 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)
(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)
(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)
(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)
(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)
(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)
(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 Responsesopenai::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)
(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::ProviderResponse with the response body and provider request id, matching the blocking path, instead of a bare status (#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)
(providers) the shared Chat Completions response decodes a missing or explicitly nullfinish_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)
(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)
(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)
(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)
(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)
(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, #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)
(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)
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)
(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)
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) - #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 bare loops, 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)
(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)
(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)
(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)
(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_locationtype 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)
(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)
(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)
(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)
(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)
(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)
(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)
(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)
(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)
(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 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)
(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)
(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, #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)
(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)