mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 13:58:36 +03:00
convert_messages() emitted reasoning items with ``content`` as a plain
string whenever preserve_reasoning was enabled (the DeepSeek spec).
DeepSeek's Responses gateway rejects that shape with a serde error
("input: invalid type: string ..., expected a sequence"), which surfaced
only after token consolidation cleared provider_state and forced the
full-history conversion path; replayed server items already carry list
content, which is why normal multi-turn requests never failed. Serialize
reasoning content as a list of output_text parts, matching the OpenAI
Responses schema and DeepSeek's accepted wire shape (verified live against
api.deepseek.com/responses).
The serde fallback classifier introduced in the previous commit remains as
a last-resort safeguard for any remaining wire incompatibility.
Tests: extend test_preserves_deepseek_reasoning_content to the array shape;
add a full-history regression with the observed failing item, a
replay/consolidation regression covering both replayed and converted
reasoning items, and provider-level request fixtures for both paths.
Full suite: 5773 passed, 22 skipped (only the known local-only
channels/sms packaging failure remains).
224 lines
8.7 KiB
Python
224 lines
8.7 KiB
Python
"""Convert Chat Completions messages/tools to Responses API format."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, cast
|
|
|
|
from nanobot.providers.base import tool_arguments_json_for_replay
|
|
|
|
|
|
def _as_json_object(value: object) -> dict[str, Any] | None:
|
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
|
|
|
|
|
def convert_messages(
|
|
messages: list[dict[str, Any]],
|
|
*,
|
|
preserve_reasoning: bool = False,
|
|
) -> tuple[str, list[dict[str, Any]]]:
|
|
"""Convert Chat Completions messages to Responses API input items.
|
|
|
|
Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted
|
|
from any ``system`` role message and *input_items* is the Responses API
|
|
``input`` array.
|
|
"""
|
|
system_prompt = ""
|
|
input_items: list[dict[str, Any]] = []
|
|
used_item_ids: set[str] = set()
|
|
|
|
for idx, msg in enumerate(messages):
|
|
role = msg.get("role")
|
|
content = msg.get("content")
|
|
|
|
if role == "system":
|
|
system_prompt = content if isinstance(content, str) else ""
|
|
continue
|
|
|
|
if role == "user":
|
|
input_items.append(convert_user_message(content))
|
|
continue
|
|
|
|
if role == "assistant":
|
|
if preserve_reasoning:
|
|
reasoning = msg.get("reasoning_content")
|
|
if isinstance(reasoning, str) and reasoning:
|
|
input_items.append({
|
|
"type": "reasoning",
|
|
"content": [{"type": "output_text", "text": reasoning}],
|
|
})
|
|
if isinstance(content, str) and content:
|
|
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
|
|
input_items.append({
|
|
"type": "message", "role": "assistant",
|
|
"content": [{"type": "output_text", "text": content}],
|
|
"status": "completed", "id": message_id,
|
|
})
|
|
for raw_tool_call in cast(list[object], msg.get("tool_calls", []) or []):
|
|
tool_call = _as_json_object(raw_tool_call)
|
|
if tool_call is None:
|
|
continue
|
|
fn = _as_json_object(tool_call.get("function")) or {}
|
|
call_id, item_id = split_tool_call_id(tool_call.get("id"))
|
|
response_item_id = _unique_item_id(item_id or f"fc_{idx}", used_item_ids)
|
|
input_items.append({
|
|
"type": "function_call",
|
|
"id": response_item_id,
|
|
"call_id": call_id or f"call_{idx}",
|
|
"name": fn.get("name"),
|
|
"arguments": tool_arguments_json_for_replay(fn.get("arguments")),
|
|
})
|
|
continue
|
|
|
|
if role == "tool":
|
|
call_id, _ = split_tool_call_id(msg.get("tool_call_id"))
|
|
output = convert_tool_output(content)
|
|
input_items.append({"type": "function_call_output", "call_id": call_id, "output": output})
|
|
|
|
return system_prompt, input_items
|
|
|
|
|
|
def convert_user_message(content: Any) -> dict[str, Any]:
|
|
"""Convert a user message's content to Responses API format.
|
|
|
|
Handles plain strings, ``text`` blocks -> ``input_text``, and
|
|
``image_url`` blocks -> ``input_image``.
|
|
"""
|
|
if isinstance(content, str):
|
|
return {"role": "user", "content": [{"type": "input_text", "text": content}]}
|
|
if isinstance(content, list):
|
|
converted: list[dict[str, Any]] = []
|
|
for raw_item in cast(list[object], content):
|
|
item = _as_json_object(raw_item)
|
|
if item is None:
|
|
continue
|
|
if item.get("type") == "text":
|
|
converted.append({"type": "input_text", "text": item.get("text", "")})
|
|
elif item.get("type") == "image_url":
|
|
image = _as_json_object(item.get("image_url")) or {}
|
|
url = image.get("url")
|
|
if url:
|
|
converted.append({"type": "input_image", "image_url": url, "detail": "auto"})
|
|
if converted:
|
|
return {"role": "user", "content": converted}
|
|
return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
|
|
|
|
|
|
def convert_tool_output(content: Any) -> str | list[dict[str, Any]]:
|
|
"""Convert a tool result to Responses API function-call output content.
|
|
|
|
The Responses API accepts text, image, and file blocks as function tool
|
|
output. Nanobot's file tools use Chat Completions-style ``text`` and
|
|
``image_url`` blocks for image reads; serializing those blocks as JSON
|
|
turns the image into inert text and can make the request unnecessarily
|
|
large. Preserve supported multimodal blocks and strip internal metadata.
|
|
"""
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
converted: list[dict[str, Any]] = []
|
|
for raw_item in cast(list[object], content):
|
|
item = _as_json_object(raw_item)
|
|
if item is None:
|
|
break
|
|
item_type = item.get("type")
|
|
if item_type in {"text", "input_text"}:
|
|
if set(item) - {"type", "text", "_meta"}:
|
|
break
|
|
text = item.get("text")
|
|
if not isinstance(text, str):
|
|
break
|
|
converted.append({"type": "input_text", "text": text})
|
|
elif item_type in {"image_url", "input_image"}:
|
|
image = item.get("image_url")
|
|
image_object = _as_json_object(image)
|
|
if image_object is not None and set(image_object) - {"url", "detail"}:
|
|
break
|
|
if set(item) - {"type", "image_url", "file_id", "detail", "_meta"}:
|
|
break
|
|
url = image_object.get("url") if image_object is not None else image
|
|
file_id = item.get("file_id")
|
|
detail = item.get(
|
|
"detail",
|
|
image_object.get("detail", "auto") if image_object is not None else "auto",
|
|
)
|
|
if detail not in {"low", "high", "auto", "original"}:
|
|
break
|
|
block = {"type": "input_image", "detail": detail}
|
|
if isinstance(url, str) and url:
|
|
block["image_url"] = url
|
|
elif isinstance(file_id, str) and file_id:
|
|
block["file_id"] = file_id
|
|
else:
|
|
break
|
|
converted.append(block)
|
|
elif item_type in {"file", "input_file"}:
|
|
if set(item) - {
|
|
"type",
|
|
"file_data",
|
|
"file_id",
|
|
"file_url",
|
|
"filename",
|
|
"_meta",
|
|
}:
|
|
break
|
|
block = {"type": "input_file"}
|
|
for key in ("file_data", "file_id", "file_url", "filename"):
|
|
value = item.get(key)
|
|
if isinstance(value, str) and value:
|
|
block[key] = value
|
|
if not any(key in block for key in ("file_data", "file_id", "file_url")):
|
|
break
|
|
converted.append(block)
|
|
else:
|
|
break
|
|
else:
|
|
if converted:
|
|
return converted
|
|
return json.dumps(content, ensure_ascii=False)
|
|
|
|
|
|
def convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""Convert OpenAI function-calling tool schema to Responses API flat format."""
|
|
converted: list[dict[str, Any]] = []
|
|
for tool in tools:
|
|
fn = _as_json_object(tool.get("function")) or {} if tool.get("type") == "function" else tool
|
|
name = fn.get("name")
|
|
if not name:
|
|
continue
|
|
params: object = fn.get("parameters") or {}
|
|
converted.append({
|
|
"type": "function",
|
|
"name": name,
|
|
"description": fn.get("description") or "",
|
|
"parameters": params if isinstance(params, dict) else {},
|
|
})
|
|
return converted
|
|
|
|
|
|
def _unique_item_id(item_id: str, used: set[str]) -> str:
|
|
"""Return a Responses input item id that is unique within one request."""
|
|
if item_id not in used:
|
|
used.add(item_id)
|
|
return item_id
|
|
|
|
suffix = 2
|
|
while f"{item_id}_{suffix}" in used:
|
|
suffix += 1
|
|
unique = f"{item_id}_{suffix}"
|
|
used.add(unique)
|
|
return unique
|
|
|
|
|
|
def split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
|
|
"""Split a compound ``call_id|item_id`` string.
|
|
|
|
Returns ``(call_id, item_id)`` where *item_id* may be ``None``.
|
|
"""
|
|
if isinstance(tool_call_id, str) and tool_call_id:
|
|
if "|" in tool_call_id:
|
|
call_id, item_id = tool_call_id.split("|", 1)
|
|
return call_id, item_id or None
|
|
return tool_call_id, None
|
|
return "call_0", None
|