Skip to content

Latest commit

 

History

History
778 lines (667 loc) · 159 KB

CHANGELOG.md

File metadata and controls

778 lines (667 loc) · 159 KB

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Sections are generated on the release PR from the merged pull requests; do not edit this file in an ordinary PR (CI fails). Put notes under ## Changelog in your PR description.

0.42.0 - 2026-08-17

Added

Fixed

Other

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: 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 whole CompletionResponse failed to decode and the turn's text, usage, id, model and finish reason went with it, while the streaming path kept the turn and dropped the unusable call — the two transports disagreeing about identical wire bytes. On a parallel turn it lost a complete call too: at a 56-token cap DeepSeek returns page_oncall with full arguments beside a truncated file_report, and blocking lost both. #2337 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 structured response_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_tokens alongside prompt_tokens rather than inside it, so a Voxtral turn's normalized usage had input_tokens + output_tokens short of total_tokens by the entire audio payload (6 + 2 against a reported total of 383). Usage::input_tokens() now includes them (#2337)
  • (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 default RawAudioGenerationProvider::audio_generation_request_body never merged the field, so it was silently inert for whoever inherited it — OpenAI included — while every provider that overrides the body already merged it (xAI, OpenRouter, Venice). The parameters demonstrably change the response: response_format: "wav" returns a RIFF payload where the default returns MP3, and instructions steers delivery on the gpt-4o-mini-tts family. Azure OpenAI overrides this body and drops the field too; that one needs a change recordable against Azure (#2332)
  • (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 intermediate finishReason when a built-in tool (code execution) runs a round and then keeps streaming, so the whole answer after it was dropped while the stream still reported a clean STOP. The terminal record is now deferred to EOF — a truncated stream (EOF with no finishReason at all) still yields no terminal record. Note the deliberate consequence: a stream whose transport fails after a finishReason now surfaces the error and no terminal record, where it previously reported a completed turn, because on this wire a finishReason is not proof the turn finished (#2328)
  • (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 Responses openai::Client, and the capability blanket impls require M: …<Client = Self> — so openai::CompletionsClient::{transcription_model, list_models, image_generation_model, audio_generation_model} failed to compile and the only route to those endpoints was .responses_api(). Each slot now names a Completions-client model (CompletionsTranscriptionModel, OpenAICompletionsModelLister, CompletionsImageGenerationModel, audio_generation::CompletionsAudioGenerationModel), so switching APIs preserves every capability (#2317)
  • (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 null finish_reason, index, object and created instead of failing the turn. Choice::index and Choice::finish_reason carried no serde attribute at all, so either one absent or null was a hard deserialization error that took the whole response — text, tool calls and usage — with it; object/created were #[serde(default)], which tolerates a missing key but still rejects an explicit null. All four now read through json_utils::null_or_default, and an absent reason arrives as finish_reason() == None because normalization filters the empty string. This is the one wire type behind every OpenAI-compatible provider, so it changes decoding for all of them; Copilot's multi-vendor chat route is the wire that sends these shapes (#2308)
  • (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_location type tags — so persisted citations still load and a serialized one carries exactly the keys and values it always did. What breaks is source: a match arm or struct literal spelling Citation::CharLocation { cited_text, .. } becomes Citation::CharLocation(CharLocationCitation { cited_text, .. }), and likewise for the other four. Citation::Unknown(serde_json::Value) is unchanged. Both routes to the type are provider-native ones the escape hatches lead to: Content::Text { citations, .. } off raw_completion, and anthropic::streaming::ContentDelta::CitationsDelta off raw_stream (#2320)
  • (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)

0.41.0 - 2026-07-28

Added

Fixed

Other

Contributors

Added

  • (agent) Restore AgentBuilder::dynamic_context and ExtractorBuilder::dynamic_context as convenience wrappers around the existing completion-call hook lifecycle. The helper retains the former query selection and document formatting behavior without restoring a separate retrieval path in agent request construction. As an ordinary hook, retrieval and injected documents follow registration order relative to application hooks; register stop policies before it when they should prevent retrieval.

  • (core) rig_core::telemetry::Empty re-exports tracing::field::Empty, so a runtime can declare a completion-parent field as not-yet-valued without taking a direct tracing dependency.

Changed

  • (core, agent) [breaking] Remove every wasm feature flag in the workspace — rig-core's wasm, rig-agent's wasm, and the rig facade's wasm. Browser wasm needs no feature flags at all: cargo build --target wasm32-unknown-unknown is the entire opt-in. The feature was a pure cfg switch that every consumer already flipped from a target table, and its one optional dependency was never referenced. Relaxing the bounds cannot break implementors — the relaxed markers are blanket-implemented (impl<T> WasmCompatSend for T {}), so every type that satisfied the strict form satisfies the relaxed one. (Generic consumers on browser wasm that wrote T: WasmCompatSend and then relied on T: Send internally are the one exception, and only if they were previously building with the feature off.) Dependents passing features = ["wasm"] should drop it; nothing replaces it.

  • (core) if_wasm!/if_not_wasm! now key on the target rather than a feature. These are #[macro_export]ed, and a cfg inside a macro expansion is evaluated in the calling crate — so the old expansion tested whether the caller had a feature named wasm, not rig-core. Any caller without one took the if_not_wasm! branch on every target, browser wasm included. Called out separately because unlike the feature removal, which Cargo rejects at resolution, this one changes behavior with nothing to fail on: a downstream crate that did define a wasm feature and expected it to drive these macros gets the target's answer now, silently. Gate on the target directly if you need the old association.

  • (agent) [breaking] The rmcp feature is native-only. It never compiled for wasm — rmcp's ClientHandler requires Send + Sync unconditionally, which rig's wasm tool registry cannot satisfy — but it failed with a wall of dyn ErasedTool trait errors. It now fails with one sentence naming the cause, and CI asserts that stays true.

  • (agent) Document the supported target matrix: native is fully supported, wasm32-unknown-unknown (browser) is supported, and WASI is not — its dependency graph has never built. Browser-only dependencies and Send-relaxed aliases are scoped accordingly, and wasm-bindgen-futures is no longer a rig-agent dependency, its only user having been the now-native-only MCP cancellation dispatch.

  • (core) Fix rig-core's SSE ResponseFuture/EventStream aliases, whose cfg arms did not partition and left some targets matching neither, so the types were undefined there. Both arms now share one predicate.

  • (core) The telemetry completion-parent contract has one declarative source: the new rig_core::telemetry::completion_parent_span! macro declares the adoption marker and every required gen_ai.* field. tracing bakes a span's field set into static metadata and Span::record silently no-ops on undeclared fields, so a hand-mirrored field list that drops one field loses that telemetry with no error — the contract was previously duplicated in six places. Exact-set tests now pin the macro against COMPLETION_PARENT_REQUIRED_FIELDS and against the span the completion builder itself creates, so those lists (including rig-agent's chat span, which now delegates to the macro) can no longer drift. A completion parent that carries the marker but omits a required field triggers a warn! naming the missing fields — once per offending span callsite, so two broken runtimes are both reported — before it degrades to a fresh rig::completions child span, so the degradation is visible in logs rather than only as a duplicated span layer in dashboards. The macro accepts an optional parent: argument (default: the current span), and its expansion resolves tracing through rig-core, so downstream crates do not need a direct tracing dependency merely to invoke it (see the Empty re-export above). Nothing is breaking: the marker field and the required field set are unchanged.

  • (derive) [breaking] #[rig_tool] required-ness is now derived from the parameter types, and the advertised schema always agrees with the deserializer. Without an explicit required(...), non-Option parameters are required and Option<T> parameters are optional (previously Option parameters were advertised as required even though absence deserialized to None). With an explicit required(...), parameters omitted from the list are deserialized with #[serde(default)], so omitting a non-Option, non-Default parameter is now a compile error instead of a runtime deserialization failure when the model leaves it out. Names in params(...) and required(...) must match actual parameters, and malformed or duplicate attribute entries are compile errors instead of being silently ignored. Listing an Option<T> parameter in required(...) is a compile error (schemars and serde would both silently ignore the directive), and a wildcard context binding (#[rig(context)] _: &mut ToolContext) is now rejected — name it _context instead.

  • (derive) #[rig_tool] recognizes fully qualified &mut ToolContext parameters under renamed rig/rig-agent dependencies without the #[rig(context)] marker; crate-name resolution and context classification now share one authority. A contextual tool in a crate with neither rig nor rig-agent reachable gets a targeted diagnostic instead of an unresolved ::rig_agent path error. Generated parameters() builds the schema once (LazyLock) and no longer contains an expect, so downstream crates denying clippy::expect_used are unaffected.

  • (derive, core) Macro-generated code resolves serde, serde_json, and schemars through rig-core's re-exports (rig_core::{serde, serde_json, schemars} are now public), so crates using #[rig_tool] or #[derive(Embed)] no longer need direct serde/serde_json dependencies. The Embed derive emits fully qualified trait impls and no longer requires the Embed trait to be imported at the call site. A field carrying both #[embed] and #[embed(embed_with = "...")] is now a compile error instead of being embedded twice, and a field carrying more than one #[embed(embed_with = "...")] attribute is a compile error instead of the first silently winning.

Removed

  • (agent) Remove the experimental rig-runtime-conformance crate and its classic-runtime adapter. With a single runtime it was a premature cross-runtime abstraction, and its scenarios were ~90% redundant with rig-agent's own test suite. The genuinely-unique invariants (multi-step memory append-once, append of only newly-committed messages, no-append on hook stop, committed-transcript role validity, and a two-sided concurrency bound) are now covered by direct tests in rig-agent. A real conformance contract can be re-extracted once a second runtime exists.

Fixed

  • (examples) candle_wasm_chat now declares the agent feature it actually imports (rig::agent::{Agent, AgentBuilder}, rig::completion::Chat), so it builds standalone rather than only inside a workspace-wide --all-features build that happened to unify the feature onto the shared rig. The wasm CI matrix now checks the example on its own, so a manifest that under-declares its features fails instead of being masked by feature unification.

  • (openai) Treat empty encrypted_content in non-streaming Responses API reasoning items as absent, matching streaming behavior and avoiding empty encrypted reasoning blocks.

  • (aws) Stop enabling the AWS SDK's legacy Rustls connector in the Bedrock and S3 Vectors integrations, removing vulnerable rustls-webpki 0.101 from their active dependency graphs while retaining the modern default HTTPS client.

Changed

  • (core, agent) [breaking] Split the monolithic core into a portable contracts crate (rig-core) and the classic agent runtime crate (rig-agent), presented behind the rig facade. Code using the rig facade needs essentially no source changes — rig::… paths, rig::prelude::*, and rig::tool::{Tool, ToolContext} all keep working. Direct rig-core dependents that constructed agents must now depend on rig-agent. See the migration guide (MIGRATING.md).

  • (tool) [breaking] The portable, context-free tool contract is now named PortableTool (with PortableToolEmbedding, PortableDynamicTool, portable_tool_definition); the rig_core::tool::Tool alias is removed. On the rig facade, rig::tool::Tool remains the classic contextual trait, so existing facade code is unchanged; portable contracts are always available as rig::tool::PortableTool (and in full under rig::tool::portable).

  • (client) [breaking] Provider clients no longer carry inherent agent() / extractor() methods. There is a single canonical CompletionClient trait (in rig-core, providing completion_model); the classic agent() / extractor() constructors live on the new AgentClientExt extension trait. use rig::prelude::*; brings both into scope for the full pre-split client surface (or import rig::client::{CompletionClient, AgentClientExt} explicitly).

  • (agent) [breaking] rig-agent no longer re-exports all of rig-core at its crate root. The previous pub use rig_core::*; made rig-agent an implicit second facade; the root now exports only runtime-owned items (plus the runtime-facing rig_tool / tool_macro macros). Code that depends on rig-agent directly and reached a portable rig-core item through the rig-agent root must import it from rig_agent::core (e.g. rig_agent::core::OneOrMany) or depend on rig-core directly. The root rig facade is unaffected: rig::… and rig::prelude::* are unchanged.

    // Before
    use rig_agent::{OneOrMany, message::Message};
    
    // After
    use rig_agent::core::{OneOrMany, message::Message};
  • (agent) [breaking] Managed agent hooks are now provider-independent. AgentHook, HookStack, and the internal erased-hook interface no longer carry a completion-model type parameter. CompletionResponseEvent and StreamResponseFinish now expose canonical Rig content, usage, prompt, and message ID fields instead of typed provider responses. Direct CompletionModel completion and streaming APIs continue to return their typed raw provider responses.

    // Before
    impl<M: CompletionModel> AgentHook<M> for TelemetryHook { /* ... */ }
    
    // After
    impl AgentHook for TelemetryHook { /* ... */ }
  • (agent) [breaking] Make AgentRunner the only execution path for configured agents: remove the raw Completion and StreamingCompletion traits and their Agent implementations, make agent execution state private, add runner-backed per-request overrides, and route Extractor through the full hook lifecycle. Raw hook-free requests remain available explicitly through CompletionModel.

    • For managed agent execution, replace agent.completion(prompt, history).await?.send().await? with agent.runner(prompt).history(history).max_turns(3).run().await?, choosing a turn budget large enough for tool follow-ups.
    • For managed streaming execution, replace agent.stream_completion(prompt, history).await?.stream().await? with agent.runner(prompt).history(history).max_turns(3).stream().await.
    • The runner consumes tool calls rather than returning the first raw model response. Callers that handled that response manually, and other intentionally hook-free transport, should start from model.completion_request(prompt).messages(history) and then call .send().await? or .stream().await?.
    • AgentRun::new(prompt).with_history(history) remains a sans-I/O state machine for custom drivers; it contains no configured agent model, tools, memory, or hooks and is not an alternate configured-agent execution path.
    • An Agent's model is fixed and private. Former per-call .model(...) / .model_opt(...) users should retain the provider CompletionModel and use its raw request API, or construct a separate Agent for the selected model.
  • (tool) [breaking] Replace the parallel tool-execution APIs with one structured path. Typed tools now implement only Tool::call(&mut ToolContext, Args) -> Result<Output, Error>; author-facing errors remain typed until private runtime erasure normalizes them into ToolExecutionError, ToolContext carries inbound values and host-only result metadata, ToolResult is the single runtime observation, and ToolSet::execute / ToolServerHandle::execute are the dispatch surfaces. Event-specific hook action types make invalid event/action combinations unrepresentable.

    • Tool implementations: retain one typed type Error for ordinary ? propagation and direct-call tests; remove classify_error, call_with_extensions, and call_structured. The optional map_error method classifies domain failures at the erased boundary, while its default preserves the source as Other. Return refusals through map_error with ToolExecutionError::refused, and attach host-only result metadata with ToolContext::insert_result.
    • Context: replace ToolCallExtensions and ToolResultExtensions with ToolContext; replace request/runner .tool_extensions(...) with .tool_context(...). Each dispatch snapshots inbound context exactly once, isolates tool-local mutations, and publishes only result metadata back to the caller and hooks.
    • Dynamic tools: ToolDyn is removed from the public API; use DynamicTool for runtime-defined tools. Rig's erased dispatch trait is private. Typed tools use Tool::NAME as their sole identity; runtime-named agents convert explicitly with Agent::into_tool().
    • Registration vocabulary: AgentBuilder::tools(Vec<Box<dyn ToolDyn>>) is removed; use repeated .tool(...) calls for typed tools or dynamic_tools(Vec<DynamicTool>) for runtime-defined callbacks. Retrieval-backed dynamic_tools(sample, index, toolset) becomes retrieved_tools. On ToolSetBuilder, static_tool remains the typed-tool path, the former embedding-backed dynamic_tool(ToolEmbedding) becomes retrieved_tool, and runtime-defined callbacks use dynamic_tool(DynamicTool).
    • Results and errors: replace ToolError, ToolFailure, ToolFailureKind, ToolReturn, ToolReturnOutcome, ToolExecutionResult, and ToolOutcome with ToolExecutionError, ToolErrorKind, and the read-only ToolResult observed by hooks.
    • Model presentation: serializable outputs convert once into canonical ToolOutput content blocks; strings remain literal text, explicit serde_json::Value values remain JSON, and multimodal tools use ToolOutput::content / ToolOutput::one or return typed ToolResultContent directly. Result hooks now rewrite ToolOutput, provider adapters preserve native JSON where supported or render it only at their terminal wire boundary, mixed user/tool-result blocks retain order, and Rig never reparses strings to infer rich content. Consumers can inspect ToolResultContent with as_text / as_json and explicitly decode either structured JSON or legacy JSON-bearing text with deserialize_json.
    • Error presentation: explicit ToolExecutionError constructors keep actionable diagnostics model-visible, while the generic ToolExecutionError::from_error path preserves operator diagnostics and the concrete source but defaults to safe kind-level model feedback. Use with_model_feedback for deliberate replacement text or with_model_output for JSON/multimodal feedback. MCP responses preserve ordered supported text/image content, retain unsupported and future blocks as typed JSON, and attach raw CallToolResult, structuredContent, and response metadata to ToolContext. MCP list installation and refresh are atomic and ownership-aware, so stale handlers cannot replace or remove newer registrations, while disconnected owners are retired during refresh, provider exposure, or direct dispatch.
    • Dispatch: replace ToolSet::{call, call_with_extensions, call_structured} with ToolSet::execute; replace ToolServerHandle::{call_tool, call_tool_with_extensions, call_tool_structured} with ToolServerHandle::execute.
    • Registration and definitions: ToolSet is the single ordered registry and records whether each tool is always advertised or retrieval-only. ToolSet::{get_tool_definitions, documents} are now synchronous and infallible, ToolServerHandle registration/removal methods no longer return an artificial Result, and the obsolete ToolSetError is removed.
    • Hooks: replace AgentHook::on_event, StepEvent, and Flow with the event-specific AgentHook methods and their corresponding action types (CompletionCallAction, ToolCallAction, ToolResultAction, InvalidToolCallAction, and ObservationAction). Result rewrites replace the effective model and result-content telemetry presentation while preserving the raw ToolResult and ToolContext for policy; result stops omit result-content telemetry. Invalid-tool hooks return None to defer; every explicit action, including Fail, is terminal for that hook stack.
    • Streaming execution observation: the atomically surfaced post-batch event is named ToolExecutionCommitted, reflecting that it is not a real-time start notification. Applications that need live host lifecycle events should observe on_tool_call / on_tool_result; typed result metadata remains available through ToolResultEvent::tool_context without entering model-facing messages.
  • (core) [breaking] Mark PromptError, StructuredOutputError, and VectorStoreError as non-exhaustive, requiring downstream match expressions to include a wildcard arm. Conversation memory load failures now surface as the typed PromptError::MemoryError variant instead of CompletionError::RequestError.

0.40.0 - 2026-07-10

Added

  • (tool) [breaking] structured tool-execution results (#2015) (by @gold-silver-copper)
  • (agent) [breaking] hook system v2 — composable middleware (#2012) (by @gold-silver-copper)
  • (examples) human-in-the-loop tool-call approval — examples + tests (#1967) (by @gold-silver-copper)
  • (rig-core) steer the model request per turn from a hook via Flow::OverrideRequest (#1966) (by @gold-silver-copper)
  • (rig-core) rewrite tool results from a hook via Flow::RewriteResult (#1965) (by @gold-silver-copper)
  • (rig-core) rewrite tool-call arguments from a hook via Flow::RewriteArgs (#1963) (by @gold-silver-copper)
  • (openai) preserve responses prompt cache parameters (#1830) (by @Kade-Powell)
  • (streaming) [breaking] surface unmodeled provider output items through the stream (#1951) (by @gold-silver-copper)
  • (rig-core) [breaking] integrate hooks into AgentRun via a composable AgentRunner (#1945) (by @gold-silver-copper)
  • (message) add video helper constructors + OpenRouter audio/video conversion tests (#1942) (by @gold-silver-copper)
  • (agent) add OutputMode to compose structured output with tools (#1928) (#1929) (by @gold-silver-copper)

Fixed

  • (telemetry) keep GenAI message span fields empty (#2066) (by @gold-silver-copper)
  • (chatgpt) preserve non-success response errors (#2053) (by @gold-silver-copper)
  • (vertexai) preserve signed thought text parts (#2052) (by @gold-silver-copper)
  • (chatgpt) fallback on empty SSE output (#2001) (by @gold-silver-copper)
  • (openai) preserve reasoning text content (#1999) (by @gold-silver-copper)
  • preserve OpenAI Responses instructions (#1995) (by @gold-silver-copper) - #1995
  • (openai) accept null Responses metadata (#1993) (by @gold-silver-copper)
  • (postgres) update sqlx and pgvector (#1992) (by @gold-silver-copper)
  • (openai) make Responses API strict tools opt-in (#1991) (by @gold-silver-copper)
  • (agent) stream concurrent tool results as they complete (#1981) (by @gold-silver-copper)
  • (rig-core) fix epub loader tests + prevent CWD-relative fixture-path regressions (#1940) (by @gold-silver-copper)
  • (ollama) preserve assistant reasoning from non-streaming responses (#1926) (#1927) (by @gold-silver-copper)

Other

  • Remove unused derive and core APIs (#2087) (by @gold-silver-copper) - #2087
  • add Bedrock cassette coverage (#2084) (by @gold-silver-copper) - #2084
  • Remove unused stream completion stdout helper (#2085) (by @gold-silver-copper) - #2085
  • Remove unused generation wrapper traits (#2083) (by @gold-silver-copper) - #2083
  • Remove unused Anthropic decoders (#2082) (by @gold-silver-copper) - #2082
  • (agent) [breaking] unify PromptResponse and FinalResponse into one type (#2056) (by @gold-silver-copper)
  • (core) [breaking] API paper cuts — duplicate names, hand-copied setters, dead types (#2055) (by @gold-silver-copper)
  • (examples) add force_tool_first_turn hook example (#2014) (by @gold-silver-copper)
  • (auth) add non-interactive oauth cassette coverage (#2050) (by @gold-silver-copper)
  • (perplexity) add cassette coverage (#2049) (by @gold-silver-copper)
  • (providers) [breaking] remove galadriel provider (#2041) (by @gold-silver-copper)
  • (providers) [breaking] collapse remaining providers onto GenericCompletionModel (#2035 phases 2–4) (#2040) (by @gold-silver-copper)
  • (providers) [breaking] migrate llamafile onto GenericCompletionModel (#2035 phase 1) (#2038) (by @gold-silver-copper)
  • (core) [breaking] delete unused evals module and experimental feature flag (#2036) (by @gold-silver-copper)
  • Flatten Tool metadata API (#2029) (by @gold-silver-copper) - #2029
  • (gemini) live cassette hook-system stress suite (#2013) (by @gold-silver-copper)
  • Add Groq agent tool cassette regressions (#2011) (by @gold-silver-copper) - #2011
  • Add Mistral agent tool cassette regressions (#2010) (by @gold-silver-copper) - #2010
  • Add DeepSeek agent tool cassette regressions (#2009) (by @gold-silver-copper) - #2009
  • Add xAI agent tool cassette regressions (#2008) (by @gold-silver-copper) - #2008
  • Gate Gemini image cassette tests on image feature (#2007) (by @gold-silver-copper) - #2007
  • Add OpenRouter agent tool cassette regressions (#2006) (by @gold-silver-copper) - #2006
  • Add ChatGPT Codex cassette regression suite (#2005) (by @gold-silver-copper) - #2005
  • (gemini) production-grade generateContent cassette suite (#2004) (by @gold-silver-copper)
  • (anthropic) production-grade Messages API cassette suite (#2003) (by @gold-silver-copper)
  • (openai) production-grade Responses API cassette suite + tool_choice and replay-ID fixes (#2002) (by @gold-silver-copper)
  • (providers) add provider implementation checklist (#1997) (by @gold-silver-copper)
  • (deps) bump assert_fs from 1.1.3 to 1.1.4 (#1933) (by @dependabot[bot])
  • (deps) bump trybuild from 1.0.116 to 1.0.117 (#1935) (by @dependabot[bot])
  • (deps) bump chrono from 0.4.44 to 0.4.45 (#1934) (by @dependabot[bot])
  • (deps) bump uuid from 1.23.3 to 1.23.4 (#1975) (by @dependabot[bot])
  • (deps) bump scylla from 1.6.0 to 1.7.0 (#1932) (by @dependabot[bot])
  • (anthropic) add null citation streaming cassette (#1978) (by @gold-silver-copper)
  • update agent and contribution guidance (#1974) (by @gold-silver-copper) - #1974
  • (openai-compat) genuinely exercise the #1958 tool-call eviction string-leak (+ live cassette) (#1962) (by @gold-silver-copper)
  • (rig-core) [breaking] remove the experimental pipeline module (#1941) (by @gold-silver-copper)
  • run doctests and stop rig-sqlite opting out of them (#1939) (by @gold-silver-copper) - #1939
  • (rig-core) replace nanoid with fastrand for internal IDs (#1938) (by @gold-silver-copper)
  • (examples) migrate to a package-per-example layout (#1937) (by @gold-silver-copper)
  • add Archestra to "Who is using Rig?" section (#1925) (by @arsenyinfo) - #1925

Contributors

  • @gold-silver-copper
  • @dependabot[bot]
  • @Kade-Powell
  • @arsenyinfo

Changed

  • (agent) [breaking] max_turns and default_max_turns now bound the exact total number of model calls, including the initial call, tool continuations, and retries. A budget of 0 makes no model call, while 1 permits only the initial call. Unconfigured tool-then-answer flows now need an explicit total budget of 2. To preserve the former maximum allowance of an explicit old budget n, account for the old effective n + 2 calls; otherwise, set the intended literal total.

  • (tool) [breaking] flatten Tool / ToolDyn metadata: tool authors now implement description() and parameters() directly, and Tool::definition(prompt) / ToolDyn::definition(prompt) are removed. ToolDefinition remains a provider/request artifact generated from registered tools, with Tool::NAME / Tool::name() / ToolDyn::name() as the single source of truth for advertised and dispatched tool names.

  • (providers) [breaking] migrate llamafile onto the shared GenericCompletionModel<Ext> / GenericEmbeddingModel<Ext> path, deleting its hand-rolled completion model, request types, message flattening, and streaming profile. llamafile::CompletionModel / llamafile::EmbeddingModel are now type aliases for the generic models; the provider-specific StreamingCompletionResponse type is replaced by the shared OpenAI one. Requests now serialize messages in the shared OpenAI shape (single-text user content still flattens to a string; system/multi-part content is sent as a content-part array, which llama.cpp-family servers accept).

  • (openai) [breaking] new OpenAICompatibleProvider trait (mirroring AnthropicCompatibleProvider) is now required by GenericCompletionModel's Ext parameter; it carries the telemetry provider name (so minimax/zai/xiaomimimo spans stop reporting as "openai") and an EMITS_COMPLETE_SINGLE_CHUNK_TOOL_CALLS flag for llama.cpp-style streaming tool calls.

  • (providers) [breaking] migrate the remaining OpenAI-chat-compatible providers onto GenericCompletionModel<Ext> — groq, deepseek, mistral, together, moonshot (OpenAI side), perplexity, hyperbolic, mira, azure, and huggingface all lose their hand-rolled CompletionModel structs, request types, and TryFrom<message::Message> conversions; CompletionModel in each module is now a type alias for the generic model. Provider wire dialects live in OpenAICompatibleProvider hooks: an associated Response type, completion_path (Azure deployment URLs, /v1-prefixed routes), prepare_request (Groq native-tool folding, Moonshot required tool-choice coercion, HuggingFace Fireworks model ids, Perplexity/Mira tool stripping), finalize_request_body (DeepSeek string content + thinking-gated tool choice, Mistral "any" tool choice + prefix field + reasoning stripping, Mira raw-message flattening), and SUPPORTS_RESPONSE_FORMAT / STREAM_INCLUDE_USAGE consts. Provider-specific StreamingCompletionResponse types are replaced by the shared OpenAI one.

  • (openai) [breaking] ToolChoice gains a Function { name } variant serializing OpenAI's {"type":"function","function":{"name":...}} form, so message::ToolChoice::Specific with one function is now supported instead of erroring; CompletionRequest fields are now public; OpenAIRequestParams gains a supports_response_format field.

  • (openai) the shared TryFrom<message::ToolResult> conversion now prefers call_id over id for tool_call_id (matching provider-issued call ids); the shared streaming delta accepts reasoning as an alias for reasoning_content (Groq), and the deprecated function_call finish reason maps to tool-call handling.

  • (providers) behavior notes from the migration: max_tokens is now forwarded by deepseek, together, hyperbolic, and azure (previously silently dropped); together's streaming request uses standard stream/stream_options instead of stream_tokens, and a rig-level ToolChoice::Required now serializes as required instead of erroring; perplexity's non-streaming endpoint drops its stray /v1 prefix (matching its streaming path and the real API); mira's preamble is sent as a system message instead of user; response_format derived from output_schema is deferred while tools are pending a result (groq/mistral/azure previously applied it unconditionally); groq's streaming usage no longer falls back to the legacy x_groq.usage envelope.

  • (openrouter) [breaking] de-fork OpenRouter's parallel message model (issue #2035 phase 4): openrouter::{Message, UserContent, ImageUrl} are now re-exports of the shared OpenAI types, and the fork's FileContent/VideoUrlContent are replaced by shared FileData/VideoUrl. To support this, the shared OpenAI types gain OpenRouter's optional extensions — UserContent::Video, ImageUrl.detail becomes Option<ImageDetail> (OpenAI still sends "detail":"auto"), and Message::Assistant gains a skip-when-empty reasoning_details field, an inbound-only images field (never serialized back into requests), plus a deserialize-only role: "model" alias. ReasoningDetails/ResponseImage move into the openai module (re-exported from openrouter). OpenRouter-specific message conversion now goes through openrouter::messages_from_rig_message; TryInto<Vec<openrouter::Message>> resolves to the plain shared conversion. OpenRouter keeps its own request/response/streaming layer (provider preferences, cost accounting, reasoning-details grouping, generated-image extraction) as a documented exception.

  • (openai) the UserContent audio part now serializes its tag as input_audio (matching OpenAI's actual API); audio is still accepted when deserializing.

  • (openai) StreamingCompletionResponse is now generic over the provider's streaming usage payload (StreamingCompletionResponse<U = Usage>, selected via OpenAICompatibleProvider::StreamingUsage), so Mistral's cached-token fallbacks and DeepSeek's cache hit/miss counters survive streaming instead of being narrowed to OpenAI's usage shape.

  • (providers) pre-migration request filtering is preserved where provider support is unverified: hyperbolic still drops tools/tool_choice/output_schema with warnings, and perplexity flattens text-only message content back to plain strings (mixed multimodal content is passed through for sonar models). llamafile keeps the current mapping of output_schema to a json_schema response format (as on the shared path since the llamafile migration; modern llama.cpp servers support it).

  • (llamafile) the chat cassettes are now recorded against an actual llama.cpp llama-server, confirming the shared OpenAI wire shape (content-part arrays, tool calls, tool results) against the real llamafile-family server rather than an OpenAI-compatible proxy.

  • (openai) the assistant tool-call echo now serializes call_id (falling back to id) so it stays consistent with the tool-result side when history recorded via the Responses API is replayed through chat completions; streaming delta content tolerates content-part arrays (Mistral reasoning models) instead of dropping the chunk.

  • (providers) review fixes: mira and perplexity no longer send stream_options (their APIs never received it pre-migration); moonshot rejects a specific forced tool client-side again; openrouter serializes plain assistant reasoning under its documented reasoning key; azure telemetry spans report azure.openai again; mira usage math saturates instead of overflowing; perplexity strips tool-exchange remnants from shared histories.

  • (providers) second review round: openrouter tool-result messages prefer the provider-issued call_id (matching the assistant echo side); Azure's deployment URL stays pinned to the model the handle was created with (a per-request model override only changes the body, as pre-migration); shared streaming spans record gen_ai.system_instructions again; providers without tool support (perplexity, mira, and now hyperbolic) sanitize tool-exchange remnants from shared histories via one shared helper that also preserves strict role alternation (tool-call-only assistant turns are dropped and consecutive assistant turns merged); openrouter's dead pre-migration ToolChoice type is removed, and ToolChoice::Specific with multiple function names now errors client-side for openrouter (the old fork serialized a non-standard array).

  • (moonshot) [breaking] reasoning-only assistant history turns are no longer preserved: the shared conversion drops assistant messages with neither text nor tool calls. Reasoning attached to text or tool-call turns still round-trips via reasoning_content.

  • (providers) [breaking] responses with empty assistant content and no tool calls now surface the shared path's "empty response" error for hyperbolic, perplexity, and huggingface (previously they returned an empty text completion).

  • (providers) [breaking] additional removed public items: the raw response types of perplexity, hyperbolic, and huggingface (each module keeps a CompletionResponse alias to the shared OpenAI payload; Message/Choice/Usage/Delta/Role companions are gone), together::ToolChoice/ToolChoiceFunctionKind, moonshot::ToolChoice, groq::send_compatible_streaming_request and deepseek::send_compatible_streaming_request (use openai::send_compatible_streaming_request), and openrouter's UserContent builder helpers (image_url, file_base64, video_url, ...) — construct the shared openai content variants directly.

  • (openai) [breaking] sending rig Video user content to providers on the shared conversion now serializes a video_url content part (an OpenRouter/gateway extension) instead of returning a client-side conversion error; providers without video support will reject it server-side.

  • (providers) [breaking] telemetry: migrated providers' streaming spans are now named chat with gen_ai.operation.name = "chat" (previously chat_streaming). GenAI message-content span fields (gen_ai.input.messages / gen_ai.output.messages) are intentionally left empty instead of recording serialized request/response messages, preserving the privacy/cardinality behavior from #2065; the public SpanCombinator::record_model_output helper is removed. gen_ai.request.model reports the per-request model override when one applies.

  • (providers) third review round: history sanitization treats refusal parts as text when flattening and, for alternation-strict perplexity, merges consecutive same-role turns (dropping a tool exchange could previously leave user/user adjacency its API rejects); streaming no longer overwrites caller-supplied stream_options; openrouter's encrypted reasoning details now correlate with the wire tool-call id and its non-streaming usage uses the reported completion_tokens (no underflow); base64 videos with unrecognized MIME types round-trip as data-URI URLs instead of failing conversion.

  • (providers) fourth review round — agent structured output: GenericCompletionModel no longer claims native structured output composes with tools for every provider; it now follows SUPPORTS_RESPONSE_FORMAT. Agents with tools plus an output schema on deepseek/together/moonshot/huggingface/hyperbolic/perplexity/mira fall back to tool-mode schema enforcement as their pre-migration models did (the migration had silently dropped the schema entirely); groq/mistral/azure now compose natively like openai.

  • (openai) new OpenAICompatibleProvider::SUPPORTS_TOOLS const (default true): perplexity, hyperbolic, and mira set it false and tools/tool_choice are dropped with a warning during request conversion — before tool-choice validation, so a multi-name ToolChoice::Specific no longer errors client-side on providers that ignored it pre-migration.

  • (openai) streaming robustness: include_usage is inserted into caller-supplied stream_options instead of being skipped (or clobbering the caller's keys, the pre-migration behavior); a delta carrying both reasoning_content and reasoning no longer fails as a serde duplicate-field error that dropped the whole chunk; streaming tool-call index defaults to 0 when omitted (Mistral marks it optional); CompletionResponse.object/created are defaulted on deserialization for gateways that omit them (HuggingFace router sub-providers).

  • (openrouter) non-streaming usage falls back to total - prompt (saturating) when the gateway omits completion_tokens; streaming spans follow the shared telemetry behavior of leaving GenAI message-content fields empty.

  • (openai) [breaking] ToolChoice is now #[non_exhaustive]; GenericCompletionModel's strict_tools/tool_result_array_content fields are private (use the with_* builder methods) and the redundant with_model constructor is removed (use new).

Removed

  • (derive) [breaking] remove the unused public rig_derive::ProviderClient derive macro and its deluxe dependency; Embed and rig_tool are unchanged, and no replacement is provided.
  • (core) [breaking] remove unused Extractor::{get_inner, into_inner} and the always-failing TryFrom<String> for Nothing; no direct replacements are provided.
  • (core) [breaking] remove the unused public streaming::stream_completion_to_stdout helper; use the high-level agent::stream_to_stdout helper instead.
  • (core) [breaking] remove the unused public AudioGeneration<M>, ImageGeneration<M>, and Transcription<M> wrapper traits; use the corresponding AudioGenerationModel, ImageGenerationModel, and TranscriptionModel APIs and request builders directly.
  • (core) [breaking] remove the unused evals module (Eval trait, judge metrics, and builders) along with the experimental feature flag that gated it
  • (anthropic) [breaking] remove the unused public providers::anthropic::decoders module; Anthropic streaming uses the shared SSE machinery.
  • (providers) [breaking] remove the Galadriel provider integration (providers::galadriel), including its client, model constants, environment-variable support, and ignored live tests.

0.39.0 - 2026-06-19

Added

  • (providers) add VoyageAI rerank support (#1917) (by @sergiomeneses)
  • (agent) [breaking] sans-IO AgentRun state machine; both agent loops become thin drivers (#1899) (by @gold-silver-copper)

Fixed

  • correct possessive pronoun typo in CONTRIBUTING.md (#1865) (by @abhicris) - #1865
  • (tool) [breaking] deterministic, duplicate-safe tool registration + cassette tests (#1913) (by @gold-silver-copper)

Other

  • (deps) bump uuid from 1.23.1 to 1.23.3 (#1907) (by @dependabot[bot])
  • (deps) bump lopdf from 0.40.0 to 0.41.0 (#1877) (by @dependabot[bot])
  • (deps) bump http from 1.4.0 to 1.4.2 (#1909) (by @dependabot[bot])
  • (deps) bump futures-timer from 3.0.3 to 3.0.4 (#1908) (by @dependabot[bot])
  • (examples) add Gemini mid-stream disruption token-counting example (#1918) (by @gold-silver-copper)
  • (tool) back ToolSet with an IndexMap instead of HashMap + order Vec (#1916) (by @gold-silver-copper)
  • de-flake tracing span tests and deepseek permission_control race (#1915) (by @gold-silver-copper) - #1915
  • (agent) cassette-backed AgentRun coverage against real Gemini turns (#1901) (by @gold-silver-copper)
  • Fix streaming reasoning history order (#1898) (by @gold-silver-copper) - #1898
  • Fix context document ordering (#1893) (by @gold-silver-copper) - #1893
  • Point ecosystem link to awesome-rig (#1895) (by @gold-silver-copper) - #1895
  • Add Gemini Nano Banana image generation (#1889) (by @gold-silver-copper) - #1889

Contributors

  • @dependabot[bot]
  • @abhicris
  • @gold-silver-copper
  • @sergiomeneses

0.38.2 - 2026-06-09

Fixed

  • support Anthropic mid-conversation system role (#1862) (by @fangkangmi) - #1862

Other

  • (deps) bump tonic-prost-build from 0.14.5 to 0.14.6 (#1874) (by @dependabot[bot])
  • (deps) bump convert_case from 0.10.0 to 0.11.0 (#1875) (by @dependabot[bot])
  • (deps) bump reqwest from 0.13.3 to 0.13.4 (#1873) (by @dependabot[bot])
  • (deps) bump reqwest-middleware from 0.5.1 to 0.5.2 (#1876) (by @dependabot[bot])
  • Remove rig-redis integration (#1887) (by @gold-silver-copper) - #1887
  • migrate Copilot tests to cassette replay (#1882) (by @gold-silver-copper) - #1882
  • Redis vector store integration (#1509) (by @daric93) - #1509
  • add Ryzome to README nav links (#1879) (by @mateobelanger) - #1879
  • [codex] support mistral.rs OpenAI-compatible reasoning (#1864) (by @gold-silver-copper) - #1864
  • convert DeepSeek live tests to cassettes (#1870) (by @gold-silver-copper) - #1870
  • [codex] add OpenRouter cassette-backed provider coverage (#1869) (by @gold-silver-copper) - #1869
  • convert xAI live tests to cassettes (#1868) (by @gold-silver-copper) - #1868
  • [codex] cover Anthropic streaming tool result batching (#1863) (by @gold-silver-copper) - #1863

Contributors

  • @dependabot[bot]
  • @gold-silver-copper
  • @daric93
  • @mateobelanger
  • @fangkangmi

0.38.1 - 2026-06-02

Other

  • unify workspace crate versions (#1853) (by @gold-silver-copper) - #1853

Contributors

  • @gold-silver-copper

0.37.1 - 2026-06-02

Added

  • (rig-derive) replace hand-rolled schema with schemars in #[rig_tool] (#1576) (by @tomasz-feliksik)
  • (gemini) expose streaming response metadata (#1790) (by @mateobelanger)
  • (anthropic) support document citations (#1778) (by @temrjan)

Fixed

  • (chatgpt) Handle ChatGPT response.completed events without output field (#1825) (by @geraschenko)
  • (rig-gemini-grpc) populate FunctionDeclaration.parameters from ToolDefinition (#1763) (by @abhicris)
  • fix sqlite threshold and null tool call streaming (#1786) (by @gold-silver-copper) - #1786

Other

  • (deps) bump mongodb from 3.6.0 to 3.7.0 (#1848) (by @dependabot[bot])
  • (deps) bump zerocopy from 0.8.48 to 0.8.50 (#1847) (by @dependabot[bot])
  • (deps) bump google-cloud-aiplatform-v1 from 1.10.0 to 1.11.0 (#1846) (by @dependabot[bot])
  • (deps) bump serde_json from 1.0.149 to 1.0.150 (#1845) (by @dependabot[bot])
  • (deps) bump tonic from 0.14.5 to 0.14.6 (#1844) (by @dependabot[bot])
  • Fix parsing of streamed function-call argument deltas (#1828) (by @geraschenko) - #1828
  • (deps) port dependency bumps and Rust 1.91 (#1842) (by @gold-silver-copper)
  • (deps) bump quick-xml from 0.39.4 to 0.40.1 (#1818) (by @dependabot[bot])
  • (deps) bump google-cloud-auth from 1.9.0 to 1.10.0 (#1817) (by @dependabot[bot])
  • Stabilize MongoDB vector search test (#1841) (by @gold-silver-copper) - #1841
  • fix VT Code line grammar in README (#1824) (by @Shaurya-Sethi) - #1824
  • [codex] Validate model tool calls (#1823) (by @gold-silver-copper) - #1823
  • [codex] apply Anthropic cache control to tools (#1815) (by @gold-silver-copper) - #1815
  • (deps) bump tokio-tungstenite from 0.23.1 to 0.28.0 (#1784) (by @dependabot[bot])
  • (deps) bump rmcp from 1.6.0 to 1.7.0 (#1783) (by @dependabot[bot])
  • (deps) bump tokio from 1.52.1 to 1.52.3 (#1782) (by @dependabot[bot])
  • Expose per-completion-call usage in agent responses (#1787) (by @gold-silver-copper) - #1787
  • (gemini) add streaming metadata cassettes (#1777) (by @gold-silver-copper)
  • Add replayable provider cassette tests (#1769) (by @gold-silver-copper) - #1769

Contributors

  • @dependabot[bot]
  • @geraschenko
  • @tomasz-feliksik
  • @gold-silver-copper
  • @abhicris
  • @Shaurya-Sethi
  • @mateobelanger
  • @temrjan

0.37.0 - 2026-05-13

Added

  • (openrouter) add transcription (STT) and audio generation (TTS) support (#1757) (by @fversaci)
  • (rig-bedrock) add structured output support via Converse API (#1667) (by @jdwil)
  • (memory) Rig-managed conversation memory + rig-memory companion crate (#1702) (by @ForeverAngry)
  • add copilot model listing (#1700) (by @BigtoC) - #1700

Fixed

  • (gemini) Token usage correctness for posthog llm analytics (#1761) (by @mateobelanger)
  • (core) [breaking] make Chat append messages to caller history (#1733) (by @gold-silver-copper)

Other

  • Clean up root facade features and integration docs (#1764) (by @gold-silver-copper) - #1764
  • fix "a ancient" grammar in glarb-glarb sample text (#1755) (by @abhicris) - #1755
  • (deps) bump lopdf from 0.36.0 to 0.40.0 (#1754) (by @dependabot[bot])
  • (deps) bump quick-xml from 0.39.2 to 0.39.4 (#1752) (by @dependabot[bot])
  • (deps) bump tonic-build from 0.14.5 to 0.14.6 (#1751) (by @dependabot[bot])
  • Move reusable test doubles into rig_core::test_utils (#1745) (by @gold-silver-copper) - #1745
  • workspace and docs cleanup (#1742) (by @gold-silver-copper) - #1742
  • openrouter vars (#1741) (by @gold-silver-copper) - #1741
  • Add provider file ID support for document inputs (#1740) (by @gold-silver-copper) - #1740
  • add smoke test for completion across all Copilot models (#1730) (by @BigtoC) - #1730
  • bump dependencies (#1728) (by @gold-silver-copper) - #1728
  • remove needless files (#1715) (by @gold-silver-copper) - #1715
  • AGENTS.MD, CONTRIBUTING.MD, and docs (#1714) (by @gold-silver-copper) - #1714
  • Add Bedrock integration tests (#1707) (by @gold-silver-copper) - #1707

Contributors

  • @gold-silver-copper
  • @fversaci
  • @mateobelanger
  • @abhicris
  • @jdwil
  • @dependabot[bot]
  • @ForeverAngry
  • @BigtoC