feat(plugin): support account-scoped adapted model catalogs (#533)

* feat(plugin): publish account-scoped adapted model catalogs

* fix(core): preserve candidate account scope during execution

Apply the narrowed candidate scope before provider account selection, including affinity and account rotation. Cover adapted-only models and same-name models with incompatible tool capabilities through the real OpenAI selector and local HTTP fixtures.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Ke Wang
2026-10-27 13:37:57 +08:00
committed by GitHub
co-authored by Codex Zyy
parent 9296885cce
commit 19fbb5da75
31 changed files with 1125 additions and 65 deletions
@@ -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
@@ -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)
})
@@ -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<crate::account::scope::FrozenAccountScope>> {
self.account.account_scope()
@@ -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<ProviderAccountId>,
pub capabilities: ModelCapabilities,
pub presentation: ModelPresentation,
}
/// Provider 客户端目录条目;缺少原生协议正文时使用已编译的通用画像
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelDescriptor {
@@ -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 {
@@ -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,
};
@@ -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<super::PublicModelProfile> {
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<super::PublicModelProfile> {
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<super::PublicModelProfile> {
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,
});
}
@@ -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<dyn Provider>
])
ProviderRegistry::new([Arc::new(NativeCatalogProvider {
fail: false,
empty: false,
}) as Arc<dyn Provider>])
.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<dyn Provider>
])
ProviderRegistry::new([Arc::new(NativeCatalogProvider {
fail: false,
empty: false,
}) as Arc<dyn Provider>])
.unwrap(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedContinuation),
@@ -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<gateway_core::routing::ContributedModelAlias>);
struct Aliases(
Vec<gateway_core::routing::ContributedModelAlias>,
Vec<gateway_core::routing::ContributedModel>,
);
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<dyn Provider>])
.expect("registry"),
ProviderRegistry::new([
Arc::new(NativeCatalogProvider { fail, empty }) as Arc<dyn Provider>
])
.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::<Vec<_>>();
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 {
@@ -99,6 +99,247 @@ struct CountingExtensionPreparation {
struct AliasLease(Vec<ContributedModelAlias>);
struct ModelLease(Vec<gateway_core::routing::ContributedModel>);
#[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, ProviderCatalogGeneration> {
[(
ProviderKind::new("alpha").unwrap(),
ProviderCatalogGeneration::new(1),
)]
.into()
}
fn query_model_capabilities(
&self,
_: &ProviderKind,
) -> BoxFuture<'_, Result<Vec<ProviderModelCapabilities>, 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
@@ -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] },
@@ -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<ContributedModelAlias>,
pub(crate) models: Vec<ContributedModel>,
}
pub(crate) async fn prepare(
manifest: &Manifest,
instance: &PluginInstance,
session: &RpcSession,
) -> Result<Vec<ContributedModelAlias>, AdminError> {
adapters: &[super::upstream_adapter::AdapterEntry],
) -> Result<PreparedCatalog, AdminError> {
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::<Result<_, AdminError>>()?;
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::<Result<_, _>>()?,
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::<Result<_, AdminError>>()?;
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(
@@ -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())
@@ -37,6 +37,24 @@ pub(crate) struct AdapterEntry {
adapter: Option<Arc<PluginUpstreamAdapter>>,
}
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,
@@ -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))
}
@@ -30,6 +30,7 @@ pub(super) struct PreparedSet {
pub(super) commands: Vec<Arc<crate::adapter::command_line::PluginCommand>>,
pub(super) management: Vec<crate::adapter::management::ManagementEntry>,
pub(super) model_aliases: Vec<gateway_core::routing::ContributedModelAlias>,
pub(super) models: Vec<gateway_core::routing::ContributedModel>,
}
#[derive(Clone, Default)]
@@ -43,6 +44,7 @@ pub(super) struct PreparedContributions {
pub(super) authentication_entries:
Vec<crate::adapter::frontend_authentication::FrontendAuthenticationEntry>,
pub(super) model_aliases: Vec<gateway_core::routing::ContributedModelAlias>,
pub(super) models: Vec<gateway_core::routing::ContributedModel>,
}
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()
@@ -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
@@ -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};
+1 -1
View File
@@ -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、模型、亲和、日志及调用内流的宿主回调合同 |
@@ -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<ModelCatalogRegistrationV2>`,
并在清单声明 `model_catalog.version = 2`。原有 `.model_catalog(...)` 作者入口继续使用 v1 合同
原生 Codex 目录成功返回后,宿主保留其中的完整对象,只追加可见且尚未出现的适配模型画像;原生读取错误仍向调用方返回错误。
适配模型的 wire 模型名、协议编解码及摘要由插件负责,宿主只使用公开 ID、能力和账号范围参与现有选号,不生成请求正文或转换历史
### 重试决策
`retry_policy` v2 固定绑定 `retry` 阶段,故障策略只能为 `delegate`。使用 `methods::RETRY_DECISION` 处理 `RetryDecisionRequest`。
@@ -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` |
例如,仅声明管理页面、命令行和模型请求中间件:
@@ -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<ModelAlias>,
pub models: Vec<AdaptedModel>,
}
/// 账号与 Provider 取自同实例的 adapter 声明,不另复制地址或凭据
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdaptedModel {
pub id: String,
pub adapter_id: String,
pub features: Vec<super::middleware::RequestFeature>,
pub max_output_tokens: Option<u64>,
pub presentation: ModelPresentation,
}
/// 通用客户端画像;省略的能力不向客户端宣告支持
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ModelPresentation {
pub display_name: Option<String>,
pub description: Option<String>,
pub default_reasoning_effort: Option<String>,
pub supported_reasoning_efforts: Vec<String>,
pub context_window_tokens: Option<u64>,
pub max_context_window_tokens: Option<u64>,
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,
}
@@ -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],
}
}
@@ -42,6 +42,15 @@ pub const MODEL_CATALOG_REGISTER: Method<Empty, catalog::ModelCatalogRegistratio
encode_payload,
);
pub const MODEL_CATALOG_REGISTER_V2: Method<Empty, catalog::ModelCatalogRegistrationV2> =
Method::new(
"model_catalog.register",
&[C::ModelCatalog],
&[S::Registration],
decode_metadata_without_payload,
encode_payload,
);
pub const RETRY_DECISION: Method<policy::RetryDecisionRequest, policy::RetryDecision> = Method::new(
"policy.retry_decision",
&[C::RetryPolicy],
@@ -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)
@@ -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;
@@ -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<ContributedModel>);
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<dyn gateway_core::engine::provider::Provider>])
.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());
@@ -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,
+3 -1
View File
@@ -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 标识经过选择/别名/聚合后的正文
+3 -2
View File
@@ -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 阶段声明具体承担的功能和额外上游需求,逐层继承尚未承担的需求,实际正文与有效需求共同参与选路。
+1 -1
View File
@@ -82,7 +82,7 @@ flowchart LR
| 页面模型请求 | 每次选择 Client Key,经固定实例版本的 Responses 桥执行,返回原生 JSON 或 SSE |
| 请求中间件、模型路由、账号调度、重试策略、请求观察 | 匹配功能范围的正常网关请求自动使用;重试策略故障时交给后续处理 |
| 上游适配器 | 为内置 OpenAI / xAI 的生成请求提供上游实现;沿用宿主选号、代理、租约与结算,命中但不可用时拒绝请求 |
| 模型目录 | 启用后原子发布别名,列表与路由继承目标模型的能力和现有访问范围 |
| 模型目录 | 原子发布直接别名或适配模型;别名继承目标能力,适配模型按适配器账号范围和声明能力参与既有路由 |
| 基础数据页面 | 通过宿主数据回调读取账号基础信息与已有额度观测,展示本身不进入请求改写链 |
| 入口认证 | 客户端按插件协议访问,宿主把明确的 principal 映射到 Client Key |
| 命令行 | 在宿主环境运行 `codex-proxy-rs plugin <配置 ID> --help` |