Skip to content

Commit 91efdbd

Browse files
committed
fix(inference, anthropic): emit tool messages before user text in translation
Anthropic only recommends placing tool_result blocks first in a user turn, so a client may send its text first. The translation flushed that text as a user message before the tool message, putting a user message between the assistant tool_calls and its result, which OpenAI rejects. Tool results are now emitted first regardless of block order, and the rest of the turn follows as a single user message. Signed-off-by: LuShadowX <xshadowlu13@gmail.com>
1 parent ebaaff8 commit 91efdbd

2 files changed

Lines changed: 83 additions & 25 deletions

File tree

src/ogx/providers/utils/inference/anthropic_translation.py

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -121,52 +121,48 @@ def convert_single_message(msg: AnthropicMessage) -> list[dict[str, Any]]:
121121
system_text = "\n".join(block.text for block in msg.content if isinstance(block, AnthropicTextBlock))
122122
return [{"role": "system", "content": system_text}]
123123

124-
# User message: may contain text and/or tool_result blocks
125-
result: list[dict[str, Any]] = []
126-
text_parts: list[dict[str, Any]] = []
124+
# User message: may contain text and/or tool_result blocks. Anthropic only
125+
# recommends putting tool_result blocks first, so they can appear after the
126+
# user's own text. OpenAI is stricter: a tool message must follow the
127+
# assistant message that requested the call, or another tool message. So the
128+
# tool messages are emitted first here whatever order the blocks arrived in,
129+
# and the rest of the turn follows as one user message.
130+
tool_messages: list[dict[str, Any]] = []
131+
content_parts: list[dict[str, Any]] = []
127132

128133
for block in msg.content:
129134
if isinstance(block, AnthropicToolResultBlock):
130-
# Flush accumulated text first
131-
if text_parts:
132-
if len(text_parts) == 1 and text_parts[0].get("type") == "text":
133-
flush_content: str | list[dict[str, Any]] = text_parts[0]["text"]
134-
else:
135-
flush_content = text_parts
136-
result.append({"role": "user", "content": flush_content})
137-
text_parts = []
138-
# Tool results become separate tool messages.
139-
# OpenAI tool messages only support text content, so image blocks
140-
# from tool results are promoted to a follow-up user message.
141135
tool_content = block.content
142-
image_parts: list[dict[str, Any]] = []
143136
if isinstance(tool_content, list):
144137
text_pieces = []
145138
for b in tool_content:
146139
if isinstance(b, AnthropicTextBlock):
147140
text_pieces.append(b.text)
148141
elif isinstance(b, AnthropicImageBlock):
149-
image_parts.append({"type": "image_url", "image_url": {"url": _image_source_to_url(b.source)}})
142+
# OpenAI tool messages only support text content, so image
143+
# blocks from tool results are promoted to the user message.
144+
content_parts.append(
145+
{"type": "image_url", "image_url": {"url": _image_source_to_url(b.source)}}
146+
)
150147
tool_content = "\n".join(text_pieces)
151-
result.append(
148+
tool_messages.append(
152149
{
153150
"role": "tool",
154151
"tool_call_id": block.tool_use_id,
155152
"content": tool_content,
156153
}
157154
)
158-
if image_parts:
159-
result.append({"role": "user", "content": image_parts})
160155
elif isinstance(block, AnthropicTextBlock):
161-
text_parts.append({"type": "text", "text": block.text})
156+
content_parts.append({"type": "text", "text": block.text})
162157
elif isinstance(block, AnthropicImageBlock):
163-
text_parts.append({"type": "image_url", "image_url": {"url": _image_source_to_url(block.source)}})
158+
content_parts.append({"type": "image_url", "image_url": {"url": _image_source_to_url(block.source)}})
164159

165-
if text_parts:
166-
if len(text_parts) == 1 and text_parts[0].get("type") == "text":
167-
user_content: str | list[dict[str, Any]] = text_parts[0]["text"]
160+
result: list[dict[str, Any]] = tool_messages
161+
if content_parts:
162+
if len(content_parts) == 1 and content_parts[0].get("type") == "text":
163+
user_content: str | list[dict[str, Any]] = content_parts[0]["text"]
168164
else:
169-
user_content = text_parts
165+
user_content = content_parts
170166
result.append({"role": "user", "content": user_content})
171167

172168
return result if result else [{"role": "user", "content": ""}]

tests/unit/providers/utils/inference/test_anthropic_translation.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,68 @@ def test_tool_result_in_user_message(self):
260260
assert msg["tool_call_id"] == "toolu_123"
261261
assert msg["content"] == "72F and sunny"
262262

263+
def test_tool_result_after_text_is_emitted_before_the_user_text(self):
264+
"""A tool message must follow the assistant message that requested the call.
265+
266+
Anthropic accepts a user turn whose text precedes its tool_result blocks, but
267+
OpenAI rejects a user message sitting between the tool call and its result.
268+
"""
269+
request = AnthropicCreateMessageRequest(
270+
model="m",
271+
messages=[
272+
AnthropicMessage(role="user", content="What is the weather?"),
273+
AnthropicMessage(
274+
role="assistant",
275+
content=[AnthropicToolUseBlock(id="toolu_123", name="get_weather", input={"city": "SF"})],
276+
),
277+
AnthropicMessage(
278+
role="user",
279+
content=[
280+
AnthropicTextBlock(text="and New York?"),
281+
AnthropicToolResultBlock(tool_use_id="toolu_123", content="72F and sunny"),
282+
],
283+
),
284+
],
285+
max_tokens=100,
286+
)
287+
result = anthropic_request_to_openai(request)
288+
289+
roles = [_msg_to_dict(m)["role"] for m in result.messages]
290+
assert roles == ["user", "assistant", "tool", "user"]
291+
tool_msg = _msg_to_dict(result.messages[2])
292+
assert tool_msg["tool_call_id"] == "toolu_123"
293+
assert tool_msg["content"] == "72F and sunny"
294+
assert _msg_to_dict(result.messages[3])["content"] == "and New York?"
295+
296+
def test_tool_results_split_by_text_stay_adjacent(self):
297+
request = AnthropicCreateMessageRequest(
298+
model="m",
299+
messages=[
300+
AnthropicMessage(
301+
role="assistant",
302+
content=[
303+
AnthropicToolUseBlock(id="toolu_1", name="a", input={}),
304+
AnthropicToolUseBlock(id="toolu_2", name="b", input={}),
305+
],
306+
),
307+
AnthropicMessage(
308+
role="user",
309+
content=[
310+
AnthropicToolResultBlock(tool_use_id="toolu_1", content="first"),
311+
AnthropicTextBlock(text="between"),
312+
AnthropicToolResultBlock(tool_use_id="toolu_2", content="second"),
313+
],
314+
),
315+
],
316+
max_tokens=100,
317+
)
318+
result = anthropic_request_to_openai(request)
319+
320+
roles = [_msg_to_dict(m)["role"] for m in result.messages]
321+
assert roles == ["assistant", "tool", "tool", "user"]
322+
assert [_msg_to_dict(m)["tool_call_id"] for m in result.messages[1:3]] == ["toolu_1", "toolu_2"]
323+
assert _msg_to_dict(result.messages[3])["content"] == "between"
324+
263325
def test_base64_image_in_user_message(self):
264326
request = AnthropicCreateMessageRequest(
265327
model="m",

0 commit comments

Comments
 (0)