Consolidate model instructions in ModelMessages (#36787)

## What changed

- Remove `ModelInfo.base_instructions` as an in-memory instruction source and use `model_messages.instructions_template` consistently for bundled, remote, fallback, and overridden model metadata.
- Preserve compatibility by promoting legacy `base_instructions` values when reading model responses and caches, and by including rendered legacy instructions when serializing `ModelsResponse` for older clients.
- Treat templates without instruction variables as literal text and retain the other model-message fields when applying instruction overrides.

This completes the consolidation proposed in https://github.com/openai/codex/pull/31302.

## Testing

- Cover legacy response and cache migration, canonical-template precedence, fallback instructions, personality rendering, overrides, and model switching.

GitOrigin-RevId: 089d986ca5e30da67db2c77a1b6a046d2cff52dc
This commit is contained in:
rhan-oai
2026-08-03 19:30:40 +00:00
committed by copyberry
parent bbcf5e10fb
commit df72fdb415
27 changed files with 655 additions and 311 deletions
@@ -5,6 +5,7 @@ use codex_models_manager::client_version_to_whole;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelMessages;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::TruncationPolicyConfig;
@@ -32,8 +33,15 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
service_tiers: preset.service_tiers.clone(),
default_service_tier: preset.default_service_tier.clone(),
upgrade: preset.upgrade.as_ref().map(Into::into),
base_instructions: "base instructions".to_string(),
model_messages: None,
model_messages: Some(ModelMessages {
instructions_template: Some("base instructions".to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
}),
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
default_reasoning_summary: ReasoningSummary::Auto,
@@ -168,7 +168,6 @@ async fn list_models_uses_chatgpt_remote_catalog_as_source_of_truth() -> Result<
"supported_in_api": true,
"priority": 0,
"upgrade": null,
"base_instructions": "base instructions",
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
@@ -208,7 +208,6 @@ mod tests {
"supported_in_api": true,
"priority": 1,
"upgrade": null,
"base_instructions": "base instructions",
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
@@ -79,7 +79,6 @@ async fn models_client_hits_models_endpoint() {
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
-1
View File
@@ -258,7 +258,6 @@ fn test_model_info() -> ModelInfo {
"supported_in_api": true,
"priority": 1,
"upgrade": null,
"base_instructions": "base instructions",
"model_messages": null,
"support_verbosity": false,
"default_verbosity": null,
+1 -1
View File
@@ -632,7 +632,7 @@ impl Session {
// Resolve base instructions for the session. Priority order:
// 1. config.base_instructions override
// 2. conversation history => session_meta.base_instructions
// 3. base_instructions for current model
// 3. rendered instructions_template for current model
let model_info = models_manager
.get_model_info(model.as_str(), &config.to_models_manager_config())
.await;
+4 -3
View File
@@ -1465,20 +1465,21 @@ async fn get_base_instructions_no_user_content() {
for test_case in test_cases {
let model_info = model_info_for_slug(test_case.slug, &config);
let model_instructions = model_info.get_model_instructions(config.personality);
if test_case.expects_apply_patch_description {
assert_eq!(
model_info.base_instructions.as_str(),
model_instructions.as_str(),
prompt_with_apply_patch_instructions
);
}
{
let mut state = session.state.lock().await;
state.session_configuration.base_instructions = model_info.base_instructions.clone();
state.session_configuration.base_instructions = model_instructions.clone();
}
let base_instructions = session.get_base_instructions().await;
assert_eq!(base_instructions.text, model_info.base_instructions);
assert_eq!(base_instructions.text, model_instructions);
}
}
-1
View File
@@ -268,7 +268,6 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
@@ -97,7 +97,6 @@ fn test_model_info(
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
@@ -961,7 +960,6 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
+36 -7
View File
@@ -13,6 +13,7 @@ use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelMessages;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
@@ -34,6 +35,7 @@ use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use wiremock::MockServer;
const ETAG: &str = "\"models-etag-ttl\"";
@@ -272,6 +274,11 @@ async fn matching_models_etag_renews_cache_after_half_its_lifetime() -> Result<(
async fn uses_cache_when_version_matches() -> Result<()> {
let server = MockServer::start().await;
let cached_model = test_remote_model(VERSIONED_MODEL, /*priority*/ 1);
let response = responses::mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let models_mock = responses::mount_models_once(
&server,
ModelsResponse {
@@ -283,20 +290,30 @@ async fn uses_cache_when_version_matches() -> Result<()> {
let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
builder = builder
.with_pre_build_hook(move |home| {
let cache = ModelsCache {
let mut cache = serde_json::to_value(ModelsCache {
fetched_at: Utc::now(),
etag: None,
client_version: Some(client_version_to_whole()),
models: vec![cached_model],
};
let cache_path = home.join(CACHE_FILE);
write_cache_sync(&cache_path, &cache).expect("write cache");
})
.expect("serialize cache");
let cached_model = cache["models"][0]
.as_object_mut()
.expect("cached model should serialize as an object");
cached_model.remove("model_messages");
cached_model.insert("base_instructions".to_string(), json!("base instructions"));
std::fs::write(
home.join(CACHE_FILE),
serde_json::to_vec_pretty(&cache).expect("serialize legacy cache"),
)
.expect("write legacy cache");
})
.with_config(|config| {
config.model = Some(VERSIONED_MODEL.to_string());
config.model_provider.request_max_retries = Some(0);
});
let test = builder.build(&server).await?;
let test = builder.build_with_auto_env(&server).await?;
let models_manager = test.thread_manager.get_models_manager();
let models = models_manager
.list_models(
@@ -314,6 +331,11 @@ async fn uses_cache_when_version_matches() -> Result<()> {
0,
"/models should not be called when cache version matches"
);
test.submit_turn("use cached model").await?;
assert_eq!(
response.single_request().instructions_text(),
"base instructions"
);
Ok(())
}
@@ -480,8 +502,15 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
model_messages: Some(ModelMessages {
instructions_template: Some("base instructions".to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
}),
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
default_reasoning_summary: ReasoningSummary::Auto,
@@ -110,6 +110,7 @@ async fn catalog_approval_message_is_sent_in_initial_permissions() -> Result<()>
)
.await;
let model_slug = "catalog-approvals-model";
let override_instructions = "literal {{ personality }} override";
let model = model_with_approval_messages(
model_slug,
"catalog user approval instructions",
@@ -118,16 +119,19 @@ async fn catalog_approval_message_is_sent_in_initial_permissions() -> Result<()>
let mut builder = test_codex()
.with_model(model_slug)
.with_config(move |config| {
config.base_instructions = Some(override_instructions.to_string());
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.model_catalog = Some(ModelsResponse {
models: vec![model],
});
});
let test = builder.build(&server).await?;
let test = builder.build_with_auto_env(&server).await?;
submit_text_turn(&test, "hello").await?;
let permissions = permissions_texts(&req.single_request());
let request = req.single_request();
assert_eq!(request.instructions_text(), override_instructions);
let permissions = permissions_texts(&request);
assert_eq!(permissions.len(), 1);
assert!(permissions[0].contains("catalog user approval instructions"));
assert!(permissions[0].contains("Filesystem sandboxing defines"));
+55 -55
View File
@@ -1,7 +1,9 @@
use codex_config::types::Personality;
use codex_features::Feature;
use codex_models_manager::bundled_models_response;
use codex_models_manager::manager::RefreshStrategy;
use codex_models_manager::manager::SharedModelsManager;
use codex_models_manager::model_info::BASE_INSTRUCTIONS;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ConfigShellToolType;
@@ -18,7 +20,6 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use core_test_support::load_default_config_for_test;
use core_test_support::responses::mount_models_once;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
@@ -31,7 +32,6 @@ use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::Instant;
use tokio::time::sleep;
@@ -88,43 +88,6 @@ fn read_only_text_turn_with_personality(
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn personality_does_not_mutate_base_instructions_without_template() {
let codex_home = TempDir::new().expect("create temp dir");
let mut config = load_default_config_for_test(&codex_home).await;
config
.features
.enable(Feature::Personality)
.expect("test config should allow feature update");
config.personality = Some(Personality::Friendly);
let model_info = codex_core::test_support::construct_model_info_offline("gpt-5.4", &config);
assert_eq!(
model_info.get_model_instructions(config.personality),
model_info.base_instructions
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn base_instructions_override_disables_personality_template() {
let codex_home = TempDir::new().expect("create temp dir");
let mut config = load_default_config_for_test(&codex_home).await;
config
.features
.enable(Feature::Personality)
.expect("test config should allow feature update");
config.personality = Some(Personality::Friendly);
config.base_instructions = Some("override instructions".to_string());
let model_info = codex_core::test_support::construct_model_info_offline("gpt-5.4", &config);
assert_eq!(model_info.base_instructions, "override instructions");
assert_eq!(
model_info.get_model_instructions(config.personality),
"override instructions"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
@@ -267,8 +230,10 @@ async fn config_personality_none_strips_baked_personality_section() -> anyhow::R
let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await;
let mut builder = test_codex()
.with_model_info_override("gpt-5.4", |model_info| {
model_info.base_instructions = "Base instructions\n# Personality\nBaked personality\n## Writing Style\nNested writing style\n# General\nGeneral instructions".to_string();
model_info.model_messages = None;
if let Some(model_messages) = model_info.model_messages.as_mut() {
model_messages.instructions_template = Some("Base instructions\n# Personality\nBaked personality\n## Writing Style\nNested writing style\n# General\nGeneral instructions".to_string());
model_messages.instructions_variables = None;
}
})
.with_config(|config| {
config
@@ -516,21 +481,53 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn instructions_uses_base_if_feature_disabled() -> anyhow::Result<()> {
let codex_home = TempDir::new().expect("create temp dir");
let mut config = load_default_config_for_test(&codex_home).await;
config
.features
.disable(Feature::Personality)
.expect("test config should allow feature update");
config.personality = Some(Personality::Friendly);
async fn disabled_personality_sends_remote_default_instructions() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
let remote_slug = "remote-disabled-personality";
let default_personality = "default remote personality";
let friendly_personality = "friendly remote personality";
let mut remote_model = bundled_models_response()?
.models
.into_iter()
.find(|model| model.slug == "gpt-5.4")
.expect("bundled gpt-5.4 model");
remote_model.slug = remote_slug.to_string();
if let Some(model_messages) = remote_model.model_messages.as_mut() {
model_messages.instructions_template = Some("remote base\n{{ personality }}".to_string());
model_messages.instructions_variables = Some(ModelInstructionsVariables {
personality_default: Some(default_personality.to_string()),
personality_friendly: Some(friendly_personality.to_string()),
personality_pragmatic: Some("pragmatic remote personality".to_string()),
});
}
mount_models_once(
&server,
ModelsResponse {
models: vec![remote_model],
},
)
.await;
let response = mount_sse_once(&server, sse_completed("resp-1")).await;
let mut builder = test_codex()
.with_auth(codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config
.features
.disable(Feature::Personality)
.expect("test config should allow feature update");
config.model = Some(remote_slug.to_string());
config.personality = Some(Personality::Friendly);
});
let test = builder.build_with_auto_env(&server).await?;
test.submit_turn("use the default personality").await?;
let model_info = codex_core::test_support::construct_model_info_offline("gpt-5.4", &config);
assert_eq!(
model_info.get_model_instructions(config.personality),
model_info.base_instructions
response.single_request().instructions_text(),
format!("remote base\n{default_personality}")
);
Ok(())
}
@@ -587,6 +584,11 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()>
let requests = resp_mock.requests();
assert_eq!(requests.len(), 2, "expected two requests");
assert!(
requests
.iter()
.all(|request| request.instructions_text() == BASE_INSTRUCTIONS)
);
let request = requests
.last()
.expect("expected personality update request");
@@ -631,7 +633,6 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: Some(ModelMessages {
instructions_template: Some("Base instructions\n{{ personality }}\n".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
@@ -752,7 +753,6 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: Some(ModelMessages {
instructions_template: Some("Base instructions\n{{ personality }}\n".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
+3 -3
View File
@@ -154,7 +154,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
})
.build(&server)
.await?;
let base_instructions = thread_manager
let model_info = thread_manager
.get_models_manager()
.get_model_info(
config
@@ -163,8 +163,8 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
.expect("test config should have a model"),
&config.to_models_manager_config(),
)
.await
.base_instructions;
.await;
let base_instructions = model_info.get_model_instructions(config.personality);
codex
.submit(Op::UserInput {
+127 -26
View File
@@ -6,10 +6,12 @@ use codex_model_provider_info::built_in_model_providers;
use codex_models_manager::bundled_models_response;
use codex_models_manager::manager::RefreshStrategy;
use codex_models_manager::manager::SharedModelsManager;
use codex_models_manager::model_info::BASE_INSTRUCTIONS;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelMessages;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ModelsResponse;
@@ -33,6 +35,7 @@ use core_test_support::responses::mount_models_once_with_delay;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_sandbox;
use core_test_support::test_codex::TestCodex;
@@ -49,10 +52,36 @@ use tokio::time::Instant;
use tokio::time::sleep;
use tokio::time::timeout;
use wiremock::BodyPrintLimit;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const REMOTE_MODEL_SLUG: &str = "codex-test";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unknown_model_sends_builtin_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let response = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let mut builder = test_codex().with_model("future-custom-model");
let test = builder.build_with_auto_env(&server).await?;
test.submit_turn("use fallback model metadata").await?;
assert_eq!(
response.single_request().instructions_text(),
BASE_INSTRUCTIONS
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -73,12 +102,28 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<(
);
let specific = ModelInfo {
display_name: "GPT 5.3 Codex".to_string(),
base_instructions: "use specific prefix".to_string(),
model_messages: Some(ModelMessages {
instructions_template: Some("use specific prefix".to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
}),
..specific
};
let generic = ModelInfo {
display_name: "GPT 5.3".to_string(),
base_instructions: "use generic prefix".to_string(),
model_messages: Some(ModelMessages {
instructions_template: Some("use generic prefix".to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
}),
..generic
};
mount_models_once(
@@ -115,7 +160,10 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<(
.await;
assert_eq!(model_info.slug, "gpt-5.3-codex-test");
assert_eq!(model_info.base_instructions, specific.base_instructions);
assert_eq!(
model_info.get_model_instructions(config.personality),
specific.get_model_instructions(config.personality)
);
Ok(())
}
@@ -487,7 +535,6 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
@@ -705,7 +752,7 @@ async fn remote_models_truncation_policy_with_tool_output_override() -> Result<(
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_models_apply_remote_base_instructions() -> Result<()> {
async fn remote_models_apply_legacy_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
@@ -716,7 +763,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
let model = "test-gpt-5-remote";
let remote_base = "Use the remote base instructions only.";
let remote_instructions = "Use the remote instructions template only.";
let remote_model = ModelInfo {
slug: model.to_string(),
display_name: "Parallel Remote".to_string(),
@@ -741,8 +788,15 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: remote_base.to_string(),
model_messages: None,
model_messages: Some(ModelMessages {
instructions_template: Some(remote_instructions.to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
}),
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
default_reasoning_summary: ReasoningSummary::Auto,
@@ -761,21 +815,34 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
effective_context_window_percent: 95,
experimental_supported_tools: Vec::new(),
};
mount_models_once(
&server,
ModelsResponse {
models: vec![remote_model],
},
)
.await;
let mut models_response = serde_json::to_value(ModelsResponse {
models: vec![remote_model],
})?;
models_response["models"][0]
.as_object_mut()
.expect("model should serialize as an object")
.remove("model_messages");
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(models_response))
.up_to_n_times(1)
.mount(&server)
.await;
let response_mock = mount_sse_once(
let response_mock = mount_sse_sequence(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]),
vec![
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-2", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
@@ -795,6 +862,31 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
let models_manager = thread_manager.get_models_manager();
wait_for_model_available(&models_manager, model).await;
let cwd_path = cwd.abs();
let (sandbox_policy, permission_profile) =
turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path());
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello base".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
environments: Some(local_selections(cwd_path.clone())),
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(sandbox_policy),
permission_profile,
summary: Some(ReasoningSummary::Auto),
..Default::default()
},
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
core_test_support::submit_thread_settings(
&codex,
codex_protocol::protocol::ThreadSettingsOverrides {
@@ -804,7 +896,6 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
)
.await?;
let cwd_path = cwd.abs();
let (sandbox_policy, permission_profile) =
turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path());
codex
@@ -832,9 +923,20 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
let base_model_info = models_manager
.get_model_info("gpt-5.2", &config.to_models_manager_config())
.await;
let body = response_mock.single_request().body_json();
let instructions = body["instructions"].as_str().unwrap();
assert_eq!(instructions, base_model_info.base_instructions);
let requests = response_mock.requests();
assert_eq!(requests.len(), 2, "expected two model requests");
let request = requests.last().expect("expected second model request");
assert_eq!(
request.instructions_text(),
base_model_info.get_model_instructions(config.personality)
);
assert!(
request
.message_input_texts("developer")
.iter()
.any(|text| text.contains(remote_instructions)),
"expected the model switch message to contain the remote instructions template"
);
Ok(())
}
@@ -1268,7 +1370,6 @@ fn test_remote_model_with_policy(
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
-1
View File
@@ -2259,7 +2259,6 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
@@ -78,7 +78,6 @@ fn test_model_info(
service_tiers,
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
-1
View File
@@ -1370,7 +1370,6 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
-1
View File
@@ -457,7 +457,6 @@ mod tests {
"supported_in_api": true,
"priority": 0,
"upgrade": null,
"base_instructions": "base instructions",
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
File diff suppressed because one or more lines are too long
+3
View File
@@ -168,6 +168,9 @@ pub(crate) struct ModelsCache {
pub(crate) etag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) client_version: Option<String>,
#[serde(
deserialize_with = "codex_protocol::openai_models::deserialize_model_infos_with_legacy_base"
)]
pub(crate) models: Vec<ModelInfo>,
}
+7 -1
View File
@@ -50,7 +50,13 @@ fn remote_model_with_visibility(
"supported_in_api": true,
"priority": priority,
"upgrade": null,
"base_instructions": "base instructions",
"model_messages": {
"instructions_template": "base instructions",
"instructions_variables": null,
"approvals": null,
"auto_review": null,
"permissions": null
},
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
+50 -32
View File
@@ -50,22 +50,48 @@ pub fn with_config_overrides(mut model: ModelInfo, config: &ModelsManagerConfig)
}
if let Some(base_instructions) = &config.base_instructions {
model.base_instructions = base_instructions.clone();
clear_instruction_messages(&mut model);
let model_messages = model.model_messages.get_or_insert(ModelMessages {
instructions_template: None,
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
});
model_messages.instructions_template = Some(base_instructions.clone());
model_messages.instructions_variables = None;
} else {
if config.personality_enabled && config.personality == Some(Personality::None) {
model.base_instructions = strip_personality_section(model.base_instructions);
if let Some(instructions_template) = model
if config.personality_enabled
&& config.personality == Some(Personality::None)
&& let Some(instructions_template) = model
.model_messages
.as_mut()
.and_then(|messages| messages.instructions_template.as_mut())
{
*instructions_template =
strip_personality_section(std::mem::take(instructions_template));
}
{
*instructions_template =
strip_personality_section(std::mem::take(instructions_template));
}
if !config.personality_enabled {
clear_instruction_messages(&mut model);
let uses_local_personality_template = model.used_fallback_model_metadata
&& matches!(
model.slug.as_str(),
"gpt-5.2-codex" | "exp-codex-personality"
);
if !config.personality_enabled
&& let Some(model_messages) = model.model_messages.as_mut()
{
if uses_local_personality_template {
model_messages.instructions_template = Some(BASE_INSTRUCTIONS.to_string());
} else {
let personality_default = model_messages
.get_personality_message(/*personality*/ None)
.unwrap_or_default();
if let Some(instructions_template) = model_messages.instructions_template.as_mut() {
*instructions_template = instructions_template
.replace(PERSONALITY_PLACEHOLDER, &personality_default);
}
}
model_messages.instructions_variables = None;
}
}
@@ -108,21 +134,6 @@ fn is_h1_heading(line: &str) -> bool {
rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t')
}
fn clear_instruction_messages(model: &mut ModelInfo) {
if let Some(model_messages) = &mut model.model_messages {
model_messages.instructions_template = None;
model_messages.instructions_variables = None;
if model_messages.approvals.is_none()
&& model_messages.collaboration_modes.is_none()
&& model_messages.auto_review.is_none()
&& model_messages.permissions.is_none()
&& model_messages.token_budget.is_none()
{
model.model_messages = None;
}
}
}
/// Build a minimal fallback model descriptor for missing/unknown slugs.
pub fn model_info_from_slug(slug: &str) -> ModelInfo {
warn!("Unknown model {slug} is used. This will use fallback model metadata.");
@@ -141,8 +152,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo {
default_service_tier: None,
availability_nux: None,
upgrade: None,
base_instructions: BASE_INSTRUCTIONS.to_string(),
model_messages: local_personality_messages_for_slug(slug),
model_messages: Some(local_model_messages_for_slug(slug)),
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
default_reasoning_summary: ReasoningSummary::Auto,
@@ -169,9 +179,9 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo {
}
}
fn local_personality_messages_for_slug(slug: &str) -> Option<ModelMessages> {
fn local_model_messages_for_slug(slug: &str) -> ModelMessages {
match slug {
"gpt-5.2-codex" | "exp-codex-personality" => Some(ModelMessages {
"gpt-5.2-codex" | "exp-codex-personality" => ModelMessages {
instructions_template: Some(format!(
"{DEFAULT_PERSONALITY_HEADER}\n\n{PERSONALITY_PLACEHOLDER}\n\n{BASE_INSTRUCTIONS}"
)),
@@ -185,8 +195,16 @@ fn local_personality_messages_for_slug(slug: &str) -> Option<ModelMessages> {
auto_review: None,
permissions: None,
token_budget: None,
}),
_ => None,
},
_ => ModelMessages {
instructions_template: Some(BASE_INSTRUCTIONS.to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
},
}
}
+87 -120
View File
@@ -4,6 +4,7 @@ use codex_protocol::config_types::Personality;
use codex_protocol::openai_models::ApprovalMessages;
use codex_protocol::openai_models::AutoReviewMessages;
use codex_protocol::openai_models::CollaborationModeMessages;
use codex_protocol::openai_models::ModelTokenBudgetConfig;
use codex_protocol::openai_models::PermissionMessages;
use pretty_assertions::assert_eq;
@@ -16,13 +17,34 @@ fn config_with_personality(personality: Option<Personality>) -> ModelsManagerCon
}
#[test]
fn base_instruction_override_preserves_catalog_approval_messages() {
fn base_instruction_override_is_literal_and_preserves_catalog_messages() {
let override_instructions = "override {{ personality }}";
let mut model = model_info_from_slug("unknown-model");
let approvals = ApprovalMessages {
on_request: Some("user approvals".to_string()),
on_request_auto_review: Some("auto approvals".to_string()),
never: None,
unless_trusted: None,
never: Some("never approvals".to_string()),
unless_trusted: Some("unless-trusted approvals".to_string()),
};
let collaboration_modes = CollaborationModeMessages {
default: Some("default instructions".to_string()),
plan: Some("plan instructions".to_string()),
};
let auto_review = AutoReviewMessages {
policy: Some("review policy".to_string()),
policy_template: Some("review policy template".to_string()),
};
let permissions = PermissionMessages {
danger_full_access: Some("danger".to_string()),
workspace_write: Some(String::new()),
read_only: None,
};
let token_budget = ModelTokenBudgetConfig {
reminder_threshold_tokens: 128,
reminder_message_template: "budget reminder".to_string(),
guidance_message: "budget guidance".to_string(),
auto_compact_fallback_prompt: "compact prompt".to_string(),
auto_compact_fallback_buffer_tokens: 64,
};
model.model_messages = Some(ModelMessages {
instructions_template: Some("template".to_string()),
@@ -32,13 +54,13 @@ fn base_instruction_override_preserves_catalog_approval_messages() {
personality_pragmatic: Some("pragmatic".to_string()),
}),
approvals: Some(approvals.clone()),
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
collaboration_modes: Some(collaboration_modes.clone()),
auto_review: Some(auto_review.clone()),
permissions: Some(permissions.clone()),
token_budget: Some(token_budget.clone()),
});
let config = ModelsManagerConfig {
base_instructions: Some("override".to_string()),
base_instructions: Some(override_instructions.to_string()),
..Default::default()
};
@@ -47,51 +69,23 @@ fn base_instruction_override_preserves_catalog_approval_messages() {
assert_eq!(
updated.model_messages,
Some(ModelMessages {
instructions_template: None,
instructions_template: Some(override_instructions.to_string()),
instructions_variables: None,
approvals: Some(approvals),
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
})
);
}
#[test]
fn base_instruction_override_preserves_catalog_collaboration_mode_messages() {
let mut model = model_info_from_slug("gpt-5.2-codex");
let collaboration_modes = CollaborationModeMessages {
default: Some("default instructions".to_string()),
plan: Some("plan instructions".to_string()),
};
if let Some(model_messages) = model.model_messages.as_mut() {
model_messages.collaboration_modes = Some(collaboration_modes.clone());
}
let config = ModelsManagerConfig {
base_instructions: Some("override".to_string()),
..Default::default()
};
let updated = with_config_overrides(model, &config);
assert_eq!(
updated.model_messages,
Some(ModelMessages {
instructions_template: None,
instructions_variables: None,
approvals: None,
collaboration_modes: Some(collaboration_modes),
auto_review: None,
permissions: None,
token_budget: None,
auto_review: Some(auto_review),
permissions: Some(permissions),
token_budget: Some(token_budget),
})
);
assert_eq!(updated.base_instructions, "override");
assert_eq!(
updated.get_model_instructions(/*personality*/ None),
override_instructions
);
}
#[test]
fn disabled_personality_preserves_catalog_approval_messages() {
fn disabled_personality_bakes_default_and_preserves_catalog_approval_messages() {
let mut model = model_info_from_slug("unknown-model");
let approvals = ApprovalMessages {
on_request: Some("user approvals".to_string()),
@@ -100,8 +94,12 @@ fn disabled_personality_preserves_catalog_approval_messages() {
unless_trusted: None,
};
model.model_messages = Some(ModelMessages {
instructions_template: Some("template".to_string()),
instructions_variables: None,
instructions_template: Some("before {{ personality }} after".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: Some("default".to_string()),
personality_friendly: Some("friendly".to_string()),
personality_pragmatic: Some("pragmatic".to_string()),
}),
approvals: Some(approvals.clone()),
collaboration_modes: None,
auto_review: None,
@@ -118,7 +116,7 @@ fn disabled_personality_preserves_catalog_approval_messages() {
assert_eq!(
updated.model_messages,
Some(ModelMessages {
instructions_template: None,
instructions_template: Some("before default after".to_string()),
instructions_variables: None,
approvals: Some(approvals),
collaboration_modes: None,
@@ -130,78 +128,30 @@ fn disabled_personality_preserves_catalog_approval_messages() {
}
#[test]
fn base_instruction_override_preserves_catalog_auto_review_messages() {
let mut model = model_info_from_slug("unknown-model");
let auto_review = AutoReviewMessages {
policy: Some("review policy".to_string()),
policy_template: Some("review policy template".to_string()),
};
model.model_messages = Some(ModelMessages {
instructions_template: Some("template".to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: Some(auto_review.clone()),
permissions: None,
token_budget: None,
});
fn disabled_personality_uses_plain_base_instructions_for_local_personality_models() {
let config = ModelsManagerConfig {
base_instructions: Some("override".to_string()),
personality_enabled: false,
personality: Some(Personality::Friendly),
..Default::default()
};
let updated = with_config_overrides(model, &config);
for slug in ["gpt-5.2-codex", "exp-codex-personality"] {
let updated = with_config_overrides(model_info_from_slug(slug), &config);
assert_eq!(
updated.model_messages,
Some(ModelMessages {
instructions_template: None,
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: Some(auto_review),
permissions: None,
token_budget: None,
})
);
}
#[test]
fn base_instruction_override_preserves_catalog_permission_messages() {
let mut model = model_info_from_slug("unknown-model");
let permissions = PermissionMessages {
danger_full_access: Some("danger".to_string()),
workspace_write: Some(String::new()),
read_only: None,
};
model.model_messages = Some(ModelMessages {
instructions_template: Some("template".to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: Some(permissions.clone()),
token_budget: None,
});
let config = ModelsManagerConfig {
base_instructions: Some("override".to_string()),
..Default::default()
};
let updated = with_config_overrides(model, &config);
assert_eq!(
updated.model_messages,
Some(ModelMessages {
instructions_template: None,
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: Some(permissions),
token_budget: None,
})
);
assert_eq!(
updated.model_messages,
Some(ModelMessages {
instructions_template: Some(BASE_INSTRUCTIONS.to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
}),
"unexpected model messages for {slug}"
);
}
}
#[test]
@@ -229,7 +179,6 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() {
for (instructions, expected) in cases {
let mut model = model_info_from_slug("unknown-model");
model.base_instructions = instructions.to_string();
model.model_messages = Some(ModelMessages {
instructions_template: Some(instructions.to_string()),
instructions_variables: None,
@@ -247,8 +196,11 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() {
.and_then(|messages| messages.instructions_template.as_deref());
assert_eq!(
(updated.base_instructions.as_str(), instructions_template),
(expected, Some(expected))
(
updated.get_model_instructions(Some(Personality::None)),
instructions_template
),
(expected.to_string(), Some(expected))
);
}
}
@@ -268,15 +220,30 @@ fn baked_personality_section_is_preserved_without_enabled_explicit_none() {
for config in configs {
let mut model = model_info_from_slug("unknown-model");
model.base_instructions = instructions.to_string();
model
.model_messages
.as_mut()
.expect("fallback model messages")
.instructions_template = Some(instructions.to_string());
assert_eq!(
with_config_overrides(model, &config).base_instructions,
with_config_overrides(model, &config).get_model_instructions(config.personality),
instructions
);
}
}
#[test]
fn unknown_model_uses_builtin_instruction_template() {
let model = model_info_from_slug("unknown-model");
assert_eq!(
model.get_model_instructions(/*personality*/ None),
BASE_INSTRUCTIONS
);
assert!(model.used_fallback_model_metadata);
}
#[test]
fn model_context_window_override_clamps_to_max_context_window() {
let mut model = model_info_from_slug("unknown-model");
+258 -29
View File
@@ -21,7 +21,7 @@ use serde::de::DeserializeOwned;
use serde::de::Error;
use strum_macros::Display;
use strum_macros::EnumIter;
use tracing::trace;
use tracing::warn;
use ts_rs::TS;
use crate::config_types::Personality;
@@ -386,7 +386,6 @@ pub struct ModelInfo {
pub default_service_tier: Option<String>,
pub availability_nux: Option<ModelAvailabilityNux>,
pub upgrade: Option<ModelInfoUpgrade>,
pub base_instructions: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_messages: Option<ModelMessages>,
#[serde(default)]
@@ -479,29 +478,27 @@ impl ModelInfo {
if let Some(model_messages) = &self.model_messages
&& let Some(template) = &model_messages.instructions_template
{
// if we have a template, always use it
if model_messages.instructions_variables.is_none() {
return template.clone();
}
let personality_message = model_messages
.get_personality_message(personality)
.unwrap_or_default();
template.replace(PERSONALITY_PLACEHOLDER, personality_message.as_str())
} else {
match personality {
Some(personality @ (Personality::Friendly | Personality::Pragmatic)) => {
trace!(
model = %self.slug,
%personality,
"Model personality requested but model_messages is missing, falling back to base instructions."
);
}
Some(Personality::None) | None => {}
}
self.base_instructions.clone()
warn!(
model = %self.slug,
"Model has no instruction template; returning empty instructions."
);
String::new()
}
}
}
/// A strongly-typed template for assembling model instructions and developer messages. If
/// instructions_* is populated and valid, it will override base_instructions.
/// A strongly-typed template for assembling model instructions and developer messages.
///
/// When `instructions_variables` is absent, `instructions_template` is treated as literal text.
/// When variables are present but incomplete, missing values render as empty strings.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct ModelMessages {
pub instructions_template: Option<String>,
@@ -619,9 +616,100 @@ impl From<&ModelUpgrade> for ModelInfoUpgrade {
/// Response wrapper for `/models`.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema, Default)]
pub struct ModelsResponse {
#[serde(
serialize_with = "serialize_model_infos_with_legacy_base",
deserialize_with = "deserialize_model_infos_with_legacy_base"
)]
pub models: Vec<ModelInfo>,
}
#[derive(Serialize)]
struct ModelInfoWithLegacyBaseInstructionsRef<'a> {
#[serde(flatten)]
model: &'a ModelInfo,
base_instructions: String,
}
#[derive(Deserialize)]
struct ModelInfoWithLegacyBaseInstructions {
#[serde(default)]
base_instructions: Option<String>,
#[serde(flatten)]
model: ModelInfo,
}
/// Serializes catalog models with the deprecated top-level instruction field required by older
/// clients.
#[doc(hidden)]
pub fn serialize_model_infos_with_legacy_base<S>(
models: &[ModelInfo],
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
models
.iter()
.map(|model| ModelInfoWithLegacyBaseInstructionsRef {
model,
base_instructions: model.get_model_instructions(/*personality*/ None),
})
.collect::<Vec<_>>()
.serialize(serializer)
}
/// Deserializes catalog models while promoting the legacy top-level instruction field into Model
/// Messages V2 when no canonical instruction template is present.
#[doc(hidden)]
pub fn deserialize_model_infos_with_legacy_base<'de, D>(
deserializer: D,
) -> Result<Vec<ModelInfo>, D::Error>
where
D: Deserializer<'de>,
{
let models = Vec::<ModelInfoWithLegacyBaseInstructions>::deserialize(deserializer)?;
models
.into_iter()
.map(|legacy_model| {
let ModelInfoWithLegacyBaseInstructions {
base_instructions,
mut model,
} = legacy_model;
if let Some(base_instructions) = base_instructions
&& model
.model_messages
.as_ref()
.and_then(|messages| messages.instructions_template.as_ref())
.is_none()
{
let messages = model.model_messages.get_or_insert(ModelMessages {
instructions_template: None,
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
});
messages.instructions_template = Some(base_instructions);
}
if model
.model_messages
.as_ref()
.and_then(|messages| messages.instructions_template.as_ref())
.is_none()
{
let model_slug = &model.slug;
return Err(D::Error::custom(format!(
"model `{model_slug}` is missing both `base_instructions` and \
`model_messages.instructions_template`"
)));
}
Ok(model)
})
.collect()
}
// convert ModelInfo to ModelPreset
impl From<ModelInfo> for ModelPreset {
fn from(info: ModelInfo) -> Self {
@@ -733,7 +821,6 @@ mod tests {
default_service_tier: None,
availability_nux: None,
upgrade: None,
base_instructions: "base".to_string(),
model_messages: spec,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
@@ -991,7 +1078,7 @@ mod tests {
}
#[test]
fn get_model_instructions_always_strips_placeholder() {
fn get_model_instructions_strips_placeholder_with_incomplete_variables() {
let model = test_model(Some(ModelMessages {
instructions_template: Some("Hello\n{{ personality }}".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
@@ -1005,18 +1092,10 @@ mod tests {
permissions: None,
token_budget: None,
}));
assert_eq!(
model.get_model_instructions(Some(Personality::Friendly)),
"Hello\nfriendly"
);
assert_eq!(
model.get_model_instructions(Some(Personality::Pragmatic)),
"Hello\n"
);
assert_eq!(
model.get_model_instructions(Some(Personality::None)),
"Hello\n"
);
assert_eq!(
model.get_model_instructions(/*personality*/ None),
"Hello\n"
@@ -1054,7 +1133,7 @@ mod tests {
}
#[test]
fn get_model_instructions_falls_back_when_template_is_missing() {
fn get_model_instructions_is_empty_when_template_is_missing() {
let model = test_model(Some(ModelMessages {
instructions_template: None,
instructions_variables: Some(ModelInstructionsVariables {
@@ -1071,7 +1150,158 @@ mod tests {
let instructions = model.get_model_instructions(Some(Personality::Friendly));
assert_eq!(instructions, "base");
assert_eq!(instructions, "");
}
#[test]
fn get_model_instructions_is_empty_when_model_messages_is_missing() {
let model = test_model(/*spec*/ None);
assert_eq!(
model.get_model_instructions(Some(Personality::Friendly)),
""
);
}
#[test]
fn models_response_promotes_legacy_base_instructions() {
let mut value = serde_json::to_value(ModelsResponse {
models: vec![test_model(/*spec*/ None)],
})
.expect("serialize models response");
value["models"][0]["base_instructions"] = serde_json::json!("legacy instructions");
let response: ModelsResponse =
serde_json::from_value(value).expect("deserialize legacy models response");
let model = &response.models[0];
assert_eq!(
model.model_messages,
Some(ModelMessages {
instructions_template: Some("legacy instructions".to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
})
);
assert_eq!(
model.get_model_instructions(/*personality*/ None),
"legacy instructions"
);
let serialized = serde_json::to_value(response).expect("serialize canonical response");
assert_eq!(
serialized["models"][0]["base_instructions"],
"legacy instructions"
);
}
#[test]
fn models_response_rejects_model_without_instruction_source() {
let mut value = serde_json::to_value(ModelsResponse {
models: vec![test_model(/*spec*/ None)],
})
.expect("serialize models response");
value["models"][0]
.as_object_mut()
.expect("model should serialize as an object")
.remove("base_instructions")
.expect("serialized model should include legacy base instructions");
let error = serde_json::from_value::<ModelsResponse>(value)
.expect_err("model without instructions should be rejected");
assert_eq!(
error.to_string(),
"model `test-model` is missing both `base_instructions` and \
`model_messages.instructions_template`"
);
}
#[test]
fn models_response_serializes_rendered_legacy_base_instructions() {
let response = ModelsResponse {
models: vec![test_model(Some(ModelMessages {
instructions_template: Some("before {{ personality }} after".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: Some("default".to_string()),
personality_friendly: Some("friendly".to_string()),
personality_pragmatic: Some("pragmatic".to_string()),
}),
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
}))],
};
let serialized = serde_json::to_value(response).expect("serialize models response");
assert_eq!(
serialized["models"][0]["base_instructions"],
"before default after"
);
}
#[test]
fn models_response_prefers_template_and_preserves_message_siblings() {
let messages = ModelMessages {
instructions_template: None,
instructions_variables: None,
approvals: Some(ApprovalMessages {
on_request: Some("approval".to_string()),
on_request_auto_review: None,
never: Some("never approval".to_string()),
unless_trusted: Some("unless-trusted approval".to_string()),
}),
collaboration_modes: Some(CollaborationModeMessages {
default: Some("default collaboration".to_string()),
plan: Some("plan collaboration".to_string()),
}),
auto_review: Some(AutoReviewMessages {
policy: Some("policy".to_string()),
policy_template: None,
}),
permissions: Some(PermissionMessages {
danger_full_access: None,
workspace_write: Some("workspace".to_string()),
read_only: None,
}),
token_budget: None,
};
let mut value = serde_json::to_value(ModelsResponse {
models: vec![test_model(Some(messages.clone()))],
})
.expect("serialize models response");
value["models"][0]["base_instructions"] = serde_json::json!("legacy instructions");
let response: ModelsResponse =
serde_json::from_value(value).expect("deserialize legacy models response");
let mut expected_messages = messages;
expected_messages.instructions_template = Some("legacy instructions".to_string());
assert_eq!(response.models[0].model_messages, Some(expected_messages));
let canonical_messages = ModelMessages {
instructions_template: Some("canonical instructions".to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
};
let mut value = serde_json::to_value(ModelsResponse {
models: vec![test_model(Some(canonical_messages.clone()))],
})
.expect("serialize models response");
value["models"][0]["base_instructions"] = serde_json::json!("legacy instructions");
let response: ModelsResponse =
serde_json::from_value(value).expect("deserialize mixed models response");
assert_eq!(response.models[0].model_messages, Some(canonical_messages));
}
#[test]
@@ -1160,7 +1390,6 @@ mod tests {
"supported_in_api": true,
"priority": 1,
"upgrade": null,
"base_instructions": "base",
"model_messages": null,
"default_reasoning_summary": "auto",
"support_verbosity": false,
-1
View File
@@ -18,7 +18,6 @@ fn model_info() -> ModelInfo {
"priority": 1,
"availability_nux": null,
"upgrade": null,
"base_instructions": "base",
"model_messages": null,
"default_reasoning_summary": "auto",
"support_verbosity": false,
-1
View File
@@ -25,7 +25,6 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo {
default_service_tier: None,
availability_nux: None,
upgrade: None,
base_instructions: String::new(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
@@ -283,7 +283,6 @@ fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> Model
"default_service_tier": null,
"availability_nux": null,
"upgrade": null,
"base_instructions": "base instructions",
"default_reasoning_summary": "none",
"support_verbosity": false,
"default_verbosity": null,