[codex] Make new-thread model defaults scope-aware

This commit is contained in:
Joe Florencio
2026-06-25 19:53:03 -07:00
parent 0f3877f5b3
commit 82972b8d6b
22 changed files with 468 additions and 83 deletions
@@ -4302,6 +4302,13 @@
"null"
]
},
"modelPolicyScope": {
"description": "Stable model-policy scope used to resolve already-loaded new-thread defaults.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"type": [
"string",
@@ -12801,16 +12801,15 @@
"ModelsRequirements": {
"properties": {
"newThread": {
"anyOf": [
{
"$ref": "#/definitions/v2/NewThreadModelDefaults"
},
{
"type": "null"
}
]
"additionalProperties": {
"$ref": "#/definitions/v2/NewThreadModelDefaults"
},
"type": "object"
}
},
"required": [
"newThread"
],
"type": "object"
},
"MultiAgentMode": {
@@ -19318,6 +19317,13 @@
"null"
]
},
"modelPolicyScope": {
"description": "Stable model-policy scope used to resolve already-loaded new-thread defaults.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"type": [
"string",
@@ -9205,16 +9205,15 @@
"ModelsRequirements": {
"properties": {
"newThread": {
"anyOf": [
{
"$ref": "#/definitions/NewThreadModelDefaults"
},
{
"type": "null"
}
]
"additionalProperties": {
"$ref": "#/definitions/NewThreadModelDefaults"
},
"type": "object"
}
},
"required": [
"newThread"
],
"type": "object"
},
"MultiAgentMode": {
@@ -17097,6 +17096,13 @@
"null"
]
},
"modelPolicyScope": {
"description": "Stable model-policy scope used to resolve already-loaded new-thread defaults.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"type": [
"string",
@@ -375,16 +375,15 @@
"ModelsRequirements": {
"properties": {
"newThread": {
"anyOf": [
{
"$ref": "#/definitions/NewThreadModelDefaults"
},
{
"type": "null"
}
]
"additionalProperties": {
"$ref": "#/definitions/NewThreadModelDefaults"
},
"type": "object"
}
},
"required": [
"newThread"
],
"type": "object"
},
"NetworkDomainPermission": {
@@ -326,6 +326,13 @@
"null"
]
},
"modelPolicyScope": {
"description": "Stable model-policy scope used to resolve already-loaded new-thread defaults.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"type": [
"string",
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { NewThreadModelDefaults } from "./NewThreadModelDefaults";
export type ModelsRequirements = { newThread: NewThreadModelDefaults | null, };
export type ModelsRequirements = { newThread: { [key in string]?: NewThreadModelDefaults }, };
@@ -9,7 +9,10 @@ import type { SandboxMode } from "./SandboxMode";
import type { ThreadSource } from "./ThreadSource";
import type { ThreadStartSource } from "./ThreadStartSource";
export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /**
export type ThreadStartParams = {model?: string | null, /**
* Stable model-policy scope used to resolve already-loaded new-thread defaults.
*/
modelPolicyScope?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /**
* Override where approval requests are routed for review on this thread
* and subsequent turns.
*/
@@ -397,7 +397,7 @@ pub struct ConfigRequirements {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ModelsRequirements {
pub new_thread: Option<NewThreadModelDefaults>,
pub new_thread: BTreeMap<String, NewThreadModelDefaults>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -3690,6 +3690,19 @@ fn thread_start_params_preserve_explicit_null_service_tier() {
assert_eq!(serialized_without_override.get("serviceTier"), None);
}
#[test]
fn thread_start_params_accept_model_policy_scope() {
let params: ThreadStartParams = serde_json::from_value(json!({
"modelPolicyScope": "codex.thread.coding",
}))
.expect("params should deserialize");
assert_eq!(
params.model_policy_scope.as_deref(),
Some("codex.thread.coding")
);
}
#[test]
fn thread_lifecycle_responses_default_missing_optional_fields() {
let response = json!({
@@ -55,6 +55,9 @@ pub enum ThreadStartSource {
pub struct ThreadStartParams {
#[ts(optional = nullable)]
pub model: Option<String>,
/// Stable model-policy scope used to resolve already-loaded new-thread defaults.
#[ts(optional = nullable)]
pub model_policy_scope: Option<String>,
#[ts(optional = nullable)]
pub model_provider: Option<String>,
#[serde(
+2 -2
View File
@@ -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`. Experimental `multiAgentMode` selects the initial thread mode and defaults to `explicitRequestOnly` when omitted; use `none` to keep multi-agent tools available without injecting mode instructions. 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 also started in that environment; HTTP MCP declarations remain inactive.
- `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. `modelPolicyScope` selects already-loaded, scope-specific model defaults for this new thread; explicit request values still win, and omitting the field preserves existing behavior. 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`. Experimental `multiAgentMode` selects the initial thread mode and defaults to `explicitRequestOnly` when omitted; use `none` to keep multi-agent tools available without injecting mode instructions. 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 also started in that environment; HTTP MCP declarations remain inactive.
- `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`. Multi-agent mode restores the last effective mode from rollout history when available; clients can select another mode on the first `turn/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 experimental `multiAgentMode` field, and the corresponding thread setting, report the thread's current mode. Turn construction separately determines whether that mode is applicable to the selected model and runtime configuration.
@@ -242,7 +242,7 @@ Example with notification opt-out:
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately.
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, managed new-thread defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, scope-indexed managed new-thread defaults (`models.newThread[scopeId].model`, `modelReasoningEffort`, and `serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread
@@ -378,11 +378,20 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
.map(map_residency_requirement_to_api),
network: requirements.network.map(map_network_requirements_to_api),
models: requirements.models.map(|models| ModelsRequirements {
new_thread: models.new_thread.map(|new_thread| NewThreadModelDefaults {
model: new_thread.model,
model_reasoning_effort: new_thread.model_reasoning_effort,
service_tier: new_thread.service_tier,
}),
new_thread: models
.new_thread
.into_iter()
.map(|(scope_id, new_thread)| {
(
scope_id,
NewThreadModelDefaults {
model: new_thread.model,
model_reasoning_effort: new_thread.model_reasoning_effort,
service_tier: new_thread.service_tier,
},
)
})
.collect(),
}),
}
}
@@ -644,18 +653,21 @@ mod tests {
fn requirements_api_includes_new_thread_model_defaults() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
models: Some(ModelsRequirementsToml {
new_thread: Some(NewThreadModelDefaultsToml {
model: Some("gpt-managed".to_string()),
model_reasoning_effort: Some(ReasoningEffort::Medium),
service_tier: Some("fast".to_string()),
}),
new_thread: BTreeMap::from([(
"codex.thread.coding".to_string(),
NewThreadModelDefaultsToml {
model: Some("gpt-managed".to_string()),
model_reasoning_effort: Some(ReasoningEffort::Medium),
service_tier: Some("fast".to_string()),
},
)]),
}),
..ConfigRequirementsToml::default()
});
let defaults = mapped
.models
.and_then(|models| models.new_thread)
.and_then(|models| models.new_thread.get("codex.thread.coding").cloned())
.expect("new-thread defaults");
assert_eq!(defaults.model.as_deref(), Some("gpt-managed"));
assert_eq!(
@@ -3,6 +3,7 @@ use crate::error_code::method_not_found;
use codex_app_server_protocol::SelectedCapabilityRoot;
use codex_extension_api::ExtensionDataInit;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
@@ -195,6 +196,12 @@ fn has_model_resume_override(
.is_some_and(|overrides| overrides.contains_key("model_reasoning_effort"))
}
fn normalize_service_tier(service_tier: String) -> String {
ServiceTier::from_request_value(&service_tier)
.map(|tier| tier.request_value().to_string())
.unwrap_or(service_tier)
}
fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> {
const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128;
const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64;
@@ -886,6 +893,7 @@ impl ThreadRequestProcessor {
) -> Result<(), JSONRPCErrorError> {
let ThreadStartParams {
model,
model_policy_scope,
model_provider,
service_tier,
cwd,
@@ -957,6 +965,7 @@ impl ThreadRequestProcessor {
supports_openai_form_elicitation,
config,
typesafe_overrides,
model_policy_scope,
multi_agent_mode,
dynamic_tools,
selected_capability_roots.unwrap_or_default(),
@@ -1032,6 +1041,7 @@ impl ThreadRequestProcessor {
supports_openai_form_elicitation: bool,
config_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
model_policy_scope: Option<String>,
multi_agent_mode: Option<MultiAgentMode>,
dynamic_tools: Option<Vec<DynamicToolSpec>>,
selected_capability_roots: Vec<SelectedCapabilityRoot>,
@@ -1044,6 +1054,26 @@ impl ThreadRequestProcessor {
) -> Result<(), JSONRPCErrorError> {
let thread_start_started_at = std::time::Instant::now();
let requested_cwd = typesafe_overrides.cwd.clone();
let current_cli_overrides = config_manager.current_cli_overrides();
let has_cli_override = |key: &str| {
current_cli_overrides
.iter()
.any(|(path, _value)| path == key)
};
let has_explicit_model = has_cli_override("model")
|| typesafe_overrides.model.is_some()
|| config_overrides
.as_ref()
.is_some_and(|overrides| overrides.contains_key("model"));
let has_explicit_reasoning_effort = has_cli_override("model_reasoning_effort")
|| config_overrides
.as_ref()
.is_some_and(|overrides| overrides.contains_key("model_reasoning_effort"));
let has_explicit_service_tier = has_cli_override("service_tier")
|| typesafe_overrides.service_tier.is_some()
|| config_overrides
.as_ref()
.is_some_and(|overrides| overrides.contains_key("service_tier"));
let mut config = config_manager
.load_with_overrides(config_overrides.clone(), typesafe_overrides.clone())
.await
@@ -1068,7 +1098,6 @@ impl ThreadRequestProcessor {
let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd)
.await
.unwrap_or_else(|| config.cwd.clone());
let current_cli_overrides = config_manager.current_cli_overrides();
let cli_overrides_with_trust;
let cli_overrides_for_reload = if let Err(err) =
codex_core::config::set_project_trust_level(
@@ -1114,6 +1143,41 @@ impl ThreadRequestProcessor {
.map_err(|err| config_load_error(&err))?;
}
if let Some(scope_id) = model_policy_scope.as_deref() {
if let Some(defaults) = config.new_thread_model_defaults.get(scope_id).cloned() {
if config.model.is_none() {
config.model = defaults.model;
}
if config.model_reasoning_effort.is_none() {
config.model_reasoning_effort = defaults.model_reasoning_effort;
}
if config.service_tier.is_none() {
config.service_tier = defaults.service_tier.map(normalize_service_tier);
}
}
if let Some(defaults) = config
.config_layer_stack
.requirements_toml()
.models
.as_ref()
.and_then(|models| models.new_thread.get(scope_id))
.cloned()
{
if !has_explicit_model && let Some(model) = defaults.model {
config.model = Some(model);
}
if !has_explicit_reasoning_effort
&& let Some(reasoning_effort) = defaults.model_reasoning_effort
{
config.model_reasoning_effort = Some(reasoning_effort);
}
if !has_explicit_service_tier && let Some(service_tier) = defaults.service_tier {
config.service_tier = Some(normalize_service_tier(service_tier));
}
}
}
let environments = environments.unwrap_or_else(|| {
listener_task_context
.thread_manager
@@ -81,7 +81,7 @@ async fn config_requirements_read_includes_new_thread_model_defaults() -> Result
std::fs::write(
codex_home.path().join("requirements.toml"),
r#"
[models.new_thread]
[models.new_thread."codex.thread.coding"]
model = "gpt-managed"
model_reasoning_effort = "medium"
service_tier = "fast"
@@ -101,7 +101,7 @@ service_tier = "fast"
let defaults = response
.requirements
.and_then(|requirements| requirements.models)
.and_then(|models| models.new_thread)
.and_then(|models| models.new_thread.get("codex.thread.coding").cloned())
.expect("managed new-thread defaults");
assert_eq!(defaults.model.as_deref(), Some("gpt-managed"));
assert_eq!(
@@ -881,6 +881,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
let thread_start_request_id = mcp
.send_thread_start_request(ThreadStartParams {
model: None,
model_policy_scope: None,
model_provider: None,
service_tier: None,
cwd: None,
@@ -27,6 +27,7 @@ use codex_app_server_protocol::UserInput as V2UserInput;
use codex_config::loader::project_trust_key;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::set_project_trust_level;
use codex_core::test_support::all_model_presets;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
@@ -36,6 +37,7 @@ use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use tempfile::TempDir;
@@ -199,6 +201,91 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_start_resolves_defaults_for_the_requested_model_policy_scope() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
let managed_model = create_config_with_scoped_model_defaults(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 general_response = start_thread(
&mut mcp,
ThreadStartParams {
model_policy_scope: Some("codex.thread.general".to_string()),
..Default::default()
},
)
.await?;
assert_eq!(general_response.model, "mock-model");
let coding_response = start_thread(
&mut mcp,
ThreadStartParams {
model_policy_scope: Some("codex.thread.coding".to_string()),
..Default::default()
},
)
.await?;
assert_eq!(
(
coding_response.model.as_str(),
coding_response.reasoning_effort,
coding_response.service_tier.as_deref(),
),
(
managed_model.as_str(),
Some(ReasoningEffort::Medium),
Some("priority"),
)
);
let explicit_response = start_thread(
&mut mcp,
ThreadStartParams {
model: Some("gpt-5.2".to_string()),
model_policy_scope: Some("codex.thread.coding".to_string()),
service_tier: Some(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())),
config: Some(HashMap::from([(
"model_reasoning_effort".to_string(),
json!("low"),
)])),
..Default::default()
},
)
.await?;
assert_eq!(
(
explicit_response.model.as_str(),
explicit_response.reasoning_effort,
explicit_response.service_tier.as_deref(),
),
(
"gpt-5.2",
Some(ReasoningEffort::Low),
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE),
)
);
let no_scope_response = start_thread(&mut mcp, ThreadStartParams::default()).await?;
assert_eq!(no_scope_response.model, "mock-model");
Ok(())
}
async fn start_thread(
mcp: &mut TestAppServer,
params: ThreadStartParams,
) -> Result<ThreadStartResponse> {
let request_id = mcp.send_thread_start_request_with_auto_env(params).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_accepts_absolute_runtime_workspace_roots() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -1196,6 +1283,44 @@ fn create_config_toml_without_approval_policy(
)
}
fn create_config_with_scoped_model_defaults(codex_home: &Path, server_uri: &str) -> Result<String> {
create_config_toml_without_approval_policy(codex_home, server_uri)?;
let config_path = codex_home.join("config.toml");
let mut config = std::fs::read_to_string(&config_path)?;
config.push_str(
r#"
[models.new_thread."codex.thread.general"]
model = "gpt-5.2"
"#,
);
std::fs::write(config_path, config)?;
let managed_model = all_model_presets()
.iter()
.find(|preset| {
preset.id != "gpt-5.2"
&& preset
.service_tiers
.iter()
.any(|tier| tier.id == "priority")
})
.context("expected a model with the fast service tier")?
.id
.clone();
std::fs::write(
codex_home.join("requirements.toml"),
format!(
r#"
[models.new_thread."codex.thread.coding"]
model = "{managed_model}"
model_reasoning_effort = "medium"
service_tier = "fast"
"#
),
)?;
Ok(managed_model)
}
fn create_config_toml_with_optional_approval_policy(
codex_home: &Path,
server_uri: &str,
+22 -36
View File
@@ -2,6 +2,7 @@ use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::models::PermissionProfile;
#[cfg(test)]
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -20,6 +21,9 @@ use super::requirements_exec_policy::RequirementsExecPolicyToml;
use crate::Constrained;
use crate::ConstraintError;
use crate::ManagedHooksRequirementsToml;
use crate::config_toml::ModelsToml;
#[cfg(test)]
use crate::config_toml::NewThreadModelDefaultsToml;
use crate::mcp_types::AppToolApproval;
use crate::permissions_toml::PermissionProfileToml;
use crate::types::WindowsSandboxModeToml;
@@ -892,31 +896,7 @@ pub struct ConfigRequirementsToml {
pub guardian_policy_config: Option<String>,
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct ModelsRequirementsToml {
pub new_thread: Option<NewThreadModelDefaultsToml>,
}
impl ModelsRequirementsToml {
fn is_empty(&self) -> bool {
self.new_thread
.as_ref()
.is_none_or(NewThreadModelDefaultsToml::is_empty)
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct NewThreadModelDefaultsToml {
pub model: Option<String>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub service_tier: Option<String>,
}
impl NewThreadModelDefaultsToml {
fn is_empty(&self) -> bool {
self.model.is_none() && self.model_reasoning_effort.is_none() && self.service_tier.is_none()
}
}
pub type ModelsRequirementsToml = ModelsToml;
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct RemoteSandboxConfigToml {
@@ -1837,7 +1817,7 @@ mod tests {
fn deserialize_new_thread_model_defaults() -> Result<()> {
let requirements: ConfigRequirementsToml = from_str(
r#"
[models.new_thread]
[models.new_thread."codex.thread.coding"]
model = "managed-model"
model_reasoning_effort = "medium"
service_tier = "fast"
@@ -1847,11 +1827,14 @@ mod tests {
assert_eq!(
requirements.models,
Some(ModelsRequirementsToml {
new_thread: Some(NewThreadModelDefaultsToml {
model: Some("managed-model".to_string()),
model_reasoning_effort: Some(ReasoningEffort::Medium),
service_tier: Some("fast".to_string()),
}),
new_thread: BTreeMap::from([(
"codex.thread.coding".to_string(),
NewThreadModelDefaultsToml {
model: Some("managed-model".to_string()),
model_reasoning_effort: Some(ReasoningEffort::Medium),
service_tier: Some("fast".to_string()),
},
)]),
})
);
assert!(!requirements.is_empty());
@@ -1881,11 +1864,14 @@ mod tests {
allow_locked_computer_use: Some(false),
};
let models = ModelsRequirementsToml {
new_thread: Some(NewThreadModelDefaultsToml {
model: Some("managed-model".to_string()),
model_reasoning_effort: Some(ReasoningEffort::Medium),
service_tier: Some("fast".to_string()),
}),
new_thread: BTreeMap::from([(
"codex.thread.coding".to_string(),
NewThreadModelDefaultsToml {
model: Some("managed-model".to_string()),
model_reasoning_effort: Some(ReasoningEffort::Medium),
service_tier: Some("fast".to_string()),
},
)]),
};
let enforce_residency = ResidencyRequirement::Us;
let enforce_source = source.clone();
+68
View File
@@ -148,12 +148,44 @@ pub struct OrchestratorFeatureToml {
pub enabled: Option<bool>,
}
/// Model defaults keyed by the stable policy scope selected when a thread starts.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ModelsToml {
#[serde(default)]
pub new_thread: BTreeMap<String, NewThreadModelDefaultsToml>,
}
impl ModelsToml {
pub(crate) fn is_empty(&self) -> bool {
self.new_thread
.values()
.all(NewThreadModelDefaultsToml::is_empty)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NewThreadModelDefaultsToml {
pub model: Option<String>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub service_tier: Option<String>,
}
impl NewThreadModelDefaultsToml {
pub(crate) fn is_empty(&self) -> bool {
self.model.is_none() && self.model_reasoning_effort.is_none() && self.service_tier.is_none()
}
}
/// Base config deserialized from ~/.codex/config.toml.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ConfigToml {
/// Optional override of model selection.
pub model: Option<String>,
/// Scope-specific defaults applied only when a thread start names a policy scope.
pub models: Option<ModelsToml>,
/// Review model override used by the `/review` feature.
pub review_model: Option<String>,
@@ -975,6 +1007,42 @@ mod tests {
const WORKSPACE_ID_A: &str = "123e4567-e89b-42d3-a456-426614174000";
const WORKSPACE_ID_B: &str = "123e4567-e89b-42d3-a456-426614174001";
#[test]
fn model_defaults_are_keyed_by_policy_scope() {
let config: ConfigToml = toml::from_str(
r#"
[models.new_thread."codex.thread.general"]
model = "general-model"
[models.new_thread."codex.thread.coding"]
model = "coding-model"
model_reasoning_effort = "high"
"#,
)
.expect("scoped model defaults should deserialize");
assert_eq!(
config.models.expect("model defaults").new_thread,
BTreeMap::from([
(
"codex.thread.coding".to_string(),
NewThreadModelDefaultsToml {
model: Some("coding-model".to_string()),
model_reasoning_effort: Some(ReasoningEffort::High),
service_tier: None,
},
),
(
"codex.thread.general".to_string(),
NewThreadModelDefaultsToml {
model: Some("general-model".to_string()),
..Default::default()
},
),
])
);
}
#[test]
fn forced_chatgpt_workspace_id_accepts_single_string() {
let config: ConfigToml = toml::from_str(&format!(
+1 -1
View File
@@ -75,7 +75,6 @@ pub use config_requirements::NetworkDomainPermissionsToml;
pub use config_requirements::NetworkRequirementsToml;
pub use config_requirements::NetworkUnixSocketPermissionToml;
pub use config_requirements::NetworkUnixSocketPermissionsToml;
pub use config_requirements::NewThreadModelDefaultsToml;
pub use config_requirements::PluginRequirementsToml;
pub use config_requirements::RemoteSandboxConfigToml;
pub use config_requirements::RequirementSource;
@@ -85,6 +84,7 @@ pub use config_requirements::Sourced;
pub use config_requirements::WebSearchModeRequirement;
pub use config_requirements::WindowsRequirementsToml;
pub use config_requirements::sandbox_mode_requirement_for_permission_profile;
pub use config_toml::NewThreadModelDefaultsToml;
pub use constraint::Constrained;
pub use constraint::ConstraintError;
pub use constraint::ConstraintResult;
@@ -116,7 +116,7 @@ fn new_thread_model_defaults_use_toml_priority() {
"req_low",
"Low",
r#"
[models.new_thread]
[models.new_thread."codex.thread.coding"]
model = "low-priority-model"
model_reasoning_effort = "low"
service_tier = "flex"
@@ -126,7 +126,7 @@ service_tier = "flex"
"req_high",
"High",
r#"
[models.new_thread]
[models.new_thread."codex.thread.coding"]
model = "high-priority-model"
model_reasoning_effort = "high"
service_tier = "fast"
@@ -140,7 +140,7 @@ service_tier = "fast"
composed,
expected_requirements(
r#"
[models.new_thread]
[models.new_thread."codex.thread.coding"]
model = "high-priority-model"
model_reasoning_effort = "high"
service_tier = "fast"
@@ -149,6 +149,45 @@ service_tier = "fast"
);
}
#[test]
fn new_thread_model_defaults_preserve_distinct_scopes() {
let composed = compose(vec![
layer(
"general",
"General",
r#"
[models.new_thread."codex.thread.general"]
model = "general-model"
"#,
),
layer(
"coding",
"Coding",
r#"
[models.new_thread."codex.thread.coding"]
model = "coding-model"
model_reasoning_effort = "high"
"#,
),
])
.expect("compose requirements")
.expect("requirements present");
assert_eq!(
composed,
expected_requirements(
r#"
[models.new_thread."codex.thread.coding"]
model = "coding-model"
model_reasoning_effort = "high"
[models.new_thread."codex.thread.general"]
model = "general-model"
"#
)
);
}
#[test]
fn composition_strategy_applies_to_non_cloud_layers() {
let mdm_source = RequirementSource::MdmManagedPreferences {
+37
View File
@@ -1673,6 +1673,20 @@
},
"type": "object"
},
"ModelsToml": {
"additionalProperties": false,
"description": "Model defaults keyed by the stable policy scope selected when a thread starts.",
"properties": {
"new_thread": {
"additionalProperties": {
"$ref": "#/definitions/NewThreadModelDefaultsToml"
},
"default": {},
"type": "object"
}
},
"type": "object"
},
"MultiAgentV2ConfigToml": {
"additionalProperties": false,
"properties": {
@@ -1983,6 +1997,21 @@
"NetworkUnixSocketPermissionsToml": {
"type": "object"
},
"NewThreadModelDefaultsToml": {
"additionalProperties": false,
"properties": {
"model": {
"type": "string"
},
"model_reasoning_effort": {
"$ref": "#/definitions/ReasoningEffort"
},
"service_tier": {
"type": "string"
}
},
"type": "object"
},
"Notice": {
"additionalProperties": false,
"properties": {
@@ -5236,6 +5265,14 @@
],
"description": "Optional verbosity control for GPT-5 models (Responses API `text.verbosity`)."
},
"models": {
"allOf": [
{
"$ref": "#/definitions/ModelsToml"
}
],
"description": "Scope-specific defaults applied only when a thread start names a policy scope."
},
"notice": {
"allOf": [
{
+9
View File
@@ -16,6 +16,7 @@ use codex_config::ConstrainedWithSource;
use codex_config::FeatureRequirementsToml;
use codex_config::McpServerIdentity;
use codex_config::McpServerRequirement;
use codex_config::NewThreadModelDefaultsToml;
use codex_config::PluginRequirementsToml;
use codex_config::ProfileV2Name;
use codex_config::ResidencyRequirement;
@@ -622,6 +623,9 @@ pub struct Config {
/// `default` means the user explicitly selected standard routing.
pub service_tier: Option<String>,
/// Scope-specific model defaults available to app-server thread starts.
pub new_thread_model_defaults: BTreeMap<String, NewThreadModelDefaultsToml>,
/// Model used specifically for review sessions.
pub review_model: Option<String>,
@@ -3544,6 +3548,10 @@ impl Config {
let forced_login_method = cfg.forced_login_method;
let model = model.or(cfg.model);
let new_thread_model_defaults = cfg
.models
.map(|models| models.new_thread)
.unwrap_or_default();
let notices = cfg.notice.unwrap_or_default();
let service_tier = match service_tier_override {
Some(Some(service_tier)) => Some(service_tier),
@@ -3753,6 +3761,7 @@ impl Config {
let config = Self {
model,
service_tier,
new_thread_model_defaults,
review_model,
model_context_window: cfg.model_context_window,
model_auto_compact_token_limit: cfg.model_auto_compact_token_limit,