Expose model upgrade retirement times (#38449)

## What changed

- Parse the optional `retirement_at` RFC 3339 value from model upgrade metadata, treating missing, null, or malformed values as unknown.
- Expose known retirement times from `model/list` as nullable Unix timestamps in `upgradeInfo.retirementAt` and preserve them when converting app-server models back into model presets.
- Update the generated protocol schemas and app-server documentation.

## Testing

- Cover absent, null, valid, and malformed catalog values.
- Verify `model/list` serialization and app-server model conversion for known, missing, and out-of-range timestamps.

GitOrigin-RevId: 969baf9f5b012997f460fb4611ba63c963ee658c
This commit is contained in:
Shijie Rao
2026-08-13 22:59:54 +00:00
committed by copyberry
parent 1da59ad257
commit 5cc65ecb98
14 changed files with 309 additions and 42 deletions
+1
View File
@@ -284,6 +284,7 @@ pub(super) async fn handle_model_migration_prompt_if_needed(
model_link,
upgrade_copy,
migration_markdown,
..
}) = upgrade
{
if migration_prompt_hidden(config, migration_config_key.as_str()) {
@@ -22,6 +22,7 @@ fn model_presets_with_test_upgrades() -> Vec<ModelPreset> {
model_link: None,
upgrade_copy: None,
migration_markdown: None,
retirement_at: None,
});
}
presets
@@ -305,6 +306,7 @@ async fn model_migration_prompt_skips_when_target_missing_or_hidden() {
model_link: None,
upgrade_copy: None,
migration_markdown: None,
retirement_at: None,
});
available.retain(|preset| preset.model != "gpt-5.2");
available.push(current.clone());
+73
View File
@@ -1519,6 +1519,13 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset {
.as_ref()
.and_then(|info| info.upgrade_copy.clone()),
migration_markdown: upgrade_info.and_then(|info| info.migration_markdown),
retirement_at: model
.upgrade_info
.as_ref()
.and_then(|info| info.retirement_at)
.and_then(|retirement_at| {
chrono::DateTime::<chrono::Utc>::from_timestamp(retirement_at, 0)
}),
}
});
@@ -2156,6 +2163,72 @@ mod tests {
}
}
fn api_model_with_upgrade_retirement_at(retirement_at: Option<i64>) -> ApiModel {
ApiModel {
id: "model-id".to_string(),
model: "current-model".to_string(),
upgrade: Some("replacement-model".to_string()),
upgrade_info: Some(codex_app_server_protocol::ModelUpgradeInfo {
model: "replacement-model".to_string(),
upgrade_copy: None,
model_link: None,
migration_markdown: None,
retirement_at,
}),
availability_nux: None,
display_name: "Current model".to_string(),
description: "A test model".to_string(),
model_specialty: None,
hidden: false,
supported_reasoning_efforts: Vec::new(),
default_reasoning_effort: ReasoningEffort::Medium,
input_modalities: Vec::new(),
supports_personality: false,
multi_agent_version: None,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
is_default: false,
}
}
#[test]
fn model_preset_from_api_model_preserves_upgrade_retirement_at() {
let retirement_at = chrono::DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
.expect("valid RFC 3339 timestamp")
.with_timezone(&chrono::Utc);
let expected_upgrade = |retirement_at| {
Some(ModelUpgrade {
id: "replacement-model".to_string(),
migration_config_key: "current-model".to_string(),
model_link: None,
upgrade_copy: None,
migration_markdown: None,
retirement_at,
})
};
assert_eq!(
vec![
model_preset_from_api_model(api_model_with_upgrade_retirement_at(Some(
retirement_at.timestamp(),
)))
.upgrade,
model_preset_from_api_model(api_model_with_upgrade_retirement_at(
/*retirement_at*/ None,
))
.upgrade,
model_preset_from_api_model(api_model_with_upgrade_retirement_at(Some(i64::MAX)))
.upgrade,
],
vec![
expected_upgrade(Some(retirement_at)),
expected_upgrade(None),
expected_upgrade(None),
]
);
}
#[test]
fn app_server_rate_limit_snapshots_deduplicates_top_level_limit_from_map() {
let response = GetAccountRateLimitsResponse {