Skip to content

Commit f85d69a

Browse files
Add support for choosing a model with Codex. (#9859)
## Description <!-- Please remember to add your design buddy onto the PR for review, if it contains any UI changes! --> WISOTT! Codex will interrupt you with an interactive prompt if you choose a model that is 5.4 or below so we add some configuration on the client to set the necessary config to avoid that prompt. ## Testing <!-- How did you test this change? What automated tests did you add? If you didn't add any new tests, what's your justification for not adding any? --> Tested end to end that all the models work and don't prompt you. ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode
1 parent e75bf80 commit f85d69a

7 files changed

Lines changed: 178 additions & 22 deletions

File tree

app/src/ai/agent_sdk/driver.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ pub struct AgentDriverOptions {
238238
/// Selected execution harness for this run.
239239
pub selected_harness: Harness,
240240
/// Model ID for the selected harness. Only used for non-Oz harnesses.
241-
pub harness_model_id: Option<String>,
241+
pub third_party_harness_model_id: Option<String>,
242242
/// Whether to skip end-of-run snapshot upload.
243243
pub snapshot_disabled: Option<bool>,
244244
/// End-of-run snapshot upload timeout override.
@@ -308,6 +308,7 @@ pub struct AgentDriver {
308308
/// conversation's `parent_agent_id` field at register time so the
309309
/// streamer recognizes the child role in driver-hosted processes.
310310
parent_run_id: Option<String>,
311+
third_party_harness_model_id: Option<String>,
311312

312313
/// Async writer that records `file` declarations for paths the agent creates or edits
313314
/// via `RequestFileEdits`. `Some` only when `FeatureFlag::OzHandoff` is enabled, the run
@@ -504,7 +505,7 @@ impl AgentDriver {
504505
cloud_providers,
505506
environment,
506507
selected_harness,
507-
harness_model_id,
508+
third_party_harness_model_id,
508509
snapshot_disabled,
509510
snapshot_upload_timeout,
510511
snapshot_script_timeout,
@@ -559,7 +560,7 @@ impl AgentDriver {
559560
));
560561
env_vars.extend(harness_model_env_vars(
561562
selected_harness,
562-
harness_model_id.as_deref(),
563+
third_party_harness_model_id.as_deref(),
563564
));
564565

565566
// Signal to third-party harnesses (e.g. Claude Code) that we're in a sandbox
@@ -632,6 +633,7 @@ impl AgentDriver {
632633
.unwrap_or(snapshot::DEFAULT_DECLARATIONS_SCRIPT_TIMEOUT),
633634
run_conversation_id,
634635
parent_run_id: parent_run_id_for_self,
636+
third_party_harness_model_id,
635637
snapshot_file_writer,
636638
})
637639
}
@@ -1614,8 +1616,13 @@ impl AgentDriver {
16141616
}
16151617
};
16161618

1617-
let secrets = foreground
1618-
.spawn(|me, _| Arc::clone(&me.secrets))
1619+
let (secrets, third_party_harness_model_id) = foreground
1620+
.spawn(|me, _| {
1621+
(
1622+
Arc::clone(&me.secrets),
1623+
me.third_party_harness_model_id.clone(),
1624+
)
1625+
})
16191626
.await
16201627
.map_err(|_| AgentDriverError::InvalidRuntimeState)?;
16211628

@@ -1654,6 +1661,7 @@ impl AgentDriver {
16541661
resume,
16551662
&resolved_env_vars,
16561663
&resolved_mcp_servers,
1664+
third_party_harness_model_id.as_deref(),
16571665
)?
16581666
.into();
16591667

app/src/ai/agent_sdk/driver/harness/claude_code.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ impl ThirdPartyHarness for ClaudeHarness {
103103
resume: Option<ResumePayload>,
104104
resolved_env_vars: &HashMap<OsString, OsString>,
105105
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
106+
_third_party_harness_model_id: Option<&str>,
106107
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
107108
// Prepare the environment config files.
108109
prepare_claude_environment_config(working_dir, resolved_env_vars).map_err(|error| {

app/src/ai/agent_sdk/driver/harness/codex.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,15 @@ impl ThirdPartyHarness for CodexHarness {
8787
resume: Option<ResumePayload>,
8888
resolved_env_vars: &HashMap<OsString, OsString>,
8989
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
90+
third_party_harness_model_id: Option<&str>,
9091
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
9192
// Prepare the environment config files.
9293
prepare_codex_environment_config(
9394
working_dir,
9495
system_prompt,
9596
resolved_env_vars,
9697
resolved_mcp_servers,
98+
third_party_harness_model_id,
9799
)
98100
.map_err(|error| AgentDriverError::HarnessConfigSetupFailed {
99101
harness: self.cli_agent().command_prefix().to_owned(),
@@ -434,6 +436,15 @@ const CODEX_TRUST_LEVEL_TRUSTED: &str = "trusted";
434436
/// Top-level config key codex reads to override the built-in `openai` provider's base URL
435437
/// (codex `core/src/config/mod.rs`).
436438
const CODEX_OPENAI_BASE_URL_KEY: &str = "openai_base_url";
439+
const CODEX_MODEL_KEY: &str = "model";
440+
/// Target model for the `[notice.model_migrations]` table that suppresses Codex's
441+
/// "choose a newer model" upgrade prompt at session launch. We stamp this for any
442+
/// pinned model id (even when it already matches the target) so the unattended
443+
/// cloud run never blocks on the prompt.
444+
///
445+
/// TODO: Ideally, we would make this server-driven so we don't depend on a client
446+
/// release to change this.
447+
const CODEX_MODEL_MIGRATIONS_TARGET: &str = "gpt-5.4";
437448
/// US data-residency endpoint. Our OpenAI keys are issued under a US-residency project,
438449
/// which rejects requests to the global host with `401 incorrect_hostname`.
439450
/// TODO(REMOTE-1509): plumb a region-tagged auth secret instead of hardcoding the URL.
@@ -444,6 +455,7 @@ fn prepare_codex_environment_config(
444455
system_prompt: Option<&str>,
445456
resolved_env_vars: &HashMap<OsString, OsString>,
446457
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
458+
third_party_harness_model_id: Option<&str>,
447459
) -> Result<()> {
448460
let home_dir =
449461
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
@@ -462,6 +474,7 @@ fn prepare_codex_environment_config(
462474
&codex_dir.join(CODEX_CONFIG_TOML_FILE_NAME),
463475
working_dir,
464476
resolved_mcp_servers,
477+
third_party_harness_model_id,
465478
)?;
466479
Ok(())
467480
}
@@ -570,10 +583,14 @@ fn resolve_openai_api_key(resolved_env_vars: &HashMap<OsString, OsString>) -> Op
570583
/// set the projects to `trusted`.
571584
/// - base URL: set `openai_base_url = "<US data-residency endpoint>"` so we
572585
/// hit the regional host our API keys require.
586+
/// - model override: when a non-default `third_party_harness_model_id` is
587+
/// supplied, write the top-level `model` key so Codex pins the chosen model
588+
/// for new sessions.
573589
fn prepare_codex_config_toml(
574590
config_toml_path: &Path,
575591
working_dir: &Path,
576592
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
593+
third_party_harness_model_id: Option<&str>,
577594
) -> Result<()> {
578595
let existing = match fs::read_to_string(config_toml_path) {
579596
Ok(content) => content,
@@ -593,6 +610,7 @@ fn prepare_codex_config_toml(
593610
})?;
594611

595612
set_codex_openai_base_url(&mut doc, CODEX_OPENAI_BASE_URL);
613+
set_codex_model(&mut doc, third_party_harness_model_id);
596614

597615
let canonical = working_dir.canonicalize().with_context(|| {
598616
format!(
@@ -631,6 +649,45 @@ fn set_codex_openai_base_url(doc: &mut toml_edit::DocumentMut, base_url: &str) {
631649
doc[CODEX_OPENAI_BASE_URL_KEY] = toml_edit::value(base_url);
632650
}
633651

652+
fn set_codex_model(doc: &mut toml_edit::DocumentMut, third_party_harness_model_id: Option<&str>) {
653+
let Some(model_id) =
654+
third_party_harness_model_id.filter(|id| !id.is_empty() && *id != "default")
655+
else {
656+
return;
657+
};
658+
doc[CODEX_MODEL_KEY] = toml_edit::value(model_id);
659+
660+
// Codex's TUI prompts the user to upgrade older models on session launch even when
661+
// a `model` key has been pinned. Stamping a migration entry keyed on the chosen
662+
// model id suppresses that prompt for the unattended cloud run. We do this
663+
// unconditionally rather than enumerating a list of "old" models on the client:
664+
// mapping the migration target to itself (e.g. `gpt-5.4 = "gpt-5.4"`) is a no-op
665+
// for Codex, and keeping the client free of model-version knowledge means we
666+
// don't have to ship a client update every time Anthropic/OpenAI ages out a model.
667+
set_codex_model_migration(doc, model_id, CODEX_MODEL_MIGRATIONS_TARGET);
668+
}
669+
670+
fn set_codex_model_migration(
671+
doc: &mut toml_edit::DocumentMut,
672+
from_model_id: &str,
673+
to_model_id: &str,
674+
) {
675+
if !doc.contains_table("notice") {
676+
let mut notice_tbl = toml_edit::Table::new();
677+
notice_tbl.set_implicit(true);
678+
doc.insert("notice", toml_edit::Item::Table(notice_tbl));
679+
}
680+
let migrations_tbl = doc["notice"]
681+
.as_table_mut()
682+
.expect("notice table inserted above")
683+
.entry("model_migrations")
684+
.or_insert_with(toml_edit::table)
685+
.as_table_mut()
686+
.expect("model_migrations entry is a table");
687+
migrations_tbl.set_implicit(false);
688+
migrations_tbl[from_model_id] = toml_edit::value(to_model_id);
689+
}
690+
634691
/// Return immediate subdirectories of `dir` that contain a `.git`.
635692
fn find_child_git_repos(dir: &Path) -> Vec<std::path::PathBuf> {
636693
let Ok(entries) = fs::read_dir(dir) else {

app/src/ai/agent_sdk/driver/harness/codex_tests.rs

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ fn prepare_codex_config_toml_writes_fresh_config() {
178178
let working_dir = tmp.path().join("workspace/proj");
179179
fs::create_dir_all(&working_dir).unwrap();
180180

181-
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new()).unwrap();
181+
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None).unwrap();
182182

183183
let canonical = working_dir.canonicalize().unwrap();
184184
let key = canonical.to_string_lossy().into_owned();
@@ -202,7 +202,8 @@ fn prepare_codex_config_toml_preserves_unrelated_keys() {
202202
)
203203
.unwrap();
204204

205-
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new()).unwrap();
205+
// Pass `None` for the model id so the helper preserves the user's existing `model = ...`.
206+
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None).unwrap();
206207

207208
let canonical = working_dir.canonicalize().unwrap();
208209
let key = canonical.to_string_lossy().into_owned();
@@ -225,9 +226,9 @@ fn prepare_codex_config_toml_is_idempotent() {
225226
let working_dir = tmp.path().join("workspace");
226227
fs::create_dir_all(&working_dir).unwrap();
227228

228-
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new()).unwrap();
229+
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None).unwrap();
229230
let after_first = fs::read_to_string(&config_path).unwrap();
230-
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new()).unwrap();
231+
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None).unwrap();
231232
let after_second = fs::read_to_string(&config_path).unwrap();
232233

233234
assert_eq!(after_first, after_second);
@@ -256,7 +257,7 @@ fn prepare_codex_config_toml_upgrades_untrusted_entry() {
256257
)
257258
.unwrap();
258259

259-
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new()).unwrap();
260+
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None).unwrap();
260261

261262
let cfg = read_codex_config(&config_path);
262263
assert_eq!(
@@ -275,7 +276,7 @@ fn prepare_codex_config_toml_trusts_multiple_child_repos() {
275276
fs::create_dir_all(repo_a.join(".git")).unwrap();
276277
fs::create_dir_all(repo_b.join(".git")).unwrap();
277278

278-
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new()).unwrap();
279+
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None).unwrap();
279280

280281
let cfg = read_codex_config(&config_path);
281282
let projects = cfg["projects"].as_table().unwrap();
@@ -303,7 +304,7 @@ fn prepare_codex_config_toml_overwrites_stale_openai_base_url() {
303304
)
304305
.unwrap();
305306

306-
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new()).unwrap();
307+
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None).unwrap();
307308

308309
let cfg = read_codex_config(&config_path);
309310
assert_eq!(cfg["openai_base_url"].as_str(), Some(CODEX_OPENAI_BASE_URL));
@@ -327,7 +328,7 @@ fn write_codex_mcp_servers_cli_server() {
327328
},
328329
},
329330
)]);
330-
prepare_codex_config_toml(&config_path, &working_dir, &servers).unwrap();
331+
prepare_codex_config_toml(&config_path, &working_dir, &servers, None).unwrap();
331332

332333
let cfg = read_codex_config(&config_path);
333334
let mcp = &cfg["mcp_servers"]["my-mcp"];
@@ -358,7 +359,7 @@ fn write_codex_mcp_servers_sse_server() {
358359
},
359360
},
360361
)]);
361-
prepare_codex_config_toml(&config_path, &working_dir, &servers).unwrap();
362+
prepare_codex_config_toml(&config_path, &working_dir, &servers, None).unwrap();
362363

363364
let cfg = read_codex_config(&config_path);
364365
let mcp = &cfg["mcp_servers"]["remote-mcp"];
@@ -384,7 +385,7 @@ fn write_codex_mcp_servers_cli_server_with_cwd() {
384385
},
385386
},
386387
)]);
387-
prepare_codex_config_toml(&config_path, &working_dir, &servers).unwrap();
388+
prepare_codex_config_toml(&config_path, &working_dir, &servers, None).unwrap();
388389

389390
let cfg = read_codex_config(&config_path);
390391
let mcp = &cfg["mcp_servers"]["my-mcp"];
@@ -410,13 +411,100 @@ fn write_codex_mcp_servers_cli_server_without_cwd_omits_key() {
410411
},
411412
},
412413
)]);
413-
prepare_codex_config_toml(&config_path, &working_dir, &servers).unwrap();
414+
prepare_codex_config_toml(&config_path, &working_dir, &servers, None).unwrap();
414415

415416
let cfg = read_codex_config(&config_path);
416417
let mcp = &cfg["mcp_servers"]["my-mcp"];
417418
assert!(mcp.get("cwd").is_none());
418419
}
419420

421+
#[test]
422+
fn prepare_codex_config_toml_writes_model_when_specified() {
423+
// A non-default model id is written to the top-level `model` key so Codex pins it
424+
// for new sessions launched from this `~/.codex/config.toml`. Even for the
425+
// current target model, we stamp a self-referential migration entry so the
426+
// upgrade prompt is suppressed regardless of what the user selected.
427+
let tmp = TempDir::new().unwrap();
428+
let config_path = tmp.path().join("config.toml");
429+
let working_dir = tmp.path().join("workspace");
430+
fs::create_dir_all(&working_dir).unwrap();
431+
432+
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), Some("gpt-5.5"))
433+
.unwrap();
434+
435+
let cfg = read_codex_config(&config_path);
436+
assert_eq!(cfg["model"].as_str(), Some("gpt-5.5"));
437+
assert_eq!(
438+
cfg["notice"]["model_migrations"]["gpt-5.5"].as_str(),
439+
Some(CODEX_MODEL_MIGRATIONS_TARGET),
440+
);
441+
}
442+
443+
#[test]
444+
fn prepare_codex_config_toml_writes_model_migration_for_older_model() {
445+
// For an older model id, the migration entry maps it to the current target
446+
// so Codex's "choose a newer model" prompt is suppressed at session launch.
447+
let tmp = TempDir::new().unwrap();
448+
let config_path = tmp.path().join("config.toml");
449+
let working_dir = tmp.path().join("workspace");
450+
fs::create_dir_all(&working_dir).unwrap();
451+
452+
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), Some("gpt-5.2"))
453+
.unwrap();
454+
455+
let cfg = read_codex_config(&config_path);
456+
assert_eq!(cfg["model"].as_str(), Some("gpt-5.2"));
457+
assert_eq!(
458+
cfg["notice"]["model_migrations"]["gpt-5.2"].as_str(),
459+
Some(CODEX_MODEL_MIGRATIONS_TARGET),
460+
);
461+
}
462+
463+
#[test]
464+
fn prepare_codex_config_toml_skips_model_for_default_sentinel() {
465+
// The literal "default" sentinel means "let Codex pick its own default model";
466+
// we should NOT write a `model` key (or a migration entry) in that case.
467+
let tmp = TempDir::new().unwrap();
468+
let config_path = tmp.path().join("config.toml");
469+
let working_dir = tmp.path().join("workspace");
470+
fs::create_dir_all(&working_dir).unwrap();
471+
472+
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), Some("default"))
473+
.unwrap();
474+
475+
let cfg = read_codex_config(&config_path);
476+
assert!(
477+
cfg.get("model").is_none(),
478+
"`model` should not be written for the default sentinel"
479+
);
480+
assert!(
481+
cfg.get("notice").is_none(),
482+
"`[notice]` table should not be written without a pinned model id"
483+
);
484+
}
485+
486+
#[test]
487+
fn prepare_codex_config_toml_skips_model_when_none() {
488+
// No model id supplied means the user didn't pick one; we should not write a
489+
// `model` key or any `[notice.model_migrations]` entries.
490+
let tmp = TempDir::new().unwrap();
491+
let config_path = tmp.path().join("config.toml");
492+
let working_dir = tmp.path().join("workspace");
493+
fs::create_dir_all(&working_dir).unwrap();
494+
495+
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None).unwrap();
496+
497+
let cfg = read_codex_config(&config_path);
498+
assert!(
499+
cfg.get("model").is_none(),
500+
"`model` should not be written when no override is supplied"
501+
);
502+
assert!(
503+
cfg.get("notice").is_none(),
504+
"`[notice]` table should not be written without a pinned model id"
505+
);
506+
}
507+
420508
#[test]
421509
fn find_child_git_repos_returns_only_repo_children() {
422510
let tmp = TempDir::new().unwrap();

app/src/ai/agent_sdk/driver/harness/gemini.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ impl ThirdPartyHarness for GeminiHarness {
6161
_resume: Option<ResumePayload>,
6262
_resolved_env_vars: &HashMap<OsString, OsString>,
6363
_resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
64+
_third_party_harness_model_id: Option<&str>,
6465
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
6566
// Prepare the environment config files.
6667
prepare_gemini_environment_config(working_dir, system_prompt).map_err(|error| {

0 commit comments

Comments
 (0)