mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user