mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user