Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions app/adapters/chat_input.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Normalize recognizable Anthropic history blocks embedded in Chat requests."""
import json

from fastapi import HTTPException


_ANTHROPIC_BLOCKS = ("tool_use", "tool_result", "thinking", "redacted_thinking", "image")


def _invalid(param, message):
return HTTPException(status_code=400, detail={"error": {
"type": "invalid_request_error", "code": "invalid_chat_content", "param": param, "message": message,
}})


def _nonempty_string(value, param):
if not isinstance(value, str) or not value.strip():
raise _invalid(param, "Expected a non-empty string")
return value


def _content_part(block, param):
if not isinstance(block, dict):
raise _invalid(param, "Content blocks must be objects")
kind = block.get("type")
if kind == "text":
if not isinstance(block.get("text"), str):
raise _invalid(param + ".text", "Text content must be a string")
return block
if kind == "image_url":
image = block.get("image_url")
if not isinstance(image, dict):
raise _invalid(param + ".image_url", "image_url must be an object")
_nonempty_string(image.get("url"), param + ".image_url.url")
return block
if kind == "image":
source = block.get("source")
if not isinstance(source, dict):
raise _invalid(param + ".source", "Image source must be an object")
if source.get("type") == "url":
url = _nonempty_string(source.get("url"), param + ".source.url")
elif source.get("type") == "base64":
media = source.get("media_type")
if not isinstance(media, str) or not media.startswith("image/"):
raise _invalid(param + ".source.media_type", "Base64 images require an image media type")
data = _nonempty_string(source.get("data"), param + ".source.data")
url = f"data:{media};base64,{data}"
else:
raise _invalid(param + ".source", "Only URL and base64 image sources can be converted to Chat")
return {"type": "image_url", "image_url": {"url": url}}
raise _invalid(param, "Unsupported block in mixed Chat/Anthropic content")


def _tool_call(block, param):
identifier = _nonempty_string(block.get("id"), param + ".id")
name = _nonempty_string(block.get("name"), param + ".name")
arguments = block.get("input")
if not isinstance(arguments, dict):
raise _invalid(param + ".input", "tool_use input must be a JSON object")
try:
arguments = json.dumps(arguments, ensure_ascii=False, allow_nan=False)
except (ValueError, TypeError, RecursionError):
raise _invalid(param + ".input", "tool_use input must be a valid JSON object") from None
return {"id": identifier, "type": "function", "function": {"name": name, "arguments": arguments}}


def _tool_result(block, param):
identifier = _nonempty_string(block.get("tool_use_id"), param + ".tool_use_id")
if "is_error" in block and not isinstance(block["is_error"], bool):
raise _invalid(param + ".is_error", "is_error must be a boolean")
content = block.get("content", "")
if isinstance(content, list):
parts = [_content_part(part, f"{param}.content[{index}]") for index, part in enumerate(content)]
content = (parts if any(part["type"] == "image_url" for part in parts)
else "".join(part["text"] for part in parts))
elif not isinstance(content, str):
raise _invalid(param + ".content", "Tool results must contain a string or text/image blocks")
if block.get("is_error"):
content = ([{"type": "text", "text": "[tool execution failed]"}, *content]
if isinstance(content, list) else "[tool execution failed]\n" + content)
return {"role": "tool", "tool_call_id": identifier, "content": content}


def _convert_message(message, index, pending):
content = message.get("content")
if not isinstance(content, list) or not any(
isinstance(block, dict) and block.get("type") in _ANTHROPIC_BLOCKS for block in content):
return [message]
role = message.get("role")
param = f"messages[{index}]"
parts, calls, results, thoughts = [], [], [], []
call_ids, result_ids = set(), set()
for offset, block in enumerate(content):
location = f"{param}.content[{offset}]"
kind = block.get("type") if isinstance(block, dict) else None
if kind == "tool_use":
if role != "assistant":
raise _invalid(location, "tool_use requires an assistant message")
if message.get("tool_calls") not in (None, []) or message.get("function_call") is not None:
raise _invalid(location, "tool_use conflicts with existing Chat tool calls")
call = _tool_call(block, location)
if call["id"] in call_ids:
raise _invalid(location + ".id", "Duplicate tool_use ID in one assistant message")
call_ids.add(call["id"])
calls.append(call)
elif kind == "tool_result":
if role != "user":
raise _invalid(location, "tool_result requires a user message")
if message.keys() - {"role", "content"}:
raise _invalid(param, "Message-level attributes cannot be assigned safely when splitting tool_result content")
if parts:
raise _invalid(location, "Tool results must precede ordinary user content")
result = _tool_result(block, location)
identifier = result["tool_call_id"]
if pending.get(identifier) != 1 or identifier in result_ids:
raise _invalid(location + ".tool_use_id", "tool_result must match one preceding, unanswered tool call")
result_ids.add(identifier)
results.append(result)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
elif kind == "thinking":
if role != "assistant":
raise _invalid(location, "thinking requires an assistant message")
if message.get("reasoning_content") not in (None, ""):
raise _invalid(location, "thinking conflicts with existing reasoning_content")
if not isinstance(block.get("thinking"), str):
raise _invalid(location + ".thinking", "Thinking content must be a string")
# Anthropic signatures have no Chat equivalent and must not become visible text.
thoughts.append(block["thinking"])
elif kind == "redacted_thinking":
raise _invalid(location, "redacted_thinking cannot be converted to Chat; use the Messages protocol")
else:
parts.append(_content_part(block, location))
if results:
# Keep results adjacent to the assistant calls, before any follow-up user text/images.
if parts and result_ids != pending.keys():
raise _invalid(param, "All pending tool results must precede follow-up user content")
return [*results, *([{**message, "content": parts}] if parts else [])]
out = {**message, "content": parts if parts else None}
if calls:
out["tool_calls"] = calls
if thoughts:
out["reasoning_content"] = "".join(thoughts)
return [out]


def normalize_chat_messages(messages, *, message_indices=None):
"""Convert recognized blocks without mutating input; retain caller indices in errors."""
result, pending = [], {}
for index, message in enumerate(messages):
original_index = index if message_indices is None else message_indices[index]
converted = _convert_message(message, original_index, pending)
result.extend(converted)
for item in converted:
if item.get("role") == "tool":
identifier = item.get("tool_call_id")
if isinstance(identifier, str):
pending.pop(identifier, None)
continue
pending = {}
if item.get("role") == "assistant" and isinstance(item.get("tool_calls"), list):
for call in item["tool_calls"]:
identifier = call.get("id") if isinstance(call, dict) else None
if isinstance(identifier, str):
pending[identifier] = pending.get(identifier, 0) + 1
return result
8 changes: 6 additions & 2 deletions converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False,
from app.request_context import SessionIdentifierError, current_context
from app import model_capabilities
from app.message_normalization import merge_intl_user_images
from app.adapters.chat_input import normalize_chat_messages
from app.model_catalog_view import INTERNATIONAL as SHARED_INTL_PROFILES, share_models
from app.inference_auth import require_api_key
from app.admin_auth import SessionStoreError
Expand Down Expand Up @@ -2656,19 +2657,22 @@ def _prepare_chat_body(body: dict, *, region=None, session_payload=None) -> dict
if not isinstance(messages, list) or not messages or any(not isinstance(message, dict) for message in messages):
raise HTTPException(status_code=400, detail={"error": {
"message": "messages must be a non-empty array of objects", "type": "invalid_request_error"}})
# Keep error paths tied to caller positions when the upstream system message moves.
message_indices = list(range(len(messages)))
# Upstreams reject developer roles; copy them as system messages without changing content.
messages = [
dict(message, role="system") if message.get("role") == "developer" else message
for message in messages
]
body["messages"] = messages
if messages[0].get("role") != "system":
system_index = next((index for index, message in enumerate(messages) if message.get("role") == "system"), None)
if system_index is None:
messages = [{"role": "system", "content": "You are a helpful assistant."}, *messages]
message_indices.insert(0, None)
else:
messages = [messages[system_index], *messages[:system_index], *messages[system_index + 1:]]
body["messages"] = messages
message_indices.insert(0, message_indices.pop(system_index))
body["messages"] = normalize_chat_messages(messages, message_indices=message_indices)
_normalize_tool_choice(body)
body["stream"] = True
body.setdefault("stream_options", {"include_usage": True})
Expand Down
4 changes: 3 additions & 1 deletion docs/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ The generation endpoints are `POST /v1/chat/completions`, `POST /v1/responses` a

## Protocol behavior worth knowing

- `developer` messages are normalized to `system` without mutating the caller's original payload.
- `developer` messages become `system`; the first system message is placed first before matching tool results, without mutating the original payload.
- Chat accepts mixed Anthropic `tool_use` / `tool_result` history, preserving call IDs, arguments, result images and error markers; ordinary `thinking` becomes `reasoning_content`, not visible text. Native Chat fields stay unchanged.
- Conflicting fields, unmatched tool results, unsupported mixed blocks and `redacted_thinking` return HTTP 400 before routing. Split user messages accept only `role` and `content`, with all `tool_result` blocks before ordinary text/images; Anthropic thinking signatures are not forwarded.
- Named function choices are sent upstream as `required` with only that function available; invalid names are rejected locally.
- Errors follow the client protocol's own shape (OpenAI `error` object vs Anthropic `{"type":"error"}`), and status codes are preserved.
- `POST /v1/messages/count_tokens` returns a character-based heuristic estimate for budgeting, not an exact count.
Expand Down
4 changes: 3 additions & 1 deletion docs/clients.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ Cherry Studio、ZCode、LobeChat、NextChat、Open WebUI 或自研 SDK 客户端

## 值得了解的协议行为

- `developer` 消息归一化为 `system`,不改动调用方原始载荷。
- 先将 `developer` 转为 `system` 并置顶首条系统消息,再关联工具结果;不改动调用方原始载荷。
- Chat 兼容混入的 Anthropic `tool_use` / `tool_result` 历史,保留调用 ID、参数、结果图片与错误标记;普通 `thinking` 转为 `reasoning_content`,不混入正文,原生 Chat 字段保持不变。
- 字段冲突、工具结果无法关联、不支持的混合内容块及 `redacted_thinking` 在选路前返回 HTTP 400。需拆分的用户消息只能包含 `role`、`content`,且 `tool_result` 必须在普通文本/图片之前;Anthropic 思考签名不转发。
- 指定名称的函数选择会以 `required` 且仅含该函数的形式发往上游;无效名称在本地拒绝。
- 错误按客户端协议各自的形态返回(OpenAI 的 `error` 对象与 Anthropic 的 `{"type":"error"}`),状态码保留。
- `POST /v1/messages/count_tokens` 返回按字符估算的启发式结果,用于预算参考,不是精确计数。
Expand Down
Loading
Loading