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.
|
// old raw_output machinery.
|
||||||
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
|
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
|
||||||
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
|
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 http_request = self.post(self.endpoint("responses")).json(&request_body);
|
||||||
|
|
||||||
let response = http_request.send().await.map_err(|e| {
|
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_text_types(&mut request_body);
|
||||||
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
|
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`
|
// Fresh per attempt so signals never leak across retries; `None`
|
||||||
// (check disabled) sends no header and does no peek work per event.
|
// (check disabled) sends no header and does no peek work per event.
|
||||||
let doom_loop = self
|
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
|
/// Neutralize a `reasoning.effort` echo the typed `rs` enum cannot parse
|
||||||
/// (`max`): remove it so response deserialization succeeds. The turn's
|
/// (`max`): remove it so response deserialization succeeds. The turn's
|
||||||
/// canonical effort lives in the session sampling config regardless; only
|
/// canonical effort lives in the session sampling config regardless; only
|
||||||
@@ -1551,6 +1616,66 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
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
|
/// String content (the only shape non-Mistral providers send) stays the
|
||||||
/// answer verbatim with no thinking — byte-identical to the pre-change
|
/// answer verbatim with no thinking — byte-identical to the pre-change
|
||||||
/// deserialization.
|
/// deserialization.
|
||||||
|
|||||||
@@ -1469,3 +1469,79 @@ async fn test_chat_completions_backend_hits_chat_endpoint_not_responses() {
|
|||||||
"Should NOT have called /v1/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