Skip to content

Commit c2e5110

Browse files
committed
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
1 parent 1f1e4c9 commit c2e5110

4 files changed

Lines changed: 810 additions & 12 deletions

File tree

xinference/api/restful_api.py

Lines changed: 213 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1290,6 +1290,201 @@ def _collect(content: Any, parts: List[str]) -> None:
12901290
normalized.insert(0, {"role": "system", "content": "\n".join(system_parts)})
12911291
return normalized
12921292

1293+
@staticmethod
1294+
def _extract_text_from_anthropic_content(content: Any) -> str:
1295+
"""
1296+
Extract plain text from an Anthropic ``content`` field.
1297+
1298+
The content may be a plain string or a list of content blocks such as
1299+
``[{"type": "text", "text": "..."}]``. Only text is collected; non-text
1300+
blocks (images, etc.) are ignored, which is sufficient for tool results.
1301+
"""
1302+
if isinstance(content, str):
1303+
return content
1304+
if isinstance(content, list):
1305+
texts = []
1306+
for block in content:
1307+
if isinstance(block, dict):
1308+
text = block.get("text")
1309+
if isinstance(text, str) and text:
1310+
texts.append(text)
1311+
elif isinstance(block, str) and block:
1312+
texts.append(block)
1313+
return "\n".join(texts)
1314+
return ""
1315+
1316+
def _convert_anthropic_messages_to_openai(self, messages: List[dict]) -> List[dict]:
1317+
"""
1318+
Convert Anthropic ``user`` / ``assistant`` content blocks into the
1319+
OpenAI-style messages that Xinference backends (and their chat
1320+
templates) expect.
1321+
1322+
The top-level ``system`` prompt and any inline ``role: system``
1323+
messages are already folded into a single leading system message by
1324+
:meth:`_normalize_anthropic_messages`, so system and plain-string turns
1325+
pass through untouched here. This pass only rewrites the content-block
1326+
turns produced by Claude Code >= 2.1.154:
1327+
1328+
* ``user`` / ``assistant`` text blocks are flattened to a string;
1329+
* ``tool_use`` blocks become assistant ``tool_calls``;
1330+
* ``tool_result`` blocks become standalone ``tool`` role messages;
1331+
* ``image`` blocks become OpenAI ``image_url`` parts.
1332+
1333+
Without this conversion the raw Anthropic blocks reach the backend chat
1334+
template and break it (e.g. a list ``content`` triggers
1335+
``'list' object has no attribute 'startswith'`` while rendering the
1336+
Jinja template).
1337+
"""
1338+
converted: List[dict] = []
1339+
for msg in messages or []:
1340+
content = msg.get("content")
1341+
# System (already folded) and plain-string turns pass through.
1342+
if not isinstance(content, list):
1343+
converted.append(msg)
1344+
continue
1345+
if msg.get("role") == "assistant":
1346+
converted.append(self._convert_anthropic_assistant_message(content))
1347+
elif msg.get("role") == "user":
1348+
converted.extend(self._convert_anthropic_user_message(content))
1349+
else:
1350+
converted.append(msg)
1351+
return converted
1352+
1353+
def _convert_anthropic_assistant_message(self, content: list) -> dict:
1354+
"""Convert an assistant message whose content is a list of blocks."""
1355+
text_parts: List[str] = []
1356+
tool_calls: List[dict] = []
1357+
for block in content:
1358+
if not isinstance(block, dict):
1359+
continue
1360+
block_type = block.get("type")
1361+
if block_type == "text":
1362+
text = block.get("text")
1363+
if isinstance(text, str) and text:
1364+
text_parts.append(text)
1365+
elif block_type == "tool_use":
1366+
tool_calls.append(
1367+
{
1368+
"id": block.get("id", ""),
1369+
"type": "function",
1370+
"function": {
1371+
"name": block.get("name", ""),
1372+
"arguments": json.dumps(
1373+
block.get("input", {}), ensure_ascii=False
1374+
),
1375+
},
1376+
}
1377+
)
1378+
# other blocks (e.g. ``thinking``) are dropped for backend prompts
1379+
new_msg: dict = {
1380+
"role": "assistant",
1381+
"content": "\n".join(text_parts),
1382+
}
1383+
if tool_calls:
1384+
new_msg["tool_calls"] = tool_calls
1385+
return new_msg
1386+
1387+
def _convert_anthropic_user_message(self, content: list) -> List[dict]:
1388+
"""
1389+
Convert a user message whose content is a list of blocks.
1390+
1391+
``tool_result`` blocks become standalone ``tool`` messages (emitted
1392+
first, so they directly follow the assistant ``tool_calls``); remaining
1393+
text/image blocks become a single ``user`` message.
1394+
"""
1395+
tool_messages: List[dict] = []
1396+
text_parts: List[str] = []
1397+
image_parts: List[dict] = []
1398+
for block in content:
1399+
if not isinstance(block, dict):
1400+
continue
1401+
block_type = block.get("type")
1402+
if block_type == "text":
1403+
text = block.get("text")
1404+
if isinstance(text, str) and text:
1405+
text_parts.append(text)
1406+
elif block_type == "tool_result":
1407+
tool_messages.append(
1408+
{
1409+
"role": "tool",
1410+
"tool_call_id": block.get("tool_use_id", ""),
1411+
"content": self._extract_text_from_anthropic_content(
1412+
block.get("content")
1413+
),
1414+
}
1415+
)
1416+
elif block_type == "image":
1417+
source = block.get("source", {})
1418+
if isinstance(source, dict):
1419+
if source.get("type") == "base64":
1420+
url = (
1421+
f"data:{source.get('media_type', '')};"
1422+
f"base64,{source.get('data', '')}"
1423+
)
1424+
image_parts.append(
1425+
{"type": "image_url", "image_url": {"url": url}}
1426+
)
1427+
elif source.get("type") == "url" and source.get("url"):
1428+
image_parts.append(
1429+
{
1430+
"type": "image_url",
1431+
"image_url": {"url": source["url"]},
1432+
}
1433+
)
1434+
1435+
result: List[dict] = list(tool_messages)
1436+
if image_parts:
1437+
# Multimodal turn: keep OpenAI content-part list (text + images).
1438+
parts: List[dict] = [
1439+
{"type": "text", "text": t} for t in text_parts
1440+
] + image_parts
1441+
result.append({"role": "user", "content": parts})
1442+
elif text_parts:
1443+
result.append({"role": "user", "content": "\n".join(text_parts)})
1444+
return result
1445+
1446+
@staticmethod
1447+
def _convert_anthropic_tools_to_openai(tools: list) -> List[dict]:
1448+
"""Convert Anthropic tool definitions to OpenAI ``function`` tools."""
1449+
openai_tools: List[dict] = []
1450+
for tool in tools:
1451+
if not isinstance(tool, dict):
1452+
continue
1453+
# Already in OpenAI shape -> keep as-is.
1454+
if tool.get("type") == "function" and "function" in tool:
1455+
openai_tools.append(tool)
1456+
continue
1457+
openai_tools.append(
1458+
{
1459+
"type": "function",
1460+
"function": {
1461+
"name": tool.get("name", ""),
1462+
"description": tool.get("description", ""),
1463+
"parameters": tool.get("input_schema", {}) or {},
1464+
},
1465+
}
1466+
)
1467+
return openai_tools
1468+
1469+
@staticmethod
1470+
def _convert_anthropic_tool_choice(tool_choice: Any) -> Any:
1471+
"""Convert an Anthropic ``tool_choice`` to the OpenAI equivalent."""
1472+
if not isinstance(tool_choice, dict):
1473+
return tool_choice
1474+
choice_type = tool_choice.get("type")
1475+
if choice_type == "auto":
1476+
return "auto"
1477+
if choice_type == "any":
1478+
return "required"
1479+
if choice_type == "none":
1480+
return "none"
1481+
if choice_type == "tool" and tool_choice.get("name"):
1482+
return {
1483+
"type": "function",
1484+
"function": {"name": tool_choice["name"]},
1485+
}
1486+
return tool_choice
1487+
12931488
async def create_message(self, request: Request) -> Response:
12941489
raw_body = await request.json()
12951490
body = CreateMessage.parse_obj(raw_body)
@@ -1322,18 +1517,32 @@ async def create_message(self, request: Request) -> Response:
13221517
messages = self._normalize_anthropic_messages(raw_body.get("system"), messages)
13231518
raw_kwargs.pop("system", None)
13241519

1325-
if not messages or messages[-1].get("role") not in ["user", "assistant"]:
1520+
# Convert Anthropic content blocks (tool_use / tool_result / text /
1521+
# image) into OpenAI-style messages. System folding is handled above, so
1522+
# this only rewrites the block turns Claude Code >= 2.1.154 sends;
1523+
# otherwise the raw blocks reach the chat template and break it.
1524+
messages = self._convert_anthropic_messages_to_openai(messages)
1525+
1526+
# A converted ``tool_result`` turn ends in a ``tool`` message, which is
1527+
# a valid last role (mirrors the OpenAI chat-completions endpoint).
1528+
if not messages or messages[-1].get("role") not in [
1529+
"user",
1530+
"assistant",
1531+
"tool",
1532+
]:
13261533
raise HTTPException(
13271534
status_code=400, detail="Invalid input. Please specify the prompt."
13281535
)
13291536

1330-
# Handle tools parameter
1537+
# Handle tools parameter (Anthropic ``input_schema`` -> OpenAI function)
13311538
if hasattr(body, "tools") and body.tools:
1332-
kwargs["tools"] = list(body.tools)
1539+
kwargs["tools"] = self._convert_anthropic_tools_to_openai(list(body.tools))
13331540

13341541
# Handle tool_choice parameter
13351542
if hasattr(body, "tool_choice") and body.tool_choice:
1336-
kwargs["tool_choice"] = body.tool_choice
1543+
kwargs["tool_choice"] = self._convert_anthropic_tool_choice(
1544+
body.tool_choice
1545+
)
13371546

13381547
# Get model mapping
13391548
try:

0 commit comments

Comments
 (0)