diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index 1c3023759..b54e66bdb 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -1101,6 +1101,7 @@ impl RuntimeSnapshot { ) -> Result { let requirements = operation.capability_requirements(); let mut candidates = Vec::new(); + let mut unlisted_candidates = Vec::new(); if context.required_provider.is_none() && account_scope.provider_kinds().is_empty() { return Err(RoutingError::EmptyAccountScope); @@ -1148,11 +1149,11 @@ impl RuntimeSnapshot { { continue; } - let emulated_features = match self + let capabilities = self .provider_models .get(provider) - .and_then(|models| models.get(&upstream_model)) - { + .and_then(|models| models.get(&upstream_model)); + let emulated_features = match capabilities { Some(capabilities) => { let Some(emulated) = capabilities.match_requirements(&requirements) else { continue; @@ -1162,7 +1163,7 @@ impl RuntimeSnapshot { None if self.exhaustive_provider_catalogs.contains(provider) => continue, None => BTreeSet::new(), }; - candidates.push(ProviderCandidate { + let candidate = ProviderCandidate { provider: provider.clone(), model_presentation: self .provider_model_presentations @@ -1172,8 +1173,15 @@ impl RuntimeSnapshot { upstream_model: Some(upstream_model), emulated_features, account_scope: Arc::clone(&account_scope), - }); + }; + if capabilities.is_some() { + candidates.push(candidate); + } else { + unlisted_candidates.push(candidate); + } } + // 已知模型优先;发现型目录的未知模型仍可透传,但不能抢在明确匹配的 Provider 前面 + candidates.extend(unlisted_candidates); if candidates.is_empty() { // 模型存在性必须包含被请求路由限制排除的 Provider;目录未知时不能断言模型不存在 diff --git a/backend/crates/gateway-core/tests/routing/snapshot.rs b/backend/crates/gateway-core/tests/routing/snapshot.rs index d76a16531..b7431d8ab 100644 --- a/backend/crates/gateway-core/tests/routing/snapshot.rs +++ b/backend/crates/gateway-core/tests/routing/snapshot.rs @@ -282,6 +282,77 @@ impl ProviderCatalogPort for TestCatalog { } } +struct MixedCatalog; + +impl ProviderCatalogPort for MixedCatalog { + fn catalog_generations(&self) -> BTreeMap { + ["openai", "xai"] + .into_iter() + .map(|provider| { + ( + ProviderKind::new(provider).unwrap(), + ProviderCatalogGeneration::new(1), + ) + }) + .collect() + } + + fn model_catalog_is_exhaustive(&self, provider: &ProviderKind) -> bool { + provider.as_str() != "openai" + } + + fn query_model_capabilities( + &self, + provider: &ProviderKind, + ) -> BoxFuture<'_, Result, ProviderCatalogUnavailable>> { + let model = match provider.as_str() { + "openai" => "gpt-5.5", + "xai" => "grok-4.7", + _ => return Box::pin(async { Err(ProviderCatalogUnavailable) }), + }; + Box::pin(async move { + Ok([model, "shared-model"] + .into_iter() + .map(|model| { + ProviderModelCapabilities::new( + UpstreamModelId::new(model).unwrap(), + super::capabilities(), + ) + }) + .collect()) + }) + } +} + +fn mixed_catalog_snapshot() -> gateway_core::routing::RuntimeSnapshot { + let facts = SnapshotFacts::new( + revision(1), + revision(1), + super::settings(), + vec![], + vec![], + ["openai", "xai"] + .into_iter() + .map(|provider| { + SnapshotProviderAccountFacts::new( + gateway_core::account::ProviderAccountId::new(format!("acct_{provider}")) + .unwrap(), + provider, + ) + }) + .collect(), + vec![], + ); + block_on( + RuntimeSnapshotCompiler::new( + Arc::new(TestSnapshotStore::new(Ok(facts))), + Arc::new(MixedCatalog), + ) + .compile(), + ) + .unwrap() +} + fn catalog_generations(generation: u64) -> BTreeMap { BTreeMap::from([( ProviderKind::new("alpha").expect("provider"), @@ -606,6 +677,139 @@ fn discovery_catalog_should_not_reject_an_unlisted_model() { .expect("upstream decides model availability"); } +#[test] +fn mixed_catalog_should_prefer_known_model_over_discovery_fallback() { + let snapshot = mixed_catalog_snapshot(); + let model = PublicModelId::new("grok-4.7").unwrap(); + assert!( + snapshot + .public_models_for_scope(&snapshot.all_account_scope()) + .contains(&model) + ); + + let plan = snapshot + .plan( + &model, + &super::operation(), + snapshot.all_account_scope(), + &Default::default(), + ) + .unwrap(); + + assert_eq!( + plan.candidates() + .iter() + .map(|candidate| candidate.provider().as_str()) + .collect::>(), + ["xai", "openai"] + ); +} + +#[test] +fn mixed_catalog_should_prioritize_the_mapped_upstream_model() { + let snapshot = mixed_catalog_snapshot(); + let settings = snapshot + .settings() + .clone() + .with_model_mappings(BTreeMap::from([( + "public-model".to_owned(), + "grok-4.7".to_owned(), + )])); + let snapshot = snapshot.with_settings(&settings).unwrap(); + + let plan = snapshot + .plan( + &PublicModelId::new("public-model").unwrap(), + &super::operation(), + snapshot.all_account_scope(), + &Default::default(), + ) + .unwrap(); + + assert_eq!(plan.candidates()[0].provider().as_str(), "xai"); + assert_eq!( + plan.candidates()[0].upstream_model().unwrap().as_str(), + "grok-4.7" + ); +} + +#[test] +fn mixed_catalog_should_keep_discovery_routes_for_unlisted_models() { + let snapshot = mixed_catalog_snapshot(); + let plan = snapshot + .plan( + &PublicModelId::new("unlisted-model").unwrap(), + &super::operation(), + snapshot.all_account_scope(), + &Default::default(), + ) + .unwrap(); + + assert_eq!(plan.candidates().len(), 1); + assert_eq!(plan.candidates()[0].provider().as_str(), "openai"); +} + +#[test] +fn mixed_catalog_should_preserve_provider_order_for_shared_models() { + let snapshot = mixed_catalog_snapshot(); + let plan = snapshot + .plan( + &PublicModelId::new("shared-model").unwrap(), + &super::operation(), + snapshot.all_account_scope(), + &Default::default(), + ) + .unwrap(); + + assert_eq!( + plan.candidates() + .iter() + .map(|candidate| candidate.provider().as_str()) + .collect::>(), + ["openai", "xai"] + ); +} + +#[test] +fn mixed_catalog_should_respect_required_provider_before_model_priority() { + let snapshot = mixed_catalog_snapshot(); + let plan = snapshot + .plan( + &PublicModelId::new("grok-4.7").unwrap(), + &super::operation(), + snapshot.all_account_scope(), + &gateway_core::routing::RoutingContext { + required_provider: Some(ProviderKind::new("openai").unwrap()), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(plan.candidates().len(), 1); + assert_eq!(plan.candidates()[0].provider().as_str(), "openai"); +} + +#[test] +fn mixed_catalog_should_respect_blocked_provider_before_model_priority() { + let snapshot = mixed_catalog_snapshot(); + let plan = snapshot + .plan( + &PublicModelId::new("grok-4.7").unwrap(), + &super::operation(), + snapshot.all_account_scope(), + &gateway_core::routing::RoutingContext { + blocked_providers: std::collections::BTreeSet::from([ + ProviderKind::new("xai").unwrap() + ]), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(plan.candidates().len(), 1); + assert_eq!(plan.candidates()[0].provider().as_str(), "openai"); +} + #[test] fn known_empty_catalog_should_report_model_not_found() { let compiler = RuntimeSnapshotCompiler::new( diff --git a/docs/architecture.md b/docs/architecture.md index 47e275acc..69b4bcdaa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -608,6 +608,10 @@ Client Key 与账号分组形成授权范围: - 已绑定分组为空或全部禁用时得到空池,绝不回退为全部账号 - 分组可以包含多个 Provider,账号也可以属于多个分组 +Core 在账号授权、模型政策与请求路由约束内,优先选择目录明确包含映射后模型且能力匹配的 Provider, +再保留发现型或暂不可读目录的未知模型候选;两类候选各自保持 Provider 标识顺序。 +目录缺项不阻止 OpenAI 模型透传,重试和跨 Provider fallback 仍受原有发送与重放安全边界约束 + 账号选择综合启停状态、credential/quota 事实、Redis cooldown、并发上限、权重、请求间隔和会话亲和。 默认账号并发上限为 `0` 时表示无限;账号独立正数上限仍优先,未设置则继承默认值。 无限并发只跳过并发上限判断,保留在途租约计数、请求间隔及其他准入约束;含无限账号的容量不投影有限占用比例。