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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions src/anthropic/translate.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -229,15 +229,36 @@ Json RequestToOpenAI(const Json& anthropic, const std::string& model) {
Json messages = Json::array();
// Anthropic carries the system prompt beside the conversation; OpenAI wants
// it as the first message, so it is moved rather than dropped.
if (anthropic.contains("system")) {
const std::string system = FlattenContent(anthropic["system"]);
if (!system.empty()) {
messages.push_back({{"role", "system"}, {"content", system}});
//
// Claude Code also puts `role: "system"` turns INSIDE `messages` (its
// environment block arrives that way, after the first user turn). Passed
// through, that is a system message mid-conversation, and Qwen's chat
// template refuses the whole request with "System message must be at the
// beginning." while GLM and Gemma quietly accept it. So every system turn
// is folded into the one leading system message, in order, and none is
// left in the conversation.
std::string system =
anthropic.contains("system") ? FlattenContent(anthropic["system"]) : std::string();
const bool has_messages = anthropic.contains("messages") && anthropic["messages"].is_array();
if (has_messages) {
for (const Json& message : anthropic["messages"]) {
if (!message.is_object() || Field(message, "role") != "system") {
continue;
}
const std::string text =
FlattenContent(message.contains("content") ? message["content"] : Json());
if (text.empty()) {
continue;
}
system += system.empty() ? text : "\n\n" + text;
}
}
if (anthropic.contains("messages") && anthropic["messages"].is_array()) {
if (!system.empty()) {
messages.push_back({{"role", "system"}, {"content", system}});
}
if (has_messages) {
for (const Json& message : anthropic["messages"]) {
if (!message.is_object()) {
if (!message.is_object() || Field(message, "role") == "system") {
continue;
}
AppendMessage(message, &messages);
Expand Down
57 changes: 57 additions & 0 deletions tests/test_wally_unit.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2648,6 +2648,61 @@ TestResult test_stream_usage_reports_input_tokens() {

} // namespace

TestResult test_system_turns_fold_into_the_leading_system_message() {
TestResult result;
result.test_name = "system_turns_fold_into_the_leading_system_message";
namespace tr = wally::anthropic::translate;

// The shape Claude Code 2.1.268 really sends, captured through wally's shim:
// a top-level `system` array AND a `role: "system"` turn inside `messages`,
// after the first user turn (its environment block). Passed through as-is,
// OpenAI gets [system, user, system], and Qwen's chat template refuses the
// whole request: "System message must be at the beginning."
const nlohmann::json anthropic = nlohmann::json::parse(R"({
"model": "qwen3.8-27b",
"system": [{"type": "text", "text": "You are Claude Code."}],
"messages": [
{"role": "user", "content": [{"type": "text", "text": "Reply with pong"}]},
{"role": "system", "content": [{"type": "text", "text": "# Environment\nPlatform: darwin"}]}
]
})");
const nlohmann::json openai = tr::RequestToOpenAI(anthropic, "qwen3.8-27b");
const nlohmann::json& messages = openai["messages"];

int system_count = 0;
for (std::size_t i = 0; i < messages.size(); ++i) {
if (messages[i].value("role", "") != "system") {
continue;
}
++system_count;
if (i != 0) {
result.details = "a system message sits at index " + std::to_string(i) +
", which Qwen refuses; got: " + messages.dump().substr(0, 300);
return result;
}
}
if (system_count != 1) {
result.expected = "exactly one system message";
result.actual = std::to_string(system_count) + ": " + messages.dump().substr(0, 300);
return result;
}
const std::string system = messages[0].value("content", "");
const auto top = system.find("You are Claude Code.");
const auto env = system.find("# Environment");
if (top == std::string::npos || env == std::string::npos || env < top) {
result.details = "the leading system message must carry both texts, top-level first; got: " +
system.substr(0, 300);
return result;
}
if (messages.size() != 2 || messages[1].value("role", "") != "user") {
result.details = "the user turn must follow the system message and nothing else; got: " +
messages.dump().substr(0, 300);
return result;
}
result.passed = true;
return result;
}

int main(int argc, char **argv) {
TestSuite suite("wally_unit");
suite.add("json_escape", test_json_escape);
Expand Down Expand Up @@ -2693,5 +2748,7 @@ int main(int argc, char **argv) {
suite.add("upstream_failure_mapping", test_upstream_failure_mapping);
suite.add("passthrough_argv_split", test_passthrough_argv_split);
suite.add("stream_usage_reports_input_tokens", test_stream_usage_reports_input_tokens);
suite.add("system_turns_fold_into_the_leading_system_message",
test_system_turns_fold_into_the_leading_system_message);
return suite.run(argc, argv);
}
Loading