diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index e8735af196..fe5a258f83 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index fe54f8b240..82a97d05d9 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json index 534dc963ad..657a433f88 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 181acb6ff1..cb549a9bd3 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 0d2b8a0122..4b1eb41a8e 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelUpgradeInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelUpgradeInfo.ts index 82d73e9d06..837473517c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ModelUpgradeInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelUpgradeInfo.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs index 3b77f8e607..f59451e3ac 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/model.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -128,6 +128,9 @@ pub struct ModelUpgradeInfo { pub upgrade_copy: Option, pub model_link: Option, pub migration_markdown: Option, + /// Informational Unix timestamp for this upgrade's scheduled retirement, if known. + #[ts(type = "number | null")] + pub retirement_at: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a99f9adf3a..4f8f97afd7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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.]` entries to be included in the current catalog view. diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 5f4a796326..1063e64c33 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -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(), diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index 1a287ea7c5..c6a9e025c7 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -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::(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::, _>>()?; 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 = vec![remote_model.into()]; + } = serde_json::from_value(response.result)?; + let mut expected_presets: Vec = + remote_models.into_iter().map(Into::into).collect(); ModelPreset::mark_default_by_picker_visibility(&mut expected_presets); let mut expected_items = expected_presets .iter() diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index a0ebcc7c7a..697bc13605 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -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, pub upgrade_copy: Option, pub migration_markdown: Option, + /// 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")] + #[ts(type = "string", optional)] + pub retirement_at: Option>, } #[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")] + #[ts(type = "string", optional)] + pub retirement_at: Option>, +} + +fn deserialize_optional_rfc3339_timestamp<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::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 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::(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::(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::(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::(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)) diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 3c98a1a262..b813136e70 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -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()) { diff --git a/codex-rs/tui/src/app/tests/model_catalog.rs b/codex-rs/tui/src/app/tests/model_catalog.rs index bd431a5c3b..b0eff0056c 100644 --- a/codex-rs/tui/src/app/tests/model_catalog.rs +++ b/codex-rs/tui/src/app/tests/model_catalog.rs @@ -22,6 +22,7 @@ fn model_presets_with_test_upgrades() -> Vec { 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()); diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index db0329f91b..4149dac6ee 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -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::::from_timestamp(retirement_at, 0) + }), } }); @@ -2156,6 +2163,72 @@ mod tests { } } + fn api_model_with_upgrade_retirement_at(retirement_at: Option) -> 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 {