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 authored and copyberry committed 2026-08-13 22:59:54 +00:00
1 parent 1da59ad257
commit 5cc65ecb98
14 files changed
+309 -42

No files matched your search

@@ -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()