Files
nanobot/nanobot/providers/openai_responses/converters.py
T
arcdrake22andchengyongru 6eda67b50c fix(providers): keep reasoning items wire-valid for DeepSeek Responses
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).
2026-08-03 18:06:45 +08:00

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