From 946d985750b711025d52a2ee342778393b49c5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:23:39 +0800 Subject: [PATCH 1/3] Normalize mixed Anthropic tool history in Chat requests --- app/adapters/chat_input.py | 161 ++++++++++++++++++++ converter.py | 2 + docs/clients.md | 2 + docs/clients.zh-CN.md | 2 + tests/test_chat_input.py | 290 +++++++++++++++++++++++++++++++++++++ 5 files changed, 457 insertions(+) create mode 100644 app/adapters/chat_input.py create mode 100644 tests/test_chat_input.py diff --git a/app/adapters/chat_input.py b/app/adapters/chat_input.py new file mode 100644 index 0000000..1d579fa --- /dev/null +++ b/app/adapters/chat_input.py @@ -0,0 +1,161 @@ +"""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") + 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) + 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): + """Convert only recognized block arrays without mutating native Chat messages or opaque JSON.""" + result, pending = [], {} + for index, message in enumerate(messages): + converted = _convert_message(message, 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 diff --git a/converter.py b/converter.py index fab676b..e3e1a8a 100644 --- a/converter.py +++ b/converter.py @@ -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 @@ -2656,6 +2657,7 @@ 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"}}) + messages = normalize_chat_messages(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 diff --git a/docs/clients.md b/docs/clients.md index b45fd58..4b98250 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -67,6 +67,8 @@ 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. +- 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 `tool_result` user messages must contain only `role` and `content`; 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. diff --git a/docs/clients.zh-CN.md b/docs/clients.zh-CN.md index d7cc8df..2d672d9 100644 --- a/docs/clients.zh-CN.md +++ b/docs/clients.zh-CN.md @@ -67,6 +67,8 @@ Cherry Studio、ZCode、LobeChat、NextChat、Open WebUI 或自研 SDK 客户端 ## 值得了解的协议行为 - `developer` 消息归一化为 `system`,不改动调用方原始载荷。 +- Chat 兼容混入的 Anthropic `tool_use` / `tool_result` 历史,保留调用 ID、参数、结果图片与错误标记;普通 `thinking` 转为 `reasoning_content`,不混入正文,原生 Chat 字段保持不变。 +- 字段冲突、工具结果无法关联、不支持的混合内容块及 `redacted_thinking` 在选路前返回 HTTP 400。需拆分的 `tool_result` 用户消息只能包含 `role`、`content`;Anthropic 思考签名不转发。 - 指定名称的函数选择会以 `required` 且仅含该函数的形式发往上游;无效名称在本地拒绝。 - 错误按客户端协议各自的形态返回(OpenAI 的 `error` 对象与 Anthropic 的 `{"type":"error"}`),状态码保留。 - `POST /v1/messages/count_tokens` 返回按字符估算的启发式结果,用于预算参考,不是精确计数。 diff --git a/tests/test_chat_input.py b/tests/test_chat_input.py new file mode 100644 index 0000000..77973a6 --- /dev/null +++ b/tests/test_chat_input.py @@ -0,0 +1,290 @@ +"""Verify mixed Chat/Anthropic history normalization with synthetic requests only.""" +from copy import deepcopy +from itertools import product +import json +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import httpx +from fastapi import HTTPException +import converter as gateway +import test_runtime_endpoints as fixtures +import test_api_flow as routing + + +IMAGE = {"type": "image_url", "image_url": {"url": "https://synthetic.invalid/image.png", "detail": "high"}} +ANTHROPIC_IMAGE = {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "YQ=="}} +TOOLS = [{"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}] + + +def call(identifier="call_1", **changes): + return {"type": "tool_use", "id": identifier, "name": "lookup", "input": {"q": "synthetic"}, **changes} + + +def result(identifier="call_1", **changes): + return {"type": "tool_result", "tool_use_id": identifier, "content": "synthetic result", **changes} + + +def history(): + return [{"role": "user", "content": "look up data"}, + {"role": "assistant", "content": [call()]}, + {"role": "user", "content": [result()]}] + + +class ChatInputTests(unittest.TestCase): + def setUp(self): + self.enterContext(patch.dict(gateway.CONFIG, model_guard=False, desensitize=False, + max_request_bytes=32 * 1024 * 1024)) + + def prepare(self, messages): + body = {"model": "auto", "messages": [{"role": "system", "content": "system"}, *messages]} + return gateway._prepare_chat_body(body)["messages"][1:] + + def assert_invalid(self, messages): + before = deepcopy(messages) + with self.assertRaises(HTTPException) as error: + self.prepare(messages) + self.assertEqual(error.exception.status_code, 400) + self.assertEqual(error.exception.detail["error"]["type"], "invalid_request_error") + self.assertTrue(error.exception.detail["error"]["param"].startswith("messages[")) + self.assertNotIn("private-synthetic", str(error.exception.detail)) + self.assertEqual(messages, before) + + def test_tool_history_keeps_arguments_ids_and_input(self): + messages = history() + arguments = {"text": "中文\nquotes: \"", "nested": {"type": "tool_use", "input": [1, 2]}, "empty": {}} + messages[1]["content"][0]["input"] = arguments + before = deepcopy(messages) + prepared = self.prepare(messages) + self.assertIsNone(prepared[1]["content"]) + tool_call = prepared[1]["tool_calls"][0] + self.assertEqual(tool_call["id"], "call_1") + self.assertEqual(tool_call["type"], "function") + self.assertEqual(tool_call["function"]["name"], "lookup") + self.assertEqual(json.loads(tool_call["function"]["arguments"]), arguments) + self.assertEqual(prepared[2], {"role": "tool", "tool_call_id": "call_1", "content": "synthetic result"}) + self.assertEqual(messages, before) + self.assertEqual(self.prepare(prepared), prepared) + + def test_parallel_results_precede_followup_and_preserve_images_and_errors(self): + messages = [{"role": "assistant", "name": "assistant_name", "content": [ + {"type": "text", "text": "before"}, deepcopy(IMAGE), call(), call("call_2"), + {"type": "text", "text": "after"}]}, + {"role": "user", "content": [{"type": "text", "text": "continue"}, + result(content=[{"type": "text", "text": "failed"}, deepcopy(IMAGE), deepcopy(ANTHROPIC_IMAGE)], is_error=True), + result("call_2", content=""), deepcopy(ANTHROPIC_IMAGE)]}] + before = deepcopy(messages) + prepared = self.prepare(messages) + self.assertEqual([m["role"] for m in prepared], ["assistant", "tool", "tool", "user"]) + self.assertEqual(prepared[0]["name"], "assistant_name") + self.assertEqual(prepared[0]["content"], [messages[0]["content"][0], IMAGE, messages[0]["content"][-1]]) + self.assertEqual([c["id"] for c in prepared[0]["tool_calls"]], ["call_1", "call_2"]) + self.assertEqual(prepared[1]["content"], [ + {"type": "text", "text": "[tool execution failed]"}, {"type": "text", "text": "failed"}, IMAGE, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,YQ=="}}]) + self.assertEqual(prepared[2]["content"], "") + self.assertEqual(prepared[3]["content"][0], {"type": "text", "text": "continue"}) + self.assertEqual(prepared[3]["content"][1]["image_url"]["url"], "data:image/png;base64,YQ==") + self.assertEqual(messages, before) + + def test_thinking_is_reasoning_not_visible_content(self): + messages = [{"role": "assistant", "content": [ + {"type": "thinking", "thinking": "first", "signature": "private-synthetic-signature"}, + {"type": "thinking", "thinking": "second"}, {"type": "text", "text": "answer"}, call()]}] + prepared = self.prepare(messages) + self.assertEqual(prepared[0]["reasoning_content"], "firstsecond") + self.assertEqual(prepared[0]["content"], [{"type": "text", "text": "answer"}]) + self.assertNotIn("private-synthetic", json.dumps(prepared)) + self.assertEqual(len(prepared[0]["tool_calls"]), 1) + thinking_only = self.prepare([{"role": "assistant", "content": [{"type": "thinking", "thinking": "reason"}]}]) + self.assertEqual(thinking_only, [{"role": "assistant", "content": None, "reasoning_content": "reason"}]) + + def test_native_chat_fields_and_opaque_json_are_unchanged(self): + messages = [{"role": "user", "name": "user_name", "content": [deepcopy(IMAGE), {"type": "text", "text": "tool_use"}]}, + {"role": "assistant", "content": None, "reasoning_content": "existing", "tool_calls": [{ + "id": "native", "type": "function", "function": {"name": "lookup", "arguments": '{"type":"tool_use"}'}}]}, + {"role": "tool", "tool_call_id": "native", "content": "{\"type\":\"tool_result\"}"}] + before = deepcopy(messages) + self.assertEqual(self.prepare(messages), before) + self.assertEqual(messages, before) + self.assertEqual(self.prepare([{"role": "assistant", "content": [], "tool_calls": []}]), + [{"role": "assistant", "content": [], "tool_calls": []}]) + + def test_converted_results_can_follow_native_calls_and_native_results_can_follow_converted_calls(self): + messages = history() + native = self.prepare(messages) + mixed = [messages[0], native[1], messages[2]] + self.assertEqual(self.prepare(mixed), native) + mixed = [messages[0], messages[1], native[2]] + self.assertEqual(self.prepare(mixed), native) + + def test_error_and_omitted_empty_result_content(self): + for is_error in (False, True): + messages = history() + messages[-1]["content"][0] = {"type": "tool_result", "tool_use_id": "call_1", "is_error": is_error} + prepared = self.prepare(messages) + self.assertEqual(prepared[-1]["content"], "[tool execution failed]\n" if is_error else "") + + def test_malformed_and_conflicting_blocks_fail_closed(self): + bad_calls = [call(id=""), call(id=42), call(name=" "), call(input="private-synthetic"), + call(input=None), call(input=[]), {"type": "tool_use", "id": "call_1", "name": "lookup"}] + for block in bad_calls: + with self.subTest(block=block): + self.assert_invalid([{"role": "assistant", "content": [block]}]) + for blocks in ([call(), call()], [call(), {"type": "unknown", "text": "private-synthetic"}], [call(), 3], + [{"type": "thinking", "thinking": 3}], [{"type": "redacted_thinking", "data": "private-synthetic"}]): + with self.subTest(blocks=blocks): + self.assert_invalid([{"role": "assistant", "content": blocks}]) + for role, block in (("user", call()), ("system", call()), ("assistant", result()), + ("tool", result()), ("user", {"type": "thinking", "thinking": "private-synthetic"})): + self.assert_invalid([{"role": role, "content": [block]}]) + for extra in ({"tool_calls": [{"id": "native"}]}, {"function_call": {"name": "lookup"}}): + self.assert_invalid([{"role": "assistant", "content": [call()], **extra}]) + self.assert_invalid([{"role": "assistant", "content": [{"type": "thinking", "thinking": "new"}], + "reasoning_content": "private-synthetic"}]) + + def test_invalid_results_and_unrepresentable_images_fail_closed(self): + for block in (result(tool_use_id=""), result(tool_use_id=12), result(content=None), result(content={}), + result(is_error="false"), result(content=[call()]), result(content=[{"type": "text", "text": 3}]), + result(content=[{"type": "image", "source": {"type": "file", "file_id": "private-synthetic"}}]), + result(content=[{"type": "image", "source": {"type": "url", "url": ""}}])): + with self.subTest(block=block): + messages = history() + messages[-1]["content"] = [block] + self.assert_invalid(messages) + messages = history() + messages[-1]["name"] = "private-synthetic" + self.assert_invalid(messages) + + def test_separate_result_messages_and_empty_blocks_keep_call_boundaries(self): + messages = [{"role": "assistant", "content": [{"type": "text", "text": ""}, call(), call("call_2")]}, + {"role": "user", "content": [result()]}, + {"role": "user", "content": [result("call_2", content=[]), {"type": "text", "text": "next"}]}] + prepared = self.prepare(messages) + self.assertEqual([message["role"] for message in prepared], ["assistant", "tool", "tool", "user"]) + self.assertEqual(prepared[0]["content"], [{"type": "text", "text": ""}]) + self.assertEqual(prepared[2]["content"], "") + messages[1]["content"].append({"type": "text", "text": "too early"}) + self.assert_invalid(messages) + + def test_thinking_alongside_native_calls_and_empty_reasoning_field(self): + native = self.prepare(history())[1] + native["reasoning_content"] = "" + native["content"] = [{"type": "thinking", "thinking": "reason"}] + prepared = self.prepare([native])[0] + self.assertEqual(prepared["tool_calls"], native["tool_calls"]) + self.assertEqual(prepared["reasoning_content"], "reason") + self.assertIsNone(prepared["content"]) + + def test_invalid_argument_json_does_not_echo_values(self): + for value in (float("nan"), float("inf"), {1, 2}): + with self.subTest(value=value): + self.assert_invalid([{"role": "assistant", "content": [call(input={"private-synthetic": value})]}]) + + + def test_orphan_duplicate_and_out_of_order_results_are_rejected(self): + self.assert_invalid([{"role": "user", "content": [result()]}]) + messages = history() + messages[-1]["content"] = [result(), result()] + self.assert_invalid(messages) + messages[-1]["content"] = [result("unknown")] + self.assert_invalid(messages) + messages = history() + messages.insert(2, {"role": "user", "content": "intervening message"}) + self.assert_invalid(messages) + + +class ChatInputEndpointTests(unittest.TestCase): + def setUp(self): + self.fx = fixtures.EndpointTests() + self.addCleanup(self.fx.doCleanups) + self.fx.setUp() + + def test_mixed_history_is_normalized_before_upstream_in_both_modes(self): + def reply(request): + body = json.loads(request.content) + for message in body["messages"]: + if isinstance(message.get("content"), list): + for index, block in enumerate(message["content"]): + if block.get("type") not in ("text", "image_url"): + return httpx.Response(400, json={"code": 11101, "msg": + f"Parse message failed: unsupported content type at index {index}: {block.get('type')}"}) + return httpx.Response(200, content=fixtures.sse()) + self.fx.respond = reply + for stream, desensitize, thinking in product((False, True), repeat=3): + with self.subTest(stream=stream, desensitize=desensitize, thinking=thinking), patch.dict( + gateway.CONFIG, desensitize=desensitize): + self.fx.requests.clear() + messages = history() + if thinking: + messages[1]["content"].insert(0, {"type": "thinking", "thinking": "synthetic reasoning"}) + response = self.fx.client.post("/v1/chat/completions", json={ + "model": "auto", "messages": messages, "tools": TOOLS, "stream": stream}) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(len(self.fx.requests), 1) + body = json.loads(self.fx.requests[0].content) + self.assertEqual(body["messages"][2].get("reasoning_content"), "synthetic reasoning" if thinking else None) + self.assertEqual(body["messages"][2]["tool_calls"][0]["id"], body["messages"][3]["tool_call_id"]) + self.assertEqual(body["tools"][0]["function"]["name"], "lookup") + + def test_invalid_history_is_rejected_before_credential_selection(self): + for stream in (False, True): + self.fx.credentials.reset_mock() + self.fx.requests.clear() + messages = history() + messages[1]["content"][0]["id"] = "" + response = self.fx.client.post("/v1/chat/completions", json={"model": "auto", "messages": messages, "stream": stream}) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(response.json()["error"]["type"], "invalid_request_error") + self.fx.credentials.assert_not_called() + self.assertEqual(self.fx.requests, []) + + def test_nested_image_policy_precedes_conversion(self): + messages = history() + messages[-1]["content"][0]["content"] = [deepcopy(ANTHROPIC_IMAGE), deepcopy(IMAGE)] + for policy, status in (("truncate", 200), ("error", 413)): + self.fx.requests.clear() + with patch.dict(gateway.CONFIG, max_images=1, image_policy=policy): + response = self.fx.client.post("/v1/chat/completions", json={"model": "auto", "messages": messages}) + self.assertEqual(response.status_code, status, response.text) + if policy == "truncate": + body = json.loads(self.fx.requests[0].content) + self.assertEqual(body["messages"][-1]["content"], [IMAGE]) + else: + self.assertEqual(self.fx.requests, []) + + def test_expanded_tool_arguments_are_subject_to_wire_size_limit(self): + messages = history() + messages[1]["content"][0]["input"] = {"escaped": '"' * 2000} + payload = {"model": "auto", "messages": messages} + before = len(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()) + self.fx.credentials.reset_mock() + with patch.dict(gateway.CONFIG, max_request_bytes=before + 500): + response = self.fx.client.post("/v1/chat/completions", json=payload) + self.assertEqual(response.status_code, 413, response.text) + self.fx.credentials.assert_not_called() + self.assertEqual(self.fx.requests, []) + + +class ChatInputRoutingTests(routing.GatewayFixture, unittest.TestCase): + def test_converted_history_reaches_each_profile_and_releases_capacity(self): + for profile in routing.fixtures.PROFILES: + for stream in (False, True): + with self.subTest(profile=profile, stream=stream): + self.fx.configure(profiles=(profile,)) + payload = self.fx.payload("chat/completions", stream=stream) + payload.update(messages=history(), tools=TOOLS) + self.fx.post_ok("chat/completions", payload, {profile}) + body = json.loads(self.fx.requests[-1].content) + assistant = next(m for m in body["messages"] if m["role"] == "assistant") + tool = next(m for m in body["messages"] if m["role"] == "tool") + self.assertEqual(assistant["tool_calls"][0]["id"], tool["tool_call_id"]) + self.assertEqual(self.fx.pool._capacity._counts, {}) + + + +if __name__ == "__main__": + unittest.main(verbosity=2) From dfcb3d1b4b1cfa04a73ea850be8f3f16eed9e841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:36:36 +0800 Subject: [PATCH 2/3] Reject reordered user content in mixed tool histories --- app/adapters/chat_input.py | 2 ++ docs/clients.md | 2 +- docs/clients.zh-CN.md | 2 +- tests/test_chat_input.py | 29 +++++++++++++++++++++++++++-- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/app/adapters/chat_input.py b/app/adapters/chat_input.py index 1d579fa..90314ff 100644 --- a/app/adapters/chat_input.py +++ b/app/adapters/chat_input.py @@ -108,6 +108,8 @@ def _convert_message(message, index, pending): 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: diff --git a/docs/clients.md b/docs/clients.md index 4b98250..0f472da 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -68,7 +68,7 @@ The generation endpoints are `POST /v1/chat/completions`, `POST /v1/responses` a - `developer` messages are normalized to `system` without mutating the caller's 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 `tool_result` user messages must contain only `role` and `content`; Anthropic thinking signatures are not forwarded. +- 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. diff --git a/docs/clients.zh-CN.md b/docs/clients.zh-CN.md index 2d672d9..7f615ee 100644 --- a/docs/clients.zh-CN.md +++ b/docs/clients.zh-CN.md @@ -68,7 +68,7 @@ Cherry Studio、ZCode、LobeChat、NextChat、Open WebUI 或自研 SDK 客户端 - `developer` 消息归一化为 `system`,不改动调用方原始载荷。 - Chat 兼容混入的 Anthropic `tool_use` / `tool_result` 历史,保留调用 ID、参数、结果图片与错误标记;普通 `thinking` 转为 `reasoning_content`,不混入正文,原生 Chat 字段保持不变。 -- 字段冲突、工具结果无法关联、不支持的混合内容块及 `redacted_thinking` 在选路前返回 HTTP 400。需拆分的 `tool_result` 用户消息只能包含 `role`、`content`;Anthropic 思考签名不转发。 +- 字段冲突、工具结果无法关联、不支持的混合内容块及 `redacted_thinking` 在选路前返回 HTTP 400。需拆分的用户消息只能包含 `role`、`content`,且 `tool_result` 必须在普通文本/图片之前;Anthropic 思考签名不转发。 - 指定名称的函数选择会以 `required` 且仅含该函数的形式发往上游;无效名称在本地拒绝。 - 错误按客户端协议各自的形态返回(OpenAI 的 `error` 对象与 Anthropic 的 `{"type":"error"}`),状态码保留。 - `POST /v1/messages/count_tokens` 返回按字符估算的启发式结果,用于预算参考,不是精确计数。 diff --git a/tests/test_chat_input.py b/tests/test_chat_input.py index 77973a6..b45b440 100644 --- a/tests/test_chat_input.py +++ b/tests/test_chat_input.py @@ -73,9 +73,9 @@ def test_parallel_results_precede_followup_and_preserve_images_and_errors(self): messages = [{"role": "assistant", "name": "assistant_name", "content": [ {"type": "text", "text": "before"}, deepcopy(IMAGE), call(), call("call_2"), {"type": "text", "text": "after"}]}, - {"role": "user", "content": [{"type": "text", "text": "continue"}, + {"role": "user", "content": [ result(content=[{"type": "text", "text": "failed"}, deepcopy(IMAGE), deepcopy(ANTHROPIC_IMAGE)], is_error=True), - result("call_2", content=""), deepcopy(ANTHROPIC_IMAGE)]}] + result("call_2", content=""), {"type": "text", "text": "continue"}, deepcopy(ANTHROPIC_IMAGE)]}] before = deepcopy(messages) prepared = self.prepare(messages) self.assertEqual([m["role"] for m in prepared], ["assistant", "tool", "tool", "user"]) @@ -159,6 +159,16 @@ def test_invalid_results_and_unrepresentable_images_fail_closed(self): messages[-1]["name"] = "private-synthetic" self.assert_invalid(messages) + def test_user_content_before_or_between_tool_results_is_rejected(self): + for ordinary in ({"type": "text", "text": "keep this order"}, IMAGE, ANTHROPIC_IMAGE): + for position in (0, 1): + with self.subTest(kind=ordinary["type"], position=position): + messages = [{"role": "assistant", "content": [call(), call("call_2")]}, + {"role": "user", "content": [result(), result("call_2")]}] + messages[-1]["content"].insert(position, deepcopy(ordinary)) + self.assert_invalid(messages) + + def test_separate_result_messages_and_empty_blocks_keep_call_boundaries(self): messages = [{"role": "assistant", "content": [{"type": "text", "text": ""}, call(), call("call_2")]}, {"role": "user", "content": [result()]}, @@ -242,6 +252,21 @@ def test_invalid_history_is_rejected_before_credential_selection(self): self.fx.credentials.assert_not_called() self.assertEqual(self.fx.requests, []) + def test_reordered_user_content_is_rejected_without_an_upstream_attempt(self): + for stream in (False, True): + with self.subTest(stream=stream): + self.fx.credentials.reset_mock() + self.fx.requests.clear() + messages = history() + messages[-1]["content"].insert(0, {"type": "text", "text": "before the result"}) + response = self.fx.client.post("/v1/chat/completions", json={ + "model": "auto", "messages": messages, "stream": stream}) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(response.json()["error"]["code"], "invalid_chat_content") + self.fx.credentials.assert_not_called() + self.assertEqual(self.fx.requests, []) + + def test_nested_image_policy_precedes_conversion(self): messages = history() messages[-1]["content"][0]["content"] = [deepcopy(ANTHROPIC_IMAGE), deepcopy(IMAGE)] From a71d4a0b51cfce3efc9faa1003261d81849e8d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:53:17 +0800 Subject: [PATCH 3/3] Normalize system placement before matching tool results --- app/adapters/chat_input.py | 7 +++--- converter.py | 8 ++++--- docs/clients.md | 2 +- docs/clients.zh-CN.md | 2 +- tests/test_chat_input.py | 44 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/app/adapters/chat_input.py b/app/adapters/chat_input.py index 90314ff..c40bda4 100644 --- a/app/adapters/chat_input.py +++ b/app/adapters/chat_input.py @@ -142,11 +142,12 @@ def _convert_message(message, index, pending): return [out] -def normalize_chat_messages(messages): - """Convert only recognized block arrays without mutating native Chat messages or opaque JSON.""" +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): - converted = _convert_message(message, index, pending) + 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": diff --git a/converter.py b/converter.py index e3e1a8a..647f684 100644 --- a/converter.py +++ b/converter.py @@ -2657,20 +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"}}) - messages = normalize_chat_messages(messages) + # 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}) diff --git a/docs/clients.md b/docs/clients.md index 0f472da..effd1a5 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -66,7 +66,7 @@ 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. diff --git a/docs/clients.zh-CN.md b/docs/clients.zh-CN.md index 7f615ee..94db46d 100644 --- a/docs/clients.zh-CN.md +++ b/docs/clients.zh-CN.md @@ -66,7 +66,7 @@ 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` 且仅含该函数的形式发往上游;无效名称在本地拒绝。 diff --git a/tests/test_chat_input.py b/tests/test_chat_input.py index b45b440..3d0f4cc 100644 --- a/tests/test_chat_input.py +++ b/tests/test_chat_input.py @@ -180,6 +180,34 @@ def test_separate_result_messages_and_empty_blocks_keep_call_boundaries(self): messages[1]["content"].append({"type": "text", "text": "too early"}) self.assert_invalid(messages) + def test_late_system_is_hoisted_before_matching_tool_results(self): + for role in ("system", "developer"): + with self.subTest(role=role): + messages = history() + messages.insert(2, {"role": role, "content": "late instructions"}) + before = deepcopy(messages) + prepared = gateway._prepare_chat_body({"model": "auto", "messages": messages})["messages"] + self.assertEqual(prepared[0], {"role": "system", "content": "late instructions"}) + self.assertEqual([m["role"] for m in prepared], ["system", "user", "assistant", "tool"]) + self.assertEqual(prepared[2]["tool_calls"][0]["id"], prepared[3]["tool_call_id"]) + self.assertEqual(messages, before) + # Only the first system is hoisted; a remaining instruction still interrupts the tool run. + self.assert_invalid([{"role": "system", "content": "already first"}, *messages]) + + def test_content_error_paths_keep_original_indices_after_system_placement(self): + for role, position in ((None, None), ("system", 0), ("system", 2), ("developer", 2)): + with self.subTest(role=role, position=position): + messages = history() + if role: + messages.insert(position, {"role": role, "content": "instructions"}) + index = next(i for i, message in enumerate(messages) if message["role"] == "assistant") + messages[index]["content"][0]["id"] = "" + with self.assertRaises(HTTPException) as error: + gateway._prepare_chat_body({"model": "auto", "messages": messages}) + self.assertEqual(error.exception.status_code, 400) + self.assertEqual(error.exception.detail["error"]["param"], f"messages[{index}].content[0].id") + + def test_thinking_alongside_native_calls_and_empty_reasoning_field(self): native = self.prepare(history())[1] native["reasoning_content"] = "" @@ -240,6 +268,22 @@ def reply(request): self.assertEqual(body["messages"][2]["tool_calls"][0]["id"], body["messages"][3]["tool_call_id"]) self.assertEqual(body["tools"][0]["function"]["name"], "lookup") + def test_late_system_and_developer_tool_history_reaches_upstream(self): + for role, stream in product(("system", "developer"), (False, True)): + with self.subTest(role=role, stream=stream): + self.fx.requests.clear() + messages = history() + messages.insert(2, {"role": role, "content": "late instructions"}) + response = self.fx.client.post("/v1/chat/completions", json={ + "model": "auto", "messages": messages, "stream": stream}) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(len(self.fx.requests), 1) + sent = json.loads(self.fx.requests[0].content)["messages"] + self.assertEqual(sent[0], {"role": "system", "content": "late instructions"}) + self.assertEqual([m["role"] for m in sent], ["system", "user", "assistant", "tool"]) + self.assertEqual(sent[2]["tool_calls"][0]["id"], sent[3]["tool_call_id"]) + + def test_invalid_history_is_rejected_before_credential_selection(self): for stream in (False, True): self.fx.credentials.reset_mock()