From 97faca0c45202b273c364d5179704cb24a947bce Mon Sep 17 00:00:00 2001 From: Zyy Date: Sun, 11 Oct 2026 23:04:51 +0800 Subject: [PATCH] fix(routing): prioritize known models over discovery fallbacks Prefer providers whose catalogs contain the mapped model before providers accepting unlisted models. Preserve account scope, route constraints, provider order within each tier, and unknown-model passthrough. Add compiled mixed-catalog regressions and document candidate ordering. Refs #391. Co-authored-by: Codex --- .../gateway-core/src/routing/snapshot.rs | 18 +- .../gateway-core/tests/routing/snapshot.rs | 204 ++++++++++++++++++ docs/architecture.md | 4 + 3 files changed, 221 insertions(+), 5 deletions(-) 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` 时表示无限;账号独立正数上限仍优先,未设置则继承默认值。 无限并发只跳过并发上限判断,保留在途租约计数、请求间隔及其他准入约束;含无限账号的容量不投影有限占用比例。