Skip to content

fix: normalize non-compliant tool call IDs for strict OpenAI-compatible providers (e.g. Mistral)#22318

Open
tombii wants to merge 2 commits intoBerriAI:mainfrom
tombii:fix/normalize-tool-call-ids-for-openai-compatible-providers
Open

fix: normalize non-compliant tool call IDs for strict OpenAI-compatible providers (e.g. Mistral)#22318
tombii wants to merge 2 commits intoBerriAI:mainfrom
tombii:fix/normalize-tool-call-ids-for-openai-compatible-providers

Conversation

@tombii
Copy link

@tombii tombii commented Feb 27, 2026

Summary

Fixes #22317

Some OpenAI-compatible providers (e.g. MiniMax via OpenRouter) return tool call IDs in non-standard formats like call_function_jlv0n7uyomle_1 — using the function name and a numeric suffix. When the router forwards a subsequent turn of that conversation to a strict provider (e.g. Mistral), the request is rejected:

litellm.BadRequestError: OpenAIException - Tool call id was call_function_jlv0n7uyomle_1
but must be a-z, A-Z, 0-9, with a length of 9.

Because this is a 400 BadRequestError, the router does not retry or fall back — the error surfaces directly to the caller.

Fix

Adds _normalize_tool_call_ids() to OpenAIGPTConfig, called from transform_request(). It:

  1. Scans the message list once for assistant messages with non-compliant tool_calls[].id values (anything not matching ^[a-zA-Z0-9]{1,64}$)
  2. Builds a stable deterministic mapping (MD5 hash, truncated to 9 chars) for each offending ID
  3. Rewrites both the assistant message tool_calls[].id and the matching tool result tool_call_id in a second pass — keeping the pair in sync
  4. No-ops when all IDs are already compliant (zero overhead for well-behaved providers)

This is analogous to the existing _sanitize_anthropic_tool_use_id() in factory.py, but generalised for the OpenAI-to-OpenAI routing case where both sides of the ID pair must match.

Test plan

  • test_compliant_ids_unchanged — valid IDs are not modified
  • test_non_compliant_id_is_remapped — bad ID is replaced with a valid 9-char alphanumeric ID
  • test_remapping_is_deterministic — same input always produces same output
  • test_multiple_tool_calls_each_remapped_consistently — distinct IDs get distinct mappings; both assistant and tool messages updated
  • test_messages_without_tool_calls_unaffected — plain messages pass through unchanged
  • test_transform_request_normalizes_ids — end-to-end through transform_request()

🤖 Generated with Claude Code

@vercel
Copy link

vercel bot commented Feb 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Feb 27, 2026 10:22pm

Request Review

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 27, 2026

Greptile Summary

Adds tool call ID normalization in OpenAIGPTConfig to handle non-compliant IDs (e.g. call_function_jlv0n7uyomle_1) from providers like MiniMax/OpenRouter that would cause 400 errors when forwarded to strict providers like Mistral. The fix scans message history for IDs not matching ^[a-zA-Z0-9]{1,64}$, builds a deterministic MD5-based remapping, and rewrites both assistant tool_calls[].id and tool tool_call_id fields to keep them in sync.

  • _normalize_tool_call_ids() added to OpenAIGPTConfig, called from both transform_request and async_transform_request
  • No-ops when all IDs are already compliant (zero overhead for well-behaved providers)
  • Analogous to the existing _sanitize_anthropic_tool_use_id() in factory.py, but handles both sides of the ID pair for OpenAI-to-OpenAI routing
  • All provider-specific logic stays within litellm/llms/ directory
  • 6 mock-only unit tests added with good coverage of edge cases

Confidence Score: 4/5

  • This PR is safe to merge — it adds a defensive normalization step with no impact on already-compliant requests.
  • The implementation is clean, idempotent, and well-tested with 6 unit tests. The MD5-based remapping is deterministic and produces valid IDs. The only concern is a minor style issue (constant placement between imports) which was already addressed in a prior review thread. No functional issues found.
  • No files require special attention.

Important Files Changed

Filename Overview
litellm/llms/openai/chat/gpt_transformation.py Adds _normalize_tool_call_ids() method and calls it in both transform_request and async_transform_request. Implementation is clean with a two-pass approach (collect then rewrite). Minor style issue with constant placement between imports (already flagged in prior thread).
tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py Adds 6 well-structured unit tests covering compliant IDs, non-compliant remapping, determinism, multiple tool calls, plain messages, and end-to-end transform_request. All tests are mock-only with no network calls.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[transform_request / async_transform_request] --> B[_normalize_tool_call_ids]
    B --> C{Scan assistant messages\nfor non-compliant\ntool_call IDs}
    C -->|All IDs compliant| D[Return messages unchanged]
    C -->|Non-compliant IDs found| E[Build MD5-based\nid_mapping dict]
    E --> F[Rewrite assistant\ntool_calls.id]
    F --> G[Rewrite tool message\ntool_call_id]
    G --> H[Return normalized messages]
Loading

Last reviewed commit: f694823

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

IDs that already satisfy ``^[a-zA-Z0-9]{1,64}$`` are left unchanged so
there is zero overhead for well-behaved providers.
"""
_VALID_TOOL_CALL_ID = re.compile(r"^[a-zA-Z0-9]{1,64}$")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regex recompiled on every call

_VALID_TOOL_CALL_ID is compiled via re.compile() on every invocation of _normalize_tool_call_ids. Since this method is called on every request, consider hoisting the compiled pattern to a module-level or class-level constant. This also aligns with the project convention of avoiding per-call overhead in the request path (CLAUDE.md: "Avoid imports within methods").

Suggested change
_VALID_TOOL_CALL_ID = re.compile(r"^[a-zA-Z0-9]{1,64}$")
_VALID_TOOL_CALL_ID = re.compile(r"^[a-zA-Z0-9]{1,64}$")

Could be moved to the top of the file as:

_VALID_TOOL_CALL_ID_RE = re.compile(r"^[a-zA-Z0-9]{1,64}$")

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 27, 2026

Additional Comments (1)

litellm/llms/openai/chat/gpt_transformation.py
Missing normalization in async path

async_transform_request does not call _normalize_tool_call_ids when self.__class__._is_base_class is True (lines 472-477). In that branch, it returns the response directly without normalization, so async requests through the base OpenAIGPTConfig will still forward non-compliant tool call IDs.

The non-base-class branch (line 480) delegates to self.transform_request() which does include normalization, so subclasses are covered. But the base class async path is not.

Consider adding transformed_messages = self._normalize_tool_call_ids(transformed_messages) after line 462, mirroring what transform_request does:

    async def async_transform_request(
        self,
        model: str,
        messages: List[AllMessageValues],
        optional_params: dict,
        litellm_params: dict,
        headers: dict,
    ) -> dict:
        transformed_messages = await self._transform_messages(
            messages=messages, model=model, is_async=True
        )
        transformed_messages = self._normalize_tool_call_ids(transformed_messages)
        transformed_messages, tools = (
            self.remove_cache_control_flag_from_messages_and_tools(
                model=model,
                messages=transformed_messages,
                tools=optional_params.get("tools", []),
            )
        )
        if tools is not None and len(tools) > 0:
            optional_params["tools"] = tools
        if self.__class__._is_base_class:
            return {
                "model": model,
                "messages": transformed_messages,
                **optional_params,
            }
        else:
            ## allow for any object specific behaviour to be handled
            return self.transform_request(
                model, messages, optional_params, litellm_params, headers
            )

tombii added a commit to tombii/litellm that referenced this pull request Feb 27, 2026
…egex to module level

Addresses review feedback on BerriAI#22318:
- `async_transform_request` base-class path was missing the
  `_normalize_tool_call_ids()` call, leaving async callers exposed to
  the same 400 BadRequestError from strict providers (e.g. Mistral)
- Hoist `_VALID_TOOL_CALL_ID_RE` to module level to avoid recompiling
  the regex on every request

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tombii and others added 2 commits February 27, 2026 23:12
…penAI-compatible providers

Some providers (e.g. MiniMax via OpenRouter) return tool call IDs in
non-standard formats like `call_function_jlv0n7uyomle_1` which contain
underscores and exceed OpenAI's strict alphanumeric-only requirement.
When those IDs are forwarded in a subsequent request to a strict provider
(e.g. Mistral), the request is rejected with a 400 BadRequestError that
does not trigger fallback logic.

Adds `_normalize_tool_call_ids()` to `OpenAIGPTConfig`, called from
`transform_request()`. It scans the message list once, detects any
tool call IDs that don't match `^[a-zA-Z0-9]{1,64}$`, and rewrites both
the assistant `tool_calls[].id` and the matching tool result
`tool_call_id` fields with a stable deterministic MD5-based 9-char ID,
keeping both sides of the pair in sync.

Fixes BerriAI#22317

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…egex to module level

Addresses review feedback on BerriAI#22318:
- `async_transform_request` base-class path was missing the
  `_normalize_tool_call_ids()` call, leaving async callers exposed to
  the same 400 BadRequestError from strict providers (e.g. Mistral)
- Hoist `_VALID_TOOL_CALL_ID_RE` to module level to avoid recompiling
  the regex on every request

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@tombii tombii force-pushed the fix/normalize-tool-call-ids-for-openai-compatible-providers branch 2 times, most recently from 55318b2 to f694823 Compare February 27, 2026 22:15
@krrishdholakia
Copy link
Member

@greptile please re-review this

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.

fix: Non-compliant tool call IDs from providers like MiniMax cause BadRequestError when routing to strict OpenAI-compatible providers (e.g. Mistral)

2 participants