diff --git a/backend/crates/gateway-core/src/engine/coordinator.rs b/backend/crates/gateway-core/src/engine/coordinator.rs index 7ea88cfd1..770a57e87 100644 --- a/backend/crates/gateway-core/src/engine/coordinator.rs +++ b/backend/crates/gateway-core/src/engine/coordinator.rs @@ -1110,7 +1110,7 @@ where pinned_account.clone(), self.account_state_owner.clone(), ) - .with_account_scope(Arc::clone(self.plan.account_scope())), + .with_account_scope(Arc::clone(candidate.account_scope())), } .with_credential_recovery_attempted(pinned_account.as_ref().is_some_and(|account| { self.credential_recovery_attempted_accounts diff --git a/backend/crates/gateway-core/src/engine/execution/service.rs b/backend/crates/gateway-core/src/engine/execution/service.rs index 1e85a906f..d94d269e0 100644 --- a/backend/crates/gateway-core/src/engine/execution/service.rs +++ b/backend/crates/gateway-core/src/engine/execution/service.rs @@ -1551,14 +1551,7 @@ impl ExecutionService for DefaultExecutionService { .query_client_model_catalog(scope, protocol, client_version) .await? else { - for profile in client.snapshot.public_model_profiles_for_provider(kind) { - if !client.snapshot.catalog_model_allowed_for_scope( - kind, - profile.model(), - scope, - ) { - continue; - } + for profile in client.snapshot.profiles_for_provider_scope(kind, scope) { if seen.insert(profile.model().clone()) { result.push(PublicModelDescriptor::Adapted(profile)); } @@ -1610,6 +1603,12 @@ impl ExecutionService for DefaultExecutionService { result.push(source.content.for_public_model(model)); } } + // 原生完整对象优先;只补入当前授权账号实际提供的独立适配画像 + for profile in client.snapshot.contributed_profiles_for_scope(kind, scope) { + if seen.insert(profile.model().clone()) { + result.push(PublicModelDescriptor::Adapted(profile)); + } + } } Ok(result) }) diff --git a/backend/crates/gateway-core/src/engine/mod.rs b/backend/crates/gateway-core/src/engine/mod.rs index fc3386e36..7ada4e130 100644 --- a/backend/crates/gateway-core/src/engine/mod.rs +++ b/backend/crates/gateway-core/src/engine/mod.rs @@ -167,7 +167,7 @@ impl AccountAttemptContext { } } - /// 附着普通请求认证时冻结的账号范围 + /// 附着本次尝试的冻结账号范围,包含路由候选的能力限制 #[must_use] pub fn with_account_scope( mut self, @@ -895,7 +895,7 @@ impl AttemptContext { self.account.is_diagnostic_required_account() } - /// 普通请求认证时冻结的账号范围;管理端诊断为 `None` + /// 当前路由候选的冻结账号范围;管理端诊断为 `None` #[must_use] pub const fn account_scope(&self) -> Option<&Arc> { self.account.account_scope() diff --git a/backend/crates/gateway-core/src/routing/catalog.rs b/backend/crates/gateway-core/src/routing/catalog.rs index 01227edd5..38ed9b626 100644 --- a/backend/crates/gateway-core/src/routing/catalog.rs +++ b/backend/crates/gateway-core/src/routing/catalog.rs @@ -21,6 +21,17 @@ pub struct ContributedModelAlias { pub target: UpstreamModelId, } +/// 适配模型的冻结账号支持事实;没有选择账号或改写请求的能力 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContributedModel { + pub owner: String, + pub id: PublicModelId, + pub provider: ProviderKind, + pub accounts: BTreeSet, + pub capabilities: ModelCapabilities, + pub presentation: ModelPresentation, +} + /// Provider 客户端目录条目;缺少原生协议正文时使用已编译的通用画像 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderModelDescriptor { diff --git a/backend/crates/gateway-core/src/routing/extensions.rs b/backend/crates/gateway-core/src/routing/extensions.rs index 15ea33ed7..7bb46f3d5 100644 --- a/backend/crates/gateway-core/src/routing/extensions.rs +++ b/backend/crates/gateway-core/src/routing/extensions.rs @@ -4,7 +4,7 @@ use std::{fmt, sync::Arc}; use futures::future::BoxFuture; -use crate::routing::{ConfigRevision, ContributedModelAlias}; +use crate::routing::{ConfigRevision, ContributedModel, ContributedModelAlias}; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ExtensionSetId(String); @@ -37,6 +37,10 @@ pub trait ExtensionSetLease: Send + Sync { &[] } + fn models(&self) -> &[ContributedModel] { + &[] + } + /// 局部故障已有请求级处理计划时,集合仍可提供服务 fn can_serve(&self) -> bool { self.is_ready() @@ -71,6 +75,11 @@ impl ExtensionSetReference { pub fn model_aliases(&self) -> &[ContributedModelAlias] { self.lease.model_aliases() } + + #[must_use] + pub fn models(&self) -> &[ContributedModel] { + self.lease.models() + } } impl fmt::Debug for ExtensionSetReference { diff --git a/backend/crates/gateway-core/src/routing/mod.rs b/backend/crates/gateway-core/src/routing/mod.rs index 375a9dade..622ae0f7d 100644 --- a/backend/crates/gateway-core/src/routing/mod.rs +++ b/backend/crates/gateway-core/src/routing/mod.rs @@ -11,7 +11,7 @@ pub use crate::account::scope::{ }; pub use crate::identity::ProviderKind; pub use catalog::{ - ContributedModelAlias, ProviderCatalogGeneration, ProviderCatalogPort, + ContributedModel, ContributedModelAlias, ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable, ProviderModelCapabilities, ProviderModelContent, ProviderModelDescriptor, PublicModelDescriptor, }; diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index 00e9873c1..7181523af 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -652,6 +652,30 @@ impl RuntimeSnapshot { .map_or(&[], |set| set.model_aliases()) } + fn contributed_models(&self) -> &[super::ContributedModel] { + self.extensions.as_ref().map_or(&[], |set| set.models()) + } + + pub(crate) fn contributed_profiles_for_scope( + &self, + provider: &ProviderKind, + scope: &FrozenAccountScope, + ) -> Vec { + self.contributed_models() + .iter() + .filter(|model| { + &model.provider == provider + && model.accounts.iter().any(|account| { + scope.account_provider(account) == Some(provider) + && scope.allows_model(account, &self.mapped_model(model.id.as_str())) + }) + }) + .map(|model| { + super::PublicModelProfile::new(model.id.clone(), model.presentation.clone()) + }) + .collect() + } + fn model_alias(&self, model: &str) -> Option<&super::ContributedModelAlias> { self.model_aliases() .iter() @@ -844,6 +868,12 @@ impl RuntimeSnapshot { .filter(|alias| &alias.provider == provider) .map(|alias| alias.id.clone()), ); + models.extend( + self.contributed_models() + .iter() + .filter(|model| &model.provider == provider) + .map(|model| model.id.clone()), + ); models.into_iter().collect() } @@ -922,10 +952,7 @@ impl RuntimeSnapshot { ) -> Vec { let mut profiles = BTreeMap::new(); for provider in scope.provider_kinds() { - for profile in self.public_model_profiles_for_provider(provider) { - if !self.catalog_model_allowed_for_scope(provider, profile.model(), scope) { - continue; - } + for profile in self.profiles_for_provider_scope(provider, scope) { profiles .entry(profile.model().clone()) .or_insert_with(|| profile.presentation().clone()); @@ -937,6 +964,20 @@ impl RuntimeSnapshot { .collect() } + pub(crate) fn profiles_for_provider_scope( + &self, + provider: &ProviderKind, + scope: &FrozenAccountScope, + ) -> Vec { + self.public_model_profiles_for_provider(provider) + .into_iter() + .filter(|profile| { + self.native_catalog_model_allowed_for_scope(provider, profile.model(), scope) + }) + .chain(self.contributed_profiles_for_scope(provider, scope)) + .collect() + } + /// 已知模型只属于明确声明它的 Provider;未知模型仍可由非完整目录透传 #[must_use] pub fn contains_public_model_for_provider( @@ -961,7 +1002,15 @@ impl RuntimeSnapshot { self.provider_models .get(provider) .is_some_and(|models| models.contains_key(&upstream_model)) + || self + .contributed_models() + .iter() + .any(|model| &model.provider == provider && &model.id == public_model) || (!self.exhaustive_provider_catalogs.contains(provider) + && !self + .contributed_models() + .iter() + .any(|model| &model.id == public_model) && !self.is_known_upstream_model(&upstream_model)) } @@ -969,6 +1018,10 @@ impl RuntimeSnapshot { self.provider_models .values() .any(|models| models.contains_key(upstream_model)) + || self + .contributed_models() + .iter() + .any(|model| model.id.as_str() == upstream_model.as_str()) } #[must_use] @@ -988,6 +1041,24 @@ impl RuntimeSnapshot { provider: &ProviderKind, public_model: &PublicModelId, scope: &FrozenAccountScope, + ) -> bool { + self.native_catalog_model_allowed_for_scope(provider, public_model, scope) + || self.contributed_models().iter().any(|model| { + &model.provider == provider + && &model.id == public_model + && model.accounts.iter().any(|account| { + scope.account_provider(account) == Some(provider) + && scope + .allows_model(account, &self.mapped_model(public_model.as_str())) + }) + }) + } + + fn native_catalog_model_allowed_for_scope( + &self, + provider: &ProviderKind, + public_model: &PublicModelId, + scope: &FrozenAccountScope, ) -> bool { let upstream_model = self.mapped_model(public_model.as_str()); match self @@ -999,7 +1070,17 @@ impl RuntimeSnapshot { scope.account_provider(account) == Some(provider) && scope.allows_model(account, &upstream_model) }), - None => scope.allows_provider_model(provider, &upstream_model), + None => { + // 只有插件声明的模型不能借非完整原生目录扩大到其他账号 + let native = self.provider_models.get(provider).is_some_and(|models| { + models.keys().any(|model| model.as_str() == upstream_model) + }); + let contributed = self + .contributed_models() + .iter() + .any(|model| &model.provider == provider && &model.id == public_model); + (native || !contributed) && scope.allows_provider_model(provider, &upstream_model) + } } } @@ -1121,7 +1202,11 @@ impl RuntimeSnapshot { } .map_err(|_| RoutingError::InvalidIdentifier)?; // 归属先于账号范围、能力与路由约束判定,原 Provider 被排除不能变成跨平台透传 - let known_model = self.is_known_upstream_model(&upstream_model); + let known_model = self.is_known_upstream_model(&upstream_model) + || self + .contributed_models() + .iter() + .any(|model| &model.id == public_model); let providers = context.required_provider.as_ref().map_or_else( || account_scope.provider_kinds().clone(), @@ -1155,17 +1240,64 @@ impl RuntimeSnapshot { .provider_models .get(provider) .and_then(|models| models.get(&upstream_model)); - let emulated_features = match capabilities { - Some(capabilities) => { - let Some(emulated) = capabilities.match_requirements(&requirements) else { + let contributions: Vec<_> = self + .contributed_models() + .iter() + .filter(|model| &model.provider == provider && &model.id == public_model) + .collect(); + let mut candidate_scope = Arc::clone(&account_scope); + let emulated_features = if contributions.is_empty() { + match capabilities { + Some(capabilities) => { + let Some(emulated) = capabilities.match_requirements(&requirements) else { + continue; + }; + emulated + } + None if known_model || self.exhaustive_provider_catalogs.contains(provider) => { continue; - }; - emulated + } + None => BTreeSet::new(), } - None if known_model || self.exhaustive_provider_catalogs.contains(provider) => { + } else { + let native = capabilities.and_then(|value| value.match_requirements(&requirements)); + let capable_accounts: BTreeSet<_> = contributions + .iter() + .filter(|model| { + model + .capabilities + .match_requirements(&requirements) + .is_some() + }) + .flat_map(|model| model.accounts.iter()) + .filter(|account| { + account_scope.account_provider(account) == Some(provider) + && account_scope.allows_model(account, upstream_model.as_str()) + }) + .cloned() + .collect(); + if native.is_some() { + // 这些账号实际由适配器执行,不能借用同名原生模型的能力 + let mut restricted = account_scope.as_ref().clone(); + for account in contributions.iter().flat_map(|model| &model.accounts) { + if !capable_accounts.contains(account) { + restricted = restricted.excluding_account(account.clone()); + } + } + candidate_scope = Arc::new(restricted); + } else { + // restricted_to 的空集合表示不收窄,因此必须先拒绝空候选 + if capable_accounts.is_empty() { + continue; + } + candidate_scope = Arc::new( + account_scope.restricted_to(&Default::default(), &capable_accounts), + ); + } + if !candidate_scope.allows_provider_model(provider, upstream_model.as_str()) { continue; } - None => BTreeSet::new(), + native.unwrap_or_default() }; candidates.push(ProviderCandidate { provider: provider.clone(), @@ -1173,10 +1305,15 @@ impl RuntimeSnapshot { .provider_model_presentations .get(provider) .and_then(|models| models.get(&upstream_model)) - .cloned(), + .cloned() + .or_else(|| { + contributions + .first() + .map(|model| model.presentation.clone()) + }), upstream_model: Some(upstream_model.clone()), emulated_features, - account_scope: Arc::clone(&account_scope), + account_scope: candidate_scope, }); } diff --git a/backend/crates/gateway-core/tests/engine/execution/identity.rs b/backend/crates/gateway-core/tests/engine/execution/identity.rs index b157c2600..8f208d8de 100644 --- a/backend/crates/gateway-core/tests/engine/execution/identity.rs +++ b/backend/crates/gateway-core/tests/engine/execution/identity.rs @@ -16,9 +16,10 @@ fn identity_queries_keep_frozen_permissions_without_using_execution_resources() let service = DefaultExecutionService::new( snapshots.clone(), store.clone(), - ProviderRegistry::new([ - Arc::new(NativeCatalogProvider { fail: false }) as Arc - ]) + ProviderRegistry::new([Arc::new(NativeCatalogProvider { + fail: false, + empty: false, + }) as Arc]) .unwrap(), Arc::new(UnusedAdmissions), Arc::new(UnusedContinuation), @@ -90,9 +91,10 @@ fn execution_scope_limits_apply_only_when_starting_a_model() { let service = DefaultExecutionService::new( RuntimeSnapshotHandle::new(start_snapshot()), store.clone(), - ProviderRegistry::new([ - Arc::new(NativeCatalogProvider { fail: false }) as Arc - ]) + ProviderRegistry::new([Arc::new(NativeCatalogProvider { + fail: false, + empty: false, + }) as Arc]) .unwrap(), Arc::new(UnusedAdmissions), Arc::new(UnusedContinuation), diff --git a/backend/crates/gateway-core/tests/engine/execution/mod.rs b/backend/crates/gateway-core/tests/engine/execution/mod.rs index 7c9dbbc94..a20efb6dc 100644 --- a/backend/crates/gateway-core/tests/engine/execution/mod.rs +++ b/backend/crates/gateway-core/tests/engine/execution/mod.rs @@ -3302,6 +3302,7 @@ struct FailingProvider; struct NativeCatalogProvider { fail: bool, + empty: bool, } #[async_trait] @@ -3332,6 +3333,9 @@ impl Provider for NativeCatalogProvider { if self.fail { return Err(gateway_core::routing::ProviderCatalogUnavailable); } + if self.empty { + return Ok(Some(Vec::new())); + } Ok(Some( ["gpt-new", "gpt-start"] .into_iter() @@ -3362,7 +3366,10 @@ impl Provider for NativeCatalogProvider { #[test] fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models() { #[derive(Debug)] - struct Aliases(Vec); + struct Aliases( + Vec, + Vec, + ); impl gateway_core::routing::extensions::ExtensionSetLease for Aliases { fn is_ready(&self) -> bool { true @@ -3370,8 +3377,29 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models( fn model_aliases(&self) -> &[gateway_core::routing::ContributedModelAlias] { &self.0 } + fn models(&self) -> &[gateway_core::routing::ContributedModel] { + &self.1 + } } - for fail in [false, true] { + for (fail, empty) in [(false, false), (false, true), (true, false)] { + let contributed = [ + ("gpt-start", "acct_start"), + ("adapted", "acct_start"), + ("private-model", "acct_other"), + ] + .into_iter() + .map(|(id, account)| gateway_core::routing::ContributedModel { + owner: "catalog-plugin".into(), + id: PublicModelId::new(id).unwrap(), + provider: ProviderKind::new("openai").unwrap(), + accounts: [ProviderAccountId::new(account).unwrap()].into(), + capabilities: ModelCapabilities::new([OperationKind::Generate].into(), None), + presentation: gateway_core::routing::ModelPresentation::new( + Some(format!("Plugin {id}")), + None, + ), + }) + .collect(); let snapshot = start_snapshot(); let settings = snapshot .settings() @@ -3386,20 +3414,23 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models( .with_extensions(Some(ExtensionSetReference::new( gateway_core::routing::extensions::ExtensionSetId::new("catalog-aliases".into()) .unwrap(), - Arc::new(Aliases(vec![ - gateway_core::routing::ContributedModelAlias { + Arc::new(Aliases( + vec![gateway_core::routing::ContributedModelAlias { owner: "catalog-plugin".into(), id: PublicModelId::new("plugin-alias").unwrap(), provider: ProviderKind::new("openai").unwrap(), target: UpstreamModelId::new("gpt-start").unwrap(), - }, - ])), + }], + contributed, + )), ))); let service = DefaultExecutionService::new( RuntimeSnapshotHandle::new(snapshot), Arc::new(TrackingExecutionStore::default()), - ProviderRegistry::new([Arc::new(NativeCatalogProvider { fail }) as Arc]) - .expect("registry"), + ProviderRegistry::new([ + Arc::new(NativeCatalogProvider { fail, empty }) as Arc + ]) + .expect("registry"), Arc::new(UnusedAdmissions), Arc::new(UnusedContinuation), Arc::new(RecordingClientApiKeyUsage::default()), @@ -3415,8 +3446,35 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models( continue; } let models = result.expect("models"); + let adapted = models + .iter() + .filter_map(|entry| match entry { + gateway_core::routing::PublicModelDescriptor::Adapted(profile) => { + Some(profile.model().as_str()) + } + _ => None, + }) + .collect::>(); + assert_eq!( + adapted, + if empty { + vec!["gpt-start", "adapted"] + } else { + vec!["adapted"] + } + ); + if empty { + assert_eq!(models.len(), 2); + continue; + } let pairs = models .iter() + .filter(|entry| { + matches!( + entry, + gateway_core::routing::PublicModelDescriptor::Native { .. } + ) + }) .map(|entry| { let gateway_core::routing::PublicModelDescriptor::Native { model, payload } = entry else { diff --git a/backend/crates/gateway-core/tests/routing/snapshot.rs b/backend/crates/gateway-core/tests/routing/snapshot.rs index 7e2386de1..bd7004b2b 100644 --- a/backend/crates/gateway-core/tests/routing/snapshot.rs +++ b/backend/crates/gateway-core/tests/routing/snapshot.rs @@ -99,6 +99,247 @@ struct CountingExtensionPreparation { struct AliasLease(Vec); +struct ModelLease(Vec); + +#[test] +fn same_name_native_and_adapted_sources_keep_account_capabilities_separate() { + use gateway_core::{ + account::{AccountModelAccess, AccountModelAccessMode, ProviderAccountId}, + operation::{Feature, GenerateRequest, Operation, ProtocolPayload}, + routing::{ContributedModel, SupportLevel}, + }; + struct Catalog; + impl ProviderCatalogPort for Catalog { + fn catalog_generations(&self) -> BTreeMap { + [( + ProviderKind::new("alpha").unwrap(), + ProviderCatalogGeneration::new(1), + )] + .into() + } + fn query_model_capabilities( + &self, + _: &ProviderKind, + ) -> BoxFuture<'_, Result, ProviderCatalogUnavailable>> + { + Box::pin(async { + Ok(vec![ + ProviderModelCapabilities::new( + UpstreamModelId::new("shared-model").unwrap(), + super::capabilities().with_feature(Feature::Tools, SupportLevel::Native), + ) + .with_catalog_accounts([ProviderAccountId::new("acct_a").unwrap()].into()) + .with_presentation(ModelPresentation::new(Some("Native".into()), None)), + ]) + }) + } + } + let id = PublicModelId::new("shared-model").unwrap(); + let b = ProviderAccountId::new("acct_b").unwrap(); + let mut contribution = ContributedModel { + owner: "fixture".into(), + id: id.clone(), + provider: ProviderKind::new("alpha").unwrap(), + accounts: [b.clone()].into(), + capabilities: super::capabilities(), + presentation: ModelPresentation::new(Some("Adapted".into()), None), + }; + let facts = SnapshotFacts::new( + revision(1), + revision(1), + super::settings(), + vec![], + vec![], + ["acct_a", "acct_b", "acct_c"] + .into_iter() + .map(|id| { + SnapshotProviderAccountFacts::new(ProviderAccountId::new(id).unwrap(), "alpha") + .with_model_access( + AccountModelAccess::new( + AccountModelAccessMode::Allowlist, + vec![ + if id == "acct_c" { + "other" + } else { + "shared-model" + } + .into(), + ], + ) + .unwrap(), + ) + }) + .collect(), + vec![], + ); + let native = block_on( + RuntimeSnapshotCompiler::new( + Arc::new(TestSnapshotStore::new(Ok(facts))), + Arc::new(Catalog), + ) + .compile(), + ) + .unwrap(); + let scope = native.all_account_scope(); + let b_only = Arc::new(scope.restricted_to(&Default::default(), &[b.clone()].into())); + assert!(native.public_models_for_scope(&b_only).is_empty()); + let snapshot = native.with_extensions(Some(ExtensionSetReference::new( + ExtensionSetId::new("adapted".into()).unwrap(), + Arc::new(ModelLease(vec![contribution.clone()])), + ))); + assert_eq!(snapshot.public_models_for_scope(&b_only), vec![id.clone()]); + assert_eq!( + snapshot.public_model_profiles_for_scope(&b_only)[0].presentation(), + &contribution.presentation + ); + let plan = snapshot + .plan( + &id, + &super::operation(), + b_only.clone(), + &Default::default(), + ) + .unwrap(); + assert!(plan.candidates()[0].account_scope().allows(&b)); + let tools = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + serde_json::json!({"model":"shared-model","tools":[{"type":"function","name":"test"}]}) + .as_object() + .unwrap() + .clone(), + ) + .unwrap(), + )); + assert!( + snapshot + .plan(&id, &tools, b_only, &Default::default()) + .is_err() + ); + let plan = snapshot + .plan(&id, &tools, scope.clone(), &Default::default()) + .unwrap(); + assert!( + plan.candidates()[0] + .account_scope() + .allows(&ProviderAccountId::new("acct_a").unwrap()) + ); + assert!(!plan.candidates()[0].account_scope().allows(&b)); + contribution.accounts = [ProviderAccountId::new("acct_c").unwrap()].into(); + let denied = snapshot.with_extensions(Some(ExtensionSetReference::new( + ExtensionSetId::new("denied".into()).unwrap(), + Arc::new(ModelLease(vec![contribution])), + ))); + let c_only = Arc::new(scope.restricted_to( + &Default::default(), + &[ProviderAccountId::new("acct_c").unwrap()].into(), + )); + assert!(denied.public_models_for_scope(&c_only).is_empty()); + assert!( + denied + .plan(&id, &super::operation(), c_only, &Default::default()) + .is_err() + ); +} + +impl ExtensionSetLease for ModelLease { + fn is_ready(&self) -> bool { + true + } + fn models(&self) -> &[gateway_core::routing::ContributedModel] { + &self.0 + } +} + +#[test] +fn native_model_without_presentation_cannot_borrow_an_unauthorized_plugin_profile() { + let snapshot = super::snapshot().with_extensions(Some(ExtensionSetReference::new( + ExtensionSetId::new("private-profile".into()).unwrap(), + Arc::new(ModelLease(vec![gateway_core::routing::ContributedModel { + owner: "fixture".into(), + id: PublicModelId::new("gpt-5.5").unwrap(), + provider: ProviderKind::new("openai").unwrap(), + accounts: [gateway_core::account::ProviderAccountId::new("acct_private").unwrap()] + .into(), + capabilities: super::capabilities(), + presentation: ModelPresentation::new(Some("Private model".into()), None), + }])), + ))); + assert!( + snapshot + .public_model_profiles_for_scope(&super::account_scope()) + .is_empty() + ); +} + +#[test] +fn adapted_models_limit_visibility_and_routing_to_capable_authorized_accounts() { + use gateway_core::{ + account::ProviderAccountId, + operation::{Feature, GenerateRequest, Operation, ProtocolPayload}, + routing::{ContributedModel, SupportLevel}, + }; + let account = ProviderAccountId::new("acct_openai").unwrap(); + let provider = ProviderKind::new("openai").unwrap(); + let id = PublicModelId::new("adapted-model").unwrap(); + let contribution = ContributedModel { + owner: "fixture".into(), + id: id.clone(), + provider, + accounts: [account.clone()].into(), + capabilities: super::capabilities(), + presentation: ModelPresentation::new(Some("Adapted".into()), None), + }; + let snapshot = super::snapshot().with_extensions(Some(ExtensionSetReference::new( + ExtensionSetId::new("models".into()).unwrap(), + Arc::new(ModelLease(vec![contribution.clone()])), + ))); + let scope = super::account_scope(); + assert!(snapshot.public_models_for_scope(&scope).contains(&id)); + assert!( + snapshot + .public_model_profiles_for_scope(&scope) + .iter() + .any(|p| p.model() == &id) + ); + let plan = snapshot + .plan(&id, &super::operation(), scope.clone(), &Default::default()) + .unwrap(); + assert_eq!(plan.candidates().len(), 1); + assert!(plan.candidates()[0].account_scope().allows(&account)); + let other = Arc::new(scope.restricted_to( + &Default::default(), + &[ProviderAccountId::new("acct_xai").unwrap()].into(), + )); + assert!(!snapshot.public_models_for_scope(&other).contains(&id)); + assert!( + snapshot + .plan(&id, &super::operation(), other, &Default::default()) + .is_err() + ); + let tools = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", serde_json::json!({"model":"adapted-model","input":[],"tools":[{"type":"function","name":"test","parameters":{}}]}).as_object().unwrap().clone()).unwrap(), + )); + assert!( + snapshot + .plan(&id, &tools, scope.clone(), &Default::default()) + .is_err() + ); + let mut supported = contribution; + supported.capabilities = supported + .capabilities + .with_feature(Feature::Tools, SupportLevel::Native); + let capable = snapshot.with_extensions(Some(ExtensionSetReference::new( + ExtensionSetId::new("capable".into()).unwrap(), + Arc::new(ModelLease(vec![supported])), + ))); + assert!( + capable + .plan(&id, &tools, scope, &Default::default()) + .is_ok() + ); +} + impl ExtensionSetLease for AliasLease { fn is_ready(&self) -> bool { true diff --git a/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json b/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json index c090f8781..8f1fcda63 100644 --- a/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json +++ b/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json @@ -6,7 +6,7 @@ { "capability": "frontend_authentication", "versions": [2] }, { "capability": "scheduler", "versions": [3] }, { "capability": "model_router", "versions": [2] }, - { "capability": "model_catalog", "versions": [1] }, + { "capability": "model_catalog", "versions": [1, 2] }, { "capability": "retry_policy", "versions": [2] }, { "capability": "middleware", "versions": [6] }, { "capability": "upstream_adapter", "versions": [5] }, diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/catalog.rs b/backend/crates/gateway-plugin/runtime/src/adapter/catalog.rs index a5e0951ac..f1f3ff529 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/catalog.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/catalog.rs @@ -4,18 +4,40 @@ use gateway_admin::model::{ AdminError, plugins::instances::{PluginCapabilityBinding, PluginInstance}, }; -use gateway_core::routing::{ContributedModelAlias, ProviderKind, PublicModelId, UpstreamModelId}; -use gateway_plugin_sdk::{Capability, Manifest, Stage, call::catalog::ModelCatalogRegistration}; +use gateway_core::{ + account::ProviderAccountId, + operation::{Feature, OperationKind}, + routing::{ + ContributedModel, ContributedModelAlias, ModelCapabilities, ModelPresentation, + ProviderKind, PublicModelId, SupportLevel, UpstreamModelId, + }, +}; +use gateway_plugin_sdk::{ + Capability, Manifest, Stage, + call::{ + catalog::{ModelCatalogRegistration, ModelCatalogRegistrationV2}, + middleware::RequestFeature, + }, +}; use crate::RpcSession; +pub(crate) struct PreparedCatalog { + pub(crate) aliases: Vec, + pub(crate) models: Vec, +} + pub(crate) async fn prepare( manifest: &Manifest, instance: &PluginInstance, session: &RpcSession, -) -> Result, AdminError> { + adapters: &[super::upstream_adapter::AdapterEntry], +) -> Result { if !manifest.contributes.contains_key(&Capability::ModelCatalog) { - return Ok(Vec::new()); + return Ok(PreparedCatalog { + aliases: Vec::new(), + models: Vec::new(), + }); } validate_bindings(manifest, &instance.bindings)?; let reply = session @@ -30,13 +52,19 @@ pub(crate) async fn prepare( if reply.payload.len() > 64 * 1024 { return Err(AdminError::invalid("插件模型目录信封无效或过大")); } - let registration: ModelCatalogRegistration = serde_json::from_slice(&reply.payload) - .map_err(|_| AdminError::invalid("插件模型目录声明无效"))?; - if registration.models.len() > 256 { - return Err(AdminError::invalid("单个插件最多贡献 256 个模型别名")); + let (aliases, models) = if manifest.contributes[&Capability::ModelCatalog].version == 2 { + let registration: ModelCatalogRegistrationV2 = serde_json::from_slice(&reply.payload) + .map_err(|_| AdminError::invalid("插件模型目录声明无效"))?; + (registration.aliases, registration.models) + } else { + let registration: ModelCatalogRegistration = serde_json::from_slice(&reply.payload) + .map_err(|_| AdminError::invalid("插件模型目录声明无效"))?; + (registration.models, Vec::new()) + }; + if aliases.len().saturating_add(models.len()) > 256 { + return Err(AdminError::invalid("单个插件最多贡献 256 个模型条目")); } - registration - .models + let aliases = aliases .into_iter() .map(|model| { let id = PublicModelId::new(model.id) @@ -50,7 +78,79 @@ pub(crate) async fn prepare( .map_err(|_| AdminError::invalid("模型别名目标无效"))?, }) }) - .collect() + .collect::>()?; + let models = models + .into_iter() + .map(|model| { + let mut targets = adapters + .iter() + .filter_map(|entry| entry.catalog_target(&model.adapter_id, &model.id)); + let target = targets + .next() + .filter(|_| targets.next().is_none()) + .ok_or_else(|| AdminError::invalid("适配模型必须关联同实例的静态账号适配器"))?; + let mut capabilities = + ModelCapabilities::new([OperationKind::Generate].into(), model.max_output_tokens); + for feature in model.features { + let feature = match feature { + RequestFeature::Tools => Feature::Tools, + RequestFeature::Vision => Feature::Vision, + RequestFeature::Reasoning => Feature::Reasoning, + RequestFeature::JsonSchema => Feature::JsonSchema, + }; + capabilities = capabilities.with_feature(feature, SupportLevel::Native); + } + let p = model.presentation; + Ok(ContributedModel { + owner: instance.id.clone(), + id: PublicModelId::new(model.id) + .map_err(|_| AdminError::invalid("适配模型 ID 无效"))?, + provider: ProviderKind::new(target.provider.as_str()) + .map_err(|_| AdminError::invalid("适配模型 Provider 无效"))?, + accounts: target + .account_ids + .iter() + .map(|id| { + ProviderAccountId::new(id.clone()) + .map_err(|_| AdminError::invalid("适配模型账号无效")) + }) + .collect::>()?, + capabilities, + presentation: ModelPresentation::new(p.display_name, p.description) + .with_reasoning(p.default_reasoning_effort, p.supported_reasoning_efforts) + .with_context_window_tokens(p.context_window_tokens) + .with_max_context_window_tokens(p.max_context_window_tokens) + .with_image_input(p.image_input) + .with_agent_tools(p.agent_tools, p.parallel_tool_calls) + .with_search_tool(p.search_tool) + .with_image_detail_original(p.image_detail_original) + .with_verbosity(p.verbosity) + .with_hidden(p.hidden), + }) + }) + .collect::>()?; + Ok(PreparedCatalog { aliases, models }) +} + +/// 新目录的同名画像必须一致,不由注册顺序拼接能力或改变别名目标 +pub(crate) fn validate_models( + aliases: &[ContributedModelAlias], + models: &[ContributedModel], +) -> Result<(), AdminError> { + for (index, model) in models.iter().enumerate() { + if aliases.iter().any(|alias| alias.id == model.id) + || models[..index].iter().any(|other| { + other.id == model.id + && (other.provider != model.provider + || other.presentation != model.presentation + || other.capabilities != model.capabilities + || !other.accounts.is_disjoint(&model.accounts)) + }) + { + return Err(AdminError::invalid("适配模型目录声明冲突")); + } + } + Ok(()) } pub(crate) fn validate_bindings( diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/scope.rs b/backend/crates/gateway-plugin/runtime/src/adapter/scope.rs index fdedb30ac..c05948738 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/scope.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/scope.rs @@ -86,6 +86,14 @@ impl BindingScope { !self.providers.is_empty() } + /// 静态目录只能继承账号范围,不能丢失请求级 Key 或组条件 + pub(crate) fn matches_catalog(&self, provider: &ProviderKind, model: &str) -> bool { + self.client_keys.is_empty() + && self.account_groups.is_empty() + && self.matches_provider(Some(provider)) + && self.matches_model(Some(model)) + } + pub(crate) fn matches_observation(&self, observation: &RequestObservation) -> bool { self.matches_identity(observation.client_key_id(), observation.account_group_ids()) && self.matches_provider(observation.provider()) diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs index 078a66647..8e3e023bf 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs @@ -37,6 +37,24 @@ pub(crate) struct AdapterEntry { adapter: Option>, } +impl AdapterEntry { + pub(crate) fn catalog_target( + &self, + id: &str, + model: &str, + ) -> Option<&UpstreamAdapterDeclaration> { + let adapter = self.adapter.as_ref()?; + let declaration = &adapter.declaration; + let provider = ProviderKind::new(declaration.provider.as_str()).ok()?; + (declaration.id == id + && !declaration.account_ids.is_empty() + && (declaration.models.is_empty() + || declaration.models.iter().any(|value| value == model)) + && self.scope.matches_catalog(&provider, model)) + .then_some(declaration) + } +} + pub(crate) struct PluginUpstreamAdapter { instance_id: String, contribution_id: String, diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs b/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs index 622553621..f83d6bf06 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs @@ -346,7 +346,6 @@ impl PluginRuntime { session.clone(), callbacks.clone(), )?; - let model_aliases = crate::adapter::catalog::prepare(manifest, &instance, session).await?; let upstream_entries = crate::adapter::upstream_adapter::prepare( manifest, &instance, @@ -354,6 +353,9 @@ impl PluginRuntime { Arc::clone(&callbacks), ) .await?; + let catalog = + crate::adapter::catalog::prepare(manifest, &instance, session, &upstream_entries) + .await?; prepared.contributions = PreparedContributions { observer_entries, commands, @@ -362,7 +364,8 @@ impl PluginRuntime { middleware_entries, upstream_entries, authentication_entries, - model_aliases, + model_aliases: catalog.aliases, + models: catalog.models, }; Ok(Arc::new(prepared)) } diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/set.rs b/backend/crates/gateway-plugin/runtime/src/generation/prepare/set.rs index 383df994b..67f44a80c 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/set.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/set.rs @@ -30,6 +30,7 @@ pub(super) struct PreparedSet { pub(super) commands: Vec>, pub(super) management: Vec, pub(super) model_aliases: Vec, + pub(super) models: Vec, } #[derive(Clone, Default)] @@ -43,6 +44,7 @@ pub(super) struct PreparedContributions { pub(super) authentication_entries: Vec, pub(super) model_aliases: Vec, + pub(super) models: Vec, } impl PreparedContributions { @@ -51,6 +53,7 @@ impl PreparedContributions { self.commands.extend(other.commands); self.management.extend(other.management); self.model_aliases.extend(other.model_aliases); + self.models.extend(other.models); self.policy_entries.extend(other.policy_entries); self.middleware_entries.extend(other.middleware_entries); self.upstream_entries.extend(other.upstream_entries); @@ -86,6 +89,10 @@ impl ExtensionSetLease for PreparedSet { &self.model_aliases } + fn models(&self) -> &[gateway_core::routing::ContributedModel] { + &self.models + } + fn can_serve(&self) -> bool { // 发布的是可用能力和故障绑定组成的完整计划;单个进程退出不能使原生转发失效 !self.shutting_down.is_cancelled() diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs b/backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs index 18b1fdb66..137890645 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs @@ -217,6 +217,7 @@ impl PluginRuntime { set.model_aliases .iter() .any(|alias| alias.owner == instance.id) + || set.models.iter().any(|model| model.owner == instance.id) }); if has_retained_catalog { return Err(AdminError::unavailable( @@ -251,7 +252,9 @@ impl PluginRuntime { upstream_entries, authentication_entries, model_aliases, + models, } = contributions; + crate::adapter::catalog::validate_models(&model_aliases, &models)?; let id = ExtensionSetId::new(uuid::Uuid::new_v4().to_string()) .map_err(|_| AdminError::internal("插件集合 ID 无效"))?; let observers = crate::adapter::observer::PluginObserverPlan::compile( @@ -316,6 +319,7 @@ impl PluginRuntime { management, failures, model_aliases, + models, shutting_down: self.shutting_down.clone(), }); self.prepared diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/snapshot.rs b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/snapshot.rs index 61863a4f1..ddfd6fb2c 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/snapshot.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/snapshot.rs @@ -9,6 +9,189 @@ use gateway_plugin_sdk::Capability; use gateway_plugin_sdk::Contributions; use gateway_plugin_sdk::Stage; +#[tokio::test] +async fn adapted_catalog_freezes_adapter_accounts_and_preserves_the_active_generation() { + use gateway_admin::model::plugins::instances::{PluginCapabilityBinding, PluginFailurePolicy}; + let mut catalog = crate::support::contribution( + Capability::ModelCatalog, + vec![Stage::Registration], + vec![], + vec![], + ); + catalog.1.version = 2; + let adapter = crate::support::contribution( + Capability::UpstreamAdapter, + vec![Stage::Upstream], + vec!["openai".into()], + vec!["openai".into()], + ); + let (cache, store, runtime) = + super::super::setup_with_contributions(Contributions::from([catalog, adapter])).await; + let mut snapshot = store.snapshot.lock().unwrap().clone(); + snapshot.instances[0].bindings = vec![PluginCapabilityBinding { + contribution: "test.example.upstreamAdapter".into(), + stage: "upstream".into(), + order: 0, + failure_policy: PluginFailurePolicy::Reject, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec!["openai".into()], + models: vec!["shared-model".into()], + event: None, + identity_bindings: vec![], + }]; + snapshot.instances[0].configuration = serde_json::json!({ + "upstream_registration":{"adapters":[{ + "id":"example","provider":"openai","base_url":"https://example.invalid/", + "paths":[{"path":"responses","purpose":"inference"}], "authentication_kinds":["oauth"], + "account_ids":["acct_b"], "models":["shared-model"], "transport":"http_sse","protocol":"openai" + }]}, + "model_catalog":{"aliases":[],"models":[{ + "id":"shared-model","adapter_id":"example","features":["tools"],"presentation":{"display_name":"Shared"} + }]} + }); + *store.snapshot.lock().unwrap() = snapshot.clone(); + let active = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + assert_eq!(active.models().len(), 1); + assert_eq!( + active.models()[0].accounts.iter().next().unwrap().as_str(), + "acct_b" + ); + assert!(active.model_aliases().is_empty()); + for scenario in ["accounts", "model", "binding", "key", "group"] { + let mut invalid = snapshot.clone(); + invalid.config_revision = Revision::new(2).unwrap(); + invalid.instances[0].revision = invalid.config_revision; + let instance = &mut invalid.instances[0]; + match scenario { + "accounts" => { + instance.configuration["upstream_registration"]["adapters"][0]["account_ids"] = + serde_json::json!([]) + } + "model" => { + instance.configuration["upstream_registration"]["adapters"][0]["models"] = + serde_json::json!(["other"]) + } + "binding" => instance.bindings[0].models = vec!["other".into()], + "key" => instance.bindings[0].client_key_ids = vec!["key_one".into()], + "group" => { + instance.bindings[0].account_group_ids = + vec!["grp_0123456789abcdef0123456789abcdef".into()] + } + _ => unreachable!(), + } + let error = PluginPreparation::prepare(&runtime, invalid) + .await + .unwrap_err(); + assert_eq!( + error.message(), + "适配模型必须关联同实例的静态账号适配器", + "{scenario}" + ); + assert!(active.is_ready()); + } + for conflict in [ + None, + Some("accounts"), + Some("presentation"), + Some("features"), + Some("alias"), + ] { + let mut candidate = snapshot.clone(); + candidate.config_revision = Revision::new(2).unwrap(); + candidate.instances[0].revision = candidate.config_revision; + let config = &mut candidate.instances[0].configuration; + let mut adapter = config["upstream_registration"]["adapters"][0].clone(); + adapter["id"] = "second".into(); + adapter["account_ids"] = serde_json::json!([if conflict == Some("accounts") { + "acct_b" + } else { + "acct_a" + }]); + config["upstream_registration"]["adapters"] + .as_array_mut() + .unwrap() + .push(adapter); + let mut model = config["model_catalog"]["models"][0].clone(); + model["adapter_id"] = "second".into(); + if conflict == Some("presentation") { + model["presentation"]["display_name"] = "Other".into(); + } + if conflict == Some("features") { + model["features"] = serde_json::json!([]); + } + config["model_catalog"]["models"] + .as_array_mut() + .unwrap() + .push(model); + if conflict == Some("alias") { + config["model_catalog"]["aliases"] = + serde_json::json!([{"id":"shared-model","provider":"openai","model":"target"}]); + } + let result = PluginPreparation::prepare(&runtime, candidate).await; + if conflict.is_some() { + assert_eq!(result.unwrap_err().message(), "适配模型目录声明冲突"); + } else { + let prepared = result.unwrap(); + assert_eq!(prepared.models().len(), 2); + assert!( + prepared.models()[0] + .accounts + .is_disjoint(&prepared.models()[1].accounts) + ); + } + assert!(active.is_ready()); + } + snapshot.config_revision = Revision::new(2).unwrap(); + snapshot.instances[0].revision = snapshot.config_revision; + snapshot.instances[0].configuration = serde_json::json!({"startup":"fail"}); + *store.snapshot.lock().unwrap() = snapshot.clone(); + assert!( + ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(2).unwrap()) + .await + .is_err() + ); + snapshot.instances[0].enabled = false; + *store.snapshot.lock().unwrap() = snapshot; + let disabled = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(2).unwrap()) + .await + .unwrap(); + assert!(disabled.models().is_empty()); + assert_eq!(active.models().len(), 1); + drop(disabled); + drop(active); + runtime.shutdown().await; + super::super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn adapted_catalog_requires_its_registered_adapter() { + let mut catalog = crate::support::contribution( + Capability::ModelCatalog, + vec![Stage::Registration], + vec![], + vec![], + ); + catalog.1.version = 2; + let (cache, store, runtime) = + super::super::setup_with_contributions(Contributions::from([catalog])).await; + let mut snapshot = store.snapshot.lock().unwrap().clone(); + snapshot.instances[0].configuration = serde_json::json!({ + "model_catalog": {"aliases":[], "models":[{ + "id":"shared-model", "adapter_id":"missing", "features":[], + "max_output_tokens":null, "presentation":{} + }]} + }); + let error = PluginPreparation::prepare(&runtime, snapshot) + .await + .unwrap_err(); + assert_eq!(error.message(), "适配模型必须关联同实例的静态账号适配器"); + runtime.shutdown().await; + super::super::wait_until_empty(cache.path()).await; +} + #[tokio::test] async fn initial_registration_http_uses_the_prepared_settings_without_a_publication() { use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; diff --git a/backend/crates/gateway-plugin/sdk/README.md b/backend/crates/gateway-plugin/sdk/README.md index e12cfe333..dca8b95be 100644 --- a/backend/crates/gateway-plugin/sdk/README.md +++ b/backend/crates/gateway-plugin/sdk/README.md @@ -22,7 +22,7 @@ SDK 包版本为 `0.1.0`,独立于网关版本,处于实验阶段。能力 | `call::services` | 公开服务的类型化操作、输入输出及错误合同;`usage` 提供[用量维护与计价](docs/usage-data.md) | | `call::upstream_adapter` | 内置 OpenAI / xAI 的[受管上游适配器](docs/upstream-adapters.md)注册、执行、出站与续接合同 | | `call::policy` | 模型路由、账号调度与重试决策合同 | -| `call::catalog` | 固定 Provider 的模型别名注册合同 | +| `call::catalog` | 固定 Provider 的直接别名与适配模型注册合同,见[模型目录](docs/capabilities.md#模型目录) | | `call::data` | 最小账号事实与已有额度观测的只读合同 | | `call::observation` | 请求完成、用量、费用与实际上游 WebSocket 响应事件的统一观察合同 | | `call::host` | 账号、私有状态、受管 HTTP、模型、亲和、日志及调用内流的宿主回调合同 | diff --git a/backend/crates/gateway-plugin/sdk/docs/capabilities.md b/backend/crates/gateway-plugin/sdk/docs/capabilities.md index 45838794f..d702c7e06 100644 --- a/backend/crates/gateway-plugin/sdk/docs/capabilities.md +++ b/backend/crates/gateway-plugin/sdk/docs/capabilities.md @@ -548,6 +548,23 @@ let plugin = PluginBuilder::from_json(include_bytes!("../plugin.json"))? 目标存在时,别名复用目标的可公开对象;禁用后新请求不再使用该别名,在途请求持有旧代次。 v1 是直接别名,动态选择可组合 `model_router`;实际路由仍须通过宿主的权限和能力校验,不提供多目标虚拟模型注册合同 +`model_catalog` v2 使用同一 RPC 方法,返回 `ModelCatalogRegistrationV2 { aliases, models }`。 +`aliases` 沿用 v1 直接别名语义;`models` 的每条 `AdaptedModel { id, adapter_id, features, max_output_tokens, presentation }` +声明一个受管适配器提供的公开模型,不要求内置 Provider 的原生目录存在同名条目 + +- `adapter_id` 必须指向同实例已绑定的 `upstream_adapter`,Provider 与非空账号集合取自该适配器声明 +- 适配器及 binding 的模型范围必须覆盖 `id`;带 Key/账号组条件的适配器不能贡献静态目录,不能丢弃这些条件扩大可见范围 +- `features` 使用 `RequestFeature`,仅为生成操作声明工具、图片输入、推理或 JSON Schema 支持;没有声明的能力不能借用同名原生模型的支持事实 +- `presentation` 是独立通用画像,字段见 `call::catalog::ModelPresentation`;省略的布尔能力为 false,未声明推理档位不补猜 +- 同名条目可关联不同账号,但 Provider、能力及画像须一致,账号集合不能重叠,也不能与插件直接别名同名,以免展示与实际选择产生歧义 +- v2 仍受每实例合计 256 条、64 KiB 信封限制;目录与适配器随同一代次发布,账号最终资格取当前 Key、分组及账号模型政策的交集 + +使用 `PluginBuilder::on(methods::MODEL_CATALOG_REGISTER_V2, handler)` 返回 `TypedReply`, +并在清单声明 `model_catalog.version = 2`。原有 `.model_catalog(...)` 作者入口继续使用 v1 合同 + +原生 Codex 目录成功返回后,宿主保留其中的完整对象,只追加可见且尚未出现的适配模型画像;原生读取错误仍向调用方返回错误。 +适配模型的 wire 模型名、协议编解码及摘要由插件负责,宿主只使用公开 ID、能力和账号范围参与现有选号,不生成请求正文或转换历史 + ### 重试决策 `retry_policy` v2 固定绑定 `retry` 阶段,故障策略只能为 `delegate`。使用 `methods::RETRY_DECISION` 处理 `RetryDecisionRequest`。 diff --git a/backend/crates/gateway-plugin/sdk/docs/manifest.md b/backend/crates/gateway-plugin/sdk/docs/manifest.md index 4b96cfa18..0c1b0235d 100644 --- a/backend/crates/gateway-plugin/sdk/docs/manifest.md +++ b/backend/crates/gateway-plugin/sdk/docs/manifest.md @@ -57,7 +57,8 @@ RPC v7 的帧布局、编码与会话生命周期见[通信边界](../README.md# | `scheduler` | `3` | | `upstream_adapter` | `5` | | `frontend_authentication`、`model_router`、`retry_policy`、`observer`、`management` | `2` | -| `model_catalog`、`command_line`、`maintenance` | `1` | +| `model_catalog` | `1`、`2` | +| `command_line`、`maintenance` | `1` | 例如,仅声明管理页面、命令行和模型请求中间件: diff --git a/backend/crates/gateway-plugin/sdk/src/call/catalog.rs b/backend/crates/gateway-plugin/sdk/src/call/catalog.rs index 2df185e3b..91ac02cfb 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/catalog.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/catalog.rs @@ -1,4 +1,4 @@ -//! 固定 Provider 的模型别名贡献;目标功能与协议元数据由宿主目录提供 +//! 固定 Provider 的直接别名与受管适配模型目录合同 use serde::{Deserialize, Serialize}; @@ -15,3 +15,39 @@ pub struct ModelAlias { pub provider: String, pub model: String, } + +/// v2 同时发布直接别名与受管适配器实际提供的模型 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelCatalogRegistrationV2 { + pub aliases: Vec, + pub models: Vec, +} + +/// 账号与 Provider 取自同实例的 adapter 声明,不另复制地址或凭据 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdaptedModel { + pub id: String, + pub adapter_id: String, + pub features: Vec, + pub max_output_tokens: Option, + pub presentation: ModelPresentation, +} + +/// 通用客户端画像;省略的能力不向客户端宣告支持 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ModelPresentation { + pub display_name: Option, + pub description: Option, + pub default_reasoning_effort: Option, + pub supported_reasoning_efforts: Vec, + pub context_window_tokens: Option, + pub max_context_window_tokens: Option, + pub image_input: bool, + pub agent_tools: bool, + pub parallel_tool_calls: bool, + pub search_tool: bool, + pub image_detail_original: bool, + pub verbosity: bool, + pub hidden: bool, +} diff --git a/backend/crates/gateway-plugin/sdk/src/capability.rs b/backend/crates/gateway-plugin/sdk/src/capability.rs index 8feef3bb2..5ddca2c0a 100644 --- a/backend/crates/gateway-plugin/sdk/src/capability.rs +++ b/backend/crates/gateway-plugin/sdk/src/capability.rs @@ -34,7 +34,8 @@ impl Capability { | Self::Management => &[2], Self::Scheduler => &[3], Self::UpstreamAdapter => &[5], - Self::ModelCatalog | Self::CommandLine | Self::Maintenance => &[1], + Self::ModelCatalog => &[1, 2], + Self::CommandLine | Self::Maintenance => &[1], } } diff --git a/backend/crates/gateway-plugin/sdk/src/client/plugin/methods.rs b/backend/crates/gateway-plugin/sdk/src/client/plugin/methods.rs index 9fad10973..206ff9f1a 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/plugin/methods.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/plugin/methods.rs @@ -42,6 +42,15 @@ pub const MODEL_CATALOG_REGISTER: Method = + Method::new( + "model_catalog.register", + &[C::ModelCatalog], + &[S::Registration], + decode_metadata_without_payload, + encode_payload, + ); + pub const RETRY_DECISION: Method = Method::new( "policy.retry_decision", &[C::RetryPolicy], diff --git a/backend/crates/providers/openai/src/credential/catalog.rs b/backend/crates/providers/openai/src/credential/catalog.rs index aad85e27d..1055617ca 100644 --- a/backend/crates/providers/openai/src/credential/catalog.rs +++ b/backend/crates/providers/openai/src/credential/catalog.rs @@ -338,6 +338,7 @@ impl CodexCredentialCatalogService { } } drop(catalogs); + let api_catalog_available = !api_catalogs.is_empty(); // 完整对象优先于普通 ID;同类目录按账号顺序选来源,并保留上游条目顺序 let (native, adapted): (Vec<_>, Vec<_>) = api_catalogs .into_values() @@ -375,7 +376,7 @@ impl CodexCredentialCatalogService { Err(error) => last_error = error, } } - if api_models.is_empty() { + if !api_catalog_available { Err(last_error) } else { Ok(api_models) diff --git a/backend/crates/providers/openai/tests/credential/catalog.rs b/backend/crates/providers/openai/tests/credential/catalog.rs index 5f77b0074..38b0d50f6 100644 --- a/backend/crates/providers/openai/tests/credential/catalog.rs +++ b/backend/crates/providers/openai/tests/credential/catalog.rs @@ -1018,6 +1018,56 @@ async fn api_key_native_catalog_preserves_unknown_reasoning_and_background_entit upstream.verify().await; } +#[tokio::test] +async fn api_key_client_catalog_distinguishes_filtered_empty_from_fetch_failure() { + use gateway_core::account::AccountModelAccess; + use provider_openai::credential::ResponsesTransport; + for status in [200, 503] { + let upstream = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + store + .seed_api_key("acct_api", upstream.uri(), ResponsesTransport::Http) + .await; + store.set_model_access( + "acct_api", + AccountModelAccess::new( + gateway_core::account::AccountModelAccessMode::Allowlist, + vec!["logical-model".into()], + ) + .unwrap(), + ); + Mock::given(method("GET")) + .and(path("/models")) + .respond_with( + ResponseTemplate::new(status) + .set_body_json(serde_json::json!({"data":[{"id":"wire-model"}]})), + ) + .expect(1) + .mount(&upstream) + .await; + let service = service_with_catalog_cache(&store, upstream.uri(), catalog_cache()); + let result = service + .client_model_catalog( + &client_scope(&[store.account("acct_api").unwrap()]), + "0.155.0", + ) + .await; + if status == 200 { + assert!( + result + .expect("success filtered by account policy") + .is_empty() + ); + } else { + assert!( + result.is_err(), + "fetch failure must not look like a successful empty catalog" + ); + } + upstream.verify().await; + } +} + #[tokio::test] async fn api_key_client_catalog_keeps_native_sources_stable_and_account_scoped() { use gateway_core::routing::ProviderModelContent; diff --git a/backend/crates/providers/openai/tests/provider/contract/selection_retry.rs b/backend/crates/providers/openai/tests/provider/contract/selection_retry.rs index 90bf62350..a19d9329d 100644 --- a/backend/crates/providers/openai/tests/provider/contract/selection_retry.rs +++ b/backend/crates/providers/openai/tests/provider/contract/selection_retry.rs @@ -57,6 +57,156 @@ impl ExecutionStore for NoopExecutionStore { } } +#[tokio::test] +async fn contributed_model_scope_survives_affinity_and_account_rotation() { + use gateway_core::routing::ContributedModel; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct Catalog(Vec); + impl ExtensionSetLease for Catalog { + fn is_ready(&self) -> bool { + true + } + fn models(&self) -> &[ContributedModel] { + &self.0 + } + } + + for native_supports_tools in [false, true] { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let server = MockServer::start().await; + let calls = AtomicUsize::new(0); + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with(move |_: &wiremock::Request| { + if calls.fetch_add(1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(429).set_body_json(json!({ + "error":{"type":"rate_limit_error","message":"fixture rate limit"} + })) + } else { + ResponseTemplate::new(200) + .set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream") + } + }) + .expect(2) + .mount(&server) + .await; + let provider = provider_with_affinity_and_base_url( + &store, + Arc::new(MemorySessionAffinity::default()), + server.uri(), + ); + let Operation::Generate(generate) = turn_request("scope-session", "scope-turn") else { + unreachable!() + }; + let mut body = generate.protocol_payload().body().clone(); + if native_supports_tools { + body.insert( + "tools".into(), + json!([{ + "type":"function", "name":"fixture", "parameters":{"type":"object"} + }]), + ); + } + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", body) + .unwrap() + .with_context(generate.protocol_payload().context().clone()), + )); + // 先冻结 A 的会话亲和,再开放 B/C;后续选号不能借亲和绕过模型能力范围 + drop( + provider + .clone() + .execute( + planned_request("openai", operation.clone()), + context("req_scope_seed", CancellationToken::new()), + ) + .await + .unwrap(), + ); + for account in ["acct_subagent_a", "acct_subagent_b"] { + create_account(&store, account).await; + } + let kind = ProviderKind::new("openai").unwrap(); + let capabilities = ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), None); + let models = if native_supports_tools { + vec![ProviderModel::new( + kind.clone(), + UpstreamModelId::new("gpt-5.4").unwrap(), + capabilities.clone().with_upstream_feature_validation(), + )] + } else { + Vec::new() + }; + // 适配专属模型只属于 B/C;同名原生模型支持 tools 时,适配账号 A 不支持 + let contributed_accounts = if native_supports_tools { + vec!["acct_provider_contract"] + } else { + vec!["acct_subagent_a", "acct_subagent_b"] + }; + let snapshot = RuntimeSnapshot::new( + ConfigRevision::new(1).unwrap(), + SettingsValues::new(3, "smart", Default::default(), None, None), + vec![kind.clone()], + models, + Vec::new(), + ) + .unwrap() + .with_extensions(Some(ExtensionSetReference::new( + ExtensionSetId::new("scope-test".into()).unwrap(), + Arc::new(Catalog(vec![ContributedModel { + owner: "scope-test".into(), + id: PublicModelId::new("gpt-5.4").unwrap(), + provider: kind, + accounts: contributed_accounts + .into_iter() + .map(|id| ProviderAccountId::new(id).unwrap()) + .collect(), + capabilities, + presentation: Default::default(), + }])), + ))); + let plan = snapshot + .plan( + &PublicModelId::new("gpt-5.4").unwrap(), + &operation, + contract_account_scope(), + &RoutingContext::default(), + ) + .unwrap(); + let providers = + ProviderRegistry::new([provider as Arc]) + .unwrap(); + let coordinator = + AttemptCoordinator::new(GatewayEngine::new(Arc::new(NoopExecutionStore), providers)); + let mut session = coordinator + .start( + execution_request("req_scope_rotation", &operation, Duration::from_secs(10)), + operation, + plan, + None, + None, + CancellationToken::new(), + ) + .await + .unwrap(); + session.collect_uncommitted().await.unwrap(); + session.commit_downstream(Some(200)).await.unwrap(); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2); + let selected: BTreeSet<_> = requests + .iter() + .map(|request| request.headers["authorization"].to_str().unwrap()) + .collect(); + assert_eq!( + selected, + BTreeSet::from(["Bearer at-acct_subagent_a", "Bearer at-acct_subagent_b",]), + "native_supports_tools={native_supports_tools}" + ); + } +} + #[tokio::test] async fn cyber_policy_rotation_preserves_upstream_response_and_next_request_stays_local() { let store = Arc::new(MemoryAccountStore::default()); diff --git a/backend/crates/providers/openai/tests/support.rs b/backend/crates/providers/openai/tests/support.rs index 647280f57..b5f4e0be8 100644 --- a/backend/crates/providers/openai/tests/support.rs +++ b/backend/crates/providers/openai/tests/support.rs @@ -148,6 +148,18 @@ impl MemoryAccountStore { .map(|stored| stored.account.clone()) } + pub(crate) fn set_model_access( + &self, + id: &str, + access: gateway_core::account::AccountModelAccess, + ) { + let mut accounts = self.accounts.lock().unwrap(); + let stored = accounts + .get_mut(&ProviderAccountId::new(id).unwrap()) + .unwrap(); + stored.account = stored.account.clone().with_model_access(access); + } + pub(crate) fn set_scheduling( &self, id: &str, diff --git a/docs/api.md b/docs/api.md index 72f3e9135..7b877068f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -439,7 +439,9 @@ xAI 没有 Codex 原生目录,使用官方 CLI proxy 当前目录生成通用 其次采用 API Key 的完整 Codex 对象,再使用普通模型 ID;同类 API Key 目录按账号 ID 确定来源,不跨账号 或套餐混拼字段。因此单账号、无别名时可保持该账号的模型对象一致,多账号/多 Provider 聚合不代表 “与某个官方账号的整个目录完全一致”,也不会固定后续推理账号。 -读取失败返回 `503 model_catalog_unavailable`,不以简化模板或空成功响应覆盖客户端缓存 +读取失败返回 `503 model_catalog_unavailable`,不以简化模板或空成功响应覆盖客户端缓存。 +成功获取的 API Key 目录被账号模型政策全部过滤时返回空列表,区别于读取失败。 +插件声明的适配模型按受管适配器账号范围与当前 Key 权限交集追加;同名原生完整对象优先保留,不与插件画像拼接字段 原生目录可能返回缓存结果,成功缓存有效期为 5 分钟。每次查询都检查账号资格和 Client Key 范围。 成功目录响应带 `Cache-Control: private, no-store`,不借用上游 ETag 标识经过选择/别名/聚合后的正文 diff --git a/docs/architecture.md b/docs/architecture.md index 990d8e259..476a44c01 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,9 +131,10 @@ flowchart LR 本节说明模型扩展的执行约束;配置与发布见[制品、配置与发布](#制品配置与发布),调用链见[请求组合与设置](#请求组合与设置),资源回收见[调用资源与状态](#调用资源与状态) -- **模型目录与路由共用事实**:`model_catalog` 的直接别名保存在发布代次引用中,插件负责条目正确性,条目冲突或目标不存在不阻止宿主快照发布。 +- **模型目录与路由共用事实**:`model_catalog` 的目录贡献保存在发布代次引用中,v1 直接别名由插件负责条目正确性,条目冲突或目标不存在不阻止宿主快照发布。 请求按顺序取首个匹配别名并直接使用其目标,不递归解析插件别名;Provider、账号权限与目标能力沿原选路流程处理。 - 公开列表、详情、原生目录和受管模型查询复用相同别名与账号访问范围,不另维护插件专用目录 + v2 适配模型从同实例受管适配器继承静态账号范围,Core 按冻结权限与请求能力收窄候选账号,不改变调度评分、亲和或租约。 + 公开列表、详情、原生目录和受管模型查询复用相同目录贡献与账号访问范围,不另维护插件专用目录;合同见 SDK [模型目录](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型目录) - **重试由 Core 裁决**:`retry_policy` 只能停止或继续 Core 已允许的恢复路径;绑定按确定顺序委托,故障回退宿主。 发送、交付、续接、外部副作用、预算和取消限制不进入插件控制面,策略返回后继续复核 - **转换声明不替代正文事实**:中间件在 request 阶段声明具体承担的功能和额外上游需求,逐层继承尚未承担的需求,实际正文与有效需求共同参与选路。 diff --git a/docs/plugins.md b/docs/plugins.md index b6d6eb291..852bc241e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -82,7 +82,7 @@ flowchart LR | 页面模型请求 | 每次选择 Client Key,经固定实例版本的 Responses 桥执行,返回原生 JSON 或 SSE | | 请求中间件、模型路由、账号调度、重试策略、请求观察 | 匹配功能范围的正常网关请求自动使用;重试策略故障时交给后续处理 | | 上游适配器 | 为内置 OpenAI / xAI 的生成请求提供上游实现;沿用宿主选号、代理、租约与结算,命中但不可用时拒绝请求 | -| 模型目录 | 启用后原子发布别名,列表与路由继承目标模型的能力和现有访问范围 | +| 模型目录 | 原子发布直接别名或适配模型;别名继承目标能力,适配模型按适配器账号范围和声明能力参与既有路由 | | 基础数据页面 | 通过宿主数据回调读取账号基础信息与已有额度观测,展示本身不进入请求改写链 | | 入口认证 | 客户端按插件协议访问,宿主把明确的 principal 映射到 Client Key | | 命令行 | 在宿主环境运行 `codex-proxy-rs plugin <配置 ID> --help` |