feat(mistral): support native web search via the Conversations API - #32059
Open
ElDjeee wants to merge 14 commits into
Open
feat(mistral): support native web search via the Conversations API#32059ElDjeee wants to merge 14 commits into
ElDjeee wants to merge 14 commits into
Conversation
ElDjeee
force-pushed
the
litellm_mistral_web_search
branch
from
July 3, 2026 13:41
96ddfd6 to
2813e0e
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
ElDjeee
force-pushed
the
litellm_mistral_web_search
branch
from
July 3, 2026 13:43
2813e0e to
d157149
Compare
Contributor
Greptile SummaryThe PR adds native Mistral web search through the Conversations API while preserving LiteLLM’s chat-completion interface.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| litellm/llms/mistral/conversations/transformation.py | Implements Conversations request and response translation, including tool history, citations, usage, costs, and truncation inference; the previously reported issues are addressed. |
| litellm/llms/mistral/chat/transformation.py | Adds web-search option support and maps seed directly to random_seed so each endpoint can place it correctly. |
| litellm/main.py | Selects the Conversations configuration only for Mistral web-search requests while retaining normal chat routing otherwise. |
| litellm/llms/openai/chat/guardrail_translation/handler.py | Includes built-in web-search forms in existing key and team tool-allowlist checks. |
| litellm/llms/custom_httpx/llm_http_handler.py | Adds provider-declared reserved request keys so extra_body cannot replace sanitized Conversations fields. |
| model_prices_and_context_window.json | Declares web-search capability on individual Mistral chat model entries rather than globally. |
| tests/llm_translation/test_mistral_api.py | Exercises Mistral Conversations behavior through the established translation-test record/replay infrastructure. |
Reviews (9): Last reviewed commit: "refactor(mistral): satisfy the Final, im..." | Re-trigger Greptile
Contributor
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 5 · PR risk: 0/10 |
ElDjeee
force-pushed
the
litellm_mistral_web_search
branch
3 times, most recently
from
July 3, 2026 20:35
7d1f879 to
2fc37bf
Compare
ElDjeee
force-pushed
the
litellm_mistral_web_search
branch
from
July 3, 2026 20:52
2fc37bf to
cf18736
Compare
ElDjeee
force-pushed
the
litellm_mistral_web_search
branch
from
July 3, 2026 21:24
cf18736 to
8fc90bf
Compare
ElDjeee
force-pushed
the
litellm_mistral_web_search
branch
from
July 3, 2026 22:14
8fc90bf to
fd0feb5
Compare
Author
ElDjeee
force-pushed
the
litellm_mistral_web_search
branch
from
July 6, 2026 13:00
fd0feb5 to
2230a8b
Compare
Author
1 similar comment
Author
ElDjeee
force-pushed
the
litellm_mistral_web_search
branch
from
July 6, 2026 16:01
4c7cf92 to
a142a94
Compare
ElDjeee
marked this pull request as draft
July 7, 2026 09:06
ElDjeee
force-pushed
the
litellm_mistral_web_search
branch
from
July 7, 2026 09:18
a142a94 to
dd50a71
Compare
ElDjeee
marked this pull request as ready for review
July 7, 2026 09:18
ElDjeee
force-pushed
the
litellm_mistral_web_search
branch
from
July 7, 2026 15:36
95e6188 to
537e3e9
Compare
ElDjeee
force-pushed
the
litellm_mistral_web_search
branch
from
July 7, 2026 16:53
e0a5ba5 to
f120783
Compare
Author
ElDjeee
force-pushed
the
litellm_mistral_web_search
branch
from
July 30, 2026 09:52
f120783 to
c60bd2c
Compare
ElDjeee
force-pushed
the
litellm_mistral_web_search
branch
from
July 30, 2026 10:31
72a4fbb to
cb55349
Compare
Author
Contributor
…el_info get_provider_model_info was a flat if/elif chain already at the repo's C901 complexity ceiling of 15, so adding any new provider tips it over the cap and forces an unrelated refactor inside the feature PR that adds it. Collapse the plain cases that just construct and return a litellm.<X>Config() into a single dict lookup, leaving the lazy-import providers as explicit branches. Behavior is identical; the only effect is headroom under the complexity cap
Mistral's built-in web_search and web_search_premium connectors are only
available on the Conversations API (POST /v1/conversations), not on
/v1/chat/completions, so a completion call that asks for web search is routed to
that endpoint transparently. The public API stays as simple as
litellm.completion(
model="mistral/mistral-medium-latest",
messages=[{"role": "user", "content": "Who won the last Euro?"}],
tools=[{"type": "web_search"}], # or web_search_options={}
)
A request is detected as web search when it carries web_search_options or a tools
entry of type web_search / web_search_premium. _complete_mistral swaps the
resolved MistralConfig for a sibling MistralConversationsConfig for those
requests, the way _complete_bedrock picks invoke vs converse. The swapped config
points get_complete_url at /conversations, builds the Conversations request body
(system messages to instructions, remaining turns to inputs, sampler args to
completion_args, the connector tool to tools) and maps the outputs back to an
OpenAI-shaped ModelResponse, surfacing tool_reference chunks as url_citation
annotations. Streaming is served through the base handler's fake-stream path, and
function calling on chat completions is untouched; only web-search requests are
rerouted
MistralModelInfo advertises supports_web_search, and a small cost calculator
bills Mistral's published rates ($30 per 1,000 web_search calls, $50 per 1,000
web_search_premium calls) off the authoritative usage.connectors count, added on
top of token cost through the shared get_cost_for_web_search_request hook
MistralModelInfo.get_provider_info hardcoded supports_web_search=True for every mistral model, so embeddings and OCR models (mistral-embed, mistral-ocr-*) wrongly reported web search support, and the capability was invisible to anything reading model_prices_and_context_window.json. Move the flag onto the individual mistral chat entries in the cost map (and its backup), the way xai, gemini and anthropic declare it, and drop the provider-level override so non-chat models resolve to False through the normal cost-map lookup
The key/team allowed_tools allowlist only extracted function tool names, so a
caller restricted by it could still reach a provider's built-in web search via
tools=[{"type":"web_search"}] or web_search_options and bill the per-call search,
bypassing the allowlist. extract_request_tool_names now surfaces web_search /
web_search_premium tool types and the web_search_options shorthand as tool names,
so check_tools_allowlist denies them with 403 tool_access_denied unless
allowed_tools lists them
… fields
The shared HTTP handler merges extra_body into the request after
transform_request, so a caller allowlisted only for standard web_search could
send web_search_options plus extra_body={"tools":[{"type":"web_search_premium"}]}
and replace the sanitized tools list on its way to /v1/conversations, reaching
the premium connector past the tool allowlist. Provider configs can now declare
reserved_request_body_keys that extra_body may not override (empty by default,
so the escape hatch is unchanged for every other provider), and the Mistral
Conversations config reserves tools, store, inputs and model; other extra_body
keys such as completion_args still pass through
The Conversations transform built inputs from only role and content, so an assistant message's tool_calls and a tool-role result were silently dropped when a web-search turn landed mid function-calling conversation; the model then lost the prior call context with no error. Each assistant tool_call now becomes a function.call input entry (tool_call_id/name/arguments) and each tool message a function.result entry (tool_call_id/result), matching Mistral's Conversations inputs schema, the way other provider transforms preserve tool history instead of flattening it
…responses The Conversations API returns no finish/stop reason field (verified against the live API, including a forced max_tokens truncation), so transform_response hardcoded finish_reason="stop"; a client checking finish_reason == "length" to detect truncation got a wrong signal when the model stopped on max_tokens. With nothing on the wire to forward, infer it from the token budget: return "length" when completion_tokens reached the requested max_tokens, otherwise "stop"
Every test in tests/llm_translation/ is auto-decorated with pytest.mark.vcr and replays from the Redis cassette cache without provider credentials; the MISTRAL_API_KEY skip guards opted these three tests out of that replay, so keyless CI runs got no regression signal from them. Removing the guards matches the rest of the folder (BaseLLMChatTest and recent provider tests carry no key guards) and turns the silent skip into either a cassette replay or a loud failure Also drop the manual LITELLM_LOCAL_MODEL_COST_MAP/model_cost reload and debug toggle (the folder conftest already pins the local cost map and reloads litellm per test) and assert supports_web_search directly so removing the capability flag from the pricing JSON fails the test instead of silently skipping it
…mpletion_args map_openai_params stuffed seed into optional_params["extra_body"], but the HTTP handler pops extra_body before transform_request runs and merges it at the top level of the final body afterwards. The chat body happened to accept top-level random_seed so the bug was invisible there, but on the Conversations route the transform never saw the seed and the merge placed random_seed at the top level of the body instead of inside completion_args. The mapping now writes optional_params["random_seed"] directly, the way watsonx and codestral map their seed equivalents, which also stops the mapping from overwriting a caller-supplied extra_body; the chat wire body is unchanged and the Conversations transform picks random_seed up as a plain completion arg The old unit test called transform_request with extra_body still present in optional_params, a state that cannot occur in the real pipeline, so it passed against the broken code. It is replaced by a respx test that drives litellm.completion(seed=...) through the full pipeline and asserts the seed lands in completion_args and never at the top level; the test fails against the previous code. Also covers the chat path (seed maps to random_seed, stays top-level in the chat body) and drops the unused MistralConversationInputMessage TypedDict
…cost dispatch Codecov flagged 16 uncovered lines across the PR. Each gap was a real untested behavior rather than dead code, so this covers them with meaningful assertions instead of coverage filler: OpenAI content-part message lists must flatten to their text with non-text and malformed parts dropped, an assistant turn with content=None plus tool_calls (the OpenAI SDK shape) must map to a function.call entry without emitting an empty message entry, a message.output with null content must transform gracefully, get_models must raise cleanly without credentials and on non-2xx responses, the shared get_cost_for_web_search_request dispatcher must route provider mistral to the new cost calculator, and non-dict tool entries in a request body must not crash allowlist extraction or shadow real tools that need checking. The two mistral files codecov called out are now at 100% line coverage locally
…earch usage field for the ratcheted lint gates
…gates added on the base branch The base branch now gates every new local without a Final declaration (LIT010), every mutable collection build (LIT002), tests that write litellm module globals (TQ005) or assert nothing (TQ001), and froze the completion dispatch context fields, so the rebased branch tripped all four. Annotate the new Mistral code with Final, build the Conversations request from ReadOnly TypedDict entries and tuples instead of list/dict literals, keep the JSON-bound dicts behind a mutable-ok with the wire-format reason, bind the swapped Conversations config to a fresh name instead of rebinding the Final context field, and inject an httpx-backed AsyncHTTPHandler in the async routing test instead of flipping litellm.disable_aiohttp_transport
ElDjeee
force-pushed
the
litellm_mistral_web_search
branch
from
September 3, 2026 13:11
b3795b4 to
1f1a30e
Compare
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR
Problem this solves:
supports_web_searchwas hardcoded true for every Mistral model, embeddings includedallowed_toolsallowlist ignored built-in web search toolsHow it solves it:
POST /v1/conversationstransparentlyurl_citationannotationssupports_web_searchmoves onto the Mistral chat entries in the cost mapallowed_toolsnow coversweb_search/web_search_premium/web_search_optionsUser Flow
Before: a developer asking a Mistral model to search the web gets a provider error, because the chat completions endpoint has no web search
"model": "mistral-medium-latest"and"tools": [{"type": "web_search"}]web_search_optionsAfter: the same request comes back with a web-grounded answer and its sources
"tools": [{"type": "web_search"}](or"web_search_options": {})choices[0].message.contentanswering from the live web andchoices[0].message.annotationslistingurl_citationentries with the source titles and URLsusage.prompt_tokens_details.web_search_requestscounts the searches, and thex-litellm-response-costheader includes the per-search charge"stream": truethe same answer and citations arrive as SSE chunksallowed_toolsdoes not listweb_searchgets a 403tool_access_deniedon that request instead of a billed searchRelevant issues
Web search requested by a customer
Linear ticket
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Both runs hit the real Mistral API through a live proxy started with
python litellm/proxy/proxy_cli.py --config qa_config.yaml --port 4000(the Before run from a worktree at the merge base, the After run from this branch's tip).qa_config.yaml:The restricted key used by the third case was minted once with:
Before (658f506)
Web search via tools
{"error":{"message":"litellm.BadRequestError: MistralException - {\"object\":\"error\",\"message\":\"WebSearchTool connector is not supported\",\"type\":\"invalid_tools\",\"param\":null,\"code\":\"1800\",\"raw_status_code\":400}. Received Model Group=mistral-medium-latest\nAvailable Model Group Fallbacks=None","type":null,"param":null,"code":"400"}}Streaming web search via web_search_options
{"error":{"message":"litellm.UnsupportedParamsError: mistral does not support parameters: ['web_search_options'], for model=mistral-medium-latest. To drop these, set `litellm.drop_params=True` or for proxy:\n\n`litellm_settings:\n drop_params: true`\n. \n If you want to use these params dynamically send allowed_openai_params=['web_search_options'] in your request.. Received Model Group=mistral-medium-latest\nAvailable Model Group Fallbacks=None","type":"None","param":null,"code":"400"}}Key restricted by allowed_tools
["get_weather"]){"error":{"message":"litellm.BadRequestError: MistralException - {\"object\":\"error\",\"message\":\"WebSearchTool connector is not supported\",\"type\":\"invalid_tools\",\"param\":null,\"code\":\"1800\",\"raw_status_code\":400}. Received Model Group=mistral-medium-latest\nAvailable Model Group Fallbacks=None","type":null,"param":null,"code":"400"}}After (f54a4c5)
Web search via tools
/v1/conversations, citations come back asurl_citationannotations andusage.prompt_tokens_details.web_search_requestscounts the search{"id":"conv_01a06779650a769fac194f5a2eb59c08","created":1788442403,"model":"mistral-medium-latest","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"The most recent UEFA European Championship was won by **Spain in 2024**.","role":"assistant","annotations":[{"type":"url_citation","url_citation":{"title":"List of UEFA European Championship finals - Wikipedia","url":"https://en.wikipedia.org/wiki/List_of_UEFA_European_Championship_finals"}},{"type":"url_citation","url_citation":{"title":"UEFA European Football Championship Winners Year by Year","url":"https://www.aworldofsoccer.com/tournaments_nt/european_cup_winners.htm"}}]}}],"usage":{"completion_tokens":53,"prompt_tokens":779,"total_tokens":6344,"prompt_tokens_details":{"web_search_requests":1}}}web_searchcalls) is billed on top of the token costStreaming web search via web_search_options
Key restricted by allowed_tools
{"error":{"message":"Tool(s) ['web_search'] are not in the allowed tools list for this key/team.","type":"tool_access_denied","param":"tools","code":"403"}}Type
New Feature
Caveats (if any)
Medium
finish_reasonis inferred from the token budget because the Conversations API returns noneLow
store: false; no conversation persistence is exposedChanges
This PR is a small preparatory refactor, the feature, and a set of follow-up fixes from review
refactor(utils): collapse plain provider dispatch in get_provider_model_infoget_provider_model_infowas a flatif/elifchain already sitting at the repo's C901 complexity ceiling of 15, so adding any new provider tips it over the cap. The plain cases that just return alitellm.<X>Config()are collapsed into a read-only mapping lookup, leaving the lazy-import providers as explicit branches. Behavior is identical; this only buys headroom so the feature commit can register mistral without a drive-by refactor bundled into itfeat(mistral): support native web search via the Conversations APIMistral's built-in web search only exists on the Conversations API, so a completion that asks for web search is routed there transparently while the public API stays as simple as
A request is detected as web search when it carries
web_search_optionsor atoolsentry of typeweb_search/web_search_premium._complete_mistralswaps the resolvedMistralConfigfor a siblingMistralConversationsConfigfor those requests, the way_complete_bedrockpicks invoke vs converse. The swapped config pointsget_complete_urlat/conversations, builds the Conversations request body (system messages to instructions, remaining turns to inputs, sampler args to completion_args, the connector tool to tools) and maps the outputs back to an OpenAI-shapedModelResponse, surfacingtool_referencechunks asurl_citationannotations. Streaming is served through the base handler's fake-stream path, and function calling on chat completions is untouched; only web-search requests are reroutedA small cost calculator bills Mistral's published rates ($30 per 1,000
web_searchcalls, $50 per 1,000web_search_premiumcalls) off the authoritativeusage.connectorscount, added on top of token cost through the sharedget_cost_for_web_search_requesthookTests: unit coverage for the Conversations request/response transformation (citation mapping, usage counting from both the
connectorscount and the tool-execution fallback, edge cases), the endpoint-selection routing for both trigger forms across sync and async plus the negative case that a normal call still hits/chat/completions, the fake-stream path, and the cost calculator. Three live tests intests/llm_translation/test_mistral_api.pyexercise the real wire schema; like every test in that folder they run under the Redis-backed VCR cassette cache (see the folder's Readme), recording against the live API on cache-miss and replaying keyless after that, so they give CI a deterministic signal without needingMISTRAL_API_KEYon every runfix(mistral): source supports_web_search from the model cost mapsupports_web_searchwas hardcodedTruefor every mistral model, so embeddings and OCR models wrongly reported support and the flag was invisible in the cost map. It now lives on the individual mistral chat entries inmodel_prices_and_context_window.json(and its backup), the way xai, gemini and anthropic declare it, somistral-embed/mistral-ocr-*correctly resolve toFalsefix(proxy): enforce allowed_tools on built-in web search toolsextract_request_tool_namesonly surfacedfunctiontool names, so a key restricted withallowed_toolscould still trigger built-in web search. It now also surfaces theweb_search/web_search_premiumtool types and theweb_search_optionsshorthand (mapped toweb_search, orweb_search_premiumwhenpremium: true), so the existing allowlist check covers them. An invariant test asserts the tool names sent on the wire never exceed the names the allowlist checkedfix(mistral): stop extra_body from overriding sanitized Conversations fieldsandfix(mistral): reserve instructions and completion_args from the extra_body mergeextra_bodywas merged into the request body after the transformation ran, so a caller could clobber the allowlist-checkedtools, the pinnedstore: false, or the builtinputs/model. Configs can now declarereserved_request_body_keys(default empty) and the HTTP handler drops those keys fromextra_body; the Conversations config reserves the sanitized fields:tools,store,inputs,model, plusinstructions(built from guardrail-inspected system messages) andcompletion_args(carries proxy-enforced limits likemax_tokens), so extra_body can neither inject an unscanned system prompt nor replace an enforced token capfix(mistral): map tool-call history into Conversations inputsAssistant
tool_callsandtoolresults were flattened into role+content message entries, silently dropping the call binding. They now map to the Conversationsfunction.call/function.resultinput entry types, preserving id, name and arguments; verified against the live APIfix(mistral): infer finish_reason=length for truncated Conversations responsesThe Conversations API returns no finish or stop reason field (verified live), so
finish_reasonwas hardcoded tostopeven when the response was truncated. It is now inferred from the token budget:lengthwhencompletion_tokensfills the requestedmax_tokens, otherwisestop. A live test forces a real truncation withmax_tokens: 1and assertslengthcomes backtest(mistral): drop key-skip guards so web search tests replay under VCRThe three live tests skipped when
MISTRAL_API_KEYwas unset, which opted them out of the folder's VCR replay and gave keyless CI runs no signal from them. The guards are gone, matchingBaseLLMChatTestand the rest of the folder, so a keyless run replays the cassette instead of silently skippingfix(mistral): map seed directly into optional_params so it reaches completion_argsmap_openai_paramsstuffedseedintooptional_params["extra_body"], but the HTTP handler popsextra_bodybeforetransform_requestruns and merges it at the top level of the final body afterwards. The chat body happened to accept top-levelrandom_seedso the bug was invisible there, but on the Conversations route the transform never saw the seed and the merge placedrandom_seedat the top level of the body instead of insidecompletion_args. The mapping now writesoptional_params["random_seed"]directly, the way watsonx and codestral map their seed equivalents; this also stops the mapping from overwriting a caller-suppliedextra_body. The chat wire body is unchanged and the Conversations transform picksrandom_seedup as a plain completion arg. Pipeline-faithful respx tests drivelitellm.completion(seed=...)through the full pipeline for both routes and assert where the seed landstest(mistral): cover content-part flattening, get_models errors, and cost dispatchCodecov flagged uncovered lines across the PR; each was a real untested behavior, so they are covered with meaningful assertions: content-part message lists flatten to their text with non-text and malformed parts dropped, an assistant turn with
content: nullplustool_callsmaps to afunction.callentry without an empty message entry, amessage.outputwith null content transforms gracefully,get_modelsraises cleanly without credentials and on non-2xx responses, the sharedget_cost_for_web_search_requestdispatcher routes providermistralto the new cost calculator, and non-dict tool entries in a request body neither crash allowlist extraction nor shadow real toolsrefactor(mistral): immutable annotations and a declared premium web search usage field for the ratcheted lint gatesandrefactor(mistral): satisfy the Final, immutability, and test-quality gates added on the base branchThe base branch ratcheted its lint gates while this PR was open: every local must carry a
Finaldeclaration, mutable list/dict builds are flagged, TypedDict fields must beReadOnly, and tests may neither writelitellmmodule globals nor assert nothing. The Conversations request is now built fromReadOnlyTypedDict entries and tuples,web_search_premium_requestsis a declared field onPromptTokensDetailsWrapperinstead of an ad-hoc attribute, the swapped Conversations config binds to a fresh name instead of rebinding the frozen dispatch context field, and the async routing test injects an httpx-backedAsyncHTTPHandlerinstead of flippinglitellm.disable_aiohttp_transporttest(mistral): cover an untitled web search source in the citation mappingThe rebase split the citation builder into a titled and an untitled branch, and only the titled one had a test. A
tool_referencewith a URL but no title now has its own case asserting it is still cited and does not carry a nulltitlekey, which brings patch coverage back to 100%