mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
merge(upstream): incorporate openai/codex@main into upstream-merge
- Adopt upstream changes in exec, mcp-server, and common by bucket. - Preserve fork-specific core (codex.rs, browser/agent tools, UA/version). - Add Prompt.output_schema; keep our Text controls; adjust tests. - Fallback to Op::UserInput for UserTurn in exec and MCP. - Keep ours for core codex files to preserve TUI/stream ordering. verify.sh: pass; build-fast.sh: pass (no warnings).
This commit is contained in:
Generated
+1647
-61
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,12 @@ pub(crate) async fn stream_chat_completions(
|
||||
provider: &ModelProviderInfo,
|
||||
debug_logger: &Arc<Mutex<DebugLogger>>,
|
||||
) -> Result<ResponseStream> {
|
||||
if prompt.output_schema.is_some() {
|
||||
return Err(CodexErr::UnsupportedOperation(
|
||||
"output_schema is not supported for Chat Completions API".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Build messages array
|
||||
let mut messages = Vec::<serde_json::Value>::new();
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ impl ModelClient {
|
||||
|
||||
// Create text parameter with verbosity and optional format.
|
||||
// Historically not supported with ChatGPT auth, but allow it when a
|
||||
// caller explicitly requests a `text.format` (side‑channel usage).
|
||||
// caller explicitly requests a `text.format` (side-channel usage).
|
||||
let want_format = prompt.text_format.as_ref();
|
||||
let text = if auth_mode == Some(AuthMode::ChatGPT) && want_format.is_none() {
|
||||
None
|
||||
|
||||
@@ -12,6 +12,7 @@ use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use futures::Stream;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Deref;
|
||||
use std::pin::Pin;
|
||||
@@ -71,6 +72,8 @@ pub struct Prompt {
|
||||
|
||||
/// Optional per-request model family override matching `model_override`.
|
||||
pub model_family_override: Option<ModelFamily>,
|
||||
/// Optional the output schema for the model's response.
|
||||
pub output_schema: Option<Value>,
|
||||
}
|
||||
|
||||
impl Default for Prompt {
|
||||
@@ -87,6 +90,7 @@ impl Default for Prompt {
|
||||
text_format: None,
|
||||
model_override: None,
|
||||
model_family_override: None,
|
||||
output_schema: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,7 +505,7 @@ mod tests {
|
||||
stream: true,
|
||||
include: vec![],
|
||||
prompt_cache_key: None,
|
||||
text: Some(Text { verbosity: OpenAiTextVerbosity::Low }),
|
||||
text: Some(Text { verbosity: OpenAiTextVerbosity::Low, format: None }),
|
||||
};
|
||||
|
||||
let v = serde_json::to_value(&req).expect("json");
|
||||
@@ -513,6 +517,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_text_schema_with_strict_format() {
|
||||
let input: Vec<ResponseItem> = vec![];
|
||||
let tools: Vec<serde_json::Value> = vec![];
|
||||
let schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {"type": "string"}
|
||||
},
|
||||
"required": ["answer"],
|
||||
});
|
||||
let req = ResponsesApiRequest {
|
||||
model: "gpt-5",
|
||||
instructions: "i",
|
||||
input: &input,
|
||||
tools: &tools,
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: false,
|
||||
reasoning: None,
|
||||
store: false,
|
||||
stream: true,
|
||||
include: vec![],
|
||||
prompt_cache_key: None,
|
||||
text: Some(Text {
|
||||
verbosity: OpenAiTextVerbosity::Medium,
|
||||
format: Some(TextFormat {
|
||||
r#type: "json_schema".to_string(),
|
||||
name: Some("codex_output_schema".to_string()),
|
||||
strict: Some(true),
|
||||
schema: Some(schema.clone()),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
let v = serde_json::to_value(&req).expect("json");
|
||||
let text = v.get("text").expect("text field");
|
||||
assert!(text.get("verbosity").is_none());
|
||||
let format = text.get("format").expect("format field");
|
||||
|
||||
assert_eq!(
|
||||
format.get("name"),
|
||||
Some(&serde_json::Value::String("codex_output_schema".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
format.get("type"),
|
||||
Some(&serde_json::Value::String("json_schema".into()))
|
||||
);
|
||||
assert_eq!(format.get("strict"), Some(&serde_json::Value::Bool(true)));
|
||||
assert_eq!(format.get("schema"), Some(&schema));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_text_when_not_set() {
|
||||
let input: Vec<ResponseItem> = vec![];
|
||||
|
||||
@@ -3444,6 +3444,7 @@ async fn run_turn(
|
||||
text_format: None,
|
||||
model_override: None,
|
||||
model_family_override: None,
|
||||
output_schema: None,
|
||||
};
|
||||
|
||||
// Start a new scratchpad for this HTTP attempt
|
||||
|
||||
@@ -127,6 +127,7 @@ pub(super) async fn perform_compaction(
|
||||
text_format: None,
|
||||
model_override: None,
|
||||
model_family_override: None,
|
||||
output_schema: None,
|
||||
};
|
||||
|
||||
let max_retries = turn_context.client.get_provider().stream_max_retries();
|
||||
@@ -233,6 +234,7 @@ async fn run_compact_task_inner_inline(
|
||||
text_format: None,
|
||||
model_override: None,
|
||||
model_family_override: None,
|
||||
output_schema: None,
|
||||
};
|
||||
|
||||
let max_retries = turn_context.client.get_provider().stream_max_retries();
|
||||
|
||||
@@ -107,6 +107,9 @@ pub enum CodexErr {
|
||||
#[error("codex-linux-sandbox was required but not provided")]
|
||||
LandlockSandboxExecutableNotProvided,
|
||||
|
||||
#[error("unsupported operation: {0}")]
|
||||
UnsupportedOperation(String),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Automatic conversions for common external error types
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use core_test_support::non_sandbox_test;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use responses::ev_assistant_message;
|
||||
use responses::ev_completed;
|
||||
use responses::sse;
|
||||
use responses::start_mock_server;
|
||||
|
||||
const SCHEMA: &str = r#"
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"explanation": { "type": "string" },
|
||||
"final_answer": { "type": "string" }
|
||||
},
|
||||
"required": ["explanation", "final_answer"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
"#;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn codex_returns_json_result() -> anyhow::Result<()> {
|
||||
non_sandbox_test!(result);
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let sse1 = sse(vec![
|
||||
ev_assistant_message(
|
||||
"m2",
|
||||
r#"{"explanation": "explanation", "final_answer": "final_answer"}"#,
|
||||
),
|
||||
ev_completed("r1"),
|
||||
]);
|
||||
|
||||
let expected_schema: serde_json::Value = serde_json::from_str(SCHEMA)?;
|
||||
let match_json_text_param = move |req: &wiremock::Request| {
|
||||
let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap_or_default();
|
||||
let Some(text) = body.get("text") else {
|
||||
return false;
|
||||
};
|
||||
let Some(format) = text.get("format") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
format.get("name") == Some(&serde_json::Value::String("codex_output_schema".into()))
|
||||
&& format.get("type") == Some(&serde_json::Value::String("json_schema".into()))
|
||||
&& format.get("strict") == Some(&serde_json::Value::Bool(true))
|
||||
&& format.get("schema") == Some(&expected_schema)
|
||||
};
|
||||
responses::mount_sse_once(&server, match_json_text_param, sse1).await;
|
||||
|
||||
let TestCodex { codex, cwd, .. } = test_codex().build(&server).await?;
|
||||
|
||||
// 1) Normal user input – should hit server once.
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello world".into(),
|
||||
}],
|
||||
final_output_json_schema: Some(serde_json::from_str(SCHEMA)?),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: "gpt-5".to_string(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let message = wait_for_event(&codex, |ev| matches!(ev, EventMsg::AgentMessage(_))).await;
|
||||
if let EventMsg::AgentMessage(message) = message {
|
||||
let json: serde_json::Value = serde_json::from_str(&message.message)?;
|
||||
assert_eq!(
|
||||
json.get("explanation"),
|
||||
Some(&serde_json::Value::String("explanation".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
json.get("final_answer"),
|
||||
Some(&serde_json::Value::String("final_answer".into()))
|
||||
);
|
||||
} else {
|
||||
anyhow::bail!("expected agent message event");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod compact_resume_fork;
|
||||
mod exec;
|
||||
mod exec_stream_events;
|
||||
mod fork_conversation;
|
||||
mod json_result;
|
||||
mod live_cli;
|
||||
mod model_overrides;
|
||||
mod prompt_caching;
|
||||
|
||||
@@ -656,6 +656,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() {
|
||||
model: "o3".to_string(),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
summary: ReasoningSummary::Detailed,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -761,6 +762,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() {
|
||||
model: default_model.clone(),
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -777,6 +779,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() {
|
||||
model: default_model.clone(),
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -872,6 +875,7 @@ async fn send_user_turn_with_changes_sends_environment_context() {
|
||||
model: default_model,
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -888,6 +892,7 @@ async fn send_user_turn_with_changes_sends_environment_context() {
|
||||
model: "o3".to_string(),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
summary: ReasoningSummary::Detailed,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -61,6 +61,10 @@ pub struct Cli {
|
||||
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
||||
pub skip_git_repo_check: bool,
|
||||
|
||||
/// Path to a JSON Schema file describing the model's final response shape.
|
||||
#[arg(long = "output-schema", value_name = "FILE")]
|
||||
pub output_schema: Option<PathBuf>,
|
||||
|
||||
#[clap(skip)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ use codex_ollama::DEFAULT_OSS_MODEL;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use event_processor_with_human_output::EventProcessorWithHumanOutput;
|
||||
use event_processor_with_json_output::EventProcessorWithJsonOutput;
|
||||
use serde_json::Value;
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
@@ -51,6 +52,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
json: json_mode,
|
||||
sandbox_mode: sandbox_mode_cli_arg,
|
||||
prompt,
|
||||
output_schema: output_schema_path,
|
||||
config_overrides,
|
||||
..
|
||||
} = cli;
|
||||
@@ -97,6 +99,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
}
|
||||
};
|
||||
|
||||
let _output_schema = load_output_schema(output_schema_path);
|
||||
|
||||
let (stdout_with_ansi, stderr_with_ansi) = match color {
|
||||
cli::Color::Always => (true, true),
|
||||
cli::Color::Never => (false, false),
|
||||
@@ -198,7 +202,14 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
// is using.
|
||||
event_processor.print_config_summary(&config, &prompt);
|
||||
|
||||
if !skip_git_repo_check && get_git_repo_root(&config.cwd.to_path_buf()).is_none() {
|
||||
let default_cwd = config.cwd.to_path_buf();
|
||||
let _default_approval_policy = config.approval_policy;
|
||||
let _default_sandbox_policy = config.sandbox_policy.clone();
|
||||
let _default_model = config.model.clone();
|
||||
let _default_effort = config.model_reasoning_effort;
|
||||
let _default_summary = config.model_reasoning_summary;
|
||||
|
||||
if !skip_git_repo_check && get_git_repo_root(&default_cwd).is_none() {
|
||||
eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -300,7 +311,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
|
||||
// Send the prompt.
|
||||
let items: Vec<InputItem> = vec![InputItem::Text { text: prompt }];
|
||||
let initial_prompt_task_id = conversation.submit(Op::UserInput { items }).await?;
|
||||
// Fallback for older core protocol: send only user input items.
|
||||
let initial_prompt_task_id = conversation
|
||||
.submit(Op::UserInput { items })
|
||||
.await?;
|
||||
info!("Sent prompt with event ID: {initial_prompt_task_id}");
|
||||
|
||||
// Run the loop until the task is complete.
|
||||
@@ -339,3 +353,29 @@ async fn resolve_resume_path(
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn load_output_schema(path: Option<PathBuf>) -> Option<Value> {
|
||||
let path = path?;
|
||||
|
||||
let schema_str = match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"Failed to read output schema file {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match serde_json::from_str::<Value>(&schema_str) {
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"Output schema file {} is not valid JSON: {err}",
|
||||
path.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Aggregates all former standalone integration tests as modules.
|
||||
mod apply_patch;
|
||||
mod common;
|
||||
mod output_schema;
|
||||
mod resume;
|
||||
mod sandbox;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use assert_cmd::prelude::*;
|
||||
use core_test_support::responses;
|
||||
use serde_json::Value;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::any;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exec_includes_output_schema_in_request() -> anyhow::Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
let workspace = TempDir::new()?;
|
||||
|
||||
let schema_contents = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": { "type": "string" }
|
||||
},
|
||||
"required": ["answer"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
let schema_path = workspace.path().join("schema.json");
|
||||
std::fs::write(&schema_path, serde_json::to_vec_pretty(&schema_contents)?)?;
|
||||
let expected_schema: Value = schema_contents;
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body = responses::sse(vec![
|
||||
serde_json::json!({
|
||||
"type": "response.created",
|
||||
"response": {"id": "resp1"}
|
||||
}),
|
||||
responses::ev_assistant_message("m1", "fixture hello"),
|
||||
responses::ev_completed("resp1"),
|
||||
]);
|
||||
responses::mount_sse_once(&server, any(), body).await;
|
||||
|
||||
Command::cargo_bin("codex-exec")?
|
||||
.current_dir(workspace.path())
|
||||
.env("CODEX_HOME", home.path())
|
||||
.env("OPENAI_API_KEY", "dummy")
|
||||
.env("OPENAI_BASE_URL", format!("{}/v1", server.uri()))
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-C")
|
||||
.arg(workspace.path())
|
||||
.arg("--output-schema")
|
||||
.arg(&schema_path)
|
||||
.arg("-m")
|
||||
.arg("gpt-5")
|
||||
.arg("tell me a joke")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("failed to capture requests");
|
||||
assert_eq!(requests.len(), 1, "expected exactly one request");
|
||||
let payload: Value = serde_json::from_slice(&requests[0].body)?;
|
||||
let text = payload.get("text").expect("request missing text field");
|
||||
let format = text
|
||||
.get("format")
|
||||
.expect("request missing text.format field");
|
||||
assert_eq!(
|
||||
format,
|
||||
&serde_json::json!({
|
||||
"name": "codex_output_schema",
|
||||
"type": "json_schema",
|
||||
"strict": true,
|
||||
"schema": expected_schema,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -235,6 +235,53 @@ impl CodexMessageProcessor {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn send_user_turn(&self, request_id: RequestId, params: SendUserTurnParams) {
|
||||
let SendUserTurnParams {
|
||||
conversation_id,
|
||||
items,
|
||||
cwd: _,
|
||||
approval_policy: _,
|
||||
sandbox_policy: _,
|
||||
model: _,
|
||||
effort: _,
|
||||
summary: _,
|
||||
} = params;
|
||||
|
||||
let Ok(conversation) = self
|
||||
.conversation_manager
|
||||
.get_conversation(conversation_id)
|
||||
.await
|
||||
else {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("conversation not found: {conversation_id}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
};
|
||||
|
||||
let mapped_items: Vec<CoreInputItem> = items
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
WireInputItem::Text { text } => CoreInputItem::Text { text },
|
||||
WireInputItem::Image { image_url } => CoreInputItem::Image { image_url },
|
||||
WireInputItem::LocalImage { path } => CoreInputItem::LocalImage { path },
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Core protocol compatibility: older cores do not support per-turn overrides.
|
||||
// Submit only the user input items.
|
||||
let _ = conversation
|
||||
.submit(Op::UserInput { items: mapped_items })
|
||||
.await;
|
||||
|
||||
self.outgoing
|
||||
.send_response(request_id, SendUserTurnResponse {})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn interrupt_conversation(
|
||||
&mut self,
|
||||
request_id: RequestId,
|
||||
|
||||
@@ -24,6 +24,7 @@ use mcp_types::CallToolResult;
|
||||
use mcp_types::Tool as McpTool;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use serde_with::serde_as;
|
||||
use strum_macros::Display;
|
||||
use ts_rs::TS;
|
||||
@@ -94,6 +95,8 @@ pub enum Op {
|
||||
|
||||
/// Will only be honored if the model is configured to use reasoning.
|
||||
summary: ReasoningSummaryConfig,
|
||||
// The JSON schema to use for the final assistant message
|
||||
final_output_json_schema: Option<Value>,
|
||||
},
|
||||
|
||||
/// Override parts of the persistent turn context for subsequent turns.
|
||||
|
||||
Reference in New Issue
Block a user