From 7461cbb723879b1a857808734e1ab1aabdb9acc0 Mon Sep 17 00:00:00 2001 From: YYQ Date: Wed, 3 Jun 2026 17:39:24 +0800 Subject: [PATCH 1/2] Fix stale model catalog cleanup --- crates/core/src/storage/model_groups.rs | 29 + crates/core/src/storage/model_options.rs | 1 + crates/core/src/storage/model_sources.rs | 20 + crates/service/src/apikey/apikey_models.rs | 596 ++++++++++++++---- .../src/gateway/request/local_models.rs | 8 +- .../request/tests/local_models_tests.rs | 23 +- crates/service/src/model_groups.rs | 4 + 7 files changed, 521 insertions(+), 160 deletions(-) diff --git a/crates/core/src/storage/model_groups.rs b/crates/core/src/storage/model_groups.rs index e9174ab85..94dcaf1b5 100644 --- a/crates/core/src/storage/model_groups.rs +++ b/crates/core/src/storage/model_groups.rs @@ -112,6 +112,7 @@ impl Storage { AND TRIM(slug) <> ''", params![DEFAULT_MODEL_GROUP_ID, now], )?; + self.prune_default_model_group_models_not_in_catalog()?; self.conn.execute( "INSERT OR IGNORE INTO user_model_groups ( user_id, group_id, status, expires_at, created_at, updated_at @@ -125,6 +126,34 @@ impl Storage { Ok(()) } + pub fn prune_default_model_group_models_not_in_catalog(&self) -> Result<()> { + self.conn.execute( + "DELETE FROM model_group_models + WHERE group_id IN (SELECT id FROM model_groups WHERE is_default = 1) + AND platform_model_slug NOT IN ( + SELECT slug + FROM model_catalog_models + WHERE scope = 'default' + AND COALESCE(supported_in_api, 1) = 1 + AND TRIM(slug) <> '' + )", + [], + )?; + Ok(()) + } + + pub fn delete_model_group_model_references(&self, platform_model_slug: &str) -> Result<()> { + let slug = platform_model_slug.trim(); + if slug.is_empty() { + return Ok(()); + } + self.conn.execute( + "DELETE FROM model_group_models WHERE platform_model_slug = ?1", + params![slug], + )?; + Ok(()) + } + pub fn default_model_group_id(&self) -> Result> { self.conn .query_row( diff --git a/crates/core/src/storage/model_options.rs b/crates/core/src/storage/model_options.rs index c829fe4b0..59e563f68 100644 --- a/crates/core/src/storage/model_options.rs +++ b/crates/core/src/storage/model_options.rs @@ -170,6 +170,7 @@ impl Storage { return Ok(()); } + self.prune_default_model_group_models_not_in_catalog()?; let now = now_ts(); self.conn.execute( "INSERT OR IGNORE INTO model_group_models ( diff --git a/crates/core/src/storage/model_sources.rs b/crates/core/src/storage/model_sources.rs index 58432a031..fe855537c 100644 --- a/crates/core/src/storage/model_sources.rs +++ b/crates/core/src/storage/model_sources.rs @@ -368,4 +368,24 @@ impl Storage { )?; Ok(()) } + + pub fn delete_model_source_routes_for_platform_model( + &self, + platform_model_slug: &str, + ) -> Result<()> { + let slug = normalize_text(platform_model_slug); + if slug.is_empty() { + return Ok(()); + } + self.conn.execute( + "DELETE FROM model_source_mappings + WHERE platform_model_slug = ?1 OR upstream_model = ?1", + params![&slug], + )?; + self.conn.execute( + "DELETE FROM model_source_models WHERE upstream_model = ?1", + params![&slug], + )?; + Ok(()) + } } diff --git a/crates/service/src/apikey/apikey_models.rs b/crates/service/src/apikey/apikey_models.rs index c7bffb181..a7283114b 100644 --- a/crates/service/src/apikey/apikey_models.rs +++ b/crates/service/src/apikey/apikey_models.rs @@ -76,7 +76,7 @@ pub(crate) fn read_managed_model_catalog( let cached_catalog = read_managed_model_catalog_from_storage(&storage)?; let cached = managed_catalog_to_models_response(&cached_catalog); if !refresh_remote && !cached.is_empty() { - return Ok(ensure_codex_image_tool_model_in_catalog(&cached_catalog)); + return Ok(cached_catalog); } match gateway::fetch_models_for_picker() { @@ -88,7 +88,7 @@ pub(crate) fn read_managed_model_catalog( "event=model_catalog_refresh_ignored_empty_remote cached_models={}", cached_catalog.items.len() ); - return Ok(ensure_codex_image_tool_model_in_catalog(&cached_catalog)); + return Ok(cached_catalog); } if refresh_remote { return Err( @@ -96,9 +96,7 @@ pub(crate) fn read_managed_model_catalog( ); } } - let merged_catalog = ensure_codex_image_tool_model_in_catalog( - &merge_managed_model_catalog(cached_catalog.clone(), models), - ); + let merged_catalog = merge_managed_model_catalog(cached_catalog.clone(), models); if !merged_catalog.items.is_empty() { let _ = save_managed_model_catalog_with_storage(&storage, &merged_catalog); } @@ -106,14 +104,12 @@ pub(crate) fn read_managed_model_catalog( } Err(err) => { if managed_catalog_has_catalog_text_model(&cached_catalog) { - return Ok(ensure_codex_image_tool_model_in_catalog(&cached_catalog)); + return Ok(cached_catalog); } if refresh_remote { Err(err) } else { - Ok(ensure_codex_image_tool_model_in_catalog( - &ManagedModelCatalogResult::default(), - )) + Ok(ManagedModelCatalogResult::default()) } } } @@ -141,6 +137,7 @@ fn managed_catalog_has_catalog_text_model(catalog: &ManagedModelCatalogResult) - pub(crate) fn read_managed_model_catalog_from_storage( storage: &Storage, ) -> Result { + purge_codex_image_tool_model(storage)?; let rows = storage .list_model_catalog_models(MODEL_CACHE_SCOPE_DEFAULT) .map_err(|e| e.to_string())?; @@ -227,6 +224,12 @@ pub(crate) fn save_managed_model_catalog_model( let current_catalog = read_managed_model_catalog_from_storage(&storage)?; let normalized_model = normalize_model_info(params.model).ok_or_else(|| "模型 slug 不能为空".to_string())?; + if is_codex_image_tool_slug(normalized_model.slug.as_str()) { + return Err(format!( + "模型 `{}` 是 Codex 图片工具的内部模型,不能加入平台模型目录", + normalized_model.slug + )); + } let previous_slug = params .previous_slug .as_deref() @@ -306,6 +309,25 @@ pub(crate) fn delete_managed_model_catalog_model(slug: &str) -> Result<(), Strin delete_model_catalog_entry(&storage, normalized_slug) } +pub(crate) fn prune_stale_remote_managed_model_catalog() -> Result +{ + let storage = + storage_helpers::open_storage().ok_or_else(|| "storage unavailable".to_string())?; + let cached_catalog = read_managed_model_catalog_from_storage(&storage)?; + let remote_models = gateway::fetch_models_for_picker()?; + let remote_models = normalize_models_response(remote_models); + if !models_response_has_catalog_text_model(&remote_models) { + return Err("远端模型目录没有返回可用 Codex 文本模型,已拒绝清理本地目录".to_string()); + } + + let merged_catalog = merge_managed_model_catalog(cached_catalog, remote_models.clone()); + if !merged_catalog.items.is_empty() { + save_managed_model_catalog_with_storage(&storage, &merged_catalog)?; + } + prune_unedited_remote_model_catalog_entries_missing_from_remote(&storage, &remote_models)?; + read_managed_model_catalog_from_storage(&storage) +} + pub(crate) fn read_managed_model_routing() -> Result { let storage = storage_helpers::open_storage().ok_or_else(|| "storage unavailable".to_string())?; @@ -795,19 +817,12 @@ fn read_account_pool_platform_catalog( ) -> Result { let cached_catalog = read_managed_model_catalog_from_storage(storage)?; if !cached_catalog.items.is_empty() { - let catalog = ensure_codex_image_tool_model_in_catalog(&cached_catalog); - if catalog.items.len() != cached_catalog.items.len() { - let _ = save_managed_model_catalog_with_storage(storage, &catalog); - } - return Ok(catalog); + return Ok(cached_catalog); } if allow_remote_catalog_fetch { if let Ok(models) = gateway::fetch_models_for_picker() { - let catalog = ensure_codex_image_tool_model_in_catalog(&merge_managed_model_catalog( - cached_catalog.clone(), - models, - )); + let catalog = merge_managed_model_catalog(cached_catalog.clone(), models); if !catalog.items.is_empty() { save_managed_model_catalog_with_storage(storage, &catalog)?; return Ok(catalog); @@ -1071,76 +1086,14 @@ fn generate_mapping_id() -> String { } fn managed_catalog_to_models_response(catalog: &ManagedModelCatalogResult) -> ModelsResponse { - ensure_codex_image_tool_model_listed(&ModelsResponse { + ModelsResponse { models: catalog .items .iter() .map(|item| item.model.clone()) .collect::>(), extra: catalog.extra.clone(), - }) -} - -fn codex_image_tool_model_info() -> ModelInfo { - let mut model = ModelInfo { - slug: CODEX_IMAGE_TOOL_MODEL.to_string(), - display_name: "GPT Image 2".to_string(), - description: Some("Image generation tool model for Codex image workflows.".to_string()), - supported_in_api: true, - visibility: Some("list".to_string()), - input_modalities: vec!["text".to_string(), "image".to_string()], - ..Default::default() - }; - model.extra.insert( - "output_modalities".to_string(), - serde_json::json!(["image"]), - ); - model -} - -pub(crate) fn ensure_codex_image_tool_model_listed(models: &ModelsResponse) -> ModelsResponse { - if models.models.iter().any(|item| { - item.slug - .trim() - .eq_ignore_ascii_case(CODEX_IMAGE_TOOL_MODEL) - }) { - return models.clone(); } - let mut augmented = models.clone(); - augmented.models.push(codex_image_tool_model_info()); - augmented.extra.remove("etag"); - augmented -} - -fn ensure_codex_image_tool_model_in_catalog( - catalog: &ManagedModelCatalogResult, -) -> ManagedModelCatalogResult { - if catalog.items.iter().any(|item| { - item.model - .slug - .trim() - .eq_ignore_ascii_case(CODEX_IMAGE_TOOL_MODEL) - }) { - return catalog.clone(); - } - - let mut augmented = catalog.clone(); - let sort_index = augmented - .items - .iter() - .map(|item| item.sort_index) - .max() - .unwrap_or(-1) - + 1; - augmented.items.push(ManagedModelCatalogEntry { - model: codex_image_tool_model_info(), - source_kind: MODEL_SOURCE_KIND_REMOTE.to_string(), - user_edited: false, - sort_index, - updated_at: 0, - }); - augmented.extra.remove("etag"); - augmented } fn normalize_managed_model_catalog( @@ -1152,6 +1105,9 @@ fn normalize_managed_model_catalog( let Some(model) = normalize_model_info(item.model) else { continue; }; + if is_codex_image_tool_slug(model.slug.as_str()) { + continue; + } if !seen.insert(model.slug.clone()) { continue; } @@ -1264,6 +1220,9 @@ pub(crate) fn normalize_models_response(response: ModelsResponse) -> ModelsRespo let mut seen = HashSet::new(); for model in response.models { if let Some(normalized) = normalize_model_info(model) { + if is_codex_image_tool_slug(normalized.slug.as_str()) { + continue; + } if seen.insert(normalized.slug.clone()) { models.push(normalized); } @@ -1557,6 +1516,7 @@ fn save_managed_model_catalog_rows( storage .upsert_model_catalog_available_in_plans(&available_in_plans) .map_err(|e| e.to_string())?; + purge_codex_image_tool_model(storage)?; Ok(()) } @@ -1703,6 +1663,12 @@ fn replace_model_catalog_entry( } fn delete_model_catalog_entry(storage: &Storage, slug: &str) -> Result<(), String> { + storage + .delete_model_group_model_references(slug) + .map_err(|e| e.to_string())?; + storage + .delete_model_source_routes_for_platform_model(slug) + .map_err(|e| e.to_string())?; storage .delete_model_catalog_reasoning_levels(MODEL_CACHE_SCOPE_DEFAULT, slug) .map_err(|e| e.to_string())?; @@ -1732,6 +1698,34 @@ fn delete_model_catalog_entry(storage: &Storage, slug: &str) -> Result<(), Strin Ok(()) } +fn prune_unedited_remote_model_catalog_entries_missing_from_remote( + storage: &Storage, + remote_models: &ModelsResponse, +) -> Result<(), String> { + let remote_slugs = remote_models + .models + .iter() + .map(|model| model.slug.as_str()) + .collect::>(); + let rows = storage + .list_model_catalog_models(MODEL_CACHE_SCOPE_DEFAULT) + .map_err(|e| e.to_string())?; + for row in rows { + let source_kind = normalize_source_kind(Some(row.source_kind.as_str())); + if source_kind == MODEL_SOURCE_KIND_REMOTE + && !row.user_edited + && !remote_slugs.contains(row.slug.as_str()) + { + delete_model_catalog_entry(storage, row.slug.as_str())?; + } + } + Ok(()) +} + +fn purge_codex_image_tool_model(storage: &Storage) -> Result<(), String> { + delete_model_catalog_entry(storage, CODEX_IMAGE_TOOL_MODEL) +} + fn reasoning_records_from_model( model: &ModelInfo, updated_at: i64, @@ -2029,20 +2023,22 @@ mod tests { use std::collections::BTreeMap; use codexmanager_core::storage::{ - now_ts, Account, AggregateApi, ModelSourceMapping, ModelSourceModel, Storage, + now_ts, Account, AggregateApi, ModelCatalogModelRecord, ModelGroupModel, + ModelSourceMapping, ModelSourceModel, Storage, }; use serde_json::{json, Value}; use super::{ auto_associate_aggregate_api_source_models, auto_associate_source_models, bootstrap_account_pool_model_routes, bootstrap_aggregate_api_model_routes, - ensure_codex_image_tool_model_in_catalog, ensure_codex_image_tool_model_listed, - managed_catalog_to_models_response, merge_managed_model_catalog, merge_models_response, - normalize_models_response, read_managed_model_catalog_from_storage, - read_managed_model_routing_from_storage, read_model_options_from_storage, - save_managed_model_catalog_with_storage, save_model_options_with_storage, - sync_aggregate_api_source_models, sync_aggregate_api_source_models_with_discovery, - MODEL_SOURCE_KIND_CUSTOM, MODEL_SOURCE_KIND_REMOTE, ROUTING_SOURCE_KIND_AGGREGATE_API, + delete_model_catalog_entry, managed_catalog_to_models_response, + merge_managed_model_catalog, merge_models_response, normalize_managed_model_catalog, + normalize_models_response, prune_unedited_remote_model_catalog_entries_missing_from_remote, + read_managed_model_catalog_from_storage, read_managed_model_routing_from_storage, + read_model_options_from_storage, save_managed_model_catalog_with_storage, + save_model_options_with_storage, sync_aggregate_api_source_models, + sync_aggregate_api_source_models_with_discovery, MODEL_SOURCE_KIND_CUSTOM, + MODEL_SOURCE_KIND_REMOTE, ROUTING_SOURCE_KIND_AGGREGATE_API, ROUTING_SOURCE_KIND_OPENAI_ACCOUNT, }; use codexmanager_core::rpc::types::{ @@ -2123,6 +2119,45 @@ mod tests { save_managed_model_catalog_with_storage(storage, &payload).expect("seed platform catalog"); } + fn model_catalog_record(slug: &str) -> ModelCatalogModelRecord { + ModelCatalogModelRecord { + scope: "default".to_string(), + slug: slug.to_string(), + display_name: slug.to_string(), + source_kind: MODEL_SOURCE_KIND_REMOTE.to_string(), + user_edited: false, + description: None, + default_reasoning_level: None, + shell_type: None, + visibility: Some("list".to_string()), + supported_in_api: Some(true), + priority: Some(0), + availability_nux_json: None, + upgrade_json: None, + base_instructions: None, + model_messages_json: None, + supports_reasoning_summaries: None, + default_reasoning_summary: None, + support_verbosity: None, + default_verbosity_json: None, + apply_patch_tool_type: None, + web_search_tool_type: None, + truncation_mode: None, + truncation_limit: None, + truncation_extra_json: None, + supports_parallel_tool_calls: None, + supports_image_detail_original: None, + context_window: None, + auto_compact_token_limit: None, + effective_context_window_percent: None, + minimal_client_version_json: None, + supports_search_tool: None, + extra_json: "{}".to_string(), + sort_index: 0, + updated_at: now_ts(), + } + } + #[test] fn normalize_models_response_keeps_full_model_metadata() { let response = ModelsResponse { @@ -2257,73 +2292,80 @@ mod tests { } #[test] - fn model_options_response_appends_codex_image_tool_model() { + fn normalize_models_response_filters_codex_image_tool_model() { let response = ModelsResponse { - models: vec![ModelInfo { - slug: "gpt-5.4-mini".to_string(), - display_name: "GPT-5.4 Mini".to_string(), - supported_in_api: true, - ..Default::default() - }], + models: vec![ + ModelInfo { + slug: "gpt-5.4-mini".to_string(), + display_name: "GPT-5.4 Mini".to_string(), + supported_in_api: true, + ..Default::default() + }, + ModelInfo { + slug: "gpt-image-2".to_string(), + display_name: "GPT Image 2".to_string(), + supported_in_api: true, + ..Default::default() + }, + ], extra: BTreeMap::from([("etag".to_string(), json!("cached"))]), }; - let augmented = ensure_codex_image_tool_model_listed(&response); + let normalized = normalize_models_response(response); assert_eq!( - augmented + normalized .models .iter() .map(|model| model.slug.as_str()) .collect::>(), - vec!["gpt-5.4-mini", "gpt-image-2"] - ); - let image_model = augmented - .models - .iter() - .find(|model| model.slug == "gpt-image-2") - .expect("image tool model"); - assert_eq!(image_model.display_name, "GPT Image 2"); - assert_eq!( - image_model.input_modalities, - vec!["text".to_string(), "image".to_string()] + vec!["gpt-5.4-mini"] ); assert_eq!( - image_model - .extra - .get("output_modalities") - .and_then(Value::as_array) - .and_then(|items| items.first()) - .and_then(Value::as_str), - Some("image") + normalized.extra.get("etag").and_then(Value::as_str), + Some("cached") ); - assert!(!augmented.extra.contains_key("etag")); } #[test] - fn managed_catalog_response_appends_codex_image_tool_model_once() { + fn normalize_managed_catalog_filters_codex_image_tool_model() { let catalog = ManagedModelCatalogResult { - items: vec![ManagedModelCatalogEntry { - model: ModelInfo { - slug: "gpt-image-2".to_string(), - display_name: "Existing Image Model".to_string(), - supported_in_api: true, - ..Default::default() + items: vec![ + ManagedModelCatalogEntry { + model: ModelInfo { + slug: "gpt-5.4-mini".to_string(), + display_name: "GPT-5.4 Mini".to_string(), + supported_in_api: true, + ..Default::default() + }, + source_kind: MODEL_SOURCE_KIND_REMOTE.to_string(), + user_edited: false, + sort_index: 0, + updated_at: 123, }, - source_kind: MODEL_SOURCE_KIND_CUSTOM.to_string(), - user_edited: true, - sort_index: 7, - updated_at: 123, - }], - extra: BTreeMap::new(), + ManagedModelCatalogEntry { + model: ModelInfo { + slug: "gpt-image-2".to_string(), + display_name: "GPT Image 2".to_string(), + supported_in_api: true, + ..Default::default() + }, + source_kind: MODEL_SOURCE_KIND_CUSTOM.to_string(), + user_edited: true, + sort_index: 1, + updated_at: 123, + }, + ], + extra: BTreeMap::from([("etag".to_string(), json!("cached"))]), }; - let augmented = ensure_codex_image_tool_model_in_catalog(&catalog); - let response = managed_catalog_to_models_response(&augmented); + let normalized = normalize_managed_model_catalog(catalog); + let response = managed_catalog_to_models_response(&normalized); - assert_eq!(augmented.items.len(), 1); + assert_eq!(normalized.items.len(), 1); + assert_eq!(normalized.items[0].model.slug, "gpt-5.4-mini"); assert_eq!(response.models.len(), 1); - assert_eq!(response.models[0].display_name, "Existing Image Model"); + assert_eq!(response.models[0].slug, "gpt-5.4-mini"); } #[test] @@ -2348,10 +2390,12 @@ mod tests { save_model_options_with_storage(&storage, &payload).expect("seed structured catalog"); let response = read_model_options_from_storage(&storage).expect("read models"); - assert_eq!(response.models.len(), 2); + assert_eq!(response.models.len(), 1); assert_eq!(response.models[0].slug, "gpt-5.4"); - assert_eq!(response.models[1].slug, "gpt-image-2"); - assert_eq!(response.extra.get("etag").and_then(Value::as_str), None); + assert_eq!( + response.extra.get("etag").and_then(Value::as_str), + Some("legacy") + ); let scope = storage .get_model_catalog_scope("default") @@ -2390,6 +2434,147 @@ mod tests { ); } + #[test] + fn managed_catalog_save_prunes_stale_default_model_group_models() { + let storage = Storage::open_in_memory().expect("open storage"); + storage.init().expect("init storage"); + let now = now_ts(); + storage + .replace_model_group_models( + "mg_default", + &[ModelGroupModel { + group_id: "mg_default".to_string(), + platform_model_slug: "gpt-stale".to_string(), + enabled: true, + rate_multiplier_millis: None, + billing_model_slug: None, + note: Some("stale".to_string()), + created_at: now, + updated_at: now, + }], + ) + .expect("seed stale default model group model"); + + seed_platform_catalog(&storage, &["gpt-current"]); + + let slugs = storage + .list_model_group_models_for_group("mg_default") + .expect("list default model group models") + .into_iter() + .map(|model| model.platform_model_slug) + .collect::>(); + assert_eq!(slugs, vec!["gpt-current"]); + } + + #[test] + fn managed_catalog_save_preserves_catalog_rows_absent_from_payload() { + let storage = Storage::open_in_memory().expect("open storage"); + storage.init().expect("init storage"); + seed_platform_catalog(&storage, &["gpt-old"]); + + seed_platform_catalog(&storage, &["gpt-current"]); + + let mut catalog_slugs = storage + .list_model_catalog_models("default") + .expect("list catalog") + .into_iter() + .map(|model| model.slug) + .collect::>(); + catalog_slugs.sort(); + let mut group_slugs = storage + .list_model_group_models_for_group("mg_default") + .expect("list default model group models") + .into_iter() + .map(|model| model.platform_model_slug) + .collect::>(); + group_slugs.sort(); + assert_eq!(catalog_slugs, vec!["gpt-current", "gpt-old"]); + assert_eq!(group_slugs, vec!["gpt-current", "gpt-old"]); + } + + #[test] + fn delete_model_catalog_entry_removes_model_group_and_source_routes() { + let storage = Storage::open_in_memory().expect("open storage"); + storage.init().expect("init storage"); + seed_platform_catalog(&storage, &["gpt-delete"]); + storage + .upsert_discovered_model_source_models( + ROUTING_SOURCE_KIND_OPENAI_ACCOUNT, + "acc-delete", + &["gpt-delete".to_string()], + "synced", + ) + .expect("seed source model"); + let now = now_ts(); + storage + .upsert_model_source_mapping(&ModelSourceMapping { + id: "mapping-delete".to_string(), + platform_model_slug: "gpt-delete".to_string(), + source_kind: ROUTING_SOURCE_KIND_OPENAI_ACCOUNT.to_string(), + source_id: "acc-delete".to_string(), + upstream_model: "gpt-delete".to_string(), + enabled: true, + priority: 0, + weight: 1, + billing_model_slug: None, + created_at: now, + updated_at: now, + }) + .expect("seed mapping"); + + delete_model_catalog_entry(&storage, "gpt-delete").expect("delete catalog entry"); + + assert!(storage + .list_model_catalog_models("default") + .expect("list catalog") + .is_empty()); + assert!(storage + .list_model_group_models_for_group("mg_default") + .expect("list default model group models") + .is_empty()); + assert!(storage + .list_model_source_models(Some(ROUTING_SOURCE_KIND_OPENAI_ACCOUNT), Some("acc-delete")) + .expect("list source models") + .is_empty()); + assert!(storage + .list_model_source_mappings(Some("gpt-delete")) + .expect("list mappings") + .is_empty()); + } + + #[test] + fn read_managed_catalog_purges_existing_codex_image_tool_rows() { + let storage = Storage::open_in_memory().expect("open storage"); + storage.init().expect("init storage"); + storage + .upsert_model_catalog_models(&[model_catalog_record("gpt-image-2")]) + .expect("seed legacy image catalog row"); + + assert!(storage + .list_model_catalog_models("default") + .expect("list catalog before purge") + .iter() + .any(|model| model.slug == "gpt-image-2")); + assert!(storage + .list_model_group_models_for_group("mg_default") + .expect("list default model group models before purge") + .iter() + .any(|model| model.platform_model_slug == "gpt-image-2")); + + let response = + read_managed_model_catalog_from_storage(&storage).expect("read managed catalog"); + + assert!(response.items.is_empty()); + assert!(storage + .list_model_catalog_models("default") + .expect("list catalog after purge") + .is_empty()); + assert!(storage + .list_model_group_models_for_group("mg_default") + .expect("list default model group models after purge") + .is_empty()); + } + #[test] fn managed_catalog_round_trip_preserves_source_kind_and_user_overrides() { let storage = Storage::open_in_memory().expect("open storage"); @@ -2468,6 +2653,143 @@ mod tests { assert!(merged.items[0].user_edited); } + #[test] + fn merge_managed_catalog_preserves_unedited_remote_entries_missing_from_remote() { + let cached = ManagedModelCatalogResult { + items: vec![ + ManagedModelCatalogEntry { + model: serde_json::from_value(json!({ + "slug": "gpt-stale", + "display_name": "GPT Stale", + "supported_in_api": true + })) + .expect("parse stale model"), + source_kind: MODEL_SOURCE_KIND_REMOTE.to_string(), + user_edited: false, + sort_index: 0, + updated_at: 10, + }, + ManagedModelCatalogEntry { + model: serde_json::from_value(json!({ + "slug": "gpt-custom", + "display_name": "GPT Custom", + "supported_in_api": true + })) + .expect("parse custom model"), + source_kind: MODEL_SOURCE_KIND_CUSTOM.to_string(), + user_edited: false, + sort_index: 1, + updated_at: 11, + }, + ], + extra: BTreeMap::new(), + }; + let incoming = ModelsResponse { + models: vec![serde_json::from_value(json!({ + "slug": "gpt-current", + "display_name": "GPT Current", + "supported_in_api": true + })) + .expect("parse incoming model")], + extra: BTreeMap::new(), + }; + + let merged = merge_managed_model_catalog(cached, incoming); + + assert_eq!( + merged + .items + .iter() + .map(|item| item.model.slug.as_str()) + .collect::>(), + vec!["gpt-current", "gpt-stale", "gpt-custom"] + ); + } + + #[test] + fn prune_stale_remote_models_only_deletes_unedited_remote_entries() { + let storage = Storage::open_in_memory().expect("open storage"); + storage.init().expect("init storage"); + let payload = ManagedModelCatalogResult { + items: vec![ + ManagedModelCatalogEntry { + model: serde_json::from_value(json!({ + "slug": "gpt-current", + "display_name": "GPT Current", + "supported_in_api": true + })) + .expect("parse current model"), + source_kind: MODEL_SOURCE_KIND_REMOTE.to_string(), + user_edited: false, + sort_index: 0, + updated_at: 10, + }, + ManagedModelCatalogEntry { + model: serde_json::from_value(json!({ + "slug": "gpt-stale-remote", + "display_name": "GPT Stale Remote", + "supported_in_api": true + })) + .expect("parse stale remote model"), + source_kind: MODEL_SOURCE_KIND_REMOTE.to_string(), + user_edited: false, + sort_index: 1, + updated_at: 11, + }, + ManagedModelCatalogEntry { + model: serde_json::from_value(json!({ + "slug": "gpt-stale-edited", + "display_name": "GPT Stale Edited", + "supported_in_api": true + })) + .expect("parse stale edited model"), + source_kind: MODEL_SOURCE_KIND_REMOTE.to_string(), + user_edited: true, + sort_index: 2, + updated_at: 12, + }, + ManagedModelCatalogEntry { + model: serde_json::from_value(json!({ + "slug": "gpt-custom", + "display_name": "GPT Custom", + "supported_in_api": true + })) + .expect("parse custom model"), + source_kind: MODEL_SOURCE_KIND_CUSTOM.to_string(), + user_edited: false, + sort_index: 3, + updated_at: 13, + }, + ], + extra: BTreeMap::new(), + }; + save_managed_model_catalog_with_storage(&storage, &payload) + .expect("seed managed model catalog"); + + let remote = ModelsResponse { + models: vec![serde_json::from_value(json!({ + "slug": "gpt-current", + "display_name": "GPT Current", + "supported_in_api": true + })) + .expect("parse remote model")], + extra: BTreeMap::new(), + }; + prune_unedited_remote_model_catalog_entries_missing_from_remote(&storage, &remote) + .expect("prune stale remote models"); + + let catalog = + read_managed_model_catalog_from_storage(&storage).expect("read managed catalog"); + assert_eq!( + catalog + .items + .iter() + .map(|item| item.model.slug.as_str()) + .collect::>(), + vec!["gpt-current", "gpt-stale-edited", "gpt-custom"] + ); + } + #[test] fn account_pool_bootstrap_links_catalog_models_on_first_load() { let storage = Storage::open_in_memory().expect("open storage"); diff --git a/crates/service/src/gateway/request/local_models.rs b/crates/service/src/gateway/request/local_models.rs index b3e0d8a02..0bb259cb5 100644 --- a/crates/service/src/gateway/request/local_models.rs +++ b/crates/service/src/gateway/request/local_models.rs @@ -42,8 +42,7 @@ fn serialize_models_response_body(models: &ModelsResponse) -> String { } fn serialize_models_response(models: &ModelsResponse) -> String { - let models = crate::apikey_models::ensure_codex_image_tool_model_listed(models); - serialize_models_response_body(&models) + serialize_models_response_body(models) } fn filter_models_for_key( @@ -192,11 +191,6 @@ pub(super) fn maybe_respond_local_models( } else { serialize_models_response_body(&output_models) }; - let output_models = if include_implicit_models { - crate::apikey_models::ensure_codex_image_tool_model_listed(&output_models) - } else { - output_models - }; let extra_headers = models_etag_header(&output_models)?.into_iter().collect(); super::local_response::respond_local_json_with_headers( request, diff --git a/crates/service/src/gateway/request/tests/local_models_tests.rs b/crates/service/src/gateway/request/tests/local_models_tests.rs index 3d3ef5c3f..4307ecb0f 100644 --- a/crates/service/src/gateway/request/tests/local_models_tests.rs +++ b/crates/service/src/gateway/request/tests/local_models_tests.rs @@ -45,7 +45,7 @@ fn serialize_models_response_outputs_codex_and_api_shapes() { .get("data") .and_then(Value::as_array) .expect("OpenAI-compatible data array"); - assert_eq!(data.len(), 3); + assert_eq!(data.len(), 2); assert_eq!( data[0].get("id").and_then(Value::as_str), Some("gpt-5.3-codex") @@ -56,15 +56,11 @@ fn serialize_models_response_outputs_codex_and_api_shapes() { Some("codexmanager") ); assert_eq!(data[1].get("id").and_then(Value::as_str), Some("gpt-4o")); - assert_eq!( - data[2].get("id").and_then(Value::as_str), - Some("gpt-image-2") - ); let models = value .get("models") .and_then(Value::as_array) .expect("models array"); - assert_eq!(models.len(), 3); + assert_eq!(models.len(), 2); assert_eq!( models[0].get("slug").and_then(Value::as_str), Some("gpt-5.3-codex") @@ -81,10 +77,6 @@ fn serialize_models_response_outputs_codex_and_api_shapes() { models[1].get("visibility").and_then(Value::as_str), Some("list") ); - assert_eq!( - models[2].get("slug").and_then(Value::as_str), - Some("gpt-image-2") - ); assert_eq!(value.as_object().map(|object| object.len()), Some(3)); assert!(value.get("etag").is_none()); } @@ -113,7 +105,7 @@ fn serialize_models_response_preserves_description_for_codex_clients() { .get("data") .and_then(Value::as_array) .expect("OpenAI-compatible data array"); - assert_eq!(models.len(), 2); + assert_eq!(models.len(), 1); assert_eq!( models[0].get("description").and_then(Value::as_str), Some("Latest frontier agentic coding model.") @@ -125,11 +117,11 @@ fn serialize_models_response_preserves_description_for_codex_clients() { } #[test] -fn serialize_models_response_appends_codex_image_tool_model_once() { +fn serialize_models_response_does_not_invent_codex_image_tool_model() { let items = ModelsResponse { models: vec![ModelInfo { - slug: "gpt-image-2".to_string(), - display_name: "GPT Image 2".to_string(), + slug: "gpt-5.4-mini".to_string(), + display_name: "GPT-5.4 Mini".to_string(), supported_in_api: true, visibility: Some("list".to_string()), ..Default::default() @@ -147,7 +139,7 @@ fn serialize_models_response_appends_codex_image_tool_model_once() { assert_eq!(models.len(), 1); assert_eq!( models[0].get("slug").and_then(Value::as_str), - Some("gpt-image-2") + Some("gpt-5.4-mini") ); } @@ -185,7 +177,6 @@ fn serialize_models_response_filters_api_data_to_supported_models() { .collect::>(); assert!(ids.contains(&"gpt-supported")); - assert!(ids.contains(&"gpt-image-2")); assert!(!ids.contains(&"gpt-hidden")); } diff --git a/crates/service/src/model_groups.rs b/crates/service/src/model_groups.rs index f479850b2..4ad0d4b44 100644 --- a/crates/service/src/model_groups.rs +++ b/crates/service/src/model_groups.rs @@ -85,6 +85,7 @@ fn user_group_entry(assignment: UserModelGroup) -> UserModelGroupEntry { } fn result_from_storage(storage: &Storage) -> Result { + crate::apikey_models::read_managed_model_catalog_from_storage(storage)?; storage .bootstrap_default_model_group() .map_err(|err| format!("bootstrap default model group failed: {err}"))?; @@ -330,6 +331,9 @@ pub(crate) fn allowed_model_slugs_for_api_key( if user.role == "admin" { return Ok(None); } + storage + .prune_default_model_group_models_not_in_catalog() + .map_err(|err| format!("prune default model group failed: {err}"))?; let slugs = storage .allowed_model_slugs_for_user(user_id, now_ts()) .map_err(|err| format!("read allowed model groups failed: {err}"))? From 9d0fcf81dc1d31d8f6d5e78d825f14d93fdefb35 Mon Sep 17 00:00:00 2001 From: YYQ Date: Wed, 3 Jun 2026 17:47:19 +0800 Subject: [PATCH 2/2] Add explicit stale remote model pruning action --- apps/src/app/models/page.tsx | 17 +++++++++++++++-- apps/src/hooks/useManagedModels.ts | 22 ++++++++++++++++++++++ apps/src/lib/api/account-client.ts | 7 +++++++ apps/src/lib/api/transport-web-commands.ts | 7 +++++-- apps/src/lib/i18n/messages/en.ts | 7 +++++++ crates/service/src/rpc_dispatch/apikey.rs | 4 ++++ crates/service/src/rpc_dispatch/mod.rs | 1 + 7 files changed, 61 insertions(+), 4 deletions(-) diff --git a/apps/src/app/models/page.tsx b/apps/src/app/models/page.tsx index 7e173a983..298457f58 100644 --- a/apps/src/app/models/page.tsx +++ b/apps/src/app/models/page.tsx @@ -34,7 +34,6 @@ import { } from "@/components/ui/dialog"; import { Empty, - EmptyDescription, EmptyHeader, EmptyTitle, } from "@/components/ui/empty"; @@ -132,6 +131,7 @@ export default function ModelsPage() { isLoading, isServiceReady, refreshRemote, + pruneStaleRemoteModels, saveModel, deleteModel, deleteModels, @@ -139,6 +139,7 @@ export default function ModelsPage() { routing, canExportCodexCache, isRefreshing, + isPruningStaleRemote, isSaving, isDeleting, isExporting, @@ -612,13 +613,25 @@ export default function ModelsPage() { + {canExportCodexCache ? (