Skip to content

Commit 13e8e80

Browse files
authored
fix(inference, anthropic): emit tool messages before user text in translation (#6367)
# What does this PR do? `convert_single_message` flushed a user turn's accumulated text as a `user` message as soon as it hit a `tool_result` block. When a client puts its text before the tool result, that lands a `user` message between the assistant `tool_calls` message and the tool result: ```text assistant tool_calls=[toolu_123] user "and New York?" tool tool_call_id=toolu_123 ``` OpenAI rejects that ordering — `Invalid parameter: messages with role 'tool' must be a response to a preceding message with 'tool_calls'` — so every such request fails once translated, and the caller sees a provider error rather than an answer. Anthropic only *recommends* placing `tool_result` blocks first in the user turn; it accepts them after text, and `AnthropicCreateMessageRequest` accepts them too. Two `tool_result` blocks separated by text were worse still: they came out as `tool, user, tool`. Tool messages are now emitted first whatever order the blocks arrived in, and the remainder of the turn follows as a single `user` message. Text blocks keep their relative order, and images promoted out of `tool_result` content still land in that trailing user message. Only the ordering changes. A turn that carries just `tool_result` blocks, or just text, is untouched. ## Test Plan Two cases added to `tests/unit/providers/utils/inference/test_anthropic_translation.py`. Both fail on `main` and pass here: ```console $ uv run pytest tests/unit/providers/utils/inference/test_anthropic_translation.py -q 51 passed in 0.09s # on main, with the two new cases applied: 2 failed, 49 passed in 0.12s FAILED ...::test_tool_result_after_text_is_emitted_before_the_user_text FAILED ...::test_tool_results_split_by_text_stay_adjacent ``` Script, `demo.py`: ```python from ogx.providers.utils.inference.anthropic_translation import anthropic_request_to_openai from ogx_api.messages.models import ( AnthropicCreateMessageRequest, AnthropicMessage, AnthropicTextBlock, AnthropicToolResultBlock, AnthropicToolUseBlock, ) request = AnthropicCreateMessageRequest( model="m", max_tokens=100, messages=[ AnthropicMessage(role="user", content="What is the weather in SF?"), AnthropicMessage( role="assistant", content=[AnthropicToolUseBlock(id="toolu_123", name="get_weather", input={"city": "SF"})], ), AnthropicMessage( role="user", content=[ AnthropicTextBlock(text="and New York?"), AnthropicToolResultBlock(tool_use_id="toolu_123", content="72F and sunny"), ], ), ], ) messages = [m.model_dump(exclude_none=True) for m in anthropic_request_to_openai(request).messages] for i, m in enumerate(messages): extra = f" tool_call_id={m['tool_call_id']}" if m.get("tool_call_id") else "" extra += " tool_calls=yes" if m.get("tool_calls") else "" print(f"{i}: role={m['role']}{extra} content={str(m.get('content'))[:40]!r}") previous = None for m in messages: if m["role"] == "tool": ok = previous is not None and ( previous["role"] == "tool" or (previous["role"] == "assistant" and previous.get("tool_calls")) ) if not ok: raise SystemExit(f"\nINVALID: tool message preceded by role={previous['role'] if previous else None}") previous = m print("\nVALID: every tool message follows the assistant tool_calls message or another tool message") ``` On `main`: ```console $ uv run python demo.py 0: role=user content='What is the weather in SF?' 1: role=assistant tool_calls=yes content='None' 2: role=user content='and New York?' 3: role=tool tool_call_id=toolu_123 content='72F and sunny' INVALID: tool message preceded by role=user ``` On this branch: ```console $ uv run python demo.py 0: role=user content='What is the weather in SF?' 1: role=assistant tool_calls=yes content='None' 2: role=tool tool_call_id=toolu_123 content='72F and sunny' 3: role=user content='and New York?' VALID: every tool message follows the assistant tool_calls message or another tool message ``` Full unit suite, unchanged apart from the two new cases: ```console $ uv run pytest tests/unit/ -q 1 failed, 3260 passed, 5 skipped, 3 xfailed, 61 errors # this branch 1 failed, 3258 passed, 5 skipped, 3 xfailed, 61 errors # main ``` The one failure and the 61 errors are `tests/unit/providers/vector_io/` on both sides — `_create_sqlite_connection` cannot load `sqlite_vec` on this macOS box. Not related to this change. `ruff check`, `ruff format --check` and `mypy` are clean on both files, and `pre-commit run --files <both files>` passes every hook. Signed-off-by: LuShadowX <xshadowlu13@gmail.com>
1 parent a7de599 commit 13e8e80

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)