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
@@ -14041,6 +14041,14 @@
"null"
]
},
"retirementAt": {
"description": "Informational Unix timestamp for this upgrade's scheduled retirement, if known.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"upgradeCopy": {
"type": [
"string",
@@ -10351,6 +10351,14 @@
"null"
]
},
"retirementAt": {
"description": "Informational Unix timestamp for this upgrade's scheduled retirement, if known.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"upgradeCopy": {
"type": [
"string",
@@ -197,6 +197,14 @@
"null"
]
},
"retirementAt": {
"description": "Informational Unix timestamp for this upgrade's scheduled retirement, if known.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"upgradeCopy": {
"type": [
"string",
@@ -2,4 +2,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ModelUpgradeInfo = { model: string, upgradeCopy: string | null, modelLink: string | null, migrationMarkdown: string | null, };
export type ModelUpgradeInfo = { model: string, upgradeCopy: string | null, modelLink: string | null, migrationMarkdown: string | null,
/**
* Informational Unix timestamp for this upgrade's scheduled retirement, if known.
*/
retirementAt: number | null, };
@@ -128,6 +128,9 @@ pub struct ModelUpgradeInfo {
pub upgrade_copy: Option<String>,
pub model_link: Option<String>,
pub migration_markdown: Option<String>,
/// Informational Unix timestamp for this upgrade's scheduled retirement, if known.
#[ts(type = "number | null")]
pub retirement_at: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
+1 -1
View File
@@ -230,7 +230,7 @@ Example with notification opt-out:
- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`.
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, optional `modelSpecialty`, nullable `multiAgentVersion` (`disabled`, `v1`, or `v2`), `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, optional `modelSpecialty`, nullable `multiAgentVersion` (`disabled`, `v1`, or `v2`), `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`, nullable informational `retirementAt` Unix timestamp), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names.
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text and an `allowed` flag reflecting effective requirements, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
+4
View File
@@ -34,6 +34,10 @@ fn model_from_preset(preset: ModelPreset) -> Model {
upgrade_copy: upgrade.upgrade_copy.clone(),
model_link: upgrade.model_link.clone(),
migration_markdown: upgrade.migration_markdown.clone(),
retirement_at: upgrade
.retirement_at
.as_ref()
.map(chrono::DateTime::timestamp),
}),
availability_nux: preset.availability_nux.map(Into::into),
display_name: preset.display_name.to_string(),
@@ -40,6 +40,10 @@ fn model_from_preset(preset: &ModelPreset) -> Model {
upgrade_copy: upgrade.upgrade_copy.clone(),
model_link: upgrade.model_link.clone(),
migration_markdown: upgrade.migration_markdown.clone(),
retirement_at: upgrade
.retirement_at
.as_ref()
.map(chrono::DateTime::timestamp),
}),
availability_nux: preset.availability_nux.clone().map(Into::into),
display_name: preset.display_name.clone(),
@@ -155,38 +159,48 @@ async fn list_models_includes_hidden_models() -> Result<()> {
#[tokio::test]
async fn list_models_uses_chatgpt_remote_catalog_as_source_of_truth() -> Result<()> {
let server = MockServer::start().await;
let remote_model: ModelInfo = serde_json::from_value(json!({
"slug": "chatgpt-remote-only",
"display_name": "ChatGPT Remote Only",
"description": "Remote-only model for app-server model/list coverage",
"model_specialty": MODEL_SPECIALTY_CYBER,
"default_reasoning_level": "max",
"supported_reasoning_levels": [
{"effort": "max", "description": "Maximum"},
{"effort": "low", "description": "Low"},
{"effort": "focused", "description": "Focused"}
],
"shell_type": "shell_command",
"visibility": "list",
"minimal_client_version": [0, 1, 0],
"supported_in_api": true,
"priority": 0,
"upgrade": null,
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
"truncation_policy": {"mode": "bytes", "limit": 10_000},
"supports_parallel_tool_calls": false,
"supports_image_detail_original": false,
"multi_agent_version": "v2",
"context_window": 272_000,
"max_context_window": 272_000,
"experimental_supported_tools": [],
}))?;
let remote_models = [json!("2030-01-01T00:00:00Z"), serde_json::Value::Null]
.into_iter()
.enumerate()
.map(|(priority, retirement_at)| {
serde_json::from_value::<ModelInfo>(json!({
"slug": format!("chatgpt-remote-only-{priority}"),
"display_name": "ChatGPT Remote Only",
"description": "Remote-only model for app-server model/list coverage",
"model_specialty": MODEL_SPECIALTY_CYBER,
"default_reasoning_level": "max",
"supported_reasoning_levels": [
{"effort": "max", "description": "Maximum"},
{"effort": "low", "description": "Low"},
{"effort": "focused", "description": "Focused"}
],
"shell_type": "shell_command",
"visibility": "list",
"minimal_client_version": [0, 1, 0],
"supported_in_api": true,
"priority": priority,
"upgrade": {
"model": "replacement-model",
"migration_markdown": "Use the replacement model.",
"retirement_at": retirement_at,
},
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
"truncation_policy": {"mode": "bytes", "limit": 10_000},
"supports_parallel_tool_calls": false,
"supports_image_detail_original": false,
"multi_agent_version": "v2",
"context_window": 272_000,
"max_context_window": 272_000,
"experimental_supported_tools": [],
}))
})
.collect::<Result<Vec<_>, _>>()?;
let models_mock = mount_models_once(
&server,
ModelsResponse {
models: vec![remote_model.clone()],
models: remote_models.clone(),
},
)
.await;
@@ -216,20 +230,30 @@ openai_base_url = "{server_uri}/v1"
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized()
.await?;
let request_id = mcp
.send_list_models_request(ModelListParams {
limit: Some(100),
cursor: None,
include_hidden: None,
})
.await?;
let response = mcp
.read_stream_until_response_message(RequestId::Integer(request_id))
.await?;
assert_eq!(
response.result["data"][0]["upgradeInfo"]["retirementAt"],
json!(1_893_456_000)
);
assert_eq!(
response.result["data"][1]["upgradeInfo"]["retirementAt"],
serde_json::Value::Null
);
let ModelListResponse {
data: items,
next_cursor,
} = mcp
.request(|request_id| ClientRequest::ModelList {
request_id,
params: ModelListParams {
limit: Some(100),
cursor: None,
include_hidden: None,
},
})
.await?;
let mut expected_presets: Vec<ModelPreset> = vec![remote_model.into()];
} = serde_json::from_value(response.result)?;
let mut expected_presets: Vec<ModelPreset> =
remote_models.into_iter().map(Into::into).collect();
ModelPreset::mark_default_by_picker_visibility(&mut expected_presets);
let mut expected_items = expected_presets
.iter()
+132
View File
@@ -6,6 +6,8 @@
use std::fmt;
use std::str::FromStr;
use chrono::DateTime;
use chrono::Utc;
use schemars::JsonSchema;
use schemars::r#gen::SchemaGenerator;
use schemars::schema::InstanceType;
@@ -187,6 +189,15 @@ pub struct ModelUpgrade {
pub model_link: Option<String>,
pub upgrade_copy: Option<String>,
pub migration_markdown: Option<String>,
/// Informational time when the model associated with this upgrade is scheduled to retire.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_rfc3339_timestamp"
)]
#[schemars(with = "Option<String>")]
#[ts(type = "string", optional)]
pub retirement_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
@@ -616,6 +627,29 @@ impl ModelInstructionsVariables {
pub struct ModelInfoUpgrade {
pub model: String,
pub migration_markdown: String,
/// Informational time when the model associated with this upgrade is scheduled to retire.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_rfc3339_timestamp"
)]
#[schemars(with = "Option<String>")]
#[ts(type = "string", optional)]
pub retirement_at: Option<DateTime<Utc>>,
}
fn deserialize_optional_rfc3339_timestamp<'de, D>(
deserializer: D,
) -> Result<Option<DateTime<Utc>>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(value
.as_ref()
.and_then(serde_json::Value::as_str)
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.with_timezone(&Utc)))
}
impl From<&ModelUpgrade> for ModelInfoUpgrade {
@@ -623,6 +657,7 @@ impl From<&ModelUpgrade> for ModelInfoUpgrade {
ModelInfoUpgrade {
model: upgrade.id.clone(),
migration_markdown: upgrade.migration_markdown.clone().unwrap_or_default(),
retirement_at: upgrade.retirement_at,
}
}
}
@@ -750,6 +785,7 @@ impl From<ModelInfo> for ModelPreset {
model_link: None,
upgrade_copy: None,
migration_markdown: Some(upgrade.migration_markdown.clone()),
retirement_at: upgrade.retirement_at,
}),
show_in_picker: info.visibility == ModelVisibility::List,
multi_agent_version: info.multi_agent_version,
@@ -1448,6 +1484,102 @@ mod tests {
assert_eq!(model.tool_mode, None);
}
#[test]
fn model_info_deserializes_optional_upgrade_retirement_at() {
let base = serde_json::to_value(test_model(/*spec*/ None))
.expect("serialize test model without retirement time");
let mut absent = base.clone();
absent
.as_object_mut()
.expect("model info should be an object")
.insert(
"upgrade".to_string(),
serde_json::json!({
"model": "replacement-model",
"migration_markdown": "Use the replacement model."
}),
);
let absent = serde_json::from_value::<ModelInfo>(absent)
.expect("deserialize model info without upgrade retirement time");
assert_eq!(
absent
.upgrade
.as_ref()
.and_then(|upgrade| upgrade.retirement_at.as_ref())
.map(DateTime::timestamp),
None
);
let mut null = base.clone();
null.as_object_mut()
.expect("model info should be an object")
.insert(
"upgrade".to_string(),
serde_json::json!({
"model": "replacement-model",
"migration_markdown": "Use the replacement model.",
"retirement_at": null
}),
);
let null = serde_json::from_value::<ModelInfo>(null)
.expect("deserialize model info with null upgrade retirement time");
assert_eq!(
null.upgrade
.as_ref()
.and_then(|upgrade| upgrade.retirement_at.as_ref())
.map(DateTime::timestamp),
None
);
let mut populated = base;
populated
.as_object_mut()
.expect("model info should be an object")
.insert(
"upgrade".to_string(),
serde_json::json!({
"model": "replacement-model",
"migration_markdown": "Use the replacement model.",
"retirement_at": "2030-01-01T00:00:00Z"
}),
);
let populated = serde_json::from_value::<ModelInfo>(populated)
.expect("deserialize model info with upgrade retirement time");
assert_eq!(
populated
.upgrade
.as_ref()
.and_then(|upgrade| upgrade.retirement_at.as_ref())
.map(DateTime::timestamp),
Some(1_893_456_000)
);
let mut malformed = serde_json::to_value(test_model(/*spec*/ None))
.expect("serialize test model for malformed retirement time");
malformed
.as_object_mut()
.expect("model info should be an object")
.insert(
"upgrade".to_string(),
serde_json::json!({
"model": "replacement-model",
"migration_markdown": "Use the replacement model.",
"retirement_at": "not-a-timestamp"
}),
);
let malformed = serde_json::from_value::<ModelInfo>(malformed)
.expect("tolerate malformed upgrade retirement time");
assert_eq!(
malformed
.upgrade
.as_ref()
.and_then(|upgrade| upgrade.retirement_at.as_ref())
.map(DateTime::timestamp),
None
);
}
#[test]
fn model_info_preserves_explicit_apps_guidance_opt_out() {
let value = serde_json::to_value(test_model(/*spec*/ None))
+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 {