When Claude Code sends requests through LiteLLM to non-Anthropic models (e.g. Qwen 3 Coder on LM Studio), Anthropic-specific tool definitions must be converted to OpenAI function-calling format. This conversion can produce malformed tools that the downstream model cannot use.
tool_param_sanitizer.py is a LiteLLM CustomLogger callback that intercepts requests in async_pre_call_hook and fixes tool definitions before they reach the model.
Claude Code (Anthropic SDK)
│
│ Anthropic-format request
│ tools: [{ type: "web_search_20250305", name: "web_search", ... }]
▼
LiteLLM Proxy (/v1/messages)
│
│ Detects non-Anthropic model → uses adapter
▼
Anthropic-to-OpenAI Adapter (adapters/transformation.py)
│
│ translate_anthropic_tools_to_openai():
│ - Takes tool["name"] as function name
│ - Takes tool["input_schema"] as function parameters (if present)
│ - Dumps ALL OTHER keys into parameters (metadata leak!)
▼
tool_param_sanitizer (async_pre_call_hook)
│
│ Detects and fixes broken tools
▼
LM Studio / Qwen (OpenAI-compatible API)
The adapter at litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py:701 converts Anthropic tools to OpenAI format. Its logic for extra keys is:
for k, v in tool.items():
if k not in mapped_tool_params: # mapped = ["name", "input_schema", "description", "cache_control"]
function_chunk.setdefault("parameters", {}).update({k: v})This means any key not in the mapped list gets dumped into parameters. For server-side tools (which have type, max_uses, etc. but no input_schema), this produces garbage parameters.
Example — web_search_20250305:
| Anthropic format (input) | OpenAI format (after adapter) |
|---|---|
{"type": "web_search_20250305", "name": "web_search", "max_uses": 8} |
{"type": "function", "function": {"name": "web_search", "parameters": {"type": "web_search_20250305", "max_uses": 8}}} |
The parameters.type is "web_search_20250305" instead of "object", and there are no properties — the model has no idea what arguments to supply.
Follow this pattern to add support for any new tool that arrives malformed.
Check LiteLLM logs or add debug logging to see what the tool looks like after adapter conversion. Look for:
parameters.typeis NOT"object"parameters.propertiesis missing- Metadata keys leaked into
parameters
Create an _is_<tool_name>_tool() function that catches the tool in all its possible forms:
def _is_<tool_name>_tool(tool: Dict[str, Any]) -> bool:
"""Detect <tool_name> tool in any format."""
fn = tool.get("function") or {}
fn_name = fn.get("name", "")
tool_type = tool.get("type", "")
tool_name = tool.get("name", "")
# After adapter: OpenAI function format
if fn_name == "<tool_name>":
return True
# Before adapter: Anthropic server-side tool
if tool_type.startswith("<tool_type_prefix>"):
return True
# Anthropic format with name + non-function type
if tool_name == "<tool_name>" and tool_type and tool_type != "function":
return True
return FalseWhat to check:
| Format | Key to check | Example |
|---|---|---|
| OpenAI function (post-adapter) | tool["function"]["name"] |
"web_search" |
| Anthropic server-side (pre-adapter) | tool["type"] starts with prefix |
"web_search_20250305" |
| Anthropic with name field | tool["name"] + tool["type"] is not "function" |
name="web_search", type="web_search_20250305" |
Create a _get_fixed_<tool_name>_tool() function that returns the correct OpenAI function definition:
def _get_fixed_<tool_name>_tool() -> Dict[str, Any]:
"""Properly formed <tool_name> function tool."""
return {
"type": "function",
"function": {
"name": "<tool_name>",
"description": "<what the tool does>",
"parameters": {
"type": "object",
"properties": {
"<param1>": {
"type": "string",
"description": "<what param1 is>",
},
# ... more properties as needed
},
"required": ["<param1>"],
},
},
}Important: The name in the fixed tool MUST match what the original Anthropic tool uses (tool["name"]), because when the model calls this function, the response flows back through the adapter which maps it back to the Anthropic tool name. A mismatch would confuse Claude Code.
Add detection at the top of the loop, before the generic parameter checks:
def _sanitize_tools(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
sanitized: List[Dict[str, Any]] = []
for tool in tools:
# ---- ADD NEW TOOL FIXES HERE ----
if _is_<tool_name>_tool(tool):
sanitized.append(_get_fixed_<tool_name>_tool())
verbose_proxy_logger.info(
"tool_param_sanitizer: replaced broken <tool_name> with fixed definition"
)
continue
# ---- END NEW TOOL FIXES ----
# ... existing generic parameter sanitization below ...- Restart the LiteLLM proxy (Docker container)
- Trigger the tool from Claude Code
- Check LiteLLM logs for your replacement message
- Verify the model calls the tool with proper arguments
- Verify Claude Code receives the tool call and executes it
All Anthropic server-side tools were tested against LM Studio (Qwen 3 Coder Next). Every one fails with the same error when sent in broken format:
Invalid discriminator value. Expected 'object'
All were fixed and verified to produce correct tool calls when given proper OpenAI function definitions.
| Anthropic tool | {"type": "web_search_20250305", "name": "web_search", "max_uses": 8} |
| After adapter | parameters: {"type": "web_search_20250305", "max_uses": 8} |
| Fix | query: string (required) |
| Verified | Qwen calls web_search(query="latest AI news") |
| Anthropic tool | {"type": "bash_20250124", "name": "bash"} |
| After adapter | parameters: {"type": "bash_20250124"} |
| Fix | command: string (required) |
| Verified | Qwen calls bash(command="ls -la") |
| Anthropic tool | {"type": "text_editor_20250124", "name": "str_replace_editor"} |
| After adapter | parameters: {"type": "text_editor_20250124"} |
| Fix | command: enum[view,create,str_replace,insert] + path: string (required), plus optional file_text, old_str, new_str, insert_line, view_range |
| Name variants | str_replace_editor (v20250124), str_replace_based_edit_tool (v20250429+) — name is preserved dynamically |
| Verified | Qwen calls text_editor(command="view", path="/tmp/test.txt") |
| Anthropic tool | {"type": "code_execution_20250522", "name": "code_execution"} |
| After adapter | parameters: {"type": "code_execution_20250522"} |
| Fix | code: string (required), language: string (optional) |
| Verified | Qwen calls code_execution(code="print(\"hello\")") |
| Anthropic tool | {"type": "web_fetch_20250910", "name": "web_fetch", "max_uses": 5} |
| After adapter | parameters: {"type": "web_fetch_20250910", "max_uses": 5} |
| Fix | url: string (required) |
| Verified | Qwen calls web_fetch(url="https://example.com") |
The async_pre_call_hook receives a call_type parameter indicating the type of LiteLLM API call. The sanitizer uses CallTypesLiteral imported from litellm.types.utils to stay in sync with LiteLLM automatically — no need to maintain a duplicate list.
Only a few call types can contain tool definitions in data["tools"]:
| Call type | Has tools? | Notes |
|---|---|---|
completion / acompletion |
Yes | Standard OpenAI-format chat completion |
anthropic_messages |
Yes | Anthropic /v1/messages pass-through |
responses / aresponses |
Yes | OpenAI Responses API |
| All others | No | Embeddings, images, audio, search, batches, etc. |
The sanitizer safely handles all call types because:
- It checks
data.get("tools")— if there are no tools, it does nothing - LiteLLM calls
async_pre_call_hookfor every call type; the signature must accept them all - Using the canonical
CallTypesLiteraltype (fromlitellm.types.utils) means new call types added by LiteLLM are automatically supported without editing the sanitizer
from litellm.types.utils import CallTypesLiteral
# In the hook signature:
async def async_pre_call_hook(self, ..., call_type: CallTypesLiteral, ...) -> ...:If LiteLLM adds new call types, this import picks them up automatically. Never duplicate the Literal inline — it becomes stale as LiteLLM evolves.
- See raw tools: Add
print(f"DEBUG tools: {data.get('tools')}", file=sys.stderr)at the top ofasync_pre_call_hook - Check adapter output: Search LiteLLM logs for
translate_anthropic_tools_to_openai - Verify parameters schema: A well-formed tool MUST have
parameters.type == "object"andparameters.propertiesas a dict - Name mapping: The adapter stores a
tool_name_mappingfor names truncated to 64 chars (OpenAI limit). If your tool name is >64 chars, check for truncation issues
| File | Purpose |
|---|---|
litellm_config/tool_param_sanitizer.py |
The sanitizer callback (this is what you edit) |
litellm_config/config.yaml |
Registers the callback under litellm_settings.callbacks |
litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py |
The adapter that causes the metadata leak (LiteLLM core, don't edit) |
litellm/types/llms/anthropic.py:623 |
ANTHROPIC_HOSTED_TOOLS enum listing all server-side tools |