mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
Merge 9ebf0b1f23 into sapling-pr-archive-bolinfest
This commit is contained in:
Generated
+2
@@ -2748,6 +2748,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"codex-api",
|
||||
"codex-app-server-protocol",
|
||||
"codex-client",
|
||||
"codex-file-system",
|
||||
@@ -2759,6 +2760,7 @@ dependencies = [
|
||||
"codex-utils-rustls-provider",
|
||||
"ctor 0.6.3",
|
||||
"futures",
|
||||
"http 1.4.0",
|
||||
"pretty_assertions",
|
||||
"prost 0.14.3",
|
||||
"reqwest",
|
||||
|
||||
@@ -2075,6 +2075,7 @@ mod tests {
|
||||
use codex_protocol::items::build_hook_prompt_message;
|
||||
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
|
||||
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
@@ -2090,7 +2091,6 @@ mod tests {
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
@@ -2166,7 +2166,7 @@ mod tests {
|
||||
agent_path: None,
|
||||
git_info: None,
|
||||
approval_mode: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
token_usage: None,
|
||||
first_user_message: Some("before rollback".to_string()),
|
||||
history: Some(StoredThreadHistory {
|
||||
|
||||
@@ -403,10 +403,47 @@ fn parse_key_path(path: &str) -> Result<Vec<String>, String> {
|
||||
if path.trim().is_empty() {
|
||||
return Err("keyPath must not be empty".to_string());
|
||||
}
|
||||
Ok(path
|
||||
.split('.')
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect())
|
||||
|
||||
let mut segments = Vec::new();
|
||||
let mut segment = String::new();
|
||||
let mut chars = path.chars();
|
||||
let mut quoted = false;
|
||||
|
||||
// Split on dots unless they appear inside a quoted segment. Bare segments
|
||||
// intentionally stay permissive so existing paths like `sample@catalog`
|
||||
// remain valid.
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'"' if segment.is_empty() && !quoted => quoted = true,
|
||||
'"' if quoted => quoted = false,
|
||||
'\\' if quoted => {
|
||||
// Quoted segments may escape punctuation that would otherwise
|
||||
// participate in parsing, such as `.` or `"`.
|
||||
let Some(escaped) = chars.next() else {
|
||||
return Err("unterminated escape in keyPath".to_string());
|
||||
};
|
||||
segment.push(escaped);
|
||||
}
|
||||
'.' if !quoted => {
|
||||
if segment.is_empty() {
|
||||
return Err("keyPath segments must not be empty".to_string());
|
||||
}
|
||||
segments.push(std::mem::take(&mut segment));
|
||||
}
|
||||
'"' => return Err("invalid quoted keyPath segment".to_string()),
|
||||
_ => segment.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
if quoted {
|
||||
return Err("unterminated quoted keyPath segment".to_string());
|
||||
}
|
||||
if segment.is_empty() {
|
||||
return Err("keyPath segments must not be empty".to_string());
|
||||
}
|
||||
|
||||
segments.push(segment);
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -65,13 +65,13 @@ mod thread_processor_behavior_tests {
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_state::ThreadMetadataBuilder;
|
||||
@@ -407,7 +407,7 @@ mod thread_processor_behavior_tests {
|
||||
agent_path: None,
|
||||
git_info: None,
|
||||
approval_mode: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
token_usage: None,
|
||||
first_user_message: Some("first user message".to_string()),
|
||||
history: None,
|
||||
|
||||
@@ -893,6 +893,71 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn config_batch_write_preserves_dotted_profile_names() -> Result<()> {
|
||||
let tmp_dir = TempDir::new()?;
|
||||
let codex_home = tmp_dir.path().canonicalize()?;
|
||||
write_config(
|
||||
&tmp_dir,
|
||||
r#"
|
||||
profile = "team.prod"
|
||||
|
||||
[profiles."team.prod"]
|
||||
model = "gpt-5.3-spark"
|
||||
|
||||
[profiles.team.prod]
|
||||
model = "should-stay-put"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(&codex_home).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let batch_id = mcp
|
||||
.send_config_batch_write_request(ConfigBatchWriteParams {
|
||||
file_path: Some(codex_home.join("config.toml").display().to_string()),
|
||||
edits: vec![
|
||||
ConfigEdit {
|
||||
key_path: "profiles.\"team.prod\".model".to_string(),
|
||||
value: json!("gpt-5.5"),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
},
|
||||
ConfigEdit {
|
||||
key_path: "items.sample@catalog.enabled".to_string(),
|
||||
value: json!(true),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
},
|
||||
],
|
||||
expected_version: None,
|
||||
reload_user_config: false,
|
||||
})
|
||||
.await?;
|
||||
let batch_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(batch_id)),
|
||||
)
|
||||
.await??;
|
||||
let batch_write: ConfigWriteResponse = to_response(batch_resp)?;
|
||||
assert_eq!(batch_write.status, WriteStatus::Ok);
|
||||
|
||||
let config: toml::Value =
|
||||
toml::from_str(&std::fs::read_to_string(codex_home.join("config.toml"))?)?;
|
||||
assert_eq!(
|
||||
config["profiles"]["team.prod"]["model"].as_str(),
|
||||
Some("gpt-5.5")
|
||||
);
|
||||
assert_eq!(
|
||||
config["profiles"]["team"]["prod"]["model"].as_str(),
|
||||
Some("should-stay-put")
|
||||
);
|
||||
assert_eq!(
|
||||
config["items"]["sample@catalog"]["enabled"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn config_batch_write_updates_multiple_desktop_settings() -> Result<()> {
|
||||
let tmp_dir = TempDir::new()?;
|
||||
|
||||
@@ -73,6 +73,8 @@ use codex_features::FEATURES;
|
||||
use codex_features::Stage;
|
||||
use codex_features::is_known_feature_key;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::read_codex_access_token_from_env;
|
||||
use codex_memories_write::clear_memory_roots_contents;
|
||||
use codex_models_manager::bundled_models_response;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
@@ -489,6 +491,10 @@ struct ExecServerCommand {
|
||||
/// Human-readable executor name.
|
||||
#[arg(long = "name", value_name = "NAME")]
|
||||
name: Option<String>,
|
||||
|
||||
/// Use Agent Identity auth from CODEX_ACCESS_TOKEN for remote registration.
|
||||
#[arg(long = "use-agent-identity-auth", requires = "remote")]
|
||||
use_agent_identity_auth: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
@@ -1388,7 +1394,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
|
||||
root_remote_auth_token_env.as_deref(),
|
||||
"exec-server",
|
||||
)?;
|
||||
run_exec_server_command(cmd, &arg0_paths).await?;
|
||||
run_exec_server_command(
|
||||
cmd,
|
||||
&arg0_paths,
|
||||
&root_config_overrides,
|
||||
interactive.config_profile.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Some(Subcommand::Features(FeaturesCli { sub })) => match sub {
|
||||
FeaturesSubcommand::List => {
|
||||
@@ -1485,6 +1497,8 @@ fn profile_v2_for_subcommand<'a>(
|
||||
async fn run_exec_server_command(
|
||||
cmd: ExecServerCommand,
|
||||
arg0_paths: &Arg0DispatchPaths,
|
||||
root_config_overrides: &CliConfigOverrides,
|
||||
config_profile: Option<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let codex_self_exe = arg0_paths
|
||||
.codex_self_exe
|
||||
@@ -1498,8 +1512,14 @@ async fn run_exec_server_command(
|
||||
let executor_id = cmd
|
||||
.executor_id
|
||||
.ok_or_else(|| anyhow::anyhow!("--executor-id is required when --remote is set"))?;
|
||||
let auth_provider = load_exec_server_remote_auth_provider(
|
||||
root_config_overrides,
|
||||
config_profile,
|
||||
cmd.use_agent_identity_auth,
|
||||
)
|
||||
.await?;
|
||||
let mut remote_config =
|
||||
codex_exec_server::RemoteExecutorConfig::new(base_url, executor_id)?;
|
||||
codex_exec_server::RemoteExecutorConfig::new(base_url, executor_id, auth_provider)?;
|
||||
if let Some(name) = cmd.name {
|
||||
remote_config.name = name;
|
||||
}
|
||||
@@ -1515,6 +1535,75 @@ async fn run_exec_server_command(
|
||||
.map_err(anyhow::Error::from_boxed)
|
||||
}
|
||||
|
||||
async fn load_exec_server_remote_auth_provider(
|
||||
root_config_overrides: &CliConfigOverrides,
|
||||
config_profile: Option<String>,
|
||||
use_agent_identity_auth: bool,
|
||||
) -> anyhow::Result<codex_api::SharedAuthProvider> {
|
||||
let config = load_exec_server_remote_config(root_config_overrides, config_profile).await?;
|
||||
if use_agent_identity_auth {
|
||||
let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| {
|
||||
anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set")
|
||||
})?;
|
||||
let auth =
|
||||
CodexAuth::from_agent_identity_jwt(&agent_identity_jwt, Some(&config.chatgpt_base_url))
|
||||
.await?;
|
||||
return Ok(codex_model_provider::auth_provider_from_auth(&auth));
|
||||
}
|
||||
|
||||
let auth = load_exec_server_remote_auth(
|
||||
&config,
|
||||
"remote exec-server registration requires ChatGPT authentication; run `codex login` first",
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !auth.is_chatgpt_auth() {
|
||||
anyhow::bail!(
|
||||
"remote exec-server registration requires ChatGPT authentication; API key and Agent Identity auth are not supported"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(codex_model_provider::auth_provider_from_auth(&auth))
|
||||
}
|
||||
|
||||
async fn load_exec_server_remote_config(
|
||||
root_config_overrides: &CliConfigOverrides,
|
||||
config_profile: Option<String>,
|
||||
) -> anyhow::Result<codex_core::config::Config> {
|
||||
let cli_kv_overrides = root_config_overrides
|
||||
.parse_overrides()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
Ok(ConfigBuilder::default()
|
||||
.cli_overrides(cli_kv_overrides)
|
||||
.harness_overrides(ConfigOverrides {
|
||||
config_profile,
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn load_exec_server_remote_auth(
|
||||
config: &codex_core::config::Config,
|
||||
missing_auth_error: &'static str,
|
||||
) -> anyhow::Result<codex_login::CodexAuth> {
|
||||
let auth_manager =
|
||||
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await;
|
||||
|
||||
let auth = match auth_manager.auth().await {
|
||||
Some(auth) => auth,
|
||||
None => {
|
||||
auth_manager.reload().await;
|
||||
auth_manager
|
||||
.auth()
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!(missing_auth_error))?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
async fn enable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> {
|
||||
FeatureToggles::validate_feature(feature)?;
|
||||
let codex_home = find_codex_home()?;
|
||||
|
||||
@@ -1516,6 +1516,12 @@
|
||||
"subagent_usage_hint_text": {
|
||||
"type": "string"
|
||||
},
|
||||
"tool_namespace": {
|
||||
"maxLength": 64,
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z0-9_-]+$",
|
||||
"type": "string"
|
||||
},
|
||||
"usage_hint_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -10112,6 +10112,7 @@ usage_hint_enabled = false
|
||||
usage_hint_text = "Custom delegation guidance."
|
||||
root_agent_usage_hint_text = "Root guidance."
|
||||
subagent_usage_hint_text = "Subagent guidance."
|
||||
tool_namespace = "agents"
|
||||
hide_spawn_agent_metadata = true
|
||||
non_code_mode_only = true
|
||||
"#,
|
||||
@@ -10142,6 +10143,10 @@ non_code_mode_only = true
|
||||
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
|
||||
Some("Subagent guidance.")
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.tool_namespace.as_deref(),
|
||||
Some("agents")
|
||||
);
|
||||
assert!(config.multi_agent_v2.hide_spawn_agent_metadata);
|
||||
assert!(config.multi_agent_v2.non_code_mode_only);
|
||||
|
||||
@@ -10164,6 +10169,7 @@ usage_hint_enabled = true
|
||||
usage_hint_text = "base hint"
|
||||
root_agent_usage_hint_text = "base root hint"
|
||||
subagent_usage_hint_text = "base subagent hint"
|
||||
tool_namespace = "base_agents"
|
||||
hide_spawn_agent_metadata = true
|
||||
non_code_mode_only = false
|
||||
|
||||
@@ -10176,6 +10182,7 @@ usage_hint_enabled = false
|
||||
usage_hint_text = "profile hint"
|
||||
root_agent_usage_hint_text = "profile root hint"
|
||||
subagent_usage_hint_text = "profile subagent hint"
|
||||
tool_namespace = "profile_agents"
|
||||
hide_spawn_agent_metadata = false
|
||||
non_code_mode_only = true
|
||||
"#,
|
||||
@@ -10204,6 +10211,10 @@ non_code_mode_only = true
|
||||
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
|
||||
Some("profile subagent hint")
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.tool_namespace.as_deref(),
|
||||
Some("profile_agents")
|
||||
);
|
||||
assert!(!config.multi_agent_v2.hide_spawn_agent_metadata);
|
||||
assert!(config.multi_agent_v2.non_code_mode_only);
|
||||
|
||||
@@ -10464,6 +10475,42 @@ default_wait_timeout_ms = 2500
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_rejects_invalid_tool_namespace() -> std::io::Result<()> {
|
||||
for (namespace, expected_message) in [
|
||||
(
|
||||
"bad namespace",
|
||||
"features.multi_agent_v2.tool_namespace must match ^[a-zA-Z0-9_-]+$",
|
||||
),
|
||||
(
|
||||
"functions",
|
||||
"features.multi_agent_v2.tool_namespace uses a reserved namespace: functions",
|
||||
),
|
||||
] {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
format!(
|
||||
r#"[features.multi_agent_v2]
|
||||
enabled = true
|
||||
tool_namespace = "{namespace}"
|
||||
"#
|
||||
),
|
||||
)?;
|
||||
|
||||
let err = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await
|
||||
.expect_err("invalid multi_agent_v2 tool namespace should fail");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(err.to_string(), expected_message);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_session_thread_cap_one_disallows_subagents() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -953,6 +953,7 @@ pub struct MultiAgentV2Config {
|
||||
pub usage_hint_text: Option<String>,
|
||||
pub root_agent_usage_hint_text: Option<String>,
|
||||
pub subagent_usage_hint_text: Option<String>,
|
||||
pub tool_namespace: Option<String>,
|
||||
pub hide_spawn_agent_metadata: bool,
|
||||
pub non_code_mode_only: bool,
|
||||
}
|
||||
@@ -969,6 +970,7 @@ impl Default for MultiAgentV2Config {
|
||||
usage_hint_text: None,
|
||||
root_agent_usage_hint_text: None,
|
||||
subagent_usage_hint_text: None,
|
||||
tool_namespace: None,
|
||||
hide_spawn_agent_metadata: false,
|
||||
non_code_mode_only: false,
|
||||
}
|
||||
@@ -2178,6 +2180,11 @@ fn resolve_multi_agent_v2_config(
|
||||
.or_else(|| base.and_then(|config| config.subagent_usage_hint_text.as_ref()))
|
||||
.cloned()
|
||||
.or(default.subagent_usage_hint_text);
|
||||
let tool_namespace = profile
|
||||
.and_then(|config| config.tool_namespace.as_ref())
|
||||
.or_else(|| base.and_then(|config| config.tool_namespace.as_ref()))
|
||||
.cloned()
|
||||
.or(default.tool_namespace);
|
||||
let hide_spawn_agent_metadata = profile
|
||||
.and_then(|config| config.hide_spawn_agent_metadata)
|
||||
.or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata))
|
||||
@@ -2196,6 +2203,7 @@ fn resolve_multi_agent_v2_config(
|
||||
usage_hint_text,
|
||||
root_agent_usage_hint_text,
|
||||
subagent_usage_hint_text,
|
||||
tool_namespace,
|
||||
hide_spawn_agent_metadata,
|
||||
non_code_mode_only,
|
||||
}
|
||||
@@ -2290,6 +2298,69 @@ fn validate_multi_agent_v2_wait_timeout(label: &str, value: i64) -> std::io::Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_multi_agent_v2_tool_namespace(namespace: Option<&str>) -> std::io::Result<()> {
|
||||
const LABEL: &str = "features.multi_agent_v2.tool_namespace";
|
||||
const MAX_LEN: usize = 64;
|
||||
const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[
|
||||
"api_tool",
|
||||
"browser",
|
||||
"computer",
|
||||
"container",
|
||||
"file_search",
|
||||
"functions",
|
||||
"image_gen",
|
||||
"multi_tool_use",
|
||||
"python",
|
||||
"python_user_visible",
|
||||
"submodel_delegator",
|
||||
"terminal",
|
||||
"tool_search",
|
||||
"web",
|
||||
];
|
||||
|
||||
let Some(namespace) = namespace else {
|
||||
return Ok(());
|
||||
};
|
||||
if namespace.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} must not be empty"),
|
||||
));
|
||||
}
|
||||
if namespace.trim() != namespace {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} must not have leading or trailing whitespace"),
|
||||
));
|
||||
}
|
||||
if !namespace
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} must match ^[a-zA-Z0-9_-]+$"),
|
||||
));
|
||||
}
|
||||
if namespace.chars().count() > MAX_LEN {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} must be at most {MAX_LEN} characters"),
|
||||
));
|
||||
}
|
||||
if namespace == "mcp"
|
||||
|| namespace.starts_with("mcp__")
|
||||
|| RESERVED_RESPONSES_NAMESPACES.contains(&namespace)
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} uses a reserved namespace: {namespace}"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Config {
|
||||
#[cfg(test)]
|
||||
async fn load_from_base_config_with_overrides(
|
||||
@@ -2911,6 +2982,7 @@ impl Config {
|
||||
"features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms",
|
||||
));
|
||||
}
|
||||
validate_multi_agent_v2_tool_namespace(multi_agent_v2.tool_namespace.as_deref())?;
|
||||
let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads);
|
||||
let agent_max_threads = if features.enabled(Feature::MultiAgentV2) {
|
||||
if agent_max_threads_from_config.is_some() {
|
||||
|
||||
@@ -13,10 +13,10 @@ use chrono::Utc;
|
||||
use codex_git_utils::GitSha;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GitInfo;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_thread_store::StoredThread;
|
||||
use core_test_support::PathBufExt;
|
||||
@@ -59,7 +59,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre
|
||||
repository_url: None,
|
||||
}),
|
||||
approval_mode: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
token_usage: None,
|
||||
first_user_message: Some(first_user_message.to_string()),
|
||||
history: None,
|
||||
|
||||
@@ -56,6 +56,7 @@ pub(super) async fn spawn_review_thread(
|
||||
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
|
||||
.with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone())
|
||||
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
|
||||
.with_multi_agent_v2_tool_namespace(config.multi_agent_v2.tool_namespace.clone())
|
||||
.with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only)
|
||||
.with_goal_tools_allowed(goal_tools_supported)
|
||||
.with_max_concurrent_threads_per_session(config.agent_max_threads)
|
||||
|
||||
@@ -220,6 +220,7 @@ impl TurnContext {
|
||||
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
|
||||
.with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone())
|
||||
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
|
||||
.with_multi_agent_v2_tool_namespace(config.multi_agent_v2.tool_namespace.clone())
|
||||
.with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only)
|
||||
.with_goal_tools_allowed(self.tools_config.goal_tools)
|
||||
.with_max_concurrent_threads_per_session(
|
||||
@@ -539,6 +540,7 @@ impl Session {
|
||||
.with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled)
|
||||
.with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone())
|
||||
.with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata)
|
||||
.with_multi_agent_v2_tool_namespace(per_turn_config.multi_agent_v2.tool_namespace.clone())
|
||||
.with_multi_agent_v2_non_code_mode_only(per_turn_config.multi_agent_v2.non_code_mode_only)
|
||||
.with_goal_tools_allowed(goal_tools_supported)
|
||||
.with_max_concurrent_threads_per_session(
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::collections::BTreeMap;
|
||||
const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed.";
|
||||
const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Optional model override for the new agent. Leave unset to inherit the same model as the parent, which is the preferred default. Only set this when the user explicitly asks for a different model or the task clearly requires one.";
|
||||
const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Optional service tier override for the new agent. Leave unset unless the user explicitly asks for one.";
|
||||
const MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SpawnAgentToolOptions {
|
||||
@@ -711,8 +712,11 @@ The new agent's canonical task name will be provided to it along with the messag
|
||||
}
|
||||
|
||||
fn spawn_agent_models_description(models: &[ModelPreset]) -> String {
|
||||
let visible_models: Vec<&ModelPreset> =
|
||||
models.iter().filter(|model| model.show_in_picker).collect();
|
||||
let visible_models: Vec<&ModelPreset> = models
|
||||
.iter()
|
||||
.filter(|model| model.show_in_picker)
|
||||
.take(MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION)
|
||||
.collect();
|
||||
if visible_models.is_empty() {
|
||||
return "No picker-visible model overrides are currently loaded.".to_string();
|
||||
}
|
||||
@@ -720,30 +724,40 @@ fn spawn_agent_models_description(models: &[ModelPreset]) -> String {
|
||||
let model_descriptions = visible_models
|
||||
.into_iter()
|
||||
.map(|model| {
|
||||
let default_reasoning_effort = model.default_reasoning_effort;
|
||||
let efforts = model
|
||||
.supported_reasoning_efforts
|
||||
.iter()
|
||||
.map(|preset| format!("{} ({})", preset.effort, preset.description))
|
||||
.map(|preset| {
|
||||
let effort = preset.effort;
|
||||
if effort == default_reasoning_effort {
|
||||
format!("{effort} (default)")
|
||||
} else {
|
||||
effort.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let service_tiers = if model.service_tiers.is_empty() {
|
||||
"none".to_string()
|
||||
let reasoning_efforts_suffix = if efforts.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
model
|
||||
.service_tiers
|
||||
.iter()
|
||||
.map(|tier| format!("{} ({}: {})", tier.id, tier.name, tier.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
format!(" Reasoning efforts: {efforts}.")
|
||||
};
|
||||
let service_tiers = model
|
||||
.service_tiers
|
||||
.iter()
|
||||
.map(|tier| tier.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let service_tiers_suffix = if service_tiers.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" Service tiers: {service_tiers}.")
|
||||
};
|
||||
let model_slug = &model.model;
|
||||
let description = &model.description;
|
||||
format!(
|
||||
"- {} (`{}`): {} Default reasoning effort: {}. Supported reasoning efforts: {}. Supported service tiers: {}.",
|
||||
model.display_name,
|
||||
model.model,
|
||||
model.description,
|
||||
model.default_reasoning_effort,
|
||||
efforts,
|
||||
service_tiers
|
||||
"- `{model_slug}`: {description}{reasoning_efforts_suffix}{service_tiers_suffix}"
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
|
||||
@@ -74,12 +74,10 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
|
||||
description
|
||||
.contains("Available model overrides (optional; inherited parent model is preferred):")
|
||||
);
|
||||
assert!(description.contains("visible display (`visible-model`)"));
|
||||
assert!(
|
||||
description
|
||||
.contains("Supported service tiers: priority (Fast: 1.5x speed, increased usage).")
|
||||
);
|
||||
assert!(!description.contains("hidden display (`hidden-model`)"));
|
||||
assert!(description.contains(
|
||||
"- `visible-model`: visible description Reasoning efforts: medium (default). Service tiers: priority."
|
||||
));
|
||||
assert!(!description.contains("hidden-model"));
|
||||
assert!(properties.contains_key("task_name"));
|
||||
assert!(properties.contains_key("message"));
|
||||
assert!(properties.contains_key("fork_turns"));
|
||||
@@ -150,6 +148,37 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_tool_caps_visible_model_summaries() {
|
||||
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
|
||||
available_models: vec![
|
||||
model_preset("first", /*show_in_picker*/ true),
|
||||
model_preset("second", /*show_in_picker*/ true),
|
||||
model_preset("third", /*show_in_picker*/ true),
|
||||
model_preset("fourth", /*show_in_picker*/ true),
|
||||
model_preset("fifth", /*show_in_picker*/ true),
|
||||
model_preset("sixth", /*show_in_picker*/ true),
|
||||
],
|
||||
agent_type_description: "role help".to_string(),
|
||||
hide_agent_type_model_reasoning: false,
|
||||
include_usage_hint: true,
|
||||
usage_hint_text: None,
|
||||
max_concurrent_threads_per_session: Some(4),
|
||||
});
|
||||
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) = tool else {
|
||||
panic!("spawn_agent should be a function tool");
|
||||
};
|
||||
|
||||
for model in ["first", "second", "third", "fourth", "fifth"] {
|
||||
assert!(
|
||||
description.contains(&format!("`{model}-model`")),
|
||||
"expected {model} model summary in spawn_agent description: {description:?}"
|
||||
);
|
||||
}
|
||||
assert!(!description.contains("`sixth-model`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() {
|
||||
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
|
||||
|
||||
@@ -281,10 +281,6 @@ impl ToolRegistry {
|
||||
self.tools.get(name).map(Arc::clone)
|
||||
}
|
||||
|
||||
pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option<ToolExposure> {
|
||||
self.tools.get(name).map(|tool| tool.exposure())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn has_tool(&self, name: &ToolName) -> bool {
|
||||
self.tool(name).is_some()
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::config::DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS;
|
||||
use crate::config::DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS;
|
||||
use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::code_mode::execute_spec::create_code_mode_tool;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::handlers::ApplyPatchHandler;
|
||||
use crate::tools::handlers::CodeModeExecuteHandler;
|
||||
use crate::tools::handlers::CodeModeWaitHandler;
|
||||
@@ -58,12 +59,14 @@ use codex_mcp::ToolInfo;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use codex_tools::ResponsesApiNamespace;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::TOOL_SEARCH_TOOL_NAME;
|
||||
use codex_tools::ToolCall as ExtensionToolCall;
|
||||
use codex_tools::ToolEnvironmentMode;
|
||||
use codex_tools::ToolExecutor;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolOutput;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_tools::ToolsConfig;
|
||||
use codex_tools::collect_code_mode_exec_prompt_tool_definitions;
|
||||
@@ -73,6 +76,8 @@ use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
|
||||
const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents.";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ToolRegistryBuildParams<'a> {
|
||||
mcp_tools: Option<&'a [ToolInfo]>,
|
||||
@@ -127,22 +132,29 @@ fn build_model_visible_specs_and_registry(
|
||||
let mut specs = Vec::new();
|
||||
let mut seen_tool_names = HashSet::new();
|
||||
for executor in &executors {
|
||||
if !seen_tool_names.insert(executor.tool_name()) {
|
||||
let tool_name = executor.tool_name();
|
||||
if !seen_tool_names.insert(tool_name.clone()) {
|
||||
continue;
|
||||
}
|
||||
if executor.exposure().is_direct()
|
||||
let exposure = executor.exposure();
|
||||
if exposure.is_direct()
|
||||
&& !is_hidden_by_code_mode_only(config, &tool_name, exposure)
|
||||
&& let Some(spec) = executor.spec()
|
||||
{
|
||||
specs.push(spec_for_model_request(config, executor.exposure(), spec));
|
||||
specs.push(spec_for_model_request(config, exposure, spec));
|
||||
}
|
||||
}
|
||||
for spec in hosted_specs {
|
||||
if !is_hidden_by_code_mode_only(config, &ToolName::plain(spec.name()), ToolExposure::Direct)
|
||||
{
|
||||
specs.push(spec);
|
||||
}
|
||||
}
|
||||
specs.extend(hosted_specs);
|
||||
|
||||
let registry = ToolRegistry::from_tools(executors);
|
||||
let model_visible_specs = merge_into_namespaces(specs)
|
||||
.into_iter()
|
||||
.filter(|spec| config.namespace_tools || !matches!(spec, ToolSpec::Namespace(_)))
|
||||
.filter(|spec| !is_hidden_by_code_mode_only(config, ®istry, spec))
|
||||
.collect();
|
||||
|
||||
(model_visible_specs, registry)
|
||||
@@ -210,17 +222,14 @@ fn agent_type_description(config: &ToolsConfig, default_agent_type_description:
|
||||
|
||||
fn is_hidden_by_code_mode_only(
|
||||
config: &ToolsConfig,
|
||||
registry: &ToolRegistry,
|
||||
spec: &ToolSpec,
|
||||
tool_name: &ToolName,
|
||||
exposure: ToolExposure,
|
||||
) -> bool {
|
||||
if !config.code_mode_only_enabled || !codex_code_mode::is_code_mode_nested_tool(spec.name()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let exposure = registry
|
||||
.tool_exposure(&ToolName::plain(spec.name()))
|
||||
.unwrap_or(ToolExposure::Direct);
|
||||
exposure != ToolExposure::DirectModelOnly
|
||||
config.code_mode_only_enabled
|
||||
&& exposure != ToolExposure::DirectModelOnly
|
||||
&& codex_code_mode::is_code_mode_nested_tool(&codex_tools::code_mode_name_for_tool_name(
|
||||
tool_name,
|
||||
))
|
||||
}
|
||||
|
||||
fn build_code_mode_executors(
|
||||
@@ -442,6 +451,10 @@ fn collect_tool_executors(
|
||||
} else {
|
||||
ToolExposure::Direct
|
||||
};
|
||||
let tool_namespace = config
|
||||
.namespace_tools
|
||||
.then_some(config.multi_agent_v2_tool_namespace.as_deref())
|
||||
.flatten();
|
||||
let agent_type_description =
|
||||
agent_type_description(config, params.default_agent_type_description);
|
||||
executors.push(multi_agent_v2_handler(
|
||||
@@ -454,15 +467,33 @@ fn collect_tool_executors(
|
||||
max_concurrent_threads_per_session: config.max_concurrent_threads_per_session,
|
||||
}),
|
||||
exposure,
|
||||
tool_namespace,
|
||||
));
|
||||
executors.push(multi_agent_v2_handler(
|
||||
SendMessageHandlerV2,
|
||||
exposure,
|
||||
tool_namespace,
|
||||
));
|
||||
executors.push(multi_agent_v2_handler(
|
||||
FollowupTaskHandlerV2,
|
||||
exposure,
|
||||
tool_namespace,
|
||||
));
|
||||
executors.push(multi_agent_v2_handler(SendMessageHandlerV2, exposure));
|
||||
executors.push(multi_agent_v2_handler(FollowupTaskHandlerV2, exposure));
|
||||
executors.push(multi_agent_v2_handler(
|
||||
WaitAgentHandlerV2::new(params.wait_agent_timeouts),
|
||||
exposure,
|
||||
tool_namespace,
|
||||
));
|
||||
executors.push(multi_agent_v2_handler(
|
||||
CloseAgentHandlerV2,
|
||||
exposure,
|
||||
tool_namespace,
|
||||
));
|
||||
executors.push(multi_agent_v2_handler(
|
||||
ListAgentsHandlerV2,
|
||||
exposure,
|
||||
tool_namespace,
|
||||
));
|
||||
executors.push(multi_agent_v2_handler(CloseAgentHandlerV2, exposure));
|
||||
executors.push(multi_agent_v2_handler(ListAgentsHandlerV2, exposure));
|
||||
} else {
|
||||
let agent_type_description =
|
||||
agent_type_description(config, params.default_agent_type_description);
|
||||
@@ -592,8 +623,70 @@ fn append_extension_tool_executors(
|
||||
fn multi_agent_v2_handler(
|
||||
handler: impl CoreToolRuntime + 'static,
|
||||
exposure: ToolExposure,
|
||||
namespace: Option<&str>,
|
||||
) -> Arc<dyn CoreToolRuntime> {
|
||||
override_tool_exposure(Arc::new(handler), exposure)
|
||||
let handler: Arc<dyn CoreToolRuntime> = match namespace {
|
||||
Some(namespace) => Arc::new(MultiAgentV2NamespaceOverride {
|
||||
handler: Arc::new(handler),
|
||||
namespace: namespace.to_string(),
|
||||
}),
|
||||
None => Arc::new(handler),
|
||||
};
|
||||
override_tool_exposure(handler, exposure)
|
||||
}
|
||||
|
||||
struct MultiAgentV2NamespaceOverride {
|
||||
handler: Arc<dyn CoreToolRuntime>,
|
||||
namespace: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for MultiAgentV2NamespaceOverride {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::namespaced(self.namespace.clone(), self.handler.tool_name().name)
|
||||
}
|
||||
|
||||
fn spec(&self) -> Option<ToolSpec> {
|
||||
match self.handler.spec()? {
|
||||
ToolSpec::Function(tool) => Some(ToolSpec::Namespace(ResponsesApiNamespace {
|
||||
name: self.namespace.clone(),
|
||||
description: MULTI_AGENT_V2_NAMESPACE_DESCRIPTION.to_string(),
|
||||
tools: vec![ResponsesApiNamespaceTool::Function(tool)],
|
||||
})),
|
||||
spec => Some(spec),
|
||||
}
|
||||
}
|
||||
|
||||
fn exposure(&self) -> ToolExposure {
|
||||
self.handler.exposure()
|
||||
}
|
||||
|
||||
fn supports_parallel_tool_calls(&self) -> bool {
|
||||
self.handler.supports_parallel_tool_calls()
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn ToolOutput>, codex_tools::FunctionCallError> {
|
||||
self.handler.handle(invocation).await
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreToolRuntime for MultiAgentV2NamespaceOverride {
|
||||
fn matches_kind(&self, payload: &crate::tools::context::ToolPayload) -> bool {
|
||||
self.handler.matches_kind(payload)
|
||||
}
|
||||
|
||||
fn search_info(&self) -> Option<crate::tools::tool_search_entry::ToolSearchInfo> {
|
||||
self.handler.search_info()
|
||||
}
|
||||
|
||||
fn create_diff_consumer(
|
||||
&self,
|
||||
) -> Option<Box<dyn crate::tools::registry::ToolArgumentDiffConsumer>> {
|
||||
self.handler.create_diff_consumer()
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_code_mode_tools(
|
||||
|
||||
@@ -1341,3 +1341,67 @@ async fn code_mode_only_can_expose_multi_agent_v2_as_normal_tools() {
|
||||
};
|
||||
assert!(!spawn_agent.description.contains("exec tool declaration"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() {
|
||||
let config = test_config().await;
|
||||
let model_info = construct_model_info_offline("gpt-5.4", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::CodeMode);
|
||||
features.enable(Feature::CodeModeOnly);
|
||||
features.enable(Feature::MultiAgentV2);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Live),
|
||||
session_source: SessionSource::Cli,
|
||||
permission_profile: &PermissionProfile::Disabled,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
})
|
||||
.with_multi_agent_v2_tool_namespace(Some("agents".to_string()))
|
||||
.with_multi_agent_v2_non_code_mode_only(/*multi_agent_v2_non_code_mode_only*/ true);
|
||||
let router = ToolRouter::from_config(
|
||||
&tools_config,
|
||||
ToolRouterParams {
|
||||
mcp_tools: None,
|
||||
deferred_mcp_tools: None,
|
||||
discoverable_tools: None,
|
||||
extension_tool_executors: Vec::new(),
|
||||
dynamic_tools: &[],
|
||||
},
|
||||
);
|
||||
let model_visible_specs = router.model_visible_specs();
|
||||
let tool_names = model_visible_specs
|
||||
.iter()
|
||||
.map(ToolSpec::name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(tool_names, vec!["exec", "wait", "agents"]);
|
||||
|
||||
let exec = find_tool(&model_visible_specs, "exec");
|
||||
let ToolSpec::Freeform(exec) = exec else {
|
||||
panic!("exec should be a freeform tool");
|
||||
};
|
||||
assert!(!exec.description.contains("spawn_agent"));
|
||||
assert!(!exec.description.contains("wait_agent"));
|
||||
assert!(
|
||||
!exec
|
||||
.description
|
||||
.contains("do not attempt to use any other tools directly")
|
||||
);
|
||||
|
||||
for tool_name in [
|
||||
"spawn_agent",
|
||||
"send_message",
|
||||
"followup_task",
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
"list_agents",
|
||||
] {
|
||||
let tool = find_namespace_function_tool(&model_visible_specs, "agents", tool_name);
|
||||
assert!(!tool.description.contains("exec tool declaration"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,6 +577,77 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
|
||||
assert_lacks_tool_name(&tools, "resume_agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_multi_agent_v2_uses_configured_tool_namespace() {
|
||||
let model_info = model_info();
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::MultiAgentV2);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
permission_profile: &PermissionProfile::Disabled,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
})
|
||||
.with_multi_agent_v2_tool_namespace(Some("agents".to_string()));
|
||||
let (tools, registry) = build_specs(
|
||||
&tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_contains_tool_names(&tools, &["agents"]);
|
||||
for tool_name in [
|
||||
"spawn_agent",
|
||||
"send_message",
|
||||
"followup_task",
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
"list_agents",
|
||||
] {
|
||||
assert_lacks_tool_name(&tools, tool_name);
|
||||
assert!(registry.has_tool(&ToolName::namespaced("agents", tool_name)));
|
||||
assert!(!registry.has_tool(&ToolName::plain(tool_name)));
|
||||
assert_namespace_contains_function(&tools, "agents", tool_name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_multi_agent_v2_ignores_tool_namespace_without_namespace_support() {
|
||||
let model_info = model_info();
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::MultiAgentV2);
|
||||
let available_models = Vec::new();
|
||||
let mut tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
permission_profile: &PermissionProfile::Disabled,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
})
|
||||
.with_multi_agent_v2_tool_namespace(Some("agents".to_string()));
|
||||
tools_config.namespace_tools = false;
|
||||
let (tools, registry) = build_specs(
|
||||
&tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_contains_tool_names(&tools, &["spawn_agent", "send_message", "list_agents"]);
|
||||
assert_lacks_tool_name(&tools, "agents");
|
||||
assert!(registry.has_tool(&ToolName::plain("spawn_agent")));
|
||||
assert!(!registry.has_tool(&ToolName::namespaced("agents", "spawn_agent")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_multi_agent_v2_does_not_require_collab_feature() {
|
||||
let model_info = model_info();
|
||||
@@ -2657,6 +2728,23 @@ fn find_tool<'a>(tools: &'a [ToolSpec], expected_name: &str) -> &'a ToolSpec {
|
||||
.unwrap_or_else(|| panic!("expected tool {expected_name}"))
|
||||
}
|
||||
|
||||
fn assert_namespace_contains_function(
|
||||
tools: &[ToolSpec],
|
||||
expected_namespace: &str,
|
||||
expected_name: &str,
|
||||
) {
|
||||
let namespace_tool = find_tool(tools, expected_namespace);
|
||||
let ToolSpec::Namespace(namespace) = namespace_tool else {
|
||||
panic!("expected namespace tool {expected_namespace}");
|
||||
};
|
||||
assert!(
|
||||
namespace.tools.iter().any(|tool| {
|
||||
matches!(tool, ResponsesApiNamespaceTool::Function(tool) if tool.name == expected_name)
|
||||
}),
|
||||
"expected tool {expected_name} in namespace {expected_namespace}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_process_tool_environment_id(
|
||||
tools: &[ToolSpec],
|
||||
expected_name: &str,
|
||||
|
||||
@@ -123,6 +123,10 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() ->
|
||||
effort: ReasoningEffort::Low,
|
||||
description: "Quick scan".to_string(),
|
||||
},
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::Medium,
|
||||
description: "Balanced".to_string(),
|
||||
},
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::High,
|
||||
description: "Deep dive".to_string(),
|
||||
@@ -175,7 +179,7 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() ->
|
||||
spawn_agent_description(&body).expect("spawn_agent description should be present");
|
||||
|
||||
assert!(
|
||||
description.contains("- Visible Model (`visible-model`): Fast and capable"),
|
||||
description.contains("- `visible-model`: Fast and capable"),
|
||||
"expected visible model summary in spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -196,20 +200,15 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() ->
|
||||
"expected model override usage guidance in spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
description.contains("Default reasoning effort: medium."),
|
||||
description.contains("Reasoning efforts: low, medium (default), high."),
|
||||
"expected default reasoning effort in spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
description.contains("low (Quick scan), high (Deep dive)."),
|
||||
"expected reasoning efforts in spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
description
|
||||
.contains("Supported service tiers: priority (Fast: 1.5x speed, increased usage)."),
|
||||
description.contains("Service tiers: priority."),
|
||||
"expected service tier guidance in spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
!description.contains("Hidden Model"),
|
||||
!description.contains("hidden-model"),
|
||||
"hidden picker model should be omitted from spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -17,6 +17,7 @@ axum = { workspace = true, features = ["http1", "tokio", "ws"] }
|
||||
base64 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-api = { workspace = true }
|
||||
codex-client = { workspace = true }
|
||||
codex-file-system = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
@@ -51,6 +52,7 @@ uuid = { workspace = true, features = ["v4"] }
|
||||
anyhow = { workspace = true }
|
||||
codex-test-binary-support = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
http = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
serial_test = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -26,7 +26,11 @@ The CLI entrypoint supports:
|
||||
|
||||
Remote mode registers the local exec-server with the executor registry,
|
||||
then reconnects to the service-provided rendezvous websocket as the executor.
|
||||
It requires a bearer token in `CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN`.
|
||||
It uses the standard Codex ChatGPT sign-in state; run `codex login` first when
|
||||
remote registration needs authentication. Containerized callers that receive an
|
||||
Agent Identity JWT in `CODEX_ACCESS_TOKEN` can opt into that auth path with
|
||||
`--use-agent-identity-auth`; Codex then registers an Agent task and sends the
|
||||
derived AgentAssertion headers on the registry request.
|
||||
|
||||
Wire framing:
|
||||
|
||||
@@ -376,7 +380,9 @@ The crate exports:
|
||||
|
||||
Callers must pass `ExecServerRuntimePaths` to `run_main()`. The top-level
|
||||
`codex exec-server` command builds these paths from the `codex` arg0 dispatch
|
||||
state.
|
||||
state. `RemoteExecutorConfig::new(...)` also takes the auth provider that
|
||||
remote registration should use; the CLI builds that provider from Codex auth
|
||||
state before starting remote mode.
|
||||
|
||||
## Example session
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ pub use protocol::TerminateResponse;
|
||||
pub use protocol::WriteParams;
|
||||
pub use protocol::WriteResponse;
|
||||
pub use protocol::WriteStatus;
|
||||
pub use remote::CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR;
|
||||
pub use remote::RemoteExecutorConfig;
|
||||
pub use remote::run_remote_executor;
|
||||
pub use runtime_paths::ExecServerRuntimePaths;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::env;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_api::SharedAuthProvider;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use tokio::time::sleep;
|
||||
@@ -14,15 +14,12 @@ use crate::ExecServerRuntimePaths;
|
||||
use crate::relay::run_multiplexed_executor;
|
||||
use crate::server::ConnectionProcessor;
|
||||
|
||||
pub const CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR: &str =
|
||||
"CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN";
|
||||
|
||||
const ERROR_BODY_PREVIEW_BYTES: usize = 4096;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ExecutorRegistryClient {
|
||||
base_url: String,
|
||||
bearer_token: String,
|
||||
auth_provider: SharedAuthProvider,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
@@ -30,17 +27,17 @@ impl std::fmt::Debug for ExecutorRegistryClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ExecutorRegistryClient")
|
||||
.field("base_url", &self.base_url)
|
||||
.field("bearer_token", &"<redacted>")
|
||||
.field("auth_provider", &"<redacted>")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecutorRegistryClient {
|
||||
fn new(base_url: String, bearer_token: String) -> Result<Self, ExecServerError> {
|
||||
fn new(base_url: String, auth_provider: SharedAuthProvider) -> Result<Self, ExecServerError> {
|
||||
let base_url = normalize_base_url(base_url)?;
|
||||
Ok(Self {
|
||||
base_url,
|
||||
bearer_token,
|
||||
auth_provider,
|
||||
http: reqwest::Client::new(),
|
||||
})
|
||||
}
|
||||
@@ -55,7 +52,7 @@ impl ExecutorRegistryClient {
|
||||
&self.base_url,
|
||||
&format!("/cloud/executor/{executor_id}/register"),
|
||||
))
|
||||
.bearer_auth(&self.bearer_token)
|
||||
.headers(self.auth_provider.to_auth_headers())
|
||||
.send()
|
||||
.await?;
|
||||
self.parse_json_response(response).await
|
||||
@@ -89,12 +86,12 @@ struct ExecutorRegistryExecutorRegistrationResponse {
|
||||
}
|
||||
|
||||
/// Configuration for registering an exec-server for remote use.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteExecutorConfig {
|
||||
pub base_url: String,
|
||||
pub executor_id: String,
|
||||
pub name: String,
|
||||
bearer_token: String,
|
||||
auth_provider: SharedAuthProvider,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RemoteExecutorConfig {
|
||||
@@ -103,28 +100,23 @@ impl std::fmt::Debug for RemoteExecutorConfig {
|
||||
.field("base_url", &self.base_url)
|
||||
.field("executor_id", &self.executor_id)
|
||||
.field("name", &self.name)
|
||||
.field("bearer_token", &"<redacted>")
|
||||
.field("auth_provider", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteExecutorConfig {
|
||||
pub fn new(base_url: String, executor_id: String) -> Result<Self, ExecServerError> {
|
||||
Self::with_bearer_token(base_url, executor_id, read_remote_bearer_token_from_env()?)
|
||||
}
|
||||
|
||||
pub fn with_bearer_token(
|
||||
pub fn new(
|
||||
base_url: String,
|
||||
executor_id: String,
|
||||
bearer_token: String,
|
||||
auth_provider: SharedAuthProvider,
|
||||
) -> Result<Self, ExecServerError> {
|
||||
let executor_id = normalize_executor_id(executor_id)?;
|
||||
let bearer_token = normalize_bearer_token(bearer_token)?;
|
||||
Ok(Self {
|
||||
base_url,
|
||||
executor_id,
|
||||
name: "codex-exec-server".to_string(),
|
||||
bearer_token,
|
||||
auth_provider,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -136,7 +128,8 @@ pub async fn run_remote_executor(
|
||||
runtime_paths: ExecServerRuntimePaths,
|
||||
) -> Result<(), ExecServerError> {
|
||||
ensure_rustls_crypto_provider();
|
||||
let client = ExecutorRegistryClient::new(config.base_url.clone(), config.bearer_token.clone())?;
|
||||
let client =
|
||||
ExecutorRegistryClient::new(config.base_url.clone(), config.auth_provider.clone())?;
|
||||
let processor = ConnectionProcessor::new(runtime_paths);
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
|
||||
@@ -162,32 +155,6 @@ pub async fn run_remote_executor(
|
||||
}
|
||||
}
|
||||
|
||||
fn read_remote_bearer_token_from_env() -> Result<String, ExecServerError> {
|
||||
read_remote_bearer_token_from_env_with(|name| env::var(name))
|
||||
}
|
||||
|
||||
fn read_remote_bearer_token_from_env_with<F>(get_var: F) -> Result<String, ExecServerError>
|
||||
where
|
||||
F: FnOnce(&str) -> Result<String, env::VarError>,
|
||||
{
|
||||
let bearer_token = get_var(CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR).map_err(|_| {
|
||||
ExecServerError::ExecutorRegistryAuth(format!(
|
||||
"executor registry bearer token environment variable `{CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR}` is not set"
|
||||
))
|
||||
})?;
|
||||
normalize_bearer_token(bearer_token)
|
||||
}
|
||||
|
||||
fn normalize_bearer_token(bearer_token: String) -> Result<String, ExecServerError> {
|
||||
let bearer_token = bearer_token.trim().to_string();
|
||||
if bearer_token.is_empty() {
|
||||
return Err(ExecServerError::ExecutorRegistryAuth(format!(
|
||||
"executor registry bearer token environment variable `{CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR}` is empty"
|
||||
)));
|
||||
}
|
||||
Ok(bearer_token)
|
||||
}
|
||||
|
||||
fn normalize_executor_id(executor_id: String) -> Result<String, ExecServerError> {
|
||||
let executor_id = executor_id.trim().to_string();
|
||||
if executor_id.is_empty() {
|
||||
@@ -274,6 +241,11 @@ fn preview_error_body(body: &str) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_api::AuthProvider;
|
||||
use http::HeaderMap;
|
||||
use http::HeaderValue;
|
||||
use pretty_assertions::assert_eq;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
@@ -284,25 +256,46 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StaticRegistryAuthProvider;
|
||||
|
||||
impl AuthProvider for StaticRegistryAuthProvider {
|
||||
fn add_auth_headers(&self, headers: &mut HeaderMap) {
|
||||
let _ = headers.insert(
|
||||
http::header::AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer registry-token"),
|
||||
);
|
||||
let _ = headers.insert(
|
||||
"ChatGPT-Account-ID",
|
||||
HeaderValue::from_static("workspace-123"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn static_registry_auth_provider() -> SharedAuthProvider {
|
||||
Arc::new(StaticRegistryAuthProvider)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_executor_posts_with_bearer_token_header() {
|
||||
async fn register_executor_posts_with_auth_provider_headers() {
|
||||
let server = MockServer::start().await;
|
||||
let config = RemoteExecutorConfig::with_bearer_token(
|
||||
let config = RemoteExecutorConfig::new(
|
||||
server.uri(),
|
||||
"exec-requested".to_string(),
|
||||
"registry-token".to_string(),
|
||||
static_registry_auth_provider(),
|
||||
)
|
||||
.expect("config");
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/cloud/executor/exec-requested/register"))
|
||||
.and(header("authorization", "Bearer registry-token"))
|
||||
.and(header("chatgpt-account-id", "workspace-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"executor_id": "exec-1",
|
||||
"url": "wss://rendezvous.test/executor/exec-1?role=executor&sig=abc"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let client = ExecutorRegistryClient::new(server.uri(), "registry-token".to_string())
|
||||
let client = ExecutorRegistryClient::new(server.uri(), static_registry_auth_provider())
|
||||
.expect("client");
|
||||
|
||||
let response = client
|
||||
@@ -320,17 +313,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_bearer_token() {
|
||||
let config = RemoteExecutorConfig::with_bearer_token(
|
||||
fn debug_output_redacts_auth_provider() {
|
||||
let config = RemoteExecutorConfig::new(
|
||||
"https://registry.example".to_string(),
|
||||
"exec-1".to_string(),
|
||||
"secret-token".to_string(),
|
||||
static_registry_auth_provider(),
|
||||
)
|
||||
.expect("config");
|
||||
|
||||
let debug = format!("{config:?}");
|
||||
|
||||
assert!(debug.contains("<redacted>"));
|
||||
assert!(!debug.contains("secret-token"));
|
||||
assert!(!debug.contains("workspace-123"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use anyhow::bail;
|
||||
use codex_api::AuthProvider;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
@@ -22,12 +23,15 @@ use codex_exec_server::InitializeResponse;
|
||||
use codex_exec_server::RemoteExecutorConfig;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
use http::HeaderMap;
|
||||
use http::HeaderValue;
|
||||
use pretty_assertions::assert_eq;
|
||||
use prost::Message as ProstMessage;
|
||||
use relay_proto::RelayData;
|
||||
use relay_proto::RelayMessageFrame;
|
||||
use relay_proto::RelayReset;
|
||||
use relay_proto::relay_message_frame;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::timeout;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
@@ -46,6 +50,22 @@ const REGISTRY_TOKEN: &str = "registry-token";
|
||||
const RELAY_MESSAGE_FRAME_VERSION: u32 = 1;
|
||||
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StaticRegistryAuthProvider;
|
||||
|
||||
impl AuthProvider for StaticRegistryAuthProvider {
|
||||
fn add_auth_headers(&self, headers: &mut HeaderMap) {
|
||||
let _ = headers.insert(
|
||||
http::header::AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer registry-token"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn static_registry_auth_provider() -> codex_api::SharedAuthProvider {
|
||||
Arc::new(StaticRegistryAuthProvider)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn multiplexed_remote_executor_routes_independent_virtual_streams() -> Result<()> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
@@ -63,10 +83,10 @@ async fn multiplexed_remote_executor_routes_independent_virtual_streams() -> Res
|
||||
|
||||
let (codex_exe, codex_linux_sandbox_exe) = common::current_test_binary_helper_paths()?;
|
||||
let runtime_paths = ExecServerRuntimePaths::new(codex_exe, codex_linux_sandbox_exe)?;
|
||||
let config = RemoteExecutorConfig::with_bearer_token(
|
||||
let config = RemoteExecutorConfig::new(
|
||||
registry.uri(),
|
||||
EXECUTOR_ID.to_string(),
|
||||
REGISTRY_TOKEN.to_string(),
|
||||
static_registry_auth_provider(),
|
||||
)?;
|
||||
let remote_executor = tokio::spawn(codex_exec_server::run_remote_executor(
|
||||
config,
|
||||
|
||||
@@ -223,6 +223,7 @@ impl EventProcessorWithJsonOutput {
|
||||
arguments,
|
||||
result: result.map(|result| McpToolCallItemResult {
|
||||
content: result.content,
|
||||
meta: result.meta,
|
||||
structured_content: result.structured_content,
|
||||
}),
|
||||
error: error.map(|error| McpToolCallItemError {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
@@ -57,3 +58,53 @@ fn failed_turn_does_not_overwrite_output_last_message_file() {
|
||||
"keep existing contents"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_call_result_preserves_meta_in_jsonl_event() {
|
||||
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
||||
|
||||
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
||||
codex_app_server_protocol::ItemCompletedNotification {
|
||||
item: ThreadItem::McpToolCall {
|
||||
id: "mcp-1".to_string(),
|
||||
server: "search service".to_string(),
|
||||
tool: "web_run".to_string(),
|
||||
status: McpToolCallStatus::Completed,
|
||||
arguments: json!({"search_query": [{"q": "OpenAI Codex CLI documentation"}]}),
|
||||
mcp_app_resource_uri: None,
|
||||
result: Some(Box::new(codex_app_server_protocol::McpToolCallResult {
|
||||
content: vec![json!({"type": "text", "text": "search result"})],
|
||||
structured_content: None,
|
||||
meta: Some(json!({"raw_messages": [{"ref_id": "turn0search0"}]})),
|
||||
})),
|
||||
error: None,
|
||||
duration_ms: Some(42),
|
||||
},
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
completed_at_ms: 0,
|
||||
},
|
||||
));
|
||||
|
||||
assert_eq!(collected.status, CodexStatus::Running);
|
||||
assert_eq!(collected.events.len(), 1);
|
||||
|
||||
let ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) = &collected.events[0] else {
|
||||
panic!("expected item.completed event");
|
||||
};
|
||||
let ThreadItemDetails::McpToolCall(item) = &item.details else {
|
||||
panic!("expected MCP tool call item");
|
||||
};
|
||||
let result = item.result.as_ref().expect("expected MCP tool result");
|
||||
assert_eq!(
|
||||
result.meta,
|
||||
Some(json!({"raw_messages": [{"ref_id": "turn0search0"}]}))
|
||||
);
|
||||
|
||||
let serialized = serde_json::to_value(&collected.events[0]).expect("serialize event");
|
||||
assert_eq!(
|
||||
serialized["item"]["result"]["_meta"],
|
||||
json!({"raw_messages": [{"ref_id": "turn0search0"}]})
|
||||
);
|
||||
assert!(serialized["item"]["result"].get("meta").is_none());
|
||||
}
|
||||
|
||||
@@ -266,6 +266,9 @@ pub struct McpToolCallItemResult {
|
||||
// representations). Using `JsonValue` keeps the payload wire-shaped and
|
||||
// easy to export.
|
||||
pub content: Vec<JsonValue>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub meta: Option<JsonValue>,
|
||||
pub structured_content: Option<JsonValue>,
|
||||
}
|
||||
|
||||
|
||||
@@ -540,6 +540,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() {
|
||||
arguments: json!({ "key": "value" }),
|
||||
result: Some(McpToolCallItemResult {
|
||||
content: Vec::new(),
|
||||
meta: None,
|
||||
structured_content: None,
|
||||
}),
|
||||
error: None,
|
||||
@@ -681,6 +682,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
|
||||
"type": "text",
|
||||
"text": "done",
|
||||
})],
|
||||
meta: None,
|
||||
structured_content: Some(json!({ "status": "ok" })),
|
||||
}),
|
||||
error: None,
|
||||
|
||||
@@ -30,6 +30,9 @@ pub struct MultiAgentV2ConfigToml {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subagent_usage_hint_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(length(min = 1, max = 64), regex(pattern = r"^[a-zA-Z0-9_-]+$"))]
|
||||
pub tool_namespace: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hide_spawn_agent_metadata: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub non_code_mode_only: Option<bool>,
|
||||
|
||||
@@ -528,6 +528,7 @@ usage_hint_enabled = false
|
||||
usage_hint_text = "Custom delegation guidance."
|
||||
root_agent_usage_hint_text = "Root guidance."
|
||||
subagent_usage_hint_text = "Subagent guidance."
|
||||
tool_namespace = "agents"
|
||||
hide_spawn_agent_metadata = true
|
||||
non_code_mode_only = true
|
||||
"#,
|
||||
@@ -550,6 +551,7 @@ non_code_mode_only = true
|
||||
usage_hint_text: Some("Custom delegation guidance.".to_string()),
|
||||
root_agent_usage_hint_text: Some("Root guidance.".to_string()),
|
||||
subagent_usage_hint_text: Some("Subagent guidance.".to_string()),
|
||||
tool_namespace: Some("agents".to_string()),
|
||||
hide_spawn_agent_metadata: Some(true),
|
||||
non_code_mode_only: Some(true),
|
||||
}))
|
||||
@@ -588,6 +590,7 @@ usage_hint_enabled = false
|
||||
usage_hint_text: None,
|
||||
root_agent_usage_hint_text: None,
|
||||
subagent_usage_hint_text: None,
|
||||
tool_namespace: None,
|
||||
hide_spawn_agent_metadata: None,
|
||||
non_code_mode_only: None,
|
||||
}))
|
||||
|
||||
@@ -8,9 +8,9 @@ use std::sync::OnceLock;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
|
||||
use crate::AppendThreadItemsParams;
|
||||
use crate::ArchiveThreadParams;
|
||||
@@ -363,9 +363,9 @@ fn stored_thread_from_state(
|
||||
approval_mode: metadata
|
||||
.and_then(|metadata| metadata.approval_mode)
|
||||
.unwrap_or(AskForApproval::Never),
|
||||
sandbox_policy: metadata
|
||||
.and_then(|metadata| metadata.sandbox_policy.clone())
|
||||
.unwrap_or_else(SandboxPolicy::new_read_only_policy),
|
||||
permission_profile: metadata
|
||||
.and_then(|metadata| metadata.permission_profile.clone())
|
||||
.unwrap_or_else(PermissionProfile::read_only),
|
||||
token_usage: metadata.and_then(|metadata| metadata.token_usage.clone()),
|
||||
first_user_message: metadata.and_then(|metadata| metadata.first_user_message.clone()),
|
||||
history,
|
||||
|
||||
@@ -9,8 +9,10 @@ use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use codex_git_utils::GitSha;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GitInfo;
|
||||
use codex_protocol::protocol::NetworkAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
|
||||
@@ -140,13 +142,34 @@ pub(super) fn stored_thread_from_rollout_item(
|
||||
agent_path: None,
|
||||
git_info,
|
||||
approval_mode: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
token_usage: None,
|
||||
first_user_message: item.first_user_message,
|
||||
history: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn permission_profile_from_metadata_value(value: &str, cwd: &Path) -> PermissionProfile {
|
||||
serde_json::from_str::<PermissionProfile>(value)
|
||||
.or_else(|_| {
|
||||
parse_legacy_sandbox_policy(value)
|
||||
.map(|policy| PermissionProfile::from_legacy_sandbox_policy_for_cwd(&policy, cwd))
|
||||
})
|
||||
.unwrap_or_else(|_| PermissionProfile::read_only())
|
||||
}
|
||||
|
||||
pub(super) fn permission_profile_to_metadata_value(
|
||||
permission_profile: &PermissionProfile,
|
||||
) -> String {
|
||||
match serde_json::to_string(permission_profile) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to serialize permission profile metadata: {err}");
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn distinct_thread_metadata_title(metadata: &ThreadMetadata) -> Option<String> {
|
||||
let title = metadata.title.trim();
|
||||
if title.is_empty() || metadata.first_user_message.as_deref().map(str::trim) == Some(title) {
|
||||
@@ -169,6 +192,20 @@ fn parse_rfc3339(value: Option<&str>) -> Option<DateTime<Utc>> {
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
fn parse_legacy_sandbox_policy(value: &str) -> serde_json::Result<SandboxPolicy> {
|
||||
serde_json::from_str(value)
|
||||
.or_else(|_| serde_json::from_value(serde_json::Value::String(value.to_string())))
|
||||
.or_else(|_| match value {
|
||||
"danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
|
||||
"read-only" => Ok(SandboxPolicy::new_read_only_policy()),
|
||||
"workspace-write" => Ok(SandboxPolicy::new_workspace_write_policy()),
|
||||
"external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Restricted,
|
||||
}),
|
||||
_ => serde_json::from_value(serde_json::Value::String(value.to_string())),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn git_info_from_parts(
|
||||
sha: Option<String>,
|
||||
branch: Option<String>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_rollout::RolloutRecorder;
|
||||
@@ -15,6 +15,7 @@ use codex_state::ThreadMetadata;
|
||||
use super::LocalThreadStore;
|
||||
use super::helpers::distinct_thread_metadata_title;
|
||||
use super::helpers::git_info_from_parts;
|
||||
use super::helpers::permission_profile_from_metadata_value;
|
||||
use super::helpers::rollout_path_is_archived;
|
||||
use super::helpers::set_thread_name_from_title;
|
||||
use super::helpers::stored_thread_from_rollout_item;
|
||||
@@ -57,6 +58,7 @@ pub(super) async fn read_thread(
|
||||
rollout_thread.name = thread.name;
|
||||
}
|
||||
rollout_thread.git_info = thread.git_info;
|
||||
rollout_thread.permission_profile = thread.permission_profile;
|
||||
thread = rollout_thread;
|
||||
}
|
||||
attach_history_if_requested(&mut thread, params.include_history).await?;
|
||||
@@ -286,6 +288,8 @@ async fn stored_thread_from_sqlite_metadata(
|
||||
.clone()
|
||||
.or_else(|| metadata.first_user_message.clone())
|
||||
.unwrap_or_default();
|
||||
let permission_profile =
|
||||
permission_profile_from_metadata_value(&metadata.sandbox_policy, metadata.cwd.as_path());
|
||||
StoredThread {
|
||||
thread_id: metadata.id,
|
||||
rollout_path: Some(metadata.rollout_path),
|
||||
@@ -315,10 +319,7 @@ async fn stored_thread_from_sqlite_metadata(
|
||||
metadata.git_origin_url,
|
||||
),
|
||||
approval_mode: parse_or_default(&metadata.approval_mode, AskForApproval::OnRequest),
|
||||
sandbox_policy: parse_or_default(
|
||||
&metadata.sandbox_policy,
|
||||
SandboxPolicy::new_read_only_policy(),
|
||||
),
|
||||
permission_profile,
|
||||
token_usage: None,
|
||||
first_user_message: metadata.first_user_message,
|
||||
history: None,
|
||||
@@ -377,7 +378,7 @@ fn stored_thread_from_meta_line(
|
||||
agent_path: meta_line.meta.agent_path,
|
||||
git_info: meta_line.git,
|
||||
approval_mode: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
token_usage: None,
|
||||
first_user_message: None,
|
||||
history: None,
|
||||
@@ -671,6 +672,84 @@ mod tests {
|
||||
assert_eq!(thread.name, Some("Saved title".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_thread_returns_permission_profile_from_sqlite_metadata() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let uuid = Uuid::from_u128(225);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
let rollout_path =
|
||||
write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file");
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
|
||||
let mut builder =
|
||||
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli);
|
||||
builder.model_provider = Some(config.default_model_provider_id.clone());
|
||||
builder.cwd = home.path().to_path_buf();
|
||||
let mut metadata = builder.build(config.default_model_provider_id.as_str());
|
||||
metadata.sandbox_policy =
|
||||
serde_json::to_string(&PermissionProfile::Disabled).expect("serialize profile");
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
.expect("state db upsert should succeed");
|
||||
|
||||
let thread = store
|
||||
.read_thread(ReadThreadParams {
|
||||
thread_id,
|
||||
include_archived: false,
|
||||
include_history: false,
|
||||
})
|
||||
.await
|
||||
.expect("read thread");
|
||||
|
||||
assert_eq!(thread.preview, "Hello from user");
|
||||
assert_eq!(thread.permission_profile, PermissionProfile::Disabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_thread_accepts_legacy_sandbox_policy_metadata() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let uuid = Uuid::from_u128(226);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
let rollout_path =
|
||||
write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file");
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
|
||||
let mut builder =
|
||||
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli);
|
||||
builder.model_provider = Some(config.default_model_provider_id.clone());
|
||||
builder.cwd = home.path().to_path_buf();
|
||||
let mut metadata = builder.build(config.default_model_provider_id.as_str());
|
||||
metadata.sandbox_policy = "danger-full-access".to_string();
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
.expect("state db upsert should succeed");
|
||||
|
||||
let thread = store
|
||||
.read_thread(ReadThreadParams {
|
||||
thread_id,
|
||||
include_archived: false,
|
||||
include_history: true,
|
||||
})
|
||||
.await
|
||||
.expect("read thread");
|
||||
|
||||
assert_eq!(thread.permission_profile, PermissionProfile::Disabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_thread_preserves_rollout_cwd_when_sqlite_metadata_exists() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
|
||||
@@ -18,6 +18,7 @@ use tracing::warn;
|
||||
|
||||
use super::LocalThreadStore;
|
||||
use super::helpers::git_info_from_parts;
|
||||
use super::helpers::permission_profile_to_metadata_value;
|
||||
use super::live_writer;
|
||||
use crate::GitInfoPatch;
|
||||
use crate::ReadThreadParams;
|
||||
@@ -275,8 +276,8 @@ async fn apply_metadata_update(
|
||||
if let Some(approval_mode) = patch.approval_mode {
|
||||
metadata.approval_mode = enum_to_string(&approval_mode);
|
||||
}
|
||||
if let Some(sandbox_policy) = patch.sandbox_policy {
|
||||
metadata.sandbox_policy = enum_to_string(&sandbox_policy);
|
||||
if let Some(permission_profile) = patch.permission_profile {
|
||||
metadata.sandbox_policy = permission_profile_to_metadata_value(&permission_profile);
|
||||
}
|
||||
if let Some(token_usage) = patch.token_usage {
|
||||
metadata.tokens_used = token_usage.total_tokens.max(0);
|
||||
@@ -390,7 +391,7 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool {
|
||||
|| patch.cwd.is_some()
|
||||
|| patch.cli_version.is_some()
|
||||
|| patch.approval_mode.is_some()
|
||||
|| patch.sandbox_policy.is_some()
|
||||
|| patch.permission_profile.is_some()
|
||||
|| patch.token_usage.is_some()
|
||||
|| patch.first_user_message.is_some()
|
||||
|| patch.dynamic_tools.is_some()
|
||||
@@ -613,6 +614,7 @@ fn rollout_path_is_archived(store: &LocalThreadStore, path: &Path) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
@@ -854,6 +856,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_thread_metadata_sets_permission_profile() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = LocalThreadStore::new(config, Some(runtime.clone()));
|
||||
let uuid = Uuid::from_u128(317);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
write_session_file(home.path(), "2025-01-03T20-30-00", uuid).expect("session file");
|
||||
|
||||
let thread = store
|
||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||
thread_id,
|
||||
patch: ThreadMetadataPatch {
|
||||
permission_profile: Some(PermissionProfile::Disabled),
|
||||
..Default::default()
|
||||
},
|
||||
include_archived: false,
|
||||
})
|
||||
.await
|
||||
.expect("set permission profile");
|
||||
|
||||
assert_eq!(thread.permission_profile, PermissionProfile::Disabled);
|
||||
let metadata = runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("sqlite metadata read")
|
||||
.expect("sqlite metadata");
|
||||
assert_eq!(
|
||||
metadata.sandbox_policy,
|
||||
serde_json::to_string(&PermissionProfile::Disabled).expect("serialize profile")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_thread_metadata_partially_updates_git_info() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
|
||||
@@ -240,7 +240,7 @@ impl ThreadMetadataSync {
|
||||
update.model = Some(turn_ctx.model.clone());
|
||||
update.reasoning_effort = turn_ctx.effort;
|
||||
update.approval_mode = Some(turn_ctx.approval_policy);
|
||||
update.sandbox_policy = Some(turn_ctx.sandbox_policy.clone());
|
||||
update.permission_profile = Some(turn_ctx.permission_profile());
|
||||
}
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(user)) => {
|
||||
if let Some(preview) = user_message_preview(user) {
|
||||
@@ -360,7 +360,7 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool {
|
||||
|| update.cwd.is_some()
|
||||
|| update.cli_version.is_some()
|
||||
|| update.approval_mode.is_some()
|
||||
|| update.sandbox_policy.is_some()
|
||||
|| update.permission_profile.is_some()
|
||||
|| update.token_usage.is_some()
|
||||
|| update.first_user_message.is_some()
|
||||
|| update.git_info.is_some()
|
||||
|
||||
@@ -5,11 +5,11 @@ use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GitInfo;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::ThreadMemoryMode as MemoryMode;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
@@ -362,8 +362,8 @@ pub struct StoredThread {
|
||||
pub git_info: Option<GitInfo>,
|
||||
/// Approval mode captured for the thread.
|
||||
pub approval_mode: AskForApproval,
|
||||
/// Sandbox policy captured for the thread.
|
||||
pub sandbox_policy: SandboxPolicy,
|
||||
/// Canonical runtime permissions captured for the thread.
|
||||
pub permission_profile: PermissionProfile,
|
||||
/// Last observed token usage.
|
||||
pub token_usage: Option<TokenUsage>,
|
||||
/// First user message observed for this thread, if any.
|
||||
@@ -485,8 +485,8 @@ pub struct ThreadMetadataPatch {
|
||||
pub cli_version: Option<String>,
|
||||
/// Approval mode.
|
||||
pub approval_mode: Option<AskForApproval>,
|
||||
/// Sandbox policy.
|
||||
pub sandbox_policy: Option<SandboxPolicy>,
|
||||
/// Canonical runtime permissions.
|
||||
pub permission_profile: Option<PermissionProfile>,
|
||||
/// Last observed token usage.
|
||||
pub token_usage: Option<TokenUsage>,
|
||||
/// First user message observed for this thread.
|
||||
@@ -557,8 +557,8 @@ impl ThreadMetadataPatch {
|
||||
if next.approval_mode.is_some() {
|
||||
self.approval_mode = next.approval_mode;
|
||||
}
|
||||
if next.sandbox_policy.is_some() {
|
||||
self.sandbox_policy = next.sandbox_policy;
|
||||
if next.permission_profile.is_some() {
|
||||
self.permission_profile = next.permission_profile;
|
||||
}
|
||||
if next.token_usage.is_some() {
|
||||
self.token_usage = next.token_usage;
|
||||
@@ -597,7 +597,7 @@ impl ThreadMetadataPatch {
|
||||
&& self.cwd.is_none()
|
||||
&& self.cli_version.is_none()
|
||||
&& self.approval_mode.is_none()
|
||||
&& self.sandbox_policy.is_none()
|
||||
&& self.permission_profile.is_none()
|
||||
&& self.token_usage.is_none()
|
||||
&& self.first_user_message.is_none()
|
||||
&& self.git_info.is_none()
|
||||
|
||||
@@ -121,6 +121,7 @@ pub struct ToolsConfig {
|
||||
pub hide_spawn_agent_metadata: bool,
|
||||
pub spawn_agent_usage_hint: bool,
|
||||
pub spawn_agent_usage_hint_text: Option<String>,
|
||||
pub multi_agent_v2_tool_namespace: Option<String>,
|
||||
pub max_concurrent_threads_per_session: Option<usize>,
|
||||
pub wait_agent_min_timeout_ms: Option<i64>,
|
||||
pub wait_agent_max_timeout_ms: Option<i64>,
|
||||
@@ -260,6 +261,7 @@ impl ToolsConfig {
|
||||
hide_spawn_agent_metadata: false,
|
||||
spawn_agent_usage_hint: true,
|
||||
spawn_agent_usage_hint_text: None,
|
||||
multi_agent_v2_tool_namespace: None,
|
||||
max_concurrent_threads_per_session: None,
|
||||
wait_agent_min_timeout_ms: None,
|
||||
wait_agent_max_timeout_ms: None,
|
||||
@@ -316,6 +318,14 @@ impl ToolsConfig {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_multi_agent_v2_tool_namespace(
|
||||
mut self,
|
||||
multi_agent_v2_tool_namespace: Option<String>,
|
||||
) -> Self {
|
||||
self.multi_agent_v2_tool_namespace = multi_agent_v2_tool_namespace;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_multi_agent_v2_non_code_mode_only(
|
||||
mut self,
|
||||
multi_agent_v2_non_code_mode_only: bool,
|
||||
|
||||
@@ -1183,11 +1183,15 @@ impl App {
|
||||
}
|
||||
AppEvent::PersistModelSelection { model, effort } => {
|
||||
let profile = self.active_profile.as_deref();
|
||||
match ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_profile(profile)
|
||||
.set_model(Some(model.as_str()), effort)
|
||||
.apply()
|
||||
.await
|
||||
match crate::config_update::write_config_batch(
|
||||
app_server.request_handle(),
|
||||
crate::config_update::build_model_selection_edits(
|
||||
profile,
|
||||
model.as_str(),
|
||||
effort,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let effort_label = effort
|
||||
@@ -1261,11 +1265,14 @@ impl App {
|
||||
}
|
||||
AppEvent::PersistPersonalitySelection { personality } => {
|
||||
let profile = self.active_profile.as_deref();
|
||||
match ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_profile(profile)
|
||||
.set_personality(Some(personality))
|
||||
.apply()
|
||||
.await
|
||||
match crate::config_update::write_config_batch(
|
||||
app_server.request_handle(),
|
||||
vec![crate::config_update::replace_config_value(
|
||||
crate::config_update::profile_scoped_key_path(profile, "personality"),
|
||||
serde_json::json!(personality.to_string()),
|
||||
)],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let label = Self::personality_label(personality);
|
||||
@@ -1298,14 +1305,16 @@ impl App {
|
||||
self.refresh_status_line();
|
||||
let profile = self.active_profile.as_deref();
|
||||
self.config.service_tier = service_tier.clone();
|
||||
let mut edits = ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_profile(profile)
|
||||
.set_service_tier(service_tier.clone());
|
||||
let edits = crate::config_update::build_service_tier_selection_edits(
|
||||
profile,
|
||||
service_tier.as_deref(),
|
||||
);
|
||||
if service_tier.is_none() {
|
||||
self.config.notices.fast_default_opt_out = Some(true);
|
||||
edits = edits.set_fast_default_opt_out(/*opted_out*/ true);
|
||||
}
|
||||
match edits.apply().await {
|
||||
match crate::config_update::write_config_batch(app_server.request_handle(), edits)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let mut message = if let Some(service_tier) = service_tier {
|
||||
format!("Service tier set to {service_tier}")
|
||||
@@ -1476,23 +1485,17 @@ impl App {
|
||||
self.sync_active_thread_permission_settings_to_cached_session()
|
||||
.await;
|
||||
let profile = self.active_profile.as_deref();
|
||||
let segments = if let Some(profile) = profile {
|
||||
vec![
|
||||
"profiles".to_string(),
|
||||
profile.to_string(),
|
||||
"approvals_reviewer".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec!["approvals_reviewer".to_string()]
|
||||
};
|
||||
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_profile(profile)
|
||||
.with_edits([ConfigEdit::SetPath {
|
||||
segments,
|
||||
value: policy.to_string().into(),
|
||||
}])
|
||||
.apply()
|
||||
.await
|
||||
if let Err(err) = crate::config_update::write_config_batch(
|
||||
app_server.request_handle(),
|
||||
vec![crate::config_update::replace_config_value(
|
||||
crate::config_update::profile_scoped_key_path(
|
||||
profile,
|
||||
"approvals_reviewer",
|
||||
),
|
||||
serde_json::json!(policy.to_string()),
|
||||
)],
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
@@ -1583,27 +1586,23 @@ impl App {
|
||||
}
|
||||
AppEvent::PersistPlanModeReasoningEffort(effort) => {
|
||||
let profile = self.active_profile.as_deref();
|
||||
let segments = if let Some(profile) = profile {
|
||||
vec![
|
||||
"profiles".to_string(),
|
||||
profile.to_string(),
|
||||
"plan_mode_reasoning_effort".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec!["plan_mode_reasoning_effort".to_string()]
|
||||
};
|
||||
let key_path = crate::config_update::profile_scoped_key_path(
|
||||
profile,
|
||||
"plan_mode_reasoning_effort",
|
||||
);
|
||||
let edit = if let Some(effort) = effort {
|
||||
ConfigEdit::SetPath {
|
||||
segments,
|
||||
value: effort.to_string().into(),
|
||||
}
|
||||
crate::config_update::replace_config_value(
|
||||
key_path,
|
||||
serde_json::json!(effort.to_string()),
|
||||
)
|
||||
} else {
|
||||
ConfigEdit::ClearPath { segments }
|
||||
crate::config_update::clear_config_value(key_path)
|
||||
};
|
||||
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_edits([edit])
|
||||
.apply()
|
||||
.await
|
||||
if let Err(err) = crate::config_update::write_config_batch(
|
||||
app_server.request_handle(),
|
||||
vec![edit],
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! App-server-backed config update helpers for the TUI.
|
||||
//!
|
||||
//! This module centralizes the small typed update helpers the TUI uses
|
||||
//! when a config mutation must be owned by the app server rather than written
|
||||
//! to the local `config.toml` directly.
|
||||
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
use codex_app_server_protocol::ConfigEdit;
|
||||
use codex_app_server_protocol::ConfigWriteResponse;
|
||||
use codex_app_server_protocol::MergeStrategy;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
use serde_json::Value as JsonValue;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) fn replace_config_value(key_path: impl Into<String>, value: JsonValue) -> ConfigEdit {
|
||||
ConfigEdit {
|
||||
key_path: key_path.into(),
|
||||
value,
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear_config_value(key_path: impl Into<String>) -> ConfigEdit {
|
||||
replace_config_value(key_path, JsonValue::Null)
|
||||
}
|
||||
|
||||
pub(crate) fn profile_scoped_key_path(profile: Option<&str>, key_path: &str) -> String {
|
||||
if let Some(profile) = profile {
|
||||
let profile = serde_json::Value::String(profile.to_string()).to_string();
|
||||
format!("profiles.{profile}.{key_path}")
|
||||
} else {
|
||||
key_path.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_model_selection_edits(
|
||||
profile: Option<&str>,
|
||||
model: &str,
|
||||
effort: Option<impl ToString>,
|
||||
) -> Vec<ConfigEdit> {
|
||||
let effort_edit = effort.map_or_else(
|
||||
|| clear_config_value(profile_scoped_key_path(profile, "model_reasoning_effort")),
|
||||
|effort| {
|
||||
replace_config_value(
|
||||
profile_scoped_key_path(profile, "model_reasoning_effort"),
|
||||
serde_json::json!(effort.to_string()),
|
||||
)
|
||||
},
|
||||
);
|
||||
vec![
|
||||
replace_config_value(
|
||||
profile_scoped_key_path(profile, "model"),
|
||||
serde_json::json!(model),
|
||||
),
|
||||
effort_edit,
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn build_service_tier_selection_edits(
|
||||
profile: Option<&str>,
|
||||
service_tier: Option<&str>,
|
||||
) -> Vec<ConfigEdit> {
|
||||
let service_tier_edit = service_tier.map_or_else(
|
||||
|| clear_config_value(profile_scoped_key_path(profile, "service_tier")),
|
||||
|service_tier| {
|
||||
let config_value =
|
||||
match codex_protocol::config_types::ServiceTier::from_request_value(service_tier) {
|
||||
Some(codex_protocol::config_types::ServiceTier::Fast) => "fast",
|
||||
Some(codex_protocol::config_types::ServiceTier::Flex) => "flex",
|
||||
None => service_tier,
|
||||
};
|
||||
replace_config_value(
|
||||
profile_scoped_key_path(profile, "service_tier"),
|
||||
serde_json::json!(config_value),
|
||||
)
|
||||
},
|
||||
);
|
||||
let mut edits = vec![service_tier_edit];
|
||||
if service_tier.is_none() {
|
||||
edits.push(replace_config_value(
|
||||
"notice.fast_default_opt_out",
|
||||
serde_json::json!(true),
|
||||
));
|
||||
}
|
||||
edits
|
||||
}
|
||||
|
||||
pub(crate) async fn write_config_batch(
|
||||
request_handle: AppServerRequestHandle,
|
||||
edits: Vec<ConfigEdit>,
|
||||
) -> Result<()> {
|
||||
let request_id = RequestId::String(format!("tui-config-write-{}", Uuid::new_v4()));
|
||||
let _: ConfigWriteResponse = request_handle
|
||||
.request_typed(ClientRequest::ConfigBatchWrite {
|
||||
request_id,
|
||||
params: ConfigBatchWriteParams {
|
||||
edits,
|
||||
file_path: None,
|
||||
expected_version: None,
|
||||
reload_user_config: true,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("config/batchWrite failed in TUI")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn profile_scoped_key_path_quotes_dotted_profile_names() {
|
||||
assert_eq!(
|
||||
profile_scoped_key_path(Some("team.prod"), "model"),
|
||||
"profiles.\"team.prod\".model"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,7 @@ mod clipboard_copy;
|
||||
mod clipboard_paste;
|
||||
mod collaboration_modes;
|
||||
mod color;
|
||||
mod config_update;
|
||||
pub(crate) mod custom_terminal;
|
||||
mod pets;
|
||||
pub use custom_terminal::Terminal;
|
||||
|
||||
+33
-2
@@ -26,14 +26,45 @@ when you intentionally want to run against a specific local app-server binary.
|
||||
from openai_codex import Codex
|
||||
|
||||
with Codex() as codex:
|
||||
# Call login_api_key(...) first when this app-server session is not
|
||||
# already authenticated.
|
||||
thread = codex.thread_start(model="gpt-5")
|
||||
result = thread.run("Say hello in one sentence.")
|
||||
print(result.final_response)
|
||||
print(len(result.items))
|
||||
```
|
||||
|
||||
`result.final_response` is `None` when the turn completes without a final-answer
|
||||
or phase-less assistant message item.
|
||||
`thread.run(...)` and `thread.turn(...).run()` return `TurnResult`. Its
|
||||
`final_response` is `None` when the turn completes without a final-answer or
|
||||
phase-less assistant message item.
|
||||
|
||||
## Login
|
||||
|
||||
Use the auth helper that matches your app:
|
||||
|
||||
```python
|
||||
from openai_codex import Codex
|
||||
|
||||
with Codex() as codex:
|
||||
codex.login_api_key("sk-...")
|
||||
account = codex.account()
|
||||
print(account.account)
|
||||
```
|
||||
|
||||
Interactive ChatGPT login returns a handle. Open the provided URL or device-code
|
||||
page, then wait for the matching completion event:
|
||||
|
||||
```python
|
||||
with Codex() as codex:
|
||||
login = codex.login_chatgpt()
|
||||
print(login.auth_url)
|
||||
completed = login.wait()
|
||||
print(completed.success)
|
||||
```
|
||||
|
||||
Use `login_chatgpt_device_code()` for device-code auth, `handle.cancel()` to
|
||||
stop an in-progress interactive login, and `logout()` to clear the active
|
||||
app-server account session.
|
||||
|
||||
## Docs map
|
||||
|
||||
|
||||
@@ -12,11 +12,15 @@ from openai_codex import (
|
||||
Codex,
|
||||
AsyncCodex,
|
||||
ApprovalMode,
|
||||
RunResult,
|
||||
ChatgptLoginHandle,
|
||||
DeviceCodeLoginHandle,
|
||||
AsyncChatgptLoginHandle,
|
||||
AsyncDeviceCodeLoginHandle,
|
||||
Thread,
|
||||
AsyncThread,
|
||||
TurnHandle,
|
||||
AsyncTurnHandle,
|
||||
TurnResult,
|
||||
Input,
|
||||
InputItem,
|
||||
TextInput,
|
||||
@@ -26,9 +30,15 @@ from openai_codex import (
|
||||
MentionInput,
|
||||
)
|
||||
from openai_codex.types import (
|
||||
Account,
|
||||
AccountLoginCompletedNotification,
|
||||
CancelLoginAccountResponse,
|
||||
CancelLoginAccountStatus,
|
||||
GetAccountResponse,
|
||||
InitializeResponse,
|
||||
ThreadItem,
|
||||
ThreadTokenUsage,
|
||||
TurnError,
|
||||
TurnStatus,
|
||||
)
|
||||
```
|
||||
@@ -47,6 +57,11 @@ Properties/methods:
|
||||
|
||||
- `metadata -> InitializeResponse`
|
||||
- `close() -> None`
|
||||
- `login_api_key(api_key: str) -> None`
|
||||
- `login_chatgpt() -> ChatgptLoginHandle`
|
||||
- `login_chatgpt_device_code() -> DeviceCodeLoginHandle`
|
||||
- `account(*, refresh_token: bool = False) -> GetAccountResponse`
|
||||
- `logout() -> None`
|
||||
- `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread`
|
||||
- `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> ThreadListResponse`
|
||||
- `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread`
|
||||
@@ -82,6 +97,11 @@ Properties/methods:
|
||||
|
||||
- `metadata -> InitializeResponse`
|
||||
- `close() -> Awaitable[None]`
|
||||
- `login_api_key(api_key: str) -> Awaitable[None]`
|
||||
- `login_chatgpt() -> Awaitable[AsyncChatgptLoginHandle]`
|
||||
- `login_chatgpt_device_code() -> Awaitable[AsyncDeviceCodeLoginHandle]`
|
||||
- `account(*, refresh_token: bool = False) -> Awaitable[GetAccountResponse]`
|
||||
- `logout() -> Awaitable[None]`
|
||||
- `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]`
|
||||
- `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> Awaitable[ThreadListResponse]`
|
||||
- `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]`
|
||||
@@ -97,13 +117,37 @@ async with AsyncCodex() as codex:
|
||||
...
|
||||
```
|
||||
|
||||
## Login handles
|
||||
|
||||
### ChatgptLoginHandle / AsyncChatgptLoginHandle
|
||||
|
||||
- `login_id: str`
|
||||
- `auth_url: str`
|
||||
- `wait() -> AccountLoginCompletedNotification`
|
||||
- `cancel() -> CancelLoginAccountResponse`
|
||||
|
||||
Async handle methods return awaitables.
|
||||
|
||||
### DeviceCodeLoginHandle / AsyncDeviceCodeLoginHandle
|
||||
|
||||
- `login_id: str`
|
||||
- `verification_url: str`
|
||||
- `user_code: str`
|
||||
- `wait() -> AccountLoginCompletedNotification`
|
||||
- `cancel() -> CancelLoginAccountResponse`
|
||||
|
||||
Async handle methods return awaitables.
|
||||
|
||||
`wait()` consumes only the completion notification for its matching login
|
||||
attempt. API-key login completes synchronously and does not return a handle.
|
||||
|
||||
## Thread / AsyncThread
|
||||
|
||||
`Thread` and `AsyncThread` share the same shape and intent.
|
||||
|
||||
### Thread
|
||||
|
||||
- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> RunResult`
|
||||
- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnResult`
|
||||
- `turn(input: Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> TurnHandle`
|
||||
- `read(*, include_turns: bool = False) -> ThreadReadResponse`
|
||||
- `set_name(name: str) -> ThreadSetNameResponse`
|
||||
@@ -111,7 +155,7 @@ async with AsyncCodex() as codex:
|
||||
|
||||
### AsyncThread
|
||||
|
||||
- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[RunResult]`
|
||||
- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[TurnResult]`
|
||||
- `turn(input: Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> Awaitable[AsyncTurnHandle]`
|
||||
- `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]`
|
||||
- `set_name(name: str) -> Awaitable[ThreadSetNameResponse]`
|
||||
@@ -121,6 +165,12 @@ async with AsyncCodex() as codex:
|
||||
the turn, consumes notifications until completion, and returns a small result
|
||||
object with:
|
||||
|
||||
- `id: str`
|
||||
- `status: TurnStatus`
|
||||
- `error: TurnError | None`
|
||||
- `started_at: int | None`
|
||||
- `completed_at: int | None`
|
||||
- `duration_ms: int | None`
|
||||
- `final_response: str | None`
|
||||
- `items: list[ThreadItem]`
|
||||
- `usage: ThreadTokenUsage | None`
|
||||
@@ -129,7 +179,7 @@ object with:
|
||||
phase-less assistant message item.
|
||||
|
||||
Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`,
|
||||
`interrupt()`) or the public `Turn` model from `TurnHandle.run()`.
|
||||
`interrupt()`) before collecting the turn result.
|
||||
|
||||
## TurnHandle / AsyncTurnHandle
|
||||
|
||||
@@ -138,7 +188,7 @@ Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`,
|
||||
- `steer(input: Input) -> TurnSteerResponse`
|
||||
- `interrupt() -> TurnInterruptResponse`
|
||||
- `stream() -> Iterator[Notification]`
|
||||
- `run() -> openai_codex.types.Turn`
|
||||
- `run() -> TurnResult`
|
||||
|
||||
Behavior notes:
|
||||
|
||||
@@ -150,7 +200,7 @@ Behavior notes:
|
||||
- `steer(input: Input) -> Awaitable[TurnSteerResponse]`
|
||||
- `interrupt() -> Awaitable[TurnInterruptResponse]`
|
||||
- `stream() -> AsyncIterator[Notification]`
|
||||
- `run() -> Awaitable[openai_codex.types.Turn]`
|
||||
- `run() -> Awaitable[TurnResult]`
|
||||
|
||||
Behavior notes:
|
||||
|
||||
@@ -176,6 +226,11 @@ The SDK wrappers return and accept public app-server models wherever possible:
|
||||
|
||||
```python
|
||||
from openai_codex.types import (
|
||||
Account,
|
||||
AccountLoginCompletedNotification,
|
||||
CancelLoginAccountResponse,
|
||||
CancelLoginAccountStatus,
|
||||
GetAccountResponse,
|
||||
ThreadReadResponse,
|
||||
Turn,
|
||||
TurnStatus,
|
||||
|
||||
+15
-5
@@ -8,7 +8,8 @@
|
||||
|
||||
## `run()` vs `stream()`
|
||||
|
||||
- `TurnHandle.run()` / `AsyncTurnHandle.run()` is the easiest path. It consumes events until completion and returns the public app-server `Turn` model from `openai_codex.types`.
|
||||
- `Thread.run(...)` starts a turn and returns `TurnResult`.
|
||||
- `TurnHandle.run()` / `AsyncTurnHandle.run()` consumes events for an existing turn handle and returns the same `TurnResult` shape.
|
||||
- `TurnHandle.stream()` / `AsyncTurnHandle.stream()` yields raw notifications (`Notification`) so you can react event-by-event.
|
||||
|
||||
Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout logic, or custom parsing.
|
||||
@@ -23,6 +24,16 @@ Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout
|
||||
|
||||
If your app is not already async, stay with `Codex`.
|
||||
|
||||
## How do I log in?
|
||||
|
||||
- `login_api_key(...)` authenticates immediately with an API key.
|
||||
- `login_chatgpt()` starts browser login and returns a handle with `auth_url`.
|
||||
- `login_chatgpt_device_code()` starts device-code login and returns a handle
|
||||
with `verification_url` and `user_code`.
|
||||
- Interactive handles expose `wait()` for the matching
|
||||
`account/login/completed` notification and `cancel()` to stop that attempt.
|
||||
- `account()` reads the current account state, and `logout()` clears it.
|
||||
|
||||
## Public kwargs are snake_case
|
||||
|
||||
Public API keyword names are snake_case. The SDK still maps them to wire camelCase under the hood.
|
||||
@@ -56,8 +67,7 @@ Common causes:
|
||||
|
||||
- published runtime package (`openai-codex-cli-bin`) is not installed
|
||||
- local `codex_bin` override points to a missing file
|
||||
- local auth/session is missing
|
||||
- incompatible/old app-server
|
||||
- app-server version older than the SDK schema
|
||||
|
||||
## Why does a turn "hang"?
|
||||
|
||||
@@ -70,11 +80,11 @@ A turn is complete only when `turn/completed` arrives for that turn ID.
|
||||
|
||||
Use `retry_on_overload(...)` for transient overload failures (`ServerBusyError`).
|
||||
|
||||
Do not blindly retry all errors. For `InvalidParamsError` or `MethodNotFoundError`, fix inputs/version compatibility instead.
|
||||
Do not blindly retry all errors. For `InvalidParamsError` or `MethodNotFoundError`, fix inputs or update the runtime/schema version instead.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- Starting a new thread for every prompt when you wanted continuity.
|
||||
- Forgetting to `close()` (or not using context managers).
|
||||
- Assuming `run()` returns extra SDK-only fields instead of the public `Turn` model.
|
||||
- Reading `Turn.items` from live start/completed payloads instead of using `TurnResult.items`.
|
||||
- Mixing SDK input classes with raw dicts incorrectly.
|
||||
|
||||
@@ -19,9 +19,37 @@ Requirements:
|
||||
- Python `>=3.10`
|
||||
- uv
|
||||
- installed `openai-codex-cli-bin` runtime package, or an explicit `codex_bin` override
|
||||
- local Codex auth/session configured
|
||||
|
||||
## 2) Run your first turn (sync)
|
||||
## 2) Authenticate when needed
|
||||
|
||||
Existing Codex auth state is reused automatically. To authenticate from the SDK,
|
||||
use the flow that fits your app:
|
||||
|
||||
```python
|
||||
from openai_codex import Codex
|
||||
|
||||
with Codex() as codex:
|
||||
codex.login_api_key("sk-...")
|
||||
account = codex.account()
|
||||
print(account.account)
|
||||
```
|
||||
|
||||
Interactive ChatGPT browser login returns a handle that carries the URL and the
|
||||
matching completion event:
|
||||
|
||||
```python
|
||||
with Codex() as codex:
|
||||
login = codex.login_chatgpt()
|
||||
print(login.auth_url)
|
||||
completed = login.wait()
|
||||
print(completed.success)
|
||||
```
|
||||
|
||||
Device-code login works the same way with
|
||||
`login_chatgpt_device_code()`, which exposes `verification_url`, `user_code`,
|
||||
and `wait()`.
|
||||
|
||||
## 3) Run your first turn (sync)
|
||||
|
||||
```python
|
||||
from openai_codex import Codex
|
||||
@@ -42,12 +70,12 @@ What happened:
|
||||
|
||||
- `Codex()` started and initialized `codex app-server`.
|
||||
- `thread_start(...)` created a thread.
|
||||
- `thread.run("...")` started a turn, consumed events until completion, and returned the final assistant response plus collected items and usage.
|
||||
- `thread.run("...")` started a turn, consumed events until completion, and returned `TurnResult` with turn metadata, final assistant response, collected items, and usage.
|
||||
- `result.final_response` is `None` when no final-answer or phase-less assistant message item completes for the turn.
|
||||
- use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, interrupting, or turn IDs/status
|
||||
- use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, or interrupting before collecting `TurnResult`
|
||||
- one client can consume multiple active turns concurrently; turn streams are routed by turn ID
|
||||
|
||||
## 3) Continue the same thread (multi-turn)
|
||||
## 4) Continue the same thread (multi-turn)
|
||||
|
||||
```python
|
||||
from openai_codex import Codex
|
||||
@@ -62,7 +90,7 @@ with Codex() as codex:
|
||||
print("second:", second.final_response)
|
||||
```
|
||||
|
||||
## 4) Async parity
|
||||
## 5) Async parity
|
||||
|
||||
Use `async with AsyncCodex()` as the normal async entrypoint. `AsyncCodex`
|
||||
initializes lazily, and context entry makes startup/shutdown explicit.
|
||||
@@ -82,7 +110,7 @@ async def main() -> None:
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## 5) Resume an existing thread
|
||||
## 6) Resume an existing thread
|
||||
|
||||
```python
|
||||
from openai_codex import Codex
|
||||
@@ -95,7 +123,7 @@ with Codex() as codex:
|
||||
print(result.final_response)
|
||||
```
|
||||
|
||||
## 6) Public app-server types
|
||||
## 7) Public app-server types
|
||||
|
||||
The convenience wrappers live at the package root. Public app-server value and
|
||||
event types live under:
|
||||
@@ -104,7 +132,7 @@ event types live under:
|
||||
from openai_codex.types import ThreadReadResponse, Turn, TurnStatus
|
||||
```
|
||||
|
||||
## 7) Next stops
|
||||
## 8) Next stops
|
||||
|
||||
- API surface and signatures: `docs/api-reference.md`
|
||||
- Common decisions/pitfalls: `docs/faq.md`
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -26,19 +21,14 @@ async def main() -> None:
|
||||
)
|
||||
turn = await thread.turn(TextInput("Give 3 bullets about SIMD."))
|
||||
result = await turn.run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
|
||||
print("thread_id:", thread.id)
|
||||
print("turn_id:", result.id)
|
||||
print("status:", result.status)
|
||||
if result.error is not None:
|
||||
print("error:", result.error)
|
||||
print("text:", assistant_text_from_turn(persisted_turn))
|
||||
print(
|
||||
"persisted.items.count:",
|
||||
0 if persisted_turn is None else len(persisted_turn.items or []),
|
||||
)
|
||||
print("text:", result.final_response)
|
||||
print("items.count:", len(result.items))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -19,16 +14,11 @@ from openai_codex import Codex, TextInput
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
result = thread.turn(TextInput("Give 3 bullets about SIMD.")).run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
|
||||
print("thread_id:", thread.id)
|
||||
print("turn_id:", result.id)
|
||||
print("status:", result.status)
|
||||
if result.error is not None:
|
||||
print("error:", result.error)
|
||||
print("text:", assistant_text_from_turn(persisted_turn))
|
||||
print(
|
||||
"persisted.items.count:",
|
||||
0 if persisted_turn is None else len(persisted_turn.items or []),
|
||||
)
|
||||
print("text:", result.final_response)
|
||||
print("items.count:", len(result.items))
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -29,7 +24,8 @@ async def main() -> None:
|
||||
event_count = 0
|
||||
saw_started = False
|
||||
saw_delta = False
|
||||
completed_status = "unknown"
|
||||
completed_status = None
|
||||
completed_texts = []
|
||||
|
||||
async for event in turn.stream():
|
||||
event_count += 1
|
||||
@@ -38,24 +34,27 @@ async def main() -> None:
|
||||
print("stream.started")
|
||||
continue
|
||||
if event.method == "item/agentMessage/delta":
|
||||
delta = getattr(event.payload, "delta", "")
|
||||
delta = event.payload.delta
|
||||
if delta:
|
||||
if not saw_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
print(delta, end="", flush=True)
|
||||
saw_delta = True
|
||||
continue
|
||||
if event.method == "item/completed":
|
||||
root = event.payload.item.root
|
||||
if root.type == "agentMessage":
|
||||
completed_texts.append(root.text)
|
||||
continue
|
||||
if event.method == "turn/completed":
|
||||
completed_status = getattr(
|
||||
event.payload.turn.status, "value", str(event.payload.turn.status)
|
||||
)
|
||||
completed_status = event.payload.turn.status.value
|
||||
|
||||
if completed_status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
if saw_delta:
|
||||
print()
|
||||
else:
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, turn.id)
|
||||
final_text = assistant_text_from_turn(persisted_turn).strip() or "[no assistant text]"
|
||||
final_text = "".join(completed_texts).strip()
|
||||
print("assistant>", final_text)
|
||||
|
||||
print("stream.started.seen:", saw_started)
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -23,7 +18,8 @@ with Codex(config=runtime_config()) as codex:
|
||||
event_count = 0
|
||||
saw_started = False
|
||||
saw_delta = False
|
||||
completed_status = "unknown"
|
||||
completed_status = None
|
||||
completed_texts = []
|
||||
|
||||
for event in turn.stream():
|
||||
event_count += 1
|
||||
@@ -32,24 +28,27 @@ with Codex(config=runtime_config()) as codex:
|
||||
print("stream.started")
|
||||
continue
|
||||
if event.method == "item/agentMessage/delta":
|
||||
delta = getattr(event.payload, "delta", "")
|
||||
delta = event.payload.delta
|
||||
if delta:
|
||||
if not saw_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
print(delta, end="", flush=True)
|
||||
saw_delta = True
|
||||
continue
|
||||
if event.method == "item/completed":
|
||||
root = event.payload.item.root
|
||||
if root.type == "agentMessage":
|
||||
completed_texts.append(root.text)
|
||||
continue
|
||||
if event.method == "turn/completed":
|
||||
completed_status = getattr(
|
||||
event.payload.turn.status, "value", str(event.payload.turn.status)
|
||||
)
|
||||
completed_status = event.payload.turn.status.value
|
||||
|
||||
if completed_status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
if saw_delta:
|
||||
print()
|
||||
else:
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, turn.id)
|
||||
final_text = assistant_text_from_turn(persisted_turn).strip() or "[no assistant text]"
|
||||
final_text = "".join(completed_texts).strip()
|
||||
print("assistant>", final_text)
|
||||
|
||||
print("stream.started.seen:", saw_started)
|
||||
|
||||
@@ -19,7 +19,7 @@ async def main() -> None:
|
||||
print("server:", server_label(codex.metadata))
|
||||
models = await codex.models()
|
||||
print("models.count:", len(models.data))
|
||||
print("models:", ", ".join(model.id for model in models.data[:5]) or "[none]")
|
||||
print("models:", ", ".join(model.id for model in models.data[:5]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -15,4 +15,4 @@ with Codex(config=runtime_config()) as codex:
|
||||
print("server:", server_label(codex.metadata))
|
||||
models = codex.models()
|
||||
print("models.count:", len(models.data))
|
||||
print("models:", ", ".join(model.id for model in models.data[:5]) or "[none]")
|
||||
print("models:", ", ".join(model.id for model in models.data[:5]))
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -32,9 +27,7 @@ async def main() -> None:
|
||||
resumed = await codex.thread_resume(original.id)
|
||||
second_turn = await resumed.turn(TextInput("Continue with one more fact."))
|
||||
second = await second_turn.run()
|
||||
persisted = await resumed.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, second.id)
|
||||
print(assistant_text_from_turn(persisted_turn))
|
||||
print(second.final_response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -25,6 +20,4 @@ with Codex(config=runtime_config()) as codex:
|
||||
# Resume the existing thread by ID.
|
||||
resumed = codex.thread_resume(original.id)
|
||||
second = resumed.turn(TextInput("Continue with one more fact.")).run()
|
||||
persisted = resumed.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, second.id)
|
||||
print(assistant_text_from_turn(persisted_turn))
|
||||
print(second.final_response)
|
||||
|
||||
@@ -33,45 +33,31 @@ async def main() -> None:
|
||||
listing_archived = await codex.thread_list(limit=20, archived=True)
|
||||
unarchived = await codex.thread_unarchive(reopened.id)
|
||||
|
||||
resumed_info = "n/a"
|
||||
try:
|
||||
resumed = await codex.thread_resume(
|
||||
unarchived.id,
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
resumed_result = await (
|
||||
await resumed.turn(TextInput("Continue in one short sentence."))
|
||||
).run()
|
||||
resumed_info = f"{resumed_result.id} {resumed_result.status}"
|
||||
except Exception as exc:
|
||||
resumed_info = f"skipped({type(exc).__name__})"
|
||||
resumed = await codex.thread_resume(
|
||||
unarchived.id,
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
resumed_result = await (
|
||||
await resumed.turn(TextInput("Continue in one short sentence."))
|
||||
).run()
|
||||
|
||||
forked_info = "n/a"
|
||||
try:
|
||||
forked = await codex.thread_fork(unarchived.id, model="gpt-5.4")
|
||||
forked_result = await (
|
||||
await forked.turn(TextInput("Take a different angle in one short sentence."))
|
||||
).run()
|
||||
forked_info = f"{forked_result.id} {forked_result.status}"
|
||||
except Exception as exc:
|
||||
forked_info = f"skipped({type(exc).__name__})"
|
||||
forked = await codex.thread_fork(unarchived.id, model="gpt-5.4")
|
||||
forked_result = await (
|
||||
await forked.turn(TextInput("Take a different angle in one short sentence."))
|
||||
).run()
|
||||
|
||||
compact_info = "sent"
|
||||
try:
|
||||
_ = await unarchived.compact()
|
||||
except Exception as exc:
|
||||
compact_info = f"skipped({type(exc).__name__})"
|
||||
compact_result = await unarchived.compact()
|
||||
|
||||
print("Lifecycle OK:", thread.id)
|
||||
print("first:", first.id, first.status)
|
||||
print("second:", second.id, second.status)
|
||||
print("read.turns:", len(reading.thread.turns or []))
|
||||
print("read.turns:", len(reading.thread.turns))
|
||||
print("list.active:", len(listing_active.data))
|
||||
print("list.archived:", len(listing_archived.data))
|
||||
print("resumed:", resumed_info)
|
||||
print("forked:", forked_info)
|
||||
print("compact:", compact_info)
|
||||
print("resumed:", resumed_result.id, resumed_result.status)
|
||||
print("forked:", forked_result.id, forked_result.status)
|
||||
print("compact:", compact_result.model_dump(mode="json", by_alias=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -25,40 +25,24 @@ with Codex(config=runtime_config()) as codex:
|
||||
listing_archived = codex.thread_list(limit=20, archived=True)
|
||||
unarchived = codex.thread_unarchive(reopened.id)
|
||||
|
||||
resumed_info = "n/a"
|
||||
try:
|
||||
resumed = codex.thread_resume(
|
||||
unarchived.id,
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
resumed_result = resumed.turn(TextInput("Continue in one short sentence.")).run()
|
||||
resumed_info = f"{resumed_result.id} {resumed_result.status}"
|
||||
except Exception as exc:
|
||||
resumed_info = f"skipped({type(exc).__name__})"
|
||||
resumed = codex.thread_resume(
|
||||
unarchived.id,
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
resumed_result = resumed.turn(TextInput("Continue in one short sentence.")).run()
|
||||
|
||||
forked_info = "n/a"
|
||||
try:
|
||||
forked = codex.thread_fork(unarchived.id, model="gpt-5.4")
|
||||
forked_result = forked.turn(
|
||||
TextInput("Take a different angle in one short sentence.")
|
||||
).run()
|
||||
forked_info = f"{forked_result.id} {forked_result.status}"
|
||||
except Exception as exc:
|
||||
forked_info = f"skipped({type(exc).__name__})"
|
||||
forked = codex.thread_fork(unarchived.id, model="gpt-5.4")
|
||||
forked_result = forked.turn(TextInput("Take a different angle in one short sentence.")).run()
|
||||
|
||||
compact_info = "sent"
|
||||
try:
|
||||
_ = unarchived.compact()
|
||||
except Exception as exc:
|
||||
compact_info = f"skipped({type(exc).__name__})"
|
||||
compact_result = unarchived.compact()
|
||||
|
||||
print("Lifecycle OK:", thread.id)
|
||||
print("first:", first.id, first.status)
|
||||
print("second:", second.id, second.status)
|
||||
print("read.turns:", len(reading.thread.turns or []))
|
||||
print("read.turns:", len(reading.thread.turns))
|
||||
print("list.active:", len(listing_active.data))
|
||||
print("list.archived:", len(listing_archived.data))
|
||||
print("resumed:", resumed_info)
|
||||
print("forked:", forked_info)
|
||||
print("compact:", compact_info)
|
||||
print("resumed:", resumed_result.id, resumed_result.status)
|
||||
print("forked:", forked_result.id, forked_result.status)
|
||||
print("compact:", compact_result.model_dump(mode="json", by_alias=True))
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -33,11 +28,9 @@ async def main() -> None:
|
||||
]
|
||||
)
|
||||
result = await turn.run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
|
||||
print("Status:", result.status)
|
||||
print(assistant_text_from_turn(persisted_turn))
|
||||
print(result.final_response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -26,8 +21,6 @@ with Codex(config=runtime_config()) as codex:
|
||||
ImageInput(REMOTE_IMAGE_URL),
|
||||
]
|
||||
).run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
|
||||
print("Status:", result.status)
|
||||
print(assistant_text_from_turn(persisted_turn))
|
||||
print(result.final_response)
|
||||
|
||||
@@ -6,9 +6,7 @@ if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
temporary_sample_image_path,
|
||||
)
|
||||
@@ -36,11 +34,9 @@ async def main() -> None:
|
||||
]
|
||||
)
|
||||
result = await turn.run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
|
||||
print("Status:", result.status)
|
||||
print(assistant_text_from_turn(persisted_turn))
|
||||
print(result.final_response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,9 +6,7 @@ if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
temporary_sample_image_path,
|
||||
)
|
||||
@@ -29,8 +27,6 @@ with temporary_sample_image_path() as image_path:
|
||||
LocalImageInput(str(image_path.resolve())),
|
||||
]
|
||||
).run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
|
||||
print("Status:", result.status)
|
||||
print(assistant_text_from_turn(persisted_turn))
|
||||
print(result.final_response)
|
||||
|
||||
@@ -5,13 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
server_label,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config, server_label
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -23,9 +17,7 @@ with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
turn = thread.turn(TextInput("Say hello in one sentence."))
|
||||
result = turn.run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
|
||||
print("Thread:", thread.id)
|
||||
print("Turn:", result.id)
|
||||
print("Text:", assistant_text_from_turn(persisted_turn).strip())
|
||||
print("Text:", result.final_response.strip())
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -73,19 +68,16 @@ async def main() -> None:
|
||||
)
|
||||
except ServerBusyError as exc:
|
||||
print("Server overloaded after retries:", exc.message)
|
||||
print("Text:")
|
||||
return
|
||||
except JsonRpcError as exc:
|
||||
print(f"JSON-RPC error {exc.code}: {exc.message}")
|
||||
print("Text:")
|
||||
return
|
||||
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
if result.status == TurnStatus.failed:
|
||||
print("Turn failed:", result.error)
|
||||
return
|
||||
|
||||
print("Text:", assistant_text_from_turn(persisted_turn))
|
||||
print("Text:", result.final_response)
|
||||
|
||||
|
||||
def _run_turn(thread, prompt: str):
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -35,13 +30,10 @@ with Codex(config=runtime_config()) as codex:
|
||||
)
|
||||
except ServerBusyError as exc:
|
||||
print("Server overloaded after retries:", exc.message)
|
||||
print("Text:")
|
||||
except JsonRpcError as exc:
|
||||
print(f"JSON-RPC error {exc.code}: {exc.message}")
|
||||
print("Text:")
|
||||
else:
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
if result.status == TurnStatus.failed:
|
||||
print("Turn failed:", result.error)
|
||||
print("Text:", assistant_text_from_turn(persisted_turn))
|
||||
else:
|
||||
print("Text:", result.final_response)
|
||||
|
||||
@@ -21,19 +21,9 @@ from openai_codex.types import (
|
||||
)
|
||||
|
||||
|
||||
def _status_value(status: object | None) -> str:
|
||||
return str(getattr(status, "value", status))
|
||||
|
||||
|
||||
def _format_usage(usage: object | None) -> str:
|
||||
if usage is None:
|
||||
return "usage> (none)"
|
||||
|
||||
last = getattr(usage, "last", None)
|
||||
total = getattr(usage, "total", None)
|
||||
if last is None or total is None:
|
||||
return f"usage> {usage}"
|
||||
|
||||
def _format_usage(usage: object) -> str:
|
||||
last = usage.last
|
||||
total = usage.total
|
||||
return (
|
||||
"usage>\n"
|
||||
f" last: input={last.input_tokens} output={last.output_tokens} reasoning={last.reasoning_output_tokens} total={last.total_tokens} cached={last.cached_input_tokens}\n"
|
||||
@@ -65,16 +55,14 @@ async def main() -> None:
|
||||
usage = None
|
||||
status = None
|
||||
error = None
|
||||
printed_delta = False
|
||||
|
||||
print("assistant> ", end="", flush=True)
|
||||
async for event in turn.stream():
|
||||
payload = event.payload
|
||||
if event.method == "item/agentMessage/delta":
|
||||
delta = getattr(payload, "delta", "")
|
||||
delta = payload.delta
|
||||
if delta:
|
||||
print(delta, end="", flush=True)
|
||||
printed_delta = True
|
||||
continue
|
||||
if isinstance(payload, ThreadTokenUsageUpdatedNotification):
|
||||
usage = payload.token_usage
|
||||
@@ -83,12 +71,13 @@ async def main() -> None:
|
||||
status = payload.turn.status
|
||||
error = payload.turn.error
|
||||
|
||||
if printed_delta:
|
||||
print()
|
||||
else:
|
||||
print("[no text]")
|
||||
print()
|
||||
if status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
if usage is None:
|
||||
raise RuntimeError("stream ended without token usage")
|
||||
|
||||
status_text = _status_value(status)
|
||||
status_text = status.value
|
||||
print(f"assistant.status> {status_text}")
|
||||
if status_text == "failed":
|
||||
print("assistant.error>", error)
|
||||
|
||||
@@ -21,19 +21,9 @@ from openai_codex.types import (
|
||||
print("Codex mini CLI. Type /exit to quit.")
|
||||
|
||||
|
||||
def _status_value(status: object | None) -> str:
|
||||
return str(getattr(status, "value", status))
|
||||
|
||||
|
||||
def _format_usage(usage: object | None) -> str:
|
||||
if usage is None:
|
||||
return "usage> (none)"
|
||||
|
||||
last = getattr(usage, "last", None)
|
||||
total = getattr(usage, "total", None)
|
||||
if last is None or total is None:
|
||||
return f"usage> {usage}"
|
||||
|
||||
def _format_usage(usage: object) -> str:
|
||||
last = usage.last
|
||||
total = usage.total
|
||||
return (
|
||||
"usage>\n"
|
||||
f" last: input={last.input_tokens} output={last.output_tokens} reasoning={last.reasoning_output_tokens} total={last.total_tokens} cached={last.cached_input_tokens}\n"
|
||||
@@ -60,16 +50,14 @@ with Codex(config=runtime_config()) as codex:
|
||||
usage = None
|
||||
status = None
|
||||
error = None
|
||||
printed_delta = False
|
||||
|
||||
print("assistant> ", end="", flush=True)
|
||||
for event in turn.stream():
|
||||
payload = event.payload
|
||||
if event.method == "item/agentMessage/delta":
|
||||
delta = getattr(payload, "delta", "")
|
||||
delta = payload.delta
|
||||
if delta:
|
||||
print(delta, end="", flush=True)
|
||||
printed_delta = True
|
||||
continue
|
||||
if isinstance(payload, ThreadTokenUsageUpdatedNotification):
|
||||
usage = payload.token_usage
|
||||
@@ -78,12 +66,13 @@ with Codex(config=runtime_config()) as codex:
|
||||
status = payload.turn.status
|
||||
error = payload.turn.error
|
||||
|
||||
if printed_delta:
|
||||
print()
|
||||
else:
|
||||
print("[no text]")
|
||||
print()
|
||||
if status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
if usage is None:
|
||||
raise RuntimeError("stream ended without token usage")
|
||||
|
||||
status_text = _status_value(status)
|
||||
status_text = status.value
|
||||
print(f"assistant.status> {status_text}")
|
||||
if status_text == "failed":
|
||||
print("assistant.error>", error)
|
||||
|
||||
@@ -6,12 +6,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -60,9 +55,7 @@ async def main() -> None:
|
||||
summary=SUMMARY,
|
||||
)
|
||||
result = await turn.run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
structured_text = assistant_text_from_turn(persisted_turn).strip()
|
||||
structured_text = result.final_response.strip()
|
||||
try:
|
||||
structured = json.loads(structured_text)
|
||||
except json.JSONDecodeError as exc:
|
||||
@@ -70,8 +63,8 @@ async def main() -> None:
|
||||
f"Expected JSON matching OUTPUT_SCHEMA, got: {structured_text!r}"
|
||||
) from exc
|
||||
|
||||
summary = structured.get("summary")
|
||||
actions = structured.get("actions")
|
||||
summary = structured["summary"]
|
||||
actions = structured["actions"]
|
||||
if (
|
||||
not isinstance(summary, str)
|
||||
or not isinstance(actions, list)
|
||||
@@ -86,7 +79,7 @@ async def main() -> None:
|
||||
print("actions:")
|
||||
for action in actions:
|
||||
print("-", action)
|
||||
print("Items:", 0 if persisted_turn is None else len(persisted_turn.items or []))
|
||||
print("Items:", len(result.items))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,12 +6,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -54,9 +49,7 @@ with Codex(config=runtime_config()) as codex:
|
||||
summary=SUMMARY,
|
||||
)
|
||||
result = turn.run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
structured_text = assistant_text_from_turn(persisted_turn).strip()
|
||||
structured_text = result.final_response.strip()
|
||||
try:
|
||||
structured = json.loads(structured_text)
|
||||
except json.JSONDecodeError as exc:
|
||||
@@ -64,8 +57,8 @@ with Codex(config=runtime_config()) as codex:
|
||||
f"Expected JSON matching OUTPUT_SCHEMA, got: {structured_text!r}"
|
||||
) from exc
|
||||
|
||||
summary = structured.get("summary")
|
||||
actions = structured.get("actions")
|
||||
summary = structured["summary"]
|
||||
actions = structured["actions"]
|
||||
if (
|
||||
not isinstance(summary, str)
|
||||
or not isinstance(actions, list)
|
||||
@@ -80,4 +73,4 @@ with Codex(config=runtime_config()) as codex:
|
||||
print("actions:")
|
||||
for action in actions:
|
||||
print("-", action)
|
||||
print("Items:", 0 if persisted_turn is None else len(persisted_turn.items or []))
|
||||
print("Items:", len(result.items))
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -35,29 +30,27 @@ REASONING_RANK = {
|
||||
"high": 4,
|
||||
"xhigh": 5,
|
||||
}
|
||||
PREFERRED_MODEL = "gpt-5.4"
|
||||
|
||||
|
||||
def _pick_highest_model(models):
|
||||
visible = [m for m in models if not m.hidden] or models
|
||||
preferred = next(
|
||||
(m for m in visible if m.model == PREFERRED_MODEL or m.id == PREFERRED_MODEL), None
|
||||
)
|
||||
if preferred is not None:
|
||||
return preferred
|
||||
visible = [m for m in models if not m.hidden]
|
||||
if not visible:
|
||||
raise RuntimeError("models response did not include visible models")
|
||||
|
||||
known_names = {m.id for m in visible} | {m.model for m in visible}
|
||||
top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)]
|
||||
pool = top_candidates or visible
|
||||
return max(pool, key=lambda m: (m.model, m.id))
|
||||
if not top_candidates:
|
||||
raise RuntimeError("models response did not include top-level visible models")
|
||||
return max(top_candidates, key=lambda m: (m.model, m.id))
|
||||
|
||||
|
||||
def _pick_highest_turn_effort(model) -> ReasoningEffort:
|
||||
if not model.supported_reasoning_efforts:
|
||||
return ReasoningEffort.medium
|
||||
raise RuntimeError(f"{model.model} did not advertise supported reasoning efforts")
|
||||
|
||||
best = max(
|
||||
model.supported_reasoning_efforts,
|
||||
key=lambda option: REASONING_RANK.get(option.reasoning_effort.value, -1),
|
||||
key=lambda option: REASONING_RANK[option.reasoning_effort.value],
|
||||
)
|
||||
return ReasoningEffort(best.reasoning_effort.value)
|
||||
|
||||
@@ -103,13 +96,9 @@ async def main() -> None:
|
||||
effort=selected_effort,
|
||||
)
|
||||
first = await first_turn.run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
first_persisted_turn = find_turn_by_id(persisted.thread.turns, first.id)
|
||||
|
||||
print("agent.message:", assistant_text_from_turn(first_persisted_turn))
|
||||
print(
|
||||
"items:", 0 if first_persisted_turn is None else len(first_persisted_turn.items or [])
|
||||
)
|
||||
print("agent.message:", first.final_response)
|
||||
print("items:", len(first.items))
|
||||
|
||||
second_turn = await thread.turn(
|
||||
TextInput("Return JSON for a safe feature-flag rollout plan."),
|
||||
@@ -122,14 +111,9 @@ async def main() -> None:
|
||||
summary=ReasoningSummary.model_validate("concise"),
|
||||
)
|
||||
second = await second_turn.run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
second_persisted_turn = find_turn_by_id(persisted.thread.turns, second.id)
|
||||
|
||||
print("agent.message.params:", assistant_text_from_turn(second_persisted_turn))
|
||||
print(
|
||||
"items.params:",
|
||||
0 if second_persisted_turn is None else len(second_persisted_turn.items or []),
|
||||
)
|
||||
print("agent.message.params:", second.final_response)
|
||||
print("items.params:", len(second.items))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -33,29 +28,27 @@ REASONING_RANK = {
|
||||
"high": 4,
|
||||
"xhigh": 5,
|
||||
}
|
||||
PREFERRED_MODEL = "gpt-5.4"
|
||||
|
||||
|
||||
def _pick_highest_model(models):
|
||||
visible = [m for m in models if not m.hidden] or models
|
||||
preferred = next(
|
||||
(m for m in visible if m.model == PREFERRED_MODEL or m.id == PREFERRED_MODEL), None
|
||||
)
|
||||
if preferred is not None:
|
||||
return preferred
|
||||
visible = [m for m in models if not m.hidden]
|
||||
if not visible:
|
||||
raise RuntimeError("models response did not include visible models")
|
||||
|
||||
known_names = {m.id for m in visible} | {m.model for m in visible}
|
||||
top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)]
|
||||
pool = top_candidates or visible
|
||||
return max(pool, key=lambda m: (m.model, m.id))
|
||||
if not top_candidates:
|
||||
raise RuntimeError("models response did not include top-level visible models")
|
||||
return max(top_candidates, key=lambda m: (m.model, m.id))
|
||||
|
||||
|
||||
def _pick_highest_turn_effort(model) -> ReasoningEffort:
|
||||
if not model.supported_reasoning_efforts:
|
||||
return ReasoningEffort.medium
|
||||
raise RuntimeError(f"{model.model} did not advertise supported reasoning efforts")
|
||||
|
||||
best = max(
|
||||
model.supported_reasoning_efforts,
|
||||
key=lambda option: REASONING_RANK.get(option.reasoning_effort.value, -1),
|
||||
key=lambda option: REASONING_RANK[option.reasoning_effort.value],
|
||||
)
|
||||
return ReasoningEffort(best.reasoning_effort.value)
|
||||
|
||||
@@ -99,11 +92,9 @@ with Codex(config=runtime_config()) as codex:
|
||||
model=selected_model.model,
|
||||
effort=selected_effort,
|
||||
).run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
first_turn = find_turn_by_id(persisted.thread.turns, first.id)
|
||||
|
||||
print("agent.message:", assistant_text_from_turn(first_turn))
|
||||
print("items:", 0 if first_turn is None else len(first_turn.items or []))
|
||||
print("agent.message:", first.final_response)
|
||||
print("items:", len(first.items))
|
||||
|
||||
second = thread.turn(
|
||||
TextInput("Return JSON for a safe feature-flag rollout plan."),
|
||||
@@ -115,8 +106,6 @@ with Codex(config=runtime_config()) as codex:
|
||||
sandbox_policy=SANDBOX_POLICY,
|
||||
summary=ReasoningSummary.model_validate("concise"),
|
||||
).run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
second_turn = find_turn_by_id(persisted.thread.turns, second.id)
|
||||
|
||||
print("agent.message.params:", assistant_text_from_turn(second_turn))
|
||||
print("items.params:", 0 if second_turn is None else len(second_turn.items or []))
|
||||
print("agent.message.params:", second.final_response)
|
||||
print("items.params:", len(second.items))
|
||||
|
||||
@@ -5,11 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -26,56 +22,48 @@ async def main() -> None:
|
||||
steer_turn = await thread.turn(
|
||||
TextInput("Count from 1 to 40 with commas, then one summary sentence.")
|
||||
)
|
||||
steer_result = "sent"
|
||||
try:
|
||||
_ = await steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers."))
|
||||
except Exception as exc:
|
||||
steer_result = f"skipped {type(exc).__name__}"
|
||||
steer_result = await steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers."))
|
||||
|
||||
steer_event_count = 0
|
||||
steer_completed_status = "unknown"
|
||||
steer_completed_turn = None
|
||||
steer_completed_status = None
|
||||
steer_deltas = []
|
||||
async for event in steer_turn.stream():
|
||||
steer_event_count += 1
|
||||
if event.method == "item/agentMessage/delta":
|
||||
steer_deltas.append(event.payload.delta)
|
||||
continue
|
||||
if event.method == "turn/completed":
|
||||
steer_completed_turn = event.payload.turn
|
||||
steer_completed_status = getattr(
|
||||
event.payload.turn.status, "value", str(event.payload.turn.status)
|
||||
)
|
||||
steer_completed_status = event.payload.turn.status.value
|
||||
|
||||
steer_preview = (
|
||||
assistant_text_from_turn(steer_completed_turn).strip() or "[no assistant text]"
|
||||
)
|
||||
if steer_completed_status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
steer_preview = "".join(steer_deltas).strip()
|
||||
|
||||
interrupt_turn = await thread.turn(
|
||||
TextInput("Count from 1 to 200 with commas, then one summary sentence.")
|
||||
)
|
||||
interrupt_result = "sent"
|
||||
try:
|
||||
_ = await interrupt_turn.interrupt()
|
||||
except Exception as exc:
|
||||
interrupt_result = f"skipped {type(exc).__name__}"
|
||||
interrupt_result = await interrupt_turn.interrupt()
|
||||
|
||||
interrupt_event_count = 0
|
||||
interrupt_completed_status = "unknown"
|
||||
interrupt_completed_turn = None
|
||||
interrupt_completed_status = None
|
||||
interrupt_deltas = []
|
||||
async for event in interrupt_turn.stream():
|
||||
interrupt_event_count += 1
|
||||
if event.method == "item/agentMessage/delta":
|
||||
interrupt_deltas.append(event.payload.delta)
|
||||
continue
|
||||
if event.method == "turn/completed":
|
||||
interrupt_completed_turn = event.payload.turn
|
||||
interrupt_completed_status = getattr(
|
||||
event.payload.turn.status, "value", str(event.payload.turn.status)
|
||||
)
|
||||
interrupt_completed_status = event.payload.turn.status.value
|
||||
|
||||
interrupt_preview = (
|
||||
assistant_text_from_turn(interrupt_completed_turn).strip() or "[no assistant text]"
|
||||
)
|
||||
if interrupt_completed_status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
interrupt_preview = "".join(interrupt_deltas).strip()
|
||||
|
||||
print("steer.result:", steer_result)
|
||||
print("steer.result:", steer_result.model_dump(mode="json", by_alias=True))
|
||||
print("steer.final.status:", steer_completed_status)
|
||||
print("steer.events.count:", steer_event_count)
|
||||
print("steer.assistant.preview:", steer_preview)
|
||||
print("interrupt.result:", interrupt_result)
|
||||
print("interrupt.result:", interrupt_result.model_dump(mode="json", by_alias=True))
|
||||
print("interrupt.final.status:", interrupt_completed_status)
|
||||
print("interrupt.events.count:", interrupt_event_count)
|
||||
print("interrupt.assistant.preview:", interrupt_preview)
|
||||
|
||||
@@ -5,11 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -20,54 +16,48 @@ with Codex(config=runtime_config()) as codex:
|
||||
steer_turn = thread.turn(
|
||||
TextInput("Count from 1 to 40 with commas, then one summary sentence.")
|
||||
)
|
||||
steer_result = "sent"
|
||||
try:
|
||||
_ = steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers."))
|
||||
except Exception as exc:
|
||||
steer_result = f"skipped {type(exc).__name__}"
|
||||
steer_result = steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers."))
|
||||
|
||||
steer_event_count = 0
|
||||
steer_completed_status = "unknown"
|
||||
steer_completed_turn = None
|
||||
steer_completed_status = None
|
||||
steer_deltas = []
|
||||
for event in steer_turn.stream():
|
||||
steer_event_count += 1
|
||||
if event.method == "item/agentMessage/delta":
|
||||
steer_deltas.append(event.payload.delta)
|
||||
continue
|
||||
if event.method == "turn/completed":
|
||||
steer_completed_turn = event.payload.turn
|
||||
steer_completed_status = getattr(
|
||||
event.payload.turn.status, "value", str(event.payload.turn.status)
|
||||
)
|
||||
steer_completed_status = event.payload.turn.status.value
|
||||
|
||||
steer_preview = assistant_text_from_turn(steer_completed_turn).strip() or "[no assistant text]"
|
||||
if steer_completed_status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
steer_preview = "".join(steer_deltas).strip()
|
||||
|
||||
interrupt_turn = thread.turn(
|
||||
TextInput("Count from 1 to 200 with commas, then one summary sentence.")
|
||||
)
|
||||
interrupt_result = "sent"
|
||||
try:
|
||||
_ = interrupt_turn.interrupt()
|
||||
except Exception as exc:
|
||||
interrupt_result = f"skipped {type(exc).__name__}"
|
||||
interrupt_result = interrupt_turn.interrupt()
|
||||
|
||||
interrupt_event_count = 0
|
||||
interrupt_completed_status = "unknown"
|
||||
interrupt_completed_turn = None
|
||||
interrupt_completed_status = None
|
||||
interrupt_deltas = []
|
||||
for event in interrupt_turn.stream():
|
||||
interrupt_event_count += 1
|
||||
if event.method == "item/agentMessage/delta":
|
||||
interrupt_deltas.append(event.payload.delta)
|
||||
continue
|
||||
if event.method == "turn/completed":
|
||||
interrupt_completed_turn = event.payload.turn
|
||||
interrupt_completed_status = getattr(
|
||||
event.payload.turn.status, "value", str(event.payload.turn.status)
|
||||
)
|
||||
interrupt_completed_status = event.payload.turn.status.value
|
||||
|
||||
interrupt_preview = (
|
||||
assistant_text_from_turn(interrupt_completed_turn).strip() or "[no assistant text]"
|
||||
)
|
||||
if interrupt_completed_status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
interrupt_preview = "".join(interrupt_deltas).strip()
|
||||
|
||||
print("steer.result:", steer_result)
|
||||
print("steer.result:", steer_result.model_dump(mode="json", by_alias=True))
|
||||
print("steer.final.status:", steer_completed_status)
|
||||
print("steer.events.count:", steer_event_count)
|
||||
print("steer.assistant.preview:", steer_preview)
|
||||
print("interrupt.result:", interrupt_result)
|
||||
print("interrupt.result:", interrupt_result.model_dump(mode="json", by_alias=True))
|
||||
print("interrupt.final.status:", interrupt_completed_status)
|
||||
print("interrupt.events.count:", interrupt_event_count)
|
||||
print("interrupt.assistant.preview:", interrupt_preview)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from openai_codex import AsyncCodex
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
# Browser login returns a live handle. Open `auth_url` and await `wait()`
|
||||
# in a real app; this example cancels immediately so it stays non-blocking.
|
||||
login = await codex.login_chatgpt()
|
||||
canceled = await login.cancel()
|
||||
completed = await login.wait()
|
||||
account = await codex.account()
|
||||
|
||||
print("login.id:", login.login_id)
|
||||
print("login.auth_url:", login.auth_url)
|
||||
print("login.cancel.status:", canceled.status)
|
||||
print("login.completed.success:", completed.success)
|
||||
print("account.requires_openai_auth:", account.requires_openai_auth)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,26 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from openai_codex import Codex
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
# Browser login returns a live handle. Open `auth_url` and call `wait()`
|
||||
# in a real app; this example cancels immediately so it stays non-blocking.
|
||||
login = codex.login_chatgpt()
|
||||
canceled = login.cancel()
|
||||
completed = login.wait()
|
||||
account = codex.account()
|
||||
|
||||
print("login.id:", login.login_id)
|
||||
print("login.auth_url:", login.auth_url)
|
||||
print("login.cancel.status:", canceled.status)
|
||||
print("login.completed.success:", completed.success)
|
||||
print("account.requires_openai_auth:", account.requires_openai_auth)
|
||||
@@ -81,4 +81,6 @@ python examples/01_quickstart_constructor/async.py
|
||||
- `13_model_select_and_turn_params/`
|
||||
- list models, pick highest model + highest supported reasoning effort, run turns, print message and usage
|
||||
- `14_turn_controls/`
|
||||
- separate best-effort `steer()` and `interrupt()` demos with concise summaries
|
||||
- separate `steer()` and `interrupt()` demos with concise summaries
|
||||
- `15_login_and_account/`
|
||||
- browser-login handle lifecycle, cancellation, and account inspection
|
||||
|
||||
@@ -6,7 +6,7 @@ import sys
|
||||
import tempfile
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Iterator
|
||||
from typing import Any, Iterator
|
||||
|
||||
_SDK_PYTHON_DIR = Path(__file__).resolve().parents[1]
|
||||
_SDK_PYTHON_STR = str(_SDK_PYTHON_DIR)
|
||||
@@ -103,53 +103,5 @@ def temporary_sample_image_path() -> Iterator[Path]:
|
||||
yield image_path
|
||||
|
||||
|
||||
def server_label(metadata: object) -> str:
|
||||
server = getattr(metadata, "serverInfo", None)
|
||||
server_name = ((getattr(server, "name", None) or "") if server is not None else "").strip()
|
||||
server_version = (
|
||||
(getattr(server, "version", None) or "") if server is not None else ""
|
||||
).strip()
|
||||
if server_name and server_version:
|
||||
return f"{server_name} {server_version}"
|
||||
|
||||
user_agent = (
|
||||
(getattr(metadata, "userAgent", None) or "") if metadata is not None else ""
|
||||
).strip()
|
||||
return user_agent or "unknown"
|
||||
|
||||
|
||||
def find_turn_by_id(turns: Iterable[object] | None, turn_id: str) -> object | None:
|
||||
for turn in turns or []:
|
||||
if getattr(turn, "id", None) == turn_id:
|
||||
return turn
|
||||
return None
|
||||
|
||||
|
||||
def assistant_text_from_turn(turn: object | None) -> str:
|
||||
if turn is None:
|
||||
return ""
|
||||
|
||||
chunks: list[str] = []
|
||||
for item in getattr(turn, "items", []) or []:
|
||||
raw_item = item.model_dump(mode="json") if hasattr(item, "model_dump") else item
|
||||
if not isinstance(raw_item, dict):
|
||||
continue
|
||||
|
||||
item_type = raw_item.get("type")
|
||||
if item_type == "agentMessage":
|
||||
text = raw_item.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
chunks.append(text)
|
||||
continue
|
||||
|
||||
if item_type != "message" or raw_item.get("role") != "assistant":
|
||||
continue
|
||||
|
||||
for content in raw_item.get("content") or []:
|
||||
if not isinstance(content, dict) or content.get("type") != "output_text":
|
||||
continue
|
||||
text = content.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
chunks.append(text)
|
||||
|
||||
return "".join(chunks)
|
||||
def server_label(metadata: Any) -> str:
|
||||
return f"{metadata.serverInfo.name} {metadata.serverInfo.version}"
|
||||
|
||||
@@ -25,23 +25,10 @@
|
||||
" f'Notebook requires Python 3.10+; current interpreter is {sys.version.split()[0]}.'\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
"try:\n",
|
||||
" _ = os.getcwd()\n",
|
||||
"except FileNotFoundError:\n",
|
||||
" os.chdir(str(Path.home()))\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def _is_sdk_python_dir(path: Path) -> bool:\n",
|
||||
" return (path / 'pyproject.toml').exists() and (path / 'src' / 'openai_codex').exists()\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def _iter_home_fallback_candidates(home: Path):\n",
|
||||
" # bounded depth scan under home to support launching notebooks from unrelated cwd values\n",
|
||||
" patterns = ('sdk/python', '*/sdk/python', '*/*/sdk/python', '*/*/*/sdk/python')\n",
|
||||
" for pattern in patterns:\n",
|
||||
" yield from home.glob(pattern)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def _find_sdk_python_dir(start: Path) -> Path | None:\n",
|
||||
" checked = set()\n",
|
||||
"\n",
|
||||
@@ -70,21 +57,6 @@
|
||||
" if found is not None:\n",
|
||||
" return found\n",
|
||||
"\n",
|
||||
" for entry in sys.path:\n",
|
||||
" if not entry:\n",
|
||||
" continue\n",
|
||||
" entry_path = Path(entry).expanduser()\n",
|
||||
" for candidate in (entry_path, entry_path / 'sdk' / 'python'):\n",
|
||||
" found = _consider(candidate)\n",
|
||||
" if found is not None:\n",
|
||||
" return found\n",
|
||||
"\n",
|
||||
" home = Path.home()\n",
|
||||
" for candidate in _iter_home_fallback_candidates(home):\n",
|
||||
" found = _consider(candidate)\n",
|
||||
" if found is not None:\n",
|
||||
" return found\n",
|
||||
"\n",
|
||||
" return None\n",
|
||||
"\n",
|
||||
"\n",
|
||||
@@ -129,7 +101,7 @@
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 2: imports (public only)\n",
|
||||
"from _bootstrap import assistant_text_from_turn, find_turn_by_id, server_label\n",
|
||||
"from _bootstrap import server_label\n",
|
||||
"from openai_codex import (\n",
|
||||
" AsyncCodex,\n",
|
||||
" Codex,\n",
|
||||
@@ -140,6 +112,27 @@
|
||||
")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 2b: browser login handle lifecycle\n",
|
||||
"with Codex() as codex:\n",
|
||||
" # Open this URL and call `wait()` without canceling when completing login for real.\n",
|
||||
" login = codex.login_chatgpt()\n",
|
||||
" canceled = login.cancel()\n",
|
||||
" completed = login.wait()\n",
|
||||
" account = codex.account()\n",
|
||||
"\n",
|
||||
" print('login.id:', login.login_id)\n",
|
||||
" print('login.auth_url:', login.auth_url)\n",
|
||||
" print('login.cancel.status:', canceled.status)\n",
|
||||
" print('login.completed.success:', completed.success)\n",
|
||||
" print('account.requires_openai_auth:', account.requires_openai_auth)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
@@ -151,12 +144,10 @@
|
||||
" thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n",
|
||||
" turn = thread.turn(TextInput('Explain gradient descent in 3 bullets.'))\n",
|
||||
" result = turn.run()\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n",
|
||||
"\n",
|
||||
" print('server:', server_label(codex.metadata))\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(assistant_text_from_turn(persisted_turn))\n"
|
||||
" print(result.final_response)\n",
|
||||
" print('items:', len(result.items))\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -171,12 +162,9 @@
|
||||
"\n",
|
||||
" first = thread.turn(TextInput('Give a short summary of transformers.')).run()\n",
|
||||
" second = thread.turn(TextInput('Now explain that to a high-school student.')).run()\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" second_turn = find_turn_by_id(persisted.thread.turns, second.id)\n",
|
||||
"\n",
|
||||
" print('first status:', first.status)\n",
|
||||
" print('second status:', second.status)\n",
|
||||
" print('second text:', assistant_text_from_turn(second_turn))\n"
|
||||
" print('second text:', second.final_response)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -200,41 +188,27 @@
|
||||
" listing_archived = codex.thread_list(limit=20, archived=True)\n",
|
||||
" unarchived = codex.thread_unarchive(reopened.id)\n",
|
||||
"\n",
|
||||
" resumed_info = 'n/a'\n",
|
||||
" try:\n",
|
||||
" resumed = codex.thread_resume(\n",
|
||||
" unarchived.id,\n",
|
||||
" model='gpt-5.4',\n",
|
||||
" config={'model_reasoning_effort': 'high'},\n",
|
||||
" )\n",
|
||||
" resumed_result = resumed.turn(TextInput('Continue in one short sentence.')).run()\n",
|
||||
" resumed_info = f'{resumed_result.id} {resumed_result.status}'\n",
|
||||
" except Exception as e:\n",
|
||||
" resumed_info = f'skipped({type(e).__name__})'\n",
|
||||
" resumed = codex.thread_resume(\n",
|
||||
" unarchived.id,\n",
|
||||
" model='gpt-5.4',\n",
|
||||
" config={'model_reasoning_effort': 'high'},\n",
|
||||
" )\n",
|
||||
" resumed_result = resumed.turn(TextInput('Continue in one short sentence.')).run()\n",
|
||||
"\n",
|
||||
" forked_info = 'n/a'\n",
|
||||
" try:\n",
|
||||
" forked = codex.thread_fork(unarchived.id, model='gpt-5.4')\n",
|
||||
" forked_result = forked.turn(TextInput('Take a different angle in one short sentence.')).run()\n",
|
||||
" forked_info = f'{forked_result.id} {forked_result.status}'\n",
|
||||
" except Exception as e:\n",
|
||||
" forked_info = f'skipped({type(e).__name__})'\n",
|
||||
" forked = codex.thread_fork(unarchived.id, model='gpt-5.4')\n",
|
||||
" forked_result = forked.turn(TextInput('Take a different angle in one short sentence.')).run()\n",
|
||||
"\n",
|
||||
" compact_info = 'sent'\n",
|
||||
" try:\n",
|
||||
" _ = unarchived.compact()\n",
|
||||
" except Exception as e:\n",
|
||||
" compact_info = f'skipped({type(e).__name__})'\n",
|
||||
" compact_result = unarchived.compact()\n",
|
||||
"\n",
|
||||
" print('Lifecycle OK:', thread.id)\n",
|
||||
" print('first:', first.id, first.status)\n",
|
||||
" print('second:', second.id, second.status)\n",
|
||||
" print('read.turns:', len(reading.thread.turns or []))\n",
|
||||
" print('read.turns:', len(reading.thread.turns))\n",
|
||||
" print('list.active:', len(listing_active.data))\n",
|
||||
" print('list.archived:', len(listing_archived.data))\n",
|
||||
" print('resumed:', resumed_info)\n",
|
||||
" print('forked:', forked_info)\n",
|
||||
" print('compact:', compact_info)\n"
|
||||
" print('resumed:', resumed_result.id, resumed_result.status)\n",
|
||||
" print('forked:', forked_result.id, forked_result.status)\n",
|
||||
" print('compact:', compact_result.model_dump(mode='json', by_alias=True))\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -278,11 +252,8 @@
|
||||
" summary=summary,\n",
|
||||
" )\n",
|
||||
" result = turn.run()\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n",
|
||||
"\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(assistant_text_from_turn(persisted_turn))\n"
|
||||
" print(result.final_response)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -311,17 +282,20 @@
|
||||
"\n",
|
||||
"\n",
|
||||
"def pick_highest_model(models):\n",
|
||||
" visible = [m for m in models if not m.hidden] or models\n",
|
||||
" visible = [m for m in models if not m.hidden]\n",
|
||||
" if not visible:\n",
|
||||
" raise RuntimeError('models response did not include visible models')\n",
|
||||
" known_names = {m.id for m in visible} | {m.model for m in visible}\n",
|
||||
" top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)]\n",
|
||||
" pool = top_candidates or visible\n",
|
||||
" return max(pool, key=lambda m: (m.model, m.id))\n",
|
||||
" if not top_candidates:\n",
|
||||
" raise RuntimeError('models response did not include top-level visible models')\n",
|
||||
" return max(top_candidates, key=lambda m: (m.model, m.id))\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def pick_highest_turn_effort(model) -> ReasoningEffort:\n",
|
||||
" if not model.supported_reasoning_efforts:\n",
|
||||
" return ReasoningEffort.medium\n",
|
||||
" best = max(model.supported_reasoning_efforts, key=lambda opt: reasoning_rank.get(opt.reasoning_effort.value, -1))\n",
|
||||
" raise RuntimeError(f'{model.model} did not advertise supported reasoning efforts')\n",
|
||||
" best = max(model.supported_reasoning_efforts, key=lambda opt: reasoning_rank[opt.reasoning_effort.value])\n",
|
||||
" return ReasoningEffort(best.reasoning_effort.value)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
@@ -351,10 +325,8 @@
|
||||
" model=selected_model.model,\n",
|
||||
" effort=selected_effort,\n",
|
||||
" ).run()\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" first_turn = find_turn_by_id(persisted.thread.turns, first.id)\n",
|
||||
" print('agent.message:', assistant_text_from_turn(first_turn))\n",
|
||||
" print('items:', 0 if first_turn is None else len(first_turn.items or []))\n",
|
||||
" print('agent.message:', first.final_response)\n",
|
||||
" print('items:', len(first.items))\n",
|
||||
"\n",
|
||||
" second = thread.turn(\n",
|
||||
" TextInput('Return JSON for a safe feature-flag rollout plan.'),\n",
|
||||
@@ -366,10 +338,8 @@
|
||||
" sandbox_policy=sandbox_policy,\n",
|
||||
" summary=ReasoningSummary.model_validate('concise'),\n",
|
||||
" ).run()\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" second_turn = find_turn_by_id(persisted.thread.turns, second.id)\n",
|
||||
" print('agent.message.params:', assistant_text_from_turn(second_turn))\n",
|
||||
" print('items.params:', 0 if second_turn is None else len(second_turn.items or []))\n"
|
||||
" print('agent.message.params:', second.final_response)\n",
|
||||
" print('items.params:', len(second.items))\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -387,11 +357,8 @@
|
||||
" TextInput('What do you see in this image? 3 bullets.'),\n",
|
||||
" ImageInput(remote_image_url),\n",
|
||||
" ]).run()\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n",
|
||||
"\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(assistant_text_from_turn(persisted_turn))\n"
|
||||
" print(result.final_response)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -408,11 +375,8 @@
|
||||
" TextInput('Describe the colors and layout in this generated local image in 2 bullets.'),\n",
|
||||
" LocalImageInput(str(local_image_path.resolve())),\n",
|
||||
" ]).run()\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n",
|
||||
"\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(assistant_text_from_turn(persisted_turn))\n"
|
||||
" print(result.final_response)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -431,11 +395,8 @@
|
||||
" initial_delay_s=0.25,\n",
|
||||
" max_delay_s=2.0,\n",
|
||||
" )\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n",
|
||||
"\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(assistant_text_from_turn(persisted_turn))\n"
|
||||
" print(result.final_response)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -463,41 +424,27 @@
|
||||
" listing_archived = await codex.thread_list(limit=20, archived=True)\n",
|
||||
" unarchived = await codex.thread_unarchive(reopened.id)\n",
|
||||
"\n",
|
||||
" resumed_info = 'n/a'\n",
|
||||
" try:\n",
|
||||
" resumed = await codex.thread_resume(\n",
|
||||
" unarchived.id,\n",
|
||||
" model='gpt-5.4',\n",
|
||||
" config={'model_reasoning_effort': 'high'},\n",
|
||||
" )\n",
|
||||
" resumed_result = await (await resumed.turn(TextInput('Continue in one short sentence.'))).run()\n",
|
||||
" resumed_info = f'{resumed_result.id} {resumed_result.status}'\n",
|
||||
" except Exception as e:\n",
|
||||
" resumed_info = f'skipped({type(e).__name__})'\n",
|
||||
" resumed = await codex.thread_resume(\n",
|
||||
" unarchived.id,\n",
|
||||
" model='gpt-5.4',\n",
|
||||
" config={'model_reasoning_effort': 'high'},\n",
|
||||
" )\n",
|
||||
" resumed_result = await (await resumed.turn(TextInput('Continue in one short sentence.'))).run()\n",
|
||||
"\n",
|
||||
" forked_info = 'n/a'\n",
|
||||
" try:\n",
|
||||
" forked = await codex.thread_fork(unarchived.id, model='gpt-5.4')\n",
|
||||
" forked_result = await (await forked.turn(TextInput('Take a different angle in one short sentence.'))).run()\n",
|
||||
" forked_info = f'{forked_result.id} {forked_result.status}'\n",
|
||||
" except Exception as e:\n",
|
||||
" forked_info = f'skipped({type(e).__name__})'\n",
|
||||
" forked = await codex.thread_fork(unarchived.id, model='gpt-5.4')\n",
|
||||
" forked_result = await (await forked.turn(TextInput('Take a different angle in one short sentence.'))).run()\n",
|
||||
"\n",
|
||||
" compact_info = 'sent'\n",
|
||||
" try:\n",
|
||||
" _ = await unarchived.compact()\n",
|
||||
" except Exception as e:\n",
|
||||
" compact_info = f'skipped({type(e).__name__})'\n",
|
||||
" compact_result = await unarchived.compact()\n",
|
||||
"\n",
|
||||
" print('Lifecycle OK:', thread.id)\n",
|
||||
" print('first:', first.id, first.status)\n",
|
||||
" print('second:', second.id, second.status)\n",
|
||||
" print('read.turns:', len(reading.thread.turns or []))\n",
|
||||
" print('read.turns:', len(reading.thread.turns))\n",
|
||||
" print('list.active:', len(listing_active.data))\n",
|
||||
" print('list.archived:', len(listing_archived.data))\n",
|
||||
" print('resumed:', resumed_info)\n",
|
||||
" print('forked:', forked_info)\n",
|
||||
" print('compact:', compact_info)\n",
|
||||
" print('resumed:', resumed_result.id, resumed_result.status)\n",
|
||||
" print('forked:', forked_result.id, forked_result.status)\n",
|
||||
" print('compact:', compact_result.model_dump(mode='json', by_alias=True))\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"await async_lifecycle_demo()\n"
|
||||
@@ -509,7 +456,7 @@
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 10: async turn controls (best effort steer + interrupt)\n",
|
||||
"# Cell 10: async turn controls (steer + interrupt)\n",
|
||||
"import asyncio\n",
|
||||
"\n",
|
||||
"\n",
|
||||
@@ -518,46 +465,46 @@
|
||||
" thread = await codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n",
|
||||
" steer_turn = await thread.turn(TextInput('Count from 1 to 40 with commas, then one summary sentence.'))\n",
|
||||
"\n",
|
||||
" steer_result = 'sent'\n",
|
||||
" try:\n",
|
||||
" _ = await steer_turn.steer(TextInput('Keep it brief and stop after 10 numbers.'))\n",
|
||||
" except Exception as e:\n",
|
||||
" steer_result = f'skipped {type(e).__name__}'\n",
|
||||
" steer_result = await steer_turn.steer(TextInput('Keep it brief and stop after 10 numbers.'))\n",
|
||||
"\n",
|
||||
" steer_event_count = 0\n",
|
||||
" steer_completed_status = 'unknown'\n",
|
||||
" steer_completed_turn = None\n",
|
||||
" steer_completed_status = None\n",
|
||||
" steer_deltas = []\n",
|
||||
" async for event in steer_turn.stream():\n",
|
||||
" steer_event_count += 1\n",
|
||||
" if event.method == 'item/agentMessage/delta':\n",
|
||||
" steer_deltas.append(event.payload.delta)\n",
|
||||
" continue\n",
|
||||
" if event.method == 'turn/completed':\n",
|
||||
" steer_completed_turn = event.payload.turn\n",
|
||||
" steer_completed_status = getattr(event.payload.turn.status, 'value', str(event.payload.turn.status))\n",
|
||||
" steer_completed_status = event.payload.turn.status.value\n",
|
||||
"\n",
|
||||
" steer_preview = assistant_text_from_turn(steer_completed_turn).strip() or '[no assistant text]'\n",
|
||||
" if steer_completed_status is None:\n",
|
||||
" raise RuntimeError('stream ended without turn/completed')\n",
|
||||
" steer_preview = ''.join(steer_deltas).strip()\n",
|
||||
"\n",
|
||||
" interrupt_turn = await thread.turn(TextInput('Count from 1 to 200 with commas, then one summary sentence.'))\n",
|
||||
" interrupt_result = 'sent'\n",
|
||||
" try:\n",
|
||||
" _ = await interrupt_turn.interrupt()\n",
|
||||
" except Exception as e:\n",
|
||||
" interrupt_result = f'skipped {type(e).__name__}'\n",
|
||||
" interrupt_result = await interrupt_turn.interrupt()\n",
|
||||
"\n",
|
||||
" interrupt_event_count = 0\n",
|
||||
" interrupt_completed_status = 'unknown'\n",
|
||||
" interrupt_completed_turn = None\n",
|
||||
" interrupt_completed_status = None\n",
|
||||
" interrupt_deltas = []\n",
|
||||
" async for event in interrupt_turn.stream():\n",
|
||||
" interrupt_event_count += 1\n",
|
||||
" if event.method == 'item/agentMessage/delta':\n",
|
||||
" interrupt_deltas.append(event.payload.delta)\n",
|
||||
" continue\n",
|
||||
" if event.method == 'turn/completed':\n",
|
||||
" interrupt_completed_turn = event.payload.turn\n",
|
||||
" interrupt_completed_status = getattr(event.payload.turn.status, 'value', str(event.payload.turn.status))\n",
|
||||
" interrupt_completed_status = event.payload.turn.status.value\n",
|
||||
"\n",
|
||||
" interrupt_preview = assistant_text_from_turn(interrupt_completed_turn).strip() or '[no assistant text]'\n",
|
||||
" if interrupt_completed_status is None:\n",
|
||||
" raise RuntimeError('stream ended without turn/completed')\n",
|
||||
" interrupt_preview = ''.join(interrupt_deltas).strip()\n",
|
||||
"\n",
|
||||
" print('steer.result:', steer_result)\n",
|
||||
" print('steer.result:', steer_result.model_dump(mode='json', by_alias=True))\n",
|
||||
" print('steer.final.status:', steer_completed_status)\n",
|
||||
" print('steer.events.count:', steer_event_count)\n",
|
||||
" print('steer.assistant.preview:', steer_preview)\n",
|
||||
" print('interrupt.result:', interrupt_result)\n",
|
||||
" print('interrupt.result:', interrupt_result.model_dump(mode='json', by_alias=True))\n",
|
||||
" print('interrupt.final.status:', interrupt_completed_status)\n",
|
||||
" print('interrupt.events.count:', interrupt_event_count)\n",
|
||||
" print('interrupt.assistant.preview:', interrupt_preview)\n",
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
from ._version import __version__
|
||||
from .api import (
|
||||
ApprovalMode,
|
||||
AsyncChatgptLoginHandle,
|
||||
AsyncCodex,
|
||||
AsyncDeviceCodeLoginHandle,
|
||||
AsyncThread,
|
||||
AsyncTurnHandle,
|
||||
ChatgptLoginHandle,
|
||||
Codex,
|
||||
DeviceCodeLoginHandle,
|
||||
ImageInput,
|
||||
Input,
|
||||
InputItem,
|
||||
LocalImageInput,
|
||||
MentionInput,
|
||||
RunResult,
|
||||
SkillInput,
|
||||
TextInput,
|
||||
Thread,
|
||||
TurnHandle,
|
||||
TurnResult,
|
||||
)
|
||||
from .client import AppServerConfig
|
||||
from .errors import (
|
||||
@@ -39,11 +43,15 @@ __all__ = [
|
||||
"Codex",
|
||||
"AsyncCodex",
|
||||
"ApprovalMode",
|
||||
"ChatgptLoginHandle",
|
||||
"DeviceCodeLoginHandle",
|
||||
"AsyncChatgptLoginHandle",
|
||||
"AsyncDeviceCodeLoginHandle",
|
||||
"Thread",
|
||||
"AsyncThread",
|
||||
"TurnHandle",
|
||||
"AsyncTurnHandle",
|
||||
"RunResult",
|
||||
"TurnResult",
|
||||
"Input",
|
||||
"InputItem",
|
||||
"TextInput",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from .async_client import AsyncAppServerClient
|
||||
from .client import AppServerClient
|
||||
from .generated.v2_all import (
|
||||
AccountLoginCompletedNotification,
|
||||
CancelLoginAccountResponse,
|
||||
ChatgptDeviceCodeLoginAccountParams,
|
||||
ChatgptDeviceCodeLoginAccountResponse,
|
||||
ChatgptLoginAccountParams,
|
||||
ChatgptLoginAccountResponse,
|
||||
LoginAccountParams,
|
||||
)
|
||||
|
||||
|
||||
class _AsyncLoginOwner(Protocol):
|
||||
"""Subset of AsyncCodex needed by async login handles."""
|
||||
|
||||
_client: AsyncAppServerClient
|
||||
|
||||
async def _ensure_initialized(self) -> None:
|
||||
"""Ensure the owning SDK client has a live app-server connection."""
|
||||
...
|
||||
|
||||
|
||||
def start_chatgpt_login(client: AppServerClient) -> ChatgptLoginHandle:
|
||||
"""Start browser ChatGPT login and return the handle for that attempt."""
|
||||
response = client.account_login_start(
|
||||
LoginAccountParams(
|
||||
root=ChatgptLoginAccountParams(type="chatgpt"),
|
||||
)
|
||||
)
|
||||
response_root = response.root
|
||||
if not isinstance(response_root, ChatgptLoginAccountResponse):
|
||||
raise RuntimeError(f"unexpected ChatGPT login response: {response_root!r}")
|
||||
return ChatgptLoginHandle(
|
||||
client,
|
||||
response_root.login_id,
|
||||
response_root.auth_url,
|
||||
)
|
||||
|
||||
|
||||
async def async_start_chatgpt_login(owner: _AsyncLoginOwner) -> AsyncChatgptLoginHandle:
|
||||
"""Start async browser ChatGPT login and return that attempt's handle."""
|
||||
response = await owner._client.account_login_start(
|
||||
LoginAccountParams(
|
||||
root=ChatgptLoginAccountParams(type="chatgpt"),
|
||||
)
|
||||
)
|
||||
response_root = response.root
|
||||
if not isinstance(response_root, ChatgptLoginAccountResponse):
|
||||
raise RuntimeError(f"unexpected ChatGPT login response: {response_root!r}")
|
||||
return AsyncChatgptLoginHandle(
|
||||
owner,
|
||||
response_root.login_id,
|
||||
response_root.auth_url,
|
||||
)
|
||||
|
||||
|
||||
def start_device_code_login(client: AppServerClient) -> DeviceCodeLoginHandle:
|
||||
"""Start device-code ChatGPT login and return the handle for that attempt."""
|
||||
response = client.account_login_start(
|
||||
LoginAccountParams(
|
||||
root=ChatgptDeviceCodeLoginAccountParams(type="chatgptDeviceCode"),
|
||||
)
|
||||
)
|
||||
response_root = response.root
|
||||
if not isinstance(response_root, ChatgptDeviceCodeLoginAccountResponse):
|
||||
raise RuntimeError(f"unexpected device-code login response: {response_root!r}")
|
||||
return DeviceCodeLoginHandle(
|
||||
client,
|
||||
response_root.login_id,
|
||||
response_root.verification_url,
|
||||
response_root.user_code,
|
||||
)
|
||||
|
||||
|
||||
async def async_start_device_code_login(
|
||||
owner: _AsyncLoginOwner,
|
||||
) -> AsyncDeviceCodeLoginHandle:
|
||||
"""Start async device-code ChatGPT login and return that attempt's handle."""
|
||||
response = await owner._client.account_login_start(
|
||||
LoginAccountParams(
|
||||
root=ChatgptDeviceCodeLoginAccountParams(type="chatgptDeviceCode"),
|
||||
)
|
||||
)
|
||||
response_root = response.root
|
||||
if not isinstance(response_root, ChatgptDeviceCodeLoginAccountResponse):
|
||||
raise RuntimeError(f"unexpected device-code login response: {response_root!r}")
|
||||
return AsyncDeviceCodeLoginHandle(
|
||||
owner,
|
||||
response_root.login_id,
|
||||
response_root.verification_url,
|
||||
response_root.user_code,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChatgptLoginHandle:
|
||||
"""Live browser-login attempt returned by `Codex.login_chatgpt()`."""
|
||||
|
||||
_client: AppServerClient
|
||||
login_id: str
|
||||
auth_url: str
|
||||
|
||||
def wait(self) -> AccountLoginCompletedNotification:
|
||||
"""Wait for this browser login attempt's completion notification."""
|
||||
return self._client.wait_for_login_completed(self.login_id)
|
||||
|
||||
def cancel(self) -> CancelLoginAccountResponse:
|
||||
"""Cancel this browser login attempt."""
|
||||
return self._client.account_login_cancel(self.login_id)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeviceCodeLoginHandle:
|
||||
"""Live device-code login attempt returned by `Codex.login_chatgpt_device_code()`."""
|
||||
|
||||
_client: AppServerClient
|
||||
login_id: str
|
||||
verification_url: str
|
||||
user_code: str
|
||||
|
||||
def wait(self) -> AccountLoginCompletedNotification:
|
||||
"""Wait for this device-code login attempt's completion notification."""
|
||||
return self._client.wait_for_login_completed(self.login_id)
|
||||
|
||||
def cancel(self) -> CancelLoginAccountResponse:
|
||||
"""Cancel this device-code login attempt."""
|
||||
return self._client.account_login_cancel(self.login_id)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AsyncChatgptLoginHandle:
|
||||
"""Live browser-login attempt returned by `AsyncCodex.login_chatgpt()`."""
|
||||
|
||||
_codex: _AsyncLoginOwner
|
||||
login_id: str
|
||||
auth_url: str
|
||||
|
||||
async def wait(self) -> AccountLoginCompletedNotification:
|
||||
"""Wait for this browser login attempt's completion notification."""
|
||||
await self._codex._ensure_initialized()
|
||||
return await self._codex._client.wait_for_login_completed(self.login_id)
|
||||
|
||||
async def cancel(self) -> CancelLoginAccountResponse:
|
||||
"""Cancel this browser login attempt."""
|
||||
await self._codex._ensure_initialized()
|
||||
return await self._codex._client.account_login_cancel(self.login_id)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AsyncDeviceCodeLoginHandle:
|
||||
"""Live device-code attempt returned by `AsyncCodex.login_chatgpt_device_code()`."""
|
||||
|
||||
_codex: _AsyncLoginOwner
|
||||
login_id: str
|
||||
verification_url: str
|
||||
user_code: str
|
||||
|
||||
async def wait(self) -> AccountLoginCompletedNotification:
|
||||
"""Wait for this device-code login attempt's completion notification."""
|
||||
await self._codex._ensure_initialized()
|
||||
return await self._codex._client.wait_for_login_completed(self.login_id)
|
||||
|
||||
async def cancel(self) -> CancelLoginAccountResponse:
|
||||
"""Cancel this device-code login attempt."""
|
||||
await self._codex._ensure_initialized()
|
||||
return await self._codex._client.account_login_cancel(self.login_id)
|
||||
@@ -6,6 +6,7 @@ from collections import deque
|
||||
|
||||
from .errors import AppServerError, map_jsonrpc_error
|
||||
from .generated.notification_registry import notification_turn_id
|
||||
from .generated.v2_all import AccountLoginCompletedNotification
|
||||
from .models import JsonValue, Notification, UnknownNotification
|
||||
|
||||
ResponseQueueItem = JsonValue | BaseException
|
||||
@@ -25,6 +26,8 @@ class MessageRouter:
|
||||
"""Create empty response, turn, and global notification queues."""
|
||||
self._lock = threading.Lock()
|
||||
self._response_waiters: dict[str, queue.Queue[ResponseQueueItem]] = {}
|
||||
self._login_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {}
|
||||
self._pending_login_notifications: dict[str, deque[Notification]] = {}
|
||||
self._turn_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {}
|
||||
self._pending_turn_notifications: dict[str, deque[Notification]] = {}
|
||||
self._global_notifications: queue.Queue[NotificationQueueItem] = queue.Queue()
|
||||
@@ -51,6 +54,36 @@ class MessageRouter:
|
||||
raise item
|
||||
return item
|
||||
|
||||
def register_login(self, login_id: str) -> None:
|
||||
"""Register a queue for one interactive login attempt."""
|
||||
|
||||
login_queue: queue.Queue[NotificationQueueItem] = queue.Queue()
|
||||
with self._lock:
|
||||
if login_id in self._login_notifications:
|
||||
return
|
||||
pending = self._pending_login_notifications.pop(login_id, deque())
|
||||
self._login_notifications[login_id] = login_queue
|
||||
for notification in pending:
|
||||
login_queue.put(notification)
|
||||
|
||||
def unregister_login(self, login_id: str) -> None:
|
||||
"""Stop routing future notifications for one login attempt."""
|
||||
|
||||
with self._lock:
|
||||
self._login_notifications.pop(login_id, None)
|
||||
|
||||
def next_login_notification(self, login_id: str) -> Notification:
|
||||
"""Block until the next notification for a registered login attempt."""
|
||||
|
||||
with self._lock:
|
||||
login_queue = self._login_notifications.get(login_id)
|
||||
if login_queue is None:
|
||||
raise RuntimeError(f"login {login_id!r} is not registered for waiting")
|
||||
item = login_queue.get()
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return item
|
||||
|
||||
def register_turn(self, turn_id: str) -> None:
|
||||
"""Register a queue for a turn stream and replay early events."""
|
||||
|
||||
@@ -111,6 +144,18 @@ class MessageRouter:
|
||||
def route_notification(self, notification: Notification) -> None:
|
||||
"""Deliver a notification to a turn queue or the global queue."""
|
||||
|
||||
login_id = self._notification_login_id(notification)
|
||||
if login_id is not None:
|
||||
with self._lock:
|
||||
login_queue = self._login_notifications.get(login_id)
|
||||
if login_queue is None:
|
||||
self._pending_login_notifications.setdefault(login_id, deque()).append(
|
||||
notification
|
||||
)
|
||||
return
|
||||
login_queue.put(notification)
|
||||
return
|
||||
|
||||
turn_id = self._notification_turn_id(notification)
|
||||
if turn_id is None:
|
||||
self._global_notifications.put(notification)
|
||||
@@ -132,16 +177,35 @@ class MessageRouter:
|
||||
with self._lock:
|
||||
response_waiters = list(self._response_waiters.values())
|
||||
self._response_waiters.clear()
|
||||
login_queues = list(self._login_notifications.values())
|
||||
self._login_notifications.clear()
|
||||
self._pending_login_notifications.clear()
|
||||
turn_queues = list(self._turn_notifications.values())
|
||||
self._pending_turn_notifications.clear()
|
||||
# Put the same transport failure into every queue so no SDK call blocks
|
||||
# forever waiting for a response that cannot arrive.
|
||||
for waiter in response_waiters:
|
||||
waiter.put(exc)
|
||||
for login_queue in login_queues:
|
||||
login_queue.put(exc)
|
||||
for turn_queue in turn_queues:
|
||||
turn_queue.put(exc)
|
||||
self._global_notifications.put(exc)
|
||||
|
||||
def _notification_login_id(self, notification: Notification) -> str | None:
|
||||
"""Extract the login attempt id from completion notifications."""
|
||||
if notification.method != "account/login/completed":
|
||||
return None
|
||||
|
||||
payload = notification.payload
|
||||
if isinstance(payload, AccountLoginCompletedNotification):
|
||||
return payload.login_id
|
||||
if isinstance(payload, UnknownNotification):
|
||||
raw_login_id = payload.params.get("loginId")
|
||||
if isinstance(raw_login_id, str):
|
||||
return raw_login_id
|
||||
return None
|
||||
|
||||
def _notification_turn_id(self, notification: Notification) -> str | None:
|
||||
"""Extract routing ids from known generated payloads or raw unknown payloads."""
|
||||
payload = notification.payload
|
||||
|
||||
@@ -12,13 +12,20 @@ from .generated.v2_all import (
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
Turn as AppServerTurn,
|
||||
TurnCompletedNotification,
|
||||
TurnError,
|
||||
TurnStatus,
|
||||
)
|
||||
from .models import Notification
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunResult:
|
||||
class TurnResult:
|
||||
id: str
|
||||
status: TurnStatus
|
||||
error: TurnError | None
|
||||
started_at: int | None
|
||||
completed_at: int | None
|
||||
duration_ms: int | None
|
||||
final_response: str | None
|
||||
items: list[ThreadItem]
|
||||
usage: ThreadTokenUsage | None
|
||||
@@ -56,7 +63,7 @@ def _raise_for_failed_turn(turn: AppServerTurn) -> None:
|
||||
raise RuntimeError(f"turn failed with status {turn.status.value}")
|
||||
|
||||
|
||||
def _collect_run_result(stream: Iterator[Notification], *, turn_id: str) -> RunResult:
|
||||
def _collect_turn_result(stream: Iterator[Notification], *, turn_id: str) -> TurnResult:
|
||||
completed: TurnCompletedNotification | None = None
|
||||
items: list[ThreadItem] = []
|
||||
usage: ThreadTokenUsage | None = None
|
||||
@@ -76,16 +83,23 @@ def _collect_run_result(stream: Iterator[Notification], *, turn_id: str) -> RunR
|
||||
raise RuntimeError("turn completed event not received")
|
||||
|
||||
_raise_for_failed_turn(completed.turn)
|
||||
return RunResult(
|
||||
turn = completed.turn
|
||||
return TurnResult(
|
||||
id=turn.id,
|
||||
status=turn.status,
|
||||
error=turn.error,
|
||||
started_at=turn.started_at,
|
||||
completed_at=turn.completed_at,
|
||||
duration_ms=turn.duration_ms,
|
||||
final_response=_final_assistant_response_from_items(items),
|
||||
items=items,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
async def _collect_async_run_result(
|
||||
async def _collect_async_turn_result(
|
||||
stream: AsyncIterator[Notification], *, turn_id: str
|
||||
) -> RunResult:
|
||||
) -> TurnResult:
|
||||
completed: TurnCompletedNotification | None = None
|
||||
items: list[ThreadItem] = []
|
||||
usage: ThreadTokenUsage | None = None
|
||||
@@ -105,7 +119,14 @@ async def _collect_async_run_result(
|
||||
raise RuntimeError("turn completed event not received")
|
||||
|
||||
_raise_for_failed_turn(completed.turn)
|
||||
return RunResult(
|
||||
turn = completed.turn
|
||||
return TurnResult(
|
||||
id=turn.id,
|
||||
status=turn.status,
|
||||
error=turn.error,
|
||||
started_at=turn.started_at,
|
||||
completed_at=turn.completed_at,
|
||||
duration_ms=turn.duration_ms,
|
||||
final_response=_final_assistant_response_from_items(items),
|
||||
items=items,
|
||||
usage=usage,
|
||||
|
||||
@@ -22,14 +22,28 @@ from ._inputs import (
|
||||
_normalize_run_input,
|
||||
_to_wire_input,
|
||||
)
|
||||
from ._login import (
|
||||
AsyncChatgptLoginHandle,
|
||||
AsyncDeviceCodeLoginHandle,
|
||||
ChatgptLoginHandle,
|
||||
DeviceCodeLoginHandle,
|
||||
async_start_chatgpt_login,
|
||||
async_start_device_code_login,
|
||||
start_chatgpt_login,
|
||||
start_device_code_login,
|
||||
)
|
||||
from ._run import (
|
||||
RunResult,
|
||||
_collect_async_run_result,
|
||||
_collect_run_result,
|
||||
TurnResult,
|
||||
_collect_async_turn_result,
|
||||
_collect_turn_result,
|
||||
)
|
||||
from .async_client import AsyncAppServerClient
|
||||
from .client import AppServerClient, AppServerConfig
|
||||
from .generated.v2_all import (
|
||||
ApiKeyLoginAccountParams,
|
||||
GetAccountParams,
|
||||
GetAccountResponse,
|
||||
LoginAccountParams,
|
||||
ModelListResponse,
|
||||
Personality,
|
||||
ReasoningEffort,
|
||||
@@ -51,7 +65,6 @@ from .generated.v2_all import (
|
||||
ThreadSourceKind,
|
||||
ThreadStartParams,
|
||||
ThreadStartSource,
|
||||
Turn as AppServerTurn,
|
||||
TurnCompletedNotification,
|
||||
TurnInterruptResponse,
|
||||
TurnStartParams,
|
||||
@@ -85,6 +98,33 @@ class Codex:
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def login_api_key(self, api_key: str) -> None:
|
||||
"""Authenticate app-server with an API key."""
|
||||
self._client.account_login_start(
|
||||
LoginAccountParams(
|
||||
root=ApiKeyLoginAccountParams(
|
||||
api_key=api_key,
|
||||
type="apiKey",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def login_chatgpt(self) -> ChatgptLoginHandle:
|
||||
"""Start browser-based ChatGPT login and return its live handle."""
|
||||
return start_chatgpt_login(self._client)
|
||||
|
||||
def login_chatgpt_device_code(self) -> DeviceCodeLoginHandle:
|
||||
"""Start device-code ChatGPT login and return its live handle."""
|
||||
return start_device_code_login(self._client)
|
||||
|
||||
def account(self, *, refresh_token: bool = False) -> GetAccountResponse:
|
||||
"""Read the current app-server account state."""
|
||||
return self._client.account_read(GetAccountParams(refresh_token=refresh_token))
|
||||
|
||||
def logout(self) -> None:
|
||||
"""Clear the current app-server account session."""
|
||||
self._client.account_logout()
|
||||
|
||||
# BEGIN GENERATED: Codex.flat_methods
|
||||
def thread_start(
|
||||
self,
|
||||
@@ -286,6 +326,38 @@ class AsyncCodex:
|
||||
self._init = None
|
||||
self._initialized = False
|
||||
|
||||
async def login_api_key(self, api_key: str) -> None:
|
||||
"""Authenticate app-server with an API key."""
|
||||
await self._ensure_initialized()
|
||||
await self._client.account_login_start(
|
||||
LoginAccountParams(
|
||||
root=ApiKeyLoginAccountParams(
|
||||
api_key=api_key,
|
||||
type="apiKey",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def login_chatgpt(self) -> AsyncChatgptLoginHandle:
|
||||
"""Start browser-based ChatGPT login and return its live handle."""
|
||||
await self._ensure_initialized()
|
||||
return await async_start_chatgpt_login(self)
|
||||
|
||||
async def login_chatgpt_device_code(self) -> AsyncDeviceCodeLoginHandle:
|
||||
"""Start device-code ChatGPT login and return its live handle."""
|
||||
await self._ensure_initialized()
|
||||
return await async_start_device_code_login(self)
|
||||
|
||||
async def account(self, *, refresh_token: bool = False) -> GetAccountResponse:
|
||||
"""Read the current app-server account state."""
|
||||
await self._ensure_initialized()
|
||||
return await self._client.account_read(GetAccountParams(refresh_token=refresh_token))
|
||||
|
||||
async def logout(self) -> None:
|
||||
"""Clear the current app-server account session."""
|
||||
await self._ensure_initialized()
|
||||
await self._client.account_logout()
|
||||
|
||||
# BEGIN GENERATED: AsyncCodex.flat_methods
|
||||
async def thread_start(
|
||||
self,
|
||||
@@ -460,7 +532,7 @@ class Thread:
|
||||
sandbox_policy: SandboxPolicy | None = None,
|
||||
service_tier: str | None = None,
|
||||
summary: ReasoningSummary | None = None,
|
||||
) -> RunResult:
|
||||
) -> TurnResult:
|
||||
turn = self.turn(
|
||||
_normalize_run_input(input),
|
||||
approval_mode=approval_mode,
|
||||
@@ -475,7 +547,7 @@ class Thread:
|
||||
)
|
||||
stream = turn.stream()
|
||||
try:
|
||||
return _collect_run_result(stream, turn_id=turn.id)
|
||||
return _collect_turn_result(stream, turn_id=turn.id)
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
@@ -543,7 +615,7 @@ class AsyncThread:
|
||||
sandbox_policy: SandboxPolicy | None = None,
|
||||
service_tier: str | None = None,
|
||||
summary: ReasoningSummary | None = None,
|
||||
) -> RunResult:
|
||||
) -> TurnResult:
|
||||
turn = await self.turn(
|
||||
_normalize_run_input(input),
|
||||
approval_mode=approval_mode,
|
||||
@@ -558,7 +630,7 @@ class AsyncThread:
|
||||
)
|
||||
stream = turn.stream()
|
||||
try:
|
||||
return await _collect_async_run_result(stream, turn_id=turn.id)
|
||||
return await _collect_async_turn_result(stream, turn_id=turn.id)
|
||||
finally:
|
||||
await stream.aclose()
|
||||
|
||||
@@ -644,21 +716,13 @@ class TurnHandle:
|
||||
finally:
|
||||
self._client.unregister_turn_notifications(self.id)
|
||||
|
||||
def run(self) -> AppServerTurn:
|
||||
completed: TurnCompletedNotification | None = None
|
||||
def run(self) -> TurnResult:
|
||||
stream = self.stream()
|
||||
try:
|
||||
for event in stream:
|
||||
payload = event.payload
|
||||
if isinstance(payload, TurnCompletedNotification) and payload.turn.id == self.id:
|
||||
completed = payload
|
||||
return _collect_turn_result(stream, turn_id=self.id)
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
if completed is None:
|
||||
raise RuntimeError("turn completed event not received")
|
||||
return completed.turn
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AsyncTurnHandle:
|
||||
@@ -695,17 +759,9 @@ class AsyncTurnHandle:
|
||||
finally:
|
||||
self._codex._client.unregister_turn_notifications(self.id)
|
||||
|
||||
async def run(self) -> AppServerTurn:
|
||||
completed: TurnCompletedNotification | None = None
|
||||
async def run(self) -> TurnResult:
|
||||
stream = self.stream()
|
||||
try:
|
||||
async for event in stream:
|
||||
payload = event.payload
|
||||
if isinstance(payload, TurnCompletedNotification) and payload.turn.id == self.id:
|
||||
completed = payload
|
||||
return await _collect_async_turn_result(stream, turn_id=self.id)
|
||||
finally:
|
||||
await stream.aclose()
|
||||
|
||||
if completed is None:
|
||||
raise RuntimeError("turn completed event not received")
|
||||
return completed.turn
|
||||
|
||||
@@ -8,7 +8,14 @@ from pydantic import BaseModel
|
||||
|
||||
from .client import AppServerClient, AppServerConfig
|
||||
from .generated.v2_all import (
|
||||
AccountLoginCompletedNotification,
|
||||
AgentMessageDeltaNotification,
|
||||
CancelLoginAccountResponse,
|
||||
GetAccountParams as V2GetAccountParams,
|
||||
GetAccountResponse,
|
||||
LoginAccountParams as V2LoginAccountParams,
|
||||
LoginAccountResponse,
|
||||
LogoutAccountResponse,
|
||||
ModelListResponse,
|
||||
ThreadArchiveResponse,
|
||||
ThreadCompactStartResponse,
|
||||
@@ -88,6 +95,14 @@ class AsyncAppServerClient:
|
||||
"""Register a turn notification queue on the wrapped sync client."""
|
||||
self._sync.register_turn_notifications(turn_id)
|
||||
|
||||
def register_login_notifications(self, login_id: str) -> None:
|
||||
"""Register a login notification queue on the wrapped sync client."""
|
||||
self._sync.register_login_notifications(login_id)
|
||||
|
||||
def unregister_login_notifications(self, login_id: str) -> None:
|
||||
"""Unregister a login notification queue on the wrapped sync client."""
|
||||
self._sync.unregister_login_notifications(login_id)
|
||||
|
||||
def unregister_turn_notifications(self, turn_id: str) -> None:
|
||||
"""Unregister a turn notification queue on the wrapped sync client."""
|
||||
self._sync.unregister_turn_notifications(turn_id)
|
||||
@@ -107,6 +122,28 @@ class AsyncAppServerClient:
|
||||
response_model=response_model,
|
||||
)
|
||||
|
||||
async def account_login_start(
|
||||
self,
|
||||
params: V2LoginAccountParams | JsonObject,
|
||||
) -> LoginAccountResponse:
|
||||
"""Start one account login attempt through the wrapped sync client."""
|
||||
return await self._call_sync(self._sync.account_login_start, params)
|
||||
|
||||
async def account_login_cancel(self, login_id: str) -> CancelLoginAccountResponse:
|
||||
"""Cancel one active account login attempt through the wrapped sync client."""
|
||||
return await self._call_sync(self._sync.account_login_cancel, login_id)
|
||||
|
||||
async def account_read(
|
||||
self,
|
||||
params: V2GetAccountParams | JsonObject | None = None,
|
||||
) -> GetAccountResponse:
|
||||
"""Read current account state through the wrapped sync client."""
|
||||
return await self._call_sync(self._sync.account_read, params)
|
||||
|
||||
async def account_logout(self) -> LogoutAccountResponse:
|
||||
"""Clear the active account session through the wrapped sync client."""
|
||||
return await self._call_sync(self._sync.account_logout)
|
||||
|
||||
async def thread_start(
|
||||
self, params: V2ThreadStartParams | JsonObject | None = None
|
||||
) -> ThreadStartResponse:
|
||||
@@ -211,10 +248,21 @@ class AsyncAppServerClient:
|
||||
"""Wait for the next global notification without blocking the event loop."""
|
||||
return await self._call_sync(self._sync.next_notification)
|
||||
|
||||
async def next_login_notification(self, login_id: str) -> Notification:
|
||||
"""Wait for the next notification routed to one login attempt."""
|
||||
return await self._call_sync(self._sync.next_login_notification, login_id)
|
||||
|
||||
async def next_turn_notification(self, turn_id: str) -> Notification:
|
||||
"""Wait for the next notification routed to one turn."""
|
||||
return await self._call_sync(self._sync.next_turn_notification, turn_id)
|
||||
|
||||
async def wait_for_login_completed(
|
||||
self,
|
||||
login_id: str,
|
||||
) -> AccountLoginCompletedNotification:
|
||||
"""Wait for the completion notification routed to one login attempt."""
|
||||
return await self._call_sync(self._sync.wait_for_login_completed, login_id)
|
||||
|
||||
async def wait_for_turn_completed(self, turn_id: str) -> TurnCompletedNotification:
|
||||
"""Wait for the completion notification routed to one turn."""
|
||||
return await self._call_sync(self._sync.wait_for_turn_completed, turn_id)
|
||||
|
||||
@@ -17,7 +17,16 @@ from ._version import __version__ as SDK_VERSION
|
||||
from .errors import AppServerError, TransportClosedError
|
||||
from .generated.notification_registry import NOTIFICATION_MODELS
|
||||
from .generated.v2_all import (
|
||||
AccountLoginCompletedNotification,
|
||||
AgentMessageDeltaNotification,
|
||||
CancelLoginAccountResponse,
|
||||
ChatgptDeviceCodeLoginAccountResponse,
|
||||
ChatgptLoginAccountResponse,
|
||||
GetAccountParams as V2GetAccountParams,
|
||||
GetAccountResponse,
|
||||
LoginAccountParams as V2LoginAccountParams,
|
||||
LoginAccountResponse,
|
||||
LogoutAccountResponse,
|
||||
ModelListResponse,
|
||||
ThreadArchiveResponse,
|
||||
ThreadCompactStartResponse,
|
||||
@@ -59,6 +68,8 @@ def _params_dict(
|
||||
| V2ThreadListParams
|
||||
| V2ThreadForkParams
|
||||
| V2TurnStartParams
|
||||
| V2GetAccountParams
|
||||
| V2LoginAccountParams
|
||||
| JsonObject
|
||||
| None
|
||||
),
|
||||
@@ -246,7 +257,10 @@ class AppServerClient:
|
||||
waiter = self._router.create_response_waiter(request_id)
|
||||
|
||||
try:
|
||||
self._write_message({"id": request_id, "method": method, "params": params or {}})
|
||||
message: JsonObject = {"id": request_id, "method": method}
|
||||
if params is not None:
|
||||
message["params"] = params
|
||||
self._write_message(message)
|
||||
except BaseException:
|
||||
self._router.discard_response_waiter(request_id)
|
||||
raise
|
||||
@@ -258,12 +272,27 @@ class AppServerClient:
|
||||
|
||||
def notify(self, method: str, params: JsonObject | None = None) -> None:
|
||||
"""Send a JSON-RPC notification without waiting for a response."""
|
||||
self._write_message({"method": method, "params": params or {}})
|
||||
message: JsonObject = {"method": method}
|
||||
if params is not None:
|
||||
message["params"] = params
|
||||
self._write_message(message)
|
||||
|
||||
def next_notification(self) -> Notification:
|
||||
"""Return the next notification that is not scoped to an active turn."""
|
||||
return self._router.next_global_notification()
|
||||
|
||||
def register_login_notifications(self, login_id: str) -> None:
|
||||
"""Start routing notifications for one interactive login attempt."""
|
||||
self._router.register_login(login_id)
|
||||
|
||||
def unregister_login_notifications(self, login_id: str) -> None:
|
||||
"""Stop routing notifications for one interactive login attempt."""
|
||||
self._router.unregister_login(login_id)
|
||||
|
||||
def next_login_notification(self, login_id: str) -> Notification:
|
||||
"""Return the next routed notification for the requested login id."""
|
||||
return self._router.next_login_notification(login_id)
|
||||
|
||||
def register_turn_notifications(self, turn_id: str) -> None:
|
||||
"""Start routing notifications for one turn into its dedicated queue."""
|
||||
self._router.register_turn(turn_id)
|
||||
@@ -276,6 +305,43 @@ class AppServerClient:
|
||||
"""Return the next routed notification for the requested turn id."""
|
||||
return self._router.next_turn_notification(turn_id)
|
||||
|
||||
def account_login_start(
|
||||
self,
|
||||
params: V2LoginAccountParams | JsonObject,
|
||||
) -> LoginAccountResponse:
|
||||
response = self.request(
|
||||
"account/login/start",
|
||||
_params_dict(params),
|
||||
response_model=LoginAccountResponse,
|
||||
)
|
||||
response_root = response.root
|
||||
if isinstance(
|
||||
response_root,
|
||||
ChatgptLoginAccountResponse | ChatgptDeviceCodeLoginAccountResponse,
|
||||
):
|
||||
self.register_login_notifications(response_root.login_id)
|
||||
return response
|
||||
|
||||
def account_login_cancel(self, login_id: str) -> CancelLoginAccountResponse:
|
||||
return self.request(
|
||||
"account/login/cancel",
|
||||
{"loginId": login_id},
|
||||
response_model=CancelLoginAccountResponse,
|
||||
)
|
||||
|
||||
def account_read(
|
||||
self,
|
||||
params: V2GetAccountParams | JsonObject | None = None,
|
||||
) -> GetAccountResponse:
|
||||
return self.request(
|
||||
"account/read",
|
||||
_params_dict(params),
|
||||
response_model=GetAccountResponse,
|
||||
)
|
||||
|
||||
def account_logout(self) -> LogoutAccountResponse:
|
||||
return self.request("account/logout", None, response_model=LogoutAccountResponse)
|
||||
|
||||
def thread_start(
|
||||
self, params: V2ThreadStartParams | JsonObject | None = None
|
||||
) -> ThreadStartResponse:
|
||||
@@ -417,6 +483,24 @@ class AppServerClient:
|
||||
finally:
|
||||
self.unregister_turn_notifications(turn_id)
|
||||
|
||||
def wait_for_login_completed(
|
||||
self,
|
||||
login_id: str,
|
||||
) -> AccountLoginCompletedNotification:
|
||||
"""Block until the matching interactive login attempt completes."""
|
||||
self.register_login_notifications(login_id)
|
||||
try:
|
||||
while True:
|
||||
notification = self.next_login_notification(login_id)
|
||||
if (
|
||||
notification.method == "account/login/completed"
|
||||
and isinstance(notification.payload, AccountLoginCompletedNotification)
|
||||
and notification.payload.login_id == login_id
|
||||
):
|
||||
return notification.payload
|
||||
finally:
|
||||
self.unregister_login_notifications(login_id)
|
||||
|
||||
def stream_text(
|
||||
self,
|
||||
thread_id: str,
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .generated.v2_all import (
|
||||
Account,
|
||||
AccountLoginCompletedNotification,
|
||||
ApprovalsReviewer,
|
||||
AskForApproval,
|
||||
CancelLoginAccountResponse,
|
||||
CancelLoginAccountStatus,
|
||||
GetAccountResponse,
|
||||
ModelListResponse,
|
||||
Personality,
|
||||
PlanType,
|
||||
@@ -28,6 +33,7 @@ from .generated.v2_all import (
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
Turn,
|
||||
TurnCompletedNotification,
|
||||
TurnError,
|
||||
TurnInterruptResponse,
|
||||
TurnStatus,
|
||||
TurnSteerResponse,
|
||||
@@ -35,8 +41,13 @@ from .generated.v2_all import (
|
||||
from .models import InitializeResponse, JsonObject, Notification
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
"AccountLoginCompletedNotification",
|
||||
"ApprovalsReviewer",
|
||||
"AskForApproval",
|
||||
"CancelLoginAccountResponse",
|
||||
"CancelLoginAccountStatus",
|
||||
"GetAccountResponse",
|
||||
"InitializeResponse",
|
||||
"JsonObject",
|
||||
"ModelListResponse",
|
||||
@@ -63,6 +74,7 @@ __all__ = [
|
||||
"ThreadTokenUsageUpdatedNotification",
|
||||
"Turn",
|
||||
"TurnCompletedNotification",
|
||||
"TurnError",
|
||||
"TurnInterruptResponse",
|
||||
"TurnStatus",
|
||||
"TurnSteerResponse",
|
||||
|
||||
@@ -206,10 +206,11 @@ class MockResponsesServer:
|
||||
class AppServerHarness:
|
||||
"""Test fixture that points a pinned runtime app-server at MockResponsesServer."""
|
||||
|
||||
def __init__(self, tmp_path: Path) -> None:
|
||||
def __init__(self, tmp_path: Path, *, requires_openai_auth: bool = False) -> None:
|
||||
self.tmp_path = tmp_path
|
||||
self.codex_home = tmp_path / "codex-home"
|
||||
self.workspace = tmp_path / "workspace"
|
||||
self.requires_openai_auth = requires_openai_auth
|
||||
self.responses = MockResponsesServer()
|
||||
|
||||
def __enter__(self) -> AppServerHarness:
|
||||
@@ -238,6 +239,7 @@ class AppServerHarness:
|
||||
def _write_config(self) -> None:
|
||||
"""Write config.toml that routes model calls to the mock server."""
|
||||
config_toml = self.codex_home / "config.toml"
|
||||
requires_openai_auth = "requires_openai_auth = true\n" if self.requires_openai_auth else ""
|
||||
config_toml.write_text(
|
||||
f"""
|
||||
model = "mock-model"
|
||||
@@ -252,6 +254,7 @@ base_url = "{self.responses.url}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
{requires_openai_auth}
|
||||
""".lstrip()
|
||||
)
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ def agent_message_texts(events: list[Notification]) -> list[str]:
|
||||
|
||||
|
||||
def agent_message_texts_from_items(items: Iterable[Any]) -> list[str]:
|
||||
"""Extract agent-message text from completed run result items."""
|
||||
"""Extract agent-message text from completed turn result items."""
|
||||
texts: list[str] = []
|
||||
for item in items:
|
||||
root = item.root
|
||||
|
||||
@@ -157,7 +157,7 @@ def test_async_lifecycle_methods_round_trip(tmp_path) -> None:
|
||||
|
||||
async with AsyncCodex(config=harness.app_server_config()) as codex:
|
||||
thread = await codex.thread_start()
|
||||
run_result = await thread.run("materialize async thread")
|
||||
turn_result = await thread.run("materialize async thread")
|
||||
await thread.set_name("async lifecycle")
|
||||
named = await thread.read()
|
||||
resumed = await codex.thread_resume(thread.id)
|
||||
@@ -166,14 +166,14 @@ def test_async_lifecycle_methods_round_trip(tmp_path) -> None:
|
||||
unarchived = await codex.thread_unarchive(thread.id)
|
||||
|
||||
assert {
|
||||
"run_final_response": run_result.final_response,
|
||||
"turn_final_response": turn_result.final_response,
|
||||
"named_thread": named.thread.name,
|
||||
"resumed_id": resumed.id,
|
||||
"forked_is_distinct": forked.id != thread.id,
|
||||
"archive_response": archive_response.model_dump(by_alias=True, mode="json"),
|
||||
"unarchived_id": unarchived.id,
|
||||
} == {
|
||||
"run_final_response": "async materialized",
|
||||
"turn_final_response": "async materialized",
|
||||
"named_thread": "async lifecycle",
|
||||
"resumed_id": thread.id,
|
||||
"forked_is_distinct": True,
|
||||
@@ -253,19 +253,19 @@ def test_compact_rpc_hits_mock_responses(tmp_path) -> None:
|
||||
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
run_result = thread.run("create history")
|
||||
turn_result = thread.run("create history")
|
||||
compact_response = thread.compact()
|
||||
requests = harness.responses.wait_for_requests(2)
|
||||
|
||||
assert {
|
||||
"run_final_response": run_result.final_response,
|
||||
"turn_final_response": turn_result.final_response,
|
||||
"compact_response": compact_response.model_dump(
|
||||
by_alias=True,
|
||||
mode="json",
|
||||
),
|
||||
"request_kinds": [request_kind(request.path) for request in requests],
|
||||
} == {
|
||||
"run_final_response": "history",
|
||||
"turn_final_response": "history",
|
||||
"compact_response": {},
|
||||
"request_kinds": ["responses", "responses"],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
from app_server_harness import AppServerHarness
|
||||
|
||||
from openai_codex import AppServerConfig, Codex
|
||||
from openai_codex.generated.v2_all import (
|
||||
ChatgptAuthTokensLoginAccountParams,
|
||||
LoginAccountParams,
|
||||
)
|
||||
|
||||
|
||||
def _app_server_config(harness: AppServerHarness) -> AppServerConfig:
|
||||
"""Build an isolated login config without inheriting ambient API-key auth."""
|
||||
config = harness.app_server_config()
|
||||
config.env = {**(config.env or {}), "OPENAI_API_KEY": ""}
|
||||
return config
|
||||
|
||||
|
||||
def test_api_key_login_authenticates_follow_up_model_requests(tmp_path) -> None:
|
||||
"""API-key login should authorize the next Responses request with that key."""
|
||||
with AppServerHarness(tmp_path, requires_openai_auth=True) as harness:
|
||||
harness.responses.enqueue_assistant_message("api key auth", response_id="api-key-auth")
|
||||
|
||||
with Codex(config=_app_server_config(harness)) as codex:
|
||||
codex.login_api_key("sk-sdk-login-test")
|
||||
result = codex.thread_start().run("prove api key auth")
|
||||
request = harness.responses.single_request()
|
||||
|
||||
assert {
|
||||
"final_response": result.final_response,
|
||||
"authorization": request.header("authorization"),
|
||||
} == {
|
||||
"final_response": "api key auth",
|
||||
"authorization": "Bearer sk-sdk-login-test",
|
||||
}
|
||||
|
||||
|
||||
def test_chatgpt_token_login_authenticates_follow_up_model_requests(tmp_path) -> None:
|
||||
"""ChatGPT token handoff should authorize later Responses requests with that token."""
|
||||
account_id = "workspace-sdk-chatgpt"
|
||||
|
||||
def _encode(payload: dict[str, object]) -> str:
|
||||
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
# App-server parses claims from the access token before persisting external ChatGPT auth.
|
||||
header = _encode({"alg": "none", "typ": "JWT"})
|
||||
claims = _encode(
|
||||
{
|
||||
"email": "sdk-chatgpt@example.com",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": account_id,
|
||||
"chatgpt_plan_type": "pro",
|
||||
},
|
||||
}
|
||||
)
|
||||
access_token = f"{header}.{claims}.sig"
|
||||
|
||||
with AppServerHarness(tmp_path, requires_openai_auth=True) as harness:
|
||||
harness.responses.enqueue_assistant_message(
|
||||
"chatgpt token auth",
|
||||
response_id="chatgpt-token-auth",
|
||||
)
|
||||
|
||||
with Codex(config=_app_server_config(harness)) as codex:
|
||||
login = codex._client.account_login_start(
|
||||
LoginAccountParams(
|
||||
root=ChatgptAuthTokensLoginAccountParams(
|
||||
access_token=access_token,
|
||||
chatgpt_account_id=account_id,
|
||||
chatgpt_plan_type="pro",
|
||||
type="chatgptAuthTokens",
|
||||
)
|
||||
)
|
||||
)
|
||||
result = codex.thread_start().run("prove chatgpt token auth")
|
||||
request = harness.responses.single_request()
|
||||
|
||||
assert {
|
||||
"login_type": login.root.type,
|
||||
"final_response": result.final_response,
|
||||
"authorization": request.header("authorization"),
|
||||
} == {
|
||||
"login_type": "chatgptAuthTokens",
|
||||
"final_response": "chatgpt token auth",
|
||||
"authorization": f"Bearer {access_token}",
|
||||
}
|
||||
@@ -145,8 +145,8 @@ def test_async_thread_run_uses_mock_responses(
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_sync_run_result_uses_last_unknown_phase_message(tmp_path) -> None:
|
||||
"""RunResult should use the last unknown-phase agent message as final text."""
|
||||
def test_sync_turn_result_uses_last_unknown_phase_message(tmp_path) -> None:
|
||||
"""TurnResult should use the last unknown-phase agent message as final text."""
|
||||
with AppServerHarness(tmp_path) as harness:
|
||||
harness.responses.enqueue_sse(
|
||||
sse(
|
||||
@@ -171,8 +171,8 @@ def test_sync_run_result_uses_last_unknown_phase_message(tmp_path) -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_sync_run_result_preserves_empty_last_message(tmp_path) -> None:
|
||||
"""RunResult should preserve an empty final agent message instead of skipping it."""
|
||||
def test_sync_turn_result_preserves_empty_last_message(tmp_path) -> None:
|
||||
"""TurnResult should preserve an empty final agent message instead of skipping it."""
|
||||
with AppServerHarness(tmp_path) as harness:
|
||||
harness.responses.enqueue_sse(
|
||||
sse(
|
||||
@@ -197,8 +197,8 @@ def test_sync_run_result_preserves_empty_last_message(tmp_path) -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_sync_run_result_does_not_promote_commentary_only_to_final(tmp_path) -> None:
|
||||
"""RunResult final_response should stay unset when app-server marks only commentary."""
|
||||
def test_sync_turn_result_does_not_promote_commentary_only_to_final(tmp_path) -> None:
|
||||
"""TurnResult final_response should stay unset when app-server marks only commentary."""
|
||||
with AppServerHarness(tmp_path) as harness:
|
||||
harness.responses.enqueue_sse(
|
||||
sse(
|
||||
@@ -226,8 +226,8 @@ def test_sync_run_result_does_not_promote_commentary_only_to_final(tmp_path) ->
|
||||
}
|
||||
|
||||
|
||||
def test_async_run_result_uses_last_unknown_phase_message(tmp_path) -> None:
|
||||
"""Async RunResult should use the last unknown-phase agent message."""
|
||||
def test_async_turn_result_uses_last_unknown_phase_message(tmp_path) -> None:
|
||||
"""Async TurnResult should use the last unknown-phase agent message."""
|
||||
|
||||
async def scenario() -> None:
|
||||
"""Run one async result-mapping case against a pinned app-server."""
|
||||
@@ -263,10 +263,10 @@ def test_async_run_result_uses_last_unknown_phase_message(tmp_path) -> None:
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_async_run_result_does_not_promote_commentary_only_to_final(
|
||||
def test_async_turn_result_does_not_promote_commentary_only_to_final(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Async RunResult final_response should stay unset for commentary-only output."""
|
||||
"""Async TurnResult final_response should stay unset for commentary-only output."""
|
||||
|
||||
async def scenario() -> None:
|
||||
"""Run one async commentary mapping case against a pinned app-server."""
|
||||
@@ -318,7 +318,7 @@ def test_thread_run_raises_when_real_app_server_reports_failed_turn(tmp_path) ->
|
||||
|
||||
|
||||
def test_final_answer_phase_survives_real_app_server_mapping(tmp_path) -> None:
|
||||
"""RunResult should use the final-answer item emitted by app-server."""
|
||||
"""TurnResult should use the final-answer item emitted by app-server."""
|
||||
with AppServerHarness(tmp_path) as harness:
|
||||
harness.responses.enqueue_sse(
|
||||
sse(
|
||||
|
||||
@@ -5,6 +5,7 @@ import asyncio
|
||||
from app_server_harness import AppServerHarness
|
||||
from app_server_helpers import (
|
||||
agent_message_texts,
|
||||
agent_message_texts_from_items,
|
||||
next_async_delta,
|
||||
next_sync_delta,
|
||||
streaming_response,
|
||||
@@ -48,7 +49,7 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_turn_run_returns_completed_turn(tmp_path) -> None:
|
||||
"""TurnHandle.run should wait for the app-server completion notification."""
|
||||
"""TurnHandle.run should collect output and completion metadata."""
|
||||
with AppServerHarness(tmp_path) as harness:
|
||||
harness.responses.enqueue_assistant_message("turn complete", response_id="turn-run-1")
|
||||
|
||||
@@ -60,11 +61,13 @@ def test_turn_run_returns_completed_turn(tmp_path) -> None:
|
||||
assert {
|
||||
"turn_id": completed.id,
|
||||
"status": completed.status,
|
||||
"items": completed.items,
|
||||
"agent_messages": agent_message_texts_from_items(completed.items),
|
||||
"final_response": completed.final_response,
|
||||
} == {
|
||||
"turn_id": turn.id,
|
||||
"status": TurnStatus.completed,
|
||||
"items": [],
|
||||
"agent_messages": ["turn complete"],
|
||||
"final_response": "turn complete",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,9 +14,11 @@ from openai_codex import (
|
||||
AppServerConfig,
|
||||
AsyncCodex,
|
||||
AsyncThread,
|
||||
AsyncTurnHandle,
|
||||
Codex,
|
||||
RunResult,
|
||||
Thread,
|
||||
TurnHandle,
|
||||
TurnResult,
|
||||
)
|
||||
from openai_codex._initialize_metadata import validate_initialize_metadata
|
||||
from openai_codex.types import InitializeResponse
|
||||
@@ -27,11 +29,15 @@ EXPECTED_ROOT_EXPORTS = [
|
||||
"Codex",
|
||||
"AsyncCodex",
|
||||
"ApprovalMode",
|
||||
"ChatgptLoginHandle",
|
||||
"DeviceCodeLoginHandle",
|
||||
"AsyncChatgptLoginHandle",
|
||||
"AsyncDeviceCodeLoginHandle",
|
||||
"Thread",
|
||||
"AsyncThread",
|
||||
"TurnHandle",
|
||||
"AsyncTurnHandle",
|
||||
"RunResult",
|
||||
"TurnResult",
|
||||
"Input",
|
||||
"InputItem",
|
||||
"TextInput",
|
||||
@@ -55,8 +61,13 @@ EXPECTED_ROOT_EXPORTS = [
|
||||
]
|
||||
|
||||
EXPECTED_TYPES_EXPORTS = [
|
||||
"Account",
|
||||
"AccountLoginCompletedNotification",
|
||||
"ApprovalsReviewer",
|
||||
"AskForApproval",
|
||||
"CancelLoginAccountResponse",
|
||||
"CancelLoginAccountStatus",
|
||||
"GetAccountResponse",
|
||||
"InitializeResponse",
|
||||
"JsonObject",
|
||||
"ModelListResponse",
|
||||
@@ -83,6 +94,7 @@ EXPECTED_TYPES_EXPORTS = [
|
||||
"ThreadTokenUsageUpdatedNotification",
|
||||
"Turn",
|
||||
"TurnCompletedNotification",
|
||||
"TurnError",
|
||||
"TurnInterruptResponse",
|
||||
"TurnStatus",
|
||||
"TurnSteerResponse",
|
||||
@@ -119,9 +131,39 @@ def test_root_exports_app_server_config() -> None:
|
||||
assert AppServerConfig.__name__ == "AppServerConfig"
|
||||
|
||||
|
||||
def test_root_exports_run_result() -> None:
|
||||
"""The root package should expose the common-case run result wrapper."""
|
||||
assert RunResult.__name__ == "RunResult"
|
||||
def test_root_exports_turn_result() -> None:
|
||||
"""The root package should expose the collected turn result wrapper."""
|
||||
assert {
|
||||
"name": TurnResult.__name__,
|
||||
"fields": list(TurnResult.__dataclass_fields__),
|
||||
} == {
|
||||
"name": "TurnResult",
|
||||
"fields": [
|
||||
"id",
|
||||
"status",
|
||||
"error",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"duration_ms",
|
||||
"final_response",
|
||||
"items",
|
||||
"usage",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_turn_run_methods_return_turn_result() -> None:
|
||||
"""Both convenience and handle-based run APIs return the same result shape."""
|
||||
funcs = [
|
||||
Thread.run,
|
||||
TurnHandle.run,
|
||||
AsyncThread.run,
|
||||
AsyncTurnHandle.run,
|
||||
]
|
||||
|
||||
assert {fn: inspect.signature(fn).return_annotation for fn in funcs} == dict.fromkeys(
|
||||
funcs, "TurnResult"
|
||||
)
|
||||
|
||||
|
||||
def test_root_exports_approval_mode() -> None:
|
||||
|
||||
@@ -135,7 +135,7 @@ def _run_python(
|
||||
)
|
||||
|
||||
|
||||
def _runtime_compatibility_hint(
|
||||
def _runtime_schema_hint(
|
||||
runtime_env: PreparedRuntimeEnv,
|
||||
*,
|
||||
stdout: str,
|
||||
@@ -144,7 +144,7 @@ def _runtime_compatibility_hint(
|
||||
combined = f"{stdout}\n{stderr}"
|
||||
if "ThreadStartResponse" in combined and "approvalsReviewer" in combined:
|
||||
return (
|
||||
"\nCompatibility hint:\n"
|
||||
"\nSchema hint:\n"
|
||||
f"Pinned runtime {runtime_env.runtime_version} returned a thread/start payload "
|
||||
"that is older than the current SDK schema and is missing "
|
||||
"`approvalsReviewer`. Bump `sdk/python/_runtime_setup.py` to a matching "
|
||||
@@ -165,7 +165,7 @@ def _run_json_python(
|
||||
"Python snippet failed.\n"
|
||||
f"STDOUT:\n{result.stdout}\n"
|
||||
f"STDERR:\n{result.stderr}"
|
||||
f"{_runtime_compatibility_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}"
|
||||
f"{_runtime_schema_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}"
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
@@ -242,17 +242,12 @@ def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> No
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
result = thread.turn(TextInput("hello")).run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = next(
|
||||
(turn for turn in persisted.thread.turns or [] if turn.id == result.id),
|
||||
None,
|
||||
)
|
||||
print(json.dumps({
|
||||
"thread_id": thread.id,
|
||||
"turn_id": result.id,
|
||||
"status": result.status.value,
|
||||
"items_count": len(result.items or []),
|
||||
"persisted_items_count": 0 if persisted_turn is None else len(persisted_turn.items or []),
|
||||
"items_count": len(result.items),
|
||||
"final_response_is_text": isinstance(result.final_response, str) and bool(result.final_response.strip()),
|
||||
}))
|
||||
"""
|
||||
),
|
||||
@@ -261,8 +256,8 @@ def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> No
|
||||
assert isinstance(data["thread_id"], str) and data["thread_id"].strip()
|
||||
assert isinstance(data["turn_id"], str) and data["turn_id"].strip()
|
||||
assert data["status"] == "completed"
|
||||
assert isinstance(data["items_count"], int)
|
||||
assert isinstance(data["persisted_items_count"], int)
|
||||
assert data["items_count"] > 0
|
||||
assert data["final_response_is_text"] is True
|
||||
|
||||
|
||||
def test_real_thread_run_convenience_smoke(runtime_env: PreparedRuntimeEnv) -> None:
|
||||
@@ -345,17 +340,12 @@ def test_real_async_thread_turn_usage_and_ids_smoke(
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
result = await (await thread.turn(TextInput("say ok"))).run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = next(
|
||||
(turn for turn in persisted.thread.turns or [] if turn.id == result.id),
|
||||
None,
|
||||
)
|
||||
print(json.dumps({
|
||||
"thread_id": thread.id,
|
||||
"turn_id": result.id,
|
||||
"status": result.status.value,
|
||||
"items_count": len(result.items or []),
|
||||
"persisted_items_count": 0 if persisted_turn is None else len(persisted_turn.items or []),
|
||||
"items_count": len(result.items),
|
||||
"final_response_is_text": isinstance(result.final_response, str) and bool(result.final_response.strip()),
|
||||
}))
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -366,8 +356,8 @@ def test_real_async_thread_turn_usage_and_ids_smoke(
|
||||
assert isinstance(data["thread_id"], str) and data["thread_id"].strip()
|
||||
assert isinstance(data["turn_id"], str) and data["turn_id"].strip()
|
||||
assert data["status"] == "completed"
|
||||
assert isinstance(data["items_count"], int)
|
||||
assert isinstance(data["persisted_items_count"], int)
|
||||
assert data["items_count"] > 0
|
||||
assert data["final_response_is_text"] is True
|
||||
|
||||
|
||||
def test_real_async_thread_run_convenience_smoke(
|
||||
@@ -434,7 +424,7 @@ def test_notebook_sync_cell_smoke(runtime_env: PreparedRuntimeEnv) -> None:
|
||||
[
|
||||
_notebook_cell_source(1),
|
||||
_notebook_cell_source(2),
|
||||
_notebook_cell_source(3),
|
||||
_notebook_cell_source(4),
|
||||
]
|
||||
)
|
||||
result = _run_python(runtime_env, source, timeout_s=240)
|
||||
@@ -450,7 +440,7 @@ def test_notebook_advanced_cell_smoke(runtime_env: PreparedRuntimeEnv) -> None:
|
||||
[
|
||||
_notebook_cell_source(1),
|
||||
_notebook_cell_source(2),
|
||||
_notebook_cell_source(7),
|
||||
_notebook_cell_source(8),
|
||||
]
|
||||
)
|
||||
result = _run_python(runtime_env, source, timeout_s=360)
|
||||
@@ -531,7 +521,7 @@ def test_real_examples_run_and_assert(
|
||||
f"Example failed: {folder}/{script}\n"
|
||||
f"STDOUT:\n{result.stdout}\n"
|
||||
f"STDERR:\n{result.stderr}"
|
||||
f"{_runtime_compatibility_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}"
|
||||
f"{_runtime_schema_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}"
|
||||
)
|
||||
|
||||
out = result.stdout
|
||||
@@ -541,7 +531,7 @@ def test_real_examples_run_and_assert(
|
||||
assert "Server: unknown" not in out
|
||||
elif folder == "02_turn_run":
|
||||
assert "thread_id:" in out and "turn_id:" in out and "status:" in out
|
||||
assert "persisted.items.count:" in out
|
||||
assert "items.count:" in out
|
||||
elif folder == "03_turn_stream_events":
|
||||
assert "stream.completed:" in out
|
||||
assert "assistant>" in out
|
||||
|
||||
@@ -60,6 +60,7 @@ export type McpToolCallItem = {
|
||||
/** Result payload returned by the MCP server for successful calls. */
|
||||
result?: {
|
||||
content: McpContentBlock[];
|
||||
_meta?: unknown;
|
||||
structured_content: unknown;
|
||||
};
|
||||
/** Error message reported for failed calls. */
|
||||
|
||||
Reference in New Issue
Block a user