Skip to content

fix(mistral): three more provider bugs found by live cassette recording - #2357

Open
gold-silver-copper wants to merge 9 commits into
mainfrom
fix/mistral-reasoning-content
Open

fix(mistral): three more provider bugs found by live cassette recording#2357
gold-silver-copper wants to merge 9 commits into
mainfrom
fix/mistral-reasoning-content

Conversation

@gold-silver-copper

Copy link
Copy Markdown
Contributor

Round 4 of the Mistral cassette hunt. #2331 and #2337 landed twelve bugs across three sweeps, the third of which came up dry. This one went after the ground those sweeps left unrecorded — the #[ignore]d live tests with no fixture directory — and found three more, one of them a whole capability's worth of data loss.

# bug severity
13 Reasoning (magistral-class) turns lose their thinking trace on both transports, and cannot replay it high
14 codestral-embed reports ndims() == 0 while returning 1536-wide vectors medium
15 A failed transcription drops the response headers, so Retry-After is unreachable medium

42 recorded cassettes + 27 unit cells. 26 of the 42 fail on origin/main and
pass here
— the other 16 are controls (a turn with no thinking chunk, a
traceless history, the fixed-width embedding model) and cells that characterise
Mistral's own behaviour rather than rig's, and they pass on both sides by
design.


Bug 13 — a reasoning turn's trace is dropped, and cannot be replayed

Mistral's reasoning models do not answer with a string. They answer with the
chunk array their reasoning docs describe: a thinking chunk carrying the
trace, then the answer's text chunk. Live, mistral-small-latest with
reasoning_effort: "high":

"content": [
  {"type": "thinking",
   "thinking": [{"type": "text", "text": "The user is asking for the product of 6 and 7. …"}],
   "closed": true},
  {"type": "text", "text": "42"}
]

Rig read that array with a text-only join, on both transports:

// providers/mistral/completion.rs:78 on origin/main
serde_json::Value::Array(parts) => openai::completion::joined_text_parts(&parts),

// providers/openai/completion/mod.rs:1836 — what that keeps
(part.get("type").and_then(as_str) == Some("text")).then(|| part.get("text"))

Every part that is not tagged text is discarded, so the trace never reached
AssistantContent::Reasoning — a slot rig has and populates for anthropic,
cohere, deepseek and openrouter. Mistral had no code path that could
construct one.

Three consequences, each worse than the last:

  1. The trace is lost. A caller asking for reasoning gets the answer only.
  2. A truncated turn is lost entirely. When the max_tokens cap is spent
    inside the trace, the thinking chunk is all the response carries — so
    after the text-only join the turn had no content at all, and the Length
    finish reason was the only thing left.
  3. The turn cannot be replayed. Mistral's reasoning docs require the whole
    assistant message, ThinkChunk included, be sent back into the next request.
    Rig did the opposite: the shared OpenAI-compatible conversion renders a
    Reasoning block as a reasoning_content string, and
    MistralExt::finalize_request_body deleted the field
    (message.remove("reasoning_content"), client.rs:139) — so rig could not
    round-trip a block it had itself produced.

Consequence 3 is the one whose severity I could not pin down, and I want to
be precise about it. Two hand-run live pairs had the traceless replay re-issue
the call it already made instead of answering from the tool result — the
incoherence Mistral's docs warn about:

# with the trace replayed  → finish_reason "stop"
"content":[{"type":"thinking",…},{"type":"text","text":"The sum of 2 and 3 is **5**."}]

# with the trace stripped, i.e. what rig sent  → finish_reason "tool_calls"
"tool_calls":[{"function":{"name":"add","arguments":"{\"a\": 2, \"b\": 3}"}}]

But a third run through the same request shape answered correctly, so it is not
reproducible, and nothing in this PR asserts it. The cell that used to has
been rewritten as a request-bytes control, and its doc says why. What stands on
its own without that anecdote: the trace is data the API returned and rig
dropped, a truncated turn came back with nothing at all, Mistral's docs require
the replay, and rig could not round-trip a reasoning block it had itself
produced.

The streaming half is the same defect on the same helper: thinking arrives as
delta.content arrays, which deserialize_delta_content joined for text and
therefore dropped.

data: {"choices":[{"delta":{"content":[{"type":"thinking","thinking":[{"type":"text","text":"6"}]}]}}]}
data: {"choices":[{"delta":{"content":[{"type":"thinking","thinking":[{"type":"text","text":" times"}]}]}}]}
…
data: {"choices":[{"delta":{"content":"4"}}]}
data: {"choices":[{"delta":{"content":"2"},"finish_reason":"stop"}],"usage":{…}}

Worth stating because it was the worse possibility: the trace is not
spliced into the answer. It is dropped, not corrupted — the answer's deltas
arrive as plain strings on their own.

The fix

Reading it (Mistral-local). Message::Assistant's content becomes
AssistantMessageContent { text, reasoning }, which decodes both halves of the
array and writes both back. It Derefs and Displays to the answer text, so
every read of the field is unchanged; only a struct literal needs .into().
normalize emits the trace as a leading AssistantContent::Reasoning, the
order Mistral sends it in and the order the streamed twin emits.

Reading it (shared). The streamed delta.content array is now split into
text and thinking by DeltaContent in the OpenAI-compatible streaming layer,
and the thinking half joins the existing reasoning slot behind
reasoning_content and reasoning. Blast radius: every OpenAI-compatible
provider rides that deserializer. The change is additive — a thinking part
previously decoded to neither text nor reasoning — and no other in-tree
provider sends one: the only cassettes carrying "type":"thinking" are
anthropic's (30) and cohere's (2), and neither rides this wire. No fixture
outside Mistral needed re-recording.

Writing it back (Mistral-local). splice_reasoning_into_content moves an
assistant message's reasoning_content into its content as Mistral's
thinking chunk, ahead of the text, before the content is normalized — so the
trace is rendered by the same chunk renderer as every other part, and
into_mistral_chunk gains a thinking arm so finalizing an already-finalized
body stays a no-op.

One deliberate behaviour change, stated plainly. Mistral answers a
reasoning input sent to a model without the capability with
400 Reasoning input is not enabled for this model (recorded). Rig used to
strip the trace, so that request silently succeeded. It now fails loudly, with
the provider's own message. That is the trade: a loud, actionable rejection on
the models that cannot take a trace, in exchange for coherence on the ones that
can. MIGRATING.md says so and names the workaround.

Matrix — tests/cassettes/mistral/reasoning_content/ + the shared suites

26 cells in reasoning_content/, plus 4 in the cross-provider reasoning
contract (reasoning_roundtrip/, reasoning_tool_roundtrip/) that Mistral had
no entry in before, because it produced no reasoning to test. Dimensions
crossed: transport (blocking × streaming) × surface (raw model × agent) ×
content shape (trace + answer, trace alone, trace beside a tool call, trace
beside a json_schema format) × model id (mistral-small-latest,
magistral-small-latest, a model without the capability) × direction (what
Mistral sends, what rig sends back).

# cell transport surface shape on origin/main
1 blocking_keeps_the_reasoning_trace blocking CompletionModel trace + answer fails
2 blocking_without_reasoning_effort_has_no_trace blocking CompletionModel control: plain string passes both
3 blocking_reasoning_effort_none_is_a_plain_string blocking CompletionModel control: effort: none passes both
4 blocking_truncated_thinking_still_yields_the_trace blocking CompletionModel trace only (cap spent thinking) fails
5 blocking_reasoning_beside_a_tool_call blocking CompletionModel trace + tool call fails
6 blocking_reasoning_on_the_magistral_alias blocking CompletionModel second model id fails
7 blocking_agent_chat_keeps_the_trace_in_history blocking Agent::chat trace reaches history fails
8 blocking_reasoning_with_structured_output blocking CompletionModel trace + json_schema fails
9 streaming_yields_the_reasoning_trace streaming CompletionModel trace + answer fails
10 streaming_without_reasoning_effort_yields_no_trace streaming CompletionModel control passes both
11 streaming_reasoning_beside_a_tool_call streaming CompletionModel trace + tool call fails
12 streaming_truncated_thinking_yields_the_trace streaming CompletionModel trace only fails
13 streaming_reasoning_on_the_magistral_alias streaming CompletionModel second model id fails
14 streaming_agent_stream_keeps_the_trace streaming Agent::stream_prompt trace in the agent stream fails
15 streaming_reasoning_with_structured_output streaming CompletionModel trace + json_schema fails
16 streaming_terminal_aggregates_the_trace streaming CompletionModel aggregated terminal fails
17 roundtrip_replays_the_thinking_chunk blocking CompletionModel request bytes fails (mock miss)
18 roundtrip_replays_the_trace_beside_a_tool_call blocking CompletionModel request bytes fails (mock miss)
19 roundtrip_without_a_trace_sends_a_plain_string blocking CompletionModel control: request bytes passes both
20 roundtrip_lets_the_model_answer_from_the_tool_result blocking CompletionModel replayed trace, converging turn fails
21 roundtrip_a_traceless_tool_history_sends_no_thinking_chunk blocking CompletionModel control: request bytes, tool history passes both
22 roundtrip_replays_a_trace_captured_from_a_stream streaming → blocking CompletionModel cross-transport replay fails
23 reasoning_history_on_a_model_without_the_capability_is_rejected blocking CompletionModel 400 + request id fails (main never sends it)
24 an_unsupported_reasoning_effort_keeps_the_id_and_body blocking CompletionModel 400 + request id passes both
25 bogus_key_reasoning_request_keeps_the_id_and_body blocking CompletionModel 401 + request id passes both
26 parity_blocking_and_streaming_both_carry_a_trace both CompletionModel parity fails
27 reasoning_roundtrip::{streaming,nonstreaming} both shared contract two-turn replay both fail
28 reasoning_tool_roundtrip::{streaming,nonstreaming} both shared contract agent loop + history both fail

Every cell checks its own premise against its own recorded bytes: a cell about
the thinking chunk fails if the recorded turn stopped carrying one, and the
controls fail if one appears. Those reads are skipped while recording,
because the fixture is written after the test body returns — and a guard
rejects a body too short to be one, which is how the SSE block-scalar parsing
bug the review found was caught.

19 unit cells next to the provider cover the schema-legal shapes no live
turn has been observed to send — a bare-string thinking payload, several
traces in one turn, a payload-less chunk, the {"thinking": []} closing frame,
the request-side splice against string/array/absent/null content, idempotence —
plus 4 in the shared streaming layer for the delta split and the
three-spelling reasoning preference.

Dropped with reason. A cell asserting what a traceless replay makes the
model do: it did not reproduce (see above), so there is no such cell.


Bug 14 — codestral-embed declares zero dimensions

#2337 gave mistral-embed a declared width and deliberately left Codestral's
at None, on the grounds that its width is configurable — pinned by a unit
test asserting default_ndims(CODESTRAL_EMBED) == None. But configurable is
not unknown: a request naming no dimension returns 1536-wide vectors.

$ POST /v1/embeddings {"model":"codestral-embed","input":["dimension probe"]}
HTTP 200  width 1536

GenericEmbeddingModel::make does ndims.or_else(|| Ext::default_ndims(&model)).unwrap_or_default(),
so client.embedding_model(CODESTRAL_EMBED).ndims() was 0 — not a width a
vector store can size itself from, which is the reason #2337 gave for fixing
mistral-embed.

Fix: both Codestral ids declare 1536. An explicit width still governs and
still rides Mistral's own output_dimension spelling — and a model asked for
its own default sends no width at all, so a request that named no dimension
puts exactly the bytes on the wire it always did. That guard already existed for
the fixed-width models; it now covers Codestral too, and the recorded cell
asserts it against the request body.

Matrix — tests/cassettes/mistral/embedding_dimensions/

The input space is small and fully enumerable — {model} × {width the caller
asks for} — so it is written out in full rather than sampled. Mistral serves two
embedding models under two ids each, and embedding_dimensions sorts a
requested width into exactly three classes: none, the model's own default, and
some other value.

# cell model requested width status
1 codestral_embed_declares_its_real_width codestral-embed none recorded — fails on main (declares 0)
2 codestral_embed_honors_an_explicit_output_dimension codestral-embed 512 recorded
3 codestral_embed_dated_alias_declares_the_same_width codestral-embed-2505 none recorded — fails on main
4 codestral_embed_batches_at_the_declared_width codestral-embed none, 3 inputs recorded — fails on main
5 mistral_embed_still_declares_its_own_width mistral-embed none (control) recorded
6 codestral_embed_declares_its_default_width both codestral ids unit
7 codestral_embed_sends_its_width_as_output_dimension codestral-embed 1536 / 512 / 3073 unit — the default echoes back as no field; over the ceiling is rejected before any request is built
8 mistral_embed_declares_its_width_without_requesting_it mistral-embed 1024 / 512 unit — the fixed-width model's dimensions rejection

Cells 1 and 5 also assert, against the recorded request, that a call naming no
width carries neither width field.

Dropped with reason. A batch over Mistral's 256-input cap: already recorded
in capability_edges.rs, and about chunking rather than width.


Bug 15 — a failed transcription drops its response headers

Mistral was the only provider whose transcription hand-rolled the
send/status/decode tail instead of using the shared driver, and the tail it
built ended with:

// providers/mistral/transcription.rs:150 on origin/main
Err(TranscriptionError::from_http_response(status, String::from_utf8_lossy(&response_bytes)))

No .with_response_headers(...). Every other transcription implementation goes
through send_transcription / send_json_transcription, both of which take
the response apart with into_parts and attach the headers, guarded by a
dedicated header_preservation_tests module. rig#2210 states the contract that
a failed capability call preserves its response headers so a caller can read
Retry-After; Mistral's transcription did not.

Fix: route through send_transcription, like every other multipart
transcription. The usage log moves onto the response's TryFrom, so it still
fires.

Scope, stated precisely: the bundled reqwest client raises a non-2xx as
InvalidStatusCodeWithDetails, which already carries its headers, so this is
unreachable on that transport. It reproduces on any custom HttpClientExt that
hands the non-success response back — the transport shape the shared drivers'
own rig#2210 tests use.

Matrix — tests/cassettes/mistral/transcription/

Migrated from the #[ignore]d live smoke test this file used to hold — the last
Mistral capability with no fixture directory at all.

The bug's own input space is {transport shape} × {outcome}, and only one column
can lose headers: a transport that hands a non-success response back. That is
exactly the column a recording cannot reach, because the cassette proxy is
driven through reqwest, which raises a non-2xx as an error that already carries
its headers. So the regression cell is a unit test with a recording transport,
and the recorded cells cover the capability's live surface around it.

# cell model shape status
1 voxtral_mini_transcribes_the_audio_fixture voxtral-mini success + audio-seconds usage recorded
2 voxtral_small_is_not_a_transcription_model voxtral-small 400 Invalid model recorded
3 timestamp_granularities_populate_segments voxtral-mini success, segments non-empty recorded
4 an_array_of_timestamp_granularities_is_rejected voxtral-mini 422 recorded
5 a_language_hint_is_accepted voxtral-mini success, language form field recorded
6 an_unknown_model_is_rejected_with_its_body invalid 4xx + body recorded
7 bogus_key_transcription_keeps_status_and_body voxtral-mini 401 + body recorded
8 transcription_non_success_preserves_response_headers non-success, response handed back unit — the regression cell; the only shape that can lose headers
9 transcription_non_success_preserves_status_and_body same shape's status/body half unit
10 transcription_form_carries_the_documented_fields request form unit — a multipart request records with no body, so a fixture cannot pin the outbound form

Two findings fell out of recording this matrix, both pinned rather than fixed:

  • VOXTRAL_SMALL is not a transcription model. It is declared beside
    VOXTRAL_MINI in transcription.rs, so it reads as the larger transcription
    model; the live catalog reports audio_transcription: false for it (and
    audio: true, completion_chat: true — it is the audio-chat model the
    multimodal matrix already drives), and the endpoint answers
    400 Invalid model: voxtral-small-latest. The constant's doc now says so, and
    cell 2 backs it with the wire.
  • timestamp_granularities must be a bare string, not an array. The shared
    multipart builder JSON-encodes a non-string additional_params value, so
    ["segment"] goes out as a single field whose value is ["segment"] and
    Mistral answers 422 naming the enum it wanted. Not fixed: rig has one
    multipart encoding for every provider on the shared driver, the string form
    works, and the error names the field. Cell 4 pins it.

Census — what the hunt looked at and did not report

  • Blocking n > 1 selects choices[0] positionally while streaming selects
    by index == 0 (fix(mistral): eight more provider bugs found by live cassette recording #2337). Recorded a blocking n: 2 turn: Mistral returned
    the candidates in index order, so the two transports agree. Not a bug on
    the evidence available; a hardening note at most.
  • EMITS_COMPLETE_SINGLE_CHUNK_TOOL_CALLS is honoured: the flag reaches
    should_emit_completed_tool_call_immediately through the profile, and a live
    ministral-3b stream does emit a whole tool call in one chunk
    ({"name":"add","arguments":"{\"a\": 2, \"b\": 3}"}).
  • Model listing carries capabilities, deprecation and
    default_model_temperature, none of which Model has a slot for, and no
    output-token field at all — so nothing is dropped. max_output_tokens stays
    None because Mistral reports none.
  • Transcription usage reports prompt_tokens_details.audio_tokens outside
    prompt_tokens, the same accounting fix(mistral): eight more provider bugs found by live cassette recording #2337 folded into the completion path.
    TranscriptionUsage types prompt_audio_seconds as Option<i32> while the
    completion Usage types it Option<u64>; every observed value is a whole
    number of seconds and I could not force a fractional one, so this is listed
    as an unconfirmed candidate rather than a bug.
  • reasoning_effort vocabulary: mistral-small-latest accepts none and
    high only — low is a 400 naming the two it takes. Recorded as an error
    cell rather than reported as a rig defect.

Independent review

A cold review of the branch caught seven defects, all fixed here. Two were the
kind this process exists to find:

  • P1 — a cell asserted an invariant the code cannot hold. The shared
    reasoning-contract streaming cell asserted stats.reasoning_block_count > 0,
    but the chat-completions wire never signs or closes a reasoning part, so
    MintedReasoningLifecycle emits ReasoningDeltas and a bare end that
    StreamingCompletionResponse suppresses — no complete Reasoning block is
    ever yielded. Every other chat-completions provider's equivalent cell guards
    the same claim; only the Responses-API suites assert a block. Fixed to
    reasoning_delta_count > 0 || reasoning_block_count > 0, which is what the
    branch's own reasoning_content.rs cell already used.
  • P1 — a premise assertion that could not read what it claimed to check.
    The fixture reader took body: line-prefixed scalars, but an SSE body is
    a YAML block scalar (|+) whose payload is the indented lines that
    follow. So every streaming premise check would have panicked on the header
    "|+", and the streaming control would have passed vacuously — the exact
    "passes while covering nothing" failure the premise checks exist to prevent.
    The reader now folds block scalars, and a assert_recorded_bodies_are_readable
    guard fails rather than letting a too-short body count as evidence.

Five more, each fixed:

  • Declaring Codestral's default width made every dimension-less request start
    carrying "output_dimension": 1536. The fixed-width models already had a
    guard for exactly that echo; it now covers Codestral too, so the default
    request's bytes are unchanged, and cell 1 asserts it against the recorded
    request.
  • The reasoning-preference test asserted that two fields decode, not that the
    preference holds — reordering the preference would have left it green. The
    preference is now its own function and the test drives it.
  • The characterisation cell named after the model re-issuing its tool call did
    not assert that it does. It does now.
  • into_mistral_chunk would render {"thinking": []} — the streaming wire's
    closing frame — as a chunk with an empty text part, contradicting the rule
    the request-side splice enforces. Both paths now refuse an empty trace.
  • MIGRATING.md said reads are unchanged without naming the .clone() case,
    and the CHANGELOG's 400 claim was too broad: an assistant turn carrying
    only reasoning is dropped by the shared message conversion before it can be
    rejected, so a truncated-thinking turn still replays as nothing. Both are
    stated correctly now.

Verification

  • cargo fmt --all --check — clean
  • RUSTFLAGS="-D warnings" cargo clippy -p rig-core -p rig -p rig-agent --all-targets --all-features — clean
  • cargo check -p rig-core --target wasm32-unknown-unknown — clean
  • cargo doc --workspace --no-deps — one warning (unresolved link to AudioGenerationModel, in providers/venice/mod.rs), pre-existing and untouched by this branch
  • cargo test -p rig-core --all-features --lib — passes
  • Key-free replay of the whole Mistral suite (--test-threads=1, no MISTRAL_API_KEY): 129 passed, 0 failed, 19 ignored, against 87/20 on origin/main
  • On-origin/main repro (provider sources reset to origin/main, same fixtures, same test code): 26 failed, 103 passed. Every failure is a cell this PR is about; the binary was verified by marker to not contain the fix before the run, so the repro cannot be a stale-build artefact.

Representative on-origin/main output:

---- mistral::reasoning_content::blocking_keeps_the_reasoning_trace ----
the trace Mistral sent ahead of the answer must reach the caller, and lead:
  [Text(Text { text: "42", additional_params: None })]

---- mistral::reasoning_content::blocking_truncated_thinking_still_yields_the_trace ----
a turn that spent its whole budget thinking still produced the trace,
but the response carried no content at all

---- mistral::embedding_dimensions::codestral_embed_declares_its_real_width ----
assertion `left != right` failed: a model that declares 0 dimensions cannot size a vector store

and, for the replay direction, the mock miss that is the diff:

---- mistral::reasoning_content::roundtrip_replays_the_thinking_chunk ----
actual_body_preview:   {"messages":[…,{"content":"42","prefix":false,"role":"assistant"},…]}
expected_body_preview: {"messages":[…,{"content":[{"closed":true,"thinking":[{"text":"The user is asking
                       for the product of 6 and 7. …","type":"text"}],"type":"thinking"},
                       {"text":"42","type":"text"}],"prefix":false,"role":"assistant"},…]}

Fixtures were audited for leaked account/org identifiers: the only ids present
are the harness's chatcmpl-REDACTED_n / req_REDACTED_n / call_REDACTED_n
placeholders.

Roughly 90 live requests were made across the hunt, all on
mistral-small-latest / ministral-3b-latest / codestral-embed /
voxtral-mini-latest with small caps; magistral-small-latest is the same
model as mistral-small-latest in the live catalog, so the reasoning cells cost
no more than the ordinary ones.

Mistral's reasoning (magistral-class) models do not answer with a string.
They answer with the chunk array their reasoning docs describe: a `thinking`
chunk carrying the trace, then the answer's `text` chunk. Rig read that array
with a text-only join, on both transports, so every part not tagged `text` was
discarded and no Mistral turn could ever produce an
`AssistantContent::Reasoning` — a slot rig has and populates for anthropic,
cohere, deepseek and openrouter.

Three consequences. The trace was lost. A turn whose `max_tokens` cap was
spent inside the trace was lost *entirely*, because the thinking chunk is then
all the response carries. And the turn could not be replayed: Mistral's docs
require the whole assistant message, ThinkChunk included, be sent back into the
next request, while `finalize_request_body` deleted the shared conversion's
`reasoning_content` outright — so rig could not round-trip a reasoning block it
had itself produced.

Reading it: `Message::Assistant`'s `content` becomes
`AssistantMessageContent { text, reasoning }`, which decodes both halves and
writes both back; it derefs and displays to the answer text, so reads are
unchanged. `normalize` emits the trace as a leading reasoning block, the order
Mistral sends it in.

Reading it while streaming: the shared `delta.content` deserializer splits the
array into text and thinking, and the thinking half joins the existing
reasoning slot behind `reasoning_content` and `reasoning`. Additive for every
other OpenAI-compatible provider: a `thinking` part previously decoded to
neither, and no other in-tree provider sends one.

Writing it back: `splice_reasoning_into_content` moves an assistant message's
`reasoning_content` into its content as Mistral's thinking chunk, ahead of the
text and before the content is normalized, so the same chunk renderer handles
it and finalizing an already-finalized body stays a no-op.

One deliberate behaviour change: Mistral answers a reasoning input sent to a
model without the capability with `400 Reasoning input is not enabled for this
model`. Rig used to strip the trace, so that request silently succeeded; it
now fails with the provider's own message.
#2337 gave `mistral-embed` a declared width and deliberately left Codestral's
at `None`, on the grounds that its width is configurable — pinned by a unit
test asserting exactly that. But configurable is not unknown: a request naming
no dimension returns 1536-wide vectors, verified live. The shared constructor
falls back to `default_ndims` and then `unwrap_or_default`, so
`client.embedding_model(CODESTRAL_EMBED).ndims()` was 0 — not a width a vector
store can size itself from, which is the reason #2337 gave for fixing
mistral-embed.

Both Codestral ids now declare 1536. An explicit width still governs and still
rides Mistral's own `output_dimension` spelling; the echoed default is a value
Mistral documents and accepts.
Mistral was the only provider whose transcription hand-rolled the
send/status/decode tail instead of using the shared driver, and the tail it
built ended in a bare `from_http_response(status, body)` with no
`with_response_headers`. rig#2210 states the contract that a failed capability
call preserves its response headers so a caller can read `Retry-After`; every
other transcription implementation honours it through `send_transcription` /
`send_json_transcription`, both of which take the response apart with
`into_parts`, guarded by a dedicated header-preservation test module.

Routed through `send_transcription` like every other multipart transcription.
The usage log moves onto the response's `TryFrom`, so it still fires.

Scope: the bundled reqwest client raises a non-2xx as
`InvalidStatusCodeWithDetails`, which already carries its headers, so this is
unreachable on that transport. It reproduces on any custom `HttpClientExt`
that hands the non-success response back — the transport shape the shared
drivers' own rig#2210 tests use.
Migrates the `#[ignore]`d live smoke test — the last Mistral capability with
no fixture directory at all — into recorded cells, and extends it into the
matrix for the header-preservation fix: both Voxtral models, a populated
`segments` array behind `timestamp_granularities`, a language hint, an unknown
model's 4xx and a bogus key's 401.

The regression cell itself is a unit test rather than a cassette, and the
matrix says why: only a transport that hands a non-success *response* back can
lose the headers, and the cassette proxy is driven through reqwest, which
takes the other branch. The outbound multipart form is likewise unit-pinned —
a multipart request is exported with no body at all, so a fixture cannot
express it.
The declared width against the vectors Mistral actually returns, for both
Codestral ids and both request classes (no dimension, an explicit one), plus a
batch cell proving every row of a batch is the declared width and a
mistral-embed control proving the fixed-width model is untouched.

The input space is small and fully enumerable — {model} × {width the caller
asks for} — so the matrix is written out in full in the file's header, with the
cells that need no traffic (a width rejected before the wire) named as the unit
tests they are.
Twenty-six recorded cells over the reasoning surface: both transports, raw
model and agent, trace-plus-answer and trace-alone and trace-beside-a-tool-call,
structured output, the magistral-branded model id, the replay of the trace into
the following request read off the recorded request bytes, and the three error
shapes (a reasoning history on a model without the capability, an unsupported
effort value, a bogus key). Three of them are controls that record a turn with
*no* thinking chunk, so the rest are provably about the chunk rather than about
array content in general.

Every cell checks its own premise against its own recorded bytes — a cell whose
provider turn stopped carrying a thinking chunk fails rather than passing while
covering nothing. The premise reads are skipped while recording, because the
fixture is written after the test body returns.

Mistral also gains an entry in the cross-provider reasoning contract
(`tests/common/reasoning.rs`) that every other reasoning-capable provider in
the tree already runs and Mistral had none of: `reasoning_roundtrip` and
`reasoning_tool_roundtrip`, streaming and non-streaming, including the agent
loop whose chat-history assertion requires a reasoning block.
… fixes

The reasoning fix changes a public provider wire type and one silent behaviour,
so it gets a `MIGRATING.md` section for each: the assistant `content` field's
new type (with the deref that keeps every read compiling), and the 400 Mistral
now answers a reasoning history sent to a model without the capability.
`cargo doc --all-features` runs with `RUSTDOCFLAGS=-D warnings` in CI, and
`rustdoc::private_intra_doc_links` fires on the `Serialize` impl's reference to
`splice_reasoning_into_content`. The name is still there, as code rather than a
link, with its visibility stated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant