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 <noreply@openai.com>

* fix(plugin): allow additive fields in data responses

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:
huhengbo
2026-09-28 14:25:01 +08:00
committed by GitHub
co-authored by Codex Zyy
parent bb859455f9
commit dde4a91974
7 changed files with 128 additions and 12 deletions
@@ -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()
@@ -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<String>);
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<dyn PluginAccountAccess> = 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,
@@ -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` 表示没有可用观测时间,
不保证当前缓存新鲜,不提供历史快照或多个查询之间的原子一致性。
@@ -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<String>,
pub group_ids: Vec<String>,
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<AccountFacts>,
@@ -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<u64>,
@@ -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::<AccountFactsPage>(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::<AccountFactsPage>(page).is_err(),
"{field}"
);
}
}
#[test]
fn facts_queries_still_reject_unknown_fields() {
assert!(serde_json::from_value::<AccountFactsQuery>(json!({"limit":1,"limti":2})).is_err());
assert!(
serde_json::from_value::<ClientKeyFactsQuery>(
json!({"client_key_id":"key_1","instance_id":"forged"})
)
.is_err()
);
assert!(
serde_json::from_value::<QuotaFactsQuery>(json!({"account_id":"acct_1","refresh":true}))
.is_err()
);
}
@@ -1,3 +1,4 @@
mod data;
mod frontend_authentication;
mod host;
mod key_budgets;
@@ -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