From 2f96c02ea090b830c8a8117972ab31228e25ae53 Mon Sep 17 00:00:00 2001 From: Zyy Date: Mon, 12 Oct 2026 13:03:10 +0800 Subject: [PATCH] fix(routing): bind known models to their providers Preserve known model ownership when provider catalog refreshes fail. Reject ambiguous unknown-provider routes and exclude ChatGPT OAuth accounts from Grok requests while preserving API key passthrough. Refs #391 Co-authored-by: Codex --- .../gateway-core/src/routing/snapshot.rs | 105 ++++++++----- .../gateway-core/src/runtime/publisher.rs | 9 +- .../gateway-core/tests/routing/snapshot.rs | 130 ++++++++++++---- .../crates/gateway-core/tests/runtime/mod.rs | 85 ++++++++++- .../openai/src/credential/selector.rs | 15 +- .../openai/tests/provider/contract/mod.rs | 1 + .../contract/model_account_eligibility.rs | 141 ++++++++++++++++++ docs/api.md | 5 + docs/architecture.md | 13 +- 9 files changed, 430 insertions(+), 74 deletions(-) create mode 100644 backend/crates/providers/openai/tests/provider/contract/model_account_eligibility.rs diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index b54e66bdb..a15f9e856 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -276,7 +276,7 @@ impl RuntimeSnapshotCompiler { /// 读取一个 revision,并为已注册 Provider 查询实时模型目录 pub async fn compile(&self) -> Result { - self.compile_inner(None).await + self.compile_inner(None, true).await } /// 配置提交复用同一扩展集合的已发布目录,目录留待对账刷新 @@ -284,12 +284,21 @@ impl RuntimeSnapshotCompiler { &self, previous: &RuntimeSnapshot, ) -> Result { - self.compile_inner(Some(previous)).await + self.compile_inner(Some(previous), false).await + } + + /// 刷新目录,单个 Provider 读取失败时保留同一扩展集合的已知模型归属 + pub(crate) async fn compile_with_catalog_fallback( + &self, + previous: &RuntimeSnapshot, + ) -> Result { + self.compile_inner(Some(previous), true).await } async fn compile_inner( &self, previous: Option<&RuntimeSnapshot>, + refresh_catalog: bool, ) -> Result { // 同一配置 revision 的重试复用扩展候选,保留插件策略的发布一致性 let mut prepared_extensions: Option<( @@ -338,6 +347,7 @@ impl RuntimeSnapshotCompiler { catalogs.as_ref(), provider_kinds, cached, + refresh_catalog, self.privacy_compiler.clone(), ) .await?; @@ -354,7 +364,7 @@ impl RuntimeSnapshotCompiler { return Err(RuntimeSnapshotCompileError::RevisionChanged); } let snapshot = snapshot - .with_provider_catalog_generations(if cached.is_some() { + .with_provider_catalog_generations(if cached.is_some() && !refresh_catalog { BTreeMap::new() } else { observed_generations @@ -373,14 +383,20 @@ async fn compile_runtime_snapshot( catalogs: &dyn ProviderCatalogPort, provider_kinds: Vec, previous: Option<&RuntimeSnapshot>, + refresh_catalog: bool, privacy_compiler: Option>, ) -> Result { - // 只有成功取得的完整目录能证明模型缺项;发现型目录和查询失败均交由上游验证 + // 目录读取失败不能抹掉已知归属;成功返回空目录则仍是权威的模型撤回 let mut provider_models = Vec::new(); let mut catalog_accounts = BTreeMap::new(); let mut exhaustive_provider_catalogs = BTreeSet::new(); for provider in &provider_kinds { - if let Some(previous) = previous { + let models = if refresh_catalog || previous.is_none() { + catalogs.query_model_capabilities(provider).await.ok() + } else { + None + }; + if let Some(previous) = previous.filter(|_| models.is_none()) { if let Some(accounts) = previous.model_catalog_accounts.get(provider) { catalog_accounts.insert(provider.clone(), accounts.clone()); } @@ -403,7 +419,7 @@ async fn compile_runtime_snapshot( } continue; } - let Ok(models) = catalogs.query_model_capabilities(provider).await else { + let Some(models) = models else { continue; }; if catalogs.model_catalog_is_exhaustive(provider) { @@ -946,7 +962,7 @@ impl RuntimeSnapshot { .collect() } - /// 完整目录按映射后的模型判定;发现型或不可用目录不作为能力白名单 + /// 已知模型只属于明确声明它的 Provider;未知模型仍可由非完整目录透传 #[must_use] pub fn contains_public_model_for_provider( &self, @@ -962,13 +978,22 @@ impl RuntimeSnapshot { { return false; } - if !self.exhaustive_provider_catalogs.contains(provider) { - return true; - } - let upstream_model = self.mapped_model(public_model.as_str()); + let Ok(upstream_model) = + UpstreamModelId::from_client_wire(self.mapped_model(public_model.as_str())) + else { + return false; + }; self.provider_models .get(provider) - .is_some_and(|models| models.keys().any(|model| model.as_str() == upstream_model)) + .is_some_and(|models| models.contains_key(&upstream_model)) + || (!self.exhaustive_provider_catalogs.contains(provider) + && !self.is_known_upstream_model(&upstream_model)) + } + + fn is_known_upstream_model(&self, upstream_model: &UpstreamModelId) -> bool { + self.provider_models + .values() + .any(|models| models.contains_key(upstream_model)) } #[must_use] @@ -1101,12 +1126,28 @@ 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); } + let requested_model = public_model.as_str(); + let mapped_model = self.mapped_model(requested_model); + let upstream_model = if self + .settings + .values + .model_mappings + .contains_key(requested_model) + || self.model_alias(requested_model).is_some() + { + UpstreamModelId::new(mapped_model) + } else { + UpstreamModelId::from_client_wire(mapped_model) + } + .map_err(|_| RoutingError::InvalidIdentifier)?; + // 归属先于账号范围、能力与路由约束判定,原 Provider 被排除不能变成跨平台透传 + let known_model = self.is_known_upstream_model(&upstream_model); + let providers = context.required_provider.as_ref().map_or_else( || account_scope.provider_kinds().clone(), |provider| BTreeSet::from([provider.clone()]), @@ -1129,20 +1170,6 @@ impl RuntimeSnapshot { { continue; } - let requested_model = public_model.as_str(); - let mapped_model = self.mapped_model(requested_model); - let upstream_model = if self - .settings - .values - .model_mappings - .contains_key(requested_model) - || self.model_alias(requested_model).is_some() - { - UpstreamModelId::new(mapped_model) - } else { - UpstreamModelId::from_client_wire(mapped_model) - } - .map_err(|_| RoutingError::InvalidIdentifier)?; // 强制 Provider 路由也不能扩大 Key 授权;管理诊断的目标账号由探测入口固定 if enforce_account_model_policy && !account_scope.allows_provider_model(provider, upstream_model.as_str()) @@ -1160,28 +1187,30 @@ impl RuntimeSnapshot { }; emulated } - None if self.exhaustive_provider_catalogs.contains(provider) => continue, + None if known_model || self.exhaustive_provider_catalogs.contains(provider) => { + continue; + } None => BTreeSet::new(), }; - let candidate = ProviderCandidate { + candidates.push(ProviderCandidate { provider: provider.clone(), model_presentation: self .provider_model_presentations .get(provider) .and_then(|models| models.get(&upstream_model)) .cloned(), - upstream_model: Some(upstream_model), + upstream_model: Some(upstream_model.clone()), emulated_features, account_scope: Arc::clone(&account_scope), - }; - if capabilities.is_some() { - candidates.push(candidate); - } else { - unlisted_candidates.push(candidate); - } + }); + } + + // 冷启动或目录不可读时不能按 Provider 名称猜测模型归属 + if !known_model && candidates.len() > 1 { + return Err(RoutingError::NoCapableProvider { + model: public_model.as_str().to_owned(), + }); } - // 已知模型优先;发现型目录的未知模型仍可透传,但不能抢在明确匹配的 Provider 前面 - candidates.extend(unlisted_candidates); if candidates.is_empty() { // 模型存在性必须包含被请求路由限制排除的 Provider;目录未知时不能断言模型不存在 diff --git a/backend/crates/gateway-core/src/runtime/publisher.rs b/backend/crates/gateway-core/src/runtime/publisher.rs index 6944e96a9..90b586a4f 100644 --- a/backend/crates/gateway-core/src/runtime/publisher.rs +++ b/backend/crates/gateway-core/src/runtime/publisher.rs @@ -234,7 +234,14 @@ impl RuntimeSnapshotPublisher { None => self.compiler.compile().await, } } else { - preempt_for_committed_refresh(cancellation, self.compiler.compile()).await? + let previous = self.snapshots.snapshot_for_diagnostics(); + preempt_for_committed_refresh(cancellation, async { + match previous { + Some(previous) => self.compiler.compile_with_catalog_fallback(&previous).await, + None => self.compiler.compile().await, + } + }) + .await? }; let snapshot = match compiled { Ok(snapshot) => snapshot, diff --git a/backend/crates/gateway-core/tests/routing/snapshot.rs b/backend/crates/gateway-core/tests/routing/snapshot.rs index b7431d8ab..3261d79d5 100644 --- a/backend/crates/gateway-core/tests/routing/snapshot.rs +++ b/backend/crates/gateway-core/tests/routing/snapshot.rs @@ -282,7 +282,9 @@ impl ProviderCatalogPort for TestCatalog { } } -struct MixedCatalog; +struct MixedCatalog { + xai_available: bool, +} impl ProviderCatalogPort for MixedCatalog { fn catalog_generations(&self) -> BTreeMap { @@ -307,7 +309,7 @@ impl ProviderCatalogPort for MixedCatalog { ) -> BoxFuture<'_, Result, ProviderCatalogUnavailable>> { let model = match provider.as_str() { "openai" => "gpt-5.5", - "xai" => "grok-4.7", + "xai" if self.xai_available => "grok-4.7", _ => return Box::pin(async { Err(ProviderCatalogUnavailable) }), }; Box::pin(async move { @@ -325,6 +327,10 @@ impl ProviderCatalogPort for MixedCatalog { } fn mixed_catalog_snapshot() -> gateway_core::routing::RuntimeSnapshot { + mixed_catalog_snapshot_with_xai(true) +} + +fn mixed_catalog_snapshot_with_xai(xai_available: bool) -> gateway_core::routing::RuntimeSnapshot { let facts = SnapshotFacts::new( revision(1), revision(1), @@ -346,7 +352,7 @@ fn mixed_catalog_snapshot() -> gateway_core::routing::RuntimeSnapshot { block_on( RuntimeSnapshotCompiler::new( Arc::new(TestSnapshotStore::new(Ok(facts))), - Arc::new(MixedCatalog), + Arc::new(MixedCatalog { xai_available }), ) .compile(), ) @@ -678,7 +684,7 @@ fn discovery_catalog_should_not_reject_an_unlisted_model() { } #[test] -fn mixed_catalog_should_prefer_known_model_over_discovery_fallback() { +fn mixed_catalog_should_bind_known_model_to_its_declared_provider() { let snapshot = mixed_catalog_snapshot(); let model = PublicModelId::new("grok-4.7").unwrap(); assert!( @@ -701,12 +707,12 @@ fn mixed_catalog_should_prefer_known_model_over_discovery_fallback() { .iter() .map(|candidate| candidate.provider().as_str()) .collect::>(), - ["xai", "openai"] + ["xai"] ); } #[test] -fn mixed_catalog_should_prioritize_the_mapped_upstream_model() { +fn mixed_catalog_should_bind_the_mapped_upstream_model() { let snapshot = mixed_catalog_snapshot(); let settings = snapshot .settings() @@ -726,6 +732,7 @@ fn mixed_catalog_should_prioritize_the_mapped_upstream_model() { ) .unwrap(); + assert_eq!(plan.candidates().len(), 1); assert_eq!(plan.candidates()[0].provider().as_str(), "xai"); assert_eq!( plan.candidates()[0].upstream_model().unwrap().as_str(), @@ -771,41 +778,106 @@ fn mixed_catalog_should_preserve_provider_order_for_shared_models() { } #[test] -fn mixed_catalog_should_respect_required_provider_before_model_priority() { +fn mixed_catalog_should_reject_forcing_a_model_to_another_provider() { 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(); + 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() + }, + ); - assert_eq!(plan.candidates().len(), 1); - assert_eq!(plan.candidates()[0].provider().as_str(), "openai"); + assert!(matches!( + plan, + Err(gateway_core::validation::RoutingError::NoCapableProvider { .. }) + )); } #[test] -fn mixed_catalog_should_respect_blocked_provider_before_model_priority() { +fn mixed_catalog_should_not_escape_a_blocked_model_provider() { 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() + }, + ); + + assert!(matches!( + plan, + Err(gateway_core::validation::RoutingError::NoCapableProvider { .. }) + )); +} + +#[test] +fn mixed_catalog_should_not_escape_model_capability_requirements() { + let snapshot = mixed_catalog_snapshot(); + let plan = snapshot.plan( + &PublicModelId::new("grok-4.7").unwrap(), + &super::image_operation(), + snapshot.all_account_scope(), + &Default::default(), + ); + assert!(matches!( + plan, + Err(gateway_core::validation::RoutingError::NoCapableProvider { .. }) + )); +} + +#[test] +fn mixed_catalog_should_not_escape_the_authorized_account_scope() { + let snapshot = mixed_catalog_snapshot(); + let scope = Arc::new(gateway_core::routing::FrozenAccountScope::new( + Arc::new(gateway_core::routing::RuntimeAccountDirectory::new( + BTreeMap::from([( + gateway_core::account::ProviderAccountId::new("acct_openai").unwrap(), + gateway_core::routing::RuntimeAccount::new( + ProviderKind::new("openai").unwrap(), + Default::default(), + ), + )]), + )), + gateway_core::routing::ClientRoutingScope::all_accounts(), + )); + let model = PublicModelId::new("grok-4.7").unwrap(); + assert!(!snapshot.contains_public_model_for_scope(&model, &scope)); + let plan = snapshot.plan(&model, &super::operation(), scope, &Default::default()); + assert!(matches!( + plan, + Err(gateway_core::validation::RoutingError::NoCapableProvider { .. }) + )); +} + +#[test] +fn mixed_catalog_should_reject_ambiguous_models_when_xai_catalog_is_unavailable() { + let snapshot = mixed_catalog_snapshot_with_xai(false); + let plan = snapshot.plan( + &PublicModelId::new("grok-4.7").unwrap(), + &super::operation(), + snapshot.all_account_scope(), + &Default::default(), + ); + assert!(matches!( + plan, + Err(gateway_core::validation::RoutingError::NoCapableProvider { .. }) + )); + let plan = snapshot .plan( - &PublicModelId::new("grok-4.7").unwrap(), + &PublicModelId::new("gpt-5.5").unwrap(), &super::operation(), snapshot.all_account_scope(), - &gateway_core::routing::RoutingContext { - blocked_providers: std::collections::BTreeSet::from([ - ProviderKind::new("xai").unwrap() - ]), - ..Default::default() - }, + &Default::default(), ) .unwrap(); - assert_eq!(plan.candidates().len(), 1); assert_eq!(plan.candidates()[0].provider().as_str(), "openai"); } diff --git a/backend/crates/gateway-core/tests/runtime/mod.rs b/backend/crates/gateway-core/tests/runtime/mod.rs index fc8652468..8a67591fe 100644 --- a/backend/crates/gateway-core/tests/runtime/mod.rs +++ b/backend/crates/gateway-core/tests/runtime/mod.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; mod publications; -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use futures::channel::{mpsc, oneshot}; @@ -612,6 +612,85 @@ fn catalog_only_reconciliation_should_keep_snapshot_on_failure_and_retry_generat }); } +#[test] +fn catalog_failure_should_preserve_model_facts_without_restoring_revoked_accounts() { + block_on(async { + let catalog = Arc::new(TestCatalog::default()); + let provider = ProviderKind::new("audit").unwrap(); + let model = gateway_core::routing::PublicModelId::new("audit-model").unwrap(); + catalog.models.lock().unwrap().push( + ProviderModelCapabilities::new( + UpstreamModelId::new("audit-model").unwrap(), + ModelCapabilities::new( + std::collections::BTreeSet::from([ + gateway_core::operation::OperationKind::Generate, + ]), + Some(16_000), + ), + ) + .with_presentation(gateway_core::routing::ModelPresentation::new( + Some("Audit model".to_owned()), + None, + )) + .with_catalog_accounts(std::collections::BTreeSet::from([ProviderAccountId::new( + "acct_audit_removed", + ) + .unwrap()])), + ); + let store = Arc::new(TestSnapshotStore::new(Ok(scoped_facts(10, true)))); + let handle = RuntimeSnapshotHandle::default(); + let publisher = RuntimeSnapshotPublisher::new( + Arc::new(catalog_compiler(store.clone(), catalog.clone())), + handle.clone(), + Arc::new(TestSnapshotSubscriptions::default()), + ); + publisher.refresh().await.unwrap(); + let initial = handle.acquire().unwrap(); + let initial_profiles = initial.public_model_profiles_for_provider(&provider); + + catalog.unavailable.store(true, Ordering::SeqCst); + catalog.generation.store(1, Ordering::SeqCst); + *store.facts.lock().unwrap() = Ok(scoped_facts(11, false)); + *store.current_revision.lock().unwrap() = Ok(revision(11)); + publisher.refresh().await.unwrap(); + + let retained = handle.acquire().unwrap(); + assert_eq!(retained.revision(), revision(11)); + assert!(!removed_account_allowed(&retained)); + assert_eq!( + retained.public_model_profiles_for_provider(&provider), + initial_profiles + ); + assert!(retained.contains_public_model_for_provider(&model, &provider)); + assert!(!retained.contains_public_model_for_provider( + &gateway_core::routing::PublicModelId::new("unlisted-model").unwrap(), + &provider, + )); + assert!(retained.public_models_for_scope( + retained.client_policies().next().unwrap().account_scope(), + ).is_empty()); + assert!(removed_account_allowed(&initial)); + assert_eq!( + retained.provider_catalog_generations(), + &catalog.catalog_generations() + ); + + // 上游恢复并明确撤回模型后,旧目录不能继续作为已知归属 + catalog.unavailable.store(false, Ordering::SeqCst); + catalog.models.lock().unwrap().clear(); + catalog.generation.store(2, Ordering::SeqCst); + let (task, context) = reconciliation_task(&publisher); + task.run_cycle(context).await.unwrap(); + let refreshed = handle.acquire().unwrap(); + assert!(refreshed.public_models_for_provider(&provider).is_empty()); + assert!(!refreshed.contains_public_model_for_provider(&model, &provider)); + assert_eq!( + refreshed.provider_catalog_generations(), + &catalog.catalog_generations() + ); + }); +} + #[test] fn dropping_inflight_refresh_should_release_publication_for_next_refresh() { block_on(async { @@ -688,6 +767,7 @@ fn reconciliation_task( #[derive(Default)] struct TestCatalog { generation: AtomicU64, + unavailable: AtomicBool, queries: AtomicUsize, next_query: Mutex>>, models: Mutex>, @@ -711,6 +791,9 @@ impl ProviderCatalogPort for TestCatalog { if let Some(gate) = gate { gate.await.expect("release catalog query"); } + if self.unavailable.load(Ordering::SeqCst) { + return Err(ProviderCatalogUnavailable); + } Ok(self.models.lock().expect("models lock").clone()) }) } diff --git a/backend/crates/providers/openai/src/credential/selector.rs b/backend/crates/providers/openai/src/credential/selector.rs index c40036023..1145f2e6c 100644 --- a/backend/crates/providers/openai/src/credential/selector.rs +++ b/backend/crates/providers/openai/src/credential/selector.rs @@ -126,10 +126,23 @@ impl<'a> CodexSelectionModel<'a> { } pub(crate) fn allows_account(self, account: &ProviderAccount) -> bool { + if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH { + return true; + } + // API Key 兼容上游可以提供 Grok;ChatGPT OAuth 不能因共用 Provider 被选中 + // 使用路由映射后的模型,不限制未知别名或尚未被目录收录的 OpenAI 模型 + if self.model().is_some_and(|model| { + let model = model.rsplit('/').next().unwrap_or(model); + model.eq_ignore_ascii_case("grok") + || model + .get(..5) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("grok-")) + }) { + return false; + } // 官方 Codex 的 image_generation_available 排除 Free,API Key 不套用 ChatGPT 套餐 // https://github.com/openai/codex/commit/0a0a9b6c8f !(matches!(self, Self::Image(_)) - && account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH && account .plan_type() .is_some_and(|plan| plan.trim().eq_ignore_ascii_case("free"))) diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index 3a69eb618..656a1c286 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -5,6 +5,7 @@ mod capacity; mod error_details; mod image_account_eligibility; mod image_model_access; +mod model_account_eligibility; mod passthrough; mod precommit; mod privacy; diff --git a/backend/crates/providers/openai/tests/provider/contract/model_account_eligibility.rs b/backend/crates/providers/openai/tests/provider/contract/model_account_eligibility.rs new file mode 100644 index 000000000..05278b04f --- /dev/null +++ b/backend/crates/providers/openai/tests/provider/contract/model_account_eligibility.rs @@ -0,0 +1,141 @@ +//! Grok 的 ChatGPT OAuth 选号边界与 API Key 未知模型透传合同 + +use super::*; + +fn operation(model: &str) -> Operation { + let payload = ProtocolPayload::json_object( + "openai", + Map::from_iter([ + ("model".to_owned(), json!(model)), + ("input".to_owned(), json!("hello")), + ]), + ) + .unwrap() + .with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])); + Operation::Generate(GenerateRequest::from_protocol_payload(payload)) +} + +#[tokio::test] +async fn grok_models_never_acquire_chatgpt_oauth_accounts() { + let oauth = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let leases = Arc::new(TestLeaseCoordinator::default()); + let provider = provider_with_affinity_and_base_url_and_leases( + &store, + Arc::default(), + oauth.uri(), + leases.clone(), + ); + for model in ["grok-4.7", "xai/grok-4.7", "GROK-NEW", "grok"] { + let error = provider + .clone() + .execute( + planned_request_for_model("openai", operation(model), model), + context("req_grok_oauth_rejected", CancellationToken::new()), + ) + .await + .err() + .expect("Grok cannot select a ChatGPT account"); + assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount); + } + assert!(leases.requests.lock().unwrap().is_empty()); + assert!(oauth.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn mixed_pool_routes_grok_to_api_key_despite_oauth_weight() { + let api = MockServer::start().await; + let oauth = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_scope_new").await; + store.set_scheduling("acct_scope_new", None, AccountWeight::new(100).unwrap()); + store + .seed_api_key( + "acct_provider_contract", + api.uri(), + provider_openai::credential::ResponsesTransport::Http, + ) + .await; + store.set_scheduling( + "acct_provider_contract", + None, + AccountWeight::new(1).unwrap(), + ); + Mock::given(method("POST")) + .and(path("/responses")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"), + ) + .expect(1) + .mount(&api) + .await; + let mut stream = provider_with_base_url(&store, oauth.uri()) + .execute( + planned_request_for_model("openai", operation("grok-4.7"), "grok-4.7"), + context("req_grok_api_key", CancellationToken::new()), + ) + .await + .unwrap(); + assert_eq!( + stream.metadata().provider_account_id().as_str(), + "acct_provider_contract" + ); + while let Some(event) = stream.next().await { + event.unwrap(); + } + let requests = api.received_requests().await.unwrap(); + assert_eq!(captured_request_body(&requests[0])["model"], "grok-4.7"); + assert!(oauth.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn unlisted_models_remain_available_without_catalog_discovery() { + for api_key in [false, true] { + let server = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + if api_key { + store + .seed_api_key( + "acct_provider_contract", + server.uri(), + provider_openai::credential::ResponsesTransport::Http, + ) + .await; + } else { + create_account(&store, "acct_provider_contract").await; + } + Mock::given(method("POST")) + .and(path(if api_key { + "/responses" + } else { + "/codex/responses" + })) + .respond_with( + ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"), + ) + .expect(1) + .mount(&server) + .await; + let mut stream = provider_with_base_url(&store, server.uri()) + .execute( + planned_request_for_model( + "openai", + operation("custom-unlisted"), + "custom-unlisted", + ), + context("req_unlisted", CancellationToken::new()), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!( + captured_request_body(&requests[0])["model"], + "custom-unlisted" + ); + } +} diff --git a/docs/api.md b/docs/api.md index b133c64e6..370e789c3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -377,6 +377,11 @@ xAI 没有 Codex 原生目录,使用官方 CLI proxy 当前目录生成通用 `reasoning_efforts` 对象列表,默认值只取菜单中的显式标记;缺失时保持未知,不以首项代替, 也不回退旧字段或其他端点的目录形状 +已知模型只路由到明确提供该模型的 Provider,账号不可用时不转发给其他平台的未知模型通道。 +未知模型只在当前范围与路由约束下存在唯一 Provider 透传候选时发送;无法确定归属时返回不可用错误, +可通过 Client Key 分组限定目标平台。一个 Provider 内的多个 API Key 账号仍按账号模型政策与调度规则选择, +不要求模型已被上游目录收录;ChatGPT OAuth 账号不接受 Grok 模型 + 目录账号只能来自本次 Client Key 冻结的账号范围。OpenAI 的 OAuth 目录按账号 ID 排序,使用首个成功读取的 合格账号,最多尝试三个账号;API Key 目录按账号模型权限过滤后聚合。同名模型优先采用 OAuth 原生对象, 其次采用 API Key 的完整 Codex 对象,再使用普通模型 ID;同类 API Key 目录按账号 ID 确定来源,不跨账号 diff --git a/docs/architecture.md b/docs/architecture.md index 4d4e0864f..9e35c0e06 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -613,9 +613,11 @@ Client Key 与账号分组形成授权范围: - 已绑定分组为空或全部禁用时得到空池,绝不回退为全部账号 - 分组可以包含多个 Provider,账号也可以属于多个分组 -Core 在账号授权、模型政策与请求路由约束内,优先选择目录明确包含映射后模型且能力匹配的 Provider, -再保留发现型或暂不可读目录的未知模型候选;两类候选各自保持 Provider 标识顺序。 -目录缺项不阻止 OpenAI 模型透传,重试和跨 Provider fallback 仍受原有发送与重放安全边界约束 +Core 按映射后的模型冻结 Provider 归属:已知模型只在明确声明该模型且能力匹配的 Provider 中路由, +再应用账号授权、模型政策和请求约束;账号不可用或所属 Provider 被排除不能扩大归属范围。 +同名模型的多个所属 Provider 保持标识顺序,重试仍受发送与重放安全边界约束。 +未被任何目录识别的模型只有一个 Provider 透传候选时才交由上游验证,多个未知候选不按名称顺序猜选平台。 +OpenAI Provider 在选号时排除 Grok 模型的 ChatGPT OAuth 账号,API Key 兼容上游仍可透传 账号选择综合启停状态、credential/quota 事实、Redis cooldown、并发上限、权重、请求间隔和会话亲和。 默认账号并发上限为 `0` 时表示无限;账号独立正数上限仍优先,未设置则继承默认值。 @@ -799,7 +801,10 @@ PostgreSQL 周期对账才是正确性基础 同一 publisher 的管理提交、订阅通知与周期对账共享编译到发布/暂停的临界区,避免旧结果覆盖新授权或 晚到的失败暂停新快照;请求读取不等待刷新锁。对账仍允许持久 revision 回退,不能只比较版本大小。 -配置不可确认时暂停新请求;仅目录 generation 变化且编译失败时继续使用旧快照,留待下次对账 +配置不可确认时暂停新请求;仅目录 generation 变化且编译失败时继续使用旧快照,留待下次对账。 +同一扩展集合内,单个 Provider 目录读取失败保留上次成功的模型、能力和来源账号事实, +新账号授权仍按当前配置发布,后续目录 generation 变化或刷新成功后更新模型事实。 +成功返回空目录表示模型撤回,移除 Provider 或切换扩展集合时不复用旧归属 ## 8. 状态所有权