fix(codex): adapt the Responses body to the ChatGPT/Codex backend contract
Root cause of 400 {"detail":"System messages are not allowed"} at
chatgpt.com/backend-api/codex/responses (both platforms): the codex
adaptation covered only IDENTITY HEADERS (originator/OpenAI-Beta/UA/
chatgpt-account-id) — the BODY still carried the system prompt as
role:system input items, which the codex backend rejects outright. Its
system channel is the top-level field, and stateless
(store:false) reasoning replay requires
include:["reasoning.encrypted_content"] — both per the same reference
the headers were ported from (official Codex CLI + Pi's
api/openai-codex-responses.ts).
New adapt_body_for_codex_backend (kigi-sampling-types): hoists every
system input item into (order preserved, appended to any
existing instructions; string and parts content shapes) and requests
encrypted reasoning. Idempotent. Applied at both Responses send paths,
openai_codex-GATED — the API-key path stays byte-identical
(pinned by a control wire test).
Tests: adapter unit tests (hoist+include, no-system no-op, idempotence)
plus two mock-server wire tests (codex body has no system role,
instructions + include present; plain Responses body unchanged).
Verified: sampling-types + sampler + chat-state + shell 6076 tests
green, clippy clean.
This commit is contained in:
@@ -1183,6 +1183,9 @@ impl SamplingClient {
|
||||
// old raw_output machinery.
|
||||
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
|
||||
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
|
||||
if self.defaults.openai_codex {
|
||||
kigi_sampling_types::adapt_body_for_codex_backend(&mut request_body);
|
||||
}
|
||||
let http_request = self.post(self.endpoint("responses")).json(&request_body);
|
||||
|
||||
let response = http_request.send().await.map_err(|e| {
|
||||
@@ -1321,6 +1324,9 @@ impl SamplingClient {
|
||||
}
|
||||
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
|
||||
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
|
||||
if self.defaults.openai_codex {
|
||||
kigi_sampling_types::adapt_body_for_codex_backend(&mut request_body);
|
||||
}
|
||||
// Fresh per attempt so signals never leak across retries; `None`
|
||||
// (check disabled) sends no header and does no peek work per event.
|
||||
let doom_loop = self
|
||||
|
||||
@@ -1041,6 +1041,71 @@ pub fn patch_reasoning_effort(body: &mut Value, effort: Option<ReasoningEffort>)
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapt a serialized Responses request body to the ChatGPT/Codex backend
|
||||
/// contract (`chatgpt.com/backend-api/codex/responses`) — ported from the
|
||||
/// same reference as the identity headers (official Codex CLI + Pi's
|
||||
/// `api/openai-codex-responses.ts`):
|
||||
///
|
||||
/// 1. The backend rejects `role: system` input items outright
|
||||
/// (400 `{"detail":"System messages are not allowed"}`); its system
|
||||
/// channel is the top-level `instructions` field. Hoist every system
|
||||
/// input message there (order preserved, blank-line joined, appended to
|
||||
/// any existing instructions) and remove them from `input`.
|
||||
/// 2. `store` is always `false` on this backend, so reasoning continuity
|
||||
/// is stateless: `include: ["reasoning.encrypted_content"]` is required
|
||||
/// for the response to carry replayable encrypted reasoning.
|
||||
///
|
||||
/// openai-codex-GATED at the call sites — API-key `openai` Responses
|
||||
/// bodies stay byte-identical.
|
||||
pub fn adapt_body_for_codex_backend(body: &mut Value) {
|
||||
// 1. Hoist system messages into `instructions`.
|
||||
let mut hoisted: Vec<String> = Vec::new();
|
||||
if let Some(input) = body.get_mut("input").and_then(|v| v.as_array_mut()) {
|
||||
input.retain(|item| {
|
||||
let is_system = item.get("role").and_then(|r| r.as_str()) == Some("system");
|
||||
if is_system {
|
||||
match item.get("content") {
|
||||
Some(Value::String(s)) => hoisted.push(s.clone()),
|
||||
Some(Value::Array(parts)) => {
|
||||
for p in parts {
|
||||
if let Some(t) = p.get("text").and_then(|t| t.as_str()) {
|
||||
hoisted.push(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
!is_system
|
||||
});
|
||||
}
|
||||
if !hoisted.is_empty() {
|
||||
let mut instructions = body
|
||||
.get("instructions")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_default();
|
||||
for part in hoisted {
|
||||
if !instructions.is_empty() {
|
||||
instructions.push_str("\n\n");
|
||||
}
|
||||
instructions.push_str(&part);
|
||||
}
|
||||
body["instructions"] = Value::String(instructions);
|
||||
}
|
||||
|
||||
// 2. Request replayable encrypted reasoning.
|
||||
let include = body
|
||||
.as_object_mut()
|
||||
.map(|obj| obj.entry("include").or_insert_with(|| Value::Array(vec![])));
|
||||
if let Some(Value::Array(entries)) = include {
|
||||
let key = Value::String("reasoning.encrypted_content".to_string());
|
||||
if !entries.contains(&key) {
|
||||
entries.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Neutralize a `reasoning.effort` echo the typed `rs` enum cannot parse
|
||||
/// (`max`): remove it so response deserialization succeeds. The turn's
|
||||
/// canonical effort lives in the session sampling config regardless; only
|
||||
@@ -1551,6 +1616,66 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// The Codex backend rejects `role: system` input outright
|
||||
/// (400 `{"detail":"System messages are not allowed"}`) — its system
|
||||
/// channel is the top-level `instructions` field, and stateless
|
||||
/// (`store: false`) reasoning replay needs
|
||||
/// `include: ["reasoning.encrypted_content"]`. The adapter must hoist
|
||||
/// every system item (string or parts content, order preserved),
|
||||
/// append to existing instructions, and leave the rest of the input
|
||||
/// untouched.
|
||||
#[test]
|
||||
fn codex_adapter_hoists_system_messages_and_requests_encrypted_reasoning() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.2-codex",
|
||||
"instructions": "base",
|
||||
"input": [
|
||||
{"type": "message", "role": "system", "content": "sys head"},
|
||||
{"type": "message", "role": "user", "content": "hello"},
|
||||
{"type": "message", "role": "system", "content": [
|
||||
{"type": "input_text", "text": "memory reminder"}
|
||||
]},
|
||||
{"type": "message", "role": "assistant", "content": "hi"}
|
||||
]
|
||||
});
|
||||
adapt_body_for_codex_backend(&mut body);
|
||||
|
||||
let input = body["input"].as_array().unwrap();
|
||||
assert_eq!(input.len(), 2, "system items removed from input: {body:#}");
|
||||
assert!(
|
||||
input.iter().all(|i| i["role"] != "system"),
|
||||
"no system role may remain: {body:#}"
|
||||
);
|
||||
assert_eq!(
|
||||
body["instructions"], "base\n\nsys head\n\nmemory reminder",
|
||||
"system content hoisted into instructions, order preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
body["include"],
|
||||
json!(["reasoning.encrypted_content"]),
|
||||
"stateless reasoning replay requires the include"
|
||||
);
|
||||
|
||||
// Idempotent: a second pass changes nothing.
|
||||
let before = body.clone();
|
||||
adapt_body_for_codex_backend(&mut body);
|
||||
assert_eq!(body, before);
|
||||
}
|
||||
|
||||
/// No system items and no prior instructions: input untouched, no
|
||||
/// empty-string instructions invented, include still requested.
|
||||
#[test]
|
||||
fn codex_adapter_without_system_messages_only_adds_include() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.2-codex",
|
||||
"input": [{"type": "message", "role": "user", "content": "q"}]
|
||||
});
|
||||
adapt_body_for_codex_backend(&mut body);
|
||||
assert!(body.get("instructions").is_none(), "{body:#}");
|
||||
assert_eq!(body["input"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
|
||||
}
|
||||
|
||||
/// String content (the only shape non-Mistral providers send) stays the
|
||||
/// answer verbatim with no thinking — byte-identical to the pre-change
|
||||
/// deserialization.
|
||||
|
||||
@@ -1469,3 +1469,79 @@ async fn test_chat_completions_backend_hits_chat_endpoint_not_responses() {
|
||||
"Should NOT have called /v1/responses"
|
||||
);
|
||||
}
|
||||
|
||||
/// ChatGPT/Codex backend body contract (`openai_codex = true`): the
|
||||
/// `/codex/responses` endpoint rejects `role: system` input outright
|
||||
/// (400 {"detail":"System messages are not allowed"}) — system content
|
||||
/// must ride the top-level `instructions` field, and stateless reasoning
|
||||
/// replay needs `include: ["reasoning.encrypted_content"]`. Ported from
|
||||
/// the official Codex CLI + Pi's api/openai-codex-responses.ts, like the
|
||||
/// identity headers.
|
||||
#[tokio::test]
|
||||
async fn codex_responses_body_hoists_system_into_instructions() {
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
server.set_response("ok");
|
||||
let mut config = common::test_sampler_config(&server.url(), ApiBackend::Responses, &[]);
|
||||
config.openai_codex = true;
|
||||
let client = Client::new(config).unwrap();
|
||||
|
||||
let _ = client
|
||||
.conversation_collect(ConversationRequest::from_items(vec![
|
||||
ConversationItem::system("You are Kigi."),
|
||||
ConversationItem::user("test"),
|
||||
]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = server.request_bodies().pop().unwrap();
|
||||
let input = body["input"].as_array().unwrap();
|
||||
assert!(
|
||||
input
|
||||
.iter()
|
||||
.all(|i| i.get("role").and_then(Value::as_str) != Some("system")),
|
||||
"codex backend must never receive system-role input: {body:#?}"
|
||||
);
|
||||
assert_eq!(
|
||||
body["instructions"].as_str(),
|
||||
Some("You are Kigi."),
|
||||
"system prompt must ride the instructions field: {body:#?}"
|
||||
);
|
||||
assert_eq!(
|
||||
body["include"],
|
||||
serde_json::json!(["reasoning.encrypted_content"]),
|
||||
"stateless reasoning replay requires the include: {body:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Control: the API-key `openai` Responses path (`openai_codex = false`)
|
||||
/// stays byte-compatible — system-role input preserved, no codex fields.
|
||||
#[tokio::test]
|
||||
async fn plain_responses_body_keeps_system_role_input() {
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
server.set_response("ok");
|
||||
let client = create_test_client(&server.url(), ApiBackend::Responses);
|
||||
|
||||
let _ = client
|
||||
.conversation_collect(ConversationRequest::from_items(vec![
|
||||
ConversationItem::system("You are Kigi."),
|
||||
ConversationItem::user("test"),
|
||||
]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = server.request_bodies().pop().unwrap();
|
||||
assert!(
|
||||
body["input"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|i| i.get("role").and_then(Value::as_str) == Some("system")),
|
||||
"api-key openai keeps system-role input: {body:#?}"
|
||||
);
|
||||
assert!(
|
||||
body.get("instructions")
|
||||
.map(|v| v.is_null())
|
||||
.unwrap_or(true),
|
||||
"no instructions hoist outside codex: {body:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user