Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/v2/thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ pub struct ThreadStartParams {
pub model: Option<String>,
#[ts(optional = nullable)]
pub model_provider: Option<String>,
/// Allow a provider with an authoritative static model catalog to replace an unavailable
/// requested model with its default.
#[experimental("thread/start.allowProviderModelFallback")]
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub allow_provider_model_fallback: bool,
#[serde(
default,
deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option",
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/app-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Example with notification opt-out:

## API Overview

- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Deprecated experimental `multiAgentMode` is ignored; use Ultra reasoning effort for proactive multi-agent behavior. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are started in that environment, and HTTP MCP connections use that environment's HTTP client.
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `allowProviderModelFallback` lets providers backed by an authoritative static model catalog replace an unavailable requested `model` with the catalog default; dynamic or cached catalogs preserve the requested model. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Deprecated experimental `multiAgentMode` is ignored; use Ultra reasoning effort for proactive multi-agent behavior. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are started in that environment, and HTTP MCP connections use that environment's HTTP client.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,11 @@ impl ExternalAgentSessionImporter {
.map_err(|err| format!("failed to load imported session config: {err}"))?;
let models_manager = self.thread_manager.get_models_manager();
let model = models_manager
.get_default_model(&config.model, RefreshStrategy::Offline)
.get_default_model(
&config.model,
/*allow_provider_model_fallback*/ false,
RefreshStrategy::Offline,
)
.await;
let model_info = models_manager
.get_model_info(model.as_str(), &config.to_models_manager_config())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,7 @@ impl ThreadRequestProcessor {
let ThreadStartParams {
model,
model_provider,
allow_provider_model_fallback,
service_tier,
cwd,
runtime_workspace_roots,
Expand Down Expand Up @@ -979,6 +980,7 @@ impl ThreadRequestProcessor {
thread_source.map(Into::into),
environment_selections,
service_name,
allow_provider_model_fallback,
experimental_raw_events,
request_trace,
)
Expand Down Expand Up @@ -1053,6 +1055,7 @@ impl ThreadRequestProcessor {
thread_source: Option<codex_protocol::protocol::ThreadSource>,
environments: Option<Vec<TurnEnvironmentSelection>>,
service_name: Option<String>,
allow_provider_model_fallback: bool,
experimental_raw_events: bool,
request_trace: Option<W3cTraceContext>,
) -> Result<(), JSONRPCErrorError> {
Expand Down Expand Up @@ -1160,6 +1163,7 @@ impl ThreadRequestProcessor {
.thread_manager
.start_thread_with_options(StartThreadOptions {
config,
allow_provider_model_fallback,
initial_history: match session_start_source
.unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup)
{
Expand Down
1 change: 1 addition & 0 deletions codex-rs/app-server/tests/suite/v2/skills_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
.send_thread_start_request(ThreadStartParams {
model: None,
model_provider: None,
allow_provider_model_fallback: false,
service_tier: None,
cwd: None,
runtime_workspace_roots: None,
Expand Down
109 changes: 109 additions & 0 deletions codex-rs/app-server/tests/suite/v2/thread_start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,115 @@ use super::analytics::wait_for_analytics_payload;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;

async fn start_thread_with_model(
mcp: &mut TestAppServer,
model: &str,
allow_provider_model_fallback: bool,
) -> Result<ThreadStartResponse> {
let request_id = mcp
.send_thread_start_request_with_auto_env(ThreadStartParams {
model: Some(model.to_string()),
allow_provider_model_fallback,
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}

#[tokio::test]
async fn thread_start_provider_model_fallback_applies_to_configured_model() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"model_provider = "amazon-bedrock"
model = "gpt-5.4-mini"
"#,
)?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

let request_id = mcp
.send_thread_start_request_with_auto_env(ThreadStartParams {
allow_provider_model_fallback: true,
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ThreadStartResponse = to_response(response)?;

assert_eq!(response.model, "openai.gpt-5.5");
Ok(())
}

#[tokio::test]
async fn thread_start_provider_model_fallback_uses_bedrock_static_catalog() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"model_provider = "amazon-bedrock"
"#,
)?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

let unsupported_with_fallback = start_thread_with_model(
&mut mcp,
"gpt-5.4-mini",
/*allow_provider_model_fallback*/ true,
)
.await?;
let supported_with_fallback = start_thread_with_model(
&mut mcp,
"openai.gpt-5.4",
/*allow_provider_model_fallback*/ true,
)
.await?;
let unsupported_without_fallback = start_thread_with_model(
&mut mcp,
"gpt-5.4-mini",
/*allow_provider_model_fallback*/ false,
)
.await?;

assert_eq!(
vec![
unsupported_with_fallback.model,
supported_with_fallback.model,
unsupported_without_fallback.model,
],
vec!["openai.gpt-5.5", "openai.gpt-5.4", "gpt-5.4-mini"]
);
Ok(())
}

#[tokio::test]
async fn thread_start_provider_model_fallback_ignores_dynamic_catalog() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

let response = start_thread_with_model(
&mut mcp,
"unlisted-dynamic-model",
/*allow_provider_model_fallback*/ true,
)
.await?;

assert_eq!(response.model, "unlisted-dynamic-model");
Ok(())
}

#[tokio::test]
async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
// Provide a mock server and config so model wiring is valid.
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/core/src/agent/control_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1462,6 +1462,7 @@ async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_li
.manager
.start_thread_with_options(StartThreadOptions {
config: harness.config.clone(),
allow_provider_model_fallback: false,
initial_history: InitialHistory::New,
session_source: None,
thread_source: None,
Expand Down Expand Up @@ -2250,6 +2251,7 @@ async fn spawn_thread_subagents_persist_parent_originator_across_new_and_truncat
.manager
.start_thread_with_options(StartThreadOptions {
config: harness.config.clone(),
allow_provider_model_fallback: false,
initial_history: InitialHistory::New,
session_source: None,
thread_source: None,
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/codex_delegate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ pub(crate) async fn run_codex_thread_interactive(
};
let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs {
config,
allow_provider_model_fallback: false,
user_instructions,
installation_id: parent_session.installation_id.clone(),
auth_manager,
Expand Down
19 changes: 18 additions & 1 deletion codex-rs/core/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ pub struct CodexSpawnOk {

pub(crate) struct CodexSpawnArgs {
pub(crate) config: Config,
pub(crate) allow_provider_model_fallback: bool,
pub(crate) user_instructions: LoadedUserInstructions,
pub(crate) installation_id: String,
pub(crate) auth_manager: Arc<AuthManager>,
Expand Down Expand Up @@ -503,6 +504,7 @@ impl Codex {
async fn spawn_internal(args: CodexSpawnArgs) -> CodexResult<CodexSpawnOk> {
let CodexSpawnArgs {
mut config,
allow_provider_model_fallback,
user_instructions,
installation_id,
auth_manager,
Expand Down Expand Up @@ -576,8 +578,23 @@ impl Codex {
let _ = models_manager.list_models(refresh_strategy).await;
}
let model = models_manager
.get_default_model(&config.model, refresh_strategy)
.get_default_model(
&config.model,
allow_provider_model_fallback,
refresh_strategy,
)
.await;
if allow_provider_model_fallback
&& let Some(requested_model) = config.model.as_ref()
&& model != *requested_model
{
info!(
model_provider = %config.model_provider_id,
requested_model,
fallback_model = %model,
"replaced unavailable requested model with provider default"
);
}

// Resolve base instructions for the session. Priority order:
// 1. config.base_instructions override
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/session/tests/guardian_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {

let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
config,
allow_provider_model_fallback: false,
user_instructions: Default::default(),
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
auth_manager,
Expand Down
11 changes: 11 additions & 0 deletions codex-rs/core/src/thread_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ pub struct ThreadManager {

pub struct StartThreadOptions {
pub config: Config,
pub allow_provider_model_fallback: bool,
pub initial_history: InitialHistory,
pub session_source: Option<SessionSource>,
pub thread_source: Option<ThreadSource>,
Expand Down Expand Up @@ -632,6 +633,7 @@ impl ThreadManager {
);
Box::pin(self.start_thread_with_options(StartThreadOptions {
config,
allow_provider_model_fallback: false,
initial_history: InitialHistory::New,
session_source: None,
thread_source: None,
Expand Down Expand Up @@ -668,6 +670,7 @@ impl ThreadManager {
Box::pin(self.state.spawn_thread_with_source(
options.config,
options.initial_history,
options.allow_provider_model_fallback,
Arc::clone(&self.state.auth_manager),
agent_control,
session_source,
Expand Down Expand Up @@ -763,6 +766,7 @@ impl ThreadManager {
Box::pin(self.state.spawn_thread_with_source(
config,
initial_history,
/*allow_provider_model_fallback*/ false,
auth_manager,
agent_control,
session_source,
Expand Down Expand Up @@ -832,6 +836,7 @@ impl ThreadManager {
Box::pin(self.state.spawn_thread_with_source(
config,
initial_history,
/*allow_provider_model_fallback*/ false,
auth_manager,
agent_control,
session_source,
Expand Down Expand Up @@ -1332,6 +1337,7 @@ impl ThreadManagerState {
Box::pin(self.spawn_thread_with_source(
config,
InitialHistory::New,
/*allow_provider_model_fallback*/ false,
Arc::clone(&self.auth_manager),
agent_control,
session_source,
Expand Down Expand Up @@ -1370,6 +1376,7 @@ impl ThreadManagerState {
Box::pin(self.spawn_thread_with_source(
config,
initial_history,
/*allow_provider_model_fallback*/ false,
Arc::clone(&self.auth_manager),
agent_control,
session_source,
Expand Down Expand Up @@ -1410,6 +1417,7 @@ impl ThreadManagerState {
Box::pin(self.spawn_thread_with_source(
config,
initial_history,
/*allow_provider_model_fallback*/ false,
Arc::clone(&self.auth_manager),
agent_control,
session_source,
Expand Down Expand Up @@ -1451,6 +1459,7 @@ impl ThreadManagerState {
Box::pin(self.spawn_thread_with_source(
config,
initial_history,
/*allow_provider_model_fallback*/ false,
auth_manager,
agent_control,
self.session_source.clone(),
Expand All @@ -1475,6 +1484,7 @@ impl ThreadManagerState {
&self,
config: Config,
initial_history: InitialHistory,
allow_provider_model_fallback: bool,
auth_manager: Arc<AuthManager>,
agent_control: AgentControl,
session_source: SessionSource,
Expand Down Expand Up @@ -1541,6 +1551,7 @@ impl ThreadManagerState {
codex, thread_id, ..
} = Box::pin(Codex::spawn(CodexSpawnArgs {
config,
allow_provider_model_fallback,
user_instructions,
installation_id: self.installation_id.clone(),
auth_manager,
Expand Down
Loading
Loading