diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index eb2439401b..83ca0d1191 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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", diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 5b137c1091..86b1733e58 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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 { diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index 64c5bee684..4255b83e62 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -403,10 +403,47 @@ fn parse_key_path(path: &str) -> Result, 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)] diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index f9841421ca..acb5fda4da 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -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, diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 38ba12f95f..56c4d0b7d1 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -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()?; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7ffdff0011..6c79c34c99 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -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, + + /// 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, ) -> 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, + use_agent_identity_auth: bool, +) -> anyhow::Result { + 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, +) -> anyhow::Result { + 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 { + 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()?; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index de3632d629..07f49a3cd6 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ce7a36d93f..21d291b309 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -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()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 18158671ec..71a55b98dc 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -953,6 +953,7 @@ pub struct MultiAgentV2Config { pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, + pub tool_namespace: Option, 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() { diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index f36239f2ae..d60484d2e7 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -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, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 497cddd912..7850e8f8b6 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -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) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 92c7893d5e..8f0ce2d5fd 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -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( diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 111ab67798..e1295b50de 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -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::>() .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::>() - .join(", ") + format!(" Reasoning efforts: {efforts}.") }; + let service_tiers = model + .service_tiers + .iter() + .map(|tier| tier.id.as_str()) + .collect::>() + .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::>() diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index aaa29d6d4c..711db2fd11 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -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 { diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 1cf0e00623..96b686a83a 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -281,10 +281,6 @@ impl ToolRegistry { self.tools.get(name).map(Arc::clone) } - pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option { - self.tools.get(name).map(|tool| tool.exposure()) - } - #[cfg(test)] pub(crate) fn has_tool(&self, name: &ToolName) -> bool { self.tool(name).is_some() diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index be5e6741d4..3f355d2509 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -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 { - override_tool_exposure(Arc::new(handler), exposure) + let handler: Arc = 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, + namespace: String, +} + +#[async_trait::async_trait] +impl ToolExecutor for MultiAgentV2NamespaceOverride { + fn tool_name(&self) -> ToolName { + ToolName::namespaced(self.namespace.clone(), self.handler.tool_name().name) + } + + fn spec(&self) -> Option { + 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, 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 { + self.handler.search_info() + } + + fn create_diff_consumer( + &self, + ) -> Option> { + self.handler.create_diff_consumer() + } } fn compare_code_mode_tools( diff --git a/codex-rs/core/src/tools/spec_plan_model_tests.rs b/codex-rs/core/src/tools/spec_plan_model_tests.rs index dcc1be30ed..86692e48df 100644 --- a/codex-rs/core/src/tools/spec_plan_model_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_model_tests.rs @@ -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::>(); + + 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")); + } +} diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 333efe6f06..3442fbb5ff 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -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, diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 5aab77cd7d..c70f9bce37 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -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!( diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 09a9a71ea0..d842094a16 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -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 } diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 1eaf6e69eb..01439108f8 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -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 diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 872f16ce32..bd556638ea 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -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; diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index 32c0d5bc8e..2493135c9a 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -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", &"") + .field("auth_provider", &"") .finish_non_exhaustive() } } impl ExecutorRegistryClient { - fn new(base_url: String, bearer_token: String) -> Result { + fn new(base_url: String, auth_provider: SharedAuthProvider) -> Result { 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", &"") + .field("auth_provider", &"") .finish() } } impl RemoteExecutorConfig { - pub fn new(base_url: String, executor_id: String) -> Result { - 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 { 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 { - read_remote_bearer_token_from_env_with(|name| env::var(name)) -} - -fn read_remote_bearer_token_from_env_with(get_var: F) -> Result -where - F: FnOnce(&str) -> Result, -{ - 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 { - 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 { let executor_id = executor_id.trim().to_string(); if executor_id.is_empty() { @@ -274,6 +241,11 @@ fn preview_error_body(body: &str) -> Option { #[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("")); - assert!(!debug.contains("secret-token")); + assert!(!debug.contains("workspace-123")); } } diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index d228db9ee5..9f985e6982 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -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, diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 045baacc7b..89e41eacb1 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -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 { diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index 88fd042f7c..6bd358f6be 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -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()); +} diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index 4a84ef7494..078a876ef4 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -266,6 +266,9 @@ pub struct McpToolCallItemResult { // representations). Using `JsonValue` keeps the payload wire-shaped and // easy to export. pub content: Vec, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub meta: Option, pub structured_content: Option, } diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index efda58f412..acbac8254c 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -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, diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 2b69c2d04d..bfd14237d5 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -30,6 +30,9 @@ pub struct MultiAgentV2ConfigToml { #[serde(skip_serializing_if = "Option::is_none")] pub subagent_usage_hint_text: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 64), regex(pattern = r"^[a-zA-Z0-9_-]+$"))] + pub tool_namespace: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub hide_spawn_agent_metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] pub non_code_mode_only: Option, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index bd55ef8e7d..f4aa3982b9 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -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, })) diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 5956b3dc41..5dade3a268 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -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, diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index bfdc94fbf0..75120eb56f 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -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::(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 { 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> { .map(|dt| dt.with_timezone(&Utc)) } +fn parse_legacy_sandbox_policy(value: &str) -> serde_json::Result { + 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, branch: Option, diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 718527e7ec..ff2c6539a9 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -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"); diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 16f9f79e06..e3bfa31c96 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -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"); diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index edc78a36c2..d5a64607fc 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -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() diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 508f6321a3..e95028dd62 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -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, /// 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, /// First user message observed for this thread, if any. @@ -485,8 +485,8 @@ pub struct ThreadMetadataPatch { pub cli_version: Option, /// Approval mode. pub approval_mode: Option, - /// Sandbox policy. - pub sandbox_policy: Option, + /// Canonical runtime permissions. + pub permission_profile: Option, /// Last observed token usage. pub token_usage: Option, /// 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() diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 6e3ce15526..5c1681d164 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -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, + pub multi_agent_v2_tool_namespace: Option, pub max_concurrent_threads_per_session: Option, pub wait_agent_min_timeout_ms: Option, pub wait_agent_max_timeout_ms: Option, @@ -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, + ) -> 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, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 15e4f9b6cd..9271089c43 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -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, diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs new file mode 100644 index 0000000000..bcdbe29a0d --- /dev/null +++ b/codex-rs/tui/src/config_update.rs @@ -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, value: JsonValue) -> ConfigEdit { + ConfigEdit { + key_path: key_path.into(), + value, + merge_strategy: MergeStrategy::Replace, + } +} + +pub(crate) fn clear_config_value(key_path: impl Into) -> 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, +) -> Vec { + 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 { + 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, +) -> 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" + ); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 69b6bec0f3..871cdcc0dd 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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; diff --git a/sdk/python/README.md b/sdk/python/README.md index d3aeff7ac5..9107d15ba9 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -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 diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index 6be7e19737..4bee871f8c 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -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, diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index 1a87b50a14..5f2ed87ffd 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -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. diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index 6e37314e77..1df0026c68 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -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` diff --git a/sdk/python/examples/02_turn_run/async.py b/sdk/python/examples/02_turn_run/async.py index 0f6ef94f91..debfad8d6e 100644 --- a/sdk/python/examples/02_turn_run/async.py +++ b/sdk/python/examples/02_turn_run/async.py @@ -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__": diff --git a/sdk/python/examples/02_turn_run/sync.py b/sdk/python/examples/02_turn_run/sync.py index 18aa8c2f81..c42bffa1f2 100644 --- a/sdk/python/examples/02_turn_run/sync.py +++ b/sdk/python/examples/02_turn_run/sync.py @@ -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)) diff --git a/sdk/python/examples/03_turn_stream_events/async.py b/sdk/python/examples/03_turn_stream_events/async.py index dcf57af4f2..f0d3f1427d 100644 --- a/sdk/python/examples/03_turn_stream_events/async.py +++ b/sdk/python/examples/03_turn_stream_events/async.py @@ -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) diff --git a/sdk/python/examples/03_turn_stream_events/sync.py b/sdk/python/examples/03_turn_stream_events/sync.py index f96f5c6acd..5a76bcb70f 100644 --- a/sdk/python/examples/03_turn_stream_events/sync.py +++ b/sdk/python/examples/03_turn_stream_events/sync.py @@ -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) diff --git a/sdk/python/examples/04_models_and_metadata/async.py b/sdk/python/examples/04_models_and_metadata/async.py index ba36e51a23..23ca58131f 100644 --- a/sdk/python/examples/04_models_and_metadata/async.py +++ b/sdk/python/examples/04_models_and_metadata/async.py @@ -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__": diff --git a/sdk/python/examples/04_models_and_metadata/sync.py b/sdk/python/examples/04_models_and_metadata/sync.py index 849116941f..155e53571d 100644 --- a/sdk/python/examples/04_models_and_metadata/sync.py +++ b/sdk/python/examples/04_models_and_metadata/sync.py @@ -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])) diff --git a/sdk/python/examples/05_existing_thread/async.py b/sdk/python/examples/05_existing_thread/async.py index 4dc36531d8..df3946ac87 100644 --- a/sdk/python/examples/05_existing_thread/async.py +++ b/sdk/python/examples/05_existing_thread/async.py @@ -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__": diff --git a/sdk/python/examples/05_existing_thread/sync.py b/sdk/python/examples/05_existing_thread/sync.py index ca441c4223..a0f0d37e27 100644 --- a/sdk/python/examples/05_existing_thread/sync.py +++ b/sdk/python/examples/05_existing_thread/sync.py @@ -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) diff --git a/sdk/python/examples/06_thread_lifecycle_and_controls/async.py b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py index ad73a896ee..24afbde6c2 100644 --- a/sdk/python/examples/06_thread_lifecycle_and_controls/async.py +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py @@ -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__": diff --git a/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py index d01f165235..0958002dd9 100644 --- a/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py @@ -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)) diff --git a/sdk/python/examples/07_image_and_text/async.py b/sdk/python/examples/07_image_and_text/async.py index 41fe96a830..c3237e320e 100644 --- a/sdk/python/examples/07_image_and_text/async.py +++ b/sdk/python/examples/07_image_and_text/async.py @@ -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__": diff --git a/sdk/python/examples/07_image_and_text/sync.py b/sdk/python/examples/07_image_and_text/sync.py index ca82e7cfdc..f7402f18c9 100644 --- a/sdk/python/examples/07_image_and_text/sync.py +++ b/sdk/python/examples/07_image_and_text/sync.py @@ -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) diff --git a/sdk/python/examples/08_local_image_and_text/async.py b/sdk/python/examples/08_local_image_and_text/async.py index c3abf4d2ee..292c0c3e3d 100644 --- a/sdk/python/examples/08_local_image_and_text/async.py +++ b/sdk/python/examples/08_local_image_and_text/async.py @@ -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__": diff --git a/sdk/python/examples/08_local_image_and_text/sync.py b/sdk/python/examples/08_local_image_and_text/sync.py index 45c5161cb8..ee80a181ff 100644 --- a/sdk/python/examples/08_local_image_and_text/sync.py +++ b/sdk/python/examples/08_local_image_and_text/sync.py @@ -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) diff --git a/sdk/python/examples/09_async_parity/sync.py b/sdk/python/examples/09_async_parity/sync.py index 018e588524..4b5d567159 100644 --- a/sdk/python/examples/09_async_parity/sync.py +++ b/sdk/python/examples/09_async_parity/sync.py @@ -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()) diff --git a/sdk/python/examples/10_error_handling_and_retry/async.py b/sdk/python/examples/10_error_handling_and_retry/async.py index 65c95b2b63..f1711bffc9 100644 --- a/sdk/python/examples/10_error_handling_and_retry/async.py +++ b/sdk/python/examples/10_error_handling_and_retry/async.py @@ -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): diff --git a/sdk/python/examples/10_error_handling_and_retry/sync.py b/sdk/python/examples/10_error_handling_and_retry/sync.py index 24ee243db3..c134063c80 100644 --- a/sdk/python/examples/10_error_handling_and_retry/sync.py +++ b/sdk/python/examples/10_error_handling_and_retry/sync.py @@ -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) diff --git a/sdk/python/examples/11_cli_mini_app/async.py b/sdk/python/examples/11_cli_mini_app/async.py index 1b0a2cfd3a..df9fc89b86 100644 --- a/sdk/python/examples/11_cli_mini_app/async.py +++ b/sdk/python/examples/11_cli_mini_app/async.py @@ -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) diff --git a/sdk/python/examples/11_cli_mini_app/sync.py b/sdk/python/examples/11_cli_mini_app/sync.py index dcb801d29c..fd16f930dc 100644 --- a/sdk/python/examples/11_cli_mini_app/sync.py +++ b/sdk/python/examples/11_cli_mini_app/sync.py @@ -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) diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/async.py b/sdk/python/examples/12_turn_params_kitchen_sink/async.py index 30b72f3173..258d674a88 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/async.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/async.py @@ -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__": diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py index dffe74a915..feced36f20 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py @@ -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)) diff --git a/sdk/python/examples/13_model_select_and_turn_params/async.py b/sdk/python/examples/13_model_select_and_turn_params/async.py index f2810d8fd9..31a0c9c243 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/async.py +++ b/sdk/python/examples/13_model_select_and_turn_params/async.py @@ -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__": diff --git a/sdk/python/examples/13_model_select_and_turn_params/sync.py b/sdk/python/examples/13_model_select_and_turn_params/sync.py index b1154ce171..9252f3913c 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/sync.py +++ b/sdk/python/examples/13_model_select_and_turn_params/sync.py @@ -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)) diff --git a/sdk/python/examples/14_turn_controls/async.py b/sdk/python/examples/14_turn_controls/async.py index 4f2777c2e4..f044dadbbe 100644 --- a/sdk/python/examples/14_turn_controls/async.py +++ b/sdk/python/examples/14_turn_controls/async.py @@ -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) diff --git a/sdk/python/examples/14_turn_controls/sync.py b/sdk/python/examples/14_turn_controls/sync.py index 03180ba8eb..6b6d046a43 100644 --- a/sdk/python/examples/14_turn_controls/sync.py +++ b/sdk/python/examples/14_turn_controls/sync.py @@ -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) diff --git a/sdk/python/examples/15_login_and_account/async.py b/sdk/python/examples/15_login_and_account/async.py new file mode 100644 index 0000000000..7de6c2afa5 --- /dev/null +++ b/sdk/python/examples/15_login_and_account/async.py @@ -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()) diff --git a/sdk/python/examples/15_login_and_account/sync.py b/sdk/python/examples/15_login_and_account/sync.py new file mode 100644 index 0000000000..ed20531186 --- /dev/null +++ b/sdk/python/examples/15_login_and_account/sync.py @@ -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) diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index fe5b630898..bef1a16c0b 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -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 diff --git a/sdk/python/examples/_bootstrap.py b/sdk/python/examples/_bootstrap.py index af115119ac..92fe18e26a 100644 --- a/sdk/python/examples/_bootstrap.py +++ b/sdk/python/examples/_bootstrap.py @@ -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}" diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index 62480fe18a..1eb80b3a71 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -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", diff --git a/sdk/python/src/openai_codex/__init__.py b/sdk/python/src/openai_codex/__init__.py index 4a68fabbef..b3db7e3175 100644 --- a/sdk/python/src/openai_codex/__init__.py +++ b/sdk/python/src/openai_codex/__init__.py @@ -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", diff --git a/sdk/python/src/openai_codex/_login.py b/sdk/python/src/openai_codex/_login.py new file mode 100644 index 0000000000..cdfdda41da --- /dev/null +++ b/sdk/python/src/openai_codex/_login.py @@ -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) diff --git a/sdk/python/src/openai_codex/_message_router.py b/sdk/python/src/openai_codex/_message_router.py index 7d4554bb8a..14a01336a4 100644 --- a/sdk/python/src/openai_codex/_message_router.py +++ b/sdk/python/src/openai_codex/_message_router.py @@ -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 diff --git a/sdk/python/src/openai_codex/_run.py b/sdk/python/src/openai_codex/_run.py index 73ec362460..ac9fa5ed38 100644 --- a/sdk/python/src/openai_codex/_run.py +++ b/sdk/python/src/openai_codex/_run.py @@ -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, diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index 0ea99f9b3d..347f95eeae 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -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 diff --git a/sdk/python/src/openai_codex/async_client.py b/sdk/python/src/openai_codex/async_client.py index 2c38a43087..282154b0cc 100644 --- a/sdk/python/src/openai_codex/async_client.py +++ b/sdk/python/src/openai_codex/async_client.py @@ -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) diff --git a/sdk/python/src/openai_codex/client.py b/sdk/python/src/openai_codex/client.py index a4b1e4acaf..6f2e2b8aac 100644 --- a/sdk/python/src/openai_codex/client.py +++ b/sdk/python/src/openai_codex/client.py @@ -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, diff --git a/sdk/python/src/openai_codex/types.py b/sdk/python/src/openai_codex/types.py index 995daa68b9..f5bbbc86c3 100644 --- a/sdk/python/src/openai_codex/types.py +++ b/sdk/python/src/openai_codex/types.py @@ -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", diff --git a/sdk/python/tests/app_server_harness.py b/sdk/python/tests/app_server_harness.py index 26fa8cf7ef..63de28d3af 100644 --- a/sdk/python/tests/app_server_harness.py +++ b/sdk/python/tests/app_server_harness.py @@ -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() ) diff --git a/sdk/python/tests/app_server_helpers.py b/sdk/python/tests/app_server_helpers.py index 10db00901c..af96a02e80 100644 --- a/sdk/python/tests/app_server_helpers.py +++ b/sdk/python/tests/app_server_helpers.py @@ -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 diff --git a/sdk/python/tests/test_app_server_lifecycle.py b/sdk/python/tests/test_app_server_lifecycle.py index 77ba269450..9636c708c2 100644 --- a/sdk/python/tests/test_app_server_lifecycle.py +++ b/sdk/python/tests/test_app_server_lifecycle.py @@ -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"], } diff --git a/sdk/python/tests/test_app_server_login.py b/sdk/python/tests/test_app_server_login.py new file mode 100644 index 0000000000..088310e5e8 --- /dev/null +++ b/sdk/python/tests/test_app_server_login.py @@ -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}", + } diff --git a/sdk/python/tests/test_app_server_run.py b/sdk/python/tests/test_app_server_run.py index a0ac3eca16..3f741e6ec6 100644 --- a/sdk/python/tests/test_app_server_run.py +++ b/sdk/python/tests/test_app_server_run.py @@ -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( diff --git a/sdk/python/tests/test_app_server_streaming.py b/sdk/python/tests/test_app_server_streaming.py index ae217adb6a..02d089f2f1 100644 --- a/sdk/python/tests/test_app_server_streaming.py +++ b/sdk/python/tests/test_app_server_streaming.py @@ -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", } diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index f13fb35c08..c7a00d3897 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -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: diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py index 7fcadcf534..08f07c986c 100644 --- a/sdk/python/tests/test_real_app_server_integration.py +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -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 diff --git a/sdk/typescript/src/items.ts b/sdk/typescript/src/items.ts index 182878f55b..8fd3b2c7ff 100644 --- a/sdk/typescript/src/items.ts +++ b/sdk/typescript/src/items.ts @@ -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. */