项目文件夹

文件
Ali Khokhar 0de7608b52 Keep inline system reminders cache-stable across providers (#1154)
## Problem

Inline Anthropic system reminders were forwarded as mid-conversation
OpenAI system roles. Compatible provider chat templates could reposition
those roles, changing prior prompt tokens and causing periodic full
cache misses. Fixes #1152.

## Changes

| Before | After |
| --- | --- |
| Top-level and inline system content both used downstream system roles.
| Only top-level system content uses the leading downstream system role.
|
| Inline system reminders could trigger provider-side prompt
retemplating. | Inline system reminders keep their text and position as
downstream user messages. |
| Provider policies could reinterpret the shared role mapping. | Shared
conversion owns one provider-independent role mapping. |
| Cache-prefix coverage allowed mid-conversation system roles. |
Cache-prefix coverage requires append-only user-encoded reminders. |
| The package version was 4.8.0. | The package version is 4.8.1. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR keeps inline system reminders stable across OpenAI-compatible
providers. The main changes are:

- Maps only the top-level system prompt to the downstream system role.
- Encodes inline system reminders as ordered user content.
- Coalesces adjacent user messages after transcript ordering.
- Adds tests for text, multimodal content, cache-prefix stability, and
tool-result ordering.
- Updates the architecture notes and bumps the package to 4.8.1.
</details>

<h3>Confidence Score: 5/5</h3>

This looks safe to merge.

Adjacent user messages are combined after assistant and tool
dependencies are ordered. Multimodal content keeps its part order. No
blocking issues were found in the changed code.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- Before capture, the converted prefix roles were shown as system, user,
and assistant.
- After capture, the same prefix appeared, followed by a user
continuation containing a second question and a system-reminder tag; all
runtime assertions passed.
- The preserved harness documents and reproduces the public-builder
validation.

<a
href="https://app.greptile.com/trex/runs/14803290/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| src/free_claude_code/core/anthropic/conversion.py | Maps inline system
reminders to user content and coalesces adjacent user messages after
transcript ordering. |
| tests/providers/test_converter.py | Adds tests for inline reminders,
multimodal content, cache-prefix stability, and tool-result ordering. |
| ARCHITECTURE.md | Documents the shared role-mapping and adjacent-user
coalescing rules. |
| pyproject.toml | Bumps the package version to 4.8.1. |
| uv.lock | Synchronizes the locked editable package version with 4.8.1.
|

</details>

<sub>Reviews (2): Last reviewed commit: ["fix: coalesce adjacent
provider user
tur..."](https://github.com/alishahryar1/free-claude-code/commit/7af6327c79fa15c0f4922bad8864f1c6656b2814)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=45005803)</sub>

<!-- /greptile_comment -->
2026-07-16 22:14:33 -07:00

1479 行
47 KiB
Python

import json
import pytest
from free_claude_code.core.anthropic import (
AnthropicToOpenAIConverter,
OpenAIConversionError,
ReasoningReplayMode,
build_base_request_body,
)
from free_claude_code.core.anthropic.models import MessagesRequest
# --- Mock Classes ---
class MockMessage:
def __init__(self, role, content, reasoning_content=None):
self.role = role
self.content = content
self.reasoning_content = reasoning_content
class MockBlock:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
self._data = kwargs
def get(self, key, default=None):
return self._data.get(key, default)
class MockTool:
def __init__(self, name, description, input_schema=None):
self.name = name
self.description = description
self.input_schema = input_schema
# --- System Prompt Tests ---
def test_convert_system_prompt_str():
system = "You are a helpful assistant."
result = AnthropicToOpenAIConverter.convert_system_prompt(system)
assert result == {"role": "system", "content": system}
def test_convert_system_prompt_list_text():
system = [
MockBlock(type="text", text="Part 1"),
MockBlock(type="text", text="Part 2"),
]
result = AnthropicToOpenAIConverter.convert_system_prompt(system)
assert result == {"role": "system", "content": "Part 1\n\nPart 2"}
def test_convert_system_prompt_none():
assert AnthropicToOpenAIConverter.convert_system_prompt(None) is None
def test_openai_build_uses_only_top_level_system_role() -> None:
request = MessagesRequest.model_validate(
{
"model": "model",
"system": "Conversation-wide instructions",
"messages": [
{"role": "user", "content": "First question"},
{"role": "system", "content": "Instructions from this point"},
{"role": "system", "content": "A second reminder"},
{"role": "assistant", "content": "First answer"},
{"role": "user", "content": "Second question"},
{"role": "system", "content": "A final reminder"},
],
}
)
body = build_base_request_body(request)
assert body["messages"] == [
{"role": "system", "content": "Conversation-wide instructions"},
{
"role": "user",
"content": (
"First question\n\nInstructions from this point\n\nA second reminder"
),
},
{"role": "assistant", "content": "First answer"},
{"role": "user", "content": "Second question\n\nA final reminder"},
]
def test_openai_build_demotes_inline_system_text_blocks_without_repositioning() -> None:
request = MessagesRequest.model_validate(
{
"model": "model",
"messages": [
{"role": "user", "content": "Before"},
{
"role": "system",
"content": [
{"type": "text", "text": "First instruction"},
{"type": "text", "text": "Second instruction"},
],
},
],
}
)
body = build_base_request_body(request)
assert body["messages"] == [
{
"role": "user",
"content": "Before\n\nFirst instruction\n\nSecond instruction",
}
]
def test_inline_system_message_preserves_existing_openai_cache_prefix() -> None:
prefix_request = MessagesRequest.model_validate(
{
"model": "model",
"system": "Conversation-wide instructions",
"messages": [
{"role": "user", "content": "First question"},
{"role": "assistant", "content": "First answer"},
],
}
)
continued_request = MessagesRequest.model_validate(
{
"model": "model",
"system": "Conversation-wide instructions",
"messages": [
{"role": "user", "content": "First question"},
{"role": "assistant", "content": "First answer"},
{"role": "user", "content": "Second question"},
{
"role": "system",
"content": (
"<system-reminder>Instructions from this point"
"</system-reminder>"
),
},
],
}
)
prefix = build_base_request_body(prefix_request)["messages"]
continued = build_base_request_body(continued_request)["messages"]
assert continued[: len(prefix)] == prefix
assert continued[len(prefix) :] == [
{
"role": "user",
"content": (
"Second question\n\n<system-reminder>Instructions from this point"
"</system-reminder>"
),
}
]
def test_inline_system_message_coalesces_with_multimodal_user_content() -> None:
request = MessagesRequest.model_validate(
{
"model": "model",
"messages": [
{"role": "user", "content": "Existing context."},
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "url",
"url": "https://example.com/image.png",
},
},
{"type": "text", "text": "Inspect this image."},
],
},
{"role": "system", "content": "Focus on correctness."},
],
}
)
body = build_base_request_body(request)
assert body["messages"] == [
{
"role": "user",
"content": [
{"type": "text", "text": "Existing context."},
{
"type": "image_url",
"image_url": {"url": "https://example.com/image.png"},
},
{
"type": "text",
"text": "Inspect this image.\n\nFocus on correctness.",
},
],
}
]
def test_inline_system_message_follows_completed_tool_result() -> None:
request = MessagesRequest.model_validate(
{
"model": "model",
"messages": [
{"role": "user", "content": "Use the tool"},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_1",
"name": "Read",
"input": {},
}
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "call_1",
"content": "done",
}
],
},
{"role": "system", "content": "New instructions"},
],
}
)
body = build_base_request_body(request)
assert [message["role"] for message in body["messages"]] == [
"user",
"assistant",
"tool",
"user",
]
assert body["messages"][-1]["content"] == "New instructions"
def test_openai_build_rejects_non_text_inline_system_blocks() -> None:
request = MessagesRequest.model_validate(
{
"model": "model",
"messages": [
{
"role": "system",
"content": [
{
"type": "image",
"source": {
"type": "url",
"url": "https://example.com/a.png",
},
}
],
}
],
}
)
with pytest.raises(
OpenAIConversionError,
match="inline Anthropic system message content block 'image' without data loss",
):
build_base_request_body(request)
def test_openai_build_rejects_empty_inline_system_content() -> None:
request = MessagesRequest.model_validate(
{
"model": "model",
"messages": [{"role": "system", "content": []}],
}
)
with pytest.raises(OpenAIConversionError, match="contain text"):
build_base_request_body(request)
# --- Tool Conversion Tests ---
def test_convert_tools():
tools = [
MockTool(
"get_weather",
"Get weather",
{"type": "object", "properties": {"loc": {"type": "string"}}},
),
MockTool("calculator", None, {"type": "object"}),
]
result = AnthropicToOpenAIConverter.convert_tools(tools)
assert len(result) == 2
assert result[0]["type"] == "function"
assert result[0]["function"]["name"] == "get_weather"
assert result[0]["function"]["description"] == "Get weather"
assert result[0]["function"]["parameters"] == {
"type": "object",
"properties": {"loc": {"type": "string"}},
}
assert result[1]["function"]["name"] == "calculator"
assert result[1]["function"]["description"] == "" # Check default empty string
def test_convert_tool_without_input_schema_uses_empty_object_schema():
tools = [MockTool("web_search", None)]
result = AnthropicToOpenAIConverter.convert_tools(tools)
assert result == [
{
"type": "function",
"function": {
"name": "web_search",
"description": "",
"parameters": {"type": "object", "properties": {}},
},
}
]
@pytest.mark.parametrize(
"tool_choice,expected",
[
(
{"type": "tool", "name": "echo_smoke"},
{"type": "function", "function": {"name": "echo_smoke"}},
),
({"type": "any"}, "required"),
({"type": "auto"}, "auto"),
({"type": "none"}, "none"),
(
{"type": "function", "function": {"name": "already_openai"}},
{"type": "function", "function": {"name": "already_openai"}},
),
],
)
def test_convert_tool_choice(tool_choice, expected):
result = AnthropicToOpenAIConverter.convert_tool_choice(tool_choice)
assert result == expected
# --- Message Conversion Tests: User ---
def test_convert_user_message_str():
messages = [MockMessage("user", "Hello world")]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 1
assert result[0] == {"role": "user", "content": "Hello world"}
def test_convert_user_message_list_text():
content = [
MockBlock(type="text", text="Hello"),
MockBlock(type="text", text="World"),
]
messages = [MockMessage("user", content)]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 1
assert result[0] == {"role": "user", "content": "Hello\nWorld"}
def test_convert_user_message_tool_result_str():
content = [
MockBlock(type="tool_result", tool_use_id="tool_123", content="Result data")
]
messages = [MockMessage("user", content)]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 1
assert result[0] == {
"role": "tool",
"tool_call_id": "tool_123",
"content": "Result data",
}
def test_convert_user_message_tool_result_list():
# Tool result content as a list of text blocks
tool_content = [
{"type": "text", "text": "Line 1"},
{"type": "text", "text": "Line 2"},
]
content = [
MockBlock(type="tool_result", tool_use_id="tool_456", content=tool_content)
]
messages = [MockMessage("user", content)]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 1
assert result[0]["role"] == "tool"
assert result[0]["tool_call_id"] == "tool_456"
assert result[0]["content"] == "Line 1\nLine 2"
def test_convert_user_message_mixed_text_and_tool_result():
# Note: Anthropic/OpenAI mapping usually separates these, but the converter handles lists
# User text usually comes before tool results in a turn, or after.
# The converter splits them into separate messages if they are different roles?
# Let's check logic: _convert_user_message returns a list of dicts.
content = [
MockBlock(type="text", text="Here is the result:"),
MockBlock(type="tool_result", tool_use_id="tool_789", content="42"),
]
messages = [MockMessage("user", content)]
result = AnthropicToOpenAIConverter.convert_messages(messages)
# Order is preserved: user text first, then tool result.
assert len(result) == 2
assert result[0] == {"role": "user", "content": "Here is the result:"}
assert result[1] == {"role": "tool", "tool_call_id": "tool_789", "content": "42"}
# --- Message Conversion Tests: Assistant ---
def test_convert_assistant_message_text_only():
messages = [MockMessage("assistant", "I am ready.")]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 1
assert result[0] == {"role": "assistant", "content": "I am ready."}
def test_convert_assistant_message_blocks_text():
content = [MockBlock(type="text", text="Part A")]
messages = [MockMessage("assistant", content)]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert result[0] == {"role": "assistant", "content": "Part A"}
def test_convert_assistant_message_thinking():
content = [
MockBlock(type="thinking", thinking="I need to calculate this."),
MockBlock(type="text", text="The answer is 4."),
]
messages = [MockMessage("assistant", content)]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 1
# Expecting <think> tags
expected_content = (
"<think>\nI need to calculate this.\n</think>\n\nThe answer is 4."
)
assert result[0]["content"] == expected_content
assert "reasoning_content" not in result[0]
def test_convert_assistant_message_thinking_replays_reasoning_content():
"""Top-level reasoning replay avoids duplicating thinking into content."""
content = [
MockBlock(type="thinking", thinking="I need to calculate this."),
MockBlock(type="text", text="The answer is 4."),
]
messages = [MockMessage("assistant", content)]
result = AnthropicToOpenAIConverter.convert_messages(
messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
)
assert len(result) == 1
assert result[0]["reasoning_content"] == "I need to calculate this."
assert result[0]["content"] == "The answer is 4."
assert "<think>" not in result[0]["content"]
def test_convert_assistant_message_thinking_replays_reasoning():
content = [
MockBlock(type="thinking", thinking="I need to calculate this."),
MockBlock(type="text", text="The answer is 4."),
]
messages = [MockMessage("assistant", content)]
result = AnthropicToOpenAIConverter.convert_messages(
messages, reasoning_replay=ReasoningReplayMode.REASONING
)
assert result == [
{
"role": "assistant",
"content": "The answer is 4.",
"reasoning": "I need to calculate this.",
}
]
def test_convert_assistant_top_level_reasoning_content_is_preserved():
messages = [
MockMessage(
"assistant",
"The answer is 4.",
reasoning_content="I need to calculate this.",
)
]
result = AnthropicToOpenAIConverter.convert_messages(
messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
)
assert result == [
{
"role": "assistant",
"content": "The answer is 4.",
"reasoning_content": "I need to calculate this.",
}
]
def test_convert_assistant_empty_top_level_reasoning_content_is_preserved():
messages = [MockMessage("assistant", "The answer is 4.", reasoning_content="")]
result = AnthropicToOpenAIConverter.convert_messages(
messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
)
assert result == [
{
"role": "assistant",
"content": "The answer is 4.",
"reasoning_content": "",
}
]
def test_convert_assistant_thinking_tool_use_replays_top_level_reasoning():
content = [
MockBlock(type="thinking", thinking="I should call the tool."),
MockBlock(
type="tool_use",
id="call_reasoning",
name="search",
input={"query": "python"},
),
]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[
MockBlock(
type="tool_result",
tool_use_id="call_reasoning",
content="result",
)
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(
messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
)
assert len(result) == 2
assert result[0]["content"] == ""
assert result[0]["reasoning_content"] == "I should call the tool."
assert "<think>" not in result[0]["content"]
assert result[0]["tool_calls"][0]["id"] == "call_reasoning"
def test_convert_assistant_tool_use_replays_ollama_reasoning_field():
messages = [
MockMessage(
"assistant",
[
MockBlock(type="thinking", thinking="Call the tool."),
MockBlock(type="tool_use", id="call_1", name="Read", input={}),
],
),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_1", content="done")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(
messages, reasoning_replay=ReasoningReplayMode.REASONING
)
assert result[0]["reasoning"] == "Call the tool."
assert "reasoning_content" not in result[0]
assert result[0]["tool_calls"][0]["id"] == "call_1"
def test_convert_assistant_empty_thinking_replays_empty_reasoning_content():
content = [
MockBlock(type="thinking", thinking=""),
MockBlock(type="text", text="The answer is 4."),
]
messages = [MockMessage("assistant", content)]
result = AnthropicToOpenAIConverter.convert_messages(
messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
)
assert result == [
{
"role": "assistant",
"content": "The answer is 4.",
"reasoning_content": "",
}
]
def test_convert_assistant_tool_use_replays_empty_reasoning_content():
content = [
MockBlock(type="thinking", thinking=""),
MockBlock(type="tool_use", id="call_empty", name="Read", input={}),
]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[
MockBlock(
type="tool_result",
tool_use_id="call_empty",
content="result",
)
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(
messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
)
assert result[0]["content"] == ""
assert result[0]["reasoning_content"] == ""
assert result[0]["tool_calls"][0]["id"] == "call_empty"
def test_convert_assistant_message_thinking_removed_when_disabled():
content = [
MockBlock(type="thinking", thinking="I need to calculate this."),
MockBlock(type="text", text="The answer is 4."),
]
messages = [MockMessage("assistant", content)]
result = AnthropicToOpenAIConverter.convert_messages(
messages,
reasoning_replay=ReasoningReplayMode.DISABLED,
)
assert len(result) == 1
assert "reasoning_content" not in result[0]
assert "<think>" not in result[0]["content"]
assert result[0]["content"] == "The answer is 4."
def test_convert_assistant_top_level_reasoning_stripped_when_disabled():
messages = [
MockMessage(
"assistant",
"The answer is 4.",
reasoning_content="I need to calculate this.",
)
]
result = AnthropicToOpenAIConverter.convert_messages(
messages, reasoning_replay=ReasoningReplayMode.DISABLED
)
assert result == [{"role": "assistant", "content": "The answer is 4."}]
def test_convert_assistant_message_tool_use():
content = [
MockBlock(type="text", text="I will call the tool."),
MockBlock(
type="tool_use", id="call_1", name="search", input={"query": "python"}
),
]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_1", content="result")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 2
msg = result[0]
assert msg["role"] == "assistant"
assert "I will call the tool." in msg["content"]
assert "tool_calls" in msg
assert len(msg["tool_calls"]) == 1
tc = msg["tool_calls"][0]
assert tc["id"] == "call_1"
assert tc["function"]["name"] == "search"
assert json.loads(tc["function"]["arguments"]) == {"query": "python"}
def test_convert_assistant_tool_use_preserves_extra_content():
content = [
MockBlock(
type="tool_use",
id="call_1",
name="search",
input={"query": "python"},
extra_content={"google": {"thought_signature": "sig"}},
),
]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_1", content="result")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert result[0]["tool_calls"][0]["extra_content"] == {
"google": {"thought_signature": "sig"}
}
def test_convert_assistant_message_empty_content():
# Verify that empty content becomes a single space (NIM requirement)
# if no tool calls are present.
content = []
messages = [MockMessage("assistant", content)]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert result[0]["content"] == " "
def test_convert_assistant_message_tool_use_no_text():
# If tool usage exists, content can be empty string?
# Logic: if not content_str and not tool_calls: content_str = " "
# So if tool_calls exist, content_str can be empty string?
# Actually code says: if not content_str and not tool_calls.
# So if tool_calls is present, content_str remains "" (empty).
content = [MockBlock(type="tool_use", id="call_2", name="test", input={})]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_2", content="result")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert (
result[0]["content"] == ""
) # Should be empty string, not space, because tools exist
assert len(result[0]["tool_calls"]) == 1
def test_convert_mixed_blocks_and_types_and_roles():
# comprehensive flow
messages = [
MockMessage("user", "Start"),
MockMessage(
"assistant",
[
MockBlock(type="thinking", thinking="Thinking..."),
MockBlock(type="text", text="Here is a tool."),
],
),
MockMessage(
"assistant", [MockBlock(type="tool_use", id="t1", name="f", input={})]
),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="t1", content="result")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 4
assert result[0]["role"] == "user"
assert "<think>" in result[1]["content"]
assert result[2]["tool_calls"][0]["id"] == "t1"
# --- Edge Cases ---
def test_get_block_attr_defaults():
# Test helper directly
from free_claude_code.core.anthropic import get_block_attr
assert get_block_attr({}, "missing", "default") == "default"
assert get_block_attr(object(), "missing", "default") == "default"
def test_input_not_dict():
# Tool input might not be a dict (e.g. malformed or string)
content = [MockBlock(type="tool_use", id="call_x", name="f", input="some_string")]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_x", content="result")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
# The converter calls json.dumps(tool_input) if dict, else str(tool_input)
# So it should be "some_string"
assert result[0]["tool_calls"][0]["function"]["arguments"] == "some_string"
# --- Parametrized Edge Case Tests ---
@pytest.mark.parametrize(
"system_input,expected",
[
("You are helpful.", {"role": "system", "content": "You are helpful."}),
(
[MockBlock(type="text", text="A"), MockBlock(type="text", text="B")],
{"role": "system", "content": "A\n\nB"},
),
(None, None),
("", {"role": "system", "content": ""}),
([], None),
],
ids=["string", "list_text", "none", "empty_string", "empty_list"],
)
def test_convert_system_prompt_parametrized(system_input, expected):
"""Parametrized system prompt conversion covering edge cases."""
result = AnthropicToOpenAIConverter.convert_system_prompt(system_input)
assert result == expected
@pytest.mark.parametrize(
"content,expected_content",
[
("Hello world", "Hello world"),
("", ""),
([MockBlock(type="text", text="A"), MockBlock(type="text", text="B")], "A\nB"),
([MockBlock(type="text", text="")], ""),
],
ids=["simple_string", "empty_string", "list_blocks", "empty_text_block"],
)
def test_convert_user_message_parametrized(content, expected_content):
"""Parametrized user message conversion."""
messages = [MockMessage("user", content)]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) >= 1
assert result[0]["content"] == expected_content
def test_convert_assistant_message_unknown_block_type():
"""Unknown block types should be silently skipped."""
content = [
MockBlock(type="unknown_type", data="something"),
MockBlock(type="text", text="visible"),
]
messages = [MockMessage("assistant", content)]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 1
assert "visible" in result[0]["content"]
def test_convert_tool_use_none_input():
"""Tool use with None input should not crash."""
content = [MockBlock(type="tool_use", id="call_n", name="test", input=None)]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_n", content="result")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 2
assert "tool_calls" in result[0]
def test_convert_assistant_interleaved_order_preserved():
"""Interleaved thinking, text, tool_use should preserve thinking+text order in content.
Bug: Current implementation collects all thinking, then all text, then tool_calls.
Original order [thinking, text, thinking, tool_use] becomes [all thinking, all text, tool_calls],
losing the interleaving. Content string should reflect original block order for thinking+text.
Tool calls stay at end (API constraint).
"""
content = [
MockBlock(type="thinking", thinking="First thought."),
MockBlock(type="text", text="Here is the answer."),
MockBlock(type="thinking", thinking="Second thought."),
MockBlock(type="tool_use", id="call_1", name="search", input={"q": "x"}),
]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_1", content="result")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 2
msg = result[0]
# Expected: thinking1, text, thinking2 in that order within content; tool_calls at end
expected_content = "<think>\nFirst thought.\n</think>\n\nHere is the answer.\n\n<think>\nSecond thought.\n</think>"
assert msg["content"] == expected_content, (
f"Interleaved order lost. Got: {msg['content']!r}"
)
assert len(msg["tool_calls"]) == 1
def test_convert_user_message_text_before_tool_result_order():
"""User message with text then tool_result should preserve order: user text first, then tool.
Bug: Current implementation emits tool_result immediately, then user text at end.
Anthropic order is typically: user says something, then provides tool results.
"""
content = [
MockBlock(type="text", text="Please use this result:"),
MockBlock(type="tool_result", tool_use_id="t1", content="42"),
]
messages = [MockMessage("user", content)]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 2
# Expected: user text first, then tool result
assert result[0]["role"] == "user"
assert result[0]["content"] == "Please use this result:"
assert result[1]["role"] == "tool"
assert result[1]["tool_call_id"] == "t1"
def test_convert_multiple_tool_results():
"""Multiple tool results in a single user message."""
content = [
MockBlock(type="tool_result", tool_use_id="t1", content="Result 1"),
MockBlock(type="tool_result", tool_use_id="t2", content="Result 2"),
]
messages = [MockMessage("user", content)]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 2
assert result[0]["tool_call_id"] == "t1"
assert result[1]["tool_call_id"] == "t2"
def test_convert_user_message_tool_result_dict_as_json():
content = [
MockBlock(
type="tool_result",
tool_use_id="t_dict",
content={"ok": True, "count": 2},
),
]
messages = [MockMessage("user", content)]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert result[0]["role"] == "tool"
assert result[0]["content"] == '{"ok": true, "count": 2}'
def test_assistant_redacted_thinking_omitted_from_openai_chat():
"""Opaque redacted_thinking is not materialized as content or reasoning_content."""
content = [
MockBlock(type="redacted_thinking", data="secret-opaque"),
MockBlock(type="text", text="Visible."),
]
messages = [MockMessage("assistant", content)]
result = AnthropicToOpenAIConverter.convert_messages(
messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
)
assert result[0]["content"] == "Visible."
assert "secret-opaque" not in result[0]["content"]
assert "reasoning_content" not in result[0]
@pytest.mark.parametrize(
"source,expected_url",
[
(
{"type": "base64", "media_type": "image/png", "data": "AAAA"},
"data:image/png;base64,AAAA",
),
(
{"type": "url", "url": "https://example.com/image.png"},
"https://example.com/image.png",
),
],
)
def test_convert_user_message_image_sources(source, expected_url):
messages = [MockMessage("user", [MockBlock(type="image", source=source)])]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert result == [
{
"role": "user",
"content": [{"type": "image_url", "image_url": {"url": expected_url}}],
}
]
def test_convert_user_message_preserves_interleaved_image_text_order():
messages = [
MockMessage(
"user",
[
MockBlock(
type="image",
source={
"type": "base64",
"media_type": "image/jpeg",
"data": "FIRST",
},
),
MockBlock(type="text", text="Compare the first image with this one."),
MockBlock(
type="image",
source={"type": "url", "url": "https://example.com/second.jpg"},
),
MockBlock(type="text", text="Describe the differences."),
],
)
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert result == [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,FIRST"},
},
{
"type": "text",
"text": "Compare the first image with this one.",
},
{
"type": "image_url",
"image_url": {"url": "https://example.com/second.jpg"},
},
{"type": "text", "text": "Describe the differences."},
],
}
]
def test_convert_user_image_before_tool_result_preserves_message_order():
messages = [
MockMessage(
"user",
[
MockBlock(type="text", text="Inspect this."),
MockBlock(
type="image",
source={"type": "url", "url": "https://example.com/image.png"},
),
MockBlock(type="tool_result", tool_use_id="tool_1", content="done"),
],
)
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert result == [
{
"role": "user",
"content": [
{"type": "text", "text": "Inspect this."},
{
"type": "image_url",
"image_url": {"url": "https://example.com/image.png"},
},
],
},
{"role": "tool", "tool_call_id": "tool_1", "content": "done"},
]
@pytest.mark.parametrize(
"source,error",
[
(
{"type": "base64", "media_type": "", "data": "AAAA"},
"non-empty media_type",
),
(
{"type": "base64", "media_type": "image/png", "data": ""},
"non-empty data",
),
({"type": "url", "url": ""}, "non-empty url"),
({"type": "file", "file_id": "file_1"}, "Unsupported image source type"),
({}, "Unsupported image source type"),
],
)
def test_convert_user_message_rejects_unconvertible_image_sources(source, error):
messages = [MockMessage("user", [MockBlock(type="image", source=source)])]
with pytest.raises(OpenAIConversionError, match=error):
AnthropicToOpenAIConverter.convert_messages(messages)
def test_convert_assistant_text_after_tool_use_requires_matching_tool_result():
"""Dangling post-tool assistant text cannot be replayed as valid OpenAI chat."""
content = [
MockBlock(type="tool_use", id="call_z", name="Read", input={}),
MockBlock(type="text", text="After tool"),
]
messages = [MockMessage("assistant", content)]
with pytest.raises(OpenAIConversionError, match="missing tool_result"):
AnthropicToOpenAIConverter.convert_messages(messages)
def test_convert_assistant_text_after_tool_use_inserts_after_tool_results():
messages = [
MockMessage(
"assistant",
[
MockBlock(type="tool_use", id="call_z", name="Read", input={}),
MockBlock(type="text", text="Post-tool commentary"),
],
),
MockMessage(
"user",
[
MockBlock(
type="tool_result",
tool_use_id="call_z",
content="file contents",
)
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert result[0]["role"] == "assistant" and "tool_calls" in result[0]
assert result[1]["role"] == "tool" and result[1]["tool_call_id"] == "call_z"
assert result[2] == {"role": "assistant", "content": "Post-tool commentary"}
def test_unrelated_user_text_before_tool_result_is_buffered_until_after_tool_result():
messages = [
MockMessage(
"assistant",
[MockBlock(type="tool_use", id="call_z", name="Read", input={})],
),
MockMessage("user", "Please also summarize it."),
MockMessage(
"user",
[
MockBlock(
type="tool_result",
tool_use_id="call_z",
content="file contents",
)
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert [message["role"] for message in result] == ["assistant", "tool", "user"]
assert result[0]["tool_calls"][0]["id"] == "call_z"
assert result[1]["tool_call_id"] == "call_z"
assert result[2]["content"] == "Please also summarize it."
def test_unrelated_assistant_text_before_tool_result_is_buffered_until_after_tool_result():
messages = [
MockMessage(
"assistant",
[MockBlock(type="tool_use", id="call_z", name="Read", input={})],
),
MockMessage("assistant", "Waiting for the result."),
MockMessage(
"user",
[
MockBlock(
type="tool_result",
tool_use_id="call_z",
content="file contents",
)
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert [message["role"] for message in result] == [
"assistant",
"tool",
"assistant",
]
assert result[0]["tool_calls"][0]["id"] == "call_z"
assert result[1]["tool_call_id"] == "call_z"
assert result[2]["content"] == "Waiting for the result."
def test_user_text_in_tool_result_message_is_replayed_after_tool_sequence():
messages = [
MockMessage(
"assistant",
[
MockBlock(type="tool_use", id="call_z", name="Read", input={}),
MockBlock(type="text", text="Post-tool commentary"),
],
),
MockMessage(
"user",
[
MockBlock(type="text", text="Use this result too."),
MockBlock(
type="tool_result",
tool_use_id="call_z",
content="file contents",
),
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert [message["role"] for message in result] == [
"assistant",
"tool",
"assistant",
"user",
]
assert result[1]["tool_call_id"] == "call_z"
assert result[2]["content"] == "Post-tool commentary"
assert result[3]["content"] == "Use this result too."
def test_nested_pending_tool_use_waits_for_its_own_tool_result_before_deferred_text():
messages = [
MockMessage(
"assistant",
[MockBlock(type="tool_use", id="call_a", name="ReadA", input={})],
),
MockMessage(
"assistant",
[
MockBlock(type="tool_use", id="call_b", name="ReadB", input={}),
MockBlock(type="text", text="Post-call-b commentary"),
],
),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_a", content="result a")],
),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_b", content="result b")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert [message["role"] for message in result] == [
"assistant",
"tool",
"assistant",
"tool",
"assistant",
]
assert result[0]["tool_calls"][0]["id"] == "call_a"
assert result[1]["tool_call_id"] == "call_a"
assert result[2]["tool_calls"][0]["id"] == "call_b"
assert result[3]["tool_call_id"] == "call_b"
assert result[4]["content"] == "Post-call-b commentary"
def test_nested_pending_uses_early_nested_tool_result_after_outer_result():
messages = [
MockMessage(
"assistant",
[MockBlock(type="tool_use", id="call_a", name="ReadA", input={})],
),
MockMessage(
"assistant",
[
MockBlock(type="tool_use", id="call_b", name="ReadB", input={}),
MockBlock(type="text", text="Post-call-b commentary"),
],
),
MockMessage(
"user",
[
MockBlock(type="tool_result", tool_use_id="call_b", content="result b"),
MockBlock(type="tool_result", tool_use_id="call_a", content="result a"),
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert [message["role"] for message in result] == [
"assistant",
"tool",
"assistant",
"tool",
"assistant",
]
assert result[0]["tool_calls"][0]["id"] == "call_a"
assert result[1]["tool_call_id"] == "call_a"
assert result[2]["tool_calls"][0]["id"] == "call_b"
assert result[3]["tool_call_id"] == "call_b"
assert result[4]["content"] == "Post-call-b commentary"
def test_multi_tool_turn_waits_for_all_results_before_deferred_text():
messages = [
MockMessage(
"assistant",
[
MockBlock(type="tool_use", id="call_a", name="ReadA", input={}),
MockBlock(type="tool_use", id="call_b", name="ReadB", input={}),
MockBlock(type="text", text="Both tools are done."),
],
),
MockMessage(
"user",
[
MockBlock(type="tool_result", tool_use_id="call_b", content="result b"),
MockBlock(type="tool_result", tool_use_id="call_a", content="result a"),
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert [message["role"] for message in result] == [
"assistant",
"tool",
"tool",
"assistant",
]
assert [message["tool_call_id"] for message in result[1:3]] == [
"call_a",
"call_b",
]
assert result[3]["content"] == "Both tools are done."
def test_nested_pending_buffers_user_text_until_all_prior_tool_sequences_complete():
messages = [
MockMessage(
"assistant",
[MockBlock(type="tool_use", id="call_a", name="ReadA", input={})],
),
MockMessage(
"assistant",
[
MockBlock(type="tool_use", id="call_b", name="ReadB", input={}),
MockBlock(type="text", text="Post-call-b commentary"),
],
),
MockMessage(
"user",
[
MockBlock(type="text", text="Use both results."),
MockBlock(
type="tool_result",
tool_use_id="call_a",
content="result a",
),
MockBlock(
type="tool_result",
tool_use_id="call_b",
content="result b",
),
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert [message["role"] for message in result] == [
"assistant",
"tool",
"assistant",
"tool",
"assistant",
"user",
]
assert result[1]["tool_call_id"] == "call_a"
assert result[3]["tool_call_id"] == "call_b"
assert result[4]["content"] == "Post-call-b commentary"
assert result[5]["content"] == "Use both results."
def test_openai_build_accepts_declared_native_top_level_hints() -> None:
"""OpenAI conversion ignores known non-OpenAI hints (e.g. context_management) without 400."""
req = MessagesRequest.model_validate(
{
"model": "m",
"messages": [{"role": "user", "content": "h"}],
"context_management": {"edits": []},
"output_config": {"foo": 1},
"mcp_servers": [{"type": "url", "url": "https://x.com"}],
}
)
body = build_base_request_body(req, default_max_tokens=100)
assert "context_management" not in body
assert "output_config" not in body
assert "mcp_servers" not in body
assert body["model"] == "m"
def test_openai_build_converts_validated_anthropic_image_block() -> None:
request = MessagesRequest.model_validate(
{
"model": "vision-model",
"messages": [
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/webp",
"data": "AAAA",
},
},
{"type": "text", "text": "What is shown?"},
],
}
],
}
)
body = build_base_request_body(request)
assert body["messages"] == [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": "data:image/webp;base64,AAAA"},
},
{"type": "text", "text": "What is shown?"},
],
}
]
def test_openai_build_rejects_unknown_top_level_extras() -> None:
"""Truly unknown keys must still be rejected (not dropped silently)."""
req = MessagesRequest.model_validate(
{
"model": "m",
"messages": [{"role": "user", "content": "h"}],
"experimental_client_only_passthrough": True,
}
)
with pytest.raises(OpenAIConversionError, match="top-level request fields"):
build_base_request_body(req)
@pytest.mark.parametrize(
"content",
[
[MockBlock(type="server_tool_use", id="1", name="web_search", input={})],
[MockBlock(type="web_search_tool_result", tool_use_id="1", content=[])],
[
MockBlock(
type="web_fetch_tool_result",
tool_use_id="1",
content={"type": "web_fetch_result", "url": "https://a.com/x"},
)
],
],
)
def test_convert_assistant_server_tool_blocks_raise(content) -> None:
messages = [MockMessage("assistant", content)]
with pytest.raises(OpenAIConversionError, match="server tool"):
AnthropicToOpenAIConverter.convert_messages(messages)