fix: collapse mid-stream system messages in Codex Responses to Chat conversion

MiniMax's OpenAI-compatible chat endpoint strict-rejects any non-leading
role=system message with "invalid params, chat content has invalid message
role: system (2013)". The Codex client uses role=developer (and occasionally
role=system) to inject collaboration_mode / permissions / skills blocks
mid-conversation, and responses_role_to_chat_role maps both to chat's
system role. The converted messages array therefore frequently contained
system entries past index 0, which DeepSeek and OpenAI tolerate but MiniMax
flags as 2013.

Collapse all system messages into a single leading system message before
returning the chat request. The rewrite preserves every system fragment
(joined by "\n\n" in original order) and leaves non-system messages
untouched, so it is lossless for permissive backends as well.

- Add collapse_system_messages_to_head in transform_codex_chat.rs.
- Run it on the messages vector at the end of responses_to_chat_completions
  before serializing.
- Cover the new path with two unit tests: one repros the MiniMax-shaped
  input (developer items between users) and asserts no system role past
  index 0; the other verifies non-system order is preserved and content
  is joined with "\n\n".
This commit is contained in:
Jason
2026-05-21 00:06:45 +08:00
parent f2935a3db9
commit 9d35709805
@@ -52,6 +52,7 @@ pub fn responses_to_chat_completions(body: Value) -> Result<Value, ProxyError> {
if let Some(input) = body.get("input") {
append_responses_input_as_chat_messages(input, &mut messages)?;
}
let messages = collapse_system_messages_to_head(messages);
result["messages"] = json!(messages);
let model = body.get("model").and_then(|v| v.as_str()).unwrap_or("");
@@ -104,6 +105,38 @@ pub fn responses_to_chat_completions(body: Value) -> Result<Value, ProxyError> {
Ok(result)
}
/// MiniMax 严格要求 messages 中只能首条出现 `role=system`
/// 否则返回 `invalid params, chat content has invalid message role: system (2013)`。
/// 把所有 system 消息合并到首位,避免中间 system(如 Codex 的 `developer` 指令)触发该约束;
/// 该重排对 OpenAI / DeepSeek 等宽松兼容层也是无损的。
fn collapse_system_messages_to_head(messages: Vec<Value>) -> Vec<Value> {
let mut system_chunks: Vec<String> = Vec::new();
let mut rest: Vec<Value> = Vec::with_capacity(messages.len());
for msg in messages {
if msg.get("role").and_then(|v| v.as_str()) == Some("system") {
if let Some(text) = msg.get("content").and_then(|v| v.as_str()) {
let trimmed = text.trim();
if !trimmed.is_empty() {
system_chunks.push(text.to_string());
}
continue;
}
}
rest.push(msg);
}
let mut out: Vec<Value> = Vec::with_capacity(rest.len() + 1);
if !system_chunks.is_empty() {
out.push(json!({
"role": "system",
"content": system_chunks.join("\n\n")
}));
}
out.extend(rest);
out
}
fn instruction_text(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
@@ -934,6 +967,61 @@ mod tests {
assert_eq!(messages[2]["content"], "Fallback content.");
}
#[test]
fn responses_request_to_chat_merges_mid_stream_system_into_head() {
let input = json!({
"model": "MiniMax-M2.7",
"instructions": "You are Codex.",
"input": [
{"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "Permissions block"}]},
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "AGENTS.md"}]},
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "你好"}]},
{"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "Collaboration Mode: Default"}]},
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "你好"}]},
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "你好"}]}
]
});
let result = responses_to_chat_completions(input).unwrap();
let messages = result["messages"].as_array().unwrap();
for (idx, msg) in messages.iter().enumerate() {
let role = msg.get("role").and_then(|v| v.as_str()).unwrap();
if idx == 0 {
assert_eq!(role, "system", "first message must be system");
} else {
assert_ne!(
role, "system",
"no system role allowed past index 0 (got at {idx})"
);
}
}
let head_content = messages[0]["content"].as_str().unwrap();
assert!(head_content.contains("You are Codex."));
assert!(head_content.contains("Permissions block"));
assert!(head_content.contains("Collaboration Mode: Default"));
}
#[test]
fn collapse_system_messages_preserves_non_system_order() {
let input = vec![
json!({"role": "system", "content": "S1"}),
json!({"role": "user", "content": "U1"}),
json!({"role": "assistant", "content": "A1"}),
json!({"role": "system", "content": "S2"}),
json!({"role": "user", "content": "U2"}),
];
let out = collapse_system_messages_to_head(input);
assert_eq!(out.len(), 4);
assert_eq!(out[0]["role"], "system");
assert_eq!(out[0]["content"], "S1\n\nS2");
assert_eq!(out[1]["content"], "U1");
assert_eq!(out[2]["content"], "A1");
assert_eq!(out[3]["content"], "U2");
}
#[test]
fn responses_request_to_chat_passes_reasoning_content_back_to_assistant_message() {
let input = json!({