Compare commits

...

5 Commits

Author SHA1 Message Date
jif-oai
cbbe3acdf4 feat: add feed char tool 2025-12-08 15:35:47 +00:00
gameofby
98923654d0 fix: refine the warning message and docs for deprecated tools config (#7685)
Issue #7661 revealed that users are confused by deprecation warnings
like:
> `tools.web_search` is deprecated. Use `web_search_request` instead.

This message misleadingly suggests renaming the config key from
`web_search` to `web_search_request`, when the actual required change is
to **move and rename the configuration from the `[tools]` section to the
`[features]` section**.

This PR clarifies the warning messages and documentation to make it
clear that deprecated `[tools]` configurations should be moved to
`[features]`. Changes made:
- Updated deprecation warning format in `codex-rs/core/src/codex.rs:520`
to include `[features].` prefix
- Updated corresponding test expectations in
`codex-rs/core/tests/suite/deprecation_notice.rs:39`
- Improved documentation in `docs/config.md` to clarify upfront that
`[tools]` options are deprecated in favor of `[features]`
2025-12-08 01:23:21 -08:00
Robby He
57ba9fa100 fix(doc): TOML otel exporter example — multi-line inline table is inv… (#7669)
…alid (#7668)

The `otel` exporter example in `docs/config.md` is misleading and will
cause
the configuration parser to fail if copied verbatim.

Summary
-------
The example uses a TOML inline table but spreads the inline-table braces
across multiple lines. TOML inline tables must be contained on a single
line
(`key = { a = 1, b = 2 }`); placing newlines inside the braces triggers
a
parse error in most TOML parsers and prevents Codex from starting.

Reproduction
------------
1. Paste the snippet below into `~/.codex/config.toml` (or your project
config).
2. Run `codex` (or the command that loads the config).
3. The process will fail to start with a TOML parse error similar to:

```text
Error loading config.toml: TOML parse error at line 55, column 27
   |
55 | exporter = { otlp-http = {
   |                           ^
newlines are unsupported in inline tables, expected nothing
```

Problematic snippet (as currently shown in the docs)
---------------------------------------------------
```toml
[otel]
exporter = { otlp-http = {
  endpoint = "https://otel.example.com/v1/logs",
  protocol = "binary",
  headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" }
}}
```

Recommended fixes
------------------
```toml
[otel.exporter."otlp-http"]
endpoint = "https://otel.example.com/v1/logs"
protocol = "binary"

[otel.exporter."otlp-http".headers]
"x-otlp-api-key" = "${OTLP_TOKEN}"
```

Or, keep an inline table but write it on one line (valid but less
readable):

```toml
[otel]
exporter = { "otlp-http" = { endpoint = "https://otel.example.com/v1/logs", protocol = "binary", headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" } } }
```
2025-12-08 01:20:23 -08:00
Eric Traut
acb8ed493f Fixed regression for chat endpoint; missing tools name caused litellm proxy to crash (#7724)
This PR addresses https://github.com/openai/codex/issues/7051
2025-12-08 00:49:51 -08:00
Ahmed Ibrahim
53a486f7ea Add remote models feature flag (#7648)
# External (non-OpenAI) Pull Request Requirements

Before opening this Pull Request, please read the dedicated
"Contributing" markdown file or your PR may be closed:
https://github.com/openai/codex/blob/main/docs/contributing.md

If your PR conforms to our contribution guidelines, replace this text
with a detailed and high quality description of your changes.

Include a link to a bug report or enhancement request.
2025-12-07 09:47:48 -08:00
19 changed files with 727 additions and 74 deletions

View File

@@ -181,12 +181,13 @@ mod tests {
"display_name": "gpt-test",
"description": "desc",
"default_reasoning_level": "medium",
"supported_reasoning_levels": ["low", "medium", "high"],
"supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}, {"effort": "high", "description": "high"}],
"shell_type": "shell_command",
"visibility": "list",
"minimal_client_version": [0, 99, 0],
"supported_in_api": true,
"priority": 1
"priority": 1,
"upgrade": null,
}))
.unwrap(),
],

View File

@@ -10,6 +10,7 @@ use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use http::HeaderMap;
use http::Method;
use wiremock::Mock;
@@ -57,15 +58,25 @@ async fn models_client_hits_models_endpoint() {
description: Some("desc".to_string()),
default_reasoning_level: ReasoningEffort::Medium,
supported_reasoning_levels: vec![
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffortPreset {
effort: ReasoningEffort::Low,
description: ReasoningEffort::Low.to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: ReasoningEffort::Medium.to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: ReasoningEffort::High.to_string(),
},
],
shell_type: ConfigShellToolType::ShellCommand,
visibility: ModelVisibility::List,
minimal_client_version: ClientVersion(0, 1, 0),
supported_in_api: true,
priority: 1,
upgrade: None,
}],
};

View File

@@ -536,7 +536,7 @@ impl Session {
for (alias, feature) in config.features.legacy_feature_usages() {
let canonical = feature.key();
let summary = format!("`{alias}` is deprecated. Use `{canonical}` instead.");
let summary = format!("`{alias}` is deprecated. Use `[features].{canonical}` instead.");
let details = if alias == canonical {
None
} else {
@@ -1470,6 +1470,16 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
let mut previous_context: Option<Arc<TurnContext>> =
Some(sess.new_turn(SessionSettingsUpdate::default()).await);
if config.features.enabled(Feature::RemoteModels)
&& let Err(err) = sess
.services
.models_manager
.refresh_available_models(&config.model_provider)
.await
{
error!("failed to refresh available models: {err}");
}
// To break out of this loop, send Op::Shutdown.
while let Ok(sub) = rx_sub.recv().await {
debug!(?sub, "Submission");

View File

@@ -40,6 +40,8 @@ pub enum Feature {
// Experimental
/// Use the single unified PTY-backed exec tool.
UnifiedExec,
/// Use the unified exec wrapper tool with named sessions.
UnifiedExecWrapper,
/// Enable experimental RMCP features such as OAuth login.
RmcpClient,
/// Include the freeform apply_patch tool.
@@ -54,6 +56,8 @@ pub enum Feature {
WindowsSandbox,
/// Remote compaction enabled (only for ChatGPT auth)
RemoteCompaction,
/// Refresh remote models and emit AppReady once the list is available.
RemoteModels,
/// Allow model to call multiple tools in parallel (only for models supporting it).
ParallelToolCalls,
/// Experimental skills injection (CLI flag-driven).
@@ -291,6 +295,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::UnifiedExecWrapper,
key: "unified_exec_wrapper",
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::RmcpClient,
key: "rmcp_client",
@@ -333,6 +343,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Experimental,
default_enabled: true,
},
FeatureSpec {
id: Feature::RemoteModels,
key: "remote_models",
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::ParallelToolCalls,
key: "parallel",

View File

@@ -291,6 +291,7 @@ mod tests {
use super::*;
use codex_protocol::openai_models::ClientVersion;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ReasoningEffortPreset;
fn remote(slug: &str, effort: ReasoningEffort, shell: ConfigShellToolType) -> ModelInfo {
ModelInfo {
@@ -298,12 +299,16 @@ mod tests {
display_name: slug.to_string(),
description: Some(format!("{slug} desc")),
default_reasoning_level: effort,
supported_reasoning_levels: vec![effort],
supported_reasoning_levels: vec![ReasoningEffortPreset {
effort,
description: effort.to_string(),
}],
shell_type: shell,
visibility: ModelVisibility::List,
minimal_client_version: ClientVersion(0, 1, 0),
supported_in_api: true,
priority: 1,
upgrade: None,
}
}

View File

@@ -36,7 +36,6 @@ impl ModelsManager {
}
}
// do not use this function yet. It's work in progress.
pub async fn refresh_available_models(
&self,
provider: &ModelProviderInfo,
@@ -47,16 +46,21 @@ impl ModelsManager {
let transport = ReqwestTransport::new(build_reqwest_client());
let client = ModelsClient::new(transport, api_provider, api_auth);
let mut client_version = env!("CARGO_PKG_VERSION");
if client_version == "0.0.0" {
client_version = "99.99.99";
}
let response = client
.list_models(env!("CARGO_PKG_VERSION"), HeaderMap::new())
.list_models(client_version, HeaderMap::new())
.await
.map_err(map_api_error)?;
let models = response.models;
*self.remote_models.write().await = models.clone();
let available_models = self.build_available_models().await;
{
let mut available_models_guard = self.available_models.write().await;
*available_models_guard = self.build_available_models().await;
*available_models_guard = available_models;
}
Ok(models)
}
@@ -75,8 +79,11 @@ impl ModelsManager {
async fn build_available_models(&self) -> Vec<ModelPreset> {
let mut available_models = self.remote_models.read().await.clone();
available_models.sort_by(|a, b| b.priority.cmp(&a.priority));
let mut model_presets: Vec<ModelPreset> =
available_models.into_iter().map(Into::into).collect();
let mut model_presets: Vec<ModelPreset> = available_models
.into_iter()
.map(Into::into)
.filter(|preset: &ModelPreset| preset.show_in_picker)
.collect();
if let Some(default) = model_presets.first_mut() {
default.is_default = true;
}
@@ -103,12 +110,13 @@ mod tests {
"display_name": display,
"description": format!("{display} desc"),
"default_reasoning_level": "medium",
"supported_reasoning_levels": ["low", "medium"],
"supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}],
"shell_type": "shell_command",
"visibility": "list",
"minimal_client_version": [0, 1, 0],
"supported_in_api": true,
"priority": priority
"priority": priority,
"upgrade": null,
}))
.expect("valid model")
}

View File

@@ -58,6 +58,28 @@ impl Shell {
}
}
}
pub fn login_command(&self) -> Vec<String> {
self.command_with_login(true)
}
pub fn non_login_command(&self) -> Vec<String> {
self.command_with_login(false)
}
fn command_with_login(&self, login_shell: bool) -> Vec<String> {
let shell_path = self.shell_path.to_string_lossy().to_string();
match self.shell_type {
ShellType::PowerShell | ShellType::Cmd => vec![shell_path],
ShellType::Zsh | ShellType::Bash | ShellType::Sh => {
if login_shell {
vec![shell_path, "-l".to_string()]
} else {
vec![shell_path]
}
}
}
}
}
#[cfg(unix)]
@@ -450,6 +472,17 @@ mod tests {
);
}
#[test]
fn shell_command_login_variants() {
let sh_shell = Shell {
shell_type: ShellType::Sh,
shell_path: PathBuf::from("/bin/sh"),
};
assert_eq!(sh_shell.login_command(), vec!["/bin/sh", "-l"]);
assert_eq!(sh_shell.non_login_command(), vec!["/bin/sh"]);
}
#[tokio::test]
async fn test_current_shell_detects_zsh() {
let shell = Command::new("sh")

View File

@@ -58,6 +58,28 @@ struct WriteStdinArgs {
max_output_tokens: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct NewSessionArgs {
session_name: String,
#[serde(default)]
workdir: Option<String>,
#[serde(default = "default_write_stdin_yield_time_ms")]
yield_time_ms: u64,
#[serde(default)]
max_output_tokens: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct FeedCharsArgs {
session_name: String,
#[serde(default)]
chars: String,
#[serde(default = "default_write_stdin_yield_time_ms")]
yield_time_ms: u64,
#[serde(default)]
max_output_tokens: Option<usize>,
}
fn default_exec_yield_time_ms() -> u64 {
10000
}
@@ -207,6 +229,87 @@ impl ToolHandler for UnifiedExecHandler {
FunctionCallError::RespondToModel(format!("exec_command failed: {err:?}"))
})?
}
"new_session" => {
let args: NewSessionArgs = serde_json::from_str(&arguments).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to parse new_session arguments: {err:?}"
))
})?;
if args.session_name.trim().is_empty() {
return Err(FunctionCallError::RespondToModel(
"session_name must not be empty".to_string(),
));
}
let process_id = manager.allocate_process_id().await;
if let Err(err) = manager
.register_session_name(&args.session_name, &process_id)
.await
{
manager.release_process_id(&process_id).await;
return Err(FunctionCallError::RespondToModel(err.to_string()));
}
let workdir = args
.workdir
.filter(|value| !value.is_empty())
.map(|dir| context.turn.resolve_path(Some(dir)));
let response = manager
.exec_command(
ExecCommandRequest {
command: session.user_shell().login_command(),
process_id,
yield_time_ms: args.yield_time_ms,
max_output_tokens: args.max_output_tokens,
workdir,
with_escalated_permissions: None,
justification: None,
},
&context,
)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("exec_command failed: {err:?}"))
})?;
if response.process_id.is_none() {
manager.clear_session_name(&args.session_name).await;
}
response
}
"feed_chars" => {
let args: FeedCharsArgs = serde_json::from_str(&arguments).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to parse feed_chars arguments: {err:?}"
))
})?;
let Some(process_id) = manager
.process_id_for_session_name(&args.session_name)
.await
else {
return Err(FunctionCallError::RespondToModel(format!(
"Session '{}' not found",
args.session_name
)));
};
manager
.write_stdin(WriteStdinRequest {
call_id: &call_id,
process_id: &process_id,
input: &args.chars,
yield_time_ms: args.yield_time_ms,
max_output_tokens: args.max_output_tokens,
})
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("write_stdin failed: {err:?}"))
})?
}
"write_stdin" => {
let args: WriteStdinArgs = serde_json::from_str(&arguments).map_err(|err| {
FunctionCallError::RespondToModel(format!(

View File

@@ -42,6 +42,8 @@ impl ToolsConfig {
let shell_type = if !features.enabled(Feature::ShellTool) {
ConfigShellToolType::Disabled
} else if features.enabled(Feature::UnifiedExecWrapper) {
ConfigShellToolType::UnifiedExecWrapper
} else if features.enabled(Feature::UnifiedExec) {
ConfigShellToolType::UnifiedExec
} else {
@@ -251,6 +253,98 @@ fn create_write_stdin_tool() -> ToolSpec {
})
}
fn create_new_session_tool() -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
"session_name".to_string(),
JsonSchema::String {
description: Some("Unique name for the session".to_string()),
},
);
properties.insert(
"workdir".to_string(),
JsonSchema::String {
description: Some(
"Optional working directory for the session; defaults to the turn cwd.".to_string(),
),
},
);
properties.insert(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) before returning the initial output."
.to_string(),
),
},
);
properties.insert(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated.".to_string(),
),
},
);
ToolSpec::Function(ResponsesApiTool {
name: "new_session".to_string(),
description: "Open a new interactive exec session in a container. Normally used for launching an interactive shell. Multiple sessions may be running at a time.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["session_name".to_string()]),
additional_properties: Some(false.into()),
},
})
}
fn create_feed_chars_tool() -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
"session_name".to_string(),
JsonSchema::String {
description: Some("Session to feed characters to".to_string()),
},
);
properties.insert(
"chars".to_string(),
JsonSchema::String {
description: Some("Characters to feed; may be empty".to_string()),
},
);
properties.insert(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for output before flushing STDOUT/STDERR."
.to_string(),
),
},
);
properties.insert(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated.".to_string(),
),
},
);
ToolSpec::Function(ResponsesApiTool {
name: "feed_chars".to_string(),
description:
"Feed characters to a session's STDIN, wait briefly, flush STDOUT/STDERR, and return the results."
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["session_name".to_string()]),
additional_properties: Some(false.into()),
},
})
}
fn create_shell_tool() -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
@@ -804,10 +898,16 @@ pub(crate) fn create_tools_json_for_chat_completions_api(
}
if let Some(map) = tool.as_object_mut() {
let name = map
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
// Remove "type" field as it is not needed in chat completions.
map.remove("type");
Some(json!({
"type": "function",
"name": name,
"function": map,
}))
} else {
@@ -1007,6 +1107,12 @@ pub(crate) fn build_specs(
builder.register_handler("exec_command", unified_exec_handler.clone());
builder.register_handler("write_stdin", unified_exec_handler);
}
ConfigShellToolType::UnifiedExecWrapper => {
builder.push_spec(create_new_session_tool());
builder.push_spec(create_feed_chars_tool());
builder.register_handler("new_session", unified_exec_handler.clone());
builder.register_handler("feed_chars", unified_exec_handler);
}
ConfigShellToolType::Disabled => {
// Do nothing.
}
@@ -1157,6 +1263,7 @@ mod tests {
ConfigShellToolType::Default => Some("shell"),
ConfigShellToolType::Local => Some("local_shell"),
ConfigShellToolType::UnifiedExec => None,
ConfigShellToolType::UnifiedExecWrapper => Some("unified_exec_wrapper"),
ConfigShellToolType::Disabled => None,
ConfigShellToolType::ShellCommand => Some("shell_command"),
}
@@ -2083,4 +2190,58 @@ Examples of valid command strings:
})
);
}
#[test]
fn chat_tools_include_top_level_name() {
let mut properties = BTreeMap::new();
properties.insert("foo".to_string(), JsonSchema::String { description: None });
let tools = vec![ToolSpec::Function(ResponsesApiTool {
name: "demo".to_string(),
description: "A demo tool".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: None,
},
})];
let responses_json = create_tools_json_for_responses_api(&tools).unwrap();
assert_eq!(
responses_json,
vec![json!({
"type": "function",
"name": "demo",
"description": "A demo tool",
"strict": false,
"parameters": {
"type": "object",
"properties": {
"foo": { "type": "string" }
},
},
})]
);
let tools_json = create_tools_json_for_chat_completions_api(&tools).unwrap();
assert_eq!(
tools_json,
vec![json!({
"type": "function",
"name": "demo",
"function": {
"name": "demo",
"description": "A demo tool",
"strict": false,
"parameters": {
"type": "object",
"properties": {
"foo": { "type": "string" }
},
},
}
})]
);
}
}

View File

@@ -109,17 +109,43 @@ pub(crate) struct UnifiedExecSessionManager {
pub(crate) struct SessionStore {
sessions: HashMap<String, SessionEntry>,
reserved_sessions_id: HashSet<String>,
session_names: HashMap<String, String>,
}
impl SessionStore {
fn remove(&mut self, session_id: &str) -> Option<SessionEntry> {
self.reserved_sessions_id.remove(session_id);
self.session_names.retain(|_, id| id != session_id);
self.sessions.remove(session_id)
}
pub(crate) fn clear(&mut self) {
self.reserved_sessions_id.clear();
self.sessions.clear();
self.session_names.clear();
}
fn process_id_for_name(&self, session_name: &str) -> Option<String> {
self.session_names.get(session_name).cloned()
}
fn insert_session_name(
&mut self,
session_name: &str,
process_id: &str,
) -> Result<(), UnifiedExecError> {
if self.session_names.contains_key(session_name) {
return Err(UnifiedExecError::create_session(format!(
"Session '{session_name}' already in use"
)));
}
self.session_names
.insert(session_name.to_string(), process_id.to_string());
Ok(())
}
fn clear_session_name(&mut self, session_name: &str) {
self.session_names.remove(session_name);
}
}
@@ -456,4 +482,45 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn session_names_track_process_ids() {
let (session, _turn) = test_session_and_turn();
let process_id = session
.services
.unified_exec_manager
.allocate_process_id()
.await;
session
.services
.unified_exec_manager
.register_session_name("default", &process_id)
.await
.expect("session name reserved");
pretty_assertions::assert_eq!(
session
.services
.unified_exec_manager
.process_id_for_session_name("default")
.await,
Some(process_id.clone())
);
session
.services
.unified_exec_manager
.release_process_id(&process_id)
.await;
assert!(
session
.services
.unified_exec_manager
.process_id_for_session_name("default")
.await
.is_none()
);
}
}

View File

@@ -116,6 +116,25 @@ impl UnifiedExecSessionManager {
store.remove(process_id);
}
pub(crate) async fn register_session_name(
&self,
session_name: &str,
process_id: &str,
) -> Result<(), UnifiedExecError> {
let mut store = self.session_store.lock().await;
store.insert_session_name(session_name, process_id)
}
pub(crate) async fn process_id_for_session_name(&self, session_name: &str) -> Option<String> {
let store = self.session_store.lock().await;
store.process_id_for_name(session_name)
}
pub(crate) async fn clear_session_name(&self, session_name: &str) {
let mut store = self.session_store.lock().await;
store.clear_session_name(session_name);
}
pub(crate) async fn exec_command(
&self,
request: ExecCommandRequest,

View File

@@ -36,7 +36,7 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<()
let DeprecationNoticeEvent { summary, details } = notice;
assert_eq!(
summary,
"`use_experimental_unified_exec_tool` is deprecated. Use `unified_exec` instead."
"`use_experimental_unified_exec_tool` is deprecated. Use `[features].unified_exec` instead."
.to_string(),
);
assert_eq!(

View File

@@ -41,6 +41,7 @@ mod otel;
mod prompt_caching;
mod quota_exceeded;
mod read_file;
mod remote_models;
mod resume;
mod review;
mod rmcp_client;

View File

@@ -0,0 +1,183 @@
#![cfg(not(target_os = "windows"))]
// unified exec is not supported on Windows OS
use std::sync::Arc;
use anyhow::Result;
use codex_core::features::Feature;
use codex_core::openai_models::models_manager::ModelsManager;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecCommandSource;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::openai_models::ClientVersion;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_models_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_sandbox;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use serde_json::json;
use tokio::time::Duration;
use tokio::time::Instant;
use tokio::time::sleep;
use wiremock::BodyPrintLimit;
use wiremock::MockServer;
const REMOTE_MODEL_SLUG: &str = "codex-test";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = MockServer::builder()
.body_print_limit(BodyPrintLimit::Limited(80_000))
.start()
.await;
let remote_model = ModelInfo {
slug: REMOTE_MODEL_SLUG.to_string(),
display_name: "Remote Test".to_string(),
description: Some("A remote model that requires the test shell".to_string()),
default_reasoning_level: ReasoningEffort::Medium,
supported_reasoning_levels: vec![ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: ReasoningEffort::Medium.to_string(),
}],
shell_type: ConfigShellToolType::UnifiedExec,
visibility: ModelVisibility::List,
minimal_client_version: ClientVersion(0, 1, 0),
supported_in_api: true,
priority: 1,
upgrade: None,
};
let models_mock = mount_models_once(
&server,
ModelsResponse {
models: vec![remote_model],
},
)
.await;
let mut builder = test_codex().with_config(|config| {
config.features.enable(Feature::RemoteModels);
config.model = "gpt-5.1".to_string();
});
let TestCodex {
codex,
cwd,
config,
conversation_manager,
..
} = builder.build(&server).await?;
let models_manager = conversation_manager.get_models_manager();
let available_model = wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG).await;
assert_eq!(available_model.model, REMOTE_MODEL_SLUG);
let requests = models_mock.requests();
assert_eq!(
requests.len(),
1,
"expected a single /models refresh request for the remote models feature"
);
assert_eq!(requests[0].url.path(), "/v1/models");
let family = models_manager
.construct_model_family(REMOTE_MODEL_SLUG, &config)
.await;
assert_eq!(family.shell_type, ConfigShellToolType::UnifiedExec);
codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: Some(REMOTE_MODEL_SLUG.to_string()),
effort: None,
summary: None,
})
.await?;
let call_id = "call";
let args = json!({
"cmd": "/bin/echo call",
"yield_time_ms": 250,
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
];
mount_sse_sequence(&server, responses).await;
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "run call".into(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: REMOTE_MODEL_SLUG.to_string(),
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
let begin_event = wait_for_event_match(&codex, |msg| match msg {
EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()),
_ => None,
})
.await;
assert_eq!(begin_event.source, ExecCommandSource::UnifiedExecStartup);
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
Ok(())
}
async fn wait_for_model_available(manager: &Arc<ModelsManager>, slug: &str) -> ModelPreset {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
if let Some(model) = {
let guard = manager.available_models.read().await;
guard.iter().find(|model| model.model == slug).cloned()
} {
return model;
}
if Instant::now() >= deadline {
panic!("timed out waiting for the remote model {slug} to appear");
}
sleep(Duration::from_millis(25)).await;
}
}

View File

@@ -296,6 +296,7 @@ async fn collect_tools(use_unified_exec: bool) -> Result<Vec<String>> {
} else {
config.features.disable(Feature::UnifiedExec);
}
config.features.disable(Feature::UnifiedExecWrapper);
});
let test = builder.build(&server).await?;

View File

@@ -3,6 +3,7 @@ use std::collections::HashMap;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use strum::IntoEnumIterator;
use strum_macros::Display;
use strum_macros::EnumIter;
use ts_rs::TS;
@@ -36,7 +37,7 @@ pub enum ReasoningEffort {
}
/// A reasoning effort option that can be surfaced for a model.
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
pub struct ReasoningEffortPreset {
/// Effort level that the model supports.
pub effort: ReasoningEffort,
@@ -107,6 +108,7 @@ pub enum ConfigShellToolType {
Default,
Local,
UnifiedExec,
UnifiedExecWrapper,
Disabled,
ShellCommand,
}
@@ -123,7 +125,7 @@ pub struct ModelInfo {
#[serde(default)]
pub description: Option<String>,
pub default_reasoning_level: ReasoningEffort,
pub supported_reasoning_levels: Vec<ReasoningEffort>,
pub supported_reasoning_levels: Vec<ReasoningEffortPreset>,
pub shell_type: ConfigShellToolType,
#[serde(default = "default_visibility")]
pub visibility: ModelVisibility,
@@ -132,6 +134,8 @@ pub struct ModelInfo {
pub supported_in_api: bool,
#[serde(default)]
pub priority: i32,
#[serde(default)]
pub upgrade: Option<String>,
}
/// Response wrapper for `/models`.
@@ -149,22 +153,57 @@ impl From<ModelInfo> for ModelPreset {
fn from(info: ModelInfo) -> Self {
ModelPreset {
id: info.slug.clone(),
model: info.slug,
model: info.slug.clone(),
display_name: info.display_name,
description: info.description.unwrap_or_default(),
default_reasoning_effort: info.default_reasoning_level,
supported_reasoning_efforts: info
.supported_reasoning_levels
.into_iter()
.map(|level| ReasoningEffortPreset {
effort: level,
// todo: add description for each reasoning effort
description: level.to_string(),
})
.collect(),
supported_reasoning_efforts: info.supported_reasoning_levels.clone(),
is_default: false, // default is the highest priority available model
upgrade: None, // no upgrade available (todo: think about it)
upgrade: info.upgrade.as_ref().map(|upgrade_slug| ModelUpgrade {
id: upgrade_slug.clone(),
reasoning_effort_mapping: reasoning_effort_mapping_from_presets(
&info.supported_reasoning_levels,
),
migration_config_key: info.slug.clone(),
}),
show_in_picker: info.visibility == ModelVisibility::List,
}
}
}
fn reasoning_effort_mapping_from_presets(
presets: &[ReasoningEffortPreset],
) -> Option<HashMap<ReasoningEffort, ReasoningEffort>> {
if presets.is_empty() {
return None;
}
// Map every canonical effort to the closest supported effort for the new model.
let supported: Vec<ReasoningEffort> = presets.iter().map(|p| p.effort).collect();
let mut map = HashMap::new();
for effort in ReasoningEffort::iter() {
let nearest = nearest_effort(effort, &supported);
map.insert(effort, nearest);
}
Some(map)
}
fn effort_rank(effort: ReasoningEffort) -> i32 {
match effort {
ReasoningEffort::None => 0,
ReasoningEffort::Minimal => 1,
ReasoningEffort::Low => 2,
ReasoningEffort::Medium => 3,
ReasoningEffort::High => 4,
ReasoningEffort::XHigh => 5,
}
}
fn nearest_effort(target: ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort {
let target_rank = effort_rank(target);
supported
.iter()
.copied()
.min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs())
.unwrap_or(target)
}

View File

@@ -1348,7 +1348,7 @@ pub struct ReviewLineRange {
pub end: u32,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[derive(Debug, Clone, Copy, Display, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum ExecCommandSource {
Agent,

View File

@@ -350,6 +350,8 @@ Though using this option may also be necessary if you try to use Codex in enviro
### tools.\*
These `[tools]` configuration options are deprecated. Use `[features]` instead (see [Feature flags](#feature-flags)).
Use the optional `[tools]` table to toggle built-in tools that the agent may call. `web_search` stays off unless you opt in, while `view_image` is now enabled by default:
```toml
@@ -358,8 +360,6 @@ web_search = true # allow Codex to issue first-party web searches without prom
view_image = false # disable image uploads (they're enabled by default)
```
`web_search` is deprecated; use the `web_search_request` feature flag instead.
The `view_image` toggle is useful when you want to include screenshots or diagrams from your repo without pasting them manually. Codex still respects sandboxing: it can only attach files inside the workspace roots you allow.
### approval_presets
@@ -615,12 +615,12 @@ Set `otel.exporter` to control where events go:
endpoint, protocol, and headers your collector expects:
```toml
[otel]
exporter = { otlp-http = {
endpoint = "https://otel.example.com/v1/logs",
protocol = "binary",
headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" }
}}
[otel.exporter."otlp-http"]
endpoint = "https://otel.example.com/v1/logs"
protocol = "binary"
[otel.exporter."otlp-http".headers]
"x-otlp-api-key" = "${OTLP_TOKEN}"
```
- `otlp-grpc` streams OTLP log records over gRPC. Provide the endpoint and any
@@ -628,27 +628,24 @@ Set `otel.exporter` to control where events go:
```toml
[otel]
exporter = { otlp-grpc = {
endpoint = "https://otel.example.com:4317",
headers = { "x-otlp-meta" = "abc123" }
}}
exporter = { otlp-grpc = {endpoint = "https://otel.example.com:4317",headers = { "x-otlp-meta" = "abc123" }}}
```
Both OTLP exporters accept an optional `tls` block so you can trust a custom CA
or enable mutual TLS. Relative paths are resolved against `~/.codex/`:
```toml
[otel]
exporter = { otlp-http = {
endpoint = "https://otel.example.com/v1/logs",
protocol = "binary",
headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" },
tls = {
ca-certificate = "certs/otel-ca.pem",
client-certificate = "/etc/codex/certs/client.pem",
client-private-key = "/etc/codex/certs/client-key.pem",
}
}}
[otel.exporter."otlp-http"]
endpoint = "https://otel.example.com/v1/logs"
protocol = "binary"
[otel.exporter."otlp-http".headers]
"x-otlp-api-key" = "${OTLP_TOKEN}"
[otel.exporter."otlp-http".tls]
ca-certificate = "certs/otel-ca.pem"
client-certificate = "/etc/codex/certs/client.pem"
client-private-key = "/etc/codex/certs/client-key.pem"
```
If the exporter is `none` nothing is written anywhere; otherwise you must run or point to your

View File

@@ -341,30 +341,28 @@ environment = "dev"
exporter = "none"
# Example OTLP/HTTP exporter configuration
# [otel]
# exporter = { otlp-http = {
# endpoint = "https://otel.example.com/v1/logs",
# protocol = "binary", # "binary" | "json"
# headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" }
# }}
# [otel.exporter."otlp-http"]
# endpoint = "https://otel.example.com/v1/logs"
# protocol = "binary" # "binary" | "json"
# [otel.exporter."otlp-http".headers]
# "x-otlp-api-key" = "${OTLP_TOKEN}"
# Example OTLP/gRPC exporter configuration
# [otel]
# exporter = { otlp-grpc = {
# endpoint = "https://otel.example.com:4317",
# headers = { "x-otlp-meta" = "abc123" }
# }}
# [otel.exporter."otlp-grpc"]
# endpoint = "https://otel.example.com:4317",
# headers = { "x-otlp-meta" = "abc123" }
# Example OTLP exporter with mutual TLS
# [otel]
# exporter = { otlp-http = {
# endpoint = "https://otel.example.com/v1/logs",
# protocol = "binary",
# headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" },
# tls = {
# ca-certificate = "certs/otel-ca.pem",
# client-certificate = "/etc/codex/certs/client.pem",
# client-private-key = "/etc/codex/certs/client-key.pem",
# }
# }}
# [otel.exporter."otlp-http"]
# endpoint = "https://otel.example.com/v1/logs"
# protocol = "binary"
# [otel.exporter."otlp-http".headers]
# "x-otlp-api-key" = "${OTLP_TOKEN}"
# [otel.exporter."otlp-http".tls]
# ca-certificate = "certs/otel-ca.pem"
# client-certificate = "/etc/codex/certs/client.pem"
# client-private-key = "/etc/codex/certs/client-key.pem"
```