From 5d941008fc316eaa4a2564e9a4340ae14e0f37db Mon Sep 17 00:00:00 2001 From: 2han9wen71an <2han9wen71an@gmail.com> Date: Thu, 17 Sep 2026 16:07:58 +0800 Subject: [PATCH] fix(providers): let disabled accounts source their own model catalogs (#143) * fix(providers): let disabled accounts source their own model catalogs The admin per-account model query (connection test model dropdown) resolves the account's plan catalog through refresh_account_catalog, whose candidates come from catalog_candidates_by_scope over the scheduling account listing. Disabled accounts never appear there, so a disabled account either failed with NoEligibleCredential (no enabled account in its plan scope) or silently sourced the catalog from other accounts instead of itself, and the panel showed no test models. Keep the pinned account reachable: when the target account is missing from the eligibility-filtered candidates, append it (for xAI, load its full credential) and let the preferred-first ordering try it before any same-scope peers. A fully filtered listing is now treated as empty candidates instead of an early error so the pinned account can still answer alone. Client-facing catalog discovery, background sync and scheduling listings continue to exclude disabled accounts. * fix(xai): keep catalog credential revision aligned --------- Co-authored-by: Zyy --- .../openai/src/credential/catalog.rs | 17 +++++-- .../openai/tests/credential/catalog.rs | 35 ++++++++++++- .../providers/xai/src/credential/catalog.rs | 49 +++++++++++++++++-- .../providers/xai/tests/credential/catalog.rs | 21 ++++++++ 4 files changed, 114 insertions(+), 8 deletions(-) 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) =