mirror of
https://github.com/openai/codex.git
synced 2026-11-11 21:10:39 +00:00
[codex] Make new-thread model defaults scope-aware
This commit is contained in:
@@ -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",
|
||||
|
||||
+14
-8
@@ -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",
|
||||
|
||||
+14
-8
@@ -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",
|
||||
|
||||
+7
-8
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user