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