Skip to content

Instantly share code, notes, and snippets.

@Deviad
Last active February 14, 2026 15:19
Show Gist options
  • Select an option

  • Save Deviad/d8a14c7823e5241bffe728439297f63c to your computer and use it in GitHub Desktop.

Select an option

Save Deviad/d8a14c7823e5241bffe728439297f63c to your computer and use it in GitHub Desktop.
This is to make LLM Studio work with Qwen3 Coder Next Model

Tool Parameter Sanitizer — How It Works & How to Fix Other Tools

Overview

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.


Architecture

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's Metadata Leak Problem

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.


How to Fix a Broken Tool

Follow this pattern to add support for any new tool that arrives malformed.

Step 1: Identify the Broken Tool

Check LiteLLM logs or add debug logging to see what the tool looks like after adapter conversion. Look for:

  • parameters.type is NOT "object"
  • parameters.properties is missing
  • Metadata keys leaked into parameters

Step 2: Add a Detection Function

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 False

What 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"

Step 3: Add a Fixed Tool Definition

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.

Step 4: Add the Early-Return in _sanitize_tools()

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 ...

Step 5: Test

  1. Restart the LiteLLM proxy (Docker container)
  2. Trigger the tool from Claude Code
  3. Check LiteLLM logs for your replacement message
  4. Verify the model calls the tool with proper arguments
  5. Verify Claude Code receives the tool call and executes it

Implemented Fixes

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.

web_search

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")

bash

Anthropic tool {"type": "bash_20250124", "name": "bash"}
After adapter parameters: {"type": "bash_20250124"}
Fix command: string (required)
Verified Qwen calls bash(command="ls -la")

text_editor

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")

code_execution

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\")")

web_fetch

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")

Call Types and Tool Sanitization

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.

Which call types carry tools?

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.

Why accept all call types?

The sanitizer safely handles all call types because:

  1. It checks data.get("tools") — if there are no tools, it does nothing
  2. LiteLLM calls async_pre_call_hook for every call type; the signature must accept them all
  3. Using the canonical CallTypesLiteral type (from litellm.types.utils) means new call types added by LiteLLM are automatically supported without editing the sanitizer

Keeping in sync

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.


Debugging Tips

  • See raw tools: Add print(f"DEBUG tools: {data.get('tools')}", file=sys.stderr) at the top of async_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" and parameters.properties as a dict
  • Name mapping: The adapter stores a tool_name_mapping for names truncated to 64 chars (OpenAI limit). If your tool name is >64 chars, check for truncation issues

File Reference

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
"""LiteLLM callback that fixes broken tool definitions before forwarding.
When Claude Code sends requests through LiteLLM to non-Anthropic models
(e.g. Qwen 3 Coder on LM Studio), the Anthropic-to-OpenAI adapter leaks
metadata into the ``parameters`` field of server-side tools that lack an
``input_schema``. LM Studio then rejects them with:
``Invalid discriminator value. Expected 'object'``.
This callback intercepts requests in ``async_pre_call_hook`` and applies
two layers of fixes:
1. **Server-side tool replacement** — Anthropic tools like ``web_search``,
``bash``, ``text_editor``, ``code_execution``, and ``web_fetch`` are
detected and replaced with properly formed OpenAI function definitions
containing the correct parameter schemas.
2. **Generic parameter sanitization** — Any remaining tool whose
``parameters`` is missing, not a dict, lacks ``properties``, or has
``type`` != ``"object"`` gets patched to satisfy LM Studio's Jinja
template requirements.
See TOOL_SANITIZER_GUIDE.md for architecture details and instructions
on adding fixes for new tools.
"""
from __future__ import annotations
import copy
import sys
from typing import Any, Dict, List, Union
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_logger import CustomLogger
from litellm.types.utils import CallTypesLiteral
print("DEBUG: Loading tool_param_sanitizer.py module", file=sys.stderr)
def _is_web_search_tool(tool: Dict[str, Any]) -> bool:
"""Detect web_search tool in any format (broken OpenAI function or Anthropic server-side).
After the Anthropic-to-OpenAI adapter runs, the server-side
``web_search_20250305`` tool becomes a malformed OpenAI function whose
``parameters`` contain leaked metadata (``type: "web_search_20250305"``,
``max_uses: 8``) instead of a proper JSON Schema. This helper catches
that case as well as the raw Anthropic format (if the hook fires before
the adapter) and the legacy ``WebSearch`` naming.
"""
fn = tool.get("function") or {}
fn_name = fn.get("name", "")
tool_type = tool.get("type", "")
tool_name = tool.get("name", "")
# OpenAI function format with a web_search-family name
if fn_name in ("web_search", "WebSearch"):
return True
# Anthropic server-side tool (before adapter conversion)
if tool_type.startswith("web_search_"):
return True
# Anthropic server-side (name + non-function type)
if tool_name == "web_search" and tool_type and tool_type != "function":
return True
return False
def _is_bash_tool(tool: Dict[str, Any]) -> bool:
"""Detect bash tool in any format (broken OpenAI function or Anthropic server-side)."""
fn = tool.get("function") or {}
fn_name = fn.get("name", "")
tool_type = tool.get("type", "")
tool_name = tool.get("name", "")
if fn_name == "bash":
return True
if tool_type.startswith("bash_"):
return True
if tool_name == "bash" and tool_type and tool_type != "function":
return True
return False
def _is_text_editor_tool(tool: Dict[str, Any]) -> bool:
"""Detect text_editor tool in any format.
The Anthropic text_editor tool may use different names across versions:
- ``text_editor_20250124``: name = ``str_replace_editor``
- ``text_editor_20250429`` / ``text_editor_20250728``: name = ``str_replace_based_edit_tool``
"""
fn = tool.get("function") or {}
fn_name = fn.get("name", "")
tool_type = tool.get("type", "")
tool_name = tool.get("name", "")
if fn_name in ("str_replace_editor", "str_replace_based_edit_tool", "text_editor"):
return True
if tool_type.startswith("text_editor_"):
return True
if tool_name in ("str_replace_editor", "str_replace_based_edit_tool") and tool_type and tool_type != "function":
return True
return False
def _is_code_execution_tool(tool: Dict[str, Any]) -> bool:
"""Detect code_execution tool in any format."""
fn = tool.get("function") or {}
fn_name = fn.get("name", "")
tool_type = tool.get("type", "")
tool_name = tool.get("name", "")
if fn_name == "code_execution":
return True
if tool_type.startswith("code_execution_"):
return True
if tool_name == "code_execution" and tool_type and tool_type != "function":
return True
return False
def _is_web_fetch_tool(tool: Dict[str, Any]) -> bool:
"""Detect web_fetch tool in any format."""
fn = tool.get("function") or {}
fn_name = fn.get("name", "")
tool_type = tool.get("type", "")
tool_name = tool.get("name", "")
if fn_name == "web_fetch":
return True
if tool_type.startswith("web_fetch_"):
return True
if tool_name == "web_fetch" and tool_type and tool_type != "function":
return True
return False
def _get_fixed_web_search_tool() -> Dict[str, Any]:
"""Return a properly formed ``web_search`` function tool.
The tool has a required ``query`` parameter so that the downstream model
(e.g. Qwen 3 Coder on LM Studio) knows it must supply a search query.
When the model calls ``web_search(query="…")``, the response flows back
to Claude Code which delegates the actual search to one of its
configured Anthropic models.
"""
return {
"type": "function",
"function": {
"name": "web_search",
"description": (
"Search the web for current information. Use this when you need "
"up-to-date data or answers that require recent information."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query to execute",
}
},
"required": ["query"],
},
},
}
def _get_fixed_bash_tool() -> Dict[str, Any]:
"""Return a properly formed ``bash`` function tool."""
return {
"type": "function",
"function": {
"name": "bash",
"description": "Execute a bash command and return the output.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute",
},
},
"required": ["command"],
},
},
}
def _get_fixed_text_editor_tool(original_name: str) -> Dict[str, Any]:
"""Return a properly formed text editor function tool.
Preserves the original tool name (``str_replace_editor`` or
``str_replace_based_edit_tool``) so the adapter can map it back correctly.
"""
return {
"type": "function",
"function": {
"name": original_name,
"description": (
"View or edit files. Supports commands: view, create, "
"str_replace, insert."
),
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"enum": ["view", "create", "str_replace", "insert"],
"description": "The operation to perform",
},
"path": {
"type": "string",
"description": "Absolute path to the file",
},
"file_text": {
"type": "string",
"description": "File content (for create command)",
},
"old_str": {
"type": "string",
"description": "String to find (for str_replace)",
},
"new_str": {
"type": "string",
"description": "Replacement string (for str_replace/insert)",
},
"insert_line": {
"type": "integer",
"description": "Line number (for insert command)",
},
"view_range": {
"type": "array",
"items": {"type": "integer"},
"description": "Start and end line numbers (for view)",
},
},
"required": ["command", "path"],
},
},
}
def _get_fixed_code_execution_tool() -> Dict[str, Any]:
"""Return a properly formed ``code_execution`` function tool."""
return {
"type": "function",
"function": {
"name": "code_execution",
"description": "Execute code in a sandboxed environment and return the output.",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "The code to execute",
},
"language": {
"type": "string",
"description": "The programming language",
},
},
"required": ["code"],
},
},
}
def _get_fixed_web_fetch_tool() -> Dict[str, Any]:
"""Return a properly formed ``web_fetch`` function tool."""
return {
"type": "function",
"function": {
"name": "web_fetch",
"description": "Fetch the contents of a URL and return the text.",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to fetch",
},
},
"required": ["url"],
},
},
}
def _resolve_text_editor_name(tool: Dict[str, Any]) -> str:
"""Extract the original tool name from a text_editor tool.
Different Anthropic versions use different names:
- ``text_editor_20250124``: ``str_replace_editor``
- ``text_editor_20250429+``: ``str_replace_based_edit_tool``
Falls back to ``str_replace_editor`` if not determinable.
"""
fn = tool.get("function") or {}
fn_name = fn.get("name", "")
if fn_name in ("str_replace_editor", "str_replace_based_edit_tool"):
return fn_name
tool_name = tool.get("name", "")
if tool_name in ("str_replace_editor", "str_replace_based_edit_tool"):
return tool_name
return "str_replace_editor"
def _sanitize_tools(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Fix broken tool definitions so downstream models can use them.
Processing order for each tool:
1. Check for known Anthropic server-side tools (web_search, bash,
text_editor, code_execution, web_fetch) and replace them with
properly formed OpenAI function definitions.
2. For all other tools, ensure ``parameters.type == "object"`` and
``parameters.properties`` exists (generic fix for LM Studio).
Mutates nothing — returns a new list with sanitized copies only where
needed. Tools that are already well-formed are passed through as-is.
"""
sanitized: List[Dict[str, Any]] = []
for tool in tools:
# ---- Anthropic server-side tool fixes ----
if _is_web_search_tool(tool):
sanitized.append(_get_fixed_web_search_tool())
verbose_proxy_logger.info(
"tool_param_sanitizer: replaced broken web_search with fixed definition"
)
continue
if _is_bash_tool(tool):
sanitized.append(_get_fixed_bash_tool())
verbose_proxy_logger.info(
"tool_param_sanitizer: replaced broken bash with fixed definition"
)
continue
if _is_text_editor_tool(tool):
name = _resolve_text_editor_name(tool)
sanitized.append(_get_fixed_text_editor_tool(name))
verbose_proxy_logger.info(
"tool_param_sanitizer: replaced broken text_editor (%s) with fixed definition",
name,
)
continue
if _is_code_execution_tool(tool):
sanitized.append(_get_fixed_code_execution_tool())
verbose_proxy_logger.info(
"tool_param_sanitizer: replaced broken code_execution with fixed definition"
)
continue
if _is_web_fetch_tool(tool):
sanitized.append(_get_fixed_web_fetch_tool())
verbose_proxy_logger.info(
"tool_param_sanitizer: replaced broken web_fetch with fixed definition"
)
continue
# ---- End server-side tool fixes ----
fn = tool.get("function") or {}
params = fn.get("parameters")
needs_fix = False
if params is None:
needs_fix = True
elif not isinstance(params, dict):
needs_fix = True
elif "properties" not in params:
needs_fix = True
elif params.get("type") != "object":
needs_fix = True
if not needs_fix:
sanitized.append(tool)
continue
# Deep-copy only the tools we need to modify
tool = copy.deepcopy(tool)
fn = tool.setdefault("function", {})
if params is None or not isinstance(params, dict):
fn["parameters"] = {
"type": "object",
"properties": {},
}
else:
params.setdefault("type", "object")
params.setdefault("properties", {})
fn["parameters"] = params
verbose_proxy_logger.info(
"tool_param_sanitizer: fixed tool '%s' — added missing parameters/properties",
fn.get("name", "<unknown>"),
)
sanitized.append(tool)
return sanitized
class ToolParamSanitizer(CustomLogger):
"""Pre-call hook that fixes Anthropic server-side tools and normalizes parameter schemas.
Registered in ``config.yaml`` under ``litellm_settings.callbacks``.
Fires for all call types but only acts when ``data["tools"]`` is present.
"""
async def async_pre_call_hook(
self,
user_api_key_dict: Any,
cache: Any,
data: dict,
call_type: CallTypesLiteral,
) -> Union[Exception, str, dict, None]:
"""Replace broken Anthropic tools and fix malformed parameters before forwarding."""
tools = data.get("tools")
if tools and isinstance(tools, list):
data["tools"] = _sanitize_tools(tools)
return data
# Module-level instance — referenced in config.yaml callbacks list
tool_param_sanitizer = ToolParamSanitizer()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment