diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 68c242504f..37377df8bd 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c706cc5d58..58ea608417 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 2858ac7fe9..0e2509bb76 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 4e35f7aead..1323e3f93c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -375,16 +375,15 @@ "ModelsRequirements": { "properties": { "newThread": { - "anyOf": [ - { - "$ref": "#/definitions/NewThreadModelDefaults" - }, - { - "type": "null" - } - ] + "additionalProperties": { + "$ref": "#/definitions/NewThreadModelDefaults" + }, + "type": "object" } }, + "required": [ + "newThread" + ], "type": "object" }, "NetworkDomainPermission": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 50c0a9cf7f..1f1171fe4b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelsRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelsRequirements.ts index 9041fff834..c4cc2e8b18 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ModelsRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelsRequirements.ts @@ -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 }, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts index 30509ef6cb..74d58bed1c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts @@ -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. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index f615b25d44..6c9a0af6c0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -397,7 +397,7 @@ pub struct ConfigRequirements { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ModelsRequirements { - pub new_thread: Option, + pub new_thread: BTreeMap, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 862b619e94..9eabb9e52e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -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!({ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 994cdd5124..d5cc1c1625 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -55,6 +55,9 @@ pub enum ThreadStartSource { pub struct ThreadStartParams { #[ts(optional = nullable)] pub model: Option, + /// Stable model-policy scope used to resolve already-loaded new-thread defaults. + #[ts(optional = nullable)] + pub model_policy_scope: Option, #[ts(optional = nullable)] pub model_provider: Option, #[serde( diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e9dcf33795..031911f987 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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 diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index b6dbb30b08..68a4c364c2 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -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!( diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 4703518f7f..06059972d2 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -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>, typesafe_overrides: ConfigOverrides, + model_policy_scope: Option, multi_agent_mode: Option, dynamic_tools: Option>, selected_capability_roots: Vec, @@ -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 diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 07b8ccc2e8..24b3d2b06f 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -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!( diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 075cc007ac..8759b79027 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -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, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 39f8935a93..d6da6ae6e0 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -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 { + 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 { + 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, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 2292c0a0a6..2fd00dd515 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -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, } -#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] -pub struct ModelsRequirementsToml { - pub new_thread: Option, -} - -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, - pub model_reasoning_effort: Option, - pub service_tier: Option, -} - -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(); diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 823d59c0bc..8e95a96095 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -148,12 +148,44 @@ pub struct OrchestratorFeatureToml { pub enabled: Option, } +/// 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, +} + +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, + pub model_reasoning_effort: Option, + pub service_tier: Option, +} + +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, + /// Scope-specific defaults applied only when a thread start names a policy scope. + pub models: Option, /// Review model override used by the `/review` feature. pub review_model: Option, @@ -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!( diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 0ef9f9e0d9..2d10db2f52 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -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; diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 92e6bd8b46..5c1b0779e2 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -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 { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index e3e4959f04..e4521e7911 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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": [ { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ac79dea390..5c5addaaad 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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, + /// Scope-specific model defaults available to app-server thread starts. + pub new_thread_model_defaults: BTreeMap, + /// Model used specifically for review sessions. pub review_model: Option, @@ -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,