Compare commits

...
19 Commits
Author SHA1 Message Date
ZyyandCodex 641f43b303 chore(release): v3.37.2
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 03:41:24 +08:00
ZyyandCodex 6ce723ce93 fix(accounts): show account names in delete confirmation
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 03:41:16 +08:00
ZyyandCodex 2ff43c73da fix(accounts): bound discovery refreshes and align import settings
Share a four-request limit across CPR discovery, manual refreshes, and
periodic refreshes, and avoid loading runtime settings for initial reads.

Reuse account name and notes fields for import and editing, apply custom
names only to single-account imports, and match API usage bars to quota
progress styling.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 03:35:29 +08:00
ZyyandCodex cb79f4d5a5 fix(accounts): start peer discovery after credential imports
Reuse quota routing for initial peer observation and preserve discovery state across ordinary account edits. Hide pending subscription badges and remove redundant provider quota labels without adding frontend polling.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 03:10:18 +08:00
ZyyandCodex 1e57805598 chore(release): v3.37.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 02:20:01 +08:00
ZyyandCodex aca75490cb fix(accounts): preserve migrated identities and restore cumulative usage
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 02:19:53 +08:00
ZyyandCodex 6a95869a72 docs: fix README links and add UI playground
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 01:59:55 +08:00
ZyyandCodex dfc29d4709 chore(release): v3.37.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 01:49:30 +08:00
ZyyandCodex 3cbebb5f9b feat(frontend): adopt UI v0.8.0 and refine form feedback
Unify theme namespaces and plugin theme delivery, improve validation and empty states, and pin UI v0.8.0 plus plugin workbench v0.9.0.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 01:40:09 +08:00
ZyyandCodex fbdfe22757 feat(settings): update default concurrency policy
Co-authored-by: Codex <noreply@openai.com>
2026-10-31 21:29:50 +08:00
7cf99769b1 feat(accounts): support editing optional account names (#568)
Allow local account names to be changed or cleared without replacing credentials or account settings. Show the name with notes and fall back to email when the name is empty.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-31 21:19:07 +08:00
ZyyandCodex 839054cdaf docs: refine GitHub reply tone
Co-authored-by: Codex <noreply@openai.com>
2026-10-31 21:09:49 +08:00
ZyyandCodex bcf0582814 refactor(peer): reuse account quota storage for CPR Link
Store peer snapshots in existing account quota fields and remove dedicated node and synchronization tables. Keep CPR protocol handling in Admin and native quota handling in Providers, sharing persistence and the quota refresh endpoint.

Use runtime refresh coordination and preserve credential, connection, and observation-time guards. Update the unreleased migration, API contracts, documentation, and regression coverage.

Validated with 354 selected tests, strict workspace Clippy, Rustfmt, frontend lint and build, and migration checksum verification. Browser interaction remains unverified.

Co-authored-by: Codex <noreply@openai.com>
2026-10-31 20:43:15 +08:00
ZyyandCodex a5d3d9f12d fix(peer): recover quota from explicit upstream budget state
Export authoritative budget access flags and keep monetary fields display-only
for peer consumers. Use the official usage_limit_reached error type for budget
exhaustion while preserving ordinary rate-limit cooldowns.

Apply fresh quota facts atomically with peer snapshots through the shared quota
writer, retaining connection, claim, and observation-time guards. Reuse the
existing sync worker and document the recovery contract.

Validated with 234 selected tests, strict workspace Clippy, and Rustfmt.

Co-authored-by: Codex <noreply@openai.com>
2026-10-31 15:58:02 +08:00
58a1f7541e feat(peer): add CPR Link discovery and scoped quota sharing (#566)
* feat(peer): add automatic discovery and scalable quota sharing

Export authorized direct-account pools and budgets through the CPR peer
protocol, with durable upstream discovery and immutable scheduling views.

Show shared-pool node quotas through common quota popovers and lazy account
details. Bound snapshot reads and export concurrency, reduce Redis and SQL
work, and keep only the latest observation for each upstream connection.

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

* refactor(peer): centralize upstream discovery and quota projections

Move peer synchronization into Admin, HTTP transport into Host, and expose
neutral Core scheduling signals through explicitly wired provider capabilities.

Preserve provider-visible model catalogs and independent quota disclosure,
with stale windows following the existing OAuth quota layout.

Cover cross-provider discovery, lifecycle fencing, and database leases, and
update the architecture and usage documentation.

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

* fix(ui): center usage card loading and empty states

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

* fix(accounts): unify peer subscription badges across account views

Share CPR identity rendering through AccountPlanBadge, project upstream metadata into dashboard and proxy account views, and align quota empty states and API badges.

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

* fix(peer): bound snapshot work and preserve quota windows

Keep summary reads off full snapshots, reject oversized model expansion early, and yield export work. Preserve daily and weekly unlimited budgets, hide empty token counters, and unify quota empty states.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-31 15:00:12 +08:00
ZyyandCodex 35e8b73075 fix(ui): center usage card loading and empty states
Co-authored-by: Codex <noreply@openai.com>
2026-10-31 13:03:30 +08:00
ZyyandCodex 73f0706f99 fix(deps): update follow-redirects to 1.16.2
Update the transitive Axios dependency to address GHSA-mrwp-f3q8-3g9p.

Co-authored-by: Codex <noreply@openai.com>
2026-10-31 12:11:14 +08:00
ZyyandCodex 76eecd771e fix(ui): correct account subscription and quota displays
Preserve authentication snapshots for diagnostic account badges and align
account note typography. Hide unavailable dashboard quotas consistently
and show available quota rates independently of local request counts.

Co-authored-by: Codex <noreply@openai.com>
2026-10-31 12:06:16 +08:00
Ke WangandCodex 7293d03d70 fix(models): separate reasoning summary capability from effort (#565)
Co-authored-by: Codex <noreply@openai.com>
2026-10-31 11:53:26 +08:00
473 changed files with 13870 additions and 3436 deletions
+3 -1
View File
@@ -44,7 +44,9 @@ Issue 可记录复现、排查和变更经过;涉及常规文档修改时执
草拟、发布或修改 Issue 回复时,按 [GitHub 回复口吻](../../../CONTRIBUTING.md#github-回复口吻)将核实结果写成面向提问者的交流答复,具体口吻规则集中维护在该节
发送或更新前从提问者视角通读:是否接住了他的目标和困难,建议是否便于尝试,补充材料的用途是否清楚,澄清或拒绝是否耐心解释了原因,能提供的替代办法是否说清。发现生硬时调整整句措辞与前后衔接,不能仅加缓和词;保留已核实的结论
按平常回帖的口吻写,语气自然、委婉,说清判断和原因,少用铺垫、转折和自我说明;暂不采纳或提出不同意见时说明具体原因
发送或更新前从提问者视角通读:判断和原因是否清楚,建议是否便于尝试,补充材料的用途是否明确。删去不影响理解的客套、重复解释和过程说明;发现生硬时调整整句,不能仅加缓和词,保留已核实的结论
## 发布与收尾
+1 -1
View File
@@ -26,7 +26,7 @@ AI 草拟、发布或修改 Issue、PR 讨论及审查意见的回复时,以
- 围绕问题、事实和结论展开,能自然省略主语时省略;需要说明个人判断或行动归属时用“我”,避免反复以“我”起句或强调自己,不用“我们”“本助手”代称自己
- 普通回复直接回应问题,不加问候语或称呼;向对方提问时称呼“佬”,同一条回复不重复称呼
- 先接住对方想完成的事和遇到的困难,再给出核实结果与建议;回复让对方感到问题被理解、有下一步可做,避免一上来归类、纠正或划定责任
- 建议用商量的口吻,说明尝试某个操作能帮助判断什么,给对方留出选择空间;“可以试试”“方便的话”等表达随语境使用,整句仍需自然,不靠堆叠缓和词修饰命令
- 给出操作建议时说明它能帮助判断什么,措辞自然、委婉,不靠堆叠缓和词修饰命令
- 回应已有能力、暂不支持或未采纳的建议时,结合场景说明可用入口或具体原因,有可行替代办法时一并说明,再交代必要边界;避免只回复“已有功能”“重复问题”“不在范围内”等结论
- 需要补充材料或配合操作时,先利用已有信息,只询问仍缺少的部分,说明用途和最小范围;例如“佬,方便再观察几次吗?如果还出现,可以附一下对应请求的诊断包,便于对照终态用量”
- 澄清误解或提出不同意见时,从观察和依据说起,照顾对方的使用环境与理解路径,避免责备、反问或居高临下的判断;已确认的事实保持明确,尚未确认的原因保留余地
+3 -7
View File
@@ -118,18 +118,14 @@ curl http://127.0.0.1:8080/v1/models \
| 部署与使用 | [部署、备份与恢复](deploy/README.md) · [客户端接入与生图](deploy/README.md#客户端配置) |
| 接口集成 | [API 参考](docs/api.md) · [模型定价](docs/api.md#模型定价) |
| 使用插件 | [安装、配置与使用](docs/plugins.md) |
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) · [官方示例](modules/plugins/README.md) |
| 开发共用组件 | [组件库说明](modules/ui/README.md) · [组件参考](modules/ui/playground/components/index.md) |
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) · [官方示例](https://github.com/zyycn/codex-proxy-plugins/blob/main/README.md) |
| 开发共用组件 | [组件库说明](https://github.com/zyycn/codex-proxy-ui/blob/main/README.md) · [组件参考](https://github.com/zyycn/codex-proxy-ui/blob/main/playground/components/index.md) · [Playground](https://zyycn.github.io/codex-proxy-ui/) |
| 开发宿主 | [开发与源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
| 参与协作 | [贡献、审查与验证](CONTRIBUTING.md) |
## 社区
欢迎到 [Discussions 讨论区](https://github.com/zyycn/codex-proxy-rs/discussions)交流:
[使用问答](https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答)、
[想法讨论](https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论)、
[实验反馈](https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈)与
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)
问题反馈与功能建议请使用 [GitHub Issues](https://github.com/zyycn/codex-proxy-rs/issues),填写要求见 [贡献说明](CONTRIBUTING.md#提交-issue)
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台
+3
View File
@@ -1705,6 +1705,7 @@ dependencies = [
"cron",
"futures",
"gateway-core",
"gateway-protocol",
"heck",
"hex",
"hkdf",
@@ -1802,6 +1803,7 @@ dependencies = [
"futures",
"gateway-admin",
"gateway-core",
"gateway-protocol",
"hex",
"http",
"http-body-util",
@@ -3323,6 +3325,7 @@ dependencies = [
"futures",
"gateway-admin",
"gateway-core",
"gateway-host",
"gateway-protocol",
"getrandom 0.4.3",
"h2",
@@ -100,7 +100,12 @@ async fn execute(
plugin_runtime,
plugin_runtime.bind_snapshots(&core.snapshots())
);
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
let management = ManagementPorts::new(
&store,
providers.admin.clone(),
core.snapshot_control(),
providers.peer.service(),
);
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
let core = or_shutdown!(plugin_runtime, core.activate().await);
or_shutdown!(
@@ -132,6 +137,7 @@ async fn execute(
providers.admin.clone(),
core.snapshot_control(),
core.account_probe(),
Some(providers.peer.service()),
);
or_shutdown!(plugin_runtime, services.register_accounts(&accounts));
let usage_data = gateway_admin::initialize_usage_data(
@@ -42,6 +42,7 @@ impl ManagementPorts {
store: &gateway_store::StoreBundle,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
upstream_info: Arc<dyn gateway_admin::ports::peer::PeerUpstreamService>,
) -> Self {
let ports = store.admin_ports();
Self {
@@ -49,6 +50,7 @@ impl ManagementPorts {
providers.clone(),
ports.accounts(),
snapshot.clone(),
Some(upstream_info),
),
keys: gateway_admin::initialize_plugin_client_keys(
providers,
+8 -1
View File
@@ -35,7 +35,12 @@ pub async fn run() -> Result<(), BootstrapError> {
plugin_runtime,
plugin_runtime.bind_snapshots(&core.snapshots())
);
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
let management = ManagementPorts::new(
&store,
providers.admin.clone(),
core.snapshot_control(),
providers.peer.service(),
);
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
or_shutdown!(
@@ -77,6 +82,7 @@ pub async fn run() -> Result<(), BootstrapError> {
config.client,
store.admin_ports(),
gateway_admin::AdminRuntimePorts {
upstream_info: Some(providers.peer.service()),
timezone,
service_middleware: service_middleware(core.snapshots(), &plugin_runtime),
providers: providers.admin,
@@ -165,6 +171,7 @@ pub async fn run() -> Result<(), BootstrapError> {
plan.extend(providers.openai.take_worker_contributions());
plan.extend(providers.xai.take_worker_contributions());
plan.extend(admin.take_worker_contributions());
plan.extend(providers.peer.take_worker_contributions());
plan.push(or_shutdown!(
plugin_runtime,
plugin_runtime.maintenance_worker(core.snapshots())
+20 -3
View File
@@ -67,6 +67,7 @@ pub(super) struct Providers {
pub(super) xai: provider_xai::ProviderBundle,
pub(super) core: ProviderRegistry,
pub(super) admin: ProviderAdminRegistry,
pub(super) peer: gateway_admin::PeerUpstreamBundle,
}
impl Providers {
@@ -75,16 +76,32 @@ impl Providers {
environment: &Environment,
) -> Result<Self, BootstrapError> {
let ports = environment.store.provider_ports();
let openai =
provider_openai::initialize(config.with_timezone(environment.timezone), ports.clone())
.await?;
let upstream_reader = Arc::new(gateway_core::account::UpstreamInfoReader::default());
let openai = provider_openai::initialize(
config.with_timezone(environment.timezone),
ports.clone(),
upstream_reader.clone(),
)
.await?;
environment.host.report_startup_ready("OpenAI Provider");
let xai = provider_xai::initialize(ports).await?;
environment.host.report_startup_ready("xAI Provider");
let core = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
let admin = ProviderAdminRegistry::new([openai.admin_provider(), xai.admin_provider()])
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?;
let peer = gateway_admin::initialize_peer_upstream(
environment.store.provider_ports().accounts(),
&admin,
Arc::new(gateway_host::peer::HttpPeerTransport::with_client_builder(
provider_openai::build_reqwest_client_with_custom_ca,
)),
)
.map_err(|_| gateway_admin::model::AdminError::invalid("互联后台任务配置无效"))?;
upstream_reader
.bind(&peer.scheduling_source())
.map_err(|_| gateway_admin::model::AdminError::invalid("互联观测端口重复绑定"))?;
Ok(Self {
peer,
openai,
xai,
core,
@@ -321,6 +321,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
"client_distribution",
"config",
"outbound",
"peer",
"official_plugins",
"pricing",
"plugin_distribution",
@@ -358,11 +359,13 @@ const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
("codex-proxy-rs", "provider-openai"),
("codex-proxy-rs", "provider-xai"),
("gateway-admin", "gateway-core"),
("gateway-admin", "gateway-protocol"),
("gateway-api", "gateway-admin"),
("gateway-api", "gateway-core"),
("gateway-api", "gateway-protocol"),
("gateway-host", "gateway-admin"),
("gateway-host", "gateway-core"),
("gateway-host", "gateway-protocol"),
("gateway-plugin-runtime", "gateway-host"),
("gateway-plugin-runtime", "gateway-core"),
("gateway-plugin-runtime", "gateway-admin"),
+1
View File
@@ -19,6 +19,7 @@ bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["serde"] }
futures = "0.3.32"
gateway-core = { path = "../gateway-core" }
gateway-protocol = { path = "../gateway-protocol" }
heck = "0.5.0"
hex = "0.4.3"
hkdf = "0.13.0"
+40 -2
View File
@@ -26,6 +26,7 @@ mod use_case;
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
pub use use_case::key_usage::KeyUsageService;
pub use use_case::peer_link::{PeerLinkError, PeerLinkService};
pub use use_case::{
account_groups::AccountGroupService,
@@ -213,6 +214,7 @@ pub struct AdminServices {
proxies: Arc<dyn ProxiesService>,
auth: Arc<dyn AuthService>,
key_usage: Arc<dyn KeyUsageService>,
peer_link: Arc<dyn PeerLinkService>,
accounts: Arc<dyn AccountsService>,
account_groups: Arc<dyn AccountGroupService>,
client_keys: Arc<dyn ClientKeyService>,
@@ -258,6 +260,11 @@ impl AdminServices {
self.key_usage.as_ref()
}
#[must_use]
pub fn peer_link(&self) -> &dyn PeerLinkService {
self.peer_link.as_ref()
}
#[must_use]
pub fn proxies(&self) -> &dyn ProxiesService {
self.proxies.as_ref()
@@ -336,8 +343,13 @@ impl AdminBundle {
}
}
pub use use_case::peer_link::upstream::{
PeerUpstreamBundle, initialize as initialize_peer_upstream,
};
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入
pub struct AdminRuntimePorts {
pub upstream_info: Option<Arc<dyn ports::peer::PeerUpstreamService>>,
pub timezone: gateway_core::time::DeploymentTimeZone,
pub service_middleware: public_service::PlanSource,
pub plugin_preparation: Arc<dyn ports::plugins::PluginPreparation>,
@@ -388,6 +400,7 @@ async fn initialize_inner(
plugin_accounts: Option<Arc<dyn PluginAccountAccess>>,
) -> Result<AdminBundle, AdminError> {
let AdminRuntimePorts {
upstream_info,
timezone,
service_middleware,
plugin_preparation,
@@ -429,6 +442,7 @@ async fn initialize_inner(
registry.clone(),
snapshot.clone(),
probe.clone(),
upstream_info.clone(),
);
let backup_ports = store.backup();
let backups = Arc::new(DefaultBackupService::new(
@@ -465,9 +479,15 @@ async fn initialize_inner(
store.accounts(),
store.proxies(),
snapshot.clone(),
upstream_info.clone(),
));
let plugin_accounts = plugin_accounts.unwrap_or_else(|| {
initialize_plugin_accounts(registry.clone(), store.accounts(), snapshot.clone())
initialize_plugin_accounts(
registry.clone(),
store.accounts(),
snapshot.clone(),
upstream_info.clone(),
)
});
let import_tasks = use_case::import_tasks::DefaultImportTasksService::new(credentials.clone());
let import_task = use_case::import_tasks::ImportTaskWorker(import_tasks.clone());
@@ -483,6 +503,12 @@ async fn initialize_inner(
public_services.register_accounts(&accounts)?;
public_services.register_usage_data(&usage_data)?;
let services = AdminServices {
peer_link: Arc::new(use_case::peer_link::DefaultPeerLinkService::new(
store.client_keys(),
store.accounts(),
store.account_runtime(),
registry.clone(),
)),
timezone,
public_services: Arc::new(public_services),
plugin_management: Arc::new(PluginManagementService::new(
@@ -505,6 +531,7 @@ async fn initialize_inner(
proxy_probe,
snapshot.clone(),
registry.clone(),
upstream_info.clone(),
)),
auth,
accounts: accounts.clone(),
@@ -526,6 +553,7 @@ async fn initialize_inner(
registry.clone(),
timezone,
system.clone(),
upstream_info,
)),
settings,
usage_data,
@@ -595,9 +623,13 @@ pub fn initialize_plugin_accounts(
providers: ports::provider::ProviderAdminRegistry,
accounts: Arc<dyn ports::store::AccountStore>,
snapshot: Arc<dyn gateway_core::runtime::SnapshotControl>,
upstream_info: Option<Arc<dyn ports::peer::PeerUpstreamService>>,
) -> Arc<dyn PluginAccountAccess> {
Arc::new(use_case::plugin_accounts::DefaultPluginAccountAccess::new(
providers, accounts, snapshot,
providers,
accounts,
snapshot,
upstream_info,
))
}
@@ -674,6 +706,10 @@ fn freeze_recovery_worker_contribution(
/// 服务端与 CLI 复用账号用例,不随命令启动后台调度
#[must_use]
#[expect(
clippy::too_many_arguments,
reason = "账号存储、发布、探测与互联分别通过独立端口注入"
)]
pub fn initialize_accounts(
accounts: Arc<dyn ports::store::AccountStore>,
account_runtime: Arc<dyn ports::store::AccountRuntimeStore>,
@@ -682,6 +718,7 @@ pub fn initialize_accounts(
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
upstream_info: Option<Arc<dyn ports::peer::PeerUpstreamService>>,
) -> Arc<dyn AccountsService> {
Arc::new(DefaultAccountsService::new(
accounts,
@@ -691,6 +728,7 @@ pub fn initialize_accounts(
providers,
snapshot,
probe,
upstream_info,
))
}
@@ -30,9 +30,10 @@ pub struct ProviderAccountCapabilities {
pub consume_reset_credit: bool,
}
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义
/// 导入时应用的账号设置;名称只覆盖单账号导入,其余设置统一应用
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountImportSettings {
pub name: Option<String>,
pub notes: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
@@ -226,6 +227,8 @@ pub struct AccountCapacity {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccount {
pub account_id: String,
/// 缺省保留名称,空字符串清除名称并使用邮箱展示
pub name: Option<String>,
/// 缺省保留备注;空字符串清空备注
pub notes: Option<String>,
pub enabled: bool,
@@ -108,6 +108,7 @@ pub struct ClientKeyListQuery {
/// 不含完整明文 Key 的管理投影
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyRecord {
pub peer_sharing: gateway_core::policy::ClientPeerSharing,
pub request_profile_overrides: ProviderRequestProfileOverrides,
pub id: ClientApiKeyId,
pub name: String,
@@ -167,6 +168,7 @@ impl fmt::Debug for ClientKeySecret {
/// API 提交的 Client Key 创建命令
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateClientKey {
pub peer_sharing: gateway_core::policy::ClientPeerSharing,
pub request_profile_overrides: ProviderRequestProfileOverrides,
pub custom_key: Option<PlaintextClientApiKey>,
pub name: String,
@@ -179,6 +181,7 @@ pub struct CreateClientKey {
/// 管理用例生成 ID 与明文后的持久化命令
#[derive(Clone, PartialEq, Eq)]
pub struct NewClientKey {
pub peer_sharing: gateway_core::policy::ClientPeerSharing,
pub request_profile_overrides: ProviderRequestProfileOverrides,
pub id: ClientApiKeyId,
pub name: String,
@@ -204,6 +207,7 @@ impl fmt::Debug for NewClientKey {
/// 修改 Client Key 的公开策略字段
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKey {
pub peer_sharing: Option<gateway_core::policy::ClientPeerSharing>,
pub request_profile_override_updates: ProviderRequestProfileOverrideUpdates,
pub id: ClientApiKeyId,
pub name: String,
@@ -14,6 +14,7 @@ pub mod diagnostics;
pub mod import_tasks;
pub mod key_usage;
pub mod observability;
pub mod peer_link;
pub mod plugin_client_keys;
pub mod plugin_resources;
pub mod plugins;
@@ -26,6 +27,7 @@ pub mod reset_credit_automation;
pub mod retention;
pub mod settings;
pub mod system;
pub mod upstream_info;
pub mod usage_data;
/// 管理用例对外返回的稳定错误分类
@@ -648,6 +648,7 @@ pub struct AccountRequestBucket {
/// Dashboard 中一个账号的完整用量事实
#[derive(Debug, Clone, PartialEq)]
pub struct DashboardAccountUsage {
pub upstream: Option<super::upstream_info::ProviderUpstreamInfo>,
pub account_id: String,
pub provider_kind: String,
pub authentication_kind: String,
@@ -965,6 +966,7 @@ pub struct DiagnosticObservation {
pub key: String,
pub name: String,
pub account_provider_kind: Option<String>,
pub account_authentication_kind: Option<String>,
pub account_plan_type: Option<String>,
pub request_count: u64,
pub success_count: u64,
@@ -1435,6 +1437,7 @@ pub struct UsageInsight {
pub struct DiagnosticsItem {
pub key: String,
pub name: String,
pub account_authentication_kind: Option<String>,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub request_count: u64,
@@ -0,0 +1,76 @@
//! 当前认证 Key 可公开的本地互联观测,不携带 HTTP 或 Provider 私有字段
use super::provider_credentials::ProviderQuotaWindowRole;
use chrono::{DateTime, Utc};
use gateway_core::engine::budget::{ClientBudgetAccess, ClientBudgetStatus};
/// 调用边界提供的响应资源预算,超限时整体拒绝而不截断授权范围
#[derive(Debug, Clone, Copy)]
pub struct PeerLinkLimits {
pub max_accounts: usize,
pub max_quota_windows: usize,
pub max_models: usize,
pub max_response_bytes: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PeerLinkExport {
pub observed_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub budget: ClientBudgetStatus,
pub budget_access: ClientBudgetAccess,
pub budget_observed_at: DateTime<Utc>,
pub pool: Option<PeerPoolSummary>,
pub quota: Option<Vec<PeerAccountQuota>>,
pub models: Vec<PeerModelSummary>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PeerPoolSummary {
pub ready: u32,
pub cooldown: u32,
pub exhausted: u32,
pub disabled: u32,
pub error: u32,
pub unknown: u32,
pub plans: Option<std::collections::BTreeMap<String, u32>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PeerAccountQuota {
pub id: String,
pub models: Vec<String>,
pub observed_at: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>,
pub windows: Vec<PeerQuotaWindow>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PeerQuotaWindow {
pub key: String,
pub group: String,
pub label: String,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<ProviderQuotaWindowRole>,
pub window_seconds: Option<u64>,
pub used_percent: Option<f64>,
pub reset_at: Option<DateTime<Utc>>,
pub limit_reached: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PeerModelState {
Available,
Exhausted,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerModelSummary {
pub model: String,
pub complete: bool,
pub state: PeerModelState,
pub observed_at: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>,
}
@@ -790,6 +790,7 @@ impl ProviderQuotaWindowRole {
/// 一个 Provider quota 窗口的公共投影
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderQuotaWindow {
pub upstream: Option<super::upstream_info::UpstreamQuotaWindow>,
pub key: String,
pub group: String,
pub label: String,
@@ -828,8 +829,9 @@ pub struct ProviderQuotaReadRequest<'a> {
}
/// Provider 已解析的 quota 结果及其不透明差异字段
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Default, Clone, PartialEq)]
pub struct ProviderQuota {
pub upstream: Option<super::upstream_info::ProviderUpstreamInfo>,
/// 上游额度响应明确提供的套餐,可用于补全账号展示
pub plan_type: Option<String>,
pub observed_at: Option<DateTime<Utc>>,
@@ -1008,6 +1010,21 @@ impl ProviderQuota {
.min_by_key(|(_, period)| *period)
}
/// 互联账号按最长的 Key 消费周期统计本地用量,池内账号窗口不代表此 Key 的周期
#[must_use]
pub fn upstream_usage_window(&self) -> Option<&ProviderQuotaWindow> {
self.windows
.iter()
.filter(|window| {
window
.upstream
.as_ref()
.is_some_and(|upstream| upstream.amount.is_some())
&& window.window_seconds.is_some_and(|seconds| seconds > 0)
})
.max_by_key(|window| window.window_seconds)
}
/// 统计与预测共用窗口归属规则,避免把模型专属桶或短期限流当作账号容量
pub(crate) fn usage_windows(
&self,
@@ -28,6 +28,7 @@ pub struct ProxyListQuery {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyAccountRef {
pub upstream: Option<super::upstream_info::ProviderUpstreamInfo>,
pub id: String,
pub name: String,
pub email: Option<String>,
@@ -0,0 +1,52 @@
//! 管理端互联快照的中立展示事实,不携带 Provider 原始协议
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderUpstreamInfo {
pub discovered: bool,
pub observed_at: Option<DateTime<Utc>>,
pub stale: bool,
pub connection_revision: String,
pub last_error: Option<String>,
pub pool: Option<UpstreamPool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpstreamPool {
pub account_count: u32,
pub states: UpstreamPoolStates,
pub plans: Vec<UpstreamPlanCount>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpstreamPoolStates {
pub ready: u32,
pub cooldown: u32,
pub exhausted: u32,
pub disabled: u32,
pub error: u32,
pub unknown: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpstreamPlanCount {
pub name: String,
pub count: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct UpstreamQuotaWindow {
pub account_id: Option<String>,
pub observed_at: Option<DateTime<Utc>>,
pub stale: bool,
pub amount: Option<UpstreamQuotaAmount>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpstreamQuotaAmount {
pub unit: String,
pub used: String,
pub remaining: Option<String>,
pub total: Option<String>,
}
@@ -2,6 +2,7 @@
pub mod backup;
pub mod client_distribution;
pub mod peer;
pub mod plugin_accounts;
pub mod plugin_client_keys;
pub mod plugin_management;
@@ -0,0 +1,77 @@
//! CPR 连接材料与传输端口;Provider 解释凭据,Host 执行受限网络请求
use std::time::Duration;
use async_trait::async_trait;
use gateway_core::account::{ProviderAccount, UpstreamInfoFailure};
use secrecy::SecretString;
/// 仅供后台请求使用,不进入日志、持久观测或管理响应
pub struct PeerConnection {
pub base_url: String,
pub bearer_token: SecretString,
}
pub struct PeerFetchFailure {
pub kind: UpstreamInfoFailure,
pub retry_after: Option<Duration>,
}
impl From<UpstreamInfoFailure> for PeerFetchFailure {
fn from(kind: UpstreamInfoFailure) -> Self {
Self {
kind,
retry_after: None,
}
}
}
#[async_trait]
pub trait PeerTransport: Send + Sync {
async fn fetch(
&self,
account: &ProviderAccount,
connection: &PeerConnection,
) -> Result<Vec<u8>, PeerFetchFailure>;
}
/// 账号用例读取公共互联观测,显式刷新等待额度查询完成
#[async_trait]
pub trait PeerUpstreamService: gateway_core::account::UpstreamInfoSource {
async fn account_changed(
&self,
id: &gateway_core::account::ProviderAccountId,
) -> Result<(), gateway_core::error::StoreError>;
fn forget(&self, id: &gateway_core::account::ProviderAccountId);
async fn refresh_quota(
&self,
id: &gateway_core::account::ProviderAccountId,
) -> Result<
crate::model::provider_credentials::ProviderQuota,
super::provider::ProviderAdminError,
>;
async fn read_quota(
&self,
account_id: &gateway_core::account::ProviderAccountId,
) -> Result<
Option<crate::model::provider_credentials::ProviderQuota>,
gateway_core::error::StoreError,
>;
/// 读取版本化摘要,不展开远端节点窗口
async fn read_summary(
&self,
account_id: &gateway_core::account::ProviderAccountId,
) -> Result<
Option<crate::model::provider_credentials::ProviderQuota>,
gateway_core::error::StoreError,
>;
async fn read_quotas(
&self,
requests: &[crate::model::provider_credentials::ProviderQuotaReadRequest<'_>],
) -> Vec<
Result<
crate::model::provider_credentials::ProviderQuota,
super::provider::ProviderAdminError,
>,
>;
}
@@ -179,6 +179,19 @@ pub trait ProviderAdmin: Send + Sync {
}
fn provider_kind(&self) -> &ProviderKind;
/// 与执行 Provider 共用的直连资格,未知实现默认不公开账号
fn is_direct_account(&self, _authentication_kind: &str) -> bool {
false
}
/// 仅依据本地账号事实判断通用模型资格;未知实现不能宣称完整覆盖
fn peer_model_eligible(
&self,
_account: &crate::model::accounts::AccountRecord,
_model: &str,
) -> Option<bool> {
None
}
/// 只读取原生 Provider 能力及账号类型,不执行网络或数据库查询
fn account_capabilities(
@@ -309,6 +322,24 @@ pub trait ProviderAdmin: Send + Sync {
.await
}
/// 仅依据账号类型声明 CPR 连接能力,通用用例不解析 Provider 凭据
fn peer_authentication_kinds(&self) -> &[&str] {
&[]
}
fn supports_peer_connection(&self, authentication_kind: &str) -> bool {
self.peer_authentication_kinds()
.contains(&authentication_kind)
}
/// 从本轮加载的凭据提取连接材料,避免在校验连接版本后另读一份凭据
fn peer_connection(
&self,
_loaded: &gateway_core::account::LoadedCredential,
) -> Result<Option<super::peer::PeerConnection>, ProviderAdminError> {
Ok(None)
}
/// 历史观测的协议字段仅由具体 Provider 解释;不支持时保留累计估算
fn quota_forecast_observation(
&self,
@@ -385,6 +416,10 @@ pub struct ProviderAdminRegistry {
}
impl ProviderAdminRegistry {
pub(crate) fn registered(&self) -> BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>> {
self.providers.as_ref().clone()
}
pub fn pricing_catalog(&self) -> gateway_core::metering::PricingOverrides {
self.providers
.iter()
@@ -131,8 +131,43 @@ pub trait AccountStore:
runtime: AccountRuntimeSnapshot,
) -> AdminStoreResult<Option<AccountPageItem>>;
/// 批量读取指定账号与当前启用分组的交集,分组只用于授权筛选,不加载展示信息
/// 不加载凭据正文或触发 Provider 查询
async fn load_accounts_in_enabled_groups(
&self,
account_ids: &[gateway_core::account::ProviderAccountId],
group_ids: &[gateway_core::routing::AccountGroupId],
) -> AdminStoreResult<Vec<crate::model::accounts::AccountRecord>> {
if group_ids.is_empty() {
return Ok(Vec::new());
}
let mut accounts = Vec::with_capacity(account_ids.len());
for id in account_ids {
if let Some(item) = self
.load_account(id.as_str(), AccountRuntimeSnapshot::default())
.await?
&& item
.account
.groups
.iter()
.any(|group| group.enabled && group_ids.contains(&group.id))
{
let mut account = item.account;
account.groups.clear();
accounts.push(account);
}
}
Ok(accounts)
}
async fn account_exists(&self, account_id: &str) -> AdminStoreResult<bool>;
/// 只读取账号记录,不加载运行配置、分组或额度展示
async fn load_account_record(
&self,
account_id: &gateway_core::account::ProviderAccountId,
) -> AdminStoreResult<Option<crate::model::accounts::AccountRecord>>;
async fn load_account_usage(
&self,
range: TimeRange,
@@ -252,6 +287,14 @@ pub trait AccountRuntimeStore: Send + Sync {
account_ids: &[String],
) -> AdminStoreResult<AccountRuntimeSnapshot>;
/// 只需账号可用状态时不读取在途租约,避免池规模放大容量查询
async fn account_rate_limits(
&self,
account_ids: &[String],
) -> AdminStoreResult<AccountRuntimeSnapshot> {
self.account_runtime(account_ids).await
}
/// 账号错误保护中的账号与其冻结快照;临时限流不包含在内
async fn active_freezes(
&self,
@@ -191,12 +191,17 @@ pub(crate) struct DefaultAccountsService {
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
reset_credit_locks:
Arc<futures::lock::Mutex<BTreeMap<ProviderAccountId, Arc<futures::lock::Mutex<()>>>>>,
}
impl DefaultAccountsService {
#[must_use]
#[expect(
clippy::too_many_arguments,
reason = "账号存储、发布、探测与互联分别通过独立端口注入"
)]
pub(crate) fn new(
accounts: Arc<dyn AccountStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
@@ -205,6 +210,7 @@ impl DefaultAccountsService {
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
) -> Self {
Self {
accounts,
@@ -214,6 +220,7 @@ impl DefaultAccountsService {
providers,
snapshot,
probe,
upstream_info,
reset_credit_locks: Arc::new(futures::lock::Mutex::new(BTreeMap::new())),
}
}
@@ -231,10 +238,11 @@ impl DefaultAccountsService {
}
async fn read_quotas(
providers: &ProviderAdminRegistry,
&self,
items: &[AccountPageItem],
rolling_usage: &BTreeMap<String, AccountUsage>,
) -> Vec<ProviderQuota> {
let providers = &self.providers;
let mut groups = BTreeMap::<_, Vec<usize>>::new();
for (index, item) in items.iter().enumerate() {
groups
@@ -264,7 +272,12 @@ impl DefaultAccountsService {
}
})
.collect::<Vec<_>>();
let results = provider.read_quotas(&requests).await;
let results = super::peer_link::upstream::read_quotas(
self.upstream_info.as_deref(),
provider.as_ref(),
&requests,
)
.await;
if results.len() != indices.len() {
tracing::warn!(
provider = %kind,
@@ -381,32 +394,51 @@ impl DefaultAccountsService {
async fn load_api_key_usage(
&self,
accounts: &[AccountPageItem],
quotas: &[ProviderQuota],
) -> Result<BTreeMap<String, AccountUsage>, AdminError> {
let now = Utc::now();
// API Key 没有套餐周期;本地累计直接查询账号创建后仍保留的请求记录
let mut usage = BTreeMap::new();
// 互联账号复用已聚合的最长 Key 窗口,普通 API Key 保留累计口径
let windows = accounts
.iter()
.filter(|item| item.account.authentication_kind == "api_key")
.map(|item| AccountUsageWindowQuery {
account_id: item.account.id.clone(),
key: "account-lifetime".to_owned(),
range: TimeRange {
start: item.account.created_at,
end: now,
},
.zip(quotas)
.filter(|(item, _)| item.account.authentication_kind == "api_key")
.filter_map(|(item, quota)| {
if quota
.upstream
.as_ref()
.is_some_and(|upstream| upstream.discovered)
{
if let Some(local_usage) = quota
.upstream_usage_window()
.and_then(|window| window.local_usage.as_ref())
{
usage.insert(item.account.id.clone(), local_usage.clone());
}
return None;
}
Some(AccountUsageWindowQuery {
account_id: item.account.id.clone(),
key: "account-lifetime".to_owned(),
range: TimeRange {
start: item.account.created_at,
end: now,
},
})
})
.collect::<Vec<_>>();
if windows.is_empty() {
return Ok(BTreeMap::new());
return Ok(usage);
}
Ok(self
.accounts
.load_account_usage_by_windows(&windows)
.await
.map_err(|error| map_store_error(error, "API Key account usage"))?
.into_iter()
.map(|result| (result.account_id, result.usage))
.collect())
usage.extend(
self.accounts
.load_account_usage_by_windows(&windows)
.await
.map_err(|error| map_store_error(error, "API Key account usage"))?
.into_iter()
.map(|result| (result.account_id, result.usage)),
);
Ok(usage)
}
async fn load_directory_item(
@@ -428,13 +460,17 @@ impl DefaultAccountsService {
.await
.map_err(|error| map_store_error(error, "rolling account usage"))?;
let rolling_usage = rolling_usage.into_iter().next();
let mut quota = match provider
.quota(ProviderQuotaRequest {
let mut quota = match super::peer_link::upstream::read_quota(
self.upstream_info.as_deref(),
provider.as_ref(),
account,
ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: refresh_quota,
rolling_usage: rolling_usage.clone(),
})
.await
},
)
.await
{
Ok(quota) => quota,
// 凭据更新后的账号投影不要求 Provider 提供额度;显式刷新仍须支持该操作
@@ -458,7 +494,7 @@ impl DefaultAccountsService {
)
.await?;
let usage = self
.load_api_key_usage(std::slice::from_ref(&stored))
.load_api_key_usage(std::slice::from_ref(&stored), std::slice::from_ref(&quota))
.await?
.remove(&stored.account.id)
.or_else(|| {
@@ -554,10 +590,10 @@ impl AccountsService for DefaultAccountsService {
.into_iter()
.map(|usage| (usage.account_id.clone(), usage))
.collect::<BTreeMap<_, _>>();
let mut quotas = Self::read_quotas(providers, &page.items, &rolling_usage).await;
let mut quotas = self.read_quotas(&page.items, &rolling_usage).await;
self.attach_quota_local_usage(&page.items, &mut quotas)
.await?;
let mut api_key_usage = self.load_api_key_usage(&page.items).await?;
let mut api_key_usage = self.load_api_key_usage(&page.items, &quotas).await?;
let items = page
.items
.into_iter()
@@ -684,9 +720,12 @@ impl AccountsService for DefaultAccountsService {
"provider credential refresh",
)
.await?;
provider
.account_facts_changed(std::slice::from_ref(&result.account_id))
.await;
super::notify_account_facts_changed(
self.upstream_info.as_deref(),
provider.as_ref(),
std::slice::from_ref(&result.account_id),
)
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
let account = self.load_directory_item(&result.account_id, false).await?;
Ok(AccountRefreshResult {
@@ -726,9 +765,12 @@ impl AccountsService for DefaultAccountsService {
.map_err(|error| map_store_error(error, "enable provider account"))?
.config_revision
};
provider
.account_facts_changed(std::slice::from_ref(&account_id))
.await;
super::notify_account_facts_changed(
self.upstream_info.as_deref(),
provider.as_ref(),
std::slice::from_ref(&account_id),
)
.await;
publish_committed(self.snapshot.as_ref(), config_revision).await?;
let account = self.load_directory_item(&account_id, false).await?;
Ok(AccountRefreshResult {
@@ -752,11 +794,19 @@ impl AccountsService for DefaultAccountsService {
.await
.map_err(|error| map_store_error(error, "provider account"))?;
if !enabled {
provider.account_unavailable(&account_id).await;
}
provider
.account_facts_changed(std::slice::from_ref(&account_id))
super::notify_account_unavailable(
self.upstream_info.as_deref(),
provider.as_ref(),
&account_id,
)
.await;
}
super::notify_account_facts_changed(
self.upstream_info.as_deref(),
provider.as_ref(),
std::slice::from_ref(&account_id),
)
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
@@ -798,10 +848,20 @@ impl AccountsService for DefaultAccountsService {
for (provider, provider_ids) in providers.values() {
if enabled == Some(false) {
for account_id in provider_ids {
provider.account_unavailable(account_id).await;
super::notify_account_unavailable(
self.upstream_info.as_deref(),
provider.as_ref(),
account_id,
)
.await;
}
}
provider.account_facts_changed(provider_ids).await;
super::notify_account_facts_changed(
self.upstream_info.as_deref(),
provider.as_ref(),
provider_ids,
)
.await;
}
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
@@ -831,14 +891,18 @@ impl AccountsService for DefaultAccountsService {
account_id: &ProviderAccountId,
) -> Result<AccountQuotaForecastReport, AdminError> {
let (stored, provider) = self.provider_for_account(account_id).await?;
let quota = provider
.quota(ProviderQuotaRequest {
let quota = super::peer_link::upstream::read_quota(
self.upstream_info.as_deref(),
provider.as_ref(),
&stored.account,
ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: false,
rolling_usage: None,
})
.await
.map_err(|error| map_provider_error(error, "forecast quota snapshot"))?;
},
)
.await
.map_err(|error| map_provider_error(error, "forecast quota snapshot"))?;
let now = Utc::now();
let mut samples = Vec::new();
let mut selected_keys = BTreeSet::new();
@@ -1143,15 +1207,7 @@ fn map_reset_credits_error_after_refresh(
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影
fn empty_quota() -> ProviderQuota {
ProviderQuota {
credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: Vec::new(),
limit_reached: false,
provider_data: None,
}
ProviderQuota::default()
}
fn quota_usage_window(
@@ -165,6 +165,7 @@ impl ClientKeyService for DefaultClientKeyService {
.and_then(|provider| provider.preview_client_profile(profile))
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
validate_peer_sharing(command.peer_sharing, &command.group_ids)?;
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
let plaintext = if let Some(key) = command.custom_key {
@@ -176,6 +177,7 @@ impl ClientKeyService for DefaultClientKeyService {
.store
.create_client_key(
NewClientKey {
peer_sharing: command.peer_sharing,
request_profile_overrides: command.request_profile_overrides,
id,
name: command.name,
@@ -211,6 +213,9 @@ impl ClientKeyService for DefaultClientKeyService {
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
}
if let Some(sharing) = command.peer_sharing {
validate_peer_sharing(sharing, &command.group_ids)?;
}
let id = command.id.clone();
let (config_revision, record) = self
.store
@@ -308,3 +313,16 @@ pub(super) fn generate_key() -> String {
OsRng.fill_bytes(&mut bytes);
format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes))
}
fn validate_peer_sharing(
sharing: gateway_core::policy::ClientPeerSharing,
groups: &[gateway_core::routing::AccountGroupId],
) -> Result<(), AdminError> {
sharing.validate(!groups.is_empty()).map_err(|error| {
use gateway_core::policy::ClientPeerSharingError;
AdminError::invalid(match error {
ClientPeerSharingError::MissingGroupScope => "开启 CPR 互联需要指定账号分组",
ClientPeerSharingError::PlansWithoutPool => "公开套餐分布需要先公开账号池",
})
})
}
@@ -19,29 +19,54 @@ use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::S
use super::{map_store_error, publish_committed};
pub(super) async fn publish_credentials_and_observe_quota(
peer: Option<&std::sync::Arc<dyn crate::ports::peer::PeerUpstreamService>>,
provider: &std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
accounts: &std::sync::Arc<dyn AccountStore>,
snapshot: &dyn SnapshotControl,
revision: crate::model::Revision,
account_ids: &[ProviderAccountId],
request_id: &str,
) -> Result<(), AdminError> {
provider.account_facts_changed(account_ids).await;
notify_account_facts_changed(peer.map(AsRef::as_ref), provider.as_ref(), account_ids).await;
publish_committed(snapshot, revision).await?;
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果
let provider = provider.clone();
let peer = peer.cloned();
let accounts = accounts.clone();
let account_ids = account_ids.to_vec();
let request_id = request_id.to_owned();
tokio::spawn(async move {
for account_id in account_ids {
if let Err(error) = provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
let request = ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
};
let result = if let Some(peer) = peer.as_deref() {
let account = match accounts.load_account_record(&account_id).await {
Ok(Some(account)) => account,
Ok(None) => continue,
Err(error) => {
tracing::warn!(request_id, account_id = %account_id, error = %error, "initial quota account lookup failed");
continue;
}
};
if !account.enabled || &account.provider_kind != provider.provider_kind() {
continue;
}
// 首查与手动刷新共用认证能力分派,互联协议由 Admin 解释
super::peer_link::upstream::read_quota(
Some(peer),
provider.as_ref(),
&account,
request,
)
.await
{
} else {
provider.quota(request).await
};
if let Err(error) = result {
tracing::warn!(
request_id,
provider = %provider.provider_kind().as_str(),
@@ -343,6 +368,7 @@ pub(super) async fn commit_credential_refresh(
}
pub(super) async fn delete_credentials(
peer: Option<&dyn crate::ports::peer::PeerUpstreamService>,
accounts: &dyn AccountStore,
provider: &dyn ProviderAdmin,
command: CredentialDeletion,
@@ -365,11 +391,37 @@ pub(super) async fn delete_credentials(
.await
.map_err(|error| map_store_error(error, resource))?;
for account_id in &account_ids {
provider.account_unavailable(account_id).await;
notify_account_unavailable(peer, provider, account_id).await;
}
provider.account_facts_changed(&account_ids).await;
notify_account_facts_changed(peer, provider, &account_ids).await;
Ok(CredentialDeletionResult {
config_revision: revision,
account_ids,
})
}
pub(super) async fn notify_account_facts_changed(
peer: Option<&dyn crate::ports::peer::PeerUpstreamService>,
provider: &dyn ProviderAdmin,
ids: &[ProviderAccountId],
) {
provider.account_facts_changed(ids).await;
if let Some(peer) = peer {
for id in ids {
if let Err(error) = peer.account_changed(id).await {
tracing::warn!(account_id = %id, error = %error, "upstream information cache invalidation failed");
}
}
}
}
pub(super) async fn notify_account_unavailable(
peer: Option<&dyn crate::ports::peer::PeerUpstreamService>,
provider: &dyn ProviderAdmin,
id: &ProviderAccountId,
) {
if let Some(peer) = peer {
peer.forget(id);
}
provider.account_unavailable(id).await;
}
@@ -30,6 +30,7 @@ pub struct ProviderCredentials {
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
}
impl ProviderCredentials {
@@ -39,12 +40,14 @@ impl ProviderCredentials {
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
) -> Self {
Self {
provider,
accounts,
proxies,
snapshot,
upstream_info,
}
}
pub async fn import_document(
@@ -89,7 +92,9 @@ impl ProviderCredentials {
.map_err(|error| map_store_error(error, "provider credential import"))?;
drop(proxy_reservation);
publish_credentials_and_observe_quota(
self.upstream_info.as_ref(),
&self.provider,
&self.accounts,
self.snapshot.as_ref(),
result.config_revision,
&result.credential_ids,
@@ -160,11 +165,19 @@ impl ProviderCredentials {
)
.await?;
if disable_account {
self.provider.account_unavailable(&account_id).await;
}
self.provider
.account_facts_changed(std::slice::from_ref(&result.account_id))
super::super::notify_account_unavailable(
self.upstream_info.as_deref(),
self.provider.as_ref(),
&account_id,
)
.await;
}
super::super::notify_account_facts_changed(
self.upstream_info.as_deref(),
self.provider.as_ref(),
std::slice::from_ref(&result.account_id),
)
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
@@ -174,6 +187,7 @@ impl ProviderCredentials {
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError> {
let result = delete_credentials(
self.upstream_info.as_deref(),
self.accounts.as_ref(),
self.provider.as_ref(),
command,
@@ -236,7 +250,9 @@ impl super::CredentialsService {
let result = outcome.result;
if outcome.newly_committed {
publish_credentials_and_observe_quota(
self.upstream_info.as_ref(),
provider,
&self.accounts,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&result.account_id),
@@ -19,6 +19,7 @@ pub struct CredentialsService {
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
}
impl CredentialsService {
@@ -28,12 +29,14 @@ impl CredentialsService {
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
) -> Self {
Self {
providers,
accounts,
proxies,
snapshot,
upstream_info,
}
}
@@ -47,6 +50,7 @@ impl CredentialsService {
self.accounts.clone(),
self.proxies.clone(),
self.snapshot.clone(),
self.upstream_info.clone(),
))
}
}
@@ -10,6 +10,7 @@ pub mod credentials;
pub mod import_tasks;
pub mod key_usage;
pub mod observability;
pub mod peer_link;
pub mod plugin_accounts;
pub mod plugin_client_keys;
pub mod plugin_resources;
@@ -26,10 +27,10 @@ mod publication;
use credential_mutation::{
commit_authorization, commit_credential_refresh, commit_credential_rotation,
delete_credentials, import_proxy_binding, pending_authorization,
publish_credentials_and_observe_quota, required_credential, required_plugin_credential,
validate_authorization_commit, validate_prepared_import, validate_prepared_rotation,
validate_prepared_rotation_facts,
delete_credentials, import_proxy_binding, notify_account_facts_changed,
notify_account_unavailable, pending_authorization, publish_credentials_and_observe_quota,
required_credential, required_plugin_credential, validate_authorization_commit,
validate_prepared_import, validate_prepared_rotation, validate_prepared_rotation_facts,
};
use error::{map_provider_error, map_store_error};
use publication::publish_committed;
@@ -94,6 +94,7 @@ pub(crate) struct DefaultObservabilityService {
settings: Arc<dyn SettingsStore>,
providers: ProviderAdminRegistry,
system: Arc<dyn super::system::SystemService>,
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
}
impl DefaultObservabilityService {
@@ -105,6 +106,7 @@ impl DefaultObservabilityService {
providers: ProviderAdminRegistry,
timezone: gateway_core::time::DeploymentTimeZone,
system: Arc<dyn super::system::SystemService>,
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
) -> Self {
Self {
timezone,
@@ -113,6 +115,7 @@ impl DefaultObservabilityService {
settings,
providers,
system,
upstream_info,
}
}
@@ -344,6 +347,7 @@ impl ObservabilityService for DefaultObservabilityService {
DiagnosticsItem {
key: item.key,
name: item.name,
account_authentication_kind: item.account_authentication_kind,
account_plan_type_display: item.account_provider_kind.as_deref().and_then(
|provider| {
self.providers
@@ -932,6 +936,7 @@ impl DefaultObservabilityService {
.map(|usage| (usage.account_id.clone(), usage))
.collect::<HashMap<_, _>>();
let providers = self.providers.clone();
let peer = self.upstream_info.as_deref();
let quota_reads = accounts.iter().map(|account| {
let rolling_usage = rolling_usage.remove(&account.account_id);
let account_id = ProviderAccountId::new(account.account_id.clone()).ok();
@@ -942,6 +947,11 @@ impl DefaultObservabilityService {
let (Some(account_id), Some(provider)) = (account_id, provider) else {
return None;
};
if let Some(peer) = peer
&& let Ok(Some(quota)) = peer.read_summary(&account_id).await
{
return Some(quota);
}
provider
.quota(ProviderQuotaRequest {
account_id,
@@ -959,6 +969,7 @@ impl DefaultObservabilityService {
quota.apply_limit_reached_display();
account.quota_used_percent = quota.representative_used_percent();
account.quota_window = quota.representative_window().cloned();
account.upstream = quota.upstream;
}
}
}
@@ -0,0 +1,259 @@
//! CPR 互联只读取认证范围内的本地事实,不参与推理准入或上游刷新
mod projection;
mod rate_limit;
pub mod upstream;
use std::{collections::BTreeMap, sync::Arc};
use async_trait::async_trait;
use chrono::{TimeDelta, Utc};
use gateway_core::{engine::execution::AuthenticatedClient, policy::ClientPeerSharing};
use tokio::sync::Semaphore;
use crate::{
model::{
AdminError,
peer_link::{PeerLinkExport, PeerLinkLimits},
provider_credentials::ProviderQuotaReadRequest,
},
ports::{
provider::ProviderAdminRegistry,
store::{AccountRuntimeStore, AccountStore, ClientKeyStore},
},
};
use super::map_store_error;
const MAX_CONCURRENT_SNAPSHOTS: usize = 8;
/// 查询失败保持独立于推理错误,不改变账号或 Key 状态
#[derive(Debug, thiserror::Error)]
pub enum PeerLinkError {
#[error("client API key is unavailable")]
Unauthorized,
#[error("peer sharing is not allowed")]
Forbidden,
#[error("peer query rate limit reached")]
RateLimited { retry_after: u64 },
#[error("peer snapshot exceeds the supported bounds")]
SnapshotTooLarge,
#[error("peer snapshot is unavailable")]
Unavailable(#[from] AdminError),
}
#[async_trait]
pub trait PeerLinkService: Send + Sync {
async fn snapshot(
&self,
client: &AuthenticatedClient,
limits: PeerLinkLimits,
) -> Result<PeerLinkExport, PeerLinkError>;
}
pub(crate) struct DefaultPeerLinkService {
keys: Arc<dyn ClientKeyStore>,
accounts: Arc<dyn AccountStore>,
runtime: Arc<dyn AccountRuntimeStore>,
providers: ProviderAdminRegistry,
rate_limit: rate_limit::QueryRateLimit,
snapshot_slots: Semaphore,
}
impl DefaultPeerLinkService {
pub(crate) fn new(
keys: Arc<dyn ClientKeyStore>,
accounts: Arc<dyn AccountStore>,
runtime: Arc<dyn AccountRuntimeStore>,
providers: ProviderAdminRegistry,
) -> Self {
Self {
keys,
accounts,
runtime,
providers,
rate_limit: Default::default(),
snapshot_slots: Semaphore::new(MAX_CONCURRENT_SNAPSHOTS),
}
}
}
#[async_trait]
impl PeerLinkService for DefaultPeerLinkService {
async fn snapshot(
&self,
client: &AuthenticatedClient,
limits: PeerLinkLimits,
) -> Result<PeerLinkExport, PeerLinkError> {
self.rate_limit.acquire(client.policy().key_id())?;
let frozen_sharing = client.policy().peer_sharing();
// 当前权限只能收窄冻结范围,重查询在读取 Key 分组前拒绝且不排队
let _permit = if frozen_sharing.share_pool || frozen_sharing.share_quota {
Some(
self.snapshot_slots
.try_acquire()
.map_err(|_| PeerLinkError::RateLimited { retry_after: 1 })?,
)
} else {
None
};
let key = self
.keys
.get_client_key(client.policy().key_id())
.await
.map_err(|error| map_store_error(error, "client API key"))?
.filter(|key| key.enabled)
.ok_or(PeerLinkError::Unauthorized)?;
let budget_observed_at = Utc::now();
let sharing = intersect_sharing(frozen_sharing, key.peer_sharing);
if !sharing.enabled || key.groups.is_empty() {
return Err(PeerLinkError::Forbidden);
}
let now = Utc::now();
let mut result = PeerLinkExport {
observed_at: now,
expires_at: now + TimeDelta::minutes(5),
budget_access: key
.budget
.limits
.access(key.budget.daily_used_usd, key.budget.weekly_used_usd),
budget: key.budget,
budget_observed_at,
pool: None,
quota: None,
models: Vec::new(),
};
if !sharing.share_pool && !sharing.share_quota {
return Ok(result);
}
let ids = client
.policy()
.account_scope()
.allowed_account_ids()
.take(limits.max_accounts.saturating_add(1))
.cloned()
.collect::<Vec<_>>();
if ids.len() > limits.max_accounts {
return Err(PeerLinkError::SnapshotTooLarge);
}
let public_models = if sharing.share_quota {
client
.snapshot()
.public_models_for_scope(client.policy().account_scope())
} else {
Vec::new()
};
if public_models.len() > limits.max_models {
return Err(PeerLinkError::SnapshotTooLarge);
}
let groups = key
.groups
.iter()
.filter(|group| group.enabled)
.map(|group| group.id.clone())
.collect::<Vec<_>>();
let accounts = self
.accounts
.load_accounts_in_enabled_groups(&ids, &groups)
.await
.map_err(|error| map_store_error(error, "peer accounts"))?
.into_iter()
.filter(|account| {
self.providers
.require(&account.provider_kind)
.is_ok_and(|provider| provider.is_direct_account(&account.authentication_kind))
})
.collect::<Vec<_>>();
let account_ids = accounts
.iter()
.map(|account| account.id.clone())
.collect::<Vec<_>>();
let runtime = self
.runtime
.account_rate_limits(&account_ids)
.await
.map_err(|error| map_store_error(error, "peer account runtime"))?;
let mut quotas = BTreeMap::new();
if sharing.share_quota {
let mut window_count = 0usize;
let mut by_provider = BTreeMap::new();
for account in &accounts {
by_provider
.entry(&account.provider_kind)
.or_insert_with(Vec::new)
.push(account);
}
for (kind, accounts) in by_provider {
let Ok(provider) = self.providers.require(kind) else {
continue;
};
let requests = accounts
.iter()
.map(|account| ProviderQuotaReadRequest {
account,
rolling_usage: None,
})
.collect::<Vec<_>>();
for (account, quota) in accounts
.into_iter()
.zip(provider.read_quotas(&requests).await)
{
if let Ok(quota) = quota {
window_count = window_count.saturating_add(quota.windows.len());
if window_count > limits.max_quota_windows {
return Err(PeerLinkError::SnapshotTooLarge);
}
quotas.insert(account.id.clone(), quota);
}
}
}
}
let now = Utc::now();
result.observed_at = now;
result.expires_at = now + TimeDelta::minutes(5);
if sharing.share_pool {
result.pool = Some(projection::pool_summary(
&accounts,
&runtime,
&self.providers,
sharing.share_plans,
now,
));
}
if sharing.share_quota {
let (quota, mut models) = projection::quota_and_models(
client,
&accounts,
&quotas,
&self.providers,
&runtime,
&public_models,
now,
limits.max_response_bytes,
)
.await?;
// 发布窗口内缺失或已变更的账号不能被静默剔除后推导整个模型池耗尽
if accounts.len() != ids.len() {
for model in &mut models {
model.complete = false;
if model.state == crate::model::peer_link::PeerModelState::Exhausted {
model.state = crate::model::peer_link::PeerModelState::Unknown;
}
}
}
result.quota = Some(quota);
result.models = models;
}
Ok(result)
}
}
fn intersect_sharing(frozen: ClientPeerSharing, current: ClientPeerSharing) -> ClientPeerSharing {
ClientPeerSharing {
enabled: frozen.enabled && current.enabled,
share_pool: frozen.share_pool && current.share_pool,
share_quota: frozen.share_quota && current.share_quota,
share_plans: frozen.share_plans && current.share_plans,
}
}
@@ -0,0 +1,260 @@
//! 从已授权的本地账号事实派生公开汇总,保留原始观测时间
use std::collections::BTreeMap;
use chrono::{DateTime, TimeDelta, Utc};
use gateway_core::{
account::{
AccountStatus, AccountStatusFacts, ProviderAccountId, QuotaAccessState,
resolve_account_status,
},
engine::execution::AuthenticatedClient,
};
use sha2::{Digest, Sha256};
use crate::{
model::{
accounts::{AccountRecord, AccountRuntimeSnapshot},
peer_link::{
PeerAccountQuota, PeerModelState, PeerModelSummary, PeerPoolSummary, PeerQuotaWindow,
},
provider_credentials::ProviderQuota,
},
ports::provider::ProviderAdminRegistry,
};
pub(super) fn pool_summary(
accounts: &[AccountRecord],
runtime: &AccountRuntimeSnapshot,
providers: &ProviderAdminRegistry,
share_plans: bool,
now: DateTime<Utc>,
) -> PeerPoolSummary {
let mut summary = PeerPoolSummary {
plans: share_plans.then(BTreeMap::new),
..Default::default()
};
for account in accounts {
match status(account, runtime, now) {
AccountStatus::Normal => summary.ready += 1,
AccountStatus::RateLimited => summary.cooldown += 1,
AccountStatus::QuotaExhausted => summary.exhausted += 1,
AccountStatus::Disabled => summary.disabled += 1,
AccountStatus::Error => summary.error += 1,
}
if let Some(plans) = &mut summary.plans
&& let (Some(plan), Ok(provider)) = (
&account.plan_type,
providers.require(&account.provider_kind),
)
{
*plans.entry(provider.plan_type_display(plan)).or_default() += 1;
}
}
summary
}
#[expect(
clippy::too_many_arguments,
reason = "投影显式接收已授权事实和调用边界的响应预算"
)]
pub(super) async fn quota_and_models(
client: &AuthenticatedClient,
accounts: &[AccountRecord],
quotas: &BTreeMap<String, ProviderQuota>,
providers: &ProviderAdminRegistry,
runtime: &AccountRuntimeSnapshot,
models: &[gateway_core::routing::PublicModelId],
now: DateTime<Utc>,
max_response_bytes: usize,
) -> Result<(Vec<PeerAccountQuota>, Vec<PeerModelSummary>), super::PeerLinkError> {
let snapshot = client.snapshot();
let scope = client.policy().account_scope();
let mut observations = BTreeMap::<&str, ModelObservations>::new();
let mut model_bytes = 0usize;
let mut result = Vec::with_capacity(accounts.len());
for (index, account) in accounts.iter().enumerate() {
// 大池投影不能连续占住异步执行线程,取消请求也要能及时释放并发名额
if index % 16 == 0 {
tokio::task::yield_now().await;
}
let observation = model_observation(account, runtime, now);
let Ok(id) = ProviderAccountId::new(account.id.clone()) else {
continue;
};
let provider = providers.require(&account.provider_kind).ok();
let mut account_models = Vec::new();
for model in models {
let upstream = snapshot.mapped_model(model.as_str());
if !snapshot.allows_public_model_for_account(model, scope, &id)
|| !account.model_access.allows(&upstream)
{
continue;
}
let eligible = provider
.as_ref()
.and_then(|provider| provider.peer_model_eligible(account, &upstream));
if eligible == Some(false) {
continue;
}
observations
.entry(model.as_str())
.or_default()
.observe(eligible.and(observation));
if eligible == Some(true) {
// 模型名是响应字节数的下界;提前拒绝必然超限的交叉展开,不误拒稀疏授权
model_bytes = model_bytes.saturating_add(model.as_str().len());
if model_bytes > max_response_bytes {
return Err(super::PeerLinkError::SnapshotTooLarge);
}
account_models.push(model.as_str().to_owned());
}
}
let quota = quotas.get(&account.id);
let observed_at = quota.and_then(|quota| quota.observed_at);
let expires_at = observed_at.map(|observed| {
quota
.into_iter()
.flat_map(|quota| &quota.windows)
.filter_map(|window| window.reset_at)
.fold(observed + TimeDelta::minutes(30), DateTime::min)
});
// 标识只在当前 Key 内稳定,不能用原始账号 ID 跨节点或跨授权关联身份
let mut digest = Sha256::new();
digest.update(b"cpr.peer.account\0");
digest.update(client.policy().plaintext_key().expose_for_auth().as_bytes());
digest.update([0]);
digest.update(account.id.as_bytes());
let id = hex::encode(&digest.finalize()[..16]);
result.push(PeerAccountQuota {
id,
models: account_models,
observed_at,
expires_at,
windows: quota
.into_iter()
.flat_map(|quota| &quota.windows)
.map(|window| PeerQuotaWindow {
key: window.key.clone(),
group: window.group.clone(),
label: window.label.clone(),
limit_id: window.limit_id.clone(),
limit_name: window.limit_name.clone(),
role: window.role,
window_seconds: window.window_seconds,
used_percent: window.used_percent,
reset_at: window.reset_at,
limit_reached: window.limit_reached,
})
.collect(),
});
}
let summaries = models
.iter()
.map(|model| {
let values = observations.remove(model.as_str()).unwrap_or_default();
let complete = values.observed_at.is_some() && !values.incomplete;
let state = if values.available {
PeerModelState::Available
} else if complete {
PeerModelState::Exhausted
} else {
PeerModelState::Unknown
};
PeerModelSummary {
model: model.as_str().to_owned(),
complete,
state,
observed_at: values.observed_at,
expires_at: values.expires_at,
}
})
.collect();
Ok((result, summaries))
}
#[derive(Clone, Copy)]
struct ModelObservation {
state: PeerModelState,
observed_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
}
#[derive(Default)]
struct ModelObservations {
incomplete: bool,
available: bool,
observed_at: Option<DateTime<Utc>>,
expires_at: Option<DateTime<Utc>>,
}
impl ModelObservations {
fn observe(&mut self, observation: Option<ModelObservation>) {
let Some(observation) = observation else {
self.incomplete = true;
return;
};
self.available |= observation.state == PeerModelState::Available;
self.observed_at = Some(self.observed_at.map_or(observation.observed_at, |current| {
current.min(observation.observed_at)
}));
self.expires_at = Some(self.expires_at.map_or(observation.expires_at, |current| {
current.min(observation.expires_at)
}));
}
}
fn model_observation(
account: &AccountRecord,
runtime: &AccountRuntimeSnapshot,
now: DateTime<Utc>,
) -> Option<ModelObservation> {
if !matches!(
status(account, runtime, now),
AccountStatus::Normal | AccountStatus::QuotaExhausted
) {
return None;
}
let state = match account.quota.access() {
QuotaAccessState::Allowed => PeerModelState::Available,
QuotaAccessState::Exhausted => PeerModelState::Exhausted,
QuotaAccessState::Unknown => return None,
};
let observed_at = DateTime::<Utc>::from(account.quota.observed_at()?);
let expires_at = account
.quota
.reset_at()
.map(DateTime::<Utc>::from)
.map_or(observed_at + TimeDelta::minutes(30), |reset| {
reset.min(observed_at + TimeDelta::minutes(30))
})
.min(now + TimeDelta::minutes(5));
if observed_at > now || expires_at <= now {
return None;
}
Some(ModelObservation {
state,
observed_at,
expires_at,
})
}
fn status(
account: &AccountRecord,
runtime: &AccountRuntimeSnapshot,
now: DateTime<Utc>,
) -> AccountStatus {
resolve_account_status(
&AccountStatusFacts {
enabled: account.enabled,
credential_state: account.credential_state,
access_token_expires_at: account.access_token_expires_at.map(Into::into),
quota: account.quota,
cooldown: runtime.cooldown.get(&account.id).copied(),
last_error_reason: account.last_error_reason,
last_error_message: None,
},
now.into(),
)
.status
}
@@ -0,0 +1,46 @@
//! 互联只读查询的短期限流,不占用推理请求配额
use super::PeerLinkError;
use gateway_core::policy::ClientApiKeyId;
use std::{
collections::{BTreeMap, VecDeque},
sync::Mutex,
time::{Duration, Instant},
};
const WINDOW: Duration = Duration::from_secs(60);
const REQUESTS_PER_WINDOW: usize = 30;
#[derive(Default)]
pub(super) struct QueryRateLimit(Mutex<BTreeMap<ClientApiKeyId, VecDeque<Instant>>>);
impl QueryRateLimit {
pub(super) fn acquire(&self, id: &ClientApiKeyId) -> Result<(), PeerLinkError> {
let now = Instant::now();
let mut entries = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
entries.retain(|_, requests| {
while requests
.front()
.is_some_and(|time| now.duration_since(*time) >= WINDOW)
{
requests.pop_front();
}
!requests.is_empty()
});
let requests = entries.entry(id.clone()).or_default();
if requests.len() >= REQUESTS_PER_WINDOW {
let retry_after = requests.front().map_or(1, |time| {
WINDOW
.saturating_sub(now.duration_since(*time))
.as_secs()
.saturating_add(1)
});
return Err(PeerLinkError::RateLimited { retry_after });
}
requests.push_back(now);
Ok(())
}
}
@@ -0,0 +1,133 @@
//! 账号查询合并原生额度与公共互联投影,保持列表摘要和详情窗口边界
use crate::{
model::{
accounts::AccountRecord,
provider_credentials::{ProviderQuota, ProviderQuotaReadRequest, ProviderQuotaRequest},
},
ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
};
use gateway_core::account::{CredentialRevision, ProviderAccountId, UpstreamConnection};
fn connection(account: &AccountRecord) -> Result<UpstreamConnection, ProviderAdminError> {
let invalid = || ProviderAdminError::new(ProviderAdminErrorKind::Invalid);
Ok(UpstreamConnection::new(
ProviderAccountId::new(account.id.clone()).map_err(|_| invalid())?,
CredentialRevision::new(account.credential_revision.get()).map_err(|_| invalid())?,
account.effective_outbound_proxy(),
))
}
pub(crate) fn store_error(error: gateway_core::error::StoreError) -> ProviderAdminError {
use gateway_core::error::StoreErrorKind;
let kind = match error.kind() {
StoreErrorKind::Conflict => ProviderAdminErrorKind::Conflict,
StoreErrorKind::InvalidData | StoreErrorKind::InvalidState => {
ProviderAdminErrorKind::NotFound
}
StoreErrorKind::Unavailable => ProviderAdminErrorKind::Unavailable,
_ => ProviderAdminErrorKind::Internal,
};
ProviderAdminError::new(kind).with_source(error)
}
pub(crate) async fn read_quota(
peer: Option<&dyn crate::ports::peer::PeerUpstreamService>,
provider: &dyn ProviderAdmin,
account: &AccountRecord,
request: ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError> {
if provider.supports_peer_connection(&account.authentication_kind)
&& let Some(peer) = peer
{
if request.refresh {
return peer.refresh_quota(&request.account_id).await;
}
return peer
.read_quota(&request.account_id)
.await
.map(|quota| quota.unwrap_or_else(ProviderQuota::default))
.map_err(store_error);
}
provider.quota(request).await
}
pub(crate) async fn read_quotas(
peer: Option<&dyn crate::ports::peer::PeerUpstreamService>,
provider: &dyn ProviderAdmin,
requests: &[ProviderQuotaReadRequest<'_>],
) -> Vec<Result<ProviderQuota, ProviderAdminError>> {
let mut results = provider.read_quotas(requests).await;
let Some(peer) = peer else {
return results;
};
if results.len() != requests.len() {
return results;
}
let indices = requests
.iter()
.enumerate()
.filter_map(|(index, request)| {
provider
.supports_peer_connection(&request.account.authentication_kind)
.then_some(index)
})
.collect::<Vec<_>>();
let queries = indices
.iter()
.map(|&index| ProviderQuotaReadRequest {
account: requests[index].account,
rolling_usage: requests[index].rolling_usage,
})
.collect::<Vec<_>>();
for (index, quota) in indices.into_iter().zip(peer.read_quotas(&queries).await) {
results[index] = quota;
}
results
}
/// 列表只加载轻量摘要,连接身份过期时丢弃旧观测
pub(super) async fn read_peer_quotas(
service: &super::PeerInfoService,
requests: &[ProviderQuotaReadRequest<'_>],
) -> Vec<Result<ProviderQuota, ProviderAdminError>> {
let connections = requests
.iter()
.map(|request| connection(request.account))
.collect::<Vec<_>>();
let queries = connections
.iter()
.zip(requests)
.filter(|(_, request)| request.account.enabled)
.filter_map(|(connection, _)| connection.as_ref().ok().cloned())
.collect::<Vec<_>>();
if queries.is_empty() {
return connections
.into_iter()
.map(|connection| connection.map(|_| ProviderQuota::default()))
.collect();
}
let summaries = service
.read_summaries(&queries)
.await
.map(|records| {
records
.into_iter()
.map(|(connection, quota)| (connection.account_id.clone(), (connection, quota)))
.collect::<std::collections::BTreeMap<_, _>>()
})
.map_err(store_error);
connections
.into_iter()
.map(|connection| {
connection.and_then(|connection| {
Ok(summaries
.as_ref()
.map_err(Clone::clone)?
.get(&connection.account_id)
.filter(|(observed, _)| *observed == connection)
.map_or_else(ProviderQuota::default, |(_, quota)| quota.clone()))
})
})
.collect()
}
@@ -0,0 +1,143 @@
//! 按账号额度版本缓存互联摘要,完整正文仅在版本变化时分批读取
use super::{
PeerInfoService, scheduling::SchedulingObservation, service::CachedObservation, snapshot,
};
use crate::model::provider_credentials::ProviderQuota;
use gateway_core::{
account::{QuotaObservation, UpstreamConnection},
error::StoreError,
};
use std::{collections::BTreeMap, sync::Arc, time::SystemTime};
impl PeerInfoService {
pub(crate) async fn read_summaries(
&self,
connections: &[UpstreamConnection],
) -> Result<Vec<(UpstreamConnection, ProviderQuota)>, StoreError> {
let _refresh = self.cache_refresh.lock().await;
let ids = connections
.iter()
.map(|connection| connection.account_id.clone())
.collect::<Vec<_>>();
let versions = self
.accounts
.get_quota_versions(&ids)
.await?
.into_iter()
.map(|version| (version.account_id.clone(), version))
.collect::<BTreeMap<_, _>>();
let now = SystemTime::now();
let mut changed = Vec::new();
let mut replaced = Vec::new();
{
let mut observations = self
.observations
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for connection in connections {
let observed_at = versions
.get(&connection.account_id)
.filter(|version| version.credential_revision == connection.credential_revision)
.map(|version| version.observed_at);
if let Some(cached) = observations.get_mut(&connection.account_id)
&& cached.connection == *connection
&& cached.observed_at == observed_at
{
cached.checked_at = now;
} else if observed_at.is_some() {
changed.push(connection.clone());
} else {
replaced.push(observations.insert(
connection.account_id.clone(),
CachedObservation {
connection: connection.clone(),
observed_at: None,
checked_at: now,
scheduling: None,
summary: None,
},
));
}
}
}
drop(replaced);
// 远端窗口可能很大,逐批投影后释放正文,锁内只交换轻量 Arc
for batch in changed.chunks(2) {
let ids = batch
.iter()
.map(|connection| connection.account_id.clone())
.collect::<Vec<_>>();
let observations = self.accounts.get_quotas(&ids).await?;
for connection in batch {
let observation = observations.iter().find(|value| {
value.account_id == connection.account_id
&& value.expected_revision == connection.credential_revision
});
self.cache(connection, observation);
}
}
let summaries = {
let observations = self
.observations
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
connections
.iter()
.map(|connection| {
(
connection.clone(),
observations
.get(&connection.account_id)
.filter(|cached| cached.connection == *connection)
.and_then(|cached| cached.summary.clone()),
)
})
.collect::<Vec<_>>()
};
Ok(summaries
.into_iter()
.map(|(connection, peer)| {
let quota = Self::project_peer(
&connection,
peer.as_deref(),
self.last_error(&connection),
now,
);
(connection, quota)
})
.collect())
}
pub(super) fn cache(
&self,
connection: &UpstreamConnection,
observation: Option<&QuotaObservation>,
) {
let now = SystemTime::now();
let peer = observation.and_then(|value| snapshot::decode(&value.quota));
let scheduling = peer
.as_ref()
.and_then(|peer| SchedulingObservation::new(peer, now))
.filter(|observation| observation.fresh(now))
.map(Arc::new);
let summary = peer.map(|mut peer| {
peer.quota = Default::default();
peer.models.clear();
Arc::new(peer)
});
let cached = CachedObservation {
connection: connection.clone(),
observed_at: observation.map(|value| value.observed_at),
checked_at: now,
scheduling,
summary,
};
let previous = self
.observations
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(connection.account_id.clone(), cached);
drop(previous);
}
}
@@ -0,0 +1,100 @@
//! 公共 CPR 上游同步、管理投影与调度观测,连接凭据解释由 Provider 提供
mod accounts;
mod cache;
mod projection;
mod quota;
mod refresh;
mod scheduling;
mod service;
mod snapshot;
mod worker;
use std::sync::Arc;
use crate::{
model::provider_credentials::ProviderQuota,
ports::{
peer::{PeerTransport, PeerUpstreamService},
provider::ProviderAdminRegistry,
},
};
pub(crate) use accounts::{read_quota, read_quotas};
use gateway_core::{
account::{ProviderAccountId, ProviderAccountStore, UpstreamInfoSource},
error::StoreError,
task::{WorkerContribution, WorkerDefinitionError},
};
use service::PeerInfoService;
/// 组合根独立注册公共互联 Worker,不属于任何具体 Provider 的任务集合
pub struct PeerUpstreamBundle {
service: Arc<PeerInfoService>,
workers: Vec<WorkerContribution>,
}
impl PeerUpstreamBundle {
pub fn service(&self) -> Arc<dyn PeerUpstreamService> {
self.service.clone()
}
pub fn scheduling_source(&self) -> Arc<dyn UpstreamInfoSource> {
self.service.clone()
}
pub fn take_worker_contributions(&mut self) -> Vec<WorkerContribution> {
std::mem::take(&mut self.workers)
}
}
pub fn initialize(
accounts: Arc<dyn ProviderAccountStore>,
providers: &ProviderAdminRegistry,
transport: Arc<dyn PeerTransport>,
) -> Result<PeerUpstreamBundle, WorkerDefinitionError> {
let service = Arc::new(PeerInfoService::new(accounts, providers, transport));
let worker = service.worker()?;
Ok(PeerUpstreamBundle {
service,
workers: vec![worker],
})
}
#[async_trait::async_trait]
impl PeerUpstreamService for PeerInfoService {
async fn account_changed(&self, id: &ProviderAccountId) -> Result<(), StoreError> {
PeerInfoService::account_changed(self, id).await
}
fn forget(&self, id: &ProviderAccountId) {
PeerInfoService::forget(self, id);
}
async fn refresh_quota(
&self,
id: &ProviderAccountId,
) -> Result<ProviderQuota, crate::ports::provider::ProviderAdminError> {
PeerInfoService::refresh(self, id, true).await
}
async fn read_quota(
&self,
account_id: &ProviderAccountId,
) -> Result<Option<ProviderQuota>, StoreError> {
PeerInfoService::read_quota(self, account_id).await
}
async fn read_summary(
&self,
account_id: &ProviderAccountId,
) -> Result<Option<ProviderQuota>, StoreError> {
PeerInfoService::read_summary(self, account_id).await
}
async fn read_quotas(
&self,
requests: &[crate::model::provider_credentials::ProviderQuotaReadRequest<'_>],
) -> Vec<Result<ProviderQuota, crate::ports::provider::ProviderAdminError>> {
accounts::read_peer_quotas(self, requests).await
}
}
@@ -0,0 +1,205 @@
//! 将已保存的互联观测投影为管理端额度与同步事实
use std::time::SystemTime;
use crate::model::{
provider_credentials::{
ProviderQuota, ProviderQuotaWindow, ProviderQuotaWindowRole, QuotaLocalUsageAttribution,
},
upstream_info::{
ProviderUpstreamInfo, UpstreamPlanCount, UpstreamPool, UpstreamPoolStates,
UpstreamQuotaAmount, UpstreamQuotaWindow,
},
};
use gateway_core::account::{QuotaAccessState, UpstreamConnection, UpstreamInfoFailure};
use gateway_protocol::peer::{
PeerComponentStatus, PeerLink, PeerPoolDisclosure, PeerQuotaDisclosure,
};
use super::{PeerInfoService, quota, snapshot};
impl PeerInfoService {
pub(super) fn project_peer(
connection: &UpstreamConnection,
peer: Option<&PeerLink>,
last_error: Option<UpstreamInfoFailure>,
now: SystemTime,
) -> ProviderQuota {
let observed_at = peer
.as_ref()
.and_then(|peer| snapshot::datetime(&peer.observed_at));
let stale = peer.as_ref().is_none_or(|peer| {
!snapshot::component_fresh(Some(&peer.observed_at), Some(&peer.expires_at), now)
});
let info = ProviderUpstreamInfo {
discovered: peer.is_some(),
observed_at,
stale,
connection_revision: connection.revision.clone(),
last_error: last_error.map(|error| error.as_str().to_owned()),
pool: peer
.as_ref()
.filter(|peer| peer.pool.disclosure == PeerPoolDisclosure::Summary)
.and_then(|peer| {
let states = peer.pool.states.as_ref()?;
Some(UpstreamPool {
account_count: peer.pool.account_count?,
states: UpstreamPoolStates {
ready: states.ready,
cooldown: states.cooldown,
exhausted: states.exhausted,
disabled: states.disabled,
error: states.error,
unknown: states.unknown,
},
plans: peer
.pool
.plans
.as_deref()
.unwrap_or_default()
.iter()
.map(|plan| UpstreamPlanCount {
name: plan.name.clone(),
count: plan.count,
})
.collect(),
})
}),
};
let mut windows = Vec::new();
if let Some(peer) = &peer {
if peer.usage.status == PeerComponentStatus::Available
&& let Some(usage) = &peer.usage.data
{
for (key, label, seconds, window) in [
("daily", "日额度", 86400, &usage.daily),
("weekly", "周额度", 604800, &usage.weekly),
] {
// 不限额仍有日、周账本,只是不计算占用百分比
let total = window
.total
.as_deref()
.and_then(snapshot::decimal)
.filter(|value| *value > 0.0);
let used_percent = snapshot::decimal(&window.used)
.zip(total)
.map(|(used, total)| (used / total * 100.0).clamp(0.0, 100.0));
let window_observed = peer
.usage
.observed_at
.as_deref()
.and_then(snapshot::datetime);
let window_stale = stale
|| !peer
.usage
.observed_at
.as_deref()
.is_some_and(|value| snapshot::fresh(value, now))
|| window
.resets_at
.as_deref()
.and_then(snapshot::timestamp)
.is_some_and(|reset| reset <= now);
windows.push(ProviderQuotaWindow {
upstream: Some(UpstreamQuotaWindow {
account_id: None,
observed_at: window_observed,
stale: window_stale,
amount: Some(UpstreamQuotaAmount {
unit: usage.unit.clone(),
used: window.used.clone(),
total: window.total.clone(),
remaining: window.remaining.clone(),
}),
}),
key: format!("peer:budget:{key}"),
group: "general".to_owned(),
label: label.to_owned(),
limit_id: None,
limit_name: None,
role: Some(if key == "weekly" {
ProviderQuotaWindowRole::Secondary
} else {
ProviderQuotaWindowRole::Primary
}),
// Key 消费窗口覆盖此连接的全部请求,本机用量按同一时间边界聚合
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(seconds),
used_percent,
reset_at: window.resets_at.as_deref().and_then(snapshot::datetime),
limit_reached: !window_stale
&& quota::window_access(window, now) == QuotaAccessState::Exhausted,
local_usage: None,
provider_data: None,
});
}
}
// 池内窗口各自保留账号归属,不能将不同套餐相加为统一消费额度
if peer.quota.disclosure == PeerQuotaDisclosure::Accounts {
for (index, account) in peer.quota.accounts.iter().enumerate() {
let window_observed =
account.observed_at.as_deref().and_then(snapshot::datetime);
let window_stale = stale
|| !snapshot::component_fresh(
account.observed_at.as_deref(),
account.expires_at.as_deref(),
now,
);
for window in &account.windows {
let window_stale = window_stale
|| window
.reset_at
.as_deref()
.and_then(snapshot::timestamp)
.is_some_and(|reset| reset <= now);
let scope = format!(
"peer:{}:{}:{}",
account.id.len(),
account.id,
window.limit_id.as_deref().unwrap_or(&window.group)
);
windows.push(ProviderQuotaWindow {
upstream: Some(UpstreamQuotaWindow {
account_id: Some(account.id.clone()),
observed_at: window_observed,
stale: window_stale,
amount: None,
}),
key: format!("{scope}:{}", window.key),
group: window.group.clone(),
label: window.label.clone(),
limit_id: Some(scope),
limit_name: Some(match window.limit_name.as_deref() {
Some(name) => format!("账号 {} · {name}", index + 1),
None => format!("账号 {}", index + 1),
}),
role: match window.role.as_deref() {
Some("primary") => Some(ProviderQuotaWindowRole::Primary),
Some("secondary") => Some(ProviderQuotaWindowRole::Secondary),
Some("monthly") => Some(ProviderQuotaWindowRole::Monthly),
_ => None,
},
local_usage_attribution: QuotaLocalUsageAttribution::Unavailable,
window_seconds: window.window_seconds,
used_percent: window.used_percent,
reset_at: window.reset_at.as_deref().and_then(snapshot::datetime),
limit_reached: !window_stale && window.limit_reached,
local_usage: None,
provider_data: None,
});
}
}
}
}
ProviderQuota {
upstream: Some(info),
plan_type: None,
observed_at,
refresh_token_expires_at: None,
windows,
credits: None,
limit_reached: false,
provider_data: None,
}
}
}
@@ -0,0 +1,79 @@
//! 消费互联明确的额度访问结论,金额只由展示层解释
use std::time::SystemTime;
use gateway_core::account::{QuotaAccessState, QuotaEvidence, QuotaState};
use gateway_protocol::peer::{PeerBudgetWindow, PeerComponentStatus, PeerKeyUsage, PeerLink};
use super::snapshot::{component_fresh, fresh, timestamp};
/// 原始时间与请求开始时间共同限制观测,迟到响应不能覆盖请求期间的新失败
pub(super) fn observe(
peer: &PeerLink,
started_at: SystemTime,
now: SystemTime,
) -> Option<QuotaState> {
if !fresh(&peer.observed_at, now)
|| !component_fresh(Some(&peer.observed_at), Some(&peer.expires_at), now)
|| peer.usage.status != PeerComponentStatus::Available
|| !component_fresh(peer.usage.observed_at.as_deref(), None, now)
{
return None;
}
let observed_at = started_at
.min(timestamp(&peer.observed_at)?)
.min(timestamp(peer.usage.observed_at.as_deref()?)?);
let usage = peer.usage.data.as_ref()?;
match access(usage, now) {
QuotaAccessState::Allowed => Some(QuotaState::allowed(observed_at)),
QuotaAccessState::Exhausted => {
let reset_at = [&usage.daily, &usage.weekly]
.into_iter()
.filter(|window| window_access(window, now) == QuotaAccessState::Exhausted)
.filter_map(|window| window.resets_at.as_deref().and_then(timestamp))
.min();
Some(QuotaState::exhausted(
if usage.allowed == Some(false) {
QuotaEvidence::ProviderDenied
} else {
QuotaEvidence::AccountLimitReached
},
observed_at,
reset_at,
))
}
QuotaAccessState::Unknown => None,
}
}
/// 与 OAuth 相同,明确的 allowed 优先,窗口耗尽仅补充缺失的访问结论
pub(super) fn access(usage: &PeerKeyUsage, now: SystemTime) -> QuotaAccessState {
match usage.allowed {
Some(true) => QuotaAccessState::Allowed,
Some(false) => QuotaAccessState::Exhausted,
None if [&usage.daily, &usage.weekly]
.into_iter()
.any(|window| window_access(window, now) == QuotaAccessState::Exhausted) =>
{
QuotaAccessState::Exhausted
}
None => QuotaAccessState::Unknown,
}
}
/// 过期窗口保持未知,不从重置时间或金额推断恢复
pub(super) fn window_access(window: &PeerBudgetWindow, now: SystemTime) -> QuotaAccessState {
if window
.resets_at
.as_deref()
.and_then(timestamp)
.is_some_and(|reset| reset <= now)
{
return QuotaAccessState::Unknown;
}
match window.limit_reached {
Some(true) => QuotaAccessState::Exhausted,
Some(false) => QuotaAccessState::Allowed,
None => QuotaAccessState::Unknown,
}
}
@@ -0,0 +1,212 @@
//! 互联请求的运行期节流、取消恢复与结果提交,协议判断留在 Admin
use super::{PeerInfoService, accounts::store_error, quota, snapshot};
use crate::{
model::provider_credentials::ProviderQuota,
ports::provider::{ProviderAdminError, ProviderAdminErrorKind},
};
use gateway_core::account::{
OpaqueProviderData, ProviderAccountId, QuotaObservation, QuotaState, QuotaWriteOutcome,
UpstreamConnection, UpstreamInfoFailure,
};
use std::{
sync::Arc,
time::{Duration, SystemTime},
};
use tokio::time::Instant;
pub(super) struct RefreshState {
connection: UpstreamConnection,
next_attempt: Instant,
manual_after: Instant,
last_error: Option<UpstreamInfoFailure>,
}
impl PeerInfoService {
fn refresh_state(
&self,
connection: &UpstreamConnection,
) -> Arc<tokio::sync::Mutex<RefreshState>> {
self.attempts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.entry(connection.account_id.clone())
.or_insert_with(|| {
Arc::new(tokio::sync::Mutex::new(RefreshState {
connection: connection.clone(),
next_attempt: Instant::now(),
manual_after: Instant::now(),
last_error: None,
}))
})
.clone()
}
pub(super) fn refresh_due(&self, connection: &UpstreamConnection) -> bool {
let state = self.refresh_state(connection);
state.try_lock().is_ok_and(|state| {
state.connection != *connection || Instant::now() >= state.next_attempt
})
}
pub(super) fn last_error(
&self,
connection: &UpstreamConnection,
) -> Option<UpstreamInfoFailure> {
let state = self
.attempts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&connection.account_id)?
.clone();
state
.try_lock()
.ok()
.filter(|state| state.connection == *connection)
.and_then(|state| state.last_error)
}
pub(crate) async fn refresh(
&self,
id: &ProviderAccountId,
manual: bool,
) -> Result<ProviderQuota, ProviderAdminError> {
// 首查、手动与周期刷新共享容量;排队后再读凭据,避免使用等待期间失效的连接
let _permit = self
.refresh_slots
.acquire()
.await
.map_err(|_| ProviderAdminError::new(ProviderAdminErrorKind::Unavailable))?;
let loaded = self
.accounts
.load_current_credential(id)
.await
.map_err(store_error)?;
let connection = UpstreamConnection::for_account(&loaded.account);
if !loaded.account.enabled() {
return Err(ProviderAdminError::new(ProviderAdminErrorKind::Conflict));
}
let authentication = self
.connection(&loaded)
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))?;
let slot = self.refresh_state(&connection);
let mut state = slot.lock().await;
if state.connection == connection
&& Instant::now()
< if manual {
state.manual_after
} else {
state.next_attempt
}
{
let error = state.last_error;
drop(state);
if let Some(error) = error {
return Err(refresh_error(error));
}
return self
.read_quota(id)
.await
.map_err(store_error)
.map(Option::unwrap_or_default);
}
if state.connection != connection {
state.last_error = None;
}
state.connection = connection.clone();
state.next_attempt = Instant::now() + Duration::from_secs(30);
state.manual_after = state.next_attempt;
let started_at = SystemTime::now();
let (result, retry_after) =
match self.transport.fetch(&loaded.account, &authentication).await {
Ok(bytes) => (snapshot::parse(&bytes, SystemTime::now()), None),
Err(failure) => (Err(failure.kind), failure.retry_after),
};
let now = SystemTime::now();
let failure = result.as_ref().err().copied();
let delay = match failure {
None => Duration::from_secs(240 + stable_jitter(id.as_str())),
Some(UpstreamInfoFailure::Unauthorized | UpstreamInfoFailure::Unsupported) => {
Duration::from_secs(1800)
}
Some(_) => Duration::from_secs(30),
}
.max(retry_after.unwrap_or_default());
state.next_attempt = Instant::now() + delay;
state.manual_after = Instant::now()
+ if failure.is_some() {
delay
} else {
Duration::from_secs(15)
};
state.last_error = failure;
let document = match result {
Ok(document) => Some(document),
// 空观测清除已撤销内容,并用共有观察时间阻止更早请求重新写回
Err(UpstreamInfoFailure::Unauthorized) => {
Some(OpaqueProviderData::new(Default::default()))
}
Err(_) => None,
};
if let Some(document) = document {
let access = snapshot::decode(&document)
.and_then(|peer| quota::observe(&peer, started_at, now))
.unwrap_or(QuotaState::unknown());
let observation = QuotaObservation {
expected_connection: Some(connection.clone()),
account_id: id.clone(),
expected_revision: connection.credential_revision,
quota: document,
plan_type: None,
observed_at: started_at,
state: access,
};
if self
.accounts
.compare_and_swap_quota(observation)
.await
.map_err(store_error)?
== QuotaWriteOutcome::Conflict
{
return Err(ProviderAdminError::new(ProviderAdminErrorKind::Conflict));
}
// 与列表 hydration 串行,避免本地缓存发布旧版本覆盖新版本
let _refresh = self.cache_refresh.lock().await;
let observations = self
.accounts
.get_quotas(std::slice::from_ref(id))
.await
.map_err(store_error)?;
self.cache(
&connection,
observations.iter().find(|observation| {
observation.expected_revision == connection.credential_revision
}),
);
}
drop(state);
if let Some(error) = failure {
return Err(refresh_error(error));
}
self.read_quota(id)
.await
.map_err(store_error)
.map(Option::unwrap_or_default)
}
}
fn stable_jitter(id: &str) -> u64 {
id.bytes().fold(0_u64, |hash, byte| {
hash.wrapping_mul(31).wrapping_add(u64::from(byte))
}) % 60
}
fn refresh_error(error: UpstreamInfoFailure) -> ProviderAdminError {
ProviderAdminError::new(ProviderAdminErrorKind::BadGateway).with_public_message(match error {
UpstreamInfoFailure::Unauthorized => "上游未授权读取互联信息",
UpstreamInfoFailure::Unsupported => "上游不支持互联信息查询",
UpstreamInfoFailure::RateLimited => "上游互联查询已限流",
UpstreamInfoFailure::InvalidResponse => "上游互联响应无效",
UpstreamInfoFailure::Unavailable => "上游互联服务暂不可用",
})
}
@@ -0,0 +1,203 @@
//! 将互联观测压缩为按模型索引的调度事实,保留各层独立的有效期
use std::{collections::HashMap, sync::Arc, time::SystemTime};
use gateway_protocol::peer::{PeerComponentStatus, PeerLink, PeerModelCoverage, PeerModelState};
use super::{quota, snapshot};
use gateway_core::account::{QuotaAccessState, UpstreamSchedulingSignal};
#[derive(Clone, Copy)]
struct Validity {
observed_at: SystemTime,
expires_at: SystemTime,
}
impl Validity {
fn component(observed: Option<&str>, expires: Option<&str>) -> Option<Self> {
let observed_at = observed.and_then(snapshot::timestamp)?;
let expires_at = match expires {
Some(value) => snapshot::timestamp(value)?,
None => observed_at + snapshot::FRESH_FOR,
};
(expires_at <= observed_at + snapshot::MAX_AGE).then_some(Self {
observed_at,
expires_at,
})
}
fn fresh(self, now: SystemTime) -> bool {
self.observed_at <= now + std::time::Duration::from_secs(60) && self.expires_at > now
}
}
struct QuotaWindow {
remaining_percent: f64,
reset_at: Option<SystemTime>,
}
struct AccountQuota {
validity: Validity,
windows: Vec<QuotaWindow>,
}
impl AccountQuota {
fn remaining_percent(&self, now: SystemTime) -> Option<f64> {
if !self.validity.fresh(now) {
return None;
}
self.windows
.iter()
.filter(|window| window.reset_at.is_none_or(|reset| reset > now))
.map(|window| window.remaining_percent)
.reduce(f64::min)
}
}
struct ModelObservation {
validity: Validity,
exhausted: bool,
account: Option<Arc<AccountQuota>>,
}
pub(super) struct SchedulingObservation {
validity: Validity,
exhausted_budgets: Vec<Validity>,
models: HashMap<String, Option<ModelObservation>>,
}
impl SchedulingObservation {
pub(super) fn new(peer: &PeerLink, now: SystemTime) -> Option<Self> {
let validity = Validity::component(Some(&peer.observed_at), Some(&peer.expires_at))?;
let mut exhausted_budgets = Vec::with_capacity(2);
if peer.usage.status == PeerComponentStatus::Available
&& let Some(usage) = &peer.usage.data
&& let Some(usage_validity) =
Validity::component(peer.usage.observed_at.as_deref(), None)
&& quota::access(usage, now) == QuotaAccessState::Exhausted
{
if usage.allowed.is_some() {
exhausted_budgets.push(usage_validity);
} else {
// 窗口提供的补充证据只在各自重置边界之前影响调度评分
for window in [&usage.daily, &usage.weekly] {
if quota::window_access(window, now) == QuotaAccessState::Exhausted {
let mut validity = usage_validity;
if let Some(reset) =
window.resets_at.as_deref().and_then(snapshot::timestamp)
{
validity.expires_at = validity.expires_at.min(reset);
}
exhausted_budgets.push(validity);
}
}
}
}
// 同一账号重复列出模型不增加候选数,多账号覆盖不计算池平均百分比
let mut model_accounts = HashMap::<&str, Option<usize>>::new();
for (index, account) in peer.quota.accounts.iter().enumerate() {
for model in &account.models {
model_accounts
.entry(model)
.and_modify(|candidate| {
if *candidate != Some(index) {
*candidate = None;
}
})
.or_insert(Some(index));
}
}
let mut accounts = HashMap::<usize, Option<Arc<AccountQuota>>>::new();
let mut models = HashMap::with_capacity(peer.models.len());
for model in &peer.models {
models.entry(model.model.clone()).or_insert_with(|| {
if model.coverage != PeerModelCoverage::Complete
|| model.state == PeerModelState::Unknown
{
return None;
}
let validity =
Validity::component(model.observed_at.as_deref(), model.expires_at.as_deref())?;
let account = model_accounts
.get(model.model.as_str())
.copied()
.flatten()
.and_then(|index| {
accounts
.entry(index)
.or_insert_with(|| {
let account = &peer.quota.accounts[index];
let validity = Validity::component(
account.observed_at.as_deref(),
account.expires_at.as_deref(),
)?;
Some(Arc::new(AccountQuota {
validity,
windows: account
.windows
.iter()
.filter_map(|window| {
Some(QuotaWindow {
remaining_percent: 100.0 - window.used_percent?,
reset_at: window
.reset_at
.as_deref()
.and_then(snapshot::timestamp),
})
})
.collect(),
}))
})
.clone()
});
Some(ModelObservation {
validity,
exhausted: model.state == PeerModelState::Exhausted,
account,
})
});
}
Some(Self {
validity,
exhausted_budgets,
models,
})
}
pub(super) fn fresh(&self, now: SystemTime) -> bool {
self.validity.fresh(now)
&& now
.duration_since(self.validity.observed_at)
.unwrap_or_default()
<= snapshot::FRESH_FOR
}
pub(super) fn signal(&self, model: &str, now: SystemTime) -> Option<UpstreamSchedulingSignal> {
if !self.fresh(now) {
return None;
}
if self
.exhausted_budgets
.iter()
.any(|validity| validity.fresh(now))
{
return Some(UpstreamSchedulingSignal {
exhausted: true,
remaining_percent: None,
});
}
let model = self.models.get(model)?.as_ref()?;
if !model.validity.fresh(now) {
return None;
}
Some(UpstreamSchedulingSignal {
exhausted: model.exhausted,
remaining_percent: model
.account
.as_ref()
.and_then(|account| account.remaining_percent(now)),
})
}
}
@@ -0,0 +1,303 @@
//! 互联额度读取与后台刷新,复用账号额度存储和 Host Worker 生命周期
use super::{refresh::RefreshState, scheduling::SchedulingObservation, snapshot};
use crate::{
model::provider_credentials::ProviderQuota,
ports::{
peer::{PeerConnection, PeerTransport},
provider::{ProviderAdmin, ProviderAdminRegistry},
},
};
use futures::{StreamExt as _, stream};
use gateway_core::{
account::{
LoadedCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
UpstreamConnection, UpstreamInfoSource, UpstreamSchedulingSignal,
},
error::{StoreError, StoreErrorKind},
routing::ProviderKind,
task::WorkerCycleContext,
};
use gateway_protocol::peer::PeerLink;
use std::{
collections::{BTreeMap, HashMap},
sync::{Arc, Mutex, RwLock},
time::SystemTime,
};
use tokio::sync::Semaphore;
const REFRESH_CONCURRENCY: usize = 4;
pub(super) struct CachedObservation {
pub connection: UpstreamConnection,
pub observed_at: Option<SystemTime>,
pub checked_at: SystemTime,
pub scheduling: Option<Arc<SchedulingObservation>>,
pub summary: Option<Arc<PeerLink>>,
}
pub(crate) struct PeerInfoService {
pub(super) accounts: Arc<dyn ProviderAccountStore>,
providers: BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>>,
pub(super) transport: Arc<dyn PeerTransport>,
pub(super) attempts: Mutex<HashMap<ProviderAccountId, Arc<tokio::sync::Mutex<RefreshState>>>>,
cursors: Mutex<BTreeMap<ProviderKind, usize>>,
pub(super) cache_refresh: tokio::sync::Mutex<()>,
pub(super) refresh_slots: Semaphore,
pub(super) observations: RwLock<HashMap<ProviderAccountId, CachedObservation>>,
}
impl PeerInfoService {
pub(crate) fn new(
accounts: Arc<dyn ProviderAccountStore>,
providers: &ProviderAdminRegistry,
transport: Arc<dyn PeerTransport>,
) -> Self {
Self {
accounts,
providers: providers.registered(),
transport,
attempts: Mutex::default(),
cursors: Mutex::default(),
cache_refresh: tokio::sync::Mutex::new(()),
refresh_slots: Semaphore::new(REFRESH_CONCURRENCY),
observations: RwLock::default(),
}
}
pub(crate) async fn account_changed(&self, id: &ProviderAccountId) -> Result<(), StoreError> {
let has_observation = self
.observations
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains_key(id);
let has_attempt = self
.attempts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains_key(id);
if !has_observation && !has_attempt {
return Ok(());
}
let Some(account) = self
.accounts
.get_account(id)
.await?
.filter(|account| self.supported(account))
else {
self.forget(id);
return Ok(());
};
let connection = UpstreamConnection::for_account(&account);
// 普通信息编辑保留观测与退避,连接变化由刷新状态中的身份比较处理
let mut observations = self
.observations
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if observations
.get(id)
.is_some_and(|cached| cached.connection != connection)
{
observations.remove(id);
}
Ok(())
}
pub(crate) fn forget(&self, id: &ProviderAccountId) {
let previous = self
.observations
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(id);
drop(previous);
// 正在执行的请求继续持有同一把锁,结果仍由数据库版本校验决定是否接纳
let mut attempts = self
.attempts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if attempts
.get(id)
.is_some_and(|state| Arc::strong_count(state) == 1)
{
attempts.remove(id);
}
}
pub(super) fn connection(&self, loaded: &LoadedCredential) -> Option<PeerConnection> {
self.providers
.get(loaded.account.provider())?
.peer_connection(loaded)
.ok()
.flatten()
}
fn supported(&self, account: &ProviderAccount) -> bool {
account.enabled()
&& self
.providers
.get(account.provider())
.is_some_and(|provider| {
provider.supports_peer_connection(account.authentication_kind())
})
}
async fn current_connection(
&self,
id: &ProviderAccountId,
) -> Result<Option<UpstreamConnection>, StoreError> {
let account = self
.accounts
.get_account(id)
.await?
.ok_or_else(|| StoreError::new(StoreErrorKind::InvalidData))?;
Ok(self
.supported(&account)
.then(|| UpstreamConnection::for_account(&account)))
}
pub(crate) async fn read_quota(
&self,
id: &ProviderAccountId,
) -> Result<Option<ProviderQuota>, StoreError> {
let Some(connection) = self.current_connection(id).await? else {
return Ok(None);
};
let observation = self
.accounts
.get_quotas(std::slice::from_ref(id))
.await?
.into_iter()
.find(|value| value.expected_revision == connection.credential_revision);
let peer = observation
.as_ref()
.and_then(|value| snapshot::decode(&value.quota));
if self.current_connection(id).await?.as_ref() != Some(&connection) {
return Ok(None);
}
Ok(Some(Self::project_peer(
&connection,
peer.as_ref(),
self.last_error(&connection),
SystemTime::now(),
)))
}
pub(crate) async fn read_summary(
&self,
id: &ProviderAccountId,
) -> Result<Option<ProviderQuota>, StoreError> {
let Some(connection) = self.current_connection(id).await? else {
return Ok(None);
};
Ok(self
.read_summaries(&[connection])
.await?
.into_iter()
.next()
.map(|(_, quota)| quota))
}
pub(super) async fn synchronize(&self, context: &WorkerCycleContext) -> Result<(), StoreError> {
let mut failure = None;
let mut active = std::collections::HashSet::new();
for provider in self.providers.keys() {
if context.cancellation().is_cancelled() {
break;
}
let accounts = match self.accounts.list_for_provider(provider).await {
Ok(accounts) => accounts,
Err(error) => {
failure.get_or_insert(error);
continue;
}
};
let connections = accounts
.iter()
.filter(|account| self.supported(account))
.map(UpstreamConnection::for_account)
.collect::<Vec<_>>();
active.extend(
connections
.iter()
.map(|connection| connection.account_id.clone()),
);
let connections = {
let mut cursors = self
.cursors
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let offset = cursors.entry(provider.clone()).or_default();
if *offset >= connections.len() {
*offset = 0;
}
let batch = connections
.iter()
.skip(*offset)
.take(100)
.cloned()
.collect::<Vec<_>>();
*offset += batch.len();
batch
};
if let Err(error) = self.read_summaries(&connections).await {
failure.get_or_insert(error);
continue;
}
// 每轮有界并发,运行期到期记录在请求开始时预留,取消后可按普通重试恢复
let pending = connections
.into_iter()
.filter(|connection| self.refresh_due(connection))
.take(100)
.collect::<Vec<_>>();
stream::iter(pending).map(|connection| async move {
if let Err(error) = self.refresh(&connection.account_id, false).await {
tracing::warn!(account_id = %connection.account_id, error = %error, "Upstream quota refresh failed");
}
}).buffer_unordered(REFRESH_CONCURRENCY).collect::<Vec<_>>().await;
}
// 查询失败时保留该轮无法核对的账号,避免错误风暴清空所有节流记录
if failure.is_none() && !context.cancellation().is_cancelled() {
self.attempts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.retain(|id, state| active.contains(id) || Arc::strong_count(state) > 1);
}
let now = SystemTime::now();
let removed = self
.observations
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.extract_if(|_, cached| {
now.duration_since(cached.checked_at).unwrap_or_default() > snapshot::MAX_AGE
})
.collect::<Vec<_>>();
drop(removed);
failure.map_or(Ok(()), Err)
}
}
impl UpstreamInfoSource for PeerInfoService {
fn signal(
&self,
account: &ProviderAccount,
model: &str,
now: SystemTime,
) -> Option<UpstreamSchedulingSignal> {
if !account.enabled() {
return None;
}
let connection = UpstreamConnection::for_account(account);
let scheduling = {
let observations = self
.observations
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let cached = observations.get(account.id())?;
if cached.connection != connection {
return None;
}
Arc::clone(cached.scheduling.as_ref()?)
};
scheduling.signal(model, now)
}
}
@@ -0,0 +1,236 @@
//! 互联响应的有界校验、能力授权与时间有效性解释
use std::time::{Duration, SystemTime};
use chrono::{DateTime, Utc};
use gateway_core::account::{OpaqueProviderData, UpstreamInfoFailure};
use gateway_protocol::peer::{
MAX_ACCOUNTS, MAX_MODELS, MAX_QUOTA_WINDOWS, OBJECT, PeerLink, PeerPoolDisclosure,
SCHEMA_VERSION,
};
use serde::Deserialize as _;
pub(super) const MAX_AGE: Duration = Duration::from_secs(30 * 60);
pub(super) const FRESH_FOR: Duration = Duration::from_secs(5 * 60);
pub(super) fn timestamp(value: &str) -> Option<SystemTime> {
DateTime::parse_from_rfc3339(value)
.ok()
.map(SystemTime::from)
}
pub(super) fn datetime(value: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(value)
.ok()
.map(|value| value.with_timezone(&Utc))
}
pub(super) fn fresh(observed: &str, now: SystemTime) -> bool {
timestamp(observed).is_some_and(|observed| {
observed <= now + Duration::from_secs(60)
&& now.duration_since(observed).unwrap_or_default() <= FRESH_FOR
})
}
pub(super) fn component_fresh(
observed: Option<&str>,
expires: Option<&str>,
now: SystemTime,
) -> bool {
let Some(observed) = observed.and_then(timestamp) else {
return false;
};
let expiry = match expires {
Some(value) => match timestamp(value) {
Some(expiry) => expiry,
None => return false,
},
None => observed + FRESH_FOR,
};
observed <= now + Duration::from_secs(60) && expiry > now && expiry <= observed + MAX_AGE
}
pub(super) fn decode(data: &OpaqueProviderData) -> Option<PeerLink> {
PeerLink::deserialize(
serde::de::value::MapDeserializer::<_, serde_json::Error>::new(
data.expose_to_provider()
.iter()
.map(|(key, value)| (key.as_str(), value)),
),
)
.ok()
}
pub(super) fn parse(
bytes: &[u8],
now: SystemTime,
) -> Result<OpaqueProviderData, UpstreamInfoFailure> {
let mut peer: PeerLink =
serde_json::from_slice(bytes).map_err(|_| UpstreamInfoFailure::InvalidResponse)?;
let valid_text = |value: &str, max: usize| {
!value.is_empty() && value.len() <= max && !value.chars().any(char::is_control)
};
let observed = timestamp(&peer.observed_at).ok_or(UpstreamInfoFailure::InvalidResponse)?;
let expires = timestamp(&peer.expires_at).ok_or(UpstreamInfoFailure::InvalidResponse)?;
if peer.object != OBJECT
|| peer.schema_version != SCHEMA_VERSION
|| observed > now + Duration::from_secs(60)
|| now.duration_since(observed).unwrap_or_default() > MAX_AGE
|| expires <= observed
|| expires > observed + MAX_AGE
|| peer.capabilities.len() > 32
|| peer
.quota
.accounts
.iter()
.map(|account| account.windows.len())
.sum::<usize>()
> MAX_QUOTA_WINDOWS
|| peer.quota.accounts.len() > MAX_ACCOUNTS
|| peer.models.len() > MAX_MODELS
{
return Err(UpstreamInfoFailure::InvalidResponse);
}
// 未授权能力不能借同名数据块越过逐层公开边界
if !peer.capabilities.iter().any(|value| value == "key_usage") {
peer.usage.data = None;
}
if !peer
.capabilities
.iter()
.any(|value| value == "pool_summary")
{
peer.pool = Default::default();
}
if !peer
.capabilities
.iter()
.any(|value| value == "quota_windows")
{
peer.quota = Default::default();
}
if !peer
.capabilities
.iter()
.any(|value| value == "model_availability")
{
peer.models.clear();
}
if peer.pool.disclosure == PeerPoolDisclosure::Summary {
let count = peer
.pool
.account_count
.ok_or(UpstreamInfoFailure::InvalidResponse)?;
let states = peer
.pool
.states
.as_ref()
.ok_or(UpstreamInfoFailure::InvalidResponse)?;
let state_total = [
states.ready,
states.cooldown,
states.exhausted,
states.disabled,
states.error,
states.unknown,
]
.into_iter()
.map(u64::from)
.sum::<u64>();
if u64::from(count) > MAX_ACCOUNTS as u64 || state_total != u64::from(count) {
return Err(UpstreamInfoFailure::InvalidResponse);
}
if let Some(plans) = &peer.pool.plans
&& (plans.len() > MAX_ACCOUNTS
|| plans
.iter()
.any(|plan| !valid_text(&plan.name, 256) || plan.count > count)
|| plans.iter().map(|plan| u64::from(plan.count)).sum::<u64>() > u64::from(count))
{
return Err(UpstreamInfoFailure::InvalidResponse);
}
}
if let Some(usage) = &peer.usage.data {
if !valid_text(&usage.unit, 16) {
return Err(UpstreamInfoFailure::InvalidResponse);
}
for window in [&usage.daily, &usage.weekly] {
if [
Some(&window.used),
window.total.as_ref(),
window.remaining.as_ref(),
]
.into_iter()
.flatten()
.any(|value| decimal(value).is_none())
|| window
.resets_at
.as_ref()
.is_some_and(|value| timestamp(value).is_none())
{
return Err(UpstreamInfoFailure::InvalidResponse);
}
}
}
for account in &peer.quota.accounts {
if !valid_text(&account.id, 256)
|| account.models.len() > MAX_MODELS
|| account.windows.iter().any(|window| {
!valid_text(&window.key, 256)
|| !valid_text(&window.label, 256)
|| !valid_text(&window.group, 128)
|| window
.limit_id
.as_deref()
.is_some_and(|value| !valid_text(value, 256))
|| window
.limit_name
.as_deref()
.is_some_and(|value| !valid_text(value, 256))
|| window
.used_percent
.is_some_and(|value| !value.is_finite() || !(0.0..=100.0).contains(&value))
|| window
.reset_at
.as_ref()
.is_some_and(|value| timestamp(value).is_none())
})
{
return Err(UpstreamInfoFailure::InvalidResponse);
}
}
for model in &peer.models {
if !valid_text(&model.model, 256)
|| model
.observed_at
.as_deref()
.is_some_and(|value| timestamp(value).is_none())
|| model
.expires_at
.as_deref()
.is_some_and(|value| timestamp(value).is_none())
{
return Err(UpstreamInfoFailure::InvalidResponse);
}
}
match serde_json::to_value(peer) {
Ok(serde_json::Value::Object(object)) => Ok(OpaqueProviderData::new(object)),
_ => Err(UpstreamInfoFailure::InvalidResponse),
}
}
pub(super) fn decimal(value: &str) -> Option<f64> {
if value.is_empty()
|| value.len() > 64
|| value
.bytes()
.any(|byte| !byte.is_ascii_digit() && byte != b'.')
|| value.bytes().filter(|byte| *byte == b'.').count() > 1
{
return None;
}
value
.parse::<f64>()
.ok()
.filter(|value| value.is_finite() && *value >= 0.0)
}
@@ -0,0 +1,50 @@
//! 将互联同步交给 Host 周期监督并响应取消
use std::{sync::Arc, time::Duration};
use futures::future::BoxFuture;
use gateway_core::task::{
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerDefinitionError, WorkerId,
WorkerKind, WorkerLeaseRequest, WorkerRegistration, WorkerRunnable, WorkerSchedule,
WorkerTaskError,
};
use super::PeerInfoService;
impl PeerInfoService {
pub(crate) fn worker(self: &Arc<Self>) -> Result<WorkerContribution, WorkerDefinitionError> {
let id = WorkerId::try_new(WorkerKind::QuotaCatalogHealth, "peer-info")?;
let ttl = Duration::from_secs(120);
let schedule = WorkerSchedule::try_new(
Duration::from_secs(15),
Duration::from_secs(1),
Duration::from_secs(60),
ttl,
Duration::from_secs(30),
)?;
let lease = WorkerLeaseRequest::try_new(id.clone(), ttl)?;
Ok(WorkerContribution::Registration(
WorkerRegistration::try_new(
id,
WorkerRunnable::Scheduled {
schedule,
lease: Some(lease),
task: Box::new(PeerInfoTask(Arc::clone(self))),
},
)?,
))
}
}
struct PeerInfoTask(Arc<PeerInfoService>);
impl ScheduledTask for PeerInfoTask {
fn run_cycle(&self, context: WorkerCycleContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
tokio::select! {
() = context.cancellation().cancelled() => Ok(()),
result = self.0.synchronize(&context) => result.map_err(|error| WorkerTaskError::safe("upstream information synchronization failed").with_source(error)),
}
})
}
}
@@ -29,6 +29,7 @@ pub(crate) struct DefaultPluginAccountAccess {
providers: ProviderAdminRegistry,
accounts: Arc<dyn AccountStore>,
snapshot: Arc<dyn SnapshotControl>,
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
}
impl DefaultPluginAccountAccess {
@@ -37,11 +38,13 @@ impl DefaultPluginAccountAccess {
providers: ProviderAdminRegistry,
accounts: Arc<dyn AccountStore>,
snapshot: Arc<dyn SnapshotControl>,
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
) -> Self {
Self {
providers,
accounts,
snapshot,
upstream_info,
}
}
@@ -51,14 +54,19 @@ impl DefaultPluginAccountAccess {
refresh: bool,
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
let account = self.get_runtime(account_id).await?;
self.provider(&account.provider_kind)?
.quota(crate::model::provider_credentials::ProviderQuotaRequest {
let provider = self.provider(&account.provider_kind)?;
super::peer_link::upstream::read_quota(
self.upstream_info.as_deref(),
provider.as_ref(),
&account,
crate::model::provider_credentials::ProviderQuotaRequest {
account_id: account_id.clone(),
refresh,
rolling_usage: None,
})
.await
.map_err(|error| map_provider_error(error, "plugin quota facts"))
},
)
.await
.map_err(|error| map_provider_error(error, "plugin quota facts"))
}
fn provider(
@@ -165,7 +173,9 @@ impl PluginAccountAccess for DefaultPluginAccountAccess {
.credential
.credential_revision;
publish_credentials_and_observe_quota(
self.upstream_info.as_ref(),
&provider,
&self.accounts,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&account_id),
@@ -208,7 +218,9 @@ impl PluginAccountAccess for DefaultPluginAccountAccess {
.credential_revision
.ok_or_else(|| AdminError::internal("账号保存未返回凭据版本"))?;
publish_credentials_and_observe_quota(
self.upstream_info.as_ref(),
&provider,
&self.accounts,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&result.account_id),
@@ -83,6 +83,7 @@ impl PluginResourceAccess for DefaultPluginResourceAccess {
resource_key,
groups,
NewClientKey {
peer_sharing: Default::default(),
id,
name: command.name,
label: None,
@@ -87,6 +87,7 @@ pub(crate) struct DefaultProxiesService {
probe: Arc<dyn ProxyProbe>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
test_slots: Semaphore,
}
@@ -96,12 +97,14 @@ impl DefaultProxiesService {
probe: Arc<dyn ProxyProbe>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
) -> Self {
Self {
store,
probe,
snapshot,
providers,
upstream_info,
test_slots: Semaphore::new(4),
}
}
@@ -226,19 +229,30 @@ impl ProxiesService for DefaultProxiesService {
.map(|mut account| async {
// 与账号目录一致,缺失套餐时读取已有额度快照;限制并发且不触发上游刷新
let mut cached_quota = None;
if explicit_plan_type(account.plan_type.as_deref()).is_none()
&& let Ok(kind) = ProviderKind::new(account.provider_kind.clone())
if let Ok(kind) = ProviderKind::new(account.provider_kind.clone())
&& let Ok(provider) = self.providers.require(&kind)
&& let Ok(account_id) = ProviderAccountId::new(account.id.clone())
&& let Ok(quota) = provider
.quota(ProviderQuotaRequest {
account_id,
refresh: false,
rolling_usage: None,
})
.await
{
cached_quota = Some(quota);
if provider.supports_peer_connection(&account.authentication_kind)
&& let Some(peer) = self.upstream_info.as_deref()
{
account.upstream = peer
.read_summary(&account_id)
.await
.ok()
.flatten()
.and_then(|quota| quota.upstream);
}
if explicit_plan_type(account.plan_type.as_deref()).is_none() {
cached_quota = provider
.quota(ProviderQuotaRequest {
account_id,
refresh: false,
rolling_usage: None,
})
.await
.ok();
}
}
account.plan_type_display = self.providers.resolve_account_plan(
&account.provider_kind,
@@ -107,6 +107,7 @@ fn provider_document_debug_should_not_expose_opaque_material() {
#[test]
fn representative_quota_should_prefer_short_window_and_highest_usage() {
let quota = ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: None,
@@ -129,6 +130,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
let quota = ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: None,
@@ -167,11 +169,13 @@ fn usage_window_should_not_use_daily_rolling_usage_for_weekly_statistics() {
models: Vec::new(),
};
let quota = ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
upstream: None,
key: "free-rolling-24h".to_owned(),
group: "shortTerm".to_owned(),
label: "日限额".to_owned(),
@@ -196,6 +200,7 @@ fn usage_window_should_not_use_daily_rolling_usage_for_weekly_statistics() {
#[test]
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
let mut quota = ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: None,
@@ -225,6 +230,7 @@ fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
reached.limit_reached = true;
let mut quota = ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: None,
@@ -247,6 +253,7 @@ fn quota_window(
used_percent: Option<f64>,
) -> ProviderQuotaWindow {
ProviderQuotaWindow {
upstream: None,
key: group.to_owned(),
group: group.to_owned(),
label: group.to_owned(),
@@ -86,6 +86,7 @@ fn selected_usage_window(quota: &ProviderQuota) -> Option<(&str, AccountUsagePer
fn usage_quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: None,
@@ -98,6 +99,7 @@ fn usage_quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
fn usage_window(key: &str, group: &str, seconds: u64) -> ProviderQuotaWindow {
ProviderQuotaWindow {
upstream: None,
key: key.to_owned(),
group: group.to_owned(),
label: key.to_owned(),
@@ -136,6 +138,7 @@ fn usage_window(key: &str, group: &str, seconds: u64) -> ProviderQuotaWindow {
#[test]
fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
let mut quota = ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: None,
@@ -148,6 +151,7 @@ fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
assert!(quota.representative_used_percent().is_none());
let rolling = ProviderQuotaWindow {
upstream: None,
key: "provider_rolling".to_owned(),
group: "requests".to_owned(),
label: "Provider rolling window".to_owned(),
@@ -180,6 +184,7 @@ fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
quota.windows.insert(
0,
ProviderQuotaWindow {
upstream: None,
key: "model_specific".to_owned(),
used_percent: Some(100.0),
local_usage_attribution: QuotaLocalUsageAttribution::Unavailable,
@@ -15,6 +15,7 @@ fn now() -> DateTime<Utc> {
fn window(key: &str, days: u64) -> ProviderQuotaWindow {
ProviderQuotaWindow {
upstream: None,
key: key.to_owned(),
group: if days > 7 { "monthly" } else { "shortTerm" }.to_owned(),
label: key.to_owned(),
@@ -58,6 +59,7 @@ fn window(key: &str, days: u64) -> ProviderQuotaWindow {
fn quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: Some(now()),
@@ -279,6 +279,10 @@ impl FakeProviderAdmin {
#[async_trait]
impl ProviderAdmin for FakeProviderAdmin {
fn peer_authentication_kinds(&self) -> &[&str] {
&["api_key"]
}
fn account_capabilities(
&self,
_account_id: &ProviderAccountId,
@@ -733,6 +737,20 @@ impl FakeAccountStore {
#[async_trait]
impl AccountStore for FakeAccountStore {
async fn load_account_record(
&self,
account_id: &ProviderAccountId,
) -> AdminStoreResult<Option<AccountRecord>> {
self.record("store.load_account_record");
Ok(self
.accounts
.lock()
.expect("accounts")
.iter()
.find(|account| account.id == account_id.as_str())
.cloned())
}
async fn account_exists(&self, account_id: &str) -> AdminStoreResult<bool> {
self.record("store.account_exists");
Ok(self
@@ -1473,6 +1491,7 @@ async fn plugin_account_save_publishes_native_credential_change() {
ProviderAdminRegistry::new([provider.clone() as Arc<dyn ProviderAdmin>]).unwrap(),
store.clone(),
snapshot,
None,
);
let result = access
.save(
@@ -1516,6 +1535,7 @@ async fn plugin_account_reads_resolve_the_authoritative_provider_from_the_accoun
ProviderAdminRegistry::new([registered_provider as Arc<dyn ProviderAdmin>]).unwrap(),
store,
Arc::new(RecordingPluginAccountPublication(events.clone())),
None,
);
let account_id = ProviderAccountId::new("acct_test").expect("account ID");
@@ -1563,6 +1583,7 @@ async fn plugin_account_save_rejects_stale_authentication_kind_before_commit() {
ProviderAdminRegistry::new([provider as Arc<dyn ProviderAdmin>]).unwrap(),
store.clone(),
Arc::new(RecordingPluginAccountPublication(events.clone())),
None,
);
let error = access
@@ -1692,9 +1713,11 @@ async fn accounts_recover_disabled_should_only_enable_scheduling_and_preserve_qu
);
}
let quota = ProviderQuota {
upstream: None,
credits: None,
observed_at: Some(Utc::now()),
windows: vec![ProviderQuotaWindow {
upstream: None,
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
@@ -1790,6 +1813,7 @@ async fn accounts_update_should_commit_then_release_disabled_account_and_publish
.update(
&context("update-request"),
UpdateAccount {
name: None,
notes: None,
model_access: Default::default(),
outbound_proxy: None,
@@ -1829,6 +1853,7 @@ async fn accounts_update_should_not_notify_provider_when_store_commit_fails() {
.update(
&context("update-failure"),
UpdateAccount {
name: None,
notes: None,
model_access: Default::default(),
outbound_proxy: None,
@@ -1996,6 +2021,7 @@ async fn accounts_should_fill_missing_plan_from_quota_without_overriding_known_s
] {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
upstream: None,
plan_type: quota_plan.map(str::to_owned),
..empty_quota()
});
@@ -2039,11 +2065,13 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
let events = events();
let openai = FakeProviderAdmin::new("openai", events.clone());
openai.set_quota(ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
upstream: None,
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
@@ -2197,11 +2225,13 @@ async fn accounts_list_should_map_unknown_credential_to_error_not_normal() {
async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view() {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
upstream: None,
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
@@ -2246,11 +2276,13 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view()
async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
upstream: None,
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
@@ -2298,10 +2330,12 @@ async fn quota_forecast_reads_raw_snapshot_and_limits_usage_to_observation_time(
let observed = now - TimeDelta::hours(1);
let reset = now + TimeDelta::days(1);
provider.set_quota(ProviderQuota {
upstream: None,
credits: None,
observed_at: Some(observed),
limit_reached: true,
windows: vec![ProviderQuotaWindow {
upstream: None,
key: "week".to_owned(),
group: "shortTerm".to_owned(),
label: "周额度".to_owned(),
@@ -2390,6 +2424,7 @@ async fn quota_forecast_only_queries_the_two_selected_source_windows() {
.map(|index| {
let monthly = index % 2 == 1;
ProviderQuotaWindow {
upstream: None,
key: if monthly {
format!("month-{index}")
} else {
@@ -2411,6 +2446,7 @@ async fn quota_forecast_only_queries_the_two_selected_source_windows() {
})
.collect();
provider.set_quota(ProviderQuota {
upstream: None,
plan_type: Some("pro".to_owned()),
observed_at: Some(observed),
windows,
@@ -2462,9 +2498,11 @@ async fn quota_forecast_same_window_reversal_keeps_recorded_usage_in_the_report(
let reset = now + TimeDelta::days(1);
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
upstream: None,
plan_type: Some("pro".to_owned()),
observed_at: Some(observed),
windows: vec![ProviderQuotaWindow {
upstream: None,
key: "week".to_owned(),
group: "shortTerm".to_owned(),
label: "周额度".to_owned(),
@@ -2540,10 +2578,12 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
let added = now - TimeDelta::hours(5);
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
upstream: None,
credits: None,
plan_type: Some("pro".to_owned()),
observed_at: Some(observed),
windows: vec![ProviderQuotaWindow {
upstream: None,
key: "week".to_owned(),
group: "shortTerm".to_owned(),
label: "周额度".to_owned(),
@@ -2636,6 +2676,156 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
assert_eq!(cycle.estimated_tokens, Some(679));
}
#[tokio::test]
async fn peer_account_usage_should_follow_budget_windows_without_borrowing_pool_windows() {
use gateway_admin::model::upstream_info::{
ProviderUpstreamInfo, UpstreamQuotaAmount, UpstreamQuotaWindow,
};
for longest in [86_400, 604_800] {
let provider = FakeProviderAdmin::new("openai", events());
let mut account = account_record("openai");
account.authentication_kind = "api_key".to_owned();
account.created_at = Utc::now() - TimeDelta::days(60);
let store = FakeAccountStore::with_account(account, events());
let reset_at = Utc::now() + TimeDelta::hours(1);
let mut quota = empty_quota();
quota.upstream = Some(ProviderUpstreamInfo {
discovered: true,
observed_at: Some(Utc::now()),
stale: false,
connection_revision: "1".to_owned(),
last_error: None,
pool: None,
});
quota.windows = [86_400, 604_800, 30 * 86_400]
.into_iter()
.filter(|seconds| *seconds <= longest || *seconds == 30 * 86_400)
.map(|seconds| ProviderQuotaWindow {
upstream: Some(UpstreamQuotaWindow {
account_id: None,
observed_at: Some(Utc::now()),
stale: false,
amount: (seconds <= longest).then(|| UpstreamQuotaAmount {
unit: "USD".to_owned(),
used: "1".to_owned(),
remaining: Some("9".to_owned()),
total: Some("10".to_owned()),
}),
}),
key: seconds.to_string(),
group: "general".to_owned(),
label: "额度".to_owned(),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: if seconds <= longest {
QuotaLocalUsageAttribution::AccountWide
} else {
QuotaLocalUsageAttribution::Unavailable
},
window_seconds: Some(seconds),
used_percent: Some(10.0),
reset_at: Some(reset_at),
limit_reached: false,
local_usage: None,
provider_data: None,
})
.collect();
provider.set_quota(quota.clone());
let window_usage = [86_400, 604_800]
.into_iter()
.filter(|seconds| *seconds <= longest)
.map(|seconds| AccountUsageWindowResult {
account_id: "acct_test".to_owned(),
key: seconds.to_string(),
usage: quota_local_usage("acct_test", if seconds == longest { 1_234 } else { 123 }),
})
.collect::<Vec<_>>();
store.set_quota_window_usage(window_usage.clone());
let services = accounts_service(provider.clone(), store.clone()).await;
let page = services
.accounts()
.list(AccountListQuery {
page: 1,
page_size: gateway_admin::model::PageSize::new(20).unwrap(),
provider_kind: None,
group_filter: None,
search: None,
status: None,
sort: None,
})
.await
.unwrap();
assert_eq!(
page.items[0].usage.as_ref().unwrap().total_tokens,
Some(1_234)
);
let queries = store.quota_window_queries();
assert_eq!(queries.len(), window_usage.len());
for (query, expected) in queries.iter().zip(&window_usage) {
assert_eq!(query.key, expected.key);
assert_eq!(
query.range.start,
reset_at - TimeDelta::seconds(query.key.parse().unwrap())
);
assert_eq!(query.range.end, reset_at);
let window = page.items[0]
.quota
.windows
.iter()
.find(|window| window.key == query.key)
.unwrap();
assert_eq!(window.local_usage.as_ref(), Some(&expected.usage));
assert_eq!(window.used_percent, Some(10.0));
assert_eq!(
window
.upstream
.as_ref()
.unwrap()
.amount
.as_ref()
.unwrap()
.used,
"1"
);
}
assert!(
page.items[0]
.quota
.windows
.last()
.unwrap()
.local_usage
.is_none()
);
let id = ProviderAccountId::new("acct_test").unwrap();
let detail = services.accounts().quota(&id, false).await.unwrap();
assert_eq!(detail.usage, page.items[0].usage);
assert_eq!(detail.quota.windows, page.items[0].quota.windows);
assert_eq!(store.quota_window_queries()[0].range, queries[0].range);
quota
.windows
.iter_mut()
.find(|window| window.window_seconds == Some(longest))
.unwrap()
.reset_at = None;
provider.set_quota(quota);
store.set_quota_window_usage(
window_usage
.into_iter()
.filter(|usage| usage.key != longest.to_string())
.collect(),
);
let unopened = services.accounts().quota(&id, false).await.unwrap();
assert!(
unopened.usage.is_none(),
"未开启的最长窗口不能借用日窗口或历史累计"
);
}
}
#[tokio::test]
async fn api_key_list_and_detail_should_accumulate_local_usage_without_subscription_windows() {
let provider = FakeProviderAdmin::new("openai", events());
@@ -2687,11 +2877,13 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
let provider = FakeProviderAdmin::new("openai", events());
let reset_at = Utc::now() + TimeDelta::hours(1);
provider.set_quota(ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
upstream: None,
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
@@ -2756,6 +2948,7 @@ async fn accounts_list_and_quota_refresh_should_select_the_same_weekly_or_monthl
let account_id = ProviderAccountId::new("acct_test").expect("account id");
let reset_at = Utc::now() + TimeDelta::days(1);
let short = ProviderQuotaWindow {
upstream: None,
key: "short".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
@@ -2771,12 +2964,14 @@ async fn accounts_list_and_quota_refresh_should_select_the_same_weekly_or_monthl
provider_data: None,
};
let week = ProviderQuotaWindow {
upstream: None,
key: "week".to_owned(),
label: "周额度".to_owned(),
window_seconds: Some(7 * 86_400),
..short.clone()
};
let month = ProviderQuotaWindow {
upstream: None,
key: "month".to_owned(),
group: "monthly".to_owned(),
label: "月额度".to_owned(),
@@ -2788,6 +2983,7 @@ async fn accounts_list_and_quota_refresh_should_select_the_same_weekly_or_monthl
(vec![short, month], "month", 3000, 30),
] {
provider.set_quota(ProviderQuota {
upstream: None,
windows,
..empty_quota()
});
@@ -2845,12 +3041,14 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
let provider = FakeProviderAdmin::new("openai", events());
let reset_at = Utc::now() + TimeDelta::days(7);
provider.set_quota(ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![
ProviderQuotaWindow {
upstream: None,
key: "core-primary".to_owned(),
group: "weekly".to_owned(),
label: "周额度".to_owned(),
@@ -2866,6 +3064,7 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
provider_data: None,
},
ProviderQuotaWindow {
upstream: None,
key: "codex-bengalfox-primary".to_owned(),
group: "weekly".to_owned(),
label: "周额度".to_owned(),
@@ -3147,6 +3346,7 @@ pub(super) fn document() -> ProviderDocument {
fn empty_quota() -> ProviderQuota {
ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: None,
@@ -3542,6 +3742,7 @@ fn unsupported() -> ProviderAdminError {
pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImportSettings {
gateway_admin::model::accounts::AccountImportSettings {
name: None,
notes: Some("团队备用".to_owned()),
model_access: Default::default(),
enabled: false,
@@ -3591,6 +3792,7 @@ async fn plugin_quota_refresh_uses_native_provider_without_changing_configuratio
ProviderAdminRegistry::new([provider.clone() as Arc<dyn ProviderAdmin>]).unwrap(),
store,
Arc::new(RecordingPluginAccountPublication(events.clone())),
None,
);
let id = ProviderAccountId::new("acct_test").unwrap();
access.get_quota(&id).await.unwrap();
@@ -726,7 +726,9 @@ async fn threshold_services(
fn weekly_quota(used_percent: f64) -> ProviderQuota {
ProviderQuota {
upstream: None,
windows: vec![ProviderQuotaWindow {
upstream: None,
key: "week".into(),
group: "shortTerm".into(),
label: "周额度".into(),
@@ -870,6 +872,7 @@ async fn automation_defers_missing_invalid_or_failed_weekly_quota() {
(empty_quota(), None),
(
ProviderQuota {
upstream: None,
windows: vec![short, model, month],
..empty_quota()
},
@@ -877,6 +880,7 @@ async fn automation_defers_missing_invalid_or_failed_weekly_quota() {
),
(
ProviderQuota {
upstream: None,
windows: vec![missing_percent],
..empty_quota()
},
@@ -96,6 +96,7 @@ impl ClientKeyStore for TestClientKeyStore {
}
self.plaintexts.lock().unwrap().push(key.plaintext);
let record = ClientKeyRecord {
peer_sharing: key.peer_sharing,
request_profile_overrides: Default::default(),
id: key.id,
name: key.name,
@@ -234,6 +235,7 @@ async fn plugin_client_key_list_projects_only_public_identity_and_uses_stable_na
list_response: Mutex::new(Some(ClientKeyPage {
config_revision: Revision::new(7).expect("revision"),
items: vec![ClientKeyRecord {
peer_sharing: Default::default(),
request_profile_overrides: Default::default(),
id: key_id.clone(),
name: "Public identity".to_owned(),
@@ -370,6 +372,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
.update(
&mutation_context(),
UpdateClientKey {
peer_sharing: None,
request_profile_override_updates: Default::default(),
id: ClientApiKeyId::new("key_existing").unwrap(),
name: "Migration".to_owned(),
@@ -390,6 +393,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
fn create_command(key: Option<&str>) -> CreateClientKey {
CreateClientKey {
peer_sharing: Default::default(),
request_profile_overrides: Default::default(),
custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()),
name: "Migration".to_owned(),
@@ -400,6 +404,53 @@ fn create_command(key: Option<&str>) -> CreateClientKey {
}
}
#[tokio::test]
async fn peer_sharing_requires_explicit_groups_and_independent_plan_authorization_before_writing() {
use gateway_core::policy::ClientPeerSharing;
let store = Arc::new(TestClientKeyStore::default());
let services = super::AdminHarness::new()
.client_keys(store.clone())
.build()
.await;
for sharing in [
ClientPeerSharing {
enabled: true,
..Default::default()
},
ClientPeerSharing {
share_plans: true,
..Default::default()
},
] {
let mut command = create_command(None);
command.peer_sharing = sharing;
let error = services
.client_keys()
.create(&mutation_context(), command)
.await
.unwrap_err();
assert_eq!(error.kind(), AdminErrorKind::Invalid);
}
assert!(store.plaintexts.lock().unwrap().is_empty());
let mut command = create_command(None);
command.peer_sharing = ClientPeerSharing {
enabled: true,
share_pool: true,
share_quota: true,
share_plans: true,
};
command.group_ids.push(
gateway_core::routing::AccountGroupId::new(format!("grp_{}", "1".repeat(32))).unwrap(),
);
let expected = command.peer_sharing;
let result = services
.client_keys()
.create(&mutation_context(), command)
.await
.unwrap();
assert_eq!(result.secret.record.peer_sharing, expected);
}
fn mutation_context() -> MutationContext {
MutationContext {
actor: MutationActor::System,
@@ -436,6 +487,7 @@ async fn duplicate_group_ids_are_rejected_before_key_creation_or_update() {
.update(
&mutation_context(),
UpdateClientKey {
peer_sharing: None,
request_profile_override_updates: Default::default(),
id: ClientApiKeyId::new("key_existing").unwrap(),
name: "Migration".to_owned(),
@@ -1,4 +1,5 @@
//! 按 Provider 组织凭据导入、授权与提交边界测试
mod openai;
mod peer;
mod xai;
@@ -0,0 +1,184 @@
//! 验证凭据提交后的互联首查、原生额度分派与异步失败边界
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use gateway_admin::{
model::provider_credentials::{ImportCredentials, ProviderQuota, ProviderQuotaReadRequest},
ports::{
peer::PeerUpstreamService,
provider::{ProviderAdminError, ProviderAdminErrorKind},
},
};
use gateway_core::{
account::{ProviderAccount, ProviderAccountId, UpstreamInfoSource, UpstreamSchedulingSignal},
error::StoreError,
routing::ProviderKind,
};
use super::super::{
AdminHarness,
accounts::{FakeAccountStore, FakeProviderAdmin, account_record, context, document, events},
};
#[tokio::test]
async fn imported_peer_accounts_should_start_observation_without_waiting_for_network() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider.set_import_account_ids(&["acct_first", "acct_second"]);
let mut first = account_record("openai");
first.id = "acct_first".into();
first.authentication_kind = "api_key".into();
let mut second = first.clone();
second.id = "acct_second".into();
let store = FakeAccountStore::with_account(first.clone(), events);
store.set_accounts(vec![first, second]);
let peer = Arc::new(PendingPeer::default());
let services = AdminHarness::new()
.provider(provider.clone())
.accounts(store)
.upstream_info(peer.clone())
.build()
.await;
let result = tokio::time::timeout(
Duration::from_secs(1),
services
.credentials()
.for_provider(&ProviderKind::new("openai").unwrap())
.unwrap()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-peer"),
document: document(),
}),
)
.await
.expect("import must not wait for peer response")
.expect("import committed");
peer.wait_for_requests(1).await;
assert_eq!(peer.changed.lock().unwrap().len(), 2);
assert_eq!(result.credential_ids.len(), 2);
assert!(provider.quota_requests().is_empty());
peer.gate.add_permits(2);
peer.wait_for_requests(2).await;
assert_eq!(
*peer.requests.lock().unwrap(),
["acct_first", "acct_second"]
);
assert!(provider.quota_requests().is_empty());
}
#[tokio::test]
async fn native_import_should_use_provider_quota_when_peer_service_is_available() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider.set_import_account_ids(&["acct_test"]);
let store = FakeAccountStore::new("openai", events);
let peer = Arc::new(PendingPeer::default());
let services = AdminHarness::new()
.provider(provider.clone())
.accounts(store)
.upstream_info(peer.clone())
.build()
.await;
services
.credentials()
.for_provider(&ProviderKind::new("openai").unwrap())
.unwrap()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-native"),
document: document(),
})
.await
.expect("native import");
provider.wait_for_quota_requests(1).await;
assert!(peer.requests.lock().unwrap().is_empty());
assert_eq!(
provider.quota_requests()[0].account_id.as_str(),
"acct_test"
);
}
struct PendingPeer {
changed: Mutex<Vec<String>>,
requests: Mutex<Vec<String>>,
started: tokio::sync::Notify,
gate: tokio::sync::Semaphore,
}
impl Default for PendingPeer {
fn default() -> Self {
Self {
changed: Mutex::default(),
requests: Mutex::default(),
started: tokio::sync::Notify::new(),
gate: tokio::sync::Semaphore::new(0),
}
}
}
impl PendingPeer {
async fn wait_for_requests(&self, count: usize) {
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let notified = self.started.notified();
if self.requests.lock().unwrap().len() >= count {
return;
}
notified.await;
}
})
.await
.expect("initial peer observation started");
}
}
impl UpstreamInfoSource for PendingPeer {
fn signal(
&self,
_: &ProviderAccount,
_: &str,
_: std::time::SystemTime,
) -> Option<UpstreamSchedulingSignal> {
None
}
}
#[async_trait]
impl PeerUpstreamService for PendingPeer {
async fn account_changed(&self, id: &ProviderAccountId) -> Result<(), StoreError> {
self.changed.lock().unwrap().push(id.as_str().into());
Ok(())
}
fn forget(&self, _: &ProviderAccountId) {}
async fn refresh_quota(
&self,
id: &ProviderAccountId,
) -> Result<ProviderQuota, ProviderAdminError> {
self.requests.lock().unwrap().push(id.as_str().into());
self.started.notify_one();
self.gate.acquire().await.unwrap().forget();
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unavailable))
}
async fn read_quota(&self, _: &ProviderAccountId) -> Result<Option<ProviderQuota>, StoreError> {
panic!("unexpected cached read")
}
async fn read_summary(
&self,
_: &ProviderAccountId,
) -> Result<Option<ProviderQuota>, StoreError> {
panic!("unexpected summary read")
}
async fn read_quotas(
&self,
_: &[ProviderQuotaReadRequest<'_>],
) -> Vec<Result<ProviderQuota, ProviderAdminError>> {
panic!("unexpected batch read")
}
}
@@ -10,6 +10,7 @@ mod credentials;
mod freeze_recovery;
mod import_tasks;
mod observability;
mod peer_link;
mod plugin_update;
mod plugins;
mod proxies;
@@ -111,6 +112,7 @@ pub(super) struct AdminHarness {
plugin_store: Arc<dyn gateway_admin::ports::plugins::PluginStore>,
plugin_inspector: Arc<dyn gateway_admin::ports::plugins::PluginPackageInspector>,
client_key_verifier: Arc<dyn ClientKeyVerifier>,
upstream_info: Option<Arc<dyn gateway_admin::ports::peer::PeerUpstreamService>>,
service_middleware: gateway_admin::public_service::PlanSource,
}
@@ -141,6 +143,7 @@ impl AdminHarness {
plugin_store: Arc::new(plugins::TestPluginPorts),
plugin_inspector: Arc::new(plugins::TestPluginPorts),
client_key_verifier: Arc::new(UnavailableClientKeyVerifier),
upstream_info: None,
service_middleware: Arc::new(|| None),
}
}
@@ -160,6 +163,14 @@ impl AdminHarness {
self
}
pub(super) fn upstream_info(
mut self,
peer: Arc<dyn gateway_admin::ports::peer::PeerUpstreamService>,
) -> Self {
self.upstream_info = Some(peer);
self
}
pub(super) fn account_runtime(mut self, store: Arc<dyn AccountRuntimeStore>) -> Self {
self.account_runtime = store;
self
@@ -286,6 +297,7 @@ impl AdminHarness {
Arc::new(plugins::TestPluginPorts),
),
gateway_admin::AdminRuntimePorts {
upstream_info: self.upstream_info,
timezone: Default::default(),
service_middleware: self.service_middleware,
plugin_preparation: Arc::new(plugins::TestPluginPorts),
@@ -485,6 +497,13 @@ impl AccountGroupStore for UnavailableAccountGroupStore {
#[async_trait]
impl AccountStore for UnavailableStore {
async fn load_account_record(
&self,
_: &ProviderAccountId,
) -> AdminStoreResult<Option<gateway_admin::model::accounts::AccountRecord>> {
Err(unavailable("account"))
}
async fn account_exists(&self, _: &str) -> AdminStoreResult<bool> {
Err(unavailable("account"))
}
@@ -909,6 +928,7 @@ impl SettingsStore for UnavailableStore {
}
struct UnavailableProvider {
peer_enabled: bool,
kind: ProviderKind,
dashboard_profile: Option<DashboardWireProfile>,
calculated_billing: Option<gateway_admin::model::observability::CalculatedBillingBreakdown>,
@@ -931,6 +951,7 @@ impl UnavailableProvider {
fn new(kind: &str) -> Self {
Self {
kind: ProviderKind::new(kind).expect("provider kind"),
peer_enabled: false,
dashboard_profile: None,
calculated_billing: None,
}
@@ -943,6 +964,28 @@ pub(super) fn test_provider(kind: &str) -> Arc<dyn ProviderAdmin> {
#[async_trait]
impl ProviderAdmin for UnavailableProvider {
fn peer_authentication_kinds(&self) -> &[&str] {
if self.peer_enabled {
&["peer_test"]
} else {
&[]
}
}
fn peer_connection(
&self,
loaded: &gateway_core::account::LoadedCredential,
) -> Result<Option<gateway_admin::ports::peer::PeerConnection>, ProviderAdminError> {
Ok(
(self.peer_enabled && loaded.account.authentication_kind() == "peer_test").then(|| {
gateway_admin::ports::peer::PeerConnection {
base_url: format!("https://{}.invalid/v1", self.kind),
bearer_token: secrecy::SecretString::from("test-peer-only"),
}
}),
)
}
fn provider_kind(&self) -> &ProviderKind {
&self.kind
}
@@ -1031,6 +1074,7 @@ impl ProviderAdmin for UnavailableProvider {
pub(super) fn dashboard_profile_provider() -> Arc<dyn ProviderAdmin> {
Arc::new(UnavailableProvider {
peer_enabled: false,
kind: ProviderKind::new("openai").expect("provider kind"),
dashboard_profile: Some(DashboardWireProfile {
provider: "openai".to_owned(),
@@ -1074,6 +1118,7 @@ pub(super) fn calculated_billing_provider() -> Arc<dyn ProviderAdmin> {
.expect("test billing amount"),
};
Arc::new(UnavailableProvider {
peer_enabled: false,
kind: ProviderKind::new("openai").expect("provider kind"),
dashboard_profile: None,
calculated_billing: Some(
@@ -995,6 +995,7 @@ async fn dashboard_should_preserve_provider_plan_names() {
*store.account_usage.lock().unwrap() = plans
.iter()
.map(|(plan, _)| DashboardAccountUsage {
upstream: None,
account_id: format!("account-{plan}"),
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
@@ -1559,6 +1560,7 @@ fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
retry_count: 0,
retried_request_count: 0,
account_provider_kind: None,
account_authentication_kind: None,
account_plan_type: None,
key: name.to_owned(),
name: name.to_owned(),
@@ -0,0 +1,3 @@
//! 互联共享与上游同步用例测试
mod upstream;
@@ -0,0 +1,533 @@
//! 公共互联按 Provider 声明发现连接,不将认证种类或调度观测绑在 OpenAI 上
use super::super::UnavailableProvider;
use async_trait::async_trait;
use chrono::Utc;
use gateway_admin::{
initialize_peer_upstream,
ports::{
peer::{PeerConnection, PeerFetchFailure, PeerTransport},
provider::{ProviderAdmin, ProviderAdminRegistry},
},
};
use gateway_core::{
account::*,
error::{StoreError, StoreErrorKind},
lifecycle::CancellationToken,
routing::ProviderKind,
task::{WorkerContribution, WorkerCycleContext, WorkerRunnable},
};
use std::{
collections::BTreeMap,
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
time::{Duration, SystemTime},
};
#[tokio::test]
async fn manual_and_periodic_refreshes_share_capacity_and_release_it_after_failure_or_cancellation()
{
let providers = ["openai", "xai"].map(|kind| {
let mut provider = UnavailableProvider::new(kind);
provider.peer_enabled = true;
Arc::new(provider) as Arc<dyn ProviderAdmin>
});
let registry = ProviderAdminRegistry::new(providers).unwrap();
let records = (0..100)
.map(|index| {
account(
&format!("acct_{index}"),
if index % 2 == 0 { "openai" } else { "xai" },
"peer_test",
)
})
.collect::<Vec<_>>();
let accounts = Arc::new(TestAccounts {
accounts: Mutex::new(records.clone()),
records: Mutex::default(),
failed_provider: None,
});
let transport = Arc::new(GatedTransport::new());
let mut bundle = initialize_peer_upstream(accounts, &registry, transport.clone()).unwrap();
let service = bundle.service();
let mut requests = Vec::new();
for record in &records[..20] {
let service = service.clone();
let id = record.id().clone();
requests.push(tokio::spawn(
async move { service.refresh_quota(&id).await },
));
}
transport.wait_for_requests(4).await;
let WorkerContribution::Registration(worker) = bundle.take_worker_contributions().remove(0)
else {
panic!("scheduled worker")
};
let worker = tokio::spawn(async move {
let WorkerRunnable::Scheduled { task, .. } = worker.runnable else {
panic!("scheduled worker")
};
task.run_cycle(WorkerCycleContext::new(
worker.id,
None,
CancellationToken::new(),
))
.await
});
assert!(
tokio::time::timeout(Duration::from_millis(50), transport.wait_for_requests(5))
.await
.is_err()
);
requests.remove(0).abort();
transport.wait_for_requests(5).await;
transport.gate.add_permits(100);
tokio::time::timeout(Duration::from_secs(5), async {
for (index, request) in requests.into_iter().enumerate() {
let result = request.await.unwrap();
assert_eq!(result.is_err(), index == 0);
}
worker.await.unwrap().unwrap();
})
.await
.expect("all queued refreshes complete");
assert_eq!(transport.peak.load(Ordering::SeqCst), 4);
assert_eq!(transport.active.load(Ordering::SeqCst), 0);
assert_eq!(transport.started_count.load(Ordering::SeqCst), 100);
}
#[tokio::test]
async fn queued_refresh_reads_current_account_after_capacity_becomes_available() {
let mut provider = UnavailableProvider::new("openai");
provider.peer_enabled = true;
let registry =
ProviderAdminRegistry::new([Arc::new(provider) as Arc<dyn ProviderAdmin>]).unwrap();
let records = (0..5)
.map(|index| account(&format!("acct_{index}"), "openai", "peer_test"))
.collect::<Vec<_>>();
let accounts = Arc::new(TestAccounts {
accounts: Mutex::new(records.clone()),
records: Mutex::default(),
failed_provider: None,
});
let transport = Arc::new(GatedTransport::new());
let bundle = initialize_peer_upstream(accounts.clone(), &registry, transport.clone()).unwrap();
let service = bundle.service();
let mut requests = Vec::new();
for record in &records[..4] {
let service = service.clone();
let id = record.id().clone();
requests.push(tokio::spawn(
async move { service.refresh_quota(&id).await },
));
}
transport.wait_for_requests(4).await;
let queued = tokio::spawn(async move { service.refresh_quota(records[4].id()).await });
assert!(
tokio::time::timeout(Duration::from_millis(50), transport.wait_for_requests(5))
.await
.is_err()
);
accounts
.accounts
.lock()
.unwrap()
.retain(|account| account.id().as_str() != "acct_4");
transport.gate.add_permits(4);
for request in requests {
let _result = request.await.unwrap();
}
assert!(
tokio::time::timeout(Duration::from_secs(1), queued)
.await
.unwrap()
.unwrap()
.is_err()
);
assert_eq!(transport.started_count.load(Ordering::SeqCst), 4);
}
struct GatedTransport {
gate: tokio::sync::Semaphore,
started: tokio::sync::Notify,
started_count: AtomicUsize,
active: AtomicUsize,
peak: AtomicUsize,
success: TestTransport,
}
impl GatedTransport {
fn new() -> Self {
Self {
gate: tokio::sync::Semaphore::new(0),
started: tokio::sync::Notify::new(),
started_count: AtomicUsize::new(0),
active: AtomicUsize::new(0),
peak: AtomicUsize::new(0),
success: TestTransport(Mutex::default()),
}
}
async fn wait_for_requests(&self, count: usize) {
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let notified = self.started.notified();
if self.started_count.load(Ordering::SeqCst) >= count {
return;
}
notified.await;
}
})
.await
.expect("refresh reached transport");
}
}
struct ActiveFetch<'a>(&'a AtomicUsize);
impl Drop for ActiveFetch<'_> {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
#[async_trait]
impl PeerTransport for GatedTransport {
async fn fetch(
&self,
account: &ProviderAccount,
connection: &PeerConnection,
) -> Result<Vec<u8>, PeerFetchFailure> {
let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
let _active = ActiveFetch(&self.active);
self.peak.fetch_max(active, Ordering::SeqCst);
self.started_count.fetch_add(1, Ordering::SeqCst);
self.started.notify_one();
self.gate.acquire().await.unwrap().forget();
if account.id().as_str() == "acct_1" {
return Err(UpstreamInfoFailure::Unavailable.into());
}
self.success.fetch(account, connection).await
}
}
#[tokio::test]
async fn discovery_uses_provider_capabilities_and_publishes_neutral_signals() {
for failed_provider in [None, Some("openai")] {
let providers = ["openai", "xai"].map(|kind| {
let mut provider = UnavailableProvider::new(kind);
provider.peer_enabled = true;
Arc::new(provider) as Arc<dyn ProviderAdmin>
});
let registry = ProviderAdminRegistry::new(providers).unwrap();
let accounts = Arc::new(TestAccounts {
accounts: Mutex::new(vec![
account("acct_first", "openai", "peer_test"),
account("acct_second", "xai", "peer_test"),
account("acct_native", "xai", "oauth"),
]),
records: Mutex::default(),
failed_provider,
});
let transport = Arc::new(TestTransport(Mutex::new(Vec::new())));
let mut bundle =
initialize_peer_upstream(accounts.clone(), &registry, transport.clone()).unwrap();
let initial_accounts = accounts.accounts.lock().unwrap().clone();
let reader = UpstreamInfoReader::default();
assert!(
reader
.signal(&initial_accounts[0], "model", SystemTime::now())
.is_none()
);
let source = bundle.scheduling_source();
reader.bind(&source).unwrap();
assert!(reader.bind(&source).is_err());
drop(source);
let WorkerContribution::Registration(worker) = bundle.take_worker_contributions().remove(0)
else {
panic!("scheduled worker")
};
let WorkerRunnable::Scheduled { ref task, .. } = worker.runnable else {
panic!("scheduled worker")
};
let result = task
.run_cycle(WorkerCycleContext::new(
worker.id.clone(),
None,
CancellationToken::new(),
))
.await;
assert_eq!(result.is_err(), failed_provider.is_some());
assert_eq!(
*transport.0.lock().unwrap(),
if failed_provider.is_some() {
vec!["acct_second"]
} else {
vec!["acct_first", "acct_second"]
}
);
for account in &initial_accounts[..2] {
if Some(account.provider().as_str()) == failed_provider {
assert!(reader.signal(account, "model", SystemTime::now()).is_none());
continue;
}
let observed = bundle
.service()
.read_quota(account.id())
.await
.unwrap()
.unwrap();
assert_eq!(
observed.upstream.unwrap().connection_revision,
UpstreamConnection::for_account(account).revision
);
assert!(
reader
.signal(account, "model", SystemTime::now())
.unwrap()
.exhausted
);
}
assert!(
reader
.signal(&initial_accounts[2], "model", SystemTime::now())
.is_none()
);
drop(worker);
drop(bundle);
assert!(
reader
.signal(&initial_accounts[0], "model", SystemTime::now())
.is_none()
);
}
}
#[tokio::test]
async fn account_edits_preserve_observation_and_refresh_budget_until_connection_changes() {
let mut provider = UnavailableProvider::new("openai");
provider.peer_enabled = true;
let registry =
ProviderAdminRegistry::new([Arc::new(provider) as Arc<dyn ProviderAdmin>]).unwrap();
let initial = account("acct_peer", "openai", "peer_test");
let accounts = Arc::new(TestAccounts {
accounts: Mutex::new(vec![initial.clone()]),
records: Mutex::default(),
failed_provider: None,
});
let transport = Arc::new(TestTransport(Mutex::new(Vec::new())));
let bundle = initialize_peer_upstream(accounts.clone(), &registry, transport.clone()).unwrap();
let service = bundle.service();
service.refresh_quota(initial.id()).await.unwrap();
let before = service.read_summary(initial.id()).await.unwrap().unwrap();
let edited = initial.clone().with_notes(Some("仅修改备注".into()));
*accounts.accounts.lock().unwrap() = vec![edited.clone()];
service.account_changed(edited.id()).await.unwrap();
assert!(
bundle
.scheduling_source()
.signal(&edited, "model", SystemTime::now())
.is_some()
);
let after = service.refresh_quota(edited.id()).await.unwrap();
assert_eq!(after.upstream, before.upstream);
assert_eq!(transport.0.lock().unwrap().len(), 1);
let reconnected = edited.with_outbound_proxy(Some(
OutboundProxy::parse("http://proxy.invalid:8080").unwrap(),
));
*accounts.accounts.lock().unwrap() = vec![reconnected.clone()];
service.account_changed(reconnected.id()).await.unwrap();
assert!(
bundle
.scheduling_source()
.signal(&reconnected, "model", SystemTime::now())
.is_none()
);
service.refresh_quota(reconnected.id()).await.unwrap();
assert_eq!(transport.0.lock().unwrap().len(), 2);
}
fn account(id: &str, provider: &str, authentication: &str) -> ProviderAccount {
ProviderAccount::new(
ProviderAccountId::new(id).unwrap(),
ProviderKind::new(provider).unwrap(),
id.to_owned(),
None,
authentication.to_owned(),
CredentialRevision::new(1).unwrap(),
None,
)
.with_account_facts(
true,
CredentialState::Ready,
QuotaState::unknown(),
None,
None,
)
}
struct TestTransport(Mutex<Vec<String>>);
#[async_trait]
impl PeerTransport for TestTransport {
async fn fetch(
&self,
account: &ProviderAccount,
connection: &PeerConnection,
) -> Result<Vec<u8>, PeerFetchFailure> {
assert_eq!(
connection.base_url,
format!("https://{}.invalid/v1", account.provider())
);
self.0
.lock()
.unwrap()
.push(account.id().as_str().to_owned());
let now = Utc::now();
Ok(serde_json::to_vec(&serde_json::json!({
"object":"cpr.peer", "schema_version":1,
"observed_at":now.to_rfc3339(),
"expires_at":(now + chrono::Duration::minutes(5)).to_rfc3339(),
"capabilities":["key_usage"],
"usage":{"status":"available","observed_at":now.to_rfc3339(),"data":{"unit":"USD","allowed":false,
"daily":{"total":"1","used":"1","remaining":"0","resetsAt":(now + chrono::Duration::days(1)).to_rfc3339()},
"weekly":{"total":"0","used":"0","remaining":"0"}}},
"pool":{"disclosure":"none"}, "quota":{"disclosure":"none"}, "models":[]
})).unwrap())
}
}
struct TestAccounts {
accounts: Mutex<Vec<ProviderAccount>>,
records: Mutex<BTreeMap<ProviderAccountId, QuotaObservation>>,
failed_provider: Option<&'static str>,
}
fn loaded(account: &ProviderAccount) -> LoadedCredential {
LoadedCredential {
account: account.clone(),
credential: PlaintextCredential::new(Default::default()),
}
}
#[async_trait]
impl ProviderAccountStore for TestAccounts {
async fn create_account(&self, _account: NewProviderAccount) -> Result<(), StoreError> {
panic!("unused account operation")
}
async fn get_account(
&self,
account_id: &ProviderAccountId,
) -> Result<Option<ProviderAccount>, StoreError> {
Ok(self
.accounts
.lock()
.unwrap()
.iter()
.find(|account| account.id() == account_id)
.cloned())
}
async fn list_accounts(&self) -> Result<Vec<ProviderAccount>, StoreError> {
panic!("unused account operation")
}
async fn list_for_provider(
&self,
provider: &ProviderKind,
) -> Result<Vec<ProviderAccount>, StoreError> {
if Some(provider.as_str()) == self.failed_provider {
return Err(StoreError::new(StoreErrorKind::Unavailable));
}
Ok(self
.accounts
.lock()
.unwrap()
.iter()
.filter(|account| account.provider() == provider)
.cloned()
.collect())
}
async fn list_refresh_candidates(
&self,
_query: ProviderRefreshQuery,
) -> Result<Vec<LoadedCredential>, StoreError> {
panic!("unused account operation")
}
async fn load_credential(
&self,
_account: &ProviderAccountId,
_expected_revision: CredentialRevision,
) -> Result<LoadedCredential, StoreError> {
panic!("unused account operation")
}
async fn load_current_credential(
&self,
id: &ProviderAccountId,
) -> Result<LoadedCredential, StoreError> {
self.accounts
.lock()
.unwrap()
.iter()
.find(|account| account.id() == id)
.map(loaded)
.ok_or_else(|| StoreError::new(StoreErrorKind::InvalidData))
}
async fn compare_and_swap_credential(
&self,
_update: CredentialCasUpdate,
) -> Result<CredentialCasOutcome, StoreError> {
panic!("unused account operation")
}
async fn get_quotas(
&self,
accounts: &[ProviderAccountId],
) -> Result<Vec<QuotaObservation>, StoreError> {
let stored = self.records.lock().unwrap();
Ok(accounts
.iter()
.filter_map(|id| stored.get(id).cloned())
.collect())
}
async fn compare_and_swap_quota(
&self,
observation: QuotaObservation,
) -> Result<QuotaWriteOutcome, StoreError> {
self.records
.lock()
.unwrap()
.insert(observation.account_id.clone(), observation);
Ok(QuotaWriteOutcome::Updated)
}
async fn touch_quota_observation(
&self,
_touch: QuotaObservationTouch,
) -> Result<QuotaWriteOutcome, StoreError> {
panic!("unused account operation")
}
async fn apply_quota_access(
&self,
_change: QuotaAccessChange,
) -> Result<QuotaWriteOutcome, StoreError> {
panic!("unused account operation")
}
async fn apply_state_change(&self, _change: AccountStateChange) -> Result<(), StoreError> {
panic!("unused account operation")
}
async fn update_account(&self, _update: ProviderAccountUpdate) -> Result<(), StoreError> {
panic!("unused account operation")
}
async fn set_enabled(
&self,
_account: &ProviderAccountId,
_enabled: bool,
) -> Result<(), StoreError> {
panic!("unused account operation")
}
async fn delete_account(&self, _account: &ProviderAccountId) -> Result<(), StoreError> {
panic!("unused account operation")
}
}
@@ -203,6 +203,7 @@ async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
] {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
upstream: None,
credits: None,
plan_type: cached.map(str::to_owned),
observed_at: None,
@@ -215,6 +216,7 @@ async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
.provider(provider.clone())
.proxies(Arc::new(TestProxies {
accounts: Some(vec![ProxyAccountRef {
upstream: None,
id: "acct_plan".to_owned(),
name: "套餐测试".to_owned(),
email: None,
@@ -18,10 +18,11 @@ fn parse_provider(value: &str) -> Result<ProviderKind, WireValidationError> {
ProviderKind::new(value.trim().to_owned()).map_err(|_| WireValidationError::new("provider"))
}
/// 导入统一设置,复用编辑账号的备注、调度和分组约束
/// 导入设置复用编辑账号的名称、备注、调度和分组约束
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportSettingsRequest {
pub name: Option<String>,
pub notes: Option<String>,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
@@ -33,6 +34,9 @@ pub struct AccountImportSettingsRequest {
impl AccountImportSettingsRequest {
fn validate(&self) -> Result<(), WireValidationError> {
if let Some(name) = &self.name {
validate_account_name(name.trim())?;
}
validate_account_notes(self.notes.as_deref())?;
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
@@ -44,6 +48,10 @@ impl AccountImportSettingsRequest {
self,
) -> Result<gateway_admin::model::accounts::AccountImportSettings, WireValidationError> {
Ok(gateway_admin::model::accounts::AccountImportSettings {
name: self
.name
.map(|name| name.trim().to_owned())
.filter(|name| !name.is_empty()),
notes: self.notes,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
@@ -116,7 +124,7 @@ pub struct StartAccountAuthorizationRequest {
impl StartAccountAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
parse_provider(&self.provider)?;
require_text(&self.name, MAX_NAME_BYTES, "name")?;
validate_account_name(&self.name)?;
if let Some(account_id) = self.account_id.as_deref() {
require_account_id(account_id, "accountId")?;
}
@@ -138,7 +146,7 @@ impl StartAccountAuthorizationRequest {
provider,
StartAuthorization {
context,
name: self.name,
name: self.name.trim().to_owned(),
reauthorization,
outbound_proxy: super::wire::proxy_selection(
self.outbound_proxy_id,
@@ -213,6 +221,7 @@ pub struct UpdateAccountRequest {
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
pub account_id: String,
pub name: Option<String>,
pub notes: Option<String>,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
@@ -225,6 +234,9 @@ pub struct UpdateAccountRequest {
impl UpdateAccountRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
if let Some(name) = &self.name {
validate_account_name(name)?;
}
if let Some(connection) = &self.connection {
connection.validate()?;
}
@@ -248,6 +260,7 @@ impl UpdateAccountRequest {
self.outbound_proxy_url,
)?,
account_id: self.account_id,
name: self.name.map(|name| name.trim().to_owned()),
notes: self.notes,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
@@ -513,6 +526,13 @@ fn require_wire_id(value: &str, field: &'static str) -> Result<(), WireValidatio
Ok(())
}
fn validate_account_name(value: &str) -> Result<(), WireValidationError> {
if value.len() > MAX_NAME_BYTES || value.chars().any(char::is_control) {
return Err(WireValidationError::new("name"));
}
Ok(())
}
fn require_text(
value: &str,
max_bytes: usize,
@@ -44,7 +44,9 @@ use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use uuid::{Uuid, Version};
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
use super::presenter::{
format_compact_number, format_decimal_currency, format_number, format_percentage,
};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, PageMeta,
WireValidationError,
@@ -66,11 +68,14 @@ mod credentials;
mod handlers;
mod import_tasks;
mod presenter;
mod upstream_info;
mod wire;
pub use credentials::*;
pub use handlers::{profile_avatar_response, router};
pub(super) use presenter::quota_window_view;
pub(crate) use upstream_info::view as upstream_info_view;
pub use upstream_info::{AccountQuotaAmountView, AccountUpstreamInfoView};
pub use wire::*;
use credentials::{
@@ -92,11 +92,31 @@ pub(super) fn account_view(
let usage_period = quota.usage_window().map(|(_, period)| period);
let mut usage = account_usage_view(usage, usage_period, now, time);
if account.authentication_kind == "api_key" {
usage.window_kind = AccountUsageWindowKind::General;
usage.window_kind = if quota
.upstream
.as_ref()
.is_some_and(|upstream| upstream.discovered)
{
match quota
.upstream_usage_window()
.and_then(|window| window.window_seconds)
{
Some(86_400) => AccountUsageWindowKind::Daily,
Some(604_800) => AccountUsageWindowKind::Weekly,
_ => AccountUsageWindowKind::Quota,
}
} else {
AccountUsageWindowKind::General
};
}
let upstream = quota
.upstream
.clone()
.map(|value| super::upstream_info::view(value, now, time));
let (mut quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now, time);
quota.protection_rules = protection_rules;
AccountView {
upstream,
capabilities: capabilities.into(),
id: account.id.clone(),
name: account.name,
@@ -277,6 +297,7 @@ pub(crate) fn quota_window_view(
time: crate::time::TimePresenter,
) -> AccountQuotaWindowView {
let ProviderQuotaWindow {
upstream,
key,
group,
label,
@@ -295,6 +316,16 @@ pub(crate) fn quota_window_view(
.as_ref()
.map_or_else(|| label.clone(), |name| format!("{name} · {label}"));
AccountQuotaWindowView {
source_account_id: upstream.as_ref().and_then(|value| value.account_id.clone()),
amount: upstream
.as_ref()
.and_then(|value| value.amount.as_ref())
.map(super::upstream_info::amount_view),
observed_at_display: upstream
.as_ref()
.and_then(|value| value.observed_at)
.map(|value| time.datetime(&value)),
stale: upstream.as_ref().is_some_and(|value| value.stale),
label_display,
window_label_display: label,
key,
@@ -304,8 +335,7 @@ pub(crate) fn quota_window_view(
role: role.map(|value| value.as_str().to_owned()),
window_seconds,
used_percent,
used_percent_display: used_percent
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
used_percent_display: used_percent.map_or_else(|| "—".to_owned(), format_percentage),
limit_reached,
local_usage: local_usage
.as_ref()
@@ -0,0 +1,101 @@
//! 互联快照与金额的管理端展示投影
use gateway_admin::model::upstream_info::{ProviderUpstreamInfo, UpstreamQuotaAmount};
use super::*;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUpstreamInfoView {
pub discovered: bool,
pub observed_at: Option<String>,
pub observed_at_display: String,
pub observed_at_relative: Option<crate::time::RelativeTimeView>,
pub stale: bool,
pub connection_revision: String,
pub last_error: Option<String>,
pub pool: Option<UpstreamPoolView>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpstreamPoolView {
pub account_count: u32,
pub states: UpstreamPoolStatesView,
pub plans: Vec<UpstreamPlanView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct UpstreamPoolStatesView {
pub ready: u32,
pub cooldown: u32,
pub exhausted: u32,
pub disabled: u32,
pub error: u32,
pub unknown: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct UpstreamPlanView {
pub name: String,
pub count: u32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountQuotaAmountView {
pub used_display: String,
pub remaining_display: String,
pub total_display: String,
}
pub(super) fn amount_view(amount: &UpstreamQuotaAmount) -> AccountQuotaAmountView {
let display = |value: Option<&str>| {
value.map_or_else(
|| "∞".to_owned(),
|value| crate::admin::presenter::format_decimal_currency(value, &amount.unit),
)
};
AccountQuotaAmountView {
used_display: display(Some(&amount.used)),
remaining_display: display(amount.remaining.as_deref()),
total_display: display(amount.total.as_deref()),
}
}
pub(crate) fn view(
info: ProviderUpstreamInfo,
now: DateTime<Utc>,
time: crate::time::TimePresenter,
) -> AccountUpstreamInfoView {
AccountUpstreamInfoView {
discovered: info.discovered,
observed_at: info.observed_at.map(|value| time.rfc3339(&value)),
observed_at_display: info
.observed_at
.map_or_else(|| "—".to_owned(), |value| time.datetime(&value)),
observed_at_relative: info.observed_at.map(|value| time.relative_view(value, now)),
stale: info.stale,
connection_revision: info.connection_revision,
last_error: info.last_error,
pool: info.pool.map(|pool| UpstreamPoolView {
account_count: pool.account_count,
states: UpstreamPoolStatesView {
ready: pool.states.ready,
cooldown: pool.states.cooldown,
exhausted: pool.states.exhausted,
disabled: pool.states.disabled,
error: pool.states.error,
unknown: pool.states.unknown,
},
plans: pool
.plans
.into_iter()
.map(|plan| UpstreamPlanView {
name: plan.name,
count: plan.count,
})
.collect(),
}),
}
}
@@ -266,6 +266,7 @@ pub struct AccountSummaryView {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountView {
pub upstream: Option<super::AccountUpstreamInfoView>,
pub capabilities: AccountCapabilitiesView,
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_endpoint: Option<String>,
@@ -412,6 +413,11 @@ pub struct AccountQuotaCreditsView {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountQuotaWindowView {
#[serde(skip_serializing_if = "Option::is_none")]
pub source_account_id: Option<String>,
pub amount: Option<super::AccountQuotaAmountView>,
pub observed_at_display: Option<String>,
pub stale: bool,
pub key: String,
pub group: String,
pub limit_id: Option<String>,
@@ -433,6 +439,7 @@ pub struct AccountQuotaWindowView {
#[serde(rename_all = "snake_case")]
pub enum AccountUsageWindowKind {
General,
Daily,
Weekly,
Monthly,
Quota,
@@ -152,6 +152,8 @@ impl ClientKeySort {
#[derive(Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateClientKeyRequest {
#[serde(default)]
peer_sharing: gateway_core::policy::ClientPeerSharing,
#[serde(default)]
provider_request_profile_overrides: ProviderRequestProfileOverrides,
openai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
@@ -190,6 +192,7 @@ impl CreateClientKeyRequest {
self.xai_client_profile_override,
)?;
Ok(CreateClientKey {
peer_sharing: self.peer_sharing,
request_profile_overrides,
custom_key: self
.custom_key
@@ -217,6 +220,7 @@ impl CreateClientKeyRequest {
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateClientKeyRequest {
peer_sharing: Option<gateway_core::policy::ClientPeerSharing>,
#[serde(default)]
provider_request_profile_overrides: ProviderRequestProfileOverrideUpdates,
#[serde(default, deserialize_with = "deserialize_profile_override")]
@@ -248,6 +252,7 @@ impl UpdateClientKeyRequest {
self.xai_client_profile_override,
)?;
Ok(UpdateClientKey {
peer_sharing: self.peer_sharing,
request_profile_override_updates,
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
name: self.name,
@@ -334,6 +339,7 @@ impl ClientKeyMutationRequest {
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientKeyView {
peer_sharing: gateway_core::policy::ClientPeerSharing,
provider_request_profile_overrides: ProviderRequestProfileOverrides,
/// 固定兼容字段;值始终从 provider_request_profile_overrides 派生
openai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
@@ -384,6 +390,7 @@ impl From<(ClientKeyRecord, crate::time::TimePresenter)> for ClientKeyView {
.cloned(),
xai_client_profile_override: provider_request_profile_overrides.get("xai").cloned(),
provider_request_profile_overrides,
peer_sharing: record.peer_sharing,
id: record.id.to_string(),
name: record.name,
label: record.label,
@@ -919,6 +919,9 @@ pub(crate) fn dashboard_view(
),
};
account_usage_views.push(DashboardAccountUsageView {
upstream: credential.upstream.map(|info| {
crate::admin::accounts::upstream_info_view(info, chrono::Utc::now(), time)
}),
id: credential.account_id.clone(),
provider: credential.provider_kind.clone(),
authentication_kind: credential.authentication_kind.clone(),
@@ -1339,6 +1342,7 @@ pub(crate) fn diagnostics_view(
.into_iter()
.map(|item| DiagnosticItemView {
key: item.key,
authentication_kind: item.account_authentication_kind,
name: if item.name == "__none__" {
"未知".to_owned()
} else {
@@ -402,6 +402,7 @@ pub enum DashboardAccountMetric {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardAccountUsageView {
pub upstream: Option<crate::admin::accounts::AccountUpstreamInfoView>,
pub id: String,
pub provider: String,
pub authentication_kind: String,
@@ -845,6 +846,7 @@ pub struct UsageCacheView {
pub struct DiagnosticItemView {
pub key: String,
pub name: String,
pub authentication_kind: Option<String>,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub request_count: u64,
@@ -41,11 +41,11 @@ pub fn format_compact_number(value: u64) -> String {
}
/// 货币展示格式化
/// USD 常规金额保留两位,小于 1 美元时最多保留四位
/// USD 常规金额最多保留两位,小于 1 美元时最多保留四位,小数末尾不补零
#[must_use]
pub fn format_decimal_currency(amount: &str, currency: &str) -> String {
if currency != "USD" {
return format!("{currency} {amount}");
return format!("{currency} {}", trim_decimal_zeros(amount));
}
const DECIMAL_SCALE: u128 = 10_000_000_000;
@@ -60,13 +60,21 @@ pub fn format_decimal_currency(amount: &str, currency: &str) -> String {
let display_scale = 10_u128.pow(precision);
let whole = rounded / display_scale;
let fraction = rounded % display_scale;
let mut display = format!("{whole}.{fraction:0width$}", width = precision as usize);
while display.ends_with('0')
&& display
.split_once('.')
.is_some_and(|(_, fraction)| fraction.len() > 2)
{
display.pop();
let display = format!("{whole}.{fraction:0width$}", width = precision as usize);
format!("${}", trim_decimal_zeros(&display))
}
/// 百分比最多保留一位小数,整数百分比不补零
#[must_use]
pub(super) fn format_percentage(value: f64) -> String {
let display = format!("{value:.1}");
format!("{}%", trim_decimal_zeros(&display))
}
fn trim_decimal_zeros(value: &str) -> &str {
if value.contains('.') {
value.trim_end_matches('0').trim_end_matches('.')
} else {
value
}
format!("${display}")
}
@@ -135,6 +135,7 @@ impl From<ProxyTestResult> for ProxyTestView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyAccountView {
upstream: Option<super::accounts::AccountUpstreamInfoView>,
id: String,
name: String,
email: Option<String>,
@@ -322,6 +323,13 @@ where
.items
.into_iter()
.map(|account| ProxyAccountView {
upstream: account.upstream.map(|info| {
super::accounts::upstream_info_view(
info,
chrono::Utc::now(),
crate::time::TimePresenter::new(state.admin_services().timezone()),
)
}),
id: account.id,
name: account.name,
email: account.email,
@@ -300,6 +300,11 @@ pub const fn gateway_error_contract(
"rate_limit_error",
"rate_limit_exceeded",
),
GatewayErrorKind::BudgetExhausted => (
StatusCode::TOO_MANY_REQUESTS,
"usage_limit_reached",
"usage_limit_reached",
),
GatewayErrorKind::UpstreamUnavailable => (
StatusCode::BAD_GATEWAY,
"server_error",
@@ -8,6 +8,7 @@ pub mod images;
pub(crate) mod live;
pub(crate) mod middleware;
pub mod models;
mod peer_link;
pub mod responses;
pub mod router;
pub mod search;
@@ -207,10 +207,7 @@ fn adapted_codex_model_json(profile: &PublicModelProfile, index: usize) -> Value
})
})
.collect::<Vec<_>>();
let reasoning_supported = presentation
.supported_reasoning_efforts()
.iter()
.any(|effort| effort != "none");
let reasoning_summary = presentation.reasoning_summary();
let context_window = presentation.context_window_tokens();
let max_context_window = presentation.max_context_window_tokens();
let input_modalities = if presentation.image_input() {
@@ -252,8 +249,8 @@ fn adapted_codex_model_json(profile: &PublicModelProfile, index: usize) -> Value
"base_instructions": CODEX_BASE_INSTRUCTIONS,
"model_messages": Value::Null,
"include_skills_usage_instructions": false,
"supports_reasoning_summary_parameter": reasoning_supported,
"default_reasoning_summary": "auto",
"supports_reasoning_summary_parameter": reasoning_summary,
"default_reasoning_summary": if reasoning_summary { "auto" } else { "none" },
"support_verbosity": presentation.verbosity(),
"default_verbosity": Value::Null,
"apply_patch_tool_type": presentation.agent_tools().then_some("freeform"),
@@ -0,0 +1,123 @@
//! Bearer Key 的 CPR 互联导出路由,只消费 Admin 的本地观测
mod wire;
use axum::{
Router,
extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode, Uri, header},
middleware,
response::{IntoResponse, Response},
routing::get,
};
use gateway_admin::PeerLinkError;
use gateway_admin::model::peer_link::PeerLinkLimits;
use gateway_core::engine::execution::AuthenticatedClient;
use gateway_protocol::peer::{MAX_ACCOUNTS, MAX_MODELS, MAX_QUOTA_WINDOWS, MAX_RESPONSE_BYTES};
use serde::Deserialize;
use super::{
auth::{client_access_error_response, verify_client},
error::{missing_client_api_key_response, openai_error_response},
};
use crate::{
ApiState,
auth::SessionState as _,
openai::middleware::{self as plugin_middleware, RequestInput},
};
pub(super) fn router() -> Router<ApiState> {
Router::new()
.route(super::router::PEER_LINK_PATH, get(peer_link))
.layer(middleware::map_response(no_store))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PeerQuery {}
async fn peer_link(State(state): State<ApiState>, headers: HeaderMap, uri: Uri) -> Response {
let client = match verify_client(state.openai(), &headers).await {
Ok(client) => client,
Err(error) => return client_access_error_response(error.into()),
};
plugin_middleware::query_response(
state.openai().execution(),
client,
RequestInput::query(super::router::PEER_LINK_PATH.to_owned(), None, &headers),
move |client| peer_response(state, client, uri),
)
.await
}
async fn peer_response(state: ApiState, client: AuthenticatedClient, uri: Uri) -> Response {
if Query::<PeerQuery>::try_from_uri(&uri).is_err() {
return openai_error_response(
StatusCode::BAD_REQUEST,
"Peer query does not accept query parameters.",
"invalid_request_error",
"invalid_peer_query",
)
.into_response();
}
match state
.admin_services()
.peer_link()
.snapshot(
&client,
PeerLinkLimits {
max_accounts: MAX_ACCOUNTS,
max_quota_windows: MAX_QUOTA_WINDOWS,
max_models: MAX_MODELS,
max_response_bytes: MAX_RESPONSE_BYTES,
},
)
.await
{
Ok(snapshot) => match serde_json::to_vec(&wire::response(snapshot)) {
Ok(body) if body.len() <= MAX_RESPONSE_BYTES => {
([(header::CONTENT_TYPE, "application/json")], body).into_response()
}
_ => unavailable(),
},
Err(PeerLinkError::Unauthorized) => missing_client_api_key_response().into_response(),
Err(PeerLinkError::Forbidden) => openai_error_response(
StatusCode::FORBIDDEN,
"CPR Link sharing is not enabled for this API key.",
"permission_error",
"peer_sharing_disabled",
)
.into_response(),
Err(PeerLinkError::RateLimited { retry_after }) => {
let mut response = openai_error_response(
StatusCode::TOO_MANY_REQUESTS,
"Peer query rate limit reached.",
"rate_limit_error",
"peer_query_rate_limited",
)
.into_response();
response
.headers_mut()
.insert(header::RETRY_AFTER, HeaderValue::from(retry_after));
response
}
Err(PeerLinkError::Unavailable(_) | PeerLinkError::SnapshotTooLarge) => unavailable(),
}
}
fn unavailable() -> Response {
openai_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"Peer snapshot is currently unavailable.",
"server_error",
"peer_snapshot_unavailable",
)
.into_response()
}
async fn no_store(mut response: Response) -> Response {
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
@@ -0,0 +1,139 @@
//! 将已脱敏的互联领域观测投影为版本化协议
use chrono::{DateTime, Utc};
use gateway_admin::model::peer_link::{PeerLinkExport, PeerModelState as ModelState};
use gateway_core::metering::Decimal;
use gateway_protocol::peer::{
OBJECT, PeerBudgetWindow, PeerComponentStatus, PeerKeyUsage, PeerLink, PeerModelAvailability,
PeerModelCoverage, PeerModelState, PeerPlanCount, PeerPool, PeerPoolDisclosure, PeerPoolStates,
PeerQuota, PeerQuotaAccount, PeerQuotaDisclosure, PeerQuotaWindow, PeerUsage, SCHEMA_VERSION,
};
use std::time::SystemTime;
pub(super) fn response(export: PeerLinkExport) -> PeerLink {
let mut capabilities = vec!["key_usage".to_owned()];
let pool = export.pool.map_or_else(PeerPool::default, |pool| {
capabilities.push("pool_summary".to_owned());
PeerPool {
disclosure: PeerPoolDisclosure::Summary,
observed_at: Some(export.observed_at.to_rfc3339()),
account_count: Some(
pool.ready
+ pool.cooldown
+ pool.exhausted
+ pool.disabled
+ pool.error
+ pool.unknown,
),
states: Some(PeerPoolStates {
ready: pool.ready,
cooldown: pool.cooldown,
exhausted: pool.exhausted,
disabled: pool.disabled,
error: pool.error,
unknown: pool.unknown,
}),
plans: pool.plans.map(|plans| {
plans
.into_iter()
.map(|(name, count)| PeerPlanCount { name, count })
.collect()
}),
}
});
let quota = export.quota.map_or_else(PeerQuota::default, |accounts| {
capabilities.extend(["quota_windows".to_owned(), "model_availability".to_owned()]);
PeerQuota {
disclosure: PeerQuotaDisclosure::Accounts,
accounts: accounts
.into_iter()
.map(|account| PeerQuotaAccount {
id: account.id,
models: account.models,
observed_at: account.observed_at.map(|time| time.to_rfc3339()),
expires_at: account.expires_at.map(|time| time.to_rfc3339()),
windows: account
.windows
.into_iter()
.map(|window| PeerQuotaWindow {
key: window.key,
group: window.group,
label: window.label,
limit_id: window.limit_id,
limit_name: window.limit_name,
role: window.role.map(|role| role.as_str().to_owned()),
window_seconds: window.window_seconds,
used_percent: window.used_percent,
reset_at: window.reset_at.map(|time| time.to_rfc3339()),
limit_reached: window.limit_reached,
})
.collect(),
})
.collect(),
}
});
PeerLink {
object: OBJECT.to_owned(),
schema_version: SCHEMA_VERSION,
capabilities,
observed_at: export.observed_at.to_rfc3339(),
expires_at: export.expires_at.to_rfc3339(),
usage: PeerUsage {
status: PeerComponentStatus::Available,
observed_at: Some(export.budget_observed_at.to_rfc3339()),
data: Some(PeerKeyUsage {
unit: "USD".to_owned(),
allowed: Some(export.budget_access.allowed()),
daily: budget_window(
export.budget.limits.daily_usd,
export.budget.daily_used_usd,
export.budget.daily_resets_at,
export.budget_access.daily_limit_reached,
),
weekly: budget_window(
export.budget.limits.weekly_usd,
export.budget.weekly_used_usd,
export.budget.weekly_resets_at,
export.budget_access.weekly_limit_reached,
),
}),
},
pool,
quota,
models: export
.models
.into_iter()
.map(|model| PeerModelAvailability {
model: model.model,
coverage: if model.complete {
PeerModelCoverage::Complete
} else {
PeerModelCoverage::Partial
},
state: match model.state {
ModelState::Available => PeerModelState::Available,
ModelState::Exhausted => PeerModelState::Exhausted,
ModelState::Unknown => PeerModelState::Unknown,
},
observed_at: model.observed_at.map(|time| time.to_rfc3339()),
expires_at: model.expires_at.map(|time| time.to_rfc3339()),
})
.collect(),
}
}
fn budget_window(
total: Decimal,
used: Decimal,
resets_at: Option<SystemTime>,
limit_reached: bool,
) -> PeerBudgetWindow {
let limited = total != Decimal::ZERO;
PeerBudgetWindow {
limit_reached: Some(limit_reached),
total: limited.then(|| total.canonical()),
used: used.canonical(),
remaining: limited.then(|| total.saturating_sub(used).canonical()),
resets_at: resets_at.map(|time| DateTime::<Utc>::from(time).to_rfc3339()),
}
}
@@ -27,6 +27,7 @@ pub(super) const SEARCH_PATH: &str = "/v1/alpha/search";
pub(super) const MODELS_PATH: &str = "/v1/models";
pub(super) const MODEL_DETAIL_PATH: &str = "/v1/models/{model_id}";
pub(super) const USAGE_PATH: &str = "/v1/usage";
pub(super) const PEER_LINK_PATH: &str = "/v1/cpr/link";
/// 构造 OpenAI 客户端协议路由
pub(crate) fn router() -> Router<ApiState> {
@@ -40,6 +41,7 @@ pub(crate) fn router() -> Router<ApiState> {
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束
.route(MODEL_DETAIL_PATH, get(model_detail))
.merge(usage::router())
.merge(super::peer_link::router())
.merge(live::router())
// OpenAI 数据面正文属于客户端/上游协议;代理不能用私有大小上限提前拒绝
// 上游本可接受的未来 payload
@@ -9,6 +9,55 @@ use tower::ServiceExt as _;
use super::super::{AdminTestFixture, AdminTestState};
#[tokio::test]
async fn peer_refresh_uses_the_account_quota_endpoint_and_requires_admin() {
use gateway_core::account::{AccountStatusFacts, CredentialState, QuotaState};
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
*fixture.account.lock().unwrap() = Some(account_page_item(
AccountStatusFacts {
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
quota: QuotaState::unknown(),
cooldown: None,
last_error_reason: None,
last_error_message: None,
},
chrono::Utc::now(),
"api_key",
));
for (authenticated, id, expected) in [
(false, "acct_cooldown", StatusCode::UNAUTHORIZED),
(true, "invalid", StatusCode::BAD_REQUEST),
(true, "acct_cooldown", StatusCode::OK),
] {
let mut request = Request::builder()
.method("POST")
.uri("/api/admin/accounts/quota/refresh")
.header("x-request-id", "req_upstream_refresh")
.header(header::CONTENT_TYPE, "application/json");
if authenticated {
request = request.header(header::COOKIE, "cpr_session=valid-session");
}
let response = admin::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
request
.body(Body::from(serde_json::json!({"accountId":id}).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), expected);
if expected == StatusCode::OK {
let body = to_bytes(response.into_body(), 8192).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value["data"]["account"]["id"], "acct_cooldown");
}
}
}
#[tokio::test]
async fn account_model_reads_and_refreshes_preserve_provider_observation_time() {
use chrono::{DateTime, Utc};
@@ -307,6 +356,7 @@ async fn account_quota_refresh_time_uses_server_clock_and_deployment_timezone()
),
] {
*fixture.provider_quota.lock().unwrap() = Some(ProviderQuota {
upstream: None,
plan_type: None,
observed_at,
refresh_token_expires_at: None,
@@ -438,6 +438,45 @@ mod batch_update {
}
}
#[test]
fn single_update_should_validate_optional_names_and_accept_clearing() {
use gateway_api::admin::accounts::UpdateAccountRequest;
use serde_json::json;
let baseline = json!({
"accountId": "acct_name", "enabled": true, "concurrencyLimit": null,
"weight": 1, "groupIds": []
});
let omitted: UpdateAccountRequest = serde_json::from_value(baseline.clone()).unwrap();
assert!(omitted.name.is_none());
omitted.validate().unwrap();
for (name, valid) in [
(json!(null), true),
(json!(" 团队账号 "), true),
(json!("a".repeat(512)), true),
(json!("账".repeat(170)), true),
(json!("账".repeat(171)), false),
(json!("a".repeat(513)), false),
(json!(""), true),
(json!(" "), true),
(json!(" \t "), false),
(json!("账号\n名称"), false),
(json!("账号\u{0085}"), false),
] {
let mut input = baseline.clone();
input["name"] = name;
let request: UpdateAccountRequest = serde_json::from_value(input).unwrap();
if valid {
request.validate().expect("accept optional bounded name");
} else {
assert_eq!(request.validate().unwrap_err().field(), "name");
}
}
let mut unknown = baseline;
unknown["unknownField"] = json!(true);
assert!(serde_json::from_value::<UpdateAccountRequest>(unknown).is_err());
}
#[test]
fn single_update_should_accept_optional_unicode_and_multiline_notes() {
use gateway_api::admin::accounts::UpdateAccountRequest;
@@ -527,10 +566,15 @@ mod response {
#[test]
fn account_usage_currency_should_limit_usd_to_four_fraction_digits() {
assert_eq!(format_decimal_currency("0.1204956", "USD"), "$0.1205");
assert_eq!(format_decimal_currency("0.99996", "USD"), "$1.00");
assert_eq!(format_decimal_currency("0.99996", "USD"), "$1");
assert_eq!(format_decimal_currency("0.0300", "USD"), "$0.03");
assert_eq!(format_decimal_currency("0.00", "USD"), "$0");
assert_eq!(format_decimal_currency("100.0000", "USD"), "$100");
assert_eq!(format_decimal_currency("12.3000", "USD"), "$12.3");
assert_eq!(format_decimal_currency("12.3456", "USD"), "$12.35");
assert_eq!(format_decimal_currency("0.1204956", "CNY"), "CNY 0.1204956");
assert_eq!(format_decimal_currency("12.3000", "CNY"), "CNY 12.3");
assert_eq!(format_decimal_currency("100", "CNY"), "CNY 100");
}
}
@@ -723,11 +767,29 @@ mod actions {
}
}
#[test]
fn authorization_accepts_optional_names_with_the_same_text_limits() {
for name in ["", " ", "My account"] {
let request: StartAccountAuthorizationRequest = serde_json::from_value(json!({
"provider": "openai", "name": name
}))
.expect("decode authorization");
assert!(request.validate().is_ok());
}
for name in ["bad\nname".to_owned(), "x".repeat(513)] {
let request: StartAccountAuthorizationRequest = serde_json::from_value(json!({
"provider": "openai", "name": name
}))
.expect("decode authorization");
assert_eq!(request.validate().unwrap_err().field(), "name");
}
}
#[test]
fn credential_recovery_requests_should_not_accept_client_revision_fences() {
let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({
"provider": "openai",
"name": "reauthorize",
"name": "",
"accountId": "acct_1"
}))
.expect("decode reauthorization");
@@ -993,6 +1055,9 @@ mod import_settings {
("weight", json!(0)),
("weight", json!(101)),
("groupIds", json!(["invalid-group"])),
("name", json!("名".repeat(171))),
("name", json!("名\n称")),
("name", json!("名称\u{0000}")),
("notes", json!("备".repeat(501))),
("notes", json!("备注\u{0000}")),
("notes", json!("备注\u{001b}")),
@@ -70,7 +70,7 @@ fn quota_forecast_projection_formats_capacity_and_preserves_null_zero() {
assert_eq!(value["forecasts"][1]["estimatedTokens"], 0);
assert_eq!(value["forecasts"][1]["estimatedTokensDisplay"], "0");
assert_eq!(value["forecasts"][1]["estimatedUsd"], 0.0);
assert_eq!(value["forecasts"][1]["estimatedUsdDisplay"], "$0.00");
assert_eq!(value["forecasts"][1]["estimatedUsdDisplay"], "$0");
assert_eq!(value["forecasts"][1]["period"], "monthly");
assert_eq!(value["forecasts"][1]["targetDays"], 30.0);
assert_eq!(value["forecasts"][1]["extrapolated"], true);
@@ -434,6 +434,7 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
.expect("valid time");
let view = ClientKeyView::from((
gateway_admin::model::client_keys::ClientKeyRecord {
peer_sharing: Default::default(),
request_profile_overrides: Default::default(),
budget: Default::default(),
id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"),
@@ -1,6 +1,7 @@
//! 管理控制面 HTTP 接口的测试入口
mod plugins;
mod upstream_info;
use std::{
collections::BTreeMap,
sync::{
@@ -97,6 +98,11 @@ mod settings;
mod system;
mod wire;
pub(super) struct ProviderQuotaGate {
pub entered: tokio::sync::Semaphore,
pub release: tokio::sync::Semaphore,
}
pub(super) struct AdminTestFixture {
pub services: AdminServices,
plugin_ports: Arc<plugins::TestPluginPorts>,
@@ -113,6 +119,7 @@ pub(super) struct AdminTestFixture {
pub dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
pub provider_error: Arc<Mutex<Option<ProviderAdminError>>>,
pub provider_quota: Arc<Mutex<Option<ProviderQuota>>>,
pub provider_quota_gate: Arc<Mutex<Option<Arc<ProviderQuotaGate>>>>,
pub provider_models: Arc<Mutex<Option<ProviderModels>>>,
pub account: Arc<Mutex<Option<AccountPageItem>>>,
pub account_usage: Arc<Mutex<Vec<AccountUsage>>>,
@@ -190,18 +197,21 @@ impl AdminTestFixture {
plugin_ports.clone(),
);
let provider_quota = Arc::new(Mutex::new(None));
let provider_quota_gate = Arc::new(Mutex::new(None));
let provider_models = Arc::new(Mutex::new(None));
let providers: Vec<Arc<dyn ProviderAdmin>> = vec![
Arc::new(UnusedProvider::new(
"openai",
Arc::clone(&provider_error),
Arc::clone(&provider_quota),
Arc::clone(&provider_quota_gate),
Arc::clone(&provider_models),
)),
Arc::new(UnusedProvider::new(
"xai",
Arc::clone(&provider_error),
Arc::clone(&provider_quota),
Arc::clone(&provider_quota_gate),
Arc::clone(&provider_models),
)),
];
@@ -215,6 +225,9 @@ impl AdminTestFixture {
ClientConfig::default(),
stores,
gateway_admin::AdminRuntimePorts {
upstream_info: Some(Arc::new(upstream_info::TestPeerInfo(Arc::clone(
&provider_quota,
)))),
timezone,
service_middleware: std::sync::Arc::new(|| None),
plugin_preparation: plugin_ports.clone(),
@@ -251,6 +264,7 @@ impl AdminTestFixture {
dashboard_summary_range,
provider_error,
provider_quota,
provider_quota_gate,
provider_models,
account,
account_usage,
@@ -1071,6 +1085,7 @@ impl ClientKeyStore for MemoryClientKeyStore {
let now = Utc::now();
Ok(Some(ClientKeySecret::new(
ClientKeyRecord {
peer_sharing: Default::default(),
request_profile_overrides: Default::default(),
budget: Default::default(),
id: id.clone(),
@@ -1144,6 +1159,13 @@ impl ClientKeyVerifier for UnusedClientKeyVerifier {
#[async_trait]
impl AccountStore for UnusedStore {
async fn load_account_record(
&self,
_: &gateway_core::account::ProviderAccountId,
) -> AdminStoreResult<Option<gateway_admin::model::accounts::AccountRecord>> {
Err(unavailable("account"))
}
async fn account_exists(&self, id: &str) -> AdminStoreResult<bool> {
Ok(self
.account
@@ -1612,6 +1634,7 @@ struct UnusedProvider {
kind: ProviderKind,
error: Arc<Mutex<Option<ProviderAdminError>>>,
quota: Arc<Mutex<Option<ProviderQuota>>>,
quota_gate: Arc<Mutex<Option<Arc<ProviderQuotaGate>>>>,
models: Arc<Mutex<Option<ProviderModels>>>,
}
@@ -1620,12 +1643,14 @@ impl UnusedProvider {
kind: &str,
error: Arc<Mutex<Option<ProviderAdminError>>>,
quota: Arc<Mutex<Option<ProviderQuota>>>,
quota_gate: Arc<Mutex<Option<Arc<ProviderQuotaGate>>>>,
models: Arc<Mutex<Option<ProviderModels>>>,
) -> Self {
Self {
kind: ProviderKind::new(kind).expect("provider kind"),
error,
quota,
quota_gate,
models,
}
}
@@ -1633,6 +1658,22 @@ impl UnusedProvider {
#[async_trait]
impl ProviderAdmin for UnusedProvider {
fn is_direct_account(&self, authentication_kind: &str) -> bool {
authentication_kind == "oauth"
}
fn peer_model_eligible(
&self,
_: &gateway_admin::model::accounts::AccountRecord,
_: &str,
) -> Option<bool> {
Some(true)
}
fn peer_authentication_kinds(&self) -> &[&str] {
&["api_key"]
}
fn pricing_catalog(&self) -> gateway_admin::model::pricing::ProviderPricingCatalog {
serde_json::from_value(serde_json::json!({
"builtin-model": {
@@ -1721,6 +1762,11 @@ impl ProviderAdmin for UnusedProvider {
&self,
_: gateway_admin::model::provider_credentials::ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError> {
let gate = self.quota_gate.lock().unwrap().clone();
if let Some(gate) = gate {
gate.entered.add_permits(1);
gate.release.acquire().await.unwrap().forget();
}
if let Some(quota) = self.quota.lock().expect("provider quota").clone() {
return Ok(quota);
}
@@ -670,67 +670,84 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
use crate::admin::{AdminTestFixture, AdminTestState};
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
*fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation {
analytics: Default::default(),
total_request_count: 4,
items: vec![DiagnosticObservation {
key: "acct_diag".to_owned(),
account_provider_kind: Some("openai".to_owned()),
account_plan_type: Some("pro".to_owned()),
name: "diag@example.invalid".to_owned(),
request_count: 2,
success_count: 2,
failure_count: 0,
attempt_count: 5,
total_tokens: 200,
average_latency_ms: Some(100),
latency_p95_ms: Some(3800),
first_token_p95_ms: Some(1200),
non_completion_count: 0,
retry_count: 3,
retried_request_count: 1,
cost_coverage: CostCoverage::default(),
costs: Vec::new(),
}],
};
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/usage/insights/diagnostics?dimension=account")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_diagnostics_snapshot")
.body(Body::empty())
.expect("diagnostics request"),
)
.await
.expect("diagnostics response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("diagnostics body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
assert_eq!(value["data"]["items"][0]["requestShare"], 0.5);
assert_eq!(value["data"]["items"][0]["retryCount"], 3);
assert_eq!(value["data"]["items"][0]["retryRate"], 0.5);
assert_eq!(value["data"]["items"][0]["accountPlanType"], "pro");
assert_eq!(value["data"]["items"][0]["accountPlanTypeDisplay"], "Pro");
assert_eq!(
(
&value["data"]["items"][0]["key"],
&value["data"]["items"][0]["name"],
&value["data"]["items"][0]["latencyP95Ms"],
&value["data"]["dimension"],
),
(
&serde_json::json!("acct_diag"),
&serde_json::json!("diag@example.invalid"),
&serde_json::json!(3800),
&serde_json::json!("account"),
)
);
for (authentication_kind, plan_type, plan_display) in [
(Some("oauth"), Some("pro"), Some("Pro")),
(Some("api_key"), None, None),
(None, None, None),
] {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
*fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation {
analytics: Default::default(),
total_request_count: 4,
items: vec![DiagnosticObservation {
key: "acct_diag".to_owned(),
account_provider_kind: Some("openai".to_owned()),
account_authentication_kind: authentication_kind.map(str::to_owned),
account_plan_type: plan_type.map(str::to_owned),
name: "diag@example.invalid".to_owned(),
request_count: 2,
success_count: 2,
failure_count: 0,
attempt_count: 5,
total_tokens: 200,
average_latency_ms: Some(100),
latency_p95_ms: Some(3800),
first_token_p95_ms: Some(1200),
non_completion_count: 0,
retry_count: 3,
retried_request_count: 1,
cost_coverage: CostCoverage::default(),
costs: Vec::new(),
}],
};
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/usage/insights/diagnostics?dimension=account")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_diagnostics_snapshot")
.body(Body::empty())
.expect("diagnostics request"),
)
.await
.expect("diagnostics response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("diagnostics body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
assert_eq!(value["data"]["items"][0]["requestShare"], 0.5);
assert_eq!(value["data"]["items"][0]["retryCount"], 3);
assert_eq!(value["data"]["items"][0]["retryRate"], 0.5);
assert_eq!(
value["data"]["items"][0]["authenticationKind"],
serde_json::json!(authentication_kind)
);
assert_eq!(
value["data"]["items"][0]["accountPlanType"],
serde_json::json!(plan_type)
);
assert_eq!(
value["data"]["items"][0]["accountPlanTypeDisplay"],
serde_json::json!(plan_display)
);
assert_eq!(
(
&value["data"]["items"][0]["key"],
&value["data"]["items"][0]["name"],
&value["data"]["items"][0]["latencyP95Ms"],
&value["data"]["dimension"],
),
(
&serde_json::json!("acct_diag"),
&serde_json::json!("diag@example.invalid"),
&serde_json::json!(3800),
&serde_json::json!("account"),
)
);
}
}
pub(super) fn usage_record_with_account(
@@ -1753,6 +1770,7 @@ async fn dashboard_summary_should_identify_metrics_without_localized_labels() {
.map(|(index, (requests, tokens, last_used_at))| {
let account_id = format!("acct_metric_{index}");
DashboardAccountUsage {
upstream: None,
account_id: account_id.clone(),
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
@@ -1779,6 +1797,7 @@ async fn dashboard_summary_should_identify_metrics_without_localized_labels() {
request_buckets: Vec::new(),
quota_used_percent: None,
quota_window: requests.map(|request_count| ProviderQuotaWindow {
upstream: None,
key: "rolling".to_owned(),
group: "shortTerm".to_owned(),
label: "滚动窗口".to_owned(),
@@ -1952,6 +1971,7 @@ async fn dashboard_relative_time_preserves_server_anchor_boundaries_and_deployme
provider_accounts: Default::default(),
trend: Vec::new(),
account_usage: vec![DashboardAccountUsage {
upstream: None,
account_id: "acct_relative_time".to_owned(),
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
@@ -192,6 +192,7 @@ impl ProxyStore for MemoryProxies {
}
Ok(ProxyAccountPage {
items: vec![ProxyAccountRef {
upstream: None,
id: "acct_linked".to_owned(),
name: "工作账号".to_owned(),
email: Some("work@example.invalid".to_owned()),
@@ -456,7 +457,7 @@ async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_creden
json!([{
"id":"acct_linked", "name":"工作账号", "email":"work@example.invalid",
"provider":"openai", "enabled":true, "authenticationKind":"oauth",
"planType":"plus", "planTypeDisplay":"Plus", "groups":[]
"planType":"plus", "planTypeDisplay":"Plus", "groups":[], "upstream":null
}])
);
@@ -0,0 +1,51 @@
//! 账号 API 通过公共互联端口读观测与提交刷新,不由具体 Provider 执行
use super::*;
use gateway_admin::model::provider_credentials::{ProviderQuota, ProviderQuotaReadRequest};
use gateway_admin::ports::peer::PeerUpstreamService;
use gateway_core::account::{ProviderAccount, UpstreamInfoSource, UpstreamSchedulingSignal};
use gateway_core::error::StoreError;
use std::time::SystemTime;
pub(super) struct TestPeerInfo(pub Arc<Mutex<Option<ProviderQuota>>>);
impl UpstreamInfoSource for TestPeerInfo {
fn signal(
&self,
_: &ProviderAccount,
_: &str,
_: SystemTime,
) -> Option<UpstreamSchedulingSignal> {
None
}
}
#[async_trait]
impl PeerUpstreamService for TestPeerInfo {
async fn account_changed(&self, _: &ProviderAccountId) -> Result<(), StoreError> {
Ok(())
}
fn forget(&self, _: &ProviderAccountId) {}
async fn refresh_quota(
&self,
_: &ProviderAccountId,
) -> Result<ProviderQuota, ProviderAdminError> {
Ok(self.0.lock().unwrap().clone().unwrap_or_default())
}
async fn read_quota(&self, _: &ProviderAccountId) -> Result<Option<ProviderQuota>, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn read_summary(
&self,
_: &ProviderAccountId,
) -> Result<Option<ProviderQuota>, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn read_quotas(
&self,
requests: &[ProviderQuotaReadRequest<'_>],
) -> Vec<Result<ProviderQuota, ProviderAdminError>> {
let quota = self.0.lock().unwrap().clone().unwrap_or_default();
requests.iter().map(|_| Ok(quota.clone())).collect()
}
}
@@ -18,6 +18,7 @@ pub(super) async fn fixture() -> AdminTestFixture {
let fixture = key_fixture().await;
let now = Utc::now();
*fixture.client_key.lock().unwrap() = Some(ClientKeyRecord {
peer_sharing: Default::default(),
request_profile_overrides: Default::default(),
id: ClientApiKeyId::new("key-42").unwrap(),
name: "Development".to_owned(),
@@ -165,9 +165,9 @@ async fn classified_capacity_error_without_special_code_returns_retryable_http_s
}
#[tokio::test]
async fn key_budget_errors_preserve_limit_code_and_retry_after() {
async fn key_budget_errors_use_the_official_quota_type_and_preserve_limit_details() {
for code in ["key_daily_budget_exceeded", "key_weekly_budget_exceeded"] {
let error = GatewayError::new(GatewayErrorKind::RateLimited, "key budget exhausted")
let error = GatewayError::new(GatewayErrorKind::BudgetExhausted, "key budget exhausted")
.with_client_code(code)
.with_retry_after(std::time::Duration::from_millis(1501));
let response = gateway_error_response(&error);
@@ -176,6 +176,7 @@ async fn key_budget_errors_preserve_limit_code_and_retry_after() {
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap();
assert_eq!(body["error"]["code"], code);
assert_eq!(body["error"]["type"], "usage_limit_reached");
}
}
@@ -9,6 +9,7 @@ mod images;
mod live;
mod middleware;
mod models;
mod peer_link;
mod responses;
mod router;
mod search;
@@ -54,6 +54,7 @@ impl ModelsExecution {
.to_vec(),
)
.with_context_window_tokens(Some(500_000))
.with_reasoning_summary(true)
.with_max_context_window_tokens(Some(900_000))
.with_image_input(true)
.with_agent_tools(true, true)
@@ -319,6 +320,7 @@ async fn native_catalog_preserves_complete_objects_and_only_rewrites_alias_slug(
"model_messages": {"token_budget": "upstream template", "future_section": [null, {"a": true}]},
"service_tiers": [{"id": "priority", "name": "Fast", "description": "Official speed tier"}],
"tool_mode": "code_mode_only", "use_responses_lite": true,
"supports_reasoning_summary_parameter": true, "default_reasoning_summary": "detailed",
"future_field": {"nested": [1, null, "value"]}, "explicit_null": null
});
let entry = |model: &str| PublicModelDescriptor::Native {
@@ -559,6 +561,8 @@ async fn models_should_encode_provider_profiles_for_current_codex_clients() {
);
assert_eq!(model["slug"], "grok-4.5");
assert_eq!(model["default_reasoning_level"], "medium");
assert_eq!(model["supports_reasoning_summary_parameter"], true);
assert_eq!(model["default_reasoning_summary"], "auto");
assert_eq!(model["context_window"], 500_000);
assert_eq!(model["max_context_window"], 900_000);
assert_eq!(model["apply_patch_tool_type"], "freeform");
@@ -594,6 +598,40 @@ async fn models_should_encode_provider_profiles_for_current_codex_clients() {
);
}
#[tokio::test]
async fn models_should_keep_reasoning_effort_independent_from_summary_support() {
for summary in [None, Some(false), Some(true)] {
let mut presentation = ModelPresentation::default()
.with_reasoning(Some("low".into()), vec!["low".into(), "high".into()]);
if let Some(summary) = summary {
presentation = presentation.with_reasoning_summary(summary);
}
let response = api_router(ModelsExecution::with_presentation(presentation))
.await
.oneshot(authorized_request("/v1/models?client_version=0.166.0"))
.await
.expect("Codex catalog response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
let model = &value["models"][0];
assert_eq!(model["default_reasoning_level"], "low");
assert_eq!(model["supported_reasoning_levels"][1]["effort"], "high");
assert_eq!(
model["supports_reasoning_summary_parameter"],
summary.unwrap_or(false)
);
assert_eq!(
model["default_reasoning_summary"],
if summary == Some(true) {
"auto"
} else {
"none"
}
);
}
}
#[tokio::test]
async fn models_should_preserve_unknown_context_windows() {
for (context_window, max_context_window) in
@@ -0,0 +1,234 @@
//! 原生目录来源仅用于展示,互联额度覆盖仍按账号的模型候选资格计算
use super::*;
use futures::future::BoxFuture;
use gateway_core::{
account::{AccountModelAccess, AccountModelAccessMode},
operation::OperationKind,
routing::{
ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable,
ProviderModelCapabilities,
snapshot::{
RuntimeSnapshotCompiler, SnapshotAccountGroupFacts, SnapshotAccountGroupMemberFacts,
SnapshotClientPolicyFacts, SnapshotFacts, SnapshotProviderAccountFacts,
SnapshotStoreError, SnapshotStorePort,
},
},
};
struct CatalogFixture(SnapshotFacts, usize);
impl SnapshotStorePort for CatalogFixture {
fn load_snapshot_facts(&self) -> BoxFuture<'_, Result<SnapshotFacts, SnapshotStoreError>> {
Box::pin(async { Ok(self.0.clone()) })
}
fn current_config_revision(&self) -> BoxFuture<'_, Result<ConfigRevision, SnapshotStoreError>> {
Box::pin(async { Ok(ConfigRevision::new(1).unwrap()) })
}
}
impl ProviderCatalogPort for CatalogFixture {
fn is_direct_account(&self, _: &ProviderKind, authentication_kind: &str) -> bool {
authentication_kind == "oauth"
}
fn catalog_generations(&self) -> BTreeMap<ProviderKind, ProviderCatalogGeneration> {
[(
ProviderKind::new("openai").unwrap(),
ProviderCatalogGeneration::new(1),
)]
.into()
}
fn query_model_capabilities(
&self,
_: &ProviderKind,
) -> BoxFuture<'_, Result<Vec<ProviderModelCapabilities>, ProviderCatalogUnavailable>> {
Box::pin(async {
Ok((0..self.1)
.map(|index| {
ProviderModelCapabilities::new(
UpstreamModelId::new(if index == 0 {
"model-a".to_owned()
} else {
format!("model-{index}")
})
.unwrap(),
ModelCapabilities::new([OperationKind::Generate].into(), None),
)
.with_catalog_accounts(
[ProviderAccountId::new("acct_catalog_source").unwrap()].into(),
)
})
.collect())
})
}
}
#[tokio::test]
async fn native_quota_coverage_uses_routing_eligibility_and_current_model_permissions() {
let (fixture, _) = fixture(sharing(), RAW_KEY).await;
let snapshot = catalog_snapshot(&fixture, 1).await;
let app = router_with_snapshot(&fixture, snapshot);
// 目录来源账号当前不可读,另一个未贡献目录的账号仍有可用观测,不能被漏掉
let body = response_json(query(&app, PATH, Some(RAW_KEY)).await).await;
assert_eq!(
body["quota"]["accounts"][0]["models"],
json!(["alias", "model-a"])
);
for model in body["models"].as_array().unwrap() {
assert_eq!(model["state"], "available");
assert_eq!(model["coverage"], "partial");
}
fixture
.account
.lock()
.unwrap()
.as_mut()
.unwrap()
.account
.model_access =
AccountModelAccess::new(AccountModelAccessMode::Denylist, vec!["model-a".into()]).unwrap();
let body = response_json(query(&app, PATH, Some(RAW_KEY)).await).await;
assert_eq!(body["quota"]["accounts"][0]["models"], json!([]));
for model in body["models"].as_array().unwrap() {
assert_eq!(model["state"], "unknown");
assert_eq!(model["coverage"], "partial");
}
}
async fn catalog_snapshot(fixture: &AdminTestFixture, models: usize) -> RuntimeSnapshot {
let group = fixture.client_key.lock().unwrap().as_ref().unwrap().groups[0].clone();
let account_ids = ["acct_catalog_source", "acct_private_sentinel"]
.map(|id| ProviderAccountId::new(id).unwrap());
let facts = Arc::new(CatalogFixture(
SnapshotFacts::new(
ConfigRevision::new(1).unwrap(),
ConfigRevision::new(1).unwrap(),
gateway_core::settings::SettingsValues::new(
2,
"smart",
[("alias".into(), "model-a".into())].into(),
None,
None,
),
vec![
SnapshotClientPolicyFacts::new(
ClientApiKeyId::new("key-42").unwrap(),
PlaintextClientApiKey::new(RAW_KEY).unwrap(),
vec![group.id.clone()],
RateLimits::unlimited(),
)
.with_peer_sharing(sharing()),
],
vec![SnapshotAccountGroupFacts::new(
group.id.clone(),
group.name,
true,
)],
account_ids
.iter()
.map(|id| {
SnapshotProviderAccountFacts::new(id.clone(), "openai")
.with_authentication_kind("oauth".into())
})
.collect(),
account_ids
.iter()
.map(|id| SnapshotAccountGroupMemberFacts::new(group.id.clone(), id.clone()))
.collect(),
),
models,
));
RuntimeSnapshotCompiler::new(facts.clone(), facts)
.compile()
.await
.unwrap()
}
#[tokio::test]
async fn native_catalog_accepts_the_model_limit_and_rejects_overflow() {
let (fixture, _) = fixture(sharing(), RAW_KEY).await;
// 目录还公开一个 alias,边界按公开名称总数计算
for (count, expected) in [
(gateway_protocol::peer::MAX_MODELS - 1, StatusCode::OK),
(
gateway_protocol::peer::MAX_MODELS,
StatusCode::SERVICE_UNAVAILABLE,
),
] {
let app = router_with_snapshot(&fixture, catalog_snapshot(&fixture, count).await);
let response = query(&app, PATH, Some(RAW_KEY)).await;
assert_eq!(response.status(), expected);
if expected == StatusCode::OK {
let bytes = axum::body::to_bytes(
response.into_body(),
gateway_protocol::peer::MAX_RESPONSE_BYTES,
)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
body["models"].as_array().unwrap().len(),
gateway_protocol::peer::MAX_MODELS
);
} else {
assert!(response_json(response).await.get("pool").is_none());
}
}
}
#[tokio::test]
async fn model_expansion_honors_the_byte_budget_and_current_permissions() {
use gateway_admin::{PeerLinkError, model::peer_link::PeerLinkLimits};
use gateway_core::engine::execution::ExecutionService as _;
let (fixture, _) = fixture(sharing(), RAW_KEY).await;
let execution = execution_with_snapshot(catalog_snapshot(&fixture, 1).await);
let client = execution.authenticate(RAW_KEY).unwrap();
let limits = PeerLinkLimits {
max_accounts: 3000,
max_models: 2000,
max_quota_windows: 6000,
max_response_bytes: 12,
};
let snapshot = fixture
.services
.peer_link()
.snapshot(&client, limits)
.await
.unwrap();
assert_eq!(snapshot.quota.unwrap()[0].models, ["alias", "model-a"]);
let limits = PeerLinkLimits {
max_response_bytes: 11,
..limits
};
assert!(matches!(
fixture.services.peer_link().snapshot(&client, limits).await,
Err(PeerLinkError::SnapshotTooLarge)
));
fixture
.account
.lock()
.unwrap()
.as_mut()
.unwrap()
.account
.model_access =
AccountModelAccess::new(AccountModelAccessMode::Denylist, vec!["model-a".into()]).unwrap();
let snapshot = fixture
.services
.peer_link()
.snapshot(&client, limits)
.await
.unwrap();
assert!(snapshot.quota.unwrap()[0].models.is_empty());
assert!(
snapshot
.models
.iter()
.all(|model| model.state == gateway_admin::model::peer_link::PeerModelState::Unknown)
);
}
@@ -0,0 +1,537 @@
//! 验证互联导出的授权范围、过期观测和独立于推理准入的只读合同
mod catalog;
use crate::{
admin::AdminTestFixture,
support::{RAW_KEY, empty_request, key_fixture, response_json},
};
use axum::{
Router,
http::{Method, StatusCode, header},
response::Response,
};
use chrono::{TimeDelta, Utc};
use gateway_admin::model::{
Revision,
account_groups::{AccountGroupColor, AccountGroupRef},
accounts::{AccountCapacity, AccountPageItem, AccountRecord},
provider_credentials::{
ProviderQuota, ProviderQuotaWindow, ProviderQuotaWindowRole, QuotaLocalUsageAttribution,
},
};
use gateway_core::{
account::{
AccountStatusFacts, CredentialState, ProviderAccountId, QuotaEvidence, QuotaState,
resolve_account_status,
},
engine::{execution::DefaultExecutionService, provider::ProviderRegistry},
policy::{ClientApiKeyId, ClientPeerSharing, ClientPolicy, PlaintextClientApiKey, RateLimits},
routing::{
AccountGroupId, ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities,
ProviderKind, ProviderModel, RoutingGroupSnapshot, RuntimeAccount, RuntimeAccountDirectory,
RuntimeSnapshot, UpstreamModelId,
},
runtime::RuntimeSnapshotHandle,
};
use serde_json::json;
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
};
use tower::ServiceExt as _;
const PATH: &str = "/v1/cpr/link";
fn sharing() -> ClientPeerSharing {
ClientPeerSharing {
enabled: true,
share_pool: true,
share_quota: true,
share_plans: false,
}
}
async fn fixture(sharing: ClientPeerSharing, plaintext: &str) -> (AdminTestFixture, Router) {
let fixture = key_fixture().await;
let now = Utc::now();
let group = AccountGroupRef {
id: AccountGroupId::new(format!("grp_{}", "1".repeat(32))).unwrap(),
name: "private-group-sentinel".into(),
color: AccountGroupColor::parse("#2563EBFF").unwrap(),
enabled: true,
};
let id = ClientApiKeyId::new("key-42").unwrap();
let mut key = fixture
.services
.client_keys()
.reveal(&id)
.await
.unwrap()
.record;
key.peer_sharing = sharing;
key.groups = vec![group.clone()];
key.name = "private-key-sentinel".into();
key.budget.limits.daily_usd = "1".parse().unwrap();
key.budget.daily_used_usd = "2".parse().unwrap();
*fixture.client_key.lock().unwrap() = Some(key);
let provider = ProviderKind::new("openai").unwrap();
let facts = AccountStatusFacts {
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
quota: QuotaState::allowed(now.into()),
cooldown: None,
last_error_reason: None,
last_error_message: None,
};
*fixture.account.lock().unwrap() = Some(AccountPageItem {
protection_rules: Vec::new(),
capacity: AccountCapacity {
used_slots: None,
total_slots: None,
},
projection: resolve_account_status(&facts, now.into()),
account: AccountRecord {
id: "acct_private_sentinel".into(),
provider_kind: provider,
groups: vec![group.clone()],
name: "private-account-sentinel".into(),
notes: None,
email: Some("private-email-sentinel@example.invalid".into()),
upstream_user_id: None,
upstream_account_id: None,
plan_type: Some("pro".into()),
authentication_kind: "oauth".into(),
credential_revision: Revision::new(1).unwrap(),
has_refresh_token: true,
access_token_expires_at: None,
next_refresh_at: None,
enabled: true,
concurrency_limit: None,
weight: Default::default(),
model_access: Default::default(),
outbound_proxy_id: None,
outbound_proxy: None,
outbound_proxy_enabled: true,
credential_state: facts.credential_state,
credential_observed_at: now,
quota: facts.quota,
last_error_reason: None,
last_error_message: None,
created_at: now,
updated_at: now,
},
});
*fixture.provider_quota.lock().unwrap() = Some(ProviderQuota {
upstream: None,
plan_type: Some("pro".into()),
observed_at: Some(now),
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
upstream: None,
key: "weekly".into(),
group: "shortTerm".into(),
label: "周额度".into(),
limit_id: None,
limit_name: None,
role: Some(ProviderQuotaWindowRole::Secondary),
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(604800),
used_percent: Some(47.0),
reset_at: Some(now + TimeDelta::days(2)),
limit_reached: false,
local_usage: None,
provider_data: None,
}],
credits: None,
limit_reached: false,
provider_data: None,
});
let app = router_with_sharing(&fixture, sharing, plaintext);
(fixture, app)
}
fn router_with_sharing(
fixture: &AdminTestFixture,
sharing: ClientPeerSharing,
plaintext: &str,
) -> Router {
let group = fixture.client_key.lock().unwrap().as_ref().unwrap().groups[0].clone();
let provider = ProviderKind::new("openai").unwrap();
let id = ClientApiKeyId::new("key-42").unwrap();
let directory = Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([(
ProviderAccountId::new("acct_private_sentinel").unwrap(),
RuntimeAccount::new(provider.clone(), BTreeSet::from([group.id.clone()]))
.with_direct_authentication_kind(Some("oauth".into())),
)])));
let scope = FrozenAccountScope::new(
directory.clone(),
ClientRoutingScope::restricted(
vec![RoutingGroupSnapshot::new(
group.id.clone(),
"test".to_owned(),
)],
BTreeSet::from([group.id]),
BTreeSet::from([provider.clone()]),
)
.unwrap(),
);
let snapshot = RuntimeSnapshot::new(
ConfigRevision::new(1).unwrap(),
gateway_core::settings::SettingsValues::new(2, "smart", Default::default(), None, None),
vec![provider.clone()],
vec![ProviderModel::new(
provider,
UpstreamModelId::new("model-a").unwrap(),
ModelCapabilities::new(
BTreeSet::from([gateway_core::operation::OperationKind::Generate]),
Some(16000),
),
)],
vec![
ClientPolicy::new(
id,
PlaintextClientApiKey::new(plaintext).unwrap(),
Arc::new(scope),
true,
RateLimits::unlimited(),
)
.with_peer_sharing(sharing),
],
)
.unwrap()
.with_account_directory(directory);
router_with_snapshot(fixture, snapshot)
}
fn router_with_snapshot(fixture: &AdminTestFixture, snapshot: RuntimeSnapshot) -> Router {
let execution = execution_with_snapshot(snapshot);
super::api_router_with_admin_and_execution(fixture.services.clone(), execution)
}
fn execution_with_snapshot(snapshot: RuntimeSnapshot) -> Arc<DefaultExecutionService> {
Arc::new(DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
Arc::new(super::UnusedExecutionStore),
ProviderRegistry::default(),
Arc::new(super::UnusedAdmissions),
Arc::new(super::UnusedContinuation),
Arc::new(super::IgnoredClientApiKeyUsage),
Arc::new(crate::support::RecordingDiagnostics::default()),
))
}
async fn query(app: &Router, path: &str, plaintext: Option<&str>) -> Response {
let mut request = empty_request(Method::GET, path);
if let Some(key) = plaintext {
request.headers_mut().insert(
header::AUTHORIZATION,
format!("Bearer {key}").parse().unwrap(),
);
}
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
response
}
#[tokio::test]
async fn exports_scoped_observations_even_when_the_key_budget_is_exhausted_without_writing_usage() {
let (fixture, app) = fixture(sharing(), RAW_KEY).await;
let before = fixture.client_key.lock().unwrap().clone();
let response = query(&app, PATH, Some(RAW_KEY)).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["object"], "cpr.peer");
assert_eq!(body["schema_version"], 1);
assert_eq!(body["usage"]["data"]["daily"]["remaining"], "0");
assert_eq!(body["usage"]["data"]["allowed"], false);
assert_eq!(body["usage"]["data"]["daily"]["limit_reached"], true);
assert_eq!(body["pool"]["account_count"], 1);
assert!(body["pool"].get("plans").is_none());
assert_eq!(
body["quota"]["accounts"][0]["windows"][0]["used_percent"],
47.0
);
assert_eq!(body["models"][0]["state"], "available");
assert_eq!(body["models"][0]["coverage"], "complete");
assert!(!body.to_string().contains("sentinel"));
assert!(!body.to_string().contains(RAW_KEY));
assert_eq!(*fixture.client_key.lock().unwrap(), before);
assert!(fixture.observations.lock().unwrap().summaries.is_empty());
}
#[tokio::test]
async fn requires_authentication_explicit_sharing_and_bound_groups_and_rejects_query_overrides() {
let (fixture, app) = fixture(sharing(), RAW_KEY).await;
assert_eq!(
query(&app, PATH, None).await.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
query(&app, &format!("{PATH}?account=private"), Some(RAW_KEY))
.await
.status(),
StatusCode::BAD_REQUEST
);
fixture
.client_key
.lock()
.unwrap()
.as_mut()
.unwrap()
.peer_sharing
.enabled = false;
assert_eq!(
query(&app, PATH, Some(RAW_KEY)).await.status(),
StatusCode::FORBIDDEN
);
{
let mut key = fixture.client_key.lock().unwrap();
let key = key.as_mut().unwrap();
key.peer_sharing.enabled = true;
key.groups.clear();
}
assert_eq!(
query(&app, PATH, Some(RAW_KEY)).await.status(),
StatusCode::FORBIDDEN
);
fixture.client_key.lock().unwrap().as_mut().unwrap().enabled = false;
assert_eq!(
query(&app, PATH, Some(RAW_KEY)).await.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn disabled_or_changed_group_bindings_remove_accounts_from_the_frozen_scope() {
let (fixture, app) = fixture(sharing(), RAW_KEY).await;
let original_group = fixture.client_key.lock().unwrap().as_ref().unwrap().groups[0].clone();
fixture.client_key.lock().unwrap().as_mut().unwrap().groups[0].enabled = false;
let response = query(&app, PATH, Some(RAW_KEY)).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["pool"]["account_count"], 0);
{
let mut key = fixture.client_key.lock().unwrap();
let group = &mut key.as_mut().unwrap().groups[0];
*group = original_group.clone();
group.id = AccountGroupId::new(format!("grp_{}", "2".repeat(32))).unwrap();
}
let body = response_json(query(&app, PATH, Some(RAW_KEY)).await).await;
assert_eq!(body["pool"]["account_count"], 0);
fixture.client_key.lock().unwrap().as_mut().unwrap().groups[0] = original_group;
let body = response_json(query(&app, PATH, Some(RAW_KEY)).await).await;
assert_eq!(body["pool"]["account_count"], 1);
}
#[tokio::test]
async fn current_field_permissions_and_direct_account_facts_narrow_the_frozen_scope() {
let (fixture, app) = fixture(sharing(), RAW_KEY).await;
fixture
.client_key
.lock()
.unwrap()
.as_mut()
.unwrap()
.peer_sharing
.share_quota = false;
let body = response_json(query(&app, PATH, Some(RAW_KEY)).await).await;
assert_eq!(body["quota"], json!({ "disclosure": "none" }));
assert!(body.get("models").is_none());
fixture
.account
.lock()
.unwrap()
.as_mut()
.unwrap()
.account
.authentication_kind = "api_key".into();
let body = response_json(query(&app, PATH, Some(RAW_KEY)).await).await;
assert_eq!(body["pool"]["account_count"], 0);
}
#[tokio::test]
async fn stale_observations_remain_visible_but_cannot_claim_exhausted_model_coverage() {
let (fixture, app) = fixture(sharing(), RAW_KEY).await;
let old = Utc::now() - TimeDelta::hours(1);
fixture
.provider_quota
.lock()
.unwrap()
.as_mut()
.unwrap()
.observed_at = Some(old);
fixture
.account
.lock()
.unwrap()
.as_mut()
.unwrap()
.account
.quota = QuotaState::exhausted(QuotaEvidence::ProviderDenied, old.into(), None);
let body = response_json(query(&app, PATH, Some(RAW_KEY)).await).await;
assert_eq!(
body["quota"]["accounts"][0]["observed_at"],
old.to_rfc3339()
);
assert_eq!(body["models"][0]["state"], "unknown");
assert_eq!(body["models"][0]["coverage"], "partial");
fixture
.account
.lock()
.unwrap()
.as_mut()
.unwrap()
.account
.quota = QuotaState::exhausted(QuotaEvidence::ProviderDenied, Utc::now().into(), None);
let body = response_json(query(&app, PATH, Some(RAW_KEY)).await).await;
assert_eq!(body["models"][0]["state"], "exhausted");
assert_eq!(body["models"][0]["coverage"], "complete");
}
#[tokio::test]
async fn account_identifiers_are_isolated_per_key_and_peer_queries_have_separate_rate_limits() {
let (_, app) = fixture(sharing(), RAW_KEY).await;
let body = response_json(query(&app, PATH, Some(RAW_KEY)).await).await;
let (_, other) = fixture(sharing(), "different-peer-key").await;
let other_body = response_json(query(&other, PATH, Some("different-peer-key")).await).await;
assert_ne!(
body["quota"]["accounts"][0]["id"],
other_body["quota"]["accounts"][0]["id"]
);
for _ in 1..30 {
assert_eq!(
query(&app, PATH, Some(RAW_KEY)).await.status(),
StatusCode::OK
);
}
let response = query(&app, PATH, Some(RAW_KEY)).await;
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(response.headers().contains_key(header::RETRY_AFTER));
assert_eq!(
query(&app, "/v1/usage", Some(RAW_KEY)).await.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn oversized_snapshots_are_rejected_without_returning_a_truncated_pool() {
let (fixture, app) = fixture(sharing(), RAW_KEY).await;
let window = fixture
.provider_quota
.lock()
.unwrap()
.as_ref()
.unwrap()
.windows[0]
.clone();
fixture
.provider_quota
.lock()
.unwrap()
.as_mut()
.unwrap()
.windows = vec![window.clone(); gateway_protocol::peer::MAX_QUOTA_WINDOWS + 1];
let response = query(&app, PATH, Some(RAW_KEY)).await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(response_json(response).await.get("pool").is_none());
let mut oversized = window;
oversized.label = "x".repeat(gateway_protocol::peer::MAX_RESPONSE_BYTES);
fixture
.provider_quota
.lock()
.unwrap()
.as_mut()
.unwrap()
.windows = vec![oversized];
assert_eq!(
query(&app, PATH, Some(RAW_KEY)).await.status(),
StatusCode::SERVICE_UNAVAILABLE
);
}
#[tokio::test]
async fn heavy_snapshots_reject_excess_concurrency_and_release_slots_on_completion_or_cancellation()
{
let (fixture, app) = fixture(sharing(), RAW_KEY).await;
let gate = Arc::new(crate::admin::ProviderQuotaGate {
entered: tokio::sync::Semaphore::new(0),
release: tokio::sync::Semaphore::new(0),
});
*fixture.provider_quota_gate.lock().unwrap() = Some(gate.clone());
let mut requests = Vec::new();
for _ in 0..8 {
let app = app.clone();
requests.push(tokio::spawn(async move {
query(&app, PATH, Some(RAW_KEY)).await
}));
}
tokio::time::timeout(
std::time::Duration::from_secs(2),
gate.entered.acquire_many(8),
)
.await
.expect("eight snapshots entered quota aggregation")
.unwrap()
.forget();
let current_key = fixture.client_key.lock().unwrap().take();
let response = tokio::time::timeout(
std::time::Duration::from_secs(2),
query(&app, PATH, Some(RAW_KEY)),
)
.await
.expect("excess snapshot must not wait for a slot");
*fixture.client_key.lock().unwrap() = current_key;
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(response.headers()[header::RETRY_AFTER], "1");
assert_eq!(
response_json(response).await["error"]["code"],
"peer_query_rate_limited"
);
assert_eq!(
query(&app, "/v1/usage", Some(RAW_KEY)).await.status(),
StatusCode::OK
);
let budget_app = router_with_sharing(
&fixture,
ClientPeerSharing {
enabled: true,
..Default::default()
},
RAW_KEY,
);
let budget = tokio::time::timeout(
std::time::Duration::from_secs(2),
query(&budget_app, PATH, Some(RAW_KEY)),
)
.await
.expect("budget-only queries do not consume heavy-query slots");
assert_eq!(budget.status(), StatusCode::OK);
assert_eq!(response_json(budget).await["pool"]["disclosure"], "none");
let cancelled = requests.pop().unwrap();
cancelled.abort();
assert!(cancelled.await.unwrap_err().is_cancelled());
let app_for_replacement = app.clone();
requests.push(tokio::spawn(async move {
query(&app_for_replacement, PATH, Some(RAW_KEY)).await
}));
tokio::time::timeout(std::time::Duration::from_secs(2), gate.entered.acquire())
.await
.expect("cancelled snapshot released its slot")
.unwrap()
.forget();
gate.release.add_permits(8);
for request in requests {
assert_eq!(request.await.unwrap().status(), StatusCode::OK);
}
gate.release.add_permits(1);
assert_eq!(
query(&app, PATH, Some(RAW_KEY)).await.status(),
StatusCode::OK
);
}
@@ -19,6 +19,7 @@ pub mod scope;
mod selection;
mod smart_scheduling;
mod store;
mod upstream;
pub use error::CredentialError;
pub use model::*;
@@ -26,3 +27,4 @@ pub(crate) use selection::smart_score;
pub use selection::*;
pub use smart_scheduling::{SmartSchedulingConfig, SmartSchedulingConfigError};
pub use store::ProviderAccountStore;
pub use upstream::*;
@@ -1392,9 +1392,19 @@ pub enum CredentialCasOutcome {
Conflict,
}
/// 当前额度快照的轻量版本,不包含协议正文
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuotaObservationVersion {
pub account_id: ProviderAccountId,
pub credential_revision: CredentialRevision,
pub observed_at: SystemTime,
}
/// Provider quota 的一次完整观察结果
#[derive(Clone, PartialEq)]
pub struct QuotaObservation {
/// 网络查询可额外校验请求出口与启用状态,避免连接变化后的迟到写入
pub expected_connection: Option<super::UpstreamConnection>,
pub account_id: ProviderAccountId,
pub expected_revision: CredentialRevision,
pub quota: OpaqueProviderData,
@@ -48,6 +48,7 @@ pub struct RuntimeAccount {
provider_kind: ProviderKind,
group_ids: Arc<BTreeSet<AccountGroupId>>,
model_access: super::AccountModelAccess,
direct_authentication_kind: Option<String>,
}
impl RuntimeAccount {
@@ -57,6 +58,7 @@ impl RuntimeAccount {
provider_kind,
group_ids: Arc::new(group_ids),
model_access: super::AccountModelAccess::all(),
direct_authentication_kind: None,
}
}
@@ -66,6 +68,23 @@ impl RuntimeAccount {
self
}
/// Provider 确认该认证方式直接使用自身资源,而非继续转供另一个节点
#[must_use]
pub fn with_direct_authentication_kind(mut self, kind: Option<String>) -> Self {
self.direct_authentication_kind = kind;
self
}
#[must_use]
pub const fn is_direct_account(&self) -> bool {
self.direct_authentication_kind.is_some()
}
#[must_use]
pub fn direct_authentication_kind(&self) -> Option<&str> {
self.direct_authentication_kind.as_deref()
}
#[must_use]
pub const fn model_access(&self) -> &super::AccountModelAccess {
&self.model_access
@@ -248,6 +267,7 @@ impl ClientRoutingScope {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrozenAccountScope {
allow_image_generation: bool,
direct_accounts_only: bool,
fast_mode: FastMode,
request_profiles: BTreeMap<ProviderKind, super::OpaqueProviderData>,
directory: Arc<RuntimeAccountDirectory>,
@@ -258,6 +278,31 @@ pub struct FrozenAccountScope {
}
impl FrozenAccountScope {
/// 枚举冻结权限实际允许的账号,保留所有既有直连和分组约束
pub fn allowed_account_ids(&self) -> impl Iterator<Item = &ProviderAccountId> {
self.directory.accounts.keys().filter(|id| self.allows(id))
}
/// 互联限制与原账号范围取交集,分组变更和请求局部重选均不能扩大它
#[must_use]
pub fn with_direct_accounts_only(mut self, enabled: bool) -> Self {
self.direct_accounts_only = enabled;
if enabled {
self.provider_kinds = Arc::new(
self.provider_kinds
.iter()
.filter(|provider| {
self.directory.accounts.iter().any(|(id, account)| {
account.provider_kind() == *provider && self.allows(id)
})
})
.cloned()
.collect(),
);
}
self
}
/// Key 绑定分组合并后的生图权限,与账号成员资格一同冻结
#[must_use]
pub const fn with_image_generation(mut self, allowed: bool) -> Self {
@@ -310,6 +355,7 @@ impl FrozenAccountScope {
};
Self {
allow_image_generation: true,
direct_accounts_only: false,
fast_mode: FastMode::Default,
request_profiles: BTreeMap::new(),
directory,
@@ -369,7 +415,10 @@ impl FrozenAccountScope {
let Some(account) = self.directory.account(account_id) else {
return false;
};
if self.excluded_account_ids.contains(account_id)
if (self.direct_accounts_only
&& (!account.is_direct_account()
|| matches!(self.client_scope, ClientRoutingScope::AllAccounts)))
|| self.excluded_account_ids.contains(account_id)
|| !self.provider_kinds.contains(account.provider_kind())
|| self
.allowed_account_ids
@@ -398,6 +447,16 @@ impl FrozenAccountScope {
.is_some_and(|account| account.model_access.allows(upstream_model))
}
/// 当前凭据类型必须与已授权的直连事实相同,快照发布间隙不能转成代理账号
#[must_use]
pub fn allows_current_account(&self, account: &super::ProviderAccount) -> bool {
self.allows(account.id())
&& (!self.direct_accounts_only
|| self.directory.account(account.id()).is_some_and(|frozen| {
frozen.direct_authentication_kind() == Some(account.authentication_kind())
}))
}
/// 目录按整个授权账号池过滤,不能只看用于获取元数据的账号政策
#[must_use]
pub fn allows_provider_model(&self, provider: &ProviderKind, upstream_model: &str) -> bool {
@@ -848,7 +848,7 @@ impl AccountSelector {
if context
.account_scope
.as_ref()
.is_some_and(|scope| !scope.allows(candidate.account.id()))
.is_some_and(|scope| !scope.allows_current_account(&candidate.account))
{
return Some(AccountSchedulingBlocker::OutsideClientScope);
}
@@ -9,7 +9,7 @@ use super::{
AccountStateChange, CredentialCasOutcome, CredentialCasUpdate, CredentialRevision,
LoadedCredential, NewProviderAccount, ProviderAccount, ProviderAccountId,
ProviderAccountUpdate, ProviderRefreshQuery, QuotaAccessChange, QuotaObservation,
QuotaObservationTouch, QuotaWriteOutcome,
QuotaObservationTouch, QuotaObservationVersion, QuotaWriteOutcome,
};
/// `provider_accounts` 的数据库中立端口
@@ -60,6 +60,23 @@ pub trait ProviderAccountStore: Send + Sync {
accounts: &[ProviderAccountId],
) -> Result<Vec<QuotaObservation>, StoreError>;
/// 仅查询快照版本,列表缓存校验不物化额度正文
async fn get_quota_versions(
&self,
accounts: &[ProviderAccountId],
) -> Result<Vec<QuotaObservationVersion>, StoreError> {
Ok(self
.get_quotas(accounts)
.await?
.into_iter()
.map(|quota| QuotaObservationVersion {
account_id: quota.account_id,
credential_revision: quota.expected_revision,
observed_at: quota.observed_at,
})
.collect())
}
async fn compare_and_swap_quota(
&self,
observation: QuotaObservation,
@@ -0,0 +1,122 @@
//! 账号连接身份与上游调度只读合同,原始协议由控制面解释
use std::{
sync::{Arc, OnceLock, Weak},
time::SystemTime,
};
use sha2::{Digest, Sha256};
use super::{CredentialRevision, ProviderAccount, ProviderAccountId};
/// 请求发出时的连接身份;出口摘要包含托管代理解析后的认证信息,但不持久化明文
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpstreamConnection {
pub account_id: ProviderAccountId,
pub credential_revision: CredentialRevision,
pub revision: String,
}
impl UpstreamConnection {
#[must_use]
pub fn for_account(account: &ProviderAccount) -> Self {
Self::new(
account.id().clone(),
account.revision(),
account.outbound_proxy(),
)
}
#[must_use]
pub fn new(
account_id: ProviderAccountId,
credential_revision: CredentialRevision,
proxy: Option<&super::OutboundProxy>,
) -> Self {
let mut hash = Sha256::new();
hash.update(credential_revision.get().to_be_bytes());
if let Some(proxy) = proxy {
hash.update(proxy.expose_url().as_bytes());
}
Self {
account_id,
credential_revision,
revision: hash
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect(),
}
}
}
/// 可展示的受控同步原因,不保存上游正文、URL 或凭据
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpstreamInfoFailure {
Unauthorized,
Unsupported,
RateLimited,
InvalidResponse,
Unavailable,
}
impl UpstreamInfoFailure {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Unauthorized => "unauthorized",
Self::Unsupported => "unsupported",
Self::RateLimited => "rate_limited",
Self::InvalidResponse => "invalid_response",
Self::Unavailable => "unavailable",
}
}
}
/// 调度侧只消费已发布的中立观测,不读取协议、凭据或持久存储
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct UpstreamSchedulingSignal {
pub exhausted: bool,
pub remaining_percent: Option<f64>,
}
pub trait UpstreamInfoSource: Send + Sync {
fn signal(
&self,
account: &ProviderAccount,
model: &str,
now: SystemTime,
) -> Option<UpstreamSchedulingSignal>;
}
/// 组合根在管理服务就绪后绑定一次;弱引用避免 Provider 与管理服务互相保活
#[derive(Default)]
pub struct UpstreamInfoReader {
source: OnceLock<Weak<dyn UpstreamInfoSource>>,
}
impl UpstreamInfoReader {
pub fn bind(
&self,
source: &Arc<dyn UpstreamInfoSource>,
) -> Result<(), UpstreamInfoAlreadyBound> {
self.source
.set(Arc::downgrade(source))
.map_err(|_| UpstreamInfoAlreadyBound)
}
}
impl UpstreamInfoSource for UpstreamInfoReader {
fn signal(
&self,
account: &ProviderAccount,
model: &str,
now: SystemTime,
) -> Option<UpstreamSchedulingSignal> {
self.source.get()?.upgrade()?.signal(account, model, now)
}
}
#[derive(Debug, thiserror::Error)]
#[error("upstream information source is already bound")]
pub struct UpstreamInfoAlreadyBound;
@@ -19,6 +19,29 @@ impl ClientBudgetLimits {
pub fn is_limited(self) -> bool {
self.daily_usd != Decimal::ZERO || self.weekly_usd != Decimal::ZERO
}
/// 准入与对外预算查询共用真实账本口径,展示字符串不参与访问判断
#[must_use]
pub fn access(self, daily_used: Decimal, weekly_used: Decimal) -> ClientBudgetAccess {
ClientBudgetAccess {
daily_limit_reached: self.daily_usd != Decimal::ZERO && daily_used >= self.daily_usd,
weekly_limit_reached: self.weekly_usd != Decimal::ZERO
&& weekly_used >= self.weekly_usd,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClientBudgetAccess {
pub daily_limit_reached: bool,
pub weekly_limit_reached: bool,
}
impl ClientBudgetAccess {
#[must_use]
pub const fn allowed(self) -> bool {
!self.daily_limit_reached && !self.weekly_limit_reached
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -1152,6 +1152,11 @@ impl fmt::Debug for ProviderRequest {
/// 新的 attempt 再次调用
#[async_trait]
pub trait Provider: Send + Sync {
/// 声明可用于互联共享的直连认证方式,不执行网络查询
fn is_direct_account(&self, _authentication_kind: &str) -> bool {
false
}
/// Provider 解释自己的隐私规则,Core 仅冻结编译结果
fn compile_privacy_policy(
&self,
@@ -1371,6 +1376,12 @@ impl ProviderRegistry {
}
impl ProviderCatalogPort for ProviderRegistry {
fn is_direct_account(&self, provider: &ProviderKind, authentication_kind: &str) -> bool {
self.providers
.get(provider)
.is_some_and(|provider| provider.is_direct_account(authentication_kind))
}
fn model_catalog_is_exhaustive(&self, provider: &ProviderKind) -> bool {
self.providers
.get(provider)
@@ -1094,6 +1094,8 @@ pub enum GatewayErrorKind {
ProviderInfrastructureUnavailable,
/// 上游限流
RateLimited,
/// 当前客户端 Key 的费用预算耗尽
BudgetExhausted,
/// 上游暂不可用
UpstreamUnavailable,
/// 上游拒收当前请求:消息过大(如 WebSocket close 1009)
@@ -1124,6 +1126,7 @@ impl GatewayErrorKind {
Self::ConcurrencyQueueTimeout => "concurrency_queue_timeout",
Self::ProviderInfrastructureUnavailable => "provider_infrastructure_unavailable",
Self::RateLimited => "rate_limited",
Self::BudgetExhausted => "budget_exhausted",
Self::UpstreamUnavailable => "upstream_unavailable",
Self::MessageTooBig => "message_too_big",
Self::Timeout => "timeout",
@@ -3,6 +3,9 @@
//! Client API Key 冻结账号分组权限;模型名称不参与权限判断
mod client_version;
mod peer_sharing;
pub use peer_sharing::{ClientPeerSharing, ClientPeerSharingError};
pub use client_version::{
ClientVersionRejection, CodexClientKind, CodexClientMinVersions, CodexClientVersion,
@@ -119,6 +122,7 @@ pub struct ClientSettings {
/// 从 `client_api_keys` 冻结的公开准入事实
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientPolicy {
peer_sharing: ClientPeerSharing,
name_snapshot: Option<String>,
defaults: Arc<ClientSettings>,
key_id: ClientApiKeyId,
@@ -164,6 +168,7 @@ impl ClientPolicy {
limits: RateLimits,
) -> Self {
Self {
peer_sharing: ClientPeerSharing::default(),
name_snapshot: None,
defaults: Arc::new(ClientSettings {
request_profiles: account_scope.request_profiles().clone(),
@@ -184,6 +189,23 @@ impl ClientPolicy {
self
}
#[must_use]
pub fn with_peer_sharing(mut self, sharing: ClientPeerSharing) -> Self {
self.account_scope = Arc::new(
self.account_scope
.as_ref()
.clone()
.with_direct_accounts_only(sharing.enabled),
);
self.peer_sharing = sharing;
self
}
#[must_use]
pub const fn peer_sharing(&self) -> ClientPeerSharing {
self.peer_sharing
}
#[must_use]
pub fn name_snapshot(&self) -> Option<&str> {
self.name_snapshot.as_deref()
@@ -0,0 +1,35 @@
//! Client Key 对互联节点公开本地观测的逐项授权
use serde::{Deserialize, Serialize};
/// 公开权限默认关闭,不因协议识别或预算查询自动开启
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ClientPeerSharing {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub share_pool: bool,
#[serde(default)]
pub share_quota: bool,
#[serde(default)]
pub share_plans: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientPeerSharingError {
MissingGroupScope,
PlansWithoutPool,
}
impl ClientPeerSharing {
pub const fn validate(self, has_bound_groups: bool) -> Result<(), ClientPeerSharingError> {
if self.enabled && !has_bound_groups {
return Err(ClientPeerSharingError::MissingGroupScope);
}
if self.share_plans && !self.share_pool {
return Err(ClientPeerSharingError::PlansWithoutPool);
}
Ok(())
}
}

Some files were not shown because too many files have changed in this diff Show More