diff --git a/backend/crates/providers/openai/src/credential/catalog.rs b/backend/crates/providers/openai/src/credential/catalog.rs index 8a8d95b2e..6fa1c2a07 100644 --- a/backend/crates/providers/openai/src/credential/catalog.rs +++ b/backend/crates/providers/openai/src/credential/catalog.rs @@ -465,9 +465,20 @@ impl CodexCredentialCatalogService { .ok_or(CodexCredentialCatalogError::NoEligibleCredential)?; let scope = CodexCatalogScope::for_account(&account)?; let mut candidates = - catalog_candidates_by_scope(self.repository.list_for_provider().await?)? - .remove(&scope) - .unwrap_or_default(); + match catalog_candidates_by_scope(self.repository.list_for_provider().await?) { + Ok(mut groups) => groups.remove(&scope).unwrap_or_default(), + // 全部账号都被调度列表过滤时按空候选处理,让下方目标账号补回继续生效。 + Err(CodexCredentialCatalogError::NoEligibleCredential) => Vec::new(), + Err(error) => return Err(error), + }; + // 常规调度列表不含停用账号;管理端按账号查询模型要对停用账号返回真实上游 + // 结果,这里把不在候选里的目标账号本身补回,仍按优先顺序先试目标账号。 + if !candidates + .iter() + .any(|candidate| candidate.id() == account_id) + { + candidates.push(account.clone()); + } if candidates.is_empty() { return Err(CodexCredentialCatalogError::NoEligibleCredential); } diff --git a/backend/crates/providers/openai/tests/credential/catalog.rs b/backend/crates/providers/openai/tests/credential/catalog.rs index ae118623a..7bdfcbc97 100644 --- a/backend/crates/providers/openai/tests/credential/catalog.rs +++ b/backend/crates/providers/openai/tests/credential/catalog.rs @@ -3,7 +3,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use chrono::{TimeZone as _, Utc}; use futures::executor::block_on; -use gateway_core::account::{ProviderAccount, ProviderAccountId}; +use gateway_core::account::{ProviderAccount, ProviderAccountId, ProviderAccountStore}; use gateway_core::provider_ports::ProviderCatalogCachePort; use gateway_core::routing::{ ClientRoutingScope, FrozenAccountScope, ModelServiceTier, ProviderKind, RuntimeAccount, @@ -440,6 +440,39 @@ async fn missing_account_refresh_fails_before_network_io() { )); } +#[tokio::test] +async fn disabled_account_refresh_discovers_models_with_the_pinned_account() { + let store = Arc::new(MemoryAccountStore::default()); + let account = seed_account(&store, "acct_disabled_models").await; + store + .set_enabled(account.id(), false) + .await + .expect("disable account"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/codex/models")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_raw( + br#"{"models":[{"slug":"gpt-5.4","display_name":"GPT-5.4"}]}"#.to_vec(), + "application/json", + ), + ) + .expect(1) + .mount(&server) + .await; + let service = service_with_catalog_cache(&store, server.uri(), catalog_cache()); + + let catalog = service + .refresh_account_catalog(account.id()) + .await + .expect("disabled account refresh still discovers models"); + + assert_eq!(catalog.models(), ["gpt-5.4"]); + server.verify().await; +} + #[tokio::test] async fn plan_catalog_cache_is_shared_and_manual_refresh_replaces_it() { let store = Arc::new(MemoryAccountStore::default()); diff --git a/backend/crates/providers/xai/src/credential/catalog.rs b/backend/crates/providers/xai/src/credential/catalog.rs index 02ea546f0..1e3131f91 100644 --- a/backend/crates/providers/xai/src/credential/catalog.rs +++ b/backend/crates/providers/xai/src/credential/catalog.rs @@ -929,16 +929,57 @@ impl GrokCredentialCatalogService { .list_loaded_for_provider() .await .map_err(|_| GrokCredentialCatalogError::Store)?; - let mut groups = catalog_candidates_by_scope(candidates)?; - let Some(scope) = groups.iter().find_map(|(scope, candidates)| { + let mut groups = match catalog_candidates_by_scope(candidates) { + Ok(groups) => groups, + // 全部候选都被调度列表过滤时按空组处理,让下方目标账号补回继续生效。 + Err(GrokCredentialCatalogError::NoEligibleCredential) => BTreeMap::new(), + Err(error) => return Err(error), + }; + let scope = groups.iter().find_map(|(scope, candidates)| { candidates .iter() .any(|candidate| candidate.account.id() == account_id) .then(|| scope.clone()) - }) else { - return Err(GrokCredentialCatalogError::NoEligibleCredential); + }); + let mut pinned_candidate = None; + let scope = match scope { + Some(scope) => scope, + None => { + // 常规调度列表不含停用账号;管理端按账号查询模型要对停用账号返回 + // 真实上游结果。按同一 revision 加载账号与凭据,避免并发更新套餐时 + // 把新凭据的目录写入旧套餐 cache。 + let account = self + .repository + .account_by_id(account_id) + .await + .map_err(|_| GrokCredentialCatalogError::Store)? + .ok_or(GrokCredentialCatalogError::NoEligibleCredential)?; + let loaded = self + .repository + .load(account_id, account.revision()) + .await + .map_err(|_| GrokCredentialCatalogError::Store)?; + let scope = GrokCatalogScope::for_account(&loaded.account) + .map_err(|_| GrokCredentialCatalogError::InvalidCredentialData)?; + pinned_candidate = Some(loaded); + scope + } }; let mut candidates = groups.remove(&scope).unwrap_or_default(); + if !candidates + .iter() + .any(|candidate| candidate.account.id() == account_id) + { + let loaded = match pinned_candidate { + Some(loaded) => loaded, + None => self + .repository + .load_current(account_id) + .await + .map_err(|_| GrokCredentialCatalogError::Store)?, + }; + candidates.push(loaded); + } candidates.sort_by(|left, right| { let left_preferred = left.account.id() == account_id; let right_preferred = right.account.id() == account_id; diff --git a/backend/crates/providers/xai/tests/credential/catalog.rs b/backend/crates/providers/xai/tests/credential/catalog.rs index 5b5fd7b24..20e0a20c9 100644 --- a/backend/crates/providers/xai/tests/credential/catalog.rs +++ b/backend/crates/providers/xai/tests/credential/catalog.rs @@ -312,6 +312,27 @@ async fn single_account_catalog_refresh_and_read_use_provider_cache_boundary() { assert_eq!(cached.seed().models(), ["grok-4.5"]); } +#[tokio::test] +async fn disabled_account_refresh_discovers_models_with_the_pinned_account() { + let (store, repository) = + repository_with_accounts(&[("disabled-models", "subject-disabled-models")]).await; + store + .set_enabled(&account_id("disabled-models"), false) + .await + .expect("disable account"); + let service = crate::support::grok_catalog_service( + repository, + QueueCatalogTransport::from_bodies([OFFICIAL_FIXTURE.to_vec()]), + MemoryGrokCatalogCache::shared(), + ); + + let refreshed = service + .refresh_account_catalog(&account_id("disabled-models")) + .await + .expect("disabled account refresh still discovers models"); + assert_eq!(refreshed.seed().models(), ["grok-4.5"]); +} + #[tokio::test] async fn single_account_catalog_read_miss_does_not_call_upstream() { let (store, repository) =