From dde4a919748e0c9ace48d3c60610eb0738f5c073 Mon Sep 17 00:00:00 2001 From: huhengbo Date: Mon, 28 Sep 2026 14:25:01 +0800 Subject: [PATCH] feat(plugin): include account name and email in data facts (#313) * feat(plugin): include account name and email in data facts Co-authored-by: Codex * fix(plugin): allow additive fields in data responses Co-authored-by: Codex --------- Co-authored-by: Codex Co-authored-by: Zyy --- .../runtime/src/callback/data.rs | 2 + .../runtime/tests/generation/data.rs | 24 ++++- .../gateway-plugin/sdk/docs/capabilities.md | 7 +- .../gateway-plugin/sdk/src/call/data.rs | 8 +- .../gateway-plugin/sdk/tests/call/data.rs | 92 +++++++++++++++++++ .../gateway-plugin/sdk/tests/call/mod.rs | 1 + .../sdk/tests/client/session.rs | 6 +- 7 files changed, 128 insertions(+), 12 deletions(-) create mode 100644 backend/crates/gateway-plugin/sdk/tests/call/data.rs diff --git a/backend/crates/gateway-plugin/runtime/src/callback/data.rs b/backend/crates/gateway-plugin/runtime/src/callback/data.rs index 27efbc9a4..813ff3916 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/data.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/data.rs @@ -111,6 +111,8 @@ impl PluginData { .map(|account| data::AccountFacts { account_id: account.id, provider_id: account.provider_kind.as_str().to_owned(), + name: account.name, + email: account.email, group_ids: account .groups .into_iter() diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/data.rs b/backend/crates/gateway-plugin/runtime/tests/generation/data.rs index 2a34b05ec..d2a27bbc2 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/data.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/data.rs @@ -22,7 +22,7 @@ use gateway_plugin_sdk::{Capability, Contributions, Permission, Stage}; use serde_json::{Value, json}; #[derive(Default)] -struct Facts(AtomicUsize, AtomicUsize); +struct Facts(AtomicUsize, AtomicUsize, Option); fn account() -> AccountRecord { let at = "2026-01-01T00:00:00Z".parse().unwrap(); @@ -90,8 +90,10 @@ impl PluginAccountAccess for Facts { assert_eq!(query.limit.get(), 1); assert_eq!(query.provider_kind.unwrap().as_str(), "openai"); assert_eq!(query.cursor.unwrap().as_str(), "acct_before"); + let mut account = account(); + account.email.clone_from(&self.2); Ok(PluginAccountPage { - accounts: vec![account()], + accounts: vec![account], next_cursor: Some(ProviderAccountId::new("acct_facts").unwrap()), }) } @@ -141,7 +143,12 @@ impl PluginAccountAccess for Facts { #[tokio::test] async fn management_facts_are_minimal_bounded_and_separately_authorized() { - for permission in [None, Some(Permission::Accounts), Some(Permission::Data)] { + for (permission, email) in [ + (None, Some("private@example.invalid")), + (Some(Permission::Accounts), Some("private@example.invalid")), + (Some(Permission::Data), Some("private@example.invalid")), + (Some(Permission::Data), None), + ] { let (cache, store, runtime) = super::setup_with_permissions( Contributions::from([crate::support::contribution( Capability::Management, @@ -153,7 +160,11 @@ async fn management_facts_are_minimal_bounded_and_separately_authorized() { permission.into_iter().collect(), ) .await; - let facts = Arc::new(Facts::default()); + let facts = Arc::new(Facts( + AtomicUsize::new(0), + AtomicUsize::new(0), + email.map(str::to_owned), + )); let port: Arc = facts.clone(); runtime.bind_account_ports(&port).unwrap(); { @@ -205,9 +216,14 @@ async fn management_facts_are_minimal_bounded_and_separately_authorized() { result[0], json!({"schema_version":1,"accounts":[{ "account_id":"acct_facts","provider_id":"openai","group_ids":["grp_11111111111111111111111111111111"], + "name":"private name","email":email, "enabled":true,"updated_at_ms":1767225600000i64 }],"next_cursor":"acct_facts"}) ); + let page: gateway_plugin_sdk::call::data::AccountFactsPage = + serde_json::from_value(result[0].clone()).unwrap(); + assert_eq!(page.accounts[0].name, "private name"); + assert_eq!(page.accounts[0].email.as_deref(), email); assert_eq!( result[1], json!({"schema_version":1,"account_id":"acct_facts","observed_at_ms":null, diff --git a/backend/crates/gateway-plugin/sdk/docs/capabilities.md b/backend/crates/gateway-plugin/sdk/docs/capabilities.md index f9f10a59f..c8e349588 100644 --- a/backend/crates/gateway-plugin/sdk/docs/capabilities.md +++ b/backend/crates/gateway-plugin/sdk/docs/capabilities.md @@ -94,11 +94,14 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能 | `call.host.key_facts(query)` | `host.data.keys.get` | `ClientKeyFactsQuery { client_key_id }`:读取当前 Key 的启用状态及显式分组 ID,不返回密钥 | 类型在 `call::data`。控制参数为 `{}`,查询和结果使用二进制 JSON;结果固定 `schema_version=1`。 +SDK 解析响应时忽略未知字段,包含账号分页、账号及额度窗口;已知字段按声明校验类型与必填性,查询拒绝未知字段。 +插件须通过 `engines.codex-proxy-rs` 限定支持所用接口和字段的宿主版本。 -`key_facts` 每次读取当前管理数据,返回 `client_key_id`、`enabled`、`group_ids`;不存在的 Key 沿用事实接口的 `rejected` 错误。它需要 `data` 权限,`keys`、`key_budgets` 和 `quota_observations` 权限不能替代。原有 Key 目录响应保持不变。旧宿主不支持新增方法,插件应将包含该方法的宿主版本声明为最低兼容版本。 +`key_facts` 每次读取当前管理数据,返回 `client_key_id`、`enabled`、`group_ids`;不存在的 Key 沿用事实接口的 `rejected` 错误。它需要 `data` 权限,`keys`、`key_budgets` 和 `quota_observations` 权限不能替代。 `group_ids` 表示显式绑定,包含停用分组,不是最终可路由账号集合;空绑定也不代表单账号范围。通过 `account_facts` 关联账号时需完整遍历分页,结果包含停用账号。跨查询关联及同步策略由插件负责,这些调用不构成跨查询事务,管理员修改绑定后应重新检查。 -账号仅返回 `account_id`、`provider_id`、`group_ids`、`enabled` 和 `updated_at_ms`,不附带姓名、邮箱、令牌或代理信息。 +账号仅返回 `account_id`、`provider_id`、`name`、`email`、`group_ids`、`enabled` 和 `updated_at_ms`,不附带令牌或代理信息。 +`name` 和 `email` 来自宿主已保存的账号资料;没有邮箱时 `email=null`,读取不会请求上游个人信息。 额度仅返回观测时间与窗口的 `key`、`window_seconds`、`used_percent`、`reset_at_ms`。 时间均为 UTC Unix 毫秒,比例为百分数;未知值保留 `null`,不能解释为 0。`observed_at_ms=null` 表示没有可用观测时间, 不保证当前缓存新鲜,不提供历史快照或多个查询之间的原子一致性。 diff --git a/backend/crates/gateway-plugin/sdk/src/call/data.rs b/backend/crates/gateway-plugin/sdk/src/call/data.rs index 4967de1a6..5ed5c169e 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/data.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/data.rs @@ -1,4 +1,5 @@ //! 基础事实投影;主动刷新由独立的 quota_observations 访问域授权。 +//! 响应忽略未知字段,以兼容宿主新增事实;查询仍严格校验字段。 use serde::{Deserialize, Serialize}; @@ -15,7 +16,6 @@ pub struct ClientKeyFactsQuery { /// 当前显式分组绑定;空列表不是单账号范围,不包含密钥或凭据。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct ClientKeyFacts { pub schema_version: u32, pub client_key_id: String, @@ -32,17 +32,17 @@ pub struct AccountFactsQuery { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct AccountFacts { pub account_id: String, pub provider_id: String, + pub name: String, + pub email: Option, pub group_ids: Vec, pub enabled: bool, pub updated_at_ms: i64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct AccountFactsPage { pub schema_version: u32, pub accounts: Vec, @@ -57,7 +57,6 @@ pub struct QuotaFactsQuery { /// Provider 已有快照的必要投影;空观测时间表示没有可用样本,不代表额度为零。 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct QuotaFacts { pub schema_version: u32, pub account_id: String, @@ -66,7 +65,6 @@ pub struct QuotaFacts { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct QuotaWindowFacts { pub key: String, pub window_seconds: Option, diff --git a/backend/crates/gateway-plugin/sdk/tests/call/data.rs b/backend/crates/gateway-plugin/sdk/tests/call/data.rs new file mode 100644 index 000000000..77f051c45 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/data.rs @@ -0,0 +1,92 @@ +use gateway_plugin_sdk::call::data::{ + AccountFactsPage, AccountFactsQuery, ClientKeyFacts, ClientKeyFactsQuery, QuotaFacts, + QuotaFactsQuery, +}; +use serde_json::{Value, json}; + +fn account_page() -> Value { + json!({ + "schema_version":1, + "accounts":[{ + "account_id":"acct_1", "provider_id":"openai", "name":"测试账号", + "email":null, "group_ids":[], "enabled":true, "updated_at_ms":0 + }], + "next_cursor":null + }) +} + +#[test] +fn account_facts_accept_additive_page_and_account_fields() { + let original = account_page(); + let mut extended = original.clone(); + extended["future_page_field"] = json!({"value":1}); + extended["accounts"][0]["future_account_field"] = json!(["value"]); + let page: AccountFactsPage = serde_json::from_value(extended).unwrap(); + assert_eq!(serde_json::to_value(page).unwrap(), original); +} + +#[test] +fn key_facts_accept_additive_fields() { + let original = json!({ + "schema_version":1, "client_key_id":"key_1", "enabled":false, + "group_ids":["grp_1"] + }); + let mut extended = original.clone(); + extended["future_key_field"] = json!({"value":1}); + let facts: ClientKeyFacts = serde_json::from_value(extended).unwrap(); + assert_eq!(serde_json::to_value(facts).unwrap(), original); +} + +#[test] +fn quota_facts_accept_additive_result_and_window_fields() { + let original = json!({ + "schema_version":1, "account_id":"acct_1", "observed_at_ms":null, + "windows":[{ + "key":"weekly", "window_seconds":604800, + "used_percent":null, "reset_at_ms":null + }] + }); + let mut extended = original.clone(); + extended["future_quota_field"] = json!({"value":1}); + extended["windows"][0]["future_window_field"] = json!(["value"]); + let facts: QuotaFacts = serde_json::from_value(extended).unwrap(); + assert_eq!(serde_json::to_value(facts).unwrap(), original); +} + +#[test] +fn account_facts_still_require_known_fields_and_valid_types() { + let mut missing_name = account_page(); + missing_name["accounts"][0] + .as_object_mut() + .unwrap() + .remove("name"); + assert!(serde_json::from_value::(missing_name).is_err()); + + for (field, invalid) in [ + ("name", json!(42)), + ("email", json!(false)), + ("enabled", json!("true")), + ] { + let mut page = account_page(); + page["accounts"][0][field] = invalid; + assert!( + serde_json::from_value::(page).is_err(), + "{field}" + ); + } +} + +#[test] +fn facts_queries_still_reject_unknown_fields() { + assert!(serde_json::from_value::(json!({"limit":1,"limti":2})).is_err()); + assert!( + serde_json::from_value::( + json!({"client_key_id":"key_1","instance_id":"forged"}) + ) + .is_err() + ); + assert!( + serde_json::from_value::(json!({"account_id":"acct_1","refresh":true})) + .is_err() + ); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/mod.rs b/backend/crates/gateway-plugin/sdk/tests/call/mod.rs index e3b46a19f..fcdc6f2f4 100644 --- a/backend/crates/gateway-plugin/sdk/tests/call/mod.rs +++ b/backend/crates/gateway-plugin/sdk/tests/call/mod.rs @@ -1,3 +1,4 @@ +mod data; mod frontend_authentication; mod host; mod key_budgets; diff --git a/backend/crates/gateway-plugin/sdk/tests/client/session.rs b/backend/crates/gateway-plugin/sdk/tests/client/session.rs index 95ddf9810..e493bb752 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/session.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/session.rs @@ -1645,6 +1645,10 @@ async fn typed_key_and_quota_calls_keep_payloads_and_do_not_retry_failures() { }; assert_eq!(error.code, ErrorCode::Conflict); } else { + let mut response_payload = response.clone(); + if matches!(entry, "key_facts" | "refresh_quota") { + response_payload["future_fact"] = json!({"value":1}); + } write_frame( &mut host.writer, &Frame { @@ -1652,7 +1656,7 @@ async fn typed_key_and_quota_calls_keep_payloads_and_do_not_retry_failures() { id, result: json!({}), }, - payload: serde_json::to_vec(&response).unwrap(), + payload: serde_json::to_vec(&response_payload).unwrap(), }, ) .await