Skip to content

Commit 7c874b4

Browse files
authored
Merge branch 'main' into send-limits
2 parents 88042d0 + fdb8dad commit 7c874b4

25 files changed

+506
-152
lines changed

codex-rs/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/core/src/chat_completions.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ pub(crate) async fn stream_chat_completions(
3535
client: &reqwest::Client,
3636
provider: &ModelProviderInfo,
3737
) -> Result<ResponseStream> {
38+
if prompt.output_schema.is_some() {
39+
return Err(CodexErr::UnsupportedOperation(
40+
"output_schema is not supported for Chat Completions API".to_string(),
41+
));
42+
}
43+
3844
// Build messages array
3945
let mut messages = Vec::<serde_json::Value>::new();
4046

codex-rs/core/src/client.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ impl ModelClient {
185185

186186
// Only include `text.verbosity` for GPT-5 family models
187187
let text = if self.config.model_family.family == "gpt-5" {
188-
create_text_param_for_request(self.config.model_verbosity)
188+
create_text_param_for_request(self.config.model_verbosity, &prompt.output_schema)
189189
} else {
190190
if self.config.model_verbosity.is_some() {
191191
warn!(

codex-rs/core/src/client_common.rs

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use codex_protocol::config_types::Verbosity as VerbosityConfig;
1010
use codex_protocol::models::ResponseItem;
1111
use futures::Stream;
1212
use serde::Serialize;
13+
use serde_json::Value;
1314
use std::borrow::Cow;
1415
use std::ops::Deref;
1516
use std::pin::Pin;
@@ -32,6 +33,9 @@ pub struct Prompt {
3233

3334
/// Optional override for the built-in BASE_INSTRUCTIONS.
3435
pub base_instructions_override: Option<String>,
36+
37+
/// Optional the output schema for the model's response.
38+
pub output_schema: Option<Value>,
3539
}
3640

3741
impl Prompt {
@@ -90,14 +94,31 @@ pub(crate) struct Reasoning {
9094
pub(crate) summary: Option<ReasoningSummaryConfig>,
9195
}
9296

97+
#[derive(Debug, Serialize, Default, Clone)]
98+
#[serde(rename_all = "snake_case")]
99+
pub(crate) enum TextFormatType {
100+
#[default]
101+
JsonSchema,
102+
}
103+
104+
#[derive(Debug, Serialize, Default, Clone)]
105+
pub(crate) struct TextFormat {
106+
pub(crate) r#type: TextFormatType,
107+
pub(crate) strict: bool,
108+
pub(crate) schema: Value,
109+
pub(crate) name: String,
110+
}
111+
93112
/// Controls under the `text` field in the Responses API for GPT-5.
94-
#[derive(Debug, Serialize, Default, Clone, Copy)]
113+
#[derive(Debug, Serialize, Default, Clone)]
95114
pub(crate) struct TextControls {
96115
#[serde(skip_serializing_if = "Option::is_none")]
97116
pub(crate) verbosity: Option<OpenAiVerbosity>,
117+
#[serde(skip_serializing_if = "Option::is_none")]
118+
pub(crate) format: Option<TextFormat>,
98119
}
99120

100-
#[derive(Debug, Serialize, Default, Clone, Copy)]
121+
#[derive(Debug, Serialize, Default, Clone)]
101122
#[serde(rename_all = "lowercase")]
102123
pub(crate) enum OpenAiVerbosity {
103124
Low,
@@ -156,9 +177,20 @@ pub(crate) fn create_reasoning_param_for_request(
156177

157178
pub(crate) fn create_text_param_for_request(
158179
verbosity: Option<VerbosityConfig>,
180+
output_schema: &Option<Value>,
159181
) -> Option<TextControls> {
160-
verbosity.map(|v| TextControls {
161-
verbosity: Some(v.into()),
182+
if verbosity.is_none() && output_schema.is_none() {
183+
return None;
184+
}
185+
186+
Some(TextControls {
187+
verbosity: verbosity.map(std::convert::Into::into),
188+
format: output_schema.as_ref().map(|schema| TextFormat {
189+
r#type: TextFormatType::JsonSchema,
190+
strict: true,
191+
schema: schema.clone(),
192+
name: "codex_output_schema".to_string(),
193+
}),
162194
})
163195
}
164196

@@ -255,6 +287,7 @@ mod tests {
255287
prompt_cache_key: None,
256288
text: Some(TextControls {
257289
verbosity: Some(OpenAiVerbosity::Low),
290+
format: None,
258291
}),
259292
};
260293

@@ -267,6 +300,52 @@ mod tests {
267300
);
268301
}
269302

303+
#[test]
304+
fn serializes_text_schema_with_strict_format() {
305+
let input: Vec<ResponseItem> = vec![];
306+
let tools: Vec<serde_json::Value> = vec![];
307+
let schema = serde_json::json!({
308+
"type": "object",
309+
"properties": {
310+
"answer": {"type": "string"}
311+
},
312+
"required": ["answer"],
313+
});
314+
let text_controls =
315+
create_text_param_for_request(None, &Some(schema.clone())).expect("text controls");
316+
317+
let req = ResponsesApiRequest {
318+
model: "gpt-5",
319+
instructions: "i",
320+
input: &input,
321+
tools: &tools,
322+
tool_choice: "auto",
323+
parallel_tool_calls: false,
324+
reasoning: None,
325+
store: false,
326+
stream: true,
327+
include: vec![],
328+
prompt_cache_key: None,
329+
text: Some(text_controls),
330+
};
331+
332+
let v = serde_json::to_value(&req).expect("json");
333+
let text = v.get("text").expect("text field");
334+
assert!(text.get("verbosity").is_none());
335+
let format = text.get("format").expect("format field");
336+
337+
assert_eq!(
338+
format.get("name"),
339+
Some(&serde_json::Value::String("codex_output_schema".into()))
340+
);
341+
assert_eq!(
342+
format.get("type"),
343+
Some(&serde_json::Value::String("json_schema".into()))
344+
);
345+
assert_eq!(format.get("strict"), Some(&serde_json::Value::Bool(true)));
346+
assert_eq!(format.get("schema"), Some(&schema));
347+
}
348+
270349
#[test]
271350
fn omits_text_when_not_set() {
272351
let input: Vec<ResponseItem> = vec![];

codex-rs/core/src/codex.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ use mcp_types::CallToolResult;
3131
use serde::Deserialize;
3232
use serde::Serialize;
3333
use serde_json;
34+
use serde_json::Value;
3435
use tokio::sync::Mutex;
3536
use tokio::sync::oneshot;
3637
use tokio::task::AbortHandle;
@@ -302,6 +303,7 @@ pub(crate) struct TurnContext {
302303
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
303304
pub(crate) tools_config: ToolsConfig,
304305
pub(crate) is_review_mode: bool,
306+
pub(crate) final_output_json_schema: Option<Value>,
305307
}
306308

307309
impl TurnContext {
@@ -469,6 +471,7 @@ impl Session {
469471
shell_environment_policy: config.shell_environment_policy.clone(),
470472
cwd,
471473
is_review_mode: false,
474+
final_output_json_schema: None,
472475
};
473476
let sess = Arc::new(Session {
474477
conversation_id,
@@ -1244,6 +1247,7 @@ async fn submission_loop(
12441247
shell_environment_policy: prev.shell_environment_policy.clone(),
12451248
cwd: new_cwd.clone(),
12461249
is_review_mode: false,
1250+
final_output_json_schema: None,
12471251
};
12481252

12491253
// Install the new persistent context for subsequent tasks/turns.
@@ -1278,6 +1282,7 @@ async fn submission_loop(
12781282
model,
12791283
effort,
12801284
summary,
1285+
final_output_json_schema,
12811286
} => {
12821287
// attempt to inject input into current task
12831288
if let Err(items) = sess.inject_input(items).await {
@@ -1328,6 +1333,7 @@ async fn submission_loop(
13281333
shell_environment_policy: turn_context.shell_environment_policy.clone(),
13291334
cwd,
13301335
is_review_mode: false,
1336+
final_output_json_schema,
13311337
};
13321338

13331339
// if the environment context has changed, record it in the conversation history
@@ -1582,6 +1588,7 @@ async fn spawn_review_thread(
15821588
shell_environment_policy: parent_turn_context.shell_environment_policy.clone(),
15831589
cwd: parent_turn_context.cwd.clone(),
15841590
is_review_mode: true,
1591+
final_output_json_schema: None,
15851592
};
15861593

15871594
// Seed the child task with the review prompt as the initial user message.
@@ -1948,6 +1955,7 @@ async fn run_turn(
19481955
input,
19491956
tools,
19501957
base_instructions_override: turn_context.base_instructions.clone(),
1958+
output_schema: turn_context.final_output_json_schema.clone(),
19511959
};
19521960

19531961
let mut retries = 0;
@@ -3609,6 +3617,7 @@ mod tests {
36093617
shell_environment_policy: config.shell_environment_policy.clone(),
36103618
tools_config,
36113619
is_review_mode: false,
3620+
final_output_json_schema: None,
36123621
};
36133622
let session = Session {
36143623
conversation_id,

codex-rs/core/src/codex/compact.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ async fn run_compact_task_inner(
106106
input: turn_input,
107107
tools: Vec::new(),
108108
base_instructions_override: instructions_override,
109+
output_schema: None,
109110
};
110111

111112
let max_retries = turn_context.client.get_provider().stream_max_retries();

codex-rs/core/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ pub enum CodexErr {
105105
#[error("codex-linux-sandbox was required but not provided")]
106106
LandlockSandboxExecutableNotProvided,
107107

108+
#[error("unsupported operation: {0}")]
109+
UnsupportedOperation(String),
110+
108111
// -----------------------------------------------------------------
109112
// Automatic conversions for common external error types
110113
// -----------------------------------------------------------------

codex-rs/core/src/user_notification.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,22 @@ pub(crate) enum UserNotification {
6262
#[cfg(test)]
6363
mod tests {
6464
use super::*;
65+
use anyhow::Result;
6566

6667
#[test]
67-
fn test_user_notification() {
68+
fn test_user_notification() -> Result<()> {
6869
let notification = UserNotification::AgentTurnComplete {
6970
turn_id: "12345".to_string(),
7071
input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()],
7172
last_assistant_message: Some(
7273
"Rename complete and verified `cargo build` succeeds.".to_string(),
7374
),
7475
};
75-
let serialized = serde_json::to_string(&notification).unwrap();
76+
let serialized = serde_json::to_string(&notification)?;
7677
assert_eq!(
7778
serialized,
7879
r#"{"type":"agent-turn-complete","turn-id":"12345","input-messages":["Rename `foo` to `bar` and update the callsites."],"last-assistant-message":"Rename complete and verified `cargo build` succeeds."}"#
7980
);
81+
Ok(())
8082
}
8183
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#![cfg(not(target_os = "windows"))]
2+
3+
use codex_core::protocol::AskForApproval;
4+
use codex_core::protocol::EventMsg;
5+
use codex_core::protocol::InputItem;
6+
use codex_core::protocol::Op;
7+
use codex_core::protocol::SandboxPolicy;
8+
use codex_protocol::config_types::ReasoningSummary;
9+
use core_test_support::non_sandbox_test;
10+
use core_test_support::responses;
11+
use core_test_support::test_codex::TestCodex;
12+
use core_test_support::test_codex::test_codex;
13+
use core_test_support::wait_for_event;
14+
use pretty_assertions::assert_eq;
15+
use responses::ev_assistant_message;
16+
use responses::ev_completed;
17+
use responses::sse;
18+
use responses::start_mock_server;
19+
20+
const SCHEMA: &str = r#"
21+
{
22+
"type": "object",
23+
"properties": {
24+
"explanation": { "type": "string" },
25+
"final_answer": { "type": "string" }
26+
},
27+
"required": ["explanation", "final_answer"],
28+
"additionalProperties": false
29+
}
30+
"#;
31+
32+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
33+
async fn codex_returns_json_result() -> anyhow::Result<()> {
34+
non_sandbox_test!(result);
35+
36+
let server = start_mock_server().await;
37+
38+
let sse1 = sse(vec![
39+
ev_assistant_message(
40+
"m2",
41+
r#"{"explanation": "explanation", "final_answer": "final_answer"}"#,
42+
),
43+
ev_completed("r1"),
44+
]);
45+
46+
let expected_schema: serde_json::Value = serde_json::from_str(SCHEMA)?;
47+
let match_json_text_param = move |req: &wiremock::Request| {
48+
let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap_or_default();
49+
let Some(text) = body.get("text") else {
50+
return false;
51+
};
52+
let Some(format) = text.get("format") else {
53+
return false;
54+
};
55+
56+
format.get("name") == Some(&serde_json::Value::String("codex_output_schema".into()))
57+
&& format.get("type") == Some(&serde_json::Value::String("json_schema".into()))
58+
&& format.get("strict") == Some(&serde_json::Value::Bool(true))
59+
&& format.get("schema") == Some(&expected_schema)
60+
};
61+
responses::mount_sse_once(&server, match_json_text_param, sse1).await;
62+
63+
let TestCodex { codex, cwd, .. } = test_codex().build(&server).await?;
64+
65+
// 1) Normal user input – should hit server once.
66+
codex
67+
.submit(Op::UserTurn {
68+
items: vec![InputItem::Text {
69+
text: "hello world".into(),
70+
}],
71+
final_output_json_schema: Some(serde_json::from_str(SCHEMA)?),
72+
cwd: cwd.path().to_path_buf(),
73+
approval_policy: AskForApproval::Never,
74+
sandbox_policy: SandboxPolicy::DangerFullAccess,
75+
model: "gpt-5".to_string(),
76+
effort: None,
77+
summary: ReasoningSummary::Auto,
78+
})
79+
.await?;
80+
81+
let message = wait_for_event(&codex, |ev| matches!(ev, EventMsg::AgentMessage(_))).await;
82+
if let EventMsg::AgentMessage(message) = message {
83+
let json: serde_json::Value = serde_json::from_str(&message.message)?;
84+
assert_eq!(
85+
json.get("explanation"),
86+
Some(&serde_json::Value::String("explanation".into()))
87+
);
88+
assert_eq!(
89+
json.get("final_answer"),
90+
Some(&serde_json::Value::String("final_answer".into()))
91+
);
92+
} else {
93+
anyhow::bail!("expected agent message event");
94+
}
95+
96+
Ok(())
97+
}

codex-rs/core/tests/suite/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod compact_resume_fork;
77
mod exec;
88
mod exec_stream_events;
99
mod fork_conversation;
10+
mod json_result;
1011
mod live_cli;
1112
mod model_overrides;
1213
mod prompt_caching;

0 commit comments

Comments
 (0)