refactor(plugins)!: remove model catalog v1 support

Keep direct aliases in v2 and update the SDK builder, runtime compatibility declaration, tests and migration guidance.

BREAKING CHANGE: model_catalog plugins must declare version 2, move their alias list to aliases and return models for adapted models.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-27 17:40:13 +08:00
co-authored by Codex
parent 7b6a204889
commit 60f8f1ee80
14 changed files with 63 additions and 68 deletions
@@ -6,7 +6,7 @@
{ "capability": "frontend_authentication", "versions": [2] },
{ "capability": "scheduler", "versions": [3] },
{ "capability": "model_router", "versions": [2] },
{ "capability": "model_catalog", "versions": [1, 2] },
{ "capability": "model_catalog", "versions": [2] },
{ "capability": "retry_policy", "versions": [2] },
{ "capability": "middleware", "versions": [6] },
{ "capability": "upstream_adapter", "versions": [5] },
@@ -14,10 +14,7 @@ use gateway_core::{
};
use gateway_plugin_sdk::{
Capability, Manifest, Stage,
call::{
catalog::{ModelCatalogRegistration, ModelCatalogRegistrationV2},
middleware::RequestFeature,
},
call::{catalog::ModelCatalogRegistrationV2, middleware::RequestFeature},
};
use crate::RpcSession;
@@ -52,15 +49,8 @@ pub(crate) async fn prepare(
if reply.payload.len() > 64 * 1024 {
return Err(AdminError::invalid("插件模型目录信封无效或过大"));
}
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())
};
let ModelCatalogRegistrationV2 { aliases, models } = serde_json::from_slice(&reply.payload)
.map_err(|_| AdminError::invalid("插件模型目录声明无效"))?;
if aliases.len().saturating_add(models.len()) > 256 {
return Err(AdminError::invalid("单个插件最多贡献 256 个模型条目"));
}
@@ -12,13 +12,12 @@ 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(
let 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],
@@ -46,7 +45,7 @@ async fn adapted_catalog_freezes_adapter_accounts_and_preserves_the_active_gener
"paths":[{"path":"responses","purpose":"inference"}], "authentication_kinds":["oauth"],
"account_ids":["acct_b"], "models":["shared-model"], "transport":"http_sse","protocol":"openai"
}]},
"model_catalog":{"aliases":[],"models":[{
"model_catalog":{"aliases":[{"id":"native-alias","provider":"openai","model":"native-model"}],"models":[{
"id":"shared-model","adapter_id":"example","features":["tools"],"presentation":{"display_name":"Shared"}
}]}
});
@@ -59,7 +58,8 @@ async fn adapted_catalog_freezes_adapter_accounts_and_preserves_the_active_gener
active.models()[0].accounts.iter().next().unwrap().as_str(),
"acct_b"
);
assert!(active.model_aliases().is_empty());
assert_eq!(active.model_aliases().len(), 1);
assert_eq!(active.model_aliases()[0].id.as_str(), "native-alias");
for scenario in ["accounts", "model", "binding", "key", "group"] {
let mut invalid = snapshot.clone();
invalid.config_revision = Revision::new(2).unwrap();
@@ -168,13 +168,12 @@ async fn adapted_catalog_freezes_adapter_accounts_and_preserves_the_active_gener
#[tokio::test]
async fn adapted_catalog_requires_its_registered_adapter() {
let mut catalog = crate::support::contribution(
let 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();
@@ -214,7 +213,7 @@ async fn initial_registration_http_uses_the_prepared_settings_without_a_publicat
.await;
store.snapshot.lock().unwrap().instances[0].configuration = serde_json::json!({
"registration_http": {"params": {"method":"POST", "url":server.uri(), "headers":[]}, "body":"x".repeat(64)},
"model_catalog":{"models":[{"id":"fixture-alias","provider":"openai","model":"fixture-upstream"}]}
"model_catalog":{"aliases":[{"id":"fixture-alias","provider":"openai","model":"fixture-upstream"}],"models":[]}
});
let prepared = ExtensionPreparationPort::prepare_with_request_body_limit(
&runtime,
@@ -242,7 +241,7 @@ async fn model_catalog_is_frozen_without_entering_the_request_chain() {
]))
.await;
store.snapshot.lock().unwrap().instances[0].configuration = serde_json::json!({
"model_catalog": {"models": [{"id": "my-model", "provider": "openai", "model": "upstream-model"}]}
"model_catalog": {"aliases": [{"id": "my-model", "provider": "openai", "model": "upstream-model"}], "models": []}
});
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
@@ -297,7 +296,7 @@ async fn model_catalog_is_frozen_without_entering_the_request_chain() {
#[tokio::test]
async fn model_catalog_prepares_plugin_aliases_without_semantic_validation() {
for models in [
for aliases in [
serde_json::json!([{"id":"alias","provider":"custom","model":"target"}]),
serde_json::json!([{"id":"alias","provider":"openai","model":"alias"}]),
serde_json::json!([
@@ -318,8 +317,8 @@ async fn model_catalog_prepares_plugin_aliases_without_semantic_validation() {
.await;
let mut snapshot = store.snapshot.lock().unwrap().clone();
snapshot.instances[0].configuration =
serde_json::json!({"model_catalog":{"models":models}});
let expected = models.as_array().unwrap().len();
serde_json::json!({"model_catalog":{"aliases":aliases,"models":[]}});
let expected = aliases.as_array().unwrap().len();
let prepared = PluginPreparation::prepare(&runtime, snapshot)
.await
.unwrap();
@@ -340,7 +339,7 @@ async fn model_catalog_prepares_plugin_aliases_without_semantic_validation() {
.await;
let mut snapshot = store.snapshot.lock().unwrap().clone();
snapshot.instances[0].configuration = serde_json::json!({
"model_catalog":{"models":[{"id":"alias","provider":"openai","model":"target"}]}
"model_catalog":{"aliases":[{"id":"alias","provider":"openai","model":"target"}],"models":[]}
});
let mut other = snapshot.instances[0].clone();
other.id = "instance-two".into();
@@ -81,13 +81,20 @@ async fn retired_contracts_remain_loadable_with_compatibility_warnings() {
vec!["openai".into()],
vec!["openai".into()],
);
let mut catalog = crate::support::contribution(
Capability::ModelCatalog,
vec![Stage::Registration],
vec![],
vec![],
);
if legacy {
middleware.1.version = 3;
upstream.1.version = 1;
catalog.1.version = 1;
}
let archive = crate::support::package_with_contributions(
b"not-an-executable",
Contributions::from([middleware, upstream]),
Contributions::from([middleware, upstream, catalog]),
);
let inspector =
PackageInspector::new(PackageLimits::default(), semver::Version::new(1, 0, 0));
@@ -102,6 +109,7 @@ async fn retired_contracts_remain_loadable_with_compatibility_warnings() {
let warning = warning.expect("retired contracts must remain visible");
assert!(warning.contains("middleware v3"));
assert!(warning.contains("upstream_adapter v1"));
assert!(warning.contains("model_catalog v1"));
} else {
assert!(warning.is_none());
}
@@ -61,7 +61,7 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。宿主开放以下 11 类扩
| `middleware` | `PluginBuilder::middleware` / `middleware.handle` |
| `upstream_adapter` | `methods::UPSTREAM_ADAPTER_REGISTER`、`methods::UPSTREAM_ADAPTER_EXECUTE`,见[受管上游](upstream-adapters.md) |
| `model_router` | `methods::ROUTE_MODEL` |
| `model_catalog` | `PluginBuilder::model_catalog` / `methods::MODEL_CATALOG_REGISTER` |
| `model_catalog` | `PluginBuilder::model_catalog` / `methods::MODEL_CATALOG_REGISTER_V2` |
| `retry_policy` | `methods::RETRY_DECISION` |
| `scheduler` | `methods::SCHEDULE_ACCOUNT` |
| `observer` | `methods::OBSERVE` / `observer.observe`,按事件类型订阅 |
@@ -530,26 +530,29 @@ restore_and_validate_response(response).await
### 模型目录
`model_catalog` v1 在 registration 阶段一次提交 `ModelCatalogRegistration { models: Vec<ModelAlias> }`,不需要请求级 binding。
每条 `ModelAlias { id, provider, model }` 定义公开 ID、Provider 及直接上游目标,条目正确性由插件负责。
每实例最多 256 条,整个二进制 JSON 最多 64 KiB;条目重复、目标不存在或 Provider 未注册不阻止宿主快照发布。
`model_catalog` 仅支持 v2,在 registration 阶段通过 `model_catalog.register` 一次提交
`ModelCatalogRegistrationV2 { aliases, models }`,不需要请求级 binding。
`aliases` 的每条 `ModelAlias { id, provider, model }` 定义公开 ID、Provider 及直接上游目标,条目正确性由插件负责。
别名条目重复、目标不存在或 Provider 未注册不阻止宿主快照发布。
请求按发布顺序取首个匹配条目,插件别名优先于静态映射,目标直接用于上游选路,不递归展开别名或目标上的静态映射
```rust,ignore
use gateway_plugin_sdk::call::catalog::{ModelAlias, ModelCatalogRegistration};
use gateway_plugin_sdk::call::catalog::{ModelAlias, ModelCatalogRegistrationV2};
let plugin = PluginBuilder::from_json(include_bytes!("../plugin.json"))?
.model_catalog(ModelCatalogRegistration { models: vec![ModelAlias {
id: "team-default".into(), provider: "openai".into(), model: "gpt-target".into(),
}] })?
.model_catalog(ModelCatalogRegistrationV2 {
aliases: vec![ModelAlias {
id: "team-default".into(), provider: "openai".into(), model: "gpt-target".into(),
}],
models: vec![],
})?
.build()?;
```
有效目录、路由和目标能力随同一个不可变快照发布,`/v1/models`、详情、原生目录和 `host.models.list` 共用该事实及现有 Key/账号模型范围。
目标存在时,别名复用目标的可公开对象;禁用后新请求不再使用该别名,在途请求持有旧代次。
v1 是直接别名,动态选择可组合 `model_router`;实际路由仍须通过宿主的权限和能力校验,不提供多目标虚拟模型注册合同
别名仅表达直接映射,动态选择可组合 `model_router`;实际路由仍须通过宿主的权限和能力校验,不提供多目标虚拟模型注册合同
`model_catalog` v2 使用同一 RPC 方法,返回 `ModelCatalogRegistrationV2 { aliases, models }`。
`aliases` 沿用 v1 直接别名语义;`models` 的每条 `AdaptedModel { id, adapter_id, features, max_output_tokens, presentation }`
`models` 的每条 `AdaptedModel { id, adapter_id, features, max_output_tokens, presentation }`
声明一个受管适配器提供的公开模型,不要求内置 Provider 的原生目录存在同名条目
- `adapter_id` 必须指向同实例已绑定的 `upstream_adapter`,Provider 与非空账号集合取自该适配器声明
@@ -557,10 +560,11 @@ v1 是直接别名,动态选择可组合 `model_router`;实际路由仍须
- `features` 使用 `RequestFeature`,仅为生成操作声明工具、图片输入、推理或 JSON Schema 支持;没有声明的能力不能借用同名原生模型的支持事实
- `presentation` 是独立通用画像,字段见 `call::catalog::ModelPresentation`;省略的布尔能力为 false,未声明推理档位不补猜
- 同名条目可关联不同账号,但 Provider、能力及画像须一致,账号集合不能重叠,也不能与插件直接别名同名,以免展示与实际选择产生歧义
- v2 仍受每实例合计 256 条、64 KiB 信封限制;目录与适配器随同一代次发布,账号最终资格取当前 Key、分组及账号模型政策的交集
- 别名与适配模型每实例合计最多 256 条,注册响应载荷最多 64 KiB;目录与适配器随同一代次发布,账号最终资格取当前 Key、分组及账号模型政策的交集
使用 `PluginBuilder::on(methods::MODEL_CATALOG_REGISTER_V2, handler)` 返回 `TypedReply<ModelCatalogRegistrationV2>`,
并在清单声明 `model_catalog.version = 2`。原有 `.model_catalog(...)` 作者入口继续使用 v1 合同
清单须显式声明 `model_catalog.version = 2`,可使用上述 `.model_catalog(...)` 固定目录,
或通过 `PluginBuilder::on(methods::MODEL_CATALOG_REGISTER_V2, handler)` 返回 `TypedReply<ModelCatalogRegistrationV2>`。
使用 v1 的插件需将原 `models` 别名列表移入 `aliases`,补充 `models: []`,更新能力声明并重新构建、打包;仅修改清单版本号不能迁移旧结构
原生 Codex 目录成功返回后,宿主保留其中的完整对象,只追加可见且尚未出现的适配模型画像;原生读取错误仍向调用方返回错误。
适配模型的 wire 模型名、协议编解码及摘要由插件负责,宿主只使用公开 ID、能力和账号范围参与现有选号,不生成请求正文或转换历史
@@ -57,7 +57,7 @@ RPC v7 的帧布局、编码与会话生命周期见[通信边界](../README.md#
| `scheduler` | `3` |
| `upstream_adapter` | `5` |
| `frontend_authentication`、`model_router`、`retry_policy`、`observer`、`management` | `2` |
| `model_catalog` | `1`、`2` |
| `model_catalog` | `2` |
| `command_line`、`maintenance` | `1` |
例如,仅声明管理页面、命令行和模型请求中间件:
@@ -2,12 +2,6 @@
use serde::{Deserialize, Serialize};
/// 一次实例准备产生的完整目录,随实例配置原子发布
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelCatalogRegistration {
pub models: Vec<ModelAlias>,
}
/// 指向内置 Provider 上游模型的直接别名,不创建新的 Provider 执行器
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelAlias {
@@ -29,12 +29,12 @@ impl Capability {
Self::Middleware => &[6],
Self::FrontendAuthentication
| Self::ModelRouter
| Self::ModelCatalog
| Self::RetryPolicy
| Self::Observer
| Self::Management => &[2],
Self::Scheduler => &[3],
Self::UpstreamAdapter => &[5],
Self::ModelCatalog => &[1, 2],
Self::CommandLine | Self::Maintenance => &[1],
}
}
@@ -34,14 +34,6 @@ use super::{
},
};
pub const MODEL_CATALOG_REGISTER: Method<Empty, catalog::ModelCatalogRegistration> = Method::new(
"model_catalog.register",
&[C::ModelCatalog],
&[S::Registration],
decode_metadata_without_payload,
encode_payload,
);
pub const MODEL_CATALOG_REGISTER_V2: Method<Empty, catalog::ModelCatalogRegistrationV2> =
Method::new(
"model_catalog.register",
@@ -143,16 +143,16 @@ impl PluginBuilder {
Ok(self)
}
/// 冻结模型别名目录;目标校验与发布由宿主负责
/// 冻结直接别名与适配模型目录,随实例配置原子发布
///
/// # Errors
///
/// 未声明模型目录能力或重复注册时失败
pub fn model_catalog(
self,
registration: crate::call::catalog::ModelCatalogRegistration,
registration: crate::call::catalog::ModelCatalogRegistrationV2,
) -> Result<Self, AuthorError> {
self.on(methods::MODEL_CATALOG_REGISTER, move |_| {
self.on(methods::MODEL_CATALOG_REGISTER_V2, move |_| {
let registration = registration.clone();
async move { Ok(TypedReply::new(registration)) }
})
@@ -25,7 +25,7 @@ pub(super) fn validate(
required(methods::UPSTREAM_ADAPTER_EXECUTE.name)?;
}
C::ModelRouter => required(methods::ROUTE_MODEL.name)?,
C::ModelCatalog => required(methods::MODEL_CATALOG_REGISTER.name)?,
C::ModelCatalog => required(methods::MODEL_CATALOG_REGISTER_V2.name)?,
C::RetryPolicy => required(methods::RETRY_DECISION.name)?,
C::Scheduler => required(methods::SCHEDULE_ACCOUNT.name)?,
C::Observer => required(methods::OBSERVE.name)?,
@@ -212,21 +212,28 @@ async fn composed_plugin_registers_and_dispatches_multiple_typed_entries() {
#[tokio::test]
async fn catalog_and_retry_entries_use_typed_registration_and_stage_validation() {
use gateway_plugin_sdk::call::{
catalog::{ModelAlias, ModelCatalogRegistration},
catalog::{AdaptedModel, ModelAlias, ModelCatalogRegistrationV2, ModelPresentation},
policy::{RetryAction, RetryDecision, RetryDecisionRequest},
};
let source = author_manifest(
json!({"model_catalog":{},"retry_policy":{"version":2}}),
json!({"model_catalog":{"version":2},"retry_policy":{"version":2}}),
json!([]),
);
let manifest = gateway_plugin_sdk::Manifest::from_author_slice(&source).unwrap();
let contributions = manifest.contributes.clone();
let catalog = ModelCatalogRegistration {
models: vec![ModelAlias {
let catalog = ModelCatalogRegistrationV2 {
aliases: vec![ModelAlias {
id: "public-model".into(),
provider: "openai".into(),
model: "upstream-model".into(),
}],
models: vec![AdaptedModel {
id: "adapted-model".into(),
adapter_id: "example".into(),
features: vec![],
max_output_tokens: None,
presentation: ModelPresentation::default(),
}],
};
let plugin = PluginBuilder::from_manifest(manifest)
.unwrap()
@@ -257,7 +264,7 @@ async fn catalog_and_retry_entries_use_typed_registration_and_stage_validation()
let (result, payload) = unwrap_result(receive(&mut host).await, 1);
assert_eq!(result, json!({}));
assert_eq!(
serde_json::from_slice::<ModelCatalogRegistration>(&payload).unwrap(),
serde_json::from_slice::<ModelCatalogRegistrationV2>(&payload).unwrap(),
catalog
);
let request = serde_json::to_value(RetryDecisionRequest {
@@ -137,6 +137,7 @@ fn capability_contract_versions_are_checked_independently_of_rpc_version() {
(Capability::Management, Stage::Management, 2, false),
(Capability::UpstreamAdapter, Stage::Upstream, 5, true),
(Capability::Scheduler, Stage::Scheduling, 3, false),
(Capability::ModelCatalog, Stage::Registration, 2, false),
] {
for mut manifest in [source_manifest(), packaged_manifest()] {
let formats = if needs_formats {
+2 -2
View File
@@ -131,9 +131,9 @@ flowchart LR
本节说明模型扩展的执行约束;配置与发布见[制品、配置与发布](#制品配置与发布),调用链见[请求组合与设置](#请求组合与设置),资源回收见[调用资源与状态](#调用资源与状态)
- **模型目录与路由共用事实**:`model_catalog` 的目录贡献保存在发布代次引用中,v1 直接别名由插件负责条目正确性,条目冲突或目标不存在不阻止宿主快照发布。
- **模型目录与路由共用事实**:`model_catalog` 的目录贡献保存在发布代次引用中,直接别名由插件负责条目正确性,条目冲突或目标不存在不阻止宿主快照发布。
请求按顺序取首个匹配别名并直接使用其目标,不递归解析插件别名;Provider、账号权限与目标能力沿原选路流程处理。
v2 适配模型从同实例受管适配器继承静态账号范围,Core 按冻结权限与请求能力收窄候选账号,不改变调度评分、亲和或租约。
适配模型从同实例受管适配器继承静态账号范围,Core 按冻结权限与请求能力收窄候选账号,不改变调度评分、亲和或租约。
公开列表、详情、原生目录和受管模型查询复用相同目录贡献与账号访问范围,不另维护插件专用目录;合同见 SDK [模型目录](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型目录)
- **重试由 Core 裁决**:`retry_policy` 只能停止或继续 Core 已允许的恢复路径;绑定按确定顺序委托,故障回退宿主。
发送、交付、续接、外部副作用、预算和取消限制不进入插件控制面,策略返回后继续复核