fix(plugins): prefer account-scoped profiles for adapted models (#588)

* fix(plugins): prefer account-scoped model profiles in catalogs

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

* fix(models): honor scoped plugin profiles in fallback catalogs

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
This commit is contained in:
Ke Wang
2026-11-04 13:20:08 +08:00
committed by Zyy
co-authored by Codex Zyy
parent 621b1faceb
commit 39aa20a13a
8 changed files with 250 additions and 104 deletions
@@ -62,15 +62,16 @@ use crate::{
policy::{ClientApiKeyId, ClientPolicy},
provider_ports::{ProviderSessionAffinityPort, ProviderStoreErrorKind},
routing::{
FrozenAccountScope, ProviderCatalogUnavailable, PublicModelDescriptor, PublicModelId,
RoutingContext, RuntimeSnapshot, UpstreamModelId, request_settings::RequestSettings,
FrozenAccountScope, ProviderCatalogUnavailable, ProviderModelContent,
PublicModelDescriptor, PublicModelId, RoutingContext, RuntimeSnapshot, UpstreamModelId,
request_settings::RequestSettings,
},
runtime::{RuntimeSnapshotHandle, RuntimeSnapshotPublisher},
};
use futures::{FutureExt as _, future::BoxFuture, pin_mut, select_biased};
use futures_timer::Delay;
use std::{
collections::BTreeSet,
collections::{BTreeMap, BTreeSet},
sync::{Arc, Weak},
time::{Instant, SystemTime},
};
@@ -1575,11 +1576,27 @@ impl ExecutionService for DefaultExecutionService {
}
continue;
};
let contributions: Vec<_> = client
.snapshot
.contributed_models_for_scope(kind, scope)
.collect();
let mut contributed_by_model = BTreeMap::new();
if !contributions.is_empty()
&& models
.iter()
.any(|entry| matches!(entry.content, ProviderModelContent::Adapted(_)))
{
contributed_by_model.extend(
contributions
.iter()
.map(|model| (&model.id, &model.presentation)),
);
}
// 保留上游顺序;映射只选择完整的目标对象,不能跨账号/模型混拼字段
let by_id = models
.iter()
.map(|entry| (entry.model.as_str(), entry))
.collect::<std::collections::BTreeMap<_, _>>();
.collect::<BTreeMap<_, _>>();
for entry in &models {
let target = client.snapshot.mapped_model(entry.model.as_str());
if !scope.allows_provider_model(kind, &target) {
@@ -1598,7 +1615,11 @@ impl ExecutionService for DefaultExecutionService {
continue;
}
if seen.insert(model.clone()) {
result.push(source.content.for_public_model(model));
result.push(
source
.content
.for_public_model(model, &contributed_by_model),
);
}
}
for model in client.snapshot.public_models_for_provider(kind) {
@@ -1617,13 +1638,17 @@ impl ExecutionService for DefaultExecutionService {
}
if let Some(source) = by_id.get(target.as_str()) {
seen.insert(model.clone());
result.push(source.content.for_public_model(model));
result.push(
source
.content
.for_public_model(model, &contributed_by_model),
);
}
}
// 原生完整对象优先;只补入当前授权账号实际提供的独立适配画像
for profile in client.snapshot.contributed_profiles_for_scope(kind, scope) {
if seen.insert(profile.model().clone()) {
result.push(PublicModelDescriptor::Adapted(profile));
// 没有 Provider 目录条目的适配模型仍由当前授权账号贡献
for model in contributions {
if seen.insert(model.id.clone()) {
result.push(PublicModelDescriptor::Adapted(model.public_profile()));
}
}
}
@@ -32,6 +32,12 @@ pub struct ContributedModel {
pub presentation: ModelPresentation,
}
impl ContributedModel {
pub(crate) fn public_profile(&self) -> PublicModelProfile {
PublicModelProfile::new(self.id.clone(), self.presentation.clone())
}
}
/// Provider 客户端目录条目;缺少原生协议正文时使用已编译的通用画像
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelDescriptor {
@@ -56,13 +62,18 @@ pub enum PublicModelDescriptor {
}
impl ProviderModelContent {
pub(crate) fn for_public_model(&self, model: PublicModelId) -> PublicModelDescriptor {
pub(crate) fn for_public_model(
&self,
model: PublicModelId,
contributed: &BTreeMap<&PublicModelId, &ModelPresentation>,
) -> PublicModelDescriptor {
match self {
Self::Native(payload) => PublicModelDescriptor::Native {
model,
payload: payload.clone(),
},
Self::Adapted(presentation) => {
let presentation = contributed.get(&model).copied().unwrap_or(presentation);
PublicModelDescriptor::Adapted(PublicModelProfile::new(model, presentation.clone()))
}
}
@@ -701,24 +701,18 @@ impl RuntimeSnapshot {
self.extensions.as_ref().map_or(&[], |set| set.models())
}
pub(crate) fn contributed_profiles_for_scope(
pub(crate) fn contributed_models_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()
) -> impl Iterator<Item = &super::ContributedModel> {
self.contributed_models().iter().filter(move |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()))
})
})
}
fn model_alias(&self, model: &str) -> Option<&super::ContributedModelAlias> {
@@ -1014,13 +1008,21 @@ impl RuntimeSnapshot {
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()
let mut profiles = self.public_model_profiles_for_provider(provider);
profiles.retain(|profile| {
self.native_catalog_model_allowed_for_scope(provider, profile.model(), scope)
});
// Provider 画像按公开 ID 排序,追加的插件画像保留注册顺序,不参与二分查找
let provider_count = profiles.len();
for model in self.contributed_models_for_scope(provider, scope) {
match profiles[..provider_count]
.binary_search_by(|profile| profile.model().cmp(&model.id))
{
Ok(index) => profiles[index].presentation.clone_from(&model.presentation),
Err(_) => profiles.push(model.public_profile()),
}
}
profiles
}
/// 已知模型只属于明确声明它的 Provider;未知模型仍可由非完整目录透传
@@ -16,11 +16,8 @@ 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,
empty: false,
}) as Arc<dyn Provider>])
.unwrap(),
ProviderRegistry::new([Arc::new(CatalogProvider::Native) as Arc<dyn Provider>])
.unwrap(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedContinuation),
usage.clone(),
@@ -91,11 +88,8 @@ 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,
empty: false,
}) as Arc<dyn Provider>])
.unwrap(),
ProviderRegistry::new([Arc::new(CatalogProvider::Native) as Arc<dyn Provider>])
.unwrap(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedContinuation),
usage.clone(),
@@ -2,7 +2,10 @@
mod identity;
mod outbound;
use gateway_core::routing::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference};
use gateway_core::routing::{
ContributedModel, ContributedModelAlias,
extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference},
};
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
@@ -3300,15 +3303,22 @@ fn probe_observation_store_failure_preserves_the_provider_error() {
struct FailingProvider;
struct NativeCatalogProvider {
fail: bool,
empty: bool,
#[derive(Clone, Copy)]
enum CatalogProvider {
Native,
Empty,
Unavailable,
Generic,
Xai,
}
#[async_trait]
impl Provider for NativeCatalogProvider {
impl Provider for CatalogProvider {
fn name(&self) -> &'static str {
"openai"
match self {
Self::Xai => "xai",
_ => "openai",
}
}
fn catalog_generation(&self) -> ProviderCatalogGeneration {
ProviderCatalogGeneration::default()
@@ -3330,29 +3340,41 @@ impl Provider for NativeCatalogProvider {
assert!(scope.allows(&ProviderAccountId::new("acct_start").expect("account")));
assert!(!scope.allows(&ProviderAccountId::new("acct_other").expect("account")));
assert_eq!((protocol, version), ("codex", "0.154.0"));
if self.fail {
return Err(gateway_core::routing::ProviderCatalogUnavailable);
match self {
Self::Unavailable => return Err(gateway_core::routing::ProviderCatalogUnavailable),
Self::Empty => return Ok(Some(Vec::new())),
Self::Xai => return Ok(None),
Self::Native | Self::Generic => {}
}
if self.empty {
return Ok(Some(Vec::new()));
}
Ok(Some(
["gpt-new", "gpt-start"]
.into_iter()
.map(|id| gateway_core::routing::ProviderModelDescriptor {
model: UpstreamModelId::new(id).expect("id"),
content: gateway_core::routing::ProviderModelContent::Native(
RawJsonPayload::new(
"codex",
serde_json::to_vec(&json!({"slug":id,"future":null}))
.expect("JSON")
.into(),
)
.expect("payload"),
let mut models = ["gpt-new", "gpt-start"]
.into_iter()
.map(|id| gateway_core::routing::ProviderModelDescriptor {
model: UpstreamModelId::new(id).expect("id"),
content: gateway_core::routing::ProviderModelContent::Native(
RawJsonPayload::new(
"codex",
serde_json::to_vec(&json!({"slug":id,"future":null}))
.expect("JSON")
.into(),
)
.expect("payload"),
),
})
.collect::<Vec<_>>();
if matches!(self, Self::Generic) {
models.extend(["shared-model", "z-last"].map(|id| {
gateway_core::routing::ProviderModelDescriptor {
model: UpstreamModelId::new(id).unwrap(),
content: gateway_core::routing::ProviderModelContent::Adapted(
gateway_core::routing::ModelPresentation::new(
Some(format!("Provider {id}")),
None,
),
),
})
.collect(),
))
}
}));
}
Ok(Some(models))
}
async fn execute(
self: Arc<Self>,
@@ -3363,32 +3385,37 @@ impl Provider for NativeCatalogProvider {
}
}
struct CatalogContributions {
aliases: Vec<ContributedModelAlias>,
models: Vec<ContributedModel>,
}
impl ExtensionSetLease for CatalogContributions {
fn is_ready(&self) -> bool {
true
}
fn model_aliases(&self) -> &[ContributedModelAlias] {
&self.aliases
}
fn models(&self) -> &[ContributedModel] {
&self.models
}
}
#[test]
fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models() {
#[derive(Debug)]
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
}
fn model_aliases(&self) -> &[gateway_core::routing::ContributedModelAlias] {
&self.0
}
fn models(&self) -> &[gateway_core::routing::ContributedModel] {
&self.1
}
}
for (fail, empty) in [(false, false), (false, true), (true, false)] {
for provider in [
CatalogProvider::Native,
CatalogProvider::Empty,
CatalogProvider::Unavailable,
] {
let contributed = [
("gpt-start", "acct_start"),
("adapted", "acct_start"),
("private-model", "acct_other"),
]
.into_iter()
.map(|(id, account)| gateway_core::routing::ContributedModel {
.map(|(id, account)| ContributedModel {
owner: "catalog-plugin".into(),
id: PublicModelId::new(id).unwrap(),
provider: ProviderKind::new("openai").unwrap(),
@@ -3412,25 +3439,21 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models(
.with_settings(&settings)
.unwrap()
.with_extensions(Some(ExtensionSetReference::new(
gateway_core::routing::extensions::ExtensionSetId::new("catalog-aliases".into())
.unwrap(),
Arc::new(Aliases(
vec![gateway_core::routing::ContributedModelAlias {
ExtensionSetId::new("catalog-aliases".into()).unwrap(),
Arc::new(CatalogContributions {
aliases: vec![ContributedModelAlias {
owner: "catalog-plugin".into(),
id: PublicModelId::new("plugin-alias").unwrap(),
provider: ProviderKind::new("openai").unwrap(),
target: UpstreamModelId::new("gpt-start").unwrap(),
}],
contributed,
)),
models: contributed,
}),
)));
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::new([
Arc::new(NativeCatalogProvider { fail, empty }) as Arc<dyn Provider>
])
.expect("registry"),
ProviderRegistry::new([Arc::new(provider) as Arc<dyn Provider>]).expect("registry"),
Arc::new(UnusedAdmissions),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
@@ -3438,7 +3461,7 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models(
);
let client = service.authenticate("sk_start_test").expect("authenticate");
let result = block_on(service.client_model_catalog(&client, "codex", "0.154.0"));
if fail {
if matches!(provider, CatalogProvider::Unavailable) {
assert!(
result.is_err(),
"native failure cannot use the global synthesized catalog"
@@ -3457,13 +3480,13 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models(
.collect::<Vec<_>>();
assert_eq!(
adapted,
if empty {
if matches!(provider, CatalogProvider::Empty) {
vec!["gpt-start", "adapted"]
} else {
vec!["adapted"]
}
);
if empty {
if matches!(provider, CatalogProvider::Empty) {
assert_eq!(models.len(), 2);
continue;
}
@@ -3495,6 +3518,95 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models(
}
}
#[test]
fn client_catalog_prefers_scoped_profiles_in_provider_order() {
use gateway_core::routing::{ModelPresentation, PublicModelDescriptor};
for provider in [CatalogProvider::Generic, CatalogProvider::Xai] {
let kind = ProviderKind::new(provider.name()).unwrap();
let capabilities = ModelCapabilities::new([OperationKind::Generate].into(), None);
let presentation =
|source: &str, id: &str| ModelPresentation::new(Some(format!("{source} {id}")), None);
let provider_models = ["shared-model", "z-last"].map(|id| {
ProviderModel::new(
kind.clone(),
UpstreamModelId::new(id).unwrap(),
capabilities.clone(),
)
.with_presentation(presentation("Provider", id))
});
let contributions = [
("extra", "acct_start"),
("z-last", "acct_other"),
("shared-model", "acct_start"),
]
.map(|(id, account)| ContributedModel {
owner: "catalog-plugin".into(),
id: PublicModelId::new(id).unwrap(),
provider: kind.clone(),
accounts: [ProviderAccountId::new(account).unwrap()].into(),
capabilities: capabilities.clone(),
presentation: presentation("Plugin", id).with_agent_tools(true, true),
});
let policy = ClientPolicy::new(
ClientApiKeyId::new("key_start_test").unwrap(),
PlaintextClientApiKey::new("sk_start_test").unwrap(),
account_scope(&kind, "acct_start"),
true,
RateLimits::unlimited(),
);
let snapshot = RuntimeSnapshot::new(
ConfigRevision::new(1).unwrap(),
gateway_core::settings::SettingsValues::new(1, "smart", Default::default(), None, None),
vec![kind],
provider_models.into(),
vec![policy],
)
.unwrap()
.with_extensions(Some(ExtensionSetReference::new(
ExtensionSetId::new("catalog-precedence".into()).unwrap(),
Arc::new(CatalogContributions {
aliases: vec![],
models: contributions.into(),
}),
)));
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::new([Arc::new(provider) as Arc<dyn Provider>]).unwrap(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
Arc::new(crate::RecordingDiagnostics::default()),
);
let client = service.authenticate("sk_start_test").unwrap();
let models = block_on(service.client_model_catalog(&client, "codex", "0.154.0")).unwrap();
let profiles = models
.iter()
.map(|entry| {
let PublicModelDescriptor::Adapted(profile) = entry else {
panic!("expected adapted catalog")
};
(
profile.model().as_str(),
profile.presentation().display_name().unwrap(),
profile.presentation().parallel_tool_calls(),
)
})
.collect::<Vec<_>>();
assert_eq!(
profiles,
[
("shared-model", "Plugin shared-model", true),
("z-last", "Provider z-last", false),
("extra", "Plugin extra", true),
],
"{}",
provider.name()
);
}
}
#[async_trait]
impl Provider for FailingProvider {
fn name(&self) -> &'static str {
@@ -578,7 +578,8 @@ let plugin = PluginBuilder::from_json(include_bytes!("../plugin.json"))?
清单须显式声明 `model_catalog.version = 2`,可使用上述 `.model_catalog(...)` 固定目录,
或通过 `PluginBuilder::on(methods::MODEL_CATALOG_REGISTER_V2, handler)` 返回 `TypedReply<ModelCatalogRegistrationV2>`。
原生 Codex 目录成功返回后,宿主保留其中的完整对象,只追加可见且尚未出现的适配模型画像;原生读取错误仍向调用方返回错误。
Codex 客户端目录保留 Provider 的完整原生对象。对于通用画像,当前账号范围内的同名适配模型使用插件声明的 `presentation`,
包括 xAI 这类由宿主转换的目录。仅由插件提供的可见模型按声明顺序追加到所属 Provider 的条目之后。原生目录读取失败时返回错误。
适配模型的 wire 模型名、协议编解码及摘要由插件负责,宿主只使用公开 ID、能力和账号范围参与现有选号,不生成请求正文或转换历史
### 重试决策
+2 -1
View File
@@ -576,7 +576,8 @@ xAI 没有 Codex 原生目录,使用官方 CLI proxy 当前目录生成通用
“与某个官方账号的整个目录完全一致”,也不会固定后续推理账号。
读取失败返回 `503 model_catalog_unavailable`,不以简化模板或空成功响应覆盖客户端缓存。
成功获取的 API Key 目录被账号模型政策全部过滤时返回空列表,区别于读取失败。
插件声明的适配模型按受管适配器账号范围与当前 Key 权限交集追加;同名原生完整对象优先保留,不与插件画像拼接字段
目录保留完整的原生 Codex 模型对象。对于 OpenAI API Key 普通模型列表和 xAI 目录生成的通用画像,
当前 Key 可访问的适配器若声明了同名模型,其画像在原位置替换通用画像。仅由插件提供的可见模型按声明顺序追加到所属 Provider 的条目之后
原生目录可能返回缓存结果,成功缓存有效期为 5 分钟。每次查询都检查账号资格和 Client Key 范围。
成功目录响应带 `Cache-Control: private, no-store`,不借用上游 ETag 标识经过选择/别名/聚合后的正文
+1 -1
View File
@@ -134,7 +134,7 @@ flowchart LR
- **模型目录与路由共用事实**:`model_catalog` 的目录贡献保存在发布代次引用中,直接别名由插件负责条目正确性,条目冲突或目标不存在不阻止宿主快照发布。
请求按顺序取首个匹配别名并直接使用其目标,不递归解析插件别名;Provider、账号权限与目标能力沿原选路流程处理。
适配模型从同实例受管适配器继承静态账号范围,Core 按冻结权限与请求能力收窄候选账号,不改变调度评分、亲和或租约。
公开列表、详情、原生目录和受管模型查询复用相同目录贡献与账号访问范围,不另维护插件专用目录;合同见 SDK [模型目录](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型目录)
公开列表、详情、客户端目录和受管模型查询复用同一组目录贡献与账号访问范围。Core 保留 Provider 的完整原生对象,在投影通用画像时应用当前账号范围的插件声明。合并顺序见 SDK [模型目录](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型目录)
- **重试由 Core 裁决**:`retry_policy` 只能停止或继续 Core 已允许的恢复路径;绑定按确定顺序委托,故障回退宿主。
发送、交付、续接、外部副作用、预算和取消限制不进入插件控制面,策略返回后继续复核
- **转换声明不替代正文事实**:中间件在 request 阶段声明具体承担的功能和额外上游需求,逐层继承尚未承担的需求,实际正文与有效需求共同参与选路。