Skip to content

Commit 6d9dbac

Browse files
authored
feat: add provider-aware model fallback to thread start (#29942)
## Why Helper threads such as task title generation can request a model ID that is valid for the default OpenAI provider but unavailable from the active provider. With Amazon Bedrock, `gpt-5.4-mini` is rejected while the provider static catalog exposes Bedrock model IDs such as `openai.gpt-5.5` and `openai.gpt-5.4`. This causes repeated background 404s and can surface a misleading turn error even when the main turn succeeds. Clients need an explicit way to ask app-server to resolve an unavailable helper model to the active provider default. That fallback must remain limited to providers with an authoritative static catalog so custom or dynamically discovered model IDs are not rewritten based on an incomplete catalog. Fixes #28741. ## What changed - Add the experimental `allowProviderModelFallback` option to `thread/start`, defaulting to `false` to preserve existing behavior. - Thread the option through thread creation and model selection. - When enabled for a static model manager, preserve requested models present in the catalog and replace unavailable models with the provider default. - Continue preserving explicit model IDs for dynamic model managers without fetching a catalog solely to validate them. - Document the new `thread/start` behavior in the app-server API overview. ## Test Temporary test-client harness: ``` ThreadStartParams { model: Some("gpt-5.4-mini".to_string()), allow_provider_model_fallback: true, ..Default::default() } ``` Command: ``` CODEX_HOME=/tmp/codex-bedrock-thread-start-home \ CODEX_E2E_BEDROCK_THREAD_START_ONLY=1 \ ./target/debug/codex-app-server-test-client \ --codex-bin ./target/debug/codex \ -c 'model_provider="amazon-bedrock"' \ send-message-v2 --experimental-api ignored ``` Relevant output: ``` > "method": "thread/start", > "params": { > "model": "gpt-5.4-mini", > "modelProvider": null, > "allowProviderModelFallback": true, > ... > } < "result": { < "model": "openai.gpt-5.5", < "modelProvider": "amazon-bedrock", < ... < } ```
1 parent 2dec46e commit 6d9dbac

19 files changed

Lines changed: 327 additions & 7 deletions

File tree

codex-rs/app-server-protocol/src/protocol/v2/thread.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ pub struct ThreadStartParams {
5757
pub model: Option<String>,
5858
#[ts(optional = nullable)]
5959
pub model_provider: Option<String>,
60+
/// Allow a provider with an authoritative static model catalog to replace an unavailable
61+
/// requested model with its default.
62+
#[experimental("thread/start.allowProviderModelFallback")]
63+
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
64+
pub allow_provider_model_fallback: bool,
6065
#[serde(
6166
default,
6267
deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option",

codex-rs/app-server/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ Example with notification opt-out:
137137

138138
## API Overview
139139

140-
- `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.
140+
- `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.
141141
- `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`.
142142
- `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`.
143143
- `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.

codex-rs/app-server/src/request_processors/external_agent_session_import.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,11 @@ impl ExternalAgentSessionImporter {
184184
.map_err(|err| format!("failed to load imported session config: {err}"))?;
185185
let models_manager = self.thread_manager.get_models_manager();
186186
let model = models_manager
187-
.get_default_model(&config.model, RefreshStrategy::Offline)
187+
.get_default_model(
188+
&config.model,
189+
/*allow_provider_model_fallback*/ false,
190+
RefreshStrategy::Offline,
191+
)
188192
.await;
189193
let model_info = models_manager
190194
.get_model_info(model.as_str(), &config.to_models_manager_config())

codex-rs/app-server/src/request_processors/thread_processor.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,7 @@ impl ThreadRequestProcessor {
903903
let ThreadStartParams {
904904
model,
905905
model_provider,
906+
allow_provider_model_fallback,
906907
service_tier,
907908
cwd,
908909
runtime_workspace_roots,
@@ -979,6 +980,7 @@ impl ThreadRequestProcessor {
979980
thread_source.map(Into::into),
980981
environment_selections,
981982
service_name,
983+
allow_provider_model_fallback,
982984
experimental_raw_events,
983985
request_trace,
984986
)
@@ -1053,6 +1055,7 @@ impl ThreadRequestProcessor {
10531055
thread_source: Option<codex_protocol::protocol::ThreadSource>,
10541056
environments: Option<Vec<TurnEnvironmentSelection>>,
10551057
service_name: Option<String>,
1058+
allow_provider_model_fallback: bool,
10561059
experimental_raw_events: bool,
10571060
request_trace: Option<W3cTraceContext>,
10581061
) -> Result<(), JSONRPCErrorError> {
@@ -1160,6 +1163,7 @@ impl ThreadRequestProcessor {
11601163
.thread_manager
11611164
.start_thread_with_options(StartThreadOptions {
11621165
config,
1166+
allow_provider_model_fallback,
11631167
initial_history: match session_start_source
11641168
.unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup)
11651169
{

codex-rs/app-server/tests/suite/v2/skills_list.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
882882
.send_thread_start_request(ThreadStartParams {
883883
model: None,
884884
model_provider: None,
885+
allow_provider_model_fallback: false,
885886
service_tier: None,
886887
cwd: None,
887888
runtime_workspace_roots: None,

codex-rs/app-server/tests/suite/v2/thread_start.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,115 @@ use super::analytics::wait_for_analytics_payload;
5454
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
5555
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
5656

57+
async fn start_thread_with_model(
58+
mcp: &mut TestAppServer,
59+
model: &str,
60+
allow_provider_model_fallback: bool,
61+
) -> Result<ThreadStartResponse> {
62+
let request_id = mcp
63+
.send_thread_start_request_with_auto_env(ThreadStartParams {
64+
model: Some(model.to_string()),
65+
allow_provider_model_fallback,
66+
..Default::default()
67+
})
68+
.await?;
69+
let response: JSONRPCResponse = timeout(
70+
DEFAULT_READ_TIMEOUT,
71+
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
72+
)
73+
.await??;
74+
to_response(response)
75+
}
76+
77+
#[tokio::test]
78+
async fn thread_start_provider_model_fallback_applies_to_configured_model() -> Result<()> {
79+
let codex_home = TempDir::new()?;
80+
std::fs::write(
81+
codex_home.path().join("config.toml"),
82+
r#"model_provider = "amazon-bedrock"
83+
model = "gpt-5.4-mini"
84+
"#,
85+
)?;
86+
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
87+
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
88+
89+
let request_id = mcp
90+
.send_thread_start_request_with_auto_env(ThreadStartParams {
91+
allow_provider_model_fallback: true,
92+
..Default::default()
93+
})
94+
.await?;
95+
let response: JSONRPCResponse = timeout(
96+
DEFAULT_READ_TIMEOUT,
97+
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
98+
)
99+
.await??;
100+
let response: ThreadStartResponse = to_response(response)?;
101+
102+
assert_eq!(response.model, "openai.gpt-5.5");
103+
Ok(())
104+
}
105+
106+
#[tokio::test]
107+
async fn thread_start_provider_model_fallback_uses_bedrock_static_catalog() -> Result<()> {
108+
let codex_home = TempDir::new()?;
109+
std::fs::write(
110+
codex_home.path().join("config.toml"),
111+
r#"model_provider = "amazon-bedrock"
112+
"#,
113+
)?;
114+
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
115+
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
116+
117+
let unsupported_with_fallback = start_thread_with_model(
118+
&mut mcp,
119+
"gpt-5.4-mini",
120+
/*allow_provider_model_fallback*/ true,
121+
)
122+
.await?;
123+
let supported_with_fallback = start_thread_with_model(
124+
&mut mcp,
125+
"openai.gpt-5.4",
126+
/*allow_provider_model_fallback*/ true,
127+
)
128+
.await?;
129+
let unsupported_without_fallback = start_thread_with_model(
130+
&mut mcp,
131+
"gpt-5.4-mini",
132+
/*allow_provider_model_fallback*/ false,
133+
)
134+
.await?;
135+
136+
assert_eq!(
137+
vec![
138+
unsupported_with_fallback.model,
139+
supported_with_fallback.model,
140+
unsupported_without_fallback.model,
141+
],
142+
vec!["openai.gpt-5.5", "openai.gpt-5.4", "gpt-5.4-mini"]
143+
);
144+
Ok(())
145+
}
146+
147+
#[tokio::test]
148+
async fn thread_start_provider_model_fallback_ignores_dynamic_catalog() -> Result<()> {
149+
let server = create_mock_responses_server_repeating_assistant("Done").await;
150+
let codex_home = TempDir::new()?;
151+
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
152+
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
153+
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
154+
155+
let response = start_thread_with_model(
156+
&mut mcp,
157+
"unlisted-dynamic-model",
158+
/*allow_provider_model_fallback*/ true,
159+
)
160+
.await?;
161+
162+
assert_eq!(response.model, "unlisted-dynamic-model");
163+
Ok(())
164+
}
165+
57166
#[tokio::test]
58167
async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
59168
// Provide a mock server and config so model wiring is valid.

codex-rs/core/src/agent/control_tests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,6 +1462,7 @@ async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_li
14621462
.manager
14631463
.start_thread_with_options(StartThreadOptions {
14641464
config: harness.config.clone(),
1465+
allow_provider_model_fallback: false,
14651466
initial_history: InitialHistory::New,
14661467
session_source: None,
14671468
thread_source: None,
@@ -2250,6 +2251,7 @@ async fn spawn_thread_subagents_persist_parent_originator_across_new_and_truncat
22502251
.manager
22512252
.start_thread_with_options(StartThreadOptions {
22522253
config: harness.config.clone(),
2254+
allow_provider_model_fallback: false,
22532255
initial_history: InitialHistory::New,
22542256
session_source: None,
22552257
thread_source: None,

codex-rs/core/src/codex_delegate.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ pub(crate) async fn run_codex_thread_interactive(
8686
};
8787
let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs {
8888
config,
89+
allow_provider_model_fallback: false,
8990
user_instructions,
9091
installation_id: parent_session.installation_id.clone(),
9192
auth_manager,

codex-rs/core/src/session/mod.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,7 @@ pub struct CodexSpawnOk {
414414

415415
pub(crate) struct CodexSpawnArgs {
416416
pub(crate) config: Config,
417+
pub(crate) allow_provider_model_fallback: bool,
417418
pub(crate) user_instructions: LoadedUserInstructions,
418419
pub(crate) installation_id: String,
419420
pub(crate) auth_manager: Arc<AuthManager>,
@@ -503,6 +504,7 @@ impl Codex {
503504
async fn spawn_internal(args: CodexSpawnArgs) -> CodexResult<CodexSpawnOk> {
504505
let CodexSpawnArgs {
505506
mut config,
507+
allow_provider_model_fallback,
506508
user_instructions,
507509
installation_id,
508510
auth_manager,
@@ -576,8 +578,23 @@ impl Codex {
576578
let _ = models_manager.list_models(refresh_strategy).await;
577579
}
578580
let model = models_manager
579-
.get_default_model(&config.model, refresh_strategy)
581+
.get_default_model(
582+
&config.model,
583+
allow_provider_model_fallback,
584+
refresh_strategy,
585+
)
580586
.await;
587+
if allow_provider_model_fallback
588+
&& let Some(requested_model) = config.model.as_ref()
589+
&& model != *requested_model
590+
{
591+
info!(
592+
model_provider = %config.model_provider_id,
593+
requested_model,
594+
fallback_model = %model,
595+
"replaced unavailable requested model with provider default"
596+
);
597+
}
581598

582599
// Resolve base instructions for the session. Priority order:
583600
// 1. config.base_instructions override

codex-rs/core/src/session/tests/guardian_tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
718718

719719
let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
720720
config,
721+
allow_provider_model_fallback: false,
721722
user_instructions: Default::default(),
722723
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
723724
auth_manager,

0 commit comments

Comments
 (0)