From 464e87299a21307b183f6d537e948013fcb4b522 Mon Sep 17 00:00:00 2001 From: xc <92363585+ace-xc@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:26:11 +0800 Subject: [PATCH] fix(api): convert Claude Code tool flows for Qwen chat templates (#5037) Builds on #5049, which folds the Anthropic system prompt. Claude Code >= 2.1.154 also sends tool history as Anthropic content blocks and tool definitions carrying `input_schema`. These reach the Qwen/Qwen3 chat template unconverted and crash it with `TypeError: Can only get item pairs from a mapping` when a tool call's `arguments` is a JSON string but the template iterates `tool_call.arguments | items`. - Convert Anthropic content blocks into OpenAI-style messages: tool_use -> assistant tool_calls, tool_result -> tool role, text/image flattened (system folding stays in _normalize_anthropic_messages from #5049) - Convert Anthropic tools / tool_choice into OpenAI-compatible shapes - Normalize JSON-string tool-call arguments and tool parameters into mappings only for templates that iterate them with |items / .items(), leaving protocol-facing OpenAI responses unchanged - Accept "tool" as a valid final message role - Add tests for the conversion helpers and the template normalization --- xinference/api/restful_api.py | 242 +++++++++++++++++++- xinference/core/tests/test_restful_api.py | 213 ++++++++++++++++++ xinference/model/llm/tests/test_utils.py | 258 ++++++++++++++++++++++ xinference/model/llm/utils.py | 213 +++++++++++++++++- 4 files changed, 913 insertions(+), 13 deletions(-) diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index dc12bcfbd3..cce6758486 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -23,7 +23,7 @@ import time import uuid import warnings -from typing import Any, List, Optional, Union, get_type_hints +from typing import Any, Dict, List, Optional, Union, get_type_hints import gradio as gr import xoscar as xo @@ -1290,6 +1290,224 @@ def _collect(content: Any, parts: List[str]) -> None: normalized.insert(0, {"role": "system", "content": "\n".join(system_parts)}) return normalized + @staticmethod + def _extract_text_from_anthropic_content(content: Any) -> str: + """ + Extract plain text from an Anthropic ``content`` field. + + The content may be a plain string or a list of content blocks such as + ``[{"type": "text", "text": "..."}]``. Only text is collected; non-text + blocks (images, etc.) are ignored, which is sufficient for tool results. + """ + if isinstance(content, str): + return content + if isinstance(content, list): + texts = [] + for block in content: + if isinstance(block, dict): + text = block.get("text") + if isinstance(text, str) and text: + texts.append(text) + elif isinstance(block, str) and block: + texts.append(block) + return "\n".join(texts) + return "" + + def _convert_anthropic_messages_to_openai(self, messages: List[dict]) -> List[dict]: + """ + Convert Anthropic ``user`` / ``assistant`` content blocks into the + OpenAI-style messages that Xinference backends (and their chat + templates) expect. + + The top-level ``system`` prompt and any inline ``role: system`` + messages are already folded into a single leading system message by + :meth:`_normalize_anthropic_messages`, so system and plain-string turns + pass through untouched here. This pass only rewrites the content-block + turns produced by Claude Code >= 2.1.154: + + * ``user`` / ``assistant`` text blocks are flattened to a string; + * ``tool_use`` blocks become assistant ``tool_calls``; + * ``tool_result`` blocks become standalone ``tool`` role messages; + * ``image`` blocks become OpenAI ``image_url`` parts. + + Without this conversion the raw Anthropic blocks reach the backend chat + template and break it (e.g. a list ``content`` triggers + ``'list' object has no attribute 'startswith'`` while rendering the + Jinja template). + """ + converted: List[dict] = [] + tool_call_names: Dict[str, str] = {} + for msg in messages or []: + content = msg.get("content") + # System (already folded) and plain-string turns pass through. + if not isinstance(content, list): + converted.append(msg) + continue + if msg.get("role") == "assistant": + converted_msg = self._convert_anthropic_assistant_message(content) + for tool_call in converted_msg.get("tool_calls", []): + if not isinstance(tool_call, dict): + continue + tool_call_id = tool_call.get("id") + function = tool_call.get("function") + if ( + isinstance(tool_call_id, str) + and isinstance(function, dict) + and isinstance(function.get("name"), str) + ): + tool_call_names[tool_call_id] = function["name"] + converted.append(converted_msg) + elif msg.get("role") == "user": + converted.extend( + self._convert_anthropic_user_message(content, tool_call_names) + ) + else: + converted.append(msg) + return converted + + def _convert_anthropic_assistant_message(self, content: list) -> dict: + """Convert an assistant message whose content is a list of blocks.""" + text_parts: List[str] = [] + tool_calls: List[dict] = [] + for block in content: + if not isinstance(block, dict): + continue + block_type = block.get("type") + if block_type == "text": + text = block.get("text") + if isinstance(text, str) and text: + text_parts.append(text) + elif block_type == "tool_use": + tool_calls.append( + { + "id": block.get("id", ""), + "type": "function", + "function": { + "name": block.get("name", ""), + "arguments": json.dumps( + block.get("input", {}), ensure_ascii=False + ), + }, + } + ) + # other blocks (e.g. ``thinking``) are dropped for backend prompts + new_msg: dict = { + "role": "assistant", + "content": "\n".join(text_parts), + } + if tool_calls: + new_msg["tool_calls"] = tool_calls + return new_msg + + def _convert_anthropic_user_message( + self, content: list, tool_call_names: Optional[Dict[str, str]] = None + ) -> List[dict]: + """ + Convert a user message whose content is a list of blocks. + + ``tool_result`` blocks become standalone ``tool`` messages (emitted + first, so they directly follow the assistant ``tool_calls``); remaining + text/image blocks become a single ``user`` message. + """ + tool_messages: List[dict] = [] + text_parts: List[str] = [] + image_parts: List[dict] = [] + for block in content: + if not isinstance(block, dict): + continue + block_type = block.get("type") + if block_type == "text": + text = block.get("text") + if isinstance(text, str) and text: + text_parts.append(text) + elif block_type == "tool_result": + tool_call_id = block.get("tool_use_id", "") + tool_message = { + "role": "tool", + "tool_call_id": tool_call_id, + "content": self._extract_text_from_anthropic_content( + block.get("content") + ), + } + if ( + isinstance(tool_call_id, str) + and tool_call_names + and tool_call_id in tool_call_names + ): + tool_message["name"] = tool_call_names[tool_call_id] + tool_messages.append(tool_message) + elif block_type == "image": + source = block.get("source", {}) + if isinstance(source, dict): + if source.get("type") == "base64": + url = ( + f"data:{source.get('media_type', '')};" + f"base64,{source.get('data', '')}" + ) + image_parts.append( + {"type": "image_url", "image_url": {"url": url}} + ) + elif source.get("type") == "url" and source.get("url"): + image_parts.append( + { + "type": "image_url", + "image_url": {"url": source["url"]}, + } + ) + + result: List[dict] = list(tool_messages) + if image_parts: + # Multimodal turn: keep OpenAI content-part list (text + images). + parts: List[dict] = [ + {"type": "text", "text": t} for t in text_parts + ] + image_parts + result.append({"role": "user", "content": parts}) + elif text_parts: + result.append({"role": "user", "content": "\n".join(text_parts)}) + return result + + @staticmethod + def _convert_anthropic_tools_to_openai(tools: list) -> List[dict]: + """Convert Anthropic tool definitions to OpenAI ``function`` tools.""" + openai_tools: List[dict] = [] + for tool in tools: + if not isinstance(tool, dict): + continue + # Already in OpenAI shape -> keep as-is. + if tool.get("type") == "function" and "function" in tool: + openai_tools.append(tool) + continue + openai_tools.append( + { + "type": "function", + "function": { + "name": tool.get("name", ""), + "description": tool.get("description", ""), + "parameters": tool.get("input_schema", {}) or {}, + }, + } + ) + return openai_tools + + @staticmethod + def _convert_anthropic_tool_choice(tool_choice: Any) -> Any: + """Convert an Anthropic ``tool_choice`` to the OpenAI equivalent.""" + if not isinstance(tool_choice, dict): + return tool_choice + choice_type = tool_choice.get("type") + if choice_type == "auto": + return "auto" + if choice_type == "any": + return "required" + if choice_type == "none": + return "none" + if choice_type == "tool" and tool_choice.get("name"): + return { + "type": "function", + "function": {"name": tool_choice["name"]}, + } + return tool_choice + async def create_message(self, request: Request) -> Response: raw_body = await request.json() body = CreateMessage.parse_obj(raw_body) @@ -1322,18 +1540,32 @@ async def create_message(self, request: Request) -> Response: messages = self._normalize_anthropic_messages(raw_body.get("system"), messages) raw_kwargs.pop("system", None) - if not messages or messages[-1].get("role") not in ["user", "assistant"]: + # Convert Anthropic content blocks (tool_use / tool_result / text / + # image) into OpenAI-style messages. System folding is handled above, so + # this only rewrites the block turns Claude Code >= 2.1.154 sends; + # otherwise the raw blocks reach the chat template and break it. + messages = self._convert_anthropic_messages_to_openai(messages) + + # A converted ``tool_result`` turn ends in a ``tool`` message, which is + # a valid last role (mirrors the OpenAI chat-completions endpoint). + if not messages or messages[-1].get("role") not in [ + "user", + "assistant", + "tool", + ]: raise HTTPException( status_code=400, detail="Invalid input. Please specify the prompt." ) - # Handle tools parameter + # Handle tools parameter (Anthropic ``input_schema`` -> OpenAI function) if hasattr(body, "tools") and body.tools: - kwargs["tools"] = list(body.tools) + kwargs["tools"] = self._convert_anthropic_tools_to_openai(list(body.tools)) # Handle tool_choice parameter if hasattr(body, "tool_choice") and body.tool_choice: - kwargs["tool_choice"] = body.tool_choice + kwargs["tool_choice"] = self._convert_anthropic_tool_choice( + body.tool_choice + ) # Get model mapping try: diff --git a/xinference/core/tests/test_restful_api.py b/xinference/core/tests/test_restful_api.py index 49159341ca..71f27c433d 100644 --- a/xinference/core/tests/test_restful_api.py +++ b/xinference/core/tests/test_restful_api.py @@ -1962,3 +1962,216 @@ async def test_anthropic_models_include_original_fields( assert data["model_format"] == "pytorch" assert data["model_size_in_billions"] == 7 assert data["quantization"] == "none" + + +# --------------------------------------------------------------------------- +# Anthropic (Claude Code) tool-flow conversion -- _convert_anthropic_* helpers. +# System folding is covered by the _normalize_anthropic_messages tests above. +# --------------------------------------------------------------------------- + + +def test_convert_anthropic_messages_to_openai_plain_unchanged(anthropic_api): + """A plain user/assistant conversation keeps its turns unchanged.""" + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] + result = anthropic_api._convert_anthropic_messages_to_openai(messages) + assert result == messages + + +def test_convert_anthropic_text_blocks_flattened(anthropic_api): + """List content of text blocks is flattened to a string.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "line1"}, + {"type": "text", "text": "line2"}, + ], + } + ] + result = anthropic_api._convert_anthropic_messages_to_openai(messages) + assert result == [{"role": "user", "content": "line1\nline2"}] + + +def test_convert_anthropic_tool_use_to_tool_calls(anthropic_api): + """Assistant ``tool_use`` blocks become OpenAI ``tool_calls``.""" + messages = [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "let me check"}, + { + "type": "tool_use", + "id": "toolu_1", + "name": "get_weather", + "input": {"city": "Beijing"}, + }, + ], + } + ] + result = anthropic_api._convert_anthropic_messages_to_openai(messages) + assert len(result) == 1 + msg = result[0] + assert msg["role"] == "assistant" + assert msg["content"] == "let me check" + assert msg["tool_calls"] == [ + { + "id": "toolu_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "Beijing"}', + }, + } + ] + + +def test_convert_anthropic_tool_result_to_tool_message(anthropic_api): + """User ``tool_result`` blocks become standalone ``tool`` messages.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "toolu_1", "content": "sunny"}, + ], + } + ] + result = anthropic_api._convert_anthropic_messages_to_openai(messages) + assert result == [{"role": "tool", "tool_call_id": "toolu_1", "content": "sunny"}] + + +def test_convert_anthropic_tool_result_with_block_content(anthropic_api): + """A ``tool_result`` whose content is a block list is flattened; user text follows.""" + messages = [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": [{"type": "text", "text": "sunny"}], + }, + {"type": "text", "text": "thanks"}, + ], + } + ] + result = anthropic_api._convert_anthropic_messages_to_openai(messages) + assert result == [ + {"role": "tool", "tool_call_id": "toolu_1", "content": "sunny"}, + {"role": "user", "content": "thanks"}, + ] + + +def test_convert_anthropic_full_agentic_roundtrip(anthropic_api): + """A realistic Claude Code agentic exchange converts to OpenAI shape.""" + messages = [ + {"role": "user", "content": [{"type": "text", "text": "weather?"}]}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "toolu_1", "name": "wx", "input": {}}, + ], + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "toolu_1", "content": "sunny"}, + ], + }, + ] + result = anthropic_api._convert_anthropic_messages_to_openai(messages) + assert [m["role"] for m in result] == ["user", "assistant", "tool"] + assert result[1]["tool_calls"][0]["id"] == "toolu_1" + assert result[2]["tool_call_id"] == "toolu_1" + assert result[2]["name"] == "wx" + + +def test_convert_anthropic_image_block(anthropic_api): + """Anthropic base64 ``image`` blocks become OpenAI ``image_url`` parts.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "what is this?"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "QUJD", + }, + }, + ], + } + ] + result = anthropic_api._convert_anthropic_messages_to_openai(messages) + assert result[0]["role"] == "user" + parts = result[0]["content"] + assert {"type": "text", "text": "what is this?"} in parts + assert { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,QUJD"}, + } in parts + + +def test_convert_anthropic_tools_to_openai(anthropic_api): + """Anthropic tool defs (``input_schema``) become OpenAI ``function`` tools.""" + tools = [ + { + "name": "get_weather", + "description": "Get weather", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + } + ] + result = anthropic_api._convert_anthropic_tools_to_openai(tools) + assert result == [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + }, + } + ] + # already-OpenAI tools pass through untouched + openai_tool = {"type": "function", "function": {"name": "x", "parameters": {}}} + assert anthropic_api._convert_anthropic_tools_to_openai([openai_tool]) == [ + openai_tool + ] + + +def test_convert_anthropic_tool_choice(anthropic_api): + """Anthropic ``tool_choice`` maps to the OpenAI equivalent.""" + assert anthropic_api._convert_anthropic_tool_choice({"type": "auto"}) == "auto" + assert anthropic_api._convert_anthropic_tool_choice({"type": "any"}) == "required" + assert anthropic_api._convert_anthropic_tool_choice({"type": "none"}) == "none" + assert anthropic_api._convert_anthropic_tool_choice( + {"type": "tool", "name": "get_weather"} + ) == {"type": "function", "function": {"name": "get_weather"}} + + +def test_extract_text_from_anthropic_content(anthropic_api): + """Text extraction handles strings, block lists, and ignores non-text.""" + assert anthropic_api._extract_text_from_anthropic_content("hello") == "hello" + assert ( + anthropic_api._extract_text_from_anthropic_content( + [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}] + ) + == "a\nb" + ) + # non-text blocks (e.g. images) are ignored + assert ( + anthropic_api._extract_text_from_anthropic_content( + [{"type": "image", "source": {}}] + ) + == "" + ) diff --git a/xinference/model/llm/tests/test_utils.py b/xinference/model/llm/tests/test_utils.py index 8d76f35008..601d7df2da 100644 --- a/xinference/model/llm/tests/test_utils.py +++ b/xinference/model/llm/tests/test_utils.py @@ -268,6 +268,264 @@ def test_transform_messages_rejects_invalid_tool_call_arguments_json(): mixin._transform_messages(messages) +def test_normalize_tool_call_arguments_for_mapping_template(): + messages = [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "view_file", + "arguments": '{"file_path": "README*"}', + }, + }, + { + "id": "call_2", + "type": "function", + "function": { + "name": "already_mapping", + "arguments": {"path": "README.md"}, + }, + }, + { + "id": "call_3", + "type": "function", + "function": { + "name": "bad_args", + "arguments": "not json", + }, + }, + ], + } + ] + template = ( + "{% for args_name, args_value in tool_call.arguments|items %}{% endfor %}" + ) + + normalized = ChatModelMixin._normalize_messages_for_chat_template( + messages, template + ) + + assert normalized[0]["tool_calls"][0]["function"]["arguments"] == { + "file_path": "README*" + } + assert normalized[0]["tool_calls"][1]["function"]["arguments"] == { + "path": "README.md" + } + assert normalized[0]["tool_calls"][2]["function"]["arguments"] == {} + # Keep protocol-facing input untouched; normalization is for template render only. + assert messages[0]["tool_calls"][0]["function"]["arguments"] == ( + '{"file_path": "README*"}' + ) + + +@pytest.mark.parametrize( + "template", + [ + "{% for name, value in tool_call.function.arguments|items %}{% endfor %}", + "{% for name, value in call.arguments.items() %}{% endfor %}", + ], +) +def test_normalize_tool_call_arguments_detects_template_aliases(template): + messages = [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "type": "function", + "function": { + "name": "view_file", + "arguments": '{"file_path": "README*"}', + }, + } + ], + } + ] + + normalized = ChatModelMixin._normalize_messages_for_chat_template( + messages, template + ) + + assert normalized[0]["tool_calls"][0]["function"]["arguments"] == { + "file_path": "README*" + } + + +def test_normalize_tool_call_arguments_skips_templates_without_mapping_iteration(): + messages = [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "type": "function", + "function": { + "name": "view_file", + "arguments": '{"file_path": "README*"}', + }, + } + ], + } + ] + template = "{{ tool_call.function.arguments }}" + + normalized = ChatModelMixin._normalize_messages_for_chat_template( + messages, template + ) + + assert normalized is messages + assert ( + normalized[0]["tool_calls"][0]["function"]["arguments"] + == '{"file_path": "README*"}' + ) + + +def test_normalize_tool_parameters_for_mapping_template(): + tools = [ + { + "type": "function", + "function": { + "name": "no_properties", + "description": "A tool without explicit properties", + "parameters": '{"type": "object"}', + }, + }, + { + "type": "function", + "function": { + "name": "properties_as_json", + "description": "A tool with JSON string properties", + "parameters": { + "type": "object", + "properties": '{"path": {"type": "string"}}', + }, + }, + }, + ] + template = "{% for name, fields in tool.parameters.properties|items %}{% endfor %}" + + normalized = ChatModelMixin._normalize_tools_for_chat_template(tools, template) + + assert normalized[0]["function"]["parameters"] == { + "type": "object", + "properties": {}, + } + assert normalized[1]["function"]["parameters"]["properties"] == { + "path": {"type": "string"} + } + assert tools[0]["function"]["parameters"] == '{"type": "object"}' + + +def test_normalize_tool_parameters_recursively_for_mapping_template(): + tools = [ + { + "type": "function", + "function": { + "name": "nested_schema", + "description": "A tool with nested JSON string properties", + "parameters": { + "type": "object", + "properties": { + "payload": { + "type": "object", + "properties": '{"path": {"type": "string"}}', + } + }, + }, + }, + } + ] + template = """ +{%- for tool in tools %} + {%- if tool.function is defined %} + {%- set tool = tool.function %} + {%- endif %} + {%- for name, fields in tool.parameters.properties|items %} + {%- for child_name, child_fields in fields.properties|items %} + {{- name + "." + child_name + ":" + child_fields.type }} + {%- endfor %} + {%- endfor %} +{%- endfor %} +""" + + normalized = ChatModelMixin._normalize_tools_for_chat_template(tools, template) + + assert normalized[0]["function"]["parameters"]["properties"]["payload"][ + "properties" + ] == {"path": {"type": "string"}} + assert normalized[0]["parameters"] == normalized[0]["function"]["parameters"] + + +@pytest.mark.parametrize( + "template", + [ + "{% for name, fields in tool.function.parameters.properties|items %}{% endfor %}", + "{% for name, fields in parameter.properties.items() %}{% endfor %}", + ], +) +def test_normalize_tool_parameters_detects_template_aliases(template): + tools = [ + { + "type": "function", + "function": { + "name": "properties_as_json", + "description": "A tool with JSON string properties", + "parameters": { + "type": "object", + "properties": '{"path": {"type": "string"}}', + }, + }, + } + ] + + normalized = ChatModelMixin._normalize_tools_for_chat_template(tools, template) + + assert normalized[0]["function"]["parameters"]["properties"] == { + "path": {"type": "string"} + } + + +def test_get_full_context_renders_string_tool_arguments_for_mapping_template(): + mixin = ChatModelMixin() + mixin.model_family = SimpleNamespace(model_name="qwen3", model_ability=["chat"]) + template = """ +{%- for message in messages %} + {%- if message.tool_calls %} + {%- for tool_call in message.tool_calls %} + {%- set tool_call = tool_call.function %} + {%- for args_name, args_value in tool_call.arguments|items %} + {{- args_name + "=" + args_value }} + {%- endfor %} + {%- endfor %} + {%- endif %} +{%- endfor %} +""" + messages = [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "view_file", + "arguments": '{"file_path": "README*"}', + }, + } + ], + } + ] + + rendered = mixin.get_full_context(messages, template) + + assert rendered == "file_path=README*" + + def test_deepseekv4_get_full_context_attaches_tools(tmp_path): encoding_dir = tmp_path / "encoding" encoding_dir.mkdir() diff --git a/xinference/model/llm/utils.py b/xinference/model/llm/utils.py index 8c8e85eb92..371bffc6f8 100644 --- a/xinference/model/llm/utils.py +++ b/xinference/model/llm/utils.py @@ -164,6 +164,17 @@ def get_full_context( tokenize=False, **kwargs, ): + template_for_normalization: Optional[str] = chat_template + if not template_for_normalization and tokenizer is not None: + template_for_normalization = getattr(tokenizer, "chat_template", None) + if template_for_normalization: + messages = self._normalize_messages_for_chat_template( + messages, template_for_normalization + ) + if kwargs.get("tools"): + kwargs["tools"] = self._normalize_tools_for_chat_template( + kwargs["tools"], template_for_normalization + ) if ( "vision" not in self.model_family.model_ability and "audio" not in self.model_family.model_ability @@ -256,6 +267,184 @@ def _get_chat_template_kwargs_from_generate_config( return {"enable_thinking": reasoning_parser.enable_thinking} return None + @staticmethod + def _chat_template_needs_mapping_tool_arguments(chat_template: str) -> bool: + compact_template = "".join(chat_template.split()) + return ( + "arguments|items" in compact_template + or "arguments.items()" in compact_template + or ( + ( + "_args=tool_call.arguments" in compact_template + or "_args=tc.arguments" in compact_template + ) + and "_args.items()" in compact_template + ) + ) + + @staticmethod + def _chat_template_needs_mapping_tool_parameters(chat_template: str) -> bool: + compact_template = "".join(chat_template.split()) + return ( + "properties|items" in compact_template + or "properties.items()" in compact_template + or "parameters|items" in compact_template + or "parameters.items()" in compact_template + ) + + @staticmethod + def _json_string_to_mapping(value: Any) -> Optional[Dict[str, Any]]: + if isinstance(value, dict): + return value + if not isinstance(value, str): + return None + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return None + return parsed if isinstance(parsed, dict) else None + + @classmethod + def _coerce_tool_arguments_mapping(cls, value: Any) -> Dict[str, Any]: + parsed = cls._json_string_to_mapping(value) + if parsed is not None: + return parsed + logger.warning( + "Tool call arguments should be a JSON object for this chat template, " + "got %r; using an empty object.", + value, + ) + return {} + + @classmethod + def _normalize_tool_call_for_chat_template(cls, tool_call: Any) -> Any: + if not isinstance(tool_call, dict): + return tool_call + + normalized = dict(tool_call) + if "arguments" in normalized: + normalized["arguments"] = cls._coerce_tool_arguments_mapping( + normalized["arguments"] + ) + + function = normalized.get("function") + if isinstance(function, dict) and "arguments" in function: + normalized_function = dict(function) + normalized_function["arguments"] = cls._coerce_tool_arguments_mapping( + normalized_function["arguments"] + ) + normalized["function"] = normalized_function + + return normalized + + @classmethod + def _normalize_messages_for_chat_template( + cls, messages: List, chat_template: str + ) -> List: + if not cls._chat_template_needs_mapping_tool_arguments(chat_template): + return messages + + normalized_messages = [] + for message in messages: + if not isinstance(message, dict) or not isinstance( + message.get("tool_calls"), list + ): + normalized_messages.append(message) + continue + + normalized_message = dict(message) + normalized_message["tool_calls"] = [ + cls._normalize_tool_call_for_chat_template(tool_call) + for tool_call in message["tool_calls"] + ] + normalized_messages.append(normalized_message) + return normalized_messages + + @classmethod + def _normalize_json_schema_for_chat_template(cls, schema: Any) -> dict: + parsed = cls._json_string_to_mapping(schema) + if parsed is None: + parsed = schema if isinstance(schema, dict) else {} + normalized_schema = dict(parsed) + + properties = normalized_schema.get("properties") + parsed_properties = cls._json_string_to_mapping(properties) + if parsed_properties is not None: + normalized_schema["properties"] = { + name: cls._normalize_json_schema_for_chat_template(value) + for name, value in parsed_properties.items() + } + elif isinstance(properties, dict): + normalized_schema["properties"] = { + name: cls._normalize_json_schema_for_chat_template(value) + for name, value in properties.items() + } + elif "properties" in normalized_schema: + normalized_schema["properties"] = {} + + items = normalized_schema.get("items") + if isinstance(items, dict) or isinstance(items, str): + normalized_schema["items"] = cls._normalize_json_schema_for_chat_template( + items + ) + elif isinstance(items, list): + normalized_schema["items"] = [ + cls._normalize_json_schema_for_chat_template(item) for item in items + ] + + additional_properties = normalized_schema.get("additionalProperties") + if isinstance(additional_properties, dict) or isinstance( + additional_properties, str + ): + normalized_schema["additionalProperties"] = ( + cls._normalize_json_schema_for_chat_template(additional_properties) + ) + + return normalized_schema + + @classmethod + def _normalize_tool_parameters_for_chat_template(cls, parameters: Any) -> dict: + normalized_parameters = cls._normalize_json_schema_for_chat_template(parameters) + if "properties" not in normalized_parameters or not isinstance( + normalized_parameters["properties"], dict + ): + normalized_parameters["properties"] = {} + return normalized_parameters + + @classmethod + def _normalize_tools_for_chat_template(cls, tools: Any, chat_template: str) -> Any: + if not cls._chat_template_needs_mapping_tool_parameters(chat_template): + return tools + if not isinstance(tools, list): + return tools + + normalized_tools = [] + for tool in tools: + if not isinstance(tool, dict): + normalized_tools.append(tool) + continue + + normalized_tool = dict(tool) + function = normalized_tool.get("function") + if isinstance(function, dict): + normalized_function = dict(function) + normalized_function["parameters"] = ( + cls._normalize_tool_parameters_for_chat_template( + normalized_function.get("parameters", {}) + ) + ) + normalized_tool["function"] = normalized_function + normalized_tool["parameters"] = normalized_function["parameters"] + else: + normalized_tool["parameters"] = ( + cls._normalize_tool_parameters_for_chat_template( + normalized_tool.get("parameters", {}) + ) + ) + normalized_tools.append(normalized_tool) + + return normalized_tools + @staticmethod def _attach_deepseekv4_tools(messages: List[Dict], tools: List[Dict]) -> List[Dict]: prepared_messages = [dict(message) for message in messages] @@ -272,17 +461,25 @@ def convert_messages_with_content_list_to_str_conversion( ) -> List[Dict]: """ Handles messages with content list conversion, in order to support Cline, see GH#2659 . + + A list ``content`` is always collapsed to a string (text blocks joined + with newlines). This must hold even when no text block is present — + e.g. Anthropic ``tool_result`` / ``tool_use`` / ``image`` blocks sent by + Claude Code — otherwise a raw list would reach the Jinja chat template + and raise ``'list' object has no attribute 'startswith'``. """ for message in messages: - texts = "" msg_content = message.get("content") - if msg_content: - if isinstance(msg_content, str): - texts = msg_content - elif isinstance(msg_content, list): - texts = "\n".join(item.get("text", "") for item in msg_content) - if texts: - message["content"] = texts + if isinstance(msg_content, list): + texts = [] + for item in msg_content: + if isinstance(item, dict): + text = item.get("text") + if isinstance(text, str) and text: + texts.append(text) + elif isinstance(item, str) and item: + texts.append(item) + message["content"] = "\n".join(texts) return messages @staticmethod