fix(anthropic, mcp): sanitize tool names to match Anthropic's [a-zA-Z0-9_-]{1,128} pattern (#26788)

* fix(anthropic, mcp): sanitize tool names to match Anthropic's `^[a-zA-Z0-9_-]{1,128}$`

Tool names with characters like `/` or `.` (commonly produced by the
OpenAPI -> MCP generator from `operationId`s such as
`actions/download-job-logs-for-workflow-run`) caused Anthropic to reject
requests with `tools.N.custom.name: String should match pattern
'^[a-zA-Z0-9_-]{1,128}$'`.

Two layers of fix:

1. Anthropic transformation: build a per-request forward map (original ->
   sanitized, disambiguated by suffix on collisions) and a reverse map
   (only for names actually rewritten). Forward map is applied to tool
   defs, `tool_choice`, and historical assistant tool_calls in messages.
   Reverse map is threaded through both the non-streaming and streaming
   response paths so callers continue to see their original tool names
   in `tool_use` blocks.

2. OpenAPI -> MCP generator: sanitize `operationId` (and the
   method+path fallback) at registration time so generated MCP tools are
   valid for any strict-name provider, not just Anthropic. The dashboard
   preview endpoint applies the same sanitization for parity.

Includes unit tests covering: collision disambiguation between
`foo_bar` and `foo/bar` in the same request, reverse-map only firing
for actually-rewritten names, message rewrite for historical tool_calls,
streaming chunk_parser reverse-mapping, and sanitization of OpenAPI
operationIds plus the preview endpoint output.

Made-with: Cursor

* fix(anthropic): build tool-name maps in transform_request, not optional_params

The previous patch stashed the per-request forward and reverse tool-name
maps under ``optional_params["_anthropic_tool_name_forward_map"]`` and
``optional_params["_anthropic_tool_name_map"]``. ``optional_params`` is
the dict that becomes the JSON body via ``data = {**optional_params}``,
so those internal keys leaked over the wire and Anthropic 400'd with:

  _anthropic_tool_name_forward_map: Extra inputs are not permitted

Worse, this meant *every* request whose tool list contained any name with
an invalid character (the exact case the patch was meant to fix) regressed
into a confusing meta-error pointing at LiteLLM's internal map instead of
the offending tool.

Fix: move all tool-name sanitization into ``transform_request``, which is
the single chokepoint already shared by ``AnthropicConfig``,
``AmazonAnthropicConfig`` (Bedrock invoke), ``VertexAIAnthropicConfig``,
and ``AzureAnthropicConfig`` (all call ``super().transform_request`` /
``AnthropicConfig.transform_request(self, ...)``). New static helper
``_sanitize_tool_names_in_request`` walks the already-Anthropic-shaped
``optional_params["tools"]`` (only ``type=="custom"`` entries -- hosted
tool names are reserved by Anthropic and must not be touched), builds
the per-request forward/reverse maps, and applies the forward map in
place to ``tools[*].name`` and ``tool_choice.name``. The reverse map is
stashed exclusively on ``litellm_params`` (which is never serialized to
a provider) under ``_anthropic_tool_name_map`` for the response paths
to consume.

Side effect of this restructure: ``map_openai_params`` is now a pure
OpenAI->Anthropic param translator with no side-channel state, which
matches its contract everywhere else in the codebase.

Tests: replaced the now-incorrect "stashes maps in optional_params"
tests with regressions that assert no underscore-prefixed keys appear
in either ``optional_params`` after ``map_openai_params`` or in the
final ``transform_request`` body. Added end-to-end coverage for:
sanitization in ``transform_request``, ``tool_choice`` rewriting,
historical ``tool_calls`` rewriting in messages, and hosted-tool
passthrough.

Made-with: Cursor

* fix(anthropic): always sanitize empty text content blocks

Anthropic 400s on `{"role": "user", "content": ""}` with:
  "messages: text content blocks must be non-empty"

LiteLLM already had `_sanitize_empty_text_content` to rewrite empty text
to a placeholder, but it was gated behind `litellm.modify_params=True`.
With that flag off (default), empty content from upstream agent
frameworks (e.g. pydantic-ai) flowed straight through and tripped the
Anthropic validator.

Fix:
- Always run `_sanitize_empty_text_content` at the top of
  `anthropic_messages_pt`, independent of `modify_params`. There is no
  way to "pass through" an empty text block, so this is non-optional.
  The richer tool-call sanitizations (Cases A/B/D, which actually
  mutate conversation structure) remain gated on `modify_params`.
- Extend `_sanitize_empty_text_content` to also handle list-of-blocks
  content (`[{"type": "text", "text": ""}]`), not just string content.

Adds 3 regression tests covering string content, list-of-blocks
content, and the no-op case (non-empty messages with modify_params off).

Made-with: Cursor

* fix(anthropic): drop dead tool-name forward-map params, fix mypy + caller-mutation

- remove unused `name_forward_map` param from `_map_tool_choice`,
  `_map_tool_helper`, `_map_tools` and the `_apply_anthropic_tool_name_forward`
  helper. Production sanitization runs in `_sanitize_tool_names_in_request`
  at `transform_request`; these params were never threaded through.
- handler.py: use `ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY` constant instead of
  the hardcoded `"_anthropic_tool_name_map"` string.
- fix mypy `"object" has no attribute "__iter__"` in
  `_rewrite_tool_names_in_messages` by guarding `tool_calls` with
  `isinstance(..., list)`.
- `_sanitize_tool_names_in_request`: build a new tools list with copy-on-
  change entries (and copy `tool_choice` on rewrite) so a caller reusing
  the same tool list/dicts across requests doesn't see its inputs
  permanently rewritten.
- doc-comment `_build_request_tool_name_maps` clarifying it operates on
  OpenAI-format tools (vs `_sanitize_tool_names_in_request` which runs
  on Anthropic-format tools post-`_map_tools`).
- tests: drop 3 tests pinning the now-removed param paths; add coverage
  for tool_calls + None function_call rewrite and caller-dict immutability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mcp): inherit stored credentials in test/tools/list for edit flow

When editing an existing MCP server, the Tool Configuration preview
calls POST /mcp-rest/test/tools/list with server_id but no credentials
(management API redacts them). The endpoint now calls
_inherit_credentials_from_existing_server() so stored bearer tokens
and OAuth2 M2M credentials are loaded from global_mcp_server_manager
automatically — tools load without re-entering credentials.

New servers (no server_id) and requests with explicit credentials are
unaffected (function is a no-op in both cases).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(mcp): show all tools in edit panel, not just allowed tools

Edit flow was passing externalTools (from GET /tools/list, filtered by
allowed_tools) to MCPToolConfiguration, disabling the internal hook.
Remove the external props so the internal hook fires via
POST /test/tools/list, which returns all tools unfiltered. Combined
with the credential inheritance fix, tools load automatically without
re-entering credentials and all tools are visible for re-configuration.

existingAllowedTools still pre-checks previously allowed tools.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix order-dependent collision in _build_anthropic_tool_name_maps

Use a two-pass approach: first pre-register all already-valid tool names
in the 'used' set, then sanitize/disambiguate names that need rewriting.
This ensures valid names always have priority regardless of input order,
preventing duplicate tool names on the wire when e.g. 'foo/bar' appears
before 'foo_bar' in the tool list.

Add regression test for the reversed ordering case.

* Fix OpenAPI tool name collision: disambiguate sanitized names with numeric suffixes

sanitize_openapi_tool_name replaces all invalid chars with '_', but when
two operationIds differ only by sanitized characters (e.g. 'foo/list' and
'foo.list' both become 'foo_list'), the second registration silently
overwrites the first in the tool registry.

Add collision disambiguation in register_tools_from_openapi that appends
_2, _3, ... suffixes when a sanitized name is already taken, mirroring
the existing logic in _build_anthropic_tool_name_maps.

* Fix preview endpoint missing collision disambiguation for tool names

Add used_names tracking and _2/_3 suffix disambiguation to
_preview_openapi_tools, matching the logic in register_tools_from_openapi.
Without this, two operationIds that sanitize to the same string (e.g.
'foo/list' and 'foo.list' both becoming 'foo_list') would show duplicate
names in the preview while registration would disambiguate them.

* Align preview HTTP method order with register_tools_from_openapi

The preview endpoint and register_tools_from_openapi both use
order-dependent collision disambiguation (_2, _3 suffixes). When the
iteration order differs, two operations on the same path with sanitized
names that collide get different suffixes in preview vs registration,
so the dashboard shows names that don't match what actually got
registered.

Also adds a regression test that fails on the swapped order.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* Skip duplicate originals in _build_anthropic_tool_name_maps

If the same invalid tool name appeared twice in original_names (e.g.
['foo/bar', 'foo/bar']), the second occurrence overwrote the forward
map entry with a freshly-suffixed name (foo_bar_2), leaving foo_bar
orphaned in 'used' with no reverse mapping. _sanitize_tool_names_in_request
then rewrote both tool entries to foo_bar_2, and Anthropic 400'd on
duplicate tool names.

Skip the rewrite if forward already has the original mapped.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
This commit is contained in:
Krrish Dholakia 2026-05-05 17:00:36 -07:00 committed by GitHub
parent dbc8f5a937
commit 454ce5073f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1680 additions and 28 deletions

View File

@ -2124,27 +2124,62 @@ def anthropic_process_openai_file_message(
)
_EMPTY_TEXT_PLACEHOLDER = (
"[System: Empty message content sanitised to satisfy protocol]"
)
def _sanitize_empty_text_content(
message: AllMessageValues,
) -> AllMessageValues:
"""
Case C: Sanitize empty text content
- Replace empty or whitespace-only text content with a placeholder message.
- Handles both string content and list-of-blocks content (rewriting only
the empty text blocks in place; non-text blocks like images are left
untouched).
Returns:
The message with sanitized content if needed, otherwise the original message
"""
if message.get("role") in ["user", "assistant"]:
content = message.get("content")
if isinstance(content, str):
if not content or not content.strip():
message = cast(AllMessageValues, dict(message)) # Make a copy
message["content"] = (
"[System: Empty message content sanitised to satisfy protocol]"
)
verbose_logger.debug(
f"_sanitize_empty_text_content: Replaced empty text content in {message.get('role')} message"
)
if message.get("role") not in ["user", "assistant"]:
return message
content = message.get("content")
if isinstance(content, str):
if not content or not content.strip():
message = cast(AllMessageValues, dict(message)) # Make a copy
message["content"] = _EMPTY_TEXT_PLACEHOLDER
verbose_logger.debug(
f"_sanitize_empty_text_content: Replaced empty text content in {message.get('role')} message"
)
return message
if isinstance(content, list):
# Walk the blocks and rewrite any empty text blocks. We rewrite (rather
# than drop) so callers don't end up with an entirely empty content
# list, which Anthropic also rejects.
new_blocks: List[Any] = []
rewrote_any = False
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text")
if not isinstance(text, str) or not text or not text.strip():
new_block = dict(block)
new_block["text"] = _EMPTY_TEXT_PLACEHOLDER
new_blocks.append(new_block)
rewrote_any = True
continue
new_blocks.append(block)
if rewrote_any:
message = cast(AllMessageValues, dict(message)) # Make a copy
message["content"] = new_blocks # type: ignore
verbose_logger.debug(
f"_sanitize_empty_text_content: Replaced empty text block(s) in {message.get('role')} message"
)
return message
@ -2427,6 +2462,18 @@ def anthropic_messages_pt( # noqa: PLR0915
# Sanitize messages for tool calling issues when modify_params=True
messages = sanitize_messages_for_tool_calling(messages)
# Anthropic rejects empty text content blocks with:
# "messages: text content blocks must be non-empty"
# OpenAI/other providers silently tolerate `{"role": "user", "content": ""}`,
# so callers (and upstream agent frameworks like pydantic-ai) routinely
# send empty user/assistant turns. We always rewrite these to a placeholder
# for Anthropic-shaped requests, independent of `litellm.modify_params`,
# because there is no way to "pass through" an empty text block — the
# request will always 400 otherwise. The richer tool-call sanitization
# (Cases A/B/D in `sanitize_messages_for_tool_calling`) remains gated on
# `modify_params` because it actually mutates conversation structure.
messages = [_sanitize_empty_text_content(m) for m in messages]
# add role=tool support to allow function call result/error submission
user_message_types = {"user", "tool", "function"}
# reformat messages to ensure user/assistant are alternating, if there's either 2 consecutive 'user' messages or 2 consecutive 'assistant' message, merge them.

View File

@ -65,7 +65,7 @@ from litellm.types.utils import (
from ...base import BaseLLM
from ..common_utils import AnthropicError, process_anthropic_headers
from .transformation import AnthropicConfig
from .transformation import ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY, AnthropicConfig
if TYPE_CHECKING:
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
@ -83,6 +83,7 @@ async def make_call(
timeout: Optional[Union[float, httpx.Timeout]],
json_mode: bool,
speed: Optional[str] = None,
tool_name_reverse_map: Optional[Dict[str, str]] = None,
) -> Tuple[Any, httpx.Headers]:
if client is None:
client = litellm.module_level_aclient
@ -117,6 +118,7 @@ async def make_call(
sync_stream=False,
json_mode=json_mode,
speed=speed,
tool_name_reverse_map=tool_name_reverse_map,
)
# LOGGING
@ -141,6 +143,7 @@ def make_sync_call(
timeout: Optional[Union[float, httpx.Timeout]],
json_mode: bool,
speed: Optional[str] = None,
tool_name_reverse_map: Optional[Dict[str, str]] = None,
) -> Tuple[Any, httpx.Headers]:
if client is None:
client = litellm.module_level_client # re-use a module level client
@ -183,6 +186,7 @@ def make_sync_call(
sync_stream=True,
json_mode=json_mode,
speed=speed,
tool_name_reverse_map=tool_name_reverse_map,
)
# LOGGING
@ -237,6 +241,11 @@ class AnthropicChatCompletion(BaseLLM):
timeout=timeout,
json_mode=json_mode,
speed=optional_params.get("speed") if optional_params else None,
tool_name_reverse_map=(
litellm_params.get(ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY)
if isinstance(litellm_params, dict)
else None
),
)
streamwrapper = CustomStreamWrapper(
completion_stream=completion_stream,
@ -462,6 +471,11 @@ class AnthropicChatCompletion(BaseLLM):
timeout=timeout,
json_mode=json_mode,
speed=optional_params.get("speed") if optional_params else None,
tool_name_reverse_map=(
litellm_params.get(ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY)
if isinstance(litellm_params, dict)
else None
),
)
return CustomStreamWrapper(
completion_stream=completion_stream,
@ -526,6 +540,7 @@ class ModelResponseIterator:
sync_stream: bool,
json_mode: Optional[bool] = False,
speed: Optional[str] = None,
tool_name_reverse_map: Optional[Dict[str, str]] = None,
):
self.streaming_response = streaming_response
self.response_iterator = self.streaming_response
@ -533,6 +548,13 @@ class ModelResponseIterator:
self.tool_index = -1
self.json_mode = json_mode
self.speed = speed
# rewritten-name -> caller's original. Built per-request from the
# forward map in AnthropicConfig._build_request_tool_name_maps; only
# contains entries we actually rewrote, so a tool legitimately named
# `foo_bar` is *not* reverse-mapped just because some other tool was
# rewritten to `foo_bar` in a different request. Empty/None is the
# common case (no '/' or other invalid chars in any tool name).
self.tool_name_reverse_map: Dict[str, str] = tool_name_reverse_map or {}
# Generate response ID once per stream to match OpenAI-compatible behavior
self.response_id = _generate_id()
@ -792,6 +814,16 @@ class ModelResponseIterator:
or content_block_start["content_block"]["type"] == "server_tool_use"
):
self.tool_index += 1
# Reverse-map the (sanitized) tool name back to the
# caller's original. No-op when the map is empty.
_stream_tool_name = content_block_start["content_block"]["name"]
if (
self.tool_name_reverse_map
and _stream_tool_name in self.tool_name_reverse_map
):
_stream_tool_name = self.tool_name_reverse_map[
_stream_tool_name
]
# Use empty string for arguments in content_block_start - actual arguments
# come in subsequent content_block_delta chunks and get accumulated.
# Using str(input) here would prepend '{}' causing invalid JSON accumulation.
@ -799,7 +831,7 @@ class ModelResponseIterator:
id=content_block_start["content_block"]["id"],
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=content_block_start["content_block"]["name"],
name=_stream_tool_name,
arguments="",
),
index=self.tool_index,

View File

@ -105,6 +105,115 @@ else:
LoggingClass = Any
# Anthropic requires tool names to match ^[a-zA-Z0-9_-]{1,128}$. Any other
# character (commonly '/' or '.' from OpenAPI-derived MCP tools, e.g.
# "actions/download-job-logs-for-workflow-run") must be replaced before
# the request is sent.
#
# A naive "replace [^a-zA-Z0-9_-] with _" is unsafe because it's lossy:
# `foo/bar` and `foo_bar` both collapse to `foo_bar`. Two tools with the
# same sanitized name would either 400 at Anthropic (duplicate) or, worse,
# cause the response side to mis-translate `foo_bar` (a name the caller
# really did register) back to `foo/bar`.
#
# Instead we build a *per-request* forward map (original -> sanitized)
# whose codomain is unique within the request: when two originals collapse
# to the same candidate, or when a sanitized name collides with an already-
# valid name elsewhere in the request, we append numeric suffixes
# (`_2`, `_3`, ...) until the result is free.
#
# The reverse map (sanitized -> original) only contains entries where the
# original was actually rewritten. So a tool whose name is already valid
# round-trips identically and is *never* mistakenly re-mapped on the
# response side.
_ANTHROPIC_TOOL_NAME_INVALID_CHARS = re.compile(r"[^a-zA-Z0-9_-]")
_ANTHROPIC_TOOL_NAME_MAX_LEN = 128
# Single, internal-only key on ``litellm_params`` used to thread the per-
# request reverse map (sanitized -> original) from request build to response
# parsing. ``litellm_params`` is never serialized to a provider; ``optional_
# params`` IS (it becomes the JSON body via ``data = {**optional_params}``).
# Keep these two channels strictly separate -- never stash internal
# coordination state in ``optional_params``.
ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY = "_anthropic_tool_name_map"
def _basic_sanitize_anthropic_tool_name(name: str) -> str:
"""Lossy: replace [^a-zA-Z0-9_-] with '_' and truncate to 128.
Used as a candidate generator for the per-request forward map.
Callers should NOT use this directly for translation -- always go
through the forward map so collisions are resolved.
"""
if not isinstance(name, str) or not name:
return name
return _ANTHROPIC_TOOL_NAME_INVALID_CHARS.sub("_", name)[
:_ANTHROPIC_TOOL_NAME_MAX_LEN
]
def _build_anthropic_tool_name_maps(
original_names: List[str],
) -> Tuple[Dict[str, str], Dict[str, str]]:
"""Build (forward, reverse) tool-name maps for a single request.
forward[original] = sanitized -- only present when name was rewritten
reverse[sanitized] = original -- inverse of `forward`
Properties:
- All sanitized names satisfy ^[a-zA-Z0-9_-]{1,128}$.
- Sanitized names are unique within the request (no two originals
collide on the wire).
- A name that's already valid AND doesn't collide with another tool's
sanitized form passes through untouched and is absent from the maps.
That's the key correctness property: response-side translation only
runs on entries we actually rewrote, so a tool legitimately named
`foo_bar` is never incorrectly retyped to `foo/bar` just because
some *other* request had that pair.
- Order-dependent: when two originals would clash, the *second* one
seen gets the disambiguating suffix. Callers should preserve the
caller's tool order (we do).
"""
forward: Dict[str, str] = {}
used: set = set()
# First pass: reserve slots for names that are already valid so they
# always have priority regardless of input order.
for original in original_names:
if not isinstance(original, str) or not original:
continue
candidate = _basic_sanitize_anthropic_tool_name(original)
if candidate == original:
used.add(candidate)
# Second pass: sanitize/disambiguate names that need rewriting.
for original in original_names:
if not isinstance(original, str) or not original:
continue
candidate = _basic_sanitize_anthropic_tool_name(original)
if candidate == original:
continue
# Skip duplicates of the same original name. Without this guard the
# second pass would assign a fresh suffix and overwrite the forward
# map entry, causing every reference to map to the suffixed name and
# leaving the original sanitized slot orphaned in `used` with no
# reverse mapping.
if original in forward:
continue
# Disambiguate against names already chosen this request.
unique = candidate
n = 1
while unique in used:
n += 1
suffix = f"_{n}"
# Keep within the 128-char cap.
head = candidate[: _ANTHROPIC_TOOL_NAME_MAX_LEN - len(suffix)]
unique = f"{head}{suffix}"
forward[original] = unique
used.add(unique)
reverse = {v: k for k, v in forward.items()}
return forward, reverse
REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT: Dict[str, str] = {
"low": "low",
"minimal": "low",
@ -486,7 +595,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
}
def _map_tool_choice(
self, tool_choice: Optional[str], parallel_tool_use: Optional[bool]
self,
tool_choice: Optional[str],
parallel_tool_use: Optional[bool],
) -> Optional[AnthropicMessagesToolChoice]:
_tool_choice: Optional[AnthropicMessagesToolChoice] = None
if tool_choice == "auto":
@ -527,7 +638,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
return _tool_choice
def _map_tool_helper( # noqa: PLR0915
self, tool: ChatCompletionToolParam
self,
tool: ChatCompletionToolParam,
) -> Tuple[Optional[AllAnthropicToolsValues], Optional[AnthropicMcpServerTool]]:
returned_tool: Optional[AllAnthropicToolsValues] = None
mcp_server: Optional[AnthropicMcpServerTool] = None
@ -783,7 +895,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
return initial_tool
def _map_tools(
self, tools: List
self,
tools: List,
) -> Tuple[List[AllAnthropicToolsValues], List[AnthropicMcpServerTool]]:
anthropic_tools = []
mcp_servers = []
@ -799,6 +912,174 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
mcp_servers.append(mcp_server_tool)
return anthropic_tools, mcp_servers
@staticmethod
def _rewrite_tool_names_in_messages(
messages: List[AllMessageValues],
name_forward_map: Dict[str, str],
) -> List[AllMessageValues]:
"""Return a copy of `messages` with tool_call/function_call names
rewritten using the per-request forward map.
Only mutates messages whose tool_call/function_call name is *in* the
forward map. Names absent from the map (already valid, no collision)
round-trip untouched. We only deep-copy the entries we actually
change to keep this O(turns-with-rewritten-tools), not O(history).
"""
if not name_forward_map:
return messages
new_messages: List[AllMessageValues] = []
for msg in messages:
if not isinstance(msg, dict):
new_messages.append(msg)
continue
tool_calls = msg.get("tool_calls")
function_call = msg.get("function_call")
if not tool_calls and not function_call:
new_messages.append(msg)
continue
new_msg = dict(msg)
if isinstance(tool_calls, list):
new_calls = []
for tc in tool_calls:
if not isinstance(tc, dict):
new_calls.append(tc)
continue
fn = tc.get("function")
fn_name = fn.get("name") if isinstance(fn, dict) else None
if (
isinstance(fn, dict)
and isinstance(fn_name, str)
and fn_name in name_forward_map
):
new_fn = dict(fn)
new_fn["name"] = name_forward_map[fn_name]
new_tc = dict(tc)
new_tc["function"] = new_fn
new_calls.append(new_tc)
else:
new_calls.append(tc)
new_msg["tool_calls"] = new_calls
fc_name = (
function_call.get("name") if isinstance(function_call, dict) else None
)
if (
isinstance(function_call, dict)
and isinstance(fc_name, str)
and fc_name in name_forward_map
):
new_fc = dict(function_call)
new_fc["name"] = name_forward_map[fc_name]
new_msg["function_call"] = new_fc
new_messages.append(cast(AllMessageValues, new_msg))
return new_messages
@staticmethod
def _build_request_tool_name_maps(
tools: List,
) -> Tuple[Dict[str, str], Dict[str, str]]:
"""Build the (forward, reverse) tool-name maps for an OpenAI tools list.
Operates on **OpenAI-format** tool dicts (pre-``_map_tools``). The
production sanitization path uses ``_sanitize_tool_names_in_request``
instead, which operates on **Anthropic-format** tools (post-
``_map_tools``, where ``type == "custom"``). This helper exists for
callers that need to compute the maps from the raw OpenAI shape --
e.g. test setup or future pre-mapping consumers.
See _build_anthropic_tool_name_maps for the collision rules. Pulls
the original name out of either ``{"function": {"name": ...}}``
(legacy OpenAI shape) or ``{"name": ...}`` (rare top-level shape).
"""
original_names: List[str] = []
for tool in tools or []:
if not isinstance(tool, dict):
continue
original = (
tool.get("function", {}).get("name")
if isinstance(tool.get("function"), dict)
else None
)
if original is None:
original = tool.get("name")
if isinstance(original, str) and original:
original_names.append(original)
return _build_anthropic_tool_name_maps(original_names)
@staticmethod
def _sanitize_tool_names_in_request(
optional_params: Dict[str, Any],
) -> Tuple[Dict[str, str], Dict[str, str]]:
"""Sanitize ``optional_params['tools']`` and ``optional_params['tool_choice']``
in place so every name matches Anthropic's ``^[a-zA-Z0-9_-]{1,128}$``.
Returns ``(forward, reverse)`` for use by message-history rewriting
and response translation. ``forward[original] = sanitized`` is only
populated for names that were actually rewritten -- i.e. either
contained an invalid character or collided with another tool's
sanitized form. Names already valid AND unique pass through and are
absent from both maps.
Only ``type == "custom"`` tools (the OpenAI function-tool shape) are
considered. Hosted tools (``web_search``, ``bash``, ``code_execution``,
``computer_*``, ``mcp``, ...) own reserved names defined by Anthropic
and must not be touched.
"""
tools = optional_params.get("tools")
if not isinstance(tools, list) or not tools:
return {}, {}
# 1. Collect originals from the Anthropic-shaped custom-tool entries.
# Order matters: the first occurrence wins the canonical slot;
# later collisions get numeric suffixes (see
# ``_build_anthropic_tool_name_maps``).
original_names: List[str] = []
for t in tools:
if not isinstance(t, dict):
continue
if t.get("type") != "custom":
continue
name = t.get("name")
if isinstance(name, str) and name:
original_names.append(name)
if not original_names:
return {}, {}
forward, reverse = _build_anthropic_tool_name_maps(original_names)
if not forward:
# Every name was already valid -- nothing to do.
return forward, reverse
# 2. Apply forward map. Build a new list with copy-on-change entries
# so a caller reusing the same tool list/dicts across requests
# doesn't see its inputs permanently rewritten (which would also
# drop the original key from `forward` on the next request).
new_tools: List[Any] = []
for t in tools:
if (
isinstance(t, dict)
and t.get("type") == "custom"
and isinstance(t.get("name"), str)
and t["name"] in forward
):
new_tools.append({**t, "name": forward[t["name"]]})
else:
new_tools.append(t)
optional_params["tools"] = new_tools
# 3. Same for ``tool_choice`` when it targets a named tool. Copy
# rather than mutate for the same reason as above.
tool_choice = optional_params.get("tool_choice")
if isinstance(tool_choice, dict) and tool_choice.get("type") == "tool":
tc_name = tool_choice.get("name")
if isinstance(tc_name, str) and tc_name in forward:
optional_params["tool_choice"] = {
**tool_choice,
"name": forward[tc_name],
}
return forward, reverse
def _detect_tool_search_tools(self, tools: Optional[List]) -> bool:
"""Check if tool search tools are present in the tools list."""
if not tools:
@ -1125,6 +1406,17 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
non_default_params=non_default_params
)
# NB: ``map_openai_params`` deliberately does NOT sanitize tool names
# here. Names are the *original* OpenAI names at this stage, and must
# remain so until ``transform_request`` -- which is the single
# chokepoint where Anthropic, Bedrock-Anthropic, and Vertex-Anthropic
# all pass through. Doing it there guarantees:
# 1. one source of truth for the per-request forward/reverse maps,
# 2. the maps land on ``litellm_params`` (internal), never on
# ``optional_params`` (which is serialized into the request body
# via ``data = {**optional_params}`` and would 400 with
# ``Extra inputs are not permitted``).
for param, value in non_default_params.items():
if param == "max_tokens":
optional_params["max_tokens"] = (
@ -1135,7 +1427,6 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
value if isinstance(value, int) else max(1, int(round(value)))
)
elif param == "tools":
# check if optional params already has tools
anthropic_tools, mcp_servers = self._map_tools(value)
optional_params = self._add_tools_to_optional_params(
optional_params=optional_params, tools=anthropic_tools
@ -1565,6 +1856,34 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
headers=headers, optional_params=optional_params
)
# === Tool-name sanitization (single chokepoint) ===
# Anthropic enforces ^[a-zA-Z0-9_-]{1,128}$ on every tool name. We
# sanitize *here* -- not in map_openai_params -- because:
#
# - This function is the single boundary shared by AnthropicConfig,
# AmazonAnthropicConfig (Bedrock invoke), VertexAIAnthropicConfig,
# and AzureAnthropicConfig (all call ``super().transform_request``
# or ``AnthropicConfig.transform_request(self, ...)``). Sanitizing
# once here covers every Anthropic-shaped request.
# - The forward/reverse maps are coordination state; they belong on
# ``litellm_params`` (internal-only), never on ``optional_params``
# (which becomes the JSON body via ``{**optional_params}``).
# - It keeps ``map_openai_params`` a pure param translator with no
# side-channel state.
#
# The reverse map only contains entries for names that were actually
# rewritten -- so a tool legitimately named ``foo_bar`` is never
# incorrectly retyped to ``foo/bar`` on the response side.
# See _build_anthropic_tool_name_maps for the collision-handling
# rules and rationale.
_name_forward_map, _name_reverse_map = self._sanitize_tool_names_in_request(
optional_params=optional_params,
)
if _name_forward_map:
messages = self._rewrite_tool_names_in_messages(messages, _name_forward_map)
if _name_reverse_map and isinstance(litellm_params, dict):
litellm_params[ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY] = _name_reverse_map
# Separate system prompt from rest of message
anthropic_system_message_list = self.translate_system_message(messages=messages)
# Handling anthropic API Prompt Caching
@ -2041,6 +2360,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
json_mode: Optional[bool] = None,
prefix_prompt: Optional[str] = None,
speed: Optional[str] = None,
tool_name_reverse_map: Optional[Dict[str, str]] = None,
):
_hidden_params: Dict = {}
_hidden_params["additional_headers"] = process_anthropic_headers(
@ -2065,6 +2385,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
compaction_blocks,
) = self.extract_response_content(completion_response=completion_response)
# Reverse-map rewritten tool names back to caller's originals so a
# downstream OpenAI-style dispatcher can match on the registered name.
# See _build_anthropic_tool_name_maps for why this is keyed on the
# per-request reverse map (so a tool legitimately named `foo_bar` is
# never incorrectly retyped to `foo/bar`). No-op when the map is
# empty (the common case).
if tool_name_reverse_map and tool_calls:
for tc in tool_calls:
fn = tc.get("function") if isinstance(tc, dict) else None
if fn is None:
continue
_name = fn.get("name")
if isinstance(_name, str) and _name in tool_name_reverse_map:
fn["name"] = tool_name_reverse_map[_name]
if (
prefix_prompt is not None
and not text_content.startswith(prefix_prompt)
@ -2191,6 +2526,11 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
prefix_prompt = self.get_prefix_prompt(messages=messages)
speed = optional_params.get("speed")
tool_name_reverse_map: Optional[Dict[str, str]] = None
if isinstance(litellm_params, dict):
_candidate = litellm_params.get(ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY)
if isinstance(_candidate, dict):
tool_name_reverse_map = _candidate
model_response = self.transform_parsed_response(
completion_response=completion_response,
@ -2199,6 +2539,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
json_mode=json_mode,
prefix_prompt=prefix_prompt,
speed=speed,
tool_name_reverse_map=tool_name_reverse_map,
)
return model_response

View File

@ -6,10 +6,34 @@ import asyncio
import contextvars
import json
import os
import re
from pathlib import PurePosixPath
from typing import Any, Dict, List, Optional
from urllib.parse import quote
# Tool names emitted from OpenAPI specs must work across all major LLM providers.
# OpenAI/Anthropic/Bedrock all enforce a character class roughly equivalent to
# ^[a-zA-Z0-9_-]+$ on tool names. Many specs (notably GitHub's REST API) use
# tag-namespaced operationIds like "actions/download-job-logs-for-workflow-run"
# which include '/'. Sanitize here so the same regex passes everywhere downstream.
_OPENAPI_TOOL_NAME_INVALID_CHARS = re.compile(r"[^a-zA-Z0-9_-]")
_OPENAPI_TOOL_NAME_MAX_LEN = 128
def sanitize_openapi_tool_name(raw_name: str) -> str:
"""Map an OpenAPI operationId / fallback to a provider-safe tool name.
Replaces any character outside ``[a-zA-Z0-9_-]`` with ``_`` and caps the
result at 128 chars (the most restrictive of the major providers).
Lowercased to match the existing convention in
``register_tools_from_openapi``.
"""
if not raw_name:
return raw_name
sanitized = _OPENAPI_TOOL_NAME_INVALID_CHARS.sub("_", raw_name).lower()
return sanitized[:_OPENAPI_TOOL_NAME_MAX_LEN]
from litellm._logging import verbose_logger
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
@ -399,17 +423,36 @@ def create_tool_function(
def register_tools_from_openapi(spec: Dict[str, Any], base_url: str):
"""Register MCP tools from OpenAPI specification."""
paths = spec.get("paths", {})
used_names: set = set()
for path, path_item in paths.items():
for method in ["get", "post", "put", "delete", "patch"]:
if method in path_item:
operation = path_item[method]
# Generate tool name
operation_id = operation.get(
"operationId", f"{method}_{path.replace('/', '_')}"
)
tool_name = operation_id.replace(" ", "_").lower()
# Generate tool name. Sanitize to ^[a-zA-Z0-9_-]+$ (lowercase)
# so the resulting name is valid across OpenAI/Anthropic/Bedrock.
# Many specs (e.g. GitHub REST) use tag-namespaced operationIds
# like "actions/download-job-logs-for-workflow-run" which
# contain '/' and would 400 at the LLM provider boundary.
operation_id = operation.get("operationId", f"{method}_{path}")
tool_name = sanitize_openapi_tool_name(operation_id)
# Disambiguate collisions: two operationIds that differ only
# by sanitized characters (e.g. "foo/list" and "foo.list")
# would both become "foo_list". Append _2, _3, … to keep
# every tool reachable, mirroring the Anthropic-side logic
# in _build_anthropic_tool_name_maps.
unique = tool_name
n = 1
while unique in used_names:
n += 1
suffix = f"_{n}"
unique = (
tool_name[: _OPENAPI_TOOL_NAME_MAX_LEN - len(suffix)] + suffix
)
tool_name = unique
used_names.add(tool_name)
# Get description
description = operation.get(

View File

@ -857,6 +857,7 @@ if MCP_AVAILABLE:
########################################################
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
NewMCPServerRequest,
_inherit_credentials_from_existing_server,
)
def _extract_credentials(
@ -975,9 +976,11 @@ if MCP_AVAILABLE:
async def _preview_openapi_tools(spec_path: str) -> dict:
"""Generate tool previews from an OpenAPI spec without creating a server."""
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
_OPENAPI_TOOL_NAME_MAX_LEN,
build_input_schema,
load_openapi_spec_async,
resolve_operation_params,
sanitize_openapi_tool_name,
)
try:
@ -985,8 +988,9 @@ if MCP_AVAILABLE:
paths = spec.get("paths", {})
components = spec.get("components", {})
tools: List[dict] = []
used_names: set = set()
for path, path_item in paths.items():
for method in ("get", "post", "put", "patch", "delete"):
for method in ("get", "post", "put", "delete", "patch"):
operation = path_item.get(method)
if operation is None:
continue
@ -995,7 +999,23 @@ if MCP_AVAILABLE:
operation, path_item, components
)
op_id = operation.get("operationId", f"{method}_{path}")
raw_op_id = operation.get("operationId", f"{method}_{path}")
# Match what register_tools_from_openapi does so the preview
# the user sees in the dashboard equals the names that get
# registered (and shipped to LLM providers, which enforce
# ^[a-zA-Z0-9_-]+$). See sanitize_openapi_tool_name docstring.
op_id = sanitize_openapi_tool_name(raw_op_id)
unique = op_id
n = 1
while unique in used_names:
n += 1
suffix = f"_{n}"
unique = (
op_id[: _OPENAPI_TOOL_NAME_MAX_LEN - len(suffix)] + suffix
)
op_id = unique
used_names.add(op_id)
summary = operation.get("summary", "")
description = operation.get("description", summary)
input_schema = build_input_schema(resolved_op)
@ -1068,6 +1088,10 @@ if MCP_AVAILABLE:
},
)
new_mcp_server_request = _inherit_credentials_from_existing_server(
new_mcp_server_request
)
# For OpenAPI spec servers, generate tools from the spec directly
if new_mcp_server_request.spec_path:
return await _preview_openapi_tools(new_mcp_server_request.spec_path)

View File

@ -3914,3 +3914,764 @@ def test_strip_advisor_blocks_no_op_when_no_advisor_blocks():
original_content = [dict(b) for b in messages[1]["content"]]
result = strip_advisor_blocks_from_messages(messages)
assert result[1]["content"] == original_content
# ---------------------------------------------------------------------------
# Tool-name sanitization for Anthropic compatibility (^[a-zA-Z0-9_-]{1,128}$)
# Repro: Slack-bot agent sent an MCP tool named
# "github_openapi_mcp-actions/download-job-logs-for-workflow-run" which 400'd
# with `tools.N.custom.name: String should match pattern`.
# ---------------------------------------------------------------------------
def test_basic_sanitize_anthropic_tool_name_replaces_invalid_chars():
from litellm.llms.anthropic.chat.transformation import (
_basic_sanitize_anthropic_tool_name,
)
assert (
_basic_sanitize_anthropic_tool_name(
"github_openapi_mcp-actions/download-job-logs-for-workflow-run"
)
== "github_openapi_mcp-actions_download-job-logs-for-workflow-run"
)
# other punctuation
assert _basic_sanitize_anthropic_tool_name("foo.bar:baz qux") == "foo_bar_baz_qux"
# already valid -> unchanged
assert _basic_sanitize_anthropic_tool_name("plain_tool-1") == "plain_tool-1"
# empty
assert _basic_sanitize_anthropic_tool_name("") == ""
# 128-char cap
long = "a/" * 200
out = _basic_sanitize_anthropic_tool_name(long)
assert len(out) <= 128
def test_build_anthropic_tool_name_maps_no_collisions():
"""Names that need rewriting go in the maps; valid names stay out."""
from litellm.llms.anthropic.chat.transformation import (
_build_anthropic_tool_name_maps,
)
forward, reverse = _build_anthropic_tool_name_maps(
[
"fine_name",
"actions/download-job-logs-for-workflow-run",
"pulls/list-files",
]
)
assert forward == {
"actions/download-job-logs-for-workflow-run": (
"actions_download-job-logs-for-workflow-run"
),
"pulls/list-files": "pulls_list-files",
}
assert reverse == {v: k for k, v in forward.items()}
# untouched names absent
assert "fine_name" not in forward
assert "fine_name" not in reverse
def test_build_anthropic_tool_name_maps_disambiguates_collision_with_existing_valid():
"""If `foo/bar` would collapse to `foo_bar` but `foo_bar` already exists,
the rewritten one must get a unique suffix and only THAT one shows up in
the reverse map. The legitimately-named `foo_bar` round-trips identically."""
from litellm.llms.anthropic.chat.transformation import (
_build_anthropic_tool_name_maps,
)
forward, reverse = _build_anthropic_tool_name_maps(["foo_bar", "foo/bar"])
# The original valid name keeps its slot.
assert "foo_bar" not in forward # untouched
# The rewritten one gets a disambiguating suffix.
assert forward["foo/bar"] == "foo_bar_2"
# Reverse map only has the rewritten entry.
assert reverse == {"foo_bar_2": "foo/bar"}
# CRITICAL: a legit `foo_bar` returned by the model must NOT round-trip
# to `foo/bar`.
assert "foo_bar" not in reverse
def test_build_anthropic_tool_name_maps_disambiguates_two_rewrites_to_same_target():
"""Two different invalid names that collapse to the same candidate must
both end up with unique sanitized forms."""
from litellm.llms.anthropic.chat.transformation import (
_build_anthropic_tool_name_maps,
)
forward, reverse = _build_anthropic_tool_name_maps(["foo/bar", "foo.bar"])
# First wins the canonical slot, second gets a suffix.
assert forward["foo/bar"] == "foo_bar"
assert forward["foo.bar"] == "foo_bar_2"
# Round-trip is unambiguous.
assert reverse["foo_bar"] == "foo/bar"
assert reverse["foo_bar_2"] == "foo.bar"
def test_build_anthropic_tool_name_maps_three_way_collision():
"""`foo/bar`, `foo.bar`, and an existing `foo_bar` must all coexist."""
from litellm.llms.anthropic.chat.transformation import (
_build_anthropic_tool_name_maps,
)
forward, reverse = _build_anthropic_tool_name_maps(
["foo_bar", "foo/bar", "foo.bar"]
)
assert "foo_bar" not in forward # untouched
assert forward["foo/bar"] == "foo_bar_2"
assert forward["foo.bar"] == "foo_bar_3"
# All three sanitized names are distinct.
sent_names = {"foo_bar", forward["foo/bar"], forward["foo.bar"]}
assert len(sent_names) == 3
assert reverse == {"foo_bar_2": "foo/bar", "foo_bar_3": "foo.bar"}
def test_build_anthropic_tool_name_maps_reverse_order_collision():
"""REGRESSION: when the invalid name appears *before* the valid name that
its sanitized form collides with, both must still end up with distinct
names on the wire."""
from litellm.llms.anthropic.chat.transformation import (
_build_anthropic_tool_name_maps,
)
forward, reverse = _build_anthropic_tool_name_maps(["foo/bar", "foo_bar"])
# The valid name keeps its slot untouched.
assert "foo_bar" not in forward
# The rewritten one gets a disambiguating suffix.
assert forward["foo/bar"] == "foo_bar_2"
assert reverse == {"foo_bar_2": "foo/bar"}
assert "foo_bar" not in reverse
def test_build_anthropic_tool_name_maps_duplicate_originals():
"""REGRESSION: duplicate originals must not corrupt the forward map.
Previously, the second occurrence of the same invalid name would
rewrite ``forward[original]`` to a suffixed name (``foo_bar_2``),
leaving ``foo_bar`` orphaned in ``used`` with no reverse mapping
so when ``_sanitize_tool_names_in_request`` applied the forward
map, *both* tool entries got the suffixed name and Anthropic 400'd
on duplicates.
"""
from litellm.llms.anthropic.chat.transformation import (
_build_anthropic_tool_name_maps,
)
forward, reverse = _build_anthropic_tool_name_maps(["foo/bar", "foo/bar"])
# Same original sanitizes to the same target — no spurious suffix.
assert forward == {"foo/bar": "foo_bar"}
assert reverse == {"foo_bar": "foo/bar"}
def test_map_openai_params_does_not_pollute_optional_params_with_internal_keys():
"""REGRESSION: ``optional_params`` is what becomes the JSON body sent to
Anthropic (``data = {**optional_params}``). It MUST NOT carry LiteLLM-
internal coordination state like the per-request forward/reverse name
maps, or Anthropic 400s with ``Extra inputs are not permitted``.
Sanitization belongs in ``transform_request``, not here."""
config = AnthropicConfig()
optional_params: dict = {}
config.map_openai_params(
non_default_params={
"tools": [
{
"type": "function",
"function": {
"name": "actions/download-job-logs-for-workflow-run",
"parameters": {"type": "object", "properties": {}},
},
}
]
},
optional_params=optional_params,
model="claude-sonnet-4",
drop_params=False,
)
# No internal keys may appear in optional_params for ANY input.
for key in optional_params:
assert not key.startswith(
"_anthropic_tool_name"
), f"optional_params leaked internal key {key!r}: {optional_params}"
# And no key starting with `_` either; optional_params should only
# contain documented Anthropic Messages API parameters.
for key in optional_params:
assert not key.startswith("_"), (
f"optional_params leaked underscore-prefixed key {key!r}: "
f"{optional_params}"
)
def test_map_openai_params_no_maps_when_all_names_already_valid():
"""Sanity check: an all-valid tool list adds nothing weird either."""
config = AnthropicConfig()
optional_params: dict = {}
config.map_openai_params(
non_default_params={
"tools": [
{
"type": "function",
"function": {
"name": "plain_tool",
"parameters": {"type": "object", "properties": {}},
},
}
]
},
optional_params=optional_params,
model="claude-sonnet-4",
drop_params=False,
)
for key in optional_params:
assert not key.startswith("_anthropic_tool_name")
def test_rewrite_tool_names_in_messages_uses_forward_map():
config = AnthropicConfig()
forward_map = {
"actions/download-job-logs-for-workflow-run": (
"actions_download-job-logs-for-workflow-run"
)
}
messages = [
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "actions/download-job-logs-for-workflow-run",
"arguments": "{}",
},
}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "ok"},
]
out = config._rewrite_tool_names_in_messages(messages, forward_map)
# input list must not be mutated
assert (
messages[1]["tool_calls"][0]["function"]["name"]
== "actions/download-job-logs-for-workflow-run"
)
# output rewritten according to forward map
assert (
out[1]["tool_calls"][0]["function"]["name"]
== "actions_download-job-logs-for-workflow-run"
)
# non-tool-call messages pass through unchanged (same object)
assert out[0] is messages[0]
assert out[2] is messages[2]
def test_rewrite_tool_names_in_messages_leaves_unmapped_names_alone():
"""A tool_call name not in the forward map must NOT be rewritten,
even if it happens to look like a sanitized form of some other tool."""
config = AnthropicConfig()
# `foo_bar` is NOT in the forward map (only `foo/bar` -> `foo_bar_2` is).
# If we naively re-sanitized, `foo_bar` would stay `foo_bar`, but more
# subtly, in a buggy implementation we might collide it with the codomain
# of some other rewrite. Either way: it must round-trip identically.
forward_map = {"foo/bar": "foo_bar_2"}
messages = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "foo_bar", "arguments": "{}"},
}
],
},
]
out = config._rewrite_tool_names_in_messages(messages, forward_map)
assert out[0]["tool_calls"][0]["function"]["name"] == "foo_bar"
# input list must not be mutated either way
assert messages[0]["tool_calls"][0]["function"]["name"] == "foo_bar"
def test_rewrite_tool_names_in_messages_with_tool_calls_and_none_function_call():
"""When a message has tool_calls but function_call is explicitly None,
the rewrite must still apply to tool_calls and leave function_call as
None. Pins behavior at the boundary where ``new_msg = dict(msg)``
copies the explicit-None key forward."""
config = AnthropicConfig()
forward_map = {"foo/bar": "foo_bar"}
messages = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "foo/bar", "arguments": "{}"},
}
],
"function_call": None,
},
]
out = config._rewrite_tool_names_in_messages(messages, forward_map)
assert out[0]["tool_calls"][0]["function"]["name"] == "foo_bar"
assert out[0]["function_call"] is None
# input list must not be mutated
assert messages[0]["tool_calls"][0]["function"]["name"] == "foo/bar"
def test_sanitize_tool_names_in_request_does_not_mutate_caller_tool_dicts():
"""REGRESSION: a caller reusing the same tool list/dicts across requests
must not see its inputs permanently rewritten. _sanitize_tool_names_in_request
builds a new list with copy-on-change entries."""
config = AnthropicConfig()
original_name = "actions/download-job-logs-for-workflow-run"
caller_tool = {
"type": "custom",
"name": original_name,
"input_schema": {"type": "object", "properties": {}},
}
caller_tools = [caller_tool]
optional_params: dict = {"tools": caller_tools}
forward, reverse = config._sanitize_tool_names_in_request(
optional_params=optional_params
)
assert forward.get(original_name)
sanitized = forward[original_name]
assert optional_params["tools"][0]["name"] == sanitized
# caller's original dict + list must not be touched
assert caller_tool["name"] == original_name
assert caller_tools[0] is caller_tool
def test_transform_parsed_response_reverse_maps_tool_names():
"""End-to-end: rewritten tool name in Anthropic response -> original in OpenAI tool_calls."""
import json as _json
config = AnthropicConfig()
raw_response = MagicMock()
raw_response.headers = {}
raw_response.status_code = 200
completion_response = {
"id": "msg_x",
"model": "claude-sonnet-4",
"stop_reason": "tool_use",
"usage": {"input_tokens": 1, "output_tokens": 1},
"content": [
{
"type": "tool_use",
"id": "toolu_1",
"name": "actions_download-job-logs-for-workflow-run",
"input": {"job_id": 123},
}
],
}
from litellm.types.utils import ModelResponse
model_response = ModelResponse()
out = config.transform_parsed_response(
completion_response=completion_response,
raw_response=raw_response,
model_response=model_response,
tool_name_reverse_map={
"actions_download-job-logs-for-workflow-run": "actions/download-job-logs-for-workflow-run",
},
)
tcs = out.choices[0].message.tool_calls
assert tcs is not None and len(tcs) == 1
assert tcs[0].function.name == "actions/download-job-logs-for-workflow-run"
assert _json.loads(tcs[0].function.arguments) == {"job_id": 123}
def test_transform_parsed_response_does_not_rewrite_unmapped_names():
"""CRITICAL: a tool legitimately named `foo_bar` must NOT be rewritten
to `foo/bar` just because some other request had that pair. The reverse
map is per-request -- only entries we actually created go in it."""
config = AnthropicConfig()
raw_response = MagicMock()
raw_response.headers = {}
raw_response.status_code = 200
# Caller registered `foo_bar` (valid) and `foo/bar` (rewrites to foo_bar_2).
# The reverse map only contains the rewrite.
reverse_map = {"foo_bar_2": "foo/bar"}
completion_response = {
"id": "msg_x",
"model": "claude-sonnet-4",
"stop_reason": "tool_use",
"usage": {"input_tokens": 1, "output_tokens": 1},
"content": [
{
"type": "tool_use",
"id": "toolu_1",
"name": "foo_bar", # the legit one, NOT in reverse map
"input": {},
}
],
}
from litellm.types.utils import ModelResponse
model_response = ModelResponse()
out = config.transform_parsed_response(
completion_response=completion_response,
raw_response=raw_response,
model_response=model_response,
tool_name_reverse_map=reverse_map,
)
# Must come back as-is, not rewritten to "foo/bar".
assert out.choices[0].message.tool_calls[0].function.name == "foo_bar"
def test_transform_parsed_response_no_reverse_map_is_noop():
"""When no map is provided, tool name is passed through unchanged."""
config = AnthropicConfig()
raw_response = MagicMock()
raw_response.headers = {}
raw_response.status_code = 200
completion_response = {
"id": "msg_x",
"model": "claude-sonnet-4",
"stop_reason": "tool_use",
"usage": {"input_tokens": 1, "output_tokens": 1},
"content": [
{
"type": "tool_use",
"id": "toolu_1",
"name": "plain_tool",
"input": {},
}
],
}
from litellm.types.utils import ModelResponse
model_response = ModelResponse()
out = config.transform_parsed_response(
completion_response=completion_response,
raw_response=raw_response,
model_response=model_response,
)
assert out.choices[0].message.tool_calls[0].function.name == "plain_tool"
def test_streaming_iterator_reverse_maps_tool_use_name():
"""Streaming `content_block_start` for tool_use should reverse-map the name."""
from litellm.llms.anthropic.chat.handler import ModelResponseIterator
iterator = ModelResponseIterator(
streaming_response=iter([]),
sync_stream=True,
tool_name_reverse_map={
"actions_download-job-logs-for-workflow-run": "actions/download-job-logs-for-workflow-run",
},
)
chunk = {
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "tool_use",
"id": "toolu_1",
"name": "actions_download-job-logs-for-workflow-run",
"input": {},
},
}
parsed = iterator.chunk_parser(chunk=chunk)
tool_calls = parsed.choices[0].delta.tool_calls
assert tool_calls is not None and len(tool_calls) == 1
assert (
tool_calls[0]["function"]["name"]
== "actions/download-job-logs-for-workflow-run"
)
def test_streaming_iterator_passthrough_when_name_not_in_map():
from litellm.llms.anthropic.chat.handler import ModelResponseIterator
iterator = ModelResponseIterator(
streaming_response=iter([]),
sync_stream=True,
tool_name_reverse_map=None,
)
chunk = {
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "tool_use",
"id": "toolu_1",
"name": "plain_tool",
"input": {},
},
}
parsed = iterator.chunk_parser(chunk=chunk)
tool_calls = parsed.choices[0].delta.tool_calls
assert tool_calls is not None and len(tool_calls) == 1
assert tool_calls[0]["function"]["name"] == "plain_tool"
# ---------------------------------------------------------------------------
# transform_request: end-to-end sanitization regression coverage
# ---------------------------------------------------------------------------
def _build_optional_params_for_tools(tools):
"""Run a tools list through ``map_openai_params`` to get the same shape
``transform_request`` will see from the router. Keeping this helper local
avoids duplicating the OpenAI->Anthropic param mapping in tests."""
config = AnthropicConfig()
optional_params: dict = {}
config.map_openai_params(
non_default_params={"tools": tools},
optional_params=optional_params,
model="claude-sonnet-4",
drop_params=False,
)
return optional_params
def test_transform_request_does_not_leak_internal_keys_into_body():
"""REGRESSION for "_anthropic_tool_name_forward_map: Extra inputs are not
permitted". The dict returned by ``transform_request`` is what becomes
the JSON body POSTed to Anthropic. It must contain ONLY documented
Anthropic Messages fields -- no LiteLLM coordination state."""
config = AnthropicConfig()
tools = [
{
"type": "function",
"function": {
"name": "github_openapi_mcp-actions/download-job-logs-for-workflow-run",
"description": "d",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "plain_tool",
"description": "d",
"parameters": {"type": "object", "properties": {}},
},
},
]
optional_params = _build_optional_params_for_tools(tools)
litellm_params: dict = {}
data = config.transform_request(
model="claude-sonnet-4",
messages=[{"role": "user", "content": "go"}],
optional_params=optional_params,
litellm_params=litellm_params,
headers={},
)
# Body must not contain any LiteLLM-internal keys.
for key in data.keys():
assert not key.startswith("_"), (
f"transformed request body leaked underscore-prefixed key {key!r}; "
f"Anthropic will reject this with 'Extra inputs are not permitted'. "
f"body keys: {list(data.keys())}"
)
# Tool names in the body match Anthropic's pattern.
import re as _re
for tool in data.get("tools", []):
name = tool.get("name")
assert isinstance(name, str)
assert _re.fullmatch(
r"[a-zA-Z0-9_-]{1,128}", name
), f"sanitized tool name {name!r} still violates Anthropic regex"
# Sent name for the bad tool is the disambiguated form, valid name passes through.
sent_names = {t["name"] for t in data["tools"]}
assert "github_openapi_mcp-actions_download-job-logs-for-workflow-run" in sent_names
assert "plain_tool" in sent_names
# Reverse map landed on litellm_params (NOT optional_params, NOT body).
rmap = litellm_params["_anthropic_tool_name_map"]
assert (
rmap["github_openapi_mcp-actions_download-job-logs-for-workflow-run"]
== "github_openapi_mcp-actions/download-job-logs-for-workflow-run"
)
# The legitimately-named tool is not in the reverse map -- it round-trips
# untouched on the response side.
assert "plain_tool" not in rmap
def test_transform_request_no_reverse_map_when_all_names_valid():
"""If every name is already valid, ``litellm_params`` stays clean
(no reverse map key) -- minimizes blast radius for the common case."""
config = AnthropicConfig()
tools = [
{
"type": "function",
"function": {
"name": "plain_tool",
"description": "d",
"parameters": {"type": "object", "properties": {}},
},
},
]
optional_params = _build_optional_params_for_tools(tools)
litellm_params: dict = {}
data = config.transform_request(
model="claude-sonnet-4",
messages=[{"role": "user", "content": "go"}],
optional_params=optional_params,
litellm_params=litellm_params,
headers={},
)
assert data["tools"][0]["name"] == "plain_tool"
assert "_anthropic_tool_name_map" not in litellm_params
def test_transform_request_sanitizes_tool_choice_named_tool():
"""``tool_choice={"type": "function", "function": {"name": "<bad/name>"}}``
must arrive at Anthropic as ``{"type": "tool", "name": "<sanitized>"}``,
matching the sanitized name in the tools array."""
config = AnthropicConfig()
tools = [
{
"type": "function",
"function": {
"name": "actions/download-job-logs-for-workflow-run",
"parameters": {"type": "object", "properties": {}},
},
}
]
optional_params = AnthropicConfig().map_openai_params(
non_default_params={
"tools": tools,
"tool_choice": {
"type": "function",
"function": {"name": "actions/download-job-logs-for-workflow-run"},
},
},
optional_params={},
model="claude-sonnet-4",
drop_params=False,
)
litellm_params: dict = {}
data = config.transform_request(
model="claude-sonnet-4",
messages=[{"role": "user", "content": "go"}],
optional_params=optional_params,
litellm_params=litellm_params,
headers={},
)
assert data["tool_choice"]["type"] == "tool"
assert data["tool_choice"]["name"] == "actions_download-job-logs-for-workflow-run"
assert data["tools"][0]["name"] == "actions_download-job-logs-for-workflow-run"
def test_transform_request_rewrites_tool_names_in_history():
"""Historical assistant messages with ``tool_calls`` referencing the bad
name must be rewritten to the sanitized form so Anthropic doesn't 400 on
``tool_use.name`` mismatching the (sanitized) tools array."""
config = AnthropicConfig()
tools = [
{
"type": "function",
"function": {
"name": "actions/download-job-logs-for-workflow-run",
"parameters": {"type": "object", "properties": {}},
},
}
]
optional_params = _build_optional_params_for_tools(tools)
messages = [
{"role": "user", "content": "logs please"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "toolu_old",
"type": "function",
"function": {
"name": "actions/download-job-logs-for-workflow-run",
"arguments": "{}",
},
}
],
},
{"role": "tool", "tool_call_id": "toolu_old", "content": "..."},
{"role": "user", "content": "again"},
]
litellm_params: dict = {}
data = config.transform_request(
model="claude-sonnet-4",
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
headers={},
)
# Find the assistant tool_use block in the Anthropic-shaped messages.
tool_use_names = []
for msg in data["messages"]:
content = msg.get("content")
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
tool_use_names.append(block.get("name"))
assert (
tool_use_names
), "expected at least one tool_use block in transformed messages"
for name in tool_use_names:
assert name == "actions_download-job-logs-for-workflow-run", (
f"history tool_use.name {name!r} not rewritten -- Anthropic will "
f"400 because it doesn't match the (sanitized) tools array"
)
def test_sanitize_tool_names_in_request_skips_hosted_tools():
"""Hosted tools (web_search, computer_*, code_execution, ...) own
Anthropic-reserved names. The sanitizer must not enumerate them as
``custom`` and must not rename them."""
optional_params = {
"tools": [
{"type": "web_search_20250305", "name": "web_search"},
{
"type": "custom",
"name": "actions/download-job-logs-for-workflow-run",
"input_schema": {"type": "object", "properties": {}},
},
],
}
forward, reverse = AnthropicConfig._sanitize_tool_names_in_request(optional_params)
# Only the custom tool was rewritten.
assert forward == {
"actions/download-job-logs-for-workflow-run": "actions_download-job-logs-for-workflow-run"
}
assert reverse == {
"actions_download-job-logs-for-workflow-run": "actions/download-job-logs-for-workflow-run"
}
# Hosted tool's name unchanged.
assert optional_params["tools"][0]["name"] == "web_search"
# Custom tool's name updated in place.
assert (
optional_params["tools"][1]["name"]
== "actions_download-job-logs-for-workflow-run"
)
def test_sanitize_tool_names_in_request_no_tools_is_noop():
"""Empty / missing tools must not error or pollute return."""
forward, reverse = AnthropicConfig._sanitize_tool_names_in_request({})
assert forward == {}
assert reverse == {}
forward, reverse = AnthropicConfig._sanitize_tool_names_in_request({"tools": []})
assert forward == {}
assert reverse == {}

View File

@ -339,6 +339,95 @@ class TestMessageSanitization:
assert result[0]["role"] == "user"
assert result[1]["role"] == "assistant"
def test_empty_string_content_sanitized_without_modify_params(self):
"""
Regression: An empty user message ({"role": "user", "content": ""}) must
be rewritten to a non-empty placeholder *before* it reaches Anthropic,
even when litellm.modify_params is False. Otherwise Anthropic returns:
"messages: text content blocks must be non-empty"
Reproduces a real failure from the pr-review agent (pydantic-ai).
"""
litellm.modify_params = False
messages = [
{"role": "user", "content": "First message"},
{"role": "user", "content": "please review this"},
{"role": "user", "content": ""},
]
result = anthropic_messages_pt(
messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic"
)
# All three user messages get merged into one user turn for Anthropic.
assert len(result) == 1
assert result[0]["role"] == "user"
text_blocks = [
b for b in result[0]["content"] if isinstance(b, dict) and b.get("type") == "text"
]
assert len(text_blocks) == 3
# No text block may be empty — that's the contract Anthropic enforces.
for block in text_blocks:
assert block["text"].strip() != ""
assert text_blocks[2]["text"] == (
"[System: Empty message content sanitised to satisfy protocol]"
)
def test_empty_text_block_in_list_content_sanitized(self):
"""
Same regression for the list-of-blocks form:
{"role": "user", "content": [{"type": "text", "text": ""}]}
Empty text *blocks* must be rewritten too, regardless of modify_params.
"""
litellm.modify_params = False
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "real content"},
{"type": "text", "text": ""},
{"type": "text", "text": " \n "},
],
},
]
result = anthropic_messages_pt(
messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic"
)
assert len(result) == 1
text_blocks = [
b for b in result[0]["content"] if isinstance(b, dict) and b.get("type") == "text"
]
assert len(text_blocks) == 3
assert text_blocks[0]["text"] == "real content"
for block in text_blocks[1:]:
assert block["text"].strip() != ""
def test_non_empty_content_unchanged_without_modify_params(self):
"""
Sanity check: when nothing is empty, the messages flow through unchanged
even with modify_params disabled.
"""
litellm.modify_params = False
messages = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there"},
{"role": "user", "content": "How are you?"},
]
result = anthropic_messages_pt(
messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic"
)
# Two user turns + one assistant turn (alternation preserved).
assert len(result) == 3
assert result[0]["content"][0]["text"] == "Hello"
assert result[1]["content"][0]["text"] == "Hi there"
assert result[2]["content"][0]["text"] == "How are you?"
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -868,3 +868,146 @@ class TestResolveOperationParams:
assert "per_page" in names
assert "sha" in names
assert len(names) == 4 # no duplicates
# ---------------------------------------------------------------------------
# Tool name sanitization for OpenAPI -> MCP
# Repro: GitHub's REST OpenAPI uses tag-namespaced operationIds like
# "actions/download-job-logs-for-workflow-run". Without sanitization the
# generated MCP tool name contains '/', which Anthropic/OpenAI/Bedrock all
# reject (^[a-zA-Z0-9_-]+$). This block guards the registration + preview
# paths against that.
# ---------------------------------------------------------------------------
class TestSanitizeOpenAPIToolName:
def test_replaces_slashes(self):
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
sanitize_openapi_tool_name,
)
assert (
sanitize_openapi_tool_name("actions/download-job-logs-for-workflow-run")
== "actions_download-job-logs-for-workflow-run"
)
def test_replaces_other_punctuation(self):
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
sanitize_openapi_tool_name,
)
assert sanitize_openapi_tool_name("foo.bar:baz qux") == "foo_bar_baz_qux"
def test_lowercases(self):
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
sanitize_openapi_tool_name,
)
assert sanitize_openapi_tool_name("Pulls/List-Files") == "pulls_list-files"
def test_already_valid_passes_through(self):
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
sanitize_openapi_tool_name,
)
assert sanitize_openapi_tool_name("plain-tool_name") == "plain-tool_name"
def test_empty_string(self):
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
sanitize_openapi_tool_name,
)
assert sanitize_openapi_tool_name("") == ""
def test_caps_at_128_chars(self):
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
sanitize_openapi_tool_name,
)
out = sanitize_openapi_tool_name("a/" * 200)
assert len(out) <= 128
class TestRegisterToolsFromOpenAPI:
"""Verify register_tools_from_openapi emits provider-safe tool names."""
def test_github_style_operation_ids_are_sanitized(self, monkeypatch):
import re
from litellm.proxy._experimental.mcp_server import openapi_to_mcp_generator
registered: list = []
def _capture(name, description, input_schema, handler): # noqa: ANN001
registered.append(name)
monkeypatch.setattr(
openapi_to_mcp_generator.global_mcp_tool_registry,
"register_tool",
_capture,
)
spec = {
"paths": {
"/repos/{owner}/{repo}/actions/jobs/{job_id}/logs": {
"get": {
"operationId": "actions/download-job-logs-for-workflow-run",
"summary": "Download job logs",
}
},
"/repos/{owner}/{repo}/pulls/{pull_number}/files": {
"get": {
"operationId": "pulls/list-files",
"summary": "List files",
}
},
}
}
openapi_to_mcp_generator.register_tools_from_openapi(
spec, base_url="https://api.example.com"
)
assert registered, "expected at least one registered tool"
anthropic_re = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
for name in registered:
assert anthropic_re.match(
name
), f"tool name {name!r} violates ^[a-zA-Z0-9_-]+$"
assert "actions_download-job-logs-for-workflow-run" in registered
assert "pulls_list-files" in registered
def test_missing_operation_id_uses_sanitized_method_path_fallback(
self, monkeypatch
):
import re
from litellm.proxy._experimental.mcp_server import openapi_to_mcp_generator
registered: list = []
def _capture(name, description, input_schema, handler): # noqa: ANN001
registered.append(name)
monkeypatch.setattr(
openapi_to_mcp_generator.global_mcp_tool_registry,
"register_tool",
_capture,
)
spec = {
"paths": {
"/foo/{bar}/baz": {
"get": {"summary": "no operationId here"},
}
}
}
openapi_to_mcp_generator.register_tools_from_openapi(
spec, base_url="https://api.example.com"
)
assert registered
for name in registered:
assert re.match(
r"^[a-zA-Z0-9_-]+$", name
), f"fallback tool name {name!r} not sanitized"

View File

@ -1376,3 +1376,164 @@ class TestEndpointRoleChecks:
user_api_key_dict=user_key,
)
assert result["status"] == "ok"
class TestPreviewOpenAPITools:
"""Verify the OpenAPI preview endpoint emits provider-safe tool names.
Regression: GitHub's OpenAPI spec uses tag-namespaced operationIds like
`actions/download-job-logs-for-workflow-run` which contain '/'. The
preview must sanitize so what the dashboard shows matches what gets
registered (and what makes it past LLM provider tool-name validation).
"""
pytestmark = pytest.mark.asyncio
async def test_preview_sanitizes_slash_in_operation_id(self, monkeypatch):
import re
async def fake_load_spec(spec_path): # noqa: ANN001
return {
"paths": {
"/repos/{owner}/{repo}/actions/jobs/{job_id}/logs": {
"get": {
"operationId": (
"actions/download-job-logs-for-workflow-run"
),
"summary": "Download job logs",
}
},
"/repos/{owner}/{repo}/pulls/{pull_number}/files": {
"get": {
"operationId": "pulls/list-files",
"summary": "List files",
}
},
}
}
from litellm.proxy._experimental.mcp_server import (
openapi_to_mcp_generator,
)
monkeypatch.setattr(
openapi_to_mcp_generator,
"load_openapi_spec_async",
fake_load_spec,
raising=False,
)
payload = NewMCPServerRequest(
server_name="github_openapi_mcp",
spec_path="https://example.invalid/openapi.json",
transport="http",
)
request = _build_request()
from litellm.proxy._types import LitellmUserRoles
result = await rest_endpoints.test_tools_list(
request,
payload,
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert result.get("error") is None, result
names = [t["name"] for t in result["tools"]]
anthropic_re = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
for name in names:
assert anthropic_re.match(
name
), f"preview tool name {name!r} violates ^[a-zA-Z0-9_-]+$"
assert "actions_download-job-logs-for-workflow-run" in names
assert "pulls_list-files" in names
async def test_preview_method_order_matches_registration(self, monkeypatch):
"""Preview must iterate HTTP methods in the same order as
register_tools_from_openapi, otherwise collision-disambiguation
suffixes (_2, _3, ...) get assigned to different operations and the
dashboard shows names that differ from what's actually registered.
"""
from litellm.proxy._experimental.mcp_server import (
openapi_to_mcp_generator,
)
spec = {
"paths": {
"/items/{id}": {
"delete": {
"operationId": "items/delete",
"summary": "Delete item",
},
"patch": {
"operationId": "items.delete",
"summary": "Soft-delete item",
},
}
}
}
async def fake_load_spec(spec_path): # noqa: ANN001
return spec
monkeypatch.setattr(
openapi_to_mcp_generator,
"load_openapi_spec_async",
fake_load_spec,
raising=False,
)
payload = NewMCPServerRequest(
server_name="collision_openapi_mcp",
spec_path="https://example.invalid/openapi.json",
transport="http",
)
request = _build_request()
from litellm.proxy._types import LitellmUserRoles
result = await rest_endpoints.test_tools_list(
request,
payload,
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert result.get("error") is None, result
preview_summary_to_name = {t["description"]: t["name"] for t in result["tools"]}
registered_summary_to_name: dict = {}
def fake_create_tool_function(
path, method, operation, base_url
): # noqa: ANN001
def _f():
return None
return _f
monkeypatch.setattr(
openapi_to_mcp_generator,
"create_tool_function",
fake_create_tool_function,
)
class _StubRegistry:
def register_tool(
self, name, description, input_schema, handler
): # noqa: ANN001
registered_summary_to_name[description] = name
monkeypatch.setattr(
openapi_to_mcp_generator,
"global_mcp_tool_registry",
_StubRegistry(),
)
openapi_to_mcp_generator.register_tools_from_openapi(
spec, base_url="https://example.invalid"
)
assert preview_summary_to_name == registered_summary_to_name, (
f"preview {preview_summary_to_name} != "
f"registered {registered_summary_to_name} — method iteration "
"order is out of sync, so collision suffixes (_2, _3, ...) "
"land on different operations"
)

View File

@ -13242,6 +13242,21 @@
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
"integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}

View File

@ -1102,10 +1102,6 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
toolNameToDescription={toolNameToDescription}
onToolNameToDisplayNameChange={setToolNameToDisplayName}
onToolNameToDescriptionChange={setToolNameToDescription}
externalTools={tools}
externalIsLoading={isLoadingTools}
externalError={toolsError}
externalCanFetch={!!mcpServer.server_id}
/>
</div>