use serde::{Deserialize, Serialize};
use super::client::{map_error, Client};
use super::sse::SseParser;
use crate::commit_msg::prompt::ChatMessage;
use crate::error::{Error, Result};
pub const FALLBACK_MODEL: &str = "gpt-5.5";
#[derive(Debug, Serialize)]
struct ResponsesRequest<'a> {
model: &'a str,
#[serde(skip_serializing_if = "str::is_empty")]
instructions: &'a str,
input: Vec<InputItem<'a>>,
tools: [(); 0],
tool_choice: &'static str,
parallel_tool_calls: bool,
stream: bool,
store: bool,
include: [(); 0],
client_metadata: ClientMetadata<'a>,
}
#[derive(Debug, Serialize)]
struct InputItem<'a> {
#[serde(rename = "type")]
kind: &'static str,
role: &'static str,
content: Vec<InputContent<'a>>,
}
#[derive(Debug, Serialize)]
struct InputContent<'a> {
#[serde(rename = "type")]
kind: &'static str,
text: &'a str,
}
#[derive(Debug, Serialize)]
struct ClientMetadata<'a> {
#[serde(rename = "x-codex-installation-id")]
installation_id: &'a str,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
enum StreamEvent {
#[serde(rename = "response.output_text.delta")]
OutputTextDelta { delta: String },
#[serde(rename = "response.completed")]
Completed,
#[serde(rename = "response.failed")]
Failed {
#[serde(default)]
response: Option<FailedResponse>,
},
#[serde(rename = "response.incomplete")]
Incomplete,
#[serde(other)]
Other,
}
#[derive(Debug, Deserialize)]
struct FailedResponse {
#[serde(default)]
error: Option<FailedError>,
}
#[derive(Debug, Deserialize)]
struct FailedError {
#[serde(default)]
message: Option<String>,
}
impl Client {
pub async fn chat(&self, model: &str, messages: &[ChatMessage]) -> Result<String> {
let (instructions, input) = build_input(messages);
let req = ResponsesRequest {
model,
instructions: &instructions,
input,
tools: [],
tool_choice: "auto",
parallel_tool_calls: false,
stream: true,
store: false,
include: [],
client_metadata: ClientMetadata {
installation_id: self.session_id(),
},
};
let url = format!("{}/responses", self.base_url());
let resp = self
.http()
.post(url)
.headers(self.headers())
.header("Accept", "text/event-stream")
.json(&req)
.send()
.await?;
if !resp.status().is_success() {
return Err(map_error(resp).await);
}
let mut parser = SseParser::new();
let mut text = String::new();
let mut completed = false;
let mut response = resp;
while let Some(chunk) = response.chunk().await? {
parser.push(&chunk);
while let Some(ev) = parser.next_event() {
match parse_event(&ev.data) {
StreamEvent::OutputTextDelta { delta } => text.push_str(&delta),
StreamEvent::Completed => {
completed = true;
}
StreamEvent::Failed { response } => {
let msg = response
.and_then(|r| r.error)
.and_then(|e| e.message)
.unwrap_or_else(|| "codex stream reported failure".into());
return Err(Error::CodexServer {
status: 200,
body: msg,
});
}
StreamEvent::Incomplete => {
return Err(Error::CodexServer {
status: 200,
body: "codex stream ended incomplete".into(),
});
}
StreamEvent::Other => {}
}
}
if completed {
break;
}
}
if !completed && text.is_empty() {
return Err(Error::EmptyModelResponse);
}
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
return Err(Error::EmptyModelResponse);
}
Ok(trimmed)
}
}
fn parse_event(data: &str) -> StreamEvent {
serde_json::from_str(data).unwrap_or(StreamEvent::Other)
}
fn build_input<'a>(messages: &'a [ChatMessage]) -> (String, Vec<InputItem<'a>>) {
let mut instructions = String::new();
let mut input = Vec::new();
for msg in messages {
if msg.role == "system" {
if !instructions.is_empty() {
instructions.push_str("\n\n");
}
instructions.push_str(&msg.content);
continue;
}
let role = match msg.role {
"assistant" => "assistant",
_ => "user",
};
let content_kind = if role == "assistant" {
"output_text"
} else {
"input_text"
};
input.push(InputItem {
kind: "message",
role,
content: vec![InputContent {
kind: content_kind,
text: &msg.content,
}],
});
}
(instructions, input)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codex::client::Client;
use crate::commit_msg::prompt::ChatMessage;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn sse(events: &[(&str, serde_json::Value)]) -> String {
let mut out = String::new();
for (event, data) in events {
out.push_str("event: ");
out.push_str(event);
out.push('\n');
out.push_str("data: ");
out.push_str(&serde_json::to_string(data).unwrap());
out.push_str("\n\n");
}
out
}
#[test]
fn build_input_lifts_system_to_instructions() {
let messages = vec![ChatMessage::system("be terse"), ChatMessage::user("hello")];
let (instructions, input) = build_input(&messages);
assert_eq!(instructions, "be terse");
assert_eq!(input.len(), 1);
assert_eq!(input[0].role, "user");
assert_eq!(input[0].content[0].kind, "input_text");
assert_eq!(input[0].content[0].text, "hello");
}
#[test]
fn build_input_concatenates_multiple_system_messages() {
let messages = vec![
ChatMessage::system("rule one"),
ChatMessage::system("rule two"),
ChatMessage::user("go"),
];
let (instructions, _input) = build_input(&messages);
assert_eq!(instructions, "rule one\n\nrule two");
}
#[tokio::test]
async fn chat_assembles_text_deltas_and_returns_on_completed() {
let server = MockServer::start().await;
let body = sse(&[
(
"response.created",
serde_json::json!({"type":"response.created","response":{"id":"r1"}}),
),
(
"response.output_text.delta",
serde_json::json!({"type":"response.output_text.delta","delta":"feat: "}),
),
(
"response.output_text.delta",
serde_json::json!({"type":"response.output_text.delta","delta":"add x"}),
),
(
"response.completed",
serde_json::json!({"type":"response.completed","response":{"id":"r1"}}),
),
]);
Mock::given(method("POST"))
.and(path("/responses"))
.and(header("Authorization", "Bearer at_xxx"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(body),
)
.mount(&server)
.await;
let http = reqwest::Client::new();
let client = Client::with_base(http, "at_xxx", Some("acct_abc"), server.uri());
let out = client
.chat("gpt-5", &[ChatMessage::user("hi")])
.await
.unwrap();
assert_eq!(out, "feat: add x");
}
#[tokio::test]
async fn chat_maps_401_to_codex_auth() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/responses"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let http = reqwest::Client::new();
let client = Client::with_base(http, "at", None, server.uri());
let err = client
.chat("gpt-5", &[ChatMessage::user("hi")])
.await
.unwrap_err();
assert!(matches!(err, Error::CodexAuth), "got {err:?}");
}
#[tokio::test]
async fn chat_maps_429_with_retry_after() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/responses"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "11"))
.mount(&server)
.await;
let http = reqwest::Client::new();
let client = Client::with_base(http, "at", None, server.uri());
let err = client
.chat("gpt-5", &[ChatMessage::user("hi")])
.await
.unwrap_err();
assert!(
matches!(err, Error::CodexRateLimited { retry_after: 11 }),
"got {err:?}",
);
}
#[tokio::test]
async fn stream_failure_event_surfaces_codex_server_error() {
let server = MockServer::start().await;
let body = sse(&[(
"response.failed",
serde_json::json!({
"type": "response.failed",
"response": {
"error": { "message": "model unavailable" }
},
}),
)]);
Mock::given(method("POST"))
.and(path("/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(body),
)
.mount(&server)
.await;
let http = reqwest::Client::new();
let client = Client::with_base(http, "at", None, server.uri());
let err = client
.chat("gpt-5", &[ChatMessage::user("hi")])
.await
.unwrap_err();
match err {
Error::CodexServer { body, .. } => {
assert!(body.contains("model unavailable"), "got {body}")
}
other => panic!("expected CodexServer, got {other:?}"),
}
}
#[tokio::test]
async fn empty_stream_maps_to_empty_model_response() {
let server = MockServer::start().await;
let body = sse(&[(
"response.completed",
serde_json::json!({"type":"response.completed","response":{"id":"r1"}}),
)]);
Mock::given(method("POST"))
.and(path("/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(body),
)
.mount(&server)
.await;
let http = reqwest::Client::new();
let client = Client::with_base(http, "at", None, server.uri());
let err = client
.chat("gpt-5", &[ChatMessage::user("hi")])
.await
.unwrap_err();
assert!(matches!(err, Error::EmptyModelResponse), "got {err:?}");
}
}