diff --git a/backend/Cargo.lock b/backend/Cargo.lock index bb90288de..2b89b67fd 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -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", diff --git a/backend/apps/gateway/src/bootstrap/command.rs b/backend/apps/gateway/src/bootstrap/command.rs index f2ddb76ef..2d024af3d 100644 --- a/backend/apps/gateway/src/bootstrap/command.rs +++ b/backend/apps/gateway/src/bootstrap/command.rs @@ -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( diff --git a/backend/apps/gateway/src/bootstrap/plugins.rs b/backend/apps/gateway/src/bootstrap/plugins.rs index 00ee71caa..b0deba600 100644 --- a/backend/apps/gateway/src/bootstrap/plugins.rs +++ b/backend/apps/gateway/src/bootstrap/plugins.rs @@ -42,6 +42,7 @@ impl ManagementPorts { store: &gateway_store::StoreBundle, providers: ProviderAdminRegistry, snapshot: Arc, + upstream_info: Arc, ) -> 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, diff --git a/backend/apps/gateway/src/bootstrap/server.rs b/backend/apps/gateway/src/bootstrap/server.rs index 2e52d185a..ac7bad165 100644 --- a/backend/apps/gateway/src/bootstrap/server.rs +++ b/backend/apps/gateway/src/bootstrap/server.rs @@ -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()) diff --git a/backend/apps/gateway/src/bootstrap/startup.rs b/backend/apps/gateway/src/bootstrap/startup.rs index 0a980d1ed..51d1c522b 100644 --- a/backend/apps/gateway/src/bootstrap/startup.rs +++ b/backend/apps/gateway/src/bootstrap/startup.rs @@ -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,33 @@ impl Providers { environment: &Environment, ) -> Result { 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(), + environment.store.upstream_info(), + &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, diff --git a/backend/apps/gateway/tests/architecture.rs b/backend/apps/gateway/tests/architecture.rs index ad690bdd3..66e7cff15 100644 --- a/backend/apps/gateway/tests/architecture.rs +++ b/backend/apps/gateway/tests/architecture.rs @@ -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"), diff --git a/backend/crates/gateway-admin/Cargo.toml b/backend/crates/gateway-admin/Cargo.toml index 6e96f617e..e2be586aa 100644 --- a/backend/crates/gateway-admin/Cargo.toml +++ b/backend/crates/gateway-admin/Cargo.toml @@ -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" diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index b770ed629..1013b73c5 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -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, auth: Arc, key_usage: Arc, + peer_link: Arc, accounts: Arc, account_groups: Arc, client_keys: Arc, @@ -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>, pub timezone: gateway_core::time::DeploymentTimeZone, pub service_middleware: public_service::PlanSource, pub plugin_preparation: Arc, @@ -388,6 +400,7 @@ async fn initialize_inner( plugin_accounts: Option>, ) -> Result { 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, snapshot: Arc, + upstream_info: Option>, ) -> Arc { 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, account_runtime: Arc, @@ -682,6 +718,7 @@ pub fn initialize_accounts( providers: ProviderAdminRegistry, snapshot: Arc, probe: Arc, + upstream_info: Option>, ) -> Arc { Arc::new(DefaultAccountsService::new( accounts, @@ -691,6 +728,7 @@ pub fn initialize_accounts( providers, snapshot, probe, + upstream_info, )) } diff --git a/backend/crates/gateway-admin/src/model/client_keys.rs b/backend/crates/gateway-admin/src/model/client_keys.rs index f0e22ead6..f32a944a2 100644 --- a/backend/crates/gateway-admin/src/model/client_keys.rs +++ b/backend/crates/gateway-admin/src/model/client_keys.rs @@ -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, 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, pub request_profile_override_updates: ProviderRequestProfileOverrideUpdates, pub id: ClientApiKeyId, pub name: String, diff --git a/backend/crates/gateway-admin/src/model/mod.rs b/backend/crates/gateway-admin/src/model/mod.rs index 2a8a522b1..931b747d0 100644 --- a/backend/crates/gateway-admin/src/model/mod.rs +++ b/backend/crates/gateway-admin/src/model/mod.rs @@ -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; /// 管理用例对外返回的稳定错误分类 diff --git a/backend/crates/gateway-admin/src/model/observability.rs b/backend/crates/gateway-admin/src/model/observability.rs index 177f5b0c9..02aa76b8f 100644 --- a/backend/crates/gateway-admin/src/model/observability.rs +++ b/backend/crates/gateway-admin/src/model/observability.rs @@ -648,6 +648,7 @@ pub struct AccountRequestBucket { /// Dashboard 中一个账号的完整用量事实 #[derive(Debug, Clone, PartialEq)] pub struct DashboardAccountUsage { + pub upstream: Option, pub account_id: String, pub provider_kind: String, pub authentication_kind: String, diff --git a/backend/crates/gateway-admin/src/model/peer_link.rs b/backend/crates/gateway-admin/src/model/peer_link.rs new file mode 100644 index 000000000..f9e47c922 --- /dev/null +++ b/backend/crates/gateway-admin/src/model/peer_link.rs @@ -0,0 +1,82 @@ +//! 当前认证 Key 可公开的本地互联观测,不携带 HTTP 或 Provider 私有字段 + +use super::provider_credentials::ProviderQuotaWindowRole; +use chrono::{DateTime, Utc}; +use gateway_core::engine::budget::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, Eq)] +pub struct PeerNodeIdentity { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PeerLinkExport { + pub node: PeerNodeIdentity, + pub observed_at: DateTime, + pub expires_at: DateTime, + pub budget: ClientBudgetStatus, + pub budget_observed_at: DateTime, + pub pool: Option, + pub quota: Option>, + pub models: Vec, +} + +#[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>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PeerAccountQuota { + pub id: String, + pub models: Vec, + pub observed_at: Option>, + pub expires_at: Option>, + pub windows: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PeerQuotaWindow { + pub key: String, + pub group: String, + pub label: String, + pub limit_id: Option, + pub limit_name: Option, + pub role: Option, + pub window_seconds: Option, + pub used_percent: Option, + pub reset_at: Option>, + 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>, + pub expires_at: Option>, +} diff --git a/backend/crates/gateway-admin/src/model/provider_credentials.rs b/backend/crates/gateway-admin/src/model/provider_credentials.rs index 028af5f6e..fee68c9b1 100644 --- a/backend/crates/gateway-admin/src/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/src/model/provider_credentials.rs @@ -790,6 +790,7 @@ impl ProviderQuotaWindowRole { /// 一个 Provider quota 窗口的公共投影 #[derive(Debug, Clone, PartialEq)] pub struct ProviderQuotaWindow { + pub upstream: Option, 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, /// 上游额度响应明确提供的套餐,可用于补全账号展示 pub plan_type: Option, pub observed_at: Option>, @@ -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, diff --git a/backend/crates/gateway-admin/src/model/proxies.rs b/backend/crates/gateway-admin/src/model/proxies.rs index 1f2f299fb..2459aab5b 100644 --- a/backend/crates/gateway-admin/src/model/proxies.rs +++ b/backend/crates/gateway-admin/src/model/proxies.rs @@ -28,6 +28,7 @@ pub struct ProxyListQuery { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProxyAccountRef { + pub upstream: Option, pub id: String, pub name: String, pub email: Option, diff --git a/backend/crates/gateway-admin/src/model/upstream_info.rs b/backend/crates/gateway-admin/src/model/upstream_info.rs new file mode 100644 index 000000000..1ece3504a --- /dev/null +++ b/backend/crates/gateway-admin/src/model/upstream_info.rs @@ -0,0 +1,93 @@ +//! 管理端互联快照的中立展示事实,不携带 Provider 原始协议 + +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderUpstreamInfo { + pub discovered: bool, + pub node: Option, + pub observed_at: Option>, + pub stale: bool, + pub connection_revision: String, + pub sync: UpstreamSync, + pub last_error: Option, + pub pool: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpstreamNode { + pub id: String, + pub name: String, + pub software: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpstreamSyncState { + Idle, + Queued, + Running, +} + +impl UpstreamSyncState { + pub const fn as_str(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Queued => "queued", + Self::Running => "running", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpstreamSync { + pub state: UpstreamSyncState, + pub request_generation: u64, + pub completed_generation: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpstreamRefreshReceipt { + pub account_id: String, + pub connection_revision: String, + pub request_generation: u64, + pub state: UpstreamSyncState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpstreamPool { + pub account_count: u32, + pub states: UpstreamPoolStates, + pub plans: Vec, +} + +#[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, + pub observed_at: Option>, + pub stale: bool, + pub amount: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpstreamQuotaAmount { + pub unit: String, + pub used: String, + pub remaining: Option, + pub total: Option, +} diff --git a/backend/crates/gateway-admin/src/ports/mod.rs b/backend/crates/gateway-admin/src/ports/mod.rs index 5f53b9662..ec0ac231d 100644 --- a/backend/crates/gateway-admin/src/ports/mod.rs +++ b/backend/crates/gateway-admin/src/ports/mod.rs @@ -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; diff --git a/backend/crates/gateway-admin/src/ports/peer.rs b/backend/crates/gateway-admin/src/ports/peer.rs new file mode 100644 index 000000000..4b59793df --- /dev/null +++ b/backend/crates/gateway-admin/src/ports/peer.rs @@ -0,0 +1,74 @@ +//! 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, +} + +impl From 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, 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 request_refresh( + &self, + id: &gateway_core::account::ProviderAccountId, + ) -> Result; + async fn read_quota( + &self, + account_id: &gateway_core::account::ProviderAccountId, + ) -> Result< + Option, + gateway_core::error::StoreError, + >; + /// 读取版本化摘要,不展开远端节点窗口 + async fn read_summary( + &self, + account_id: &gateway_core::account::ProviderAccountId, + ) -> Result< + Option, + 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, + >, + >; +} diff --git a/backend/crates/gateway-admin/src/ports/provider.rs b/backend/crates/gateway-admin/src/ports/provider.rs index a784c4a8f..70dacb6cd 100644 --- a/backend/crates/gateway-admin/src/ports/provider.rs +++ b/backend/crates/gateway-admin/src/ports/provider.rs @@ -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 { + 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, ProviderAdminError> { + Ok(None) + } + /// 历史观测的协议字段仅由具体 Provider 解释;不支持时保留累计估算 fn quota_forecast_observation( &self, @@ -385,6 +416,10 @@ pub struct ProviderAdminRegistry { } impl ProviderAdminRegistry { + pub(crate) fn registered(&self) -> BTreeMap> { + self.providers.as_ref().clone() + } + pub fn pricing_catalog(&self) -> gateway_core::metering::PricingOverrides { self.providers .iter() diff --git a/backend/crates/gateway-admin/src/ports/store.rs b/backend/crates/gateway-admin/src/ports/store.rs index e34c8b798..a637b4460 100644 --- a/backend/crates/gateway-admin/src/ports/store.rs +++ b/backend/crates/gateway-admin/src/ports/store.rs @@ -131,6 +131,35 @@ pub trait AccountStore: runtime: AccountRuntimeSnapshot, ) -> AdminStoreResult>; + /// 批量读取指定账号与当前启用分组的交集,分组只用于授权筛选,不加载展示信息 + /// 不加载凭据正文或触发 Provider 查询 + async fn load_accounts_in_enabled_groups( + &self, + account_ids: &[gateway_core::account::ProviderAccountId], + group_ids: &[gateway_core::routing::AccountGroupId], + ) -> AdminStoreResult> { + 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; async fn load_account_usage( @@ -252,6 +281,14 @@ pub trait AccountRuntimeStore: Send + Sync { account_ids: &[String], ) -> AdminStoreResult; + /// 只需账号可用状态时不读取在途租约,避免池规模放大容量查询 + async fn account_rate_limits( + &self, + account_ids: &[String], + ) -> AdminStoreResult { + self.account_runtime(account_ids).await + } + /// 账号错误保护中的账号与其冻结快照;临时限流不包含在内 async fn active_freezes( &self, @@ -322,6 +359,16 @@ pub trait AuthStore: Send + Sync { /// Client API Key 资料读取与管理写入 #[async_trait] pub trait ClientKeyStore: Send + Sync { + async fn peer_node_identity( + &self, + ) -> AdminStoreResult { + Err(AdminStoreError::new( + AdminStoreErrorKind::Unavailable, + "peer node", + "peer node is unavailable", + )) + } + /// 按已验证的 ID 读取资料,不读取完整明文 Key async fn get_client_key( &self, diff --git a/backend/crates/gateway-admin/src/use_case/accounts/mod.rs b/backend/crates/gateway-admin/src/use_case/accounts/mod.rs index 91655a1a4..700ad9a76 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts/mod.rs @@ -55,6 +55,13 @@ const CONNECTION_TEST_INPUT: &str = "Reply with exactly OK."; /// 统一账号页消费的服务 #[async_trait] pub trait AccountsService: Send + Sync { + async fn refresh_upstream_info( + &self, + _account_ids: Vec, + ) -> Result, AdminError> { + Err(AdminError::invalid("该服务商不支持上游信息查询")) + } + async fn concurrency( &self, account_id: ProviderAccountId, @@ -191,12 +198,17 @@ pub(crate) struct DefaultAccountsService { providers: ProviderAdminRegistry, snapshot: Arc, probe: Arc, + upstream_info: Option>, reset_credit_locks: Arc>>>>, } impl DefaultAccountsService { #[must_use] + #[expect( + clippy::too_many_arguments, + reason = "账号存储、发布、探测与互联分别通过独立端口注入" + )] pub(crate) fn new( accounts: Arc, account_runtime: Arc, @@ -205,6 +217,7 @@ impl DefaultAccountsService { providers: ProviderAdminRegistry, snapshot: Arc, probe: Arc, + upstream_info: Option>, ) -> Self { Self { accounts, @@ -214,6 +227,7 @@ impl DefaultAccountsService { providers, snapshot, probe, + upstream_info, reset_credit_locks: Arc::new(futures::lock::Mutex::new(BTreeMap::new())), } } @@ -231,10 +245,11 @@ impl DefaultAccountsService { } async fn read_quotas( - providers: &ProviderAdminRegistry, + &self, items: &[AccountPageItem], rolling_usage: &BTreeMap, ) -> Vec { + let providers = &self.providers; let mut groups = BTreeMap::<_, Vec>::new(); for (index, item) in items.iter().enumerate() { groups @@ -264,7 +279,12 @@ impl DefaultAccountsService { } }) .collect::>(); - 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 +401,51 @@ impl DefaultAccountsService { async fn load_api_key_usage( &self, accounts: &[AccountPageItem], + quotas: &[ProviderQuota], ) -> Result, 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::>(); 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 +467,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 +501,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("a)) .await? .remove(&stored.account.id) .or_else(|| { @@ -486,6 +529,33 @@ impl DefaultAccountsService { #[async_trait] impl AccountsService for DefaultAccountsService { + async fn refresh_upstream_info( + &self, + account_ids: Vec, + ) -> Result, AdminError> { + if account_ids.is_empty() || account_ids.len() > 200 { + return Err(AdminError::invalid("请选择要刷新的账号")); + } + let mut results = Vec::with_capacity(account_ids.len()); + for id in account_ids { + let (stored, provider) = self.provider_for_account(&id).await?; + if !provider.supports_peer_connection(&stored.account.authentication_kind) { + return Err(AdminError::invalid("该账号不支持上游信息刷新")); + } + let peer = self + .upstream_info + .as_deref() + .ok_or_else(|| AdminError::invalid("上游信息服务未启用"))?; + results.push(peer.request_refresh(&id).await.map_err(|error| { + map_provider_error( + super::peer_link::upstream::store_error(error), + "upstream information refresh", + ) + })?); + } + Ok(results) + } + async fn concurrency( &self, account_id: ProviderAccountId, @@ -554,10 +624,10 @@ impl AccountsService for DefaultAccountsService { .into_iter() .map(|usage| (usage.account_id.clone(), usage)) .collect::>(); - 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, "as).await?; let items = page .items .into_iter() @@ -684,9 +754,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 +799,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 +828,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 +882,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 +925,18 @@ impl AccountsService for DefaultAccountsService { account_id: &ProviderAccountId, ) -> Result { 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 +1241,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( diff --git a/backend/crates/gateway-admin/src/use_case/client_keys.rs b/backend/crates/gateway-admin/src/use_case/client_keys.rs index 0a94bfd36..ba0e5e33e 100644 --- a/backend/crates/gateway-admin/src/use_case/client_keys.rs +++ b/backend/crates/gateway-admin/src/use_case/client_keys.rs @@ -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 => "公开套餐分布需要先公开账号池", + }) + }) +} diff --git a/backend/crates/gateway-admin/src/use_case/credential_mutation.rs b/backend/crates/gateway-admin/src/use_case/credential_mutation.rs index 86891dcad..c04a17a5b 100644 --- a/backend/crates/gateway-admin/src/use_case/credential_mutation.rs +++ b/backend/crates/gateway-admin/src/use_case/credential_mutation.rs @@ -19,13 +19,14 @@ 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<&dyn crate::ports::peer::PeerUpstreamService>, provider: &std::sync::Arc, 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, provider.as_ref(), account_ids).await; publish_committed(snapshot, revision).await?; // 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果 @@ -343,6 +344,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 +367,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 plan reconciliation 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; +} diff --git a/backend/crates/gateway-admin/src/use_case/credentials/flow.rs b/backend/crates/gateway-admin/src/use_case/credentials/flow.rs index 8cc311031..1457d4b6b 100644 --- a/backend/crates/gateway-admin/src/use_case/credentials/flow.rs +++ b/backend/crates/gateway-admin/src/use_case/credentials/flow.rs @@ -30,6 +30,7 @@ pub struct ProviderCredentials { accounts: Arc, proxies: Arc, snapshot: Arc, + upstream_info: Option>, } impl ProviderCredentials { @@ -39,12 +40,14 @@ impl ProviderCredentials { accounts: Arc, proxies: Arc, snapshot: Arc, + upstream_info: Option>, ) -> Self { Self { provider, accounts, proxies, snapshot, + upstream_info, } } pub async fn import_document( @@ -89,6 +92,7 @@ impl ProviderCredentials { .map_err(|error| map_store_error(error, "provider credential import"))?; drop(proxy_reservation); publish_credentials_and_observe_quota( + self.upstream_info.as_deref(), &self.provider, self.snapshot.as_ref(), result.config_revision, @@ -160,11 +164,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 +186,7 @@ impl ProviderCredentials { command: CredentialDeletion, ) -> Result { let result = delete_credentials( + self.upstream_info.as_deref(), self.accounts.as_ref(), self.provider.as_ref(), command, @@ -236,6 +249,7 @@ impl super::CredentialsService { let result = outcome.result; if outcome.newly_committed { publish_credentials_and_observe_quota( + self.upstream_info.as_deref(), provider, self.snapshot.as_ref(), result.config_revision, diff --git a/backend/crates/gateway-admin/src/use_case/credentials/mod.rs b/backend/crates/gateway-admin/src/use_case/credentials/mod.rs index 7b36b2b4f..b52ae4944 100644 --- a/backend/crates/gateway-admin/src/use_case/credentials/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/credentials/mod.rs @@ -19,6 +19,7 @@ pub struct CredentialsService { accounts: Arc, proxies: Arc, snapshot: Arc, + upstream_info: Option>, } impl CredentialsService { @@ -28,12 +29,14 @@ impl CredentialsService { accounts: Arc, proxies: Arc, snapshot: Arc, + upstream_info: Option>, ) -> 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(), )) } } diff --git a/backend/crates/gateway-admin/src/use_case/mod.rs b/backend/crates/gateway-admin/src/use_case/mod.rs index 96914e0e8..be8cafe4d 100644 --- a/backend/crates/gateway-admin/src/use_case/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/mod.rs @@ -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; diff --git a/backend/crates/gateway-admin/src/use_case/observability/mod.rs b/backend/crates/gateway-admin/src/use_case/observability/mod.rs index 6d4d38fa8..50bf7d900 100644 --- a/backend/crates/gateway-admin/src/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/observability/mod.rs @@ -94,6 +94,7 @@ pub(crate) struct DefaultObservabilityService { settings: Arc, providers: ProviderAdminRegistry, system: Arc, + upstream_info: Option>, } impl DefaultObservabilityService { @@ -105,6 +106,7 @@ impl DefaultObservabilityService { providers: ProviderAdminRegistry, timezone: gateway_core::time::DeploymentTimeZone, system: Arc, + upstream_info: Option>, ) -> Self { Self { timezone, @@ -113,6 +115,7 @@ impl DefaultObservabilityService { settings, providers, system, + upstream_info, } } @@ -933,6 +936,7 @@ impl DefaultObservabilityService { .map(|usage| (usage.account_id.clone(), usage)) .collect::>(); 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(); @@ -943,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, @@ -960,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; } } } diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/mod.rs b/backend/crates/gateway-admin/src/use_case/peer_link/mod.rs new file mode 100644 index 000000000..2d584a4bd --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/peer_link/mod.rs @@ -0,0 +1,261 @@ +//! 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; +} + +pub(crate) struct DefaultPeerLinkService { + keys: Arc, + accounts: Arc, + runtime: Arc, + providers: ProviderAdminRegistry, + rate_limit: rate_limit::QueryRateLimit, + snapshot_slots: Semaphore, +} + +impl DefaultPeerLinkService { + pub(crate) fn new( + keys: Arc, + accounts: Arc, + runtime: Arc, + 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 { + 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 node = self + .keys + .peer_node_identity() + .await + .map_err(|error| map_store_error(error, "peer node"))?; + let now = Utc::now(); + let mut result = PeerLinkExport { + node, + observed_at: now, + expires_at: now + TimeDelta::minutes(5), + 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::>(); + 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::>(); + 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::>(); + let account_ids = accounts + .iter() + .map(|account| account.id.clone()) + .collect::>(); + 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::>(); + 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, + "as, + &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, + } +} diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/projection.rs b/backend/crates/gateway-admin/src/use_case/peer_link/projection.rs new file mode 100644 index 000000000..c8b065319 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/peer_link/projection.rs @@ -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, +) -> 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, + providers: &ProviderAdminRegistry, + runtime: &AccountRuntimeSnapshot, + models: &[gateway_core::routing::PublicModelId], + now: DateTime, + max_response_bytes: usize, +) -> Result<(Vec, Vec), 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| "a.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| "a.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, + expires_at: DateTime, +} + +#[derive(Default)] +struct ModelObservations { + incomplete: bool, + available: bool, + observed_at: Option>, + expires_at: Option>, +} + +impl ModelObservations { + fn observe(&mut self, observation: Option) { + 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, +) -> Option { + 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::::from(account.quota.observed_at()?); + let expires_at = account + .quota + .reset_at() + .map(DateTime::::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, +) -> 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 +} diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/rate_limit.rs b/backend/crates/gateway-admin/src/use_case/peer_link/rate_limit.rs new file mode 100644 index 000000000..9fb5606c3 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/peer_link/rate_limit.rs @@ -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>>); + +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(()) + } +} diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/accounts.rs b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/accounts.rs new file mode 100644 index 000000000..75c97a6b2 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/accounts.rs @@ -0,0 +1,134 @@ +//! 账号查询合并原生额度与公共互联投影,保持列表摘要和详情窗口边界 + +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 { + 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 { + if !request.refresh + && provider.supports_peer_connection(&account.authentication_kind) + && let Some(peer) = peer + { + 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> { + 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::>(); + let queries = indices + .iter() + .map(|&index| ProviderQuotaReadRequest { + account: requests[index].account, + rolling_usage: requests[index].rolling_usage, + }) + .collect::>(); + 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> { + let connections = requests + .iter() + .map(|request| connection(request.account)) + .collect::>(); + let ids = connections + .iter() + .filter_map(|connection| { + connection + .as_ref() + .ok() + .map(|value| value.account_id.clone()) + }) + .collect::>(); + if ids.is_empty() { + return connections + .into_iter() + .map(|connection| connection.map(|_| ProviderQuota::default())) + .collect(); + } + let summaries = service + .read_summaries(&ids) + .await + .map(|records| { + records + .into_iter() + .map(|(connection, quota)| (connection.account_id.clone(), (connection, quota))) + .collect::>() + }) + .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() +} diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/mod.rs b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/mod.rs new file mode 100644 index 000000000..4c7cefe88 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/mod.rs @@ -0,0 +1,100 @@ +//! 公共 CPR 上游同步、管理投影与调度观测,连接凭据解释由 Provider 提供 + +mod accounts; +mod projection; +mod scheduling; +mod service; +mod snapshot; +mod worker; + +use std::sync::Arc; + +use crate::{ + model::{provider_credentials::ProviderQuota, upstream_info::UpstreamRefreshReceipt}, + ports::{ + peer::{PeerTransport, PeerUpstreamService}, + provider::ProviderAdminRegistry, + }, +}; +pub(crate) use accounts::{read_quota, read_quotas, store_error}; +use gateway_core::{ + account::{ProviderAccountId, ProviderAccountStore, UpstreamInfoSource, UpstreamInfoStore}, + error::StoreError, + task::{WorkerContribution, WorkerDefinitionError}, +}; +use service::PeerInfoService; + +/// 组合根独立注册公共互联 Worker,不属于任何具体 Provider 的任务集合 +pub struct PeerUpstreamBundle { + service: Arc, + workers: Vec, +} + +impl PeerUpstreamBundle { + pub fn service(&self) -> Arc { + self.service.clone() + } + + pub fn scheduling_source(&self) -> Arc { + self.service.clone() + } + + pub fn take_worker_contributions(&mut self) -> Vec { + std::mem::take(&mut self.workers) + } +} + +pub fn initialize( + accounts: Arc, + store: Arc, + providers: &ProviderAdminRegistry, + transport: Arc, +) -> Result { + let service = Arc::new(PeerInfoService::new(accounts, store, 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 request_refresh( + &self, + id: &ProviderAccountId, + ) -> Result { + PeerInfoService::request_refresh(self, id) + .await + .map(|plan| Self::receipt(&plan)) + } + + async fn read_quota( + &self, + account_id: &ProviderAccountId, + ) -> Result, StoreError> { + PeerInfoService::read_quota(self, account_id).await + } + + async fn read_summary( + &self, + account_id: &ProviderAccountId, + ) -> Result, StoreError> { + PeerInfoService::read_summary(self, account_id).await + } + + async fn read_quotas( + &self, + requests: &[crate::model::provider_credentials::ProviderQuotaReadRequest<'_>], + ) -> Vec> { + accounts::read_peer_quotas(self, requests).await + } +} diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/projection.rs b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/projection.rs new file mode 100644 index 000000000..972f617a6 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/projection.rs @@ -0,0 +1,238 @@ +//! 将已保存的互联观测投影为管理端额度与同步事实 + +use std::time::SystemTime; + +use crate::model::{ + provider_credentials::{ + ProviderQuota, ProviderQuotaWindow, ProviderQuotaWindowRole, QuotaLocalUsageAttribution, + }, + upstream_info::{ + ProviderUpstreamInfo, UpstreamNode, UpstreamPlanCount, UpstreamPool, UpstreamPoolStates, + UpstreamQuotaAmount, UpstreamQuotaWindow, UpstreamRefreshReceipt, UpstreamSync, + UpstreamSyncState, + }, +}; +use gateway_core::account::{UpstreamInfoPlan, UpstreamInfoRecord}; +use gateway_protocol::peer::{ + PeerComponentStatus, PeerLink, PeerPoolDisclosure, PeerQuotaDisclosure, +}; + +use super::{PeerInfoService, snapshot}; + +impl PeerInfoService { + pub(crate) fn project(record: &UpstreamInfoRecord, now: SystemTime) -> ProviderQuota { + let peer = record.snapshot.as_ref().and_then(snapshot::decode); + Self::project_peer(&record.plan, peer.as_ref(), now) + } + + pub(super) fn project_peer( + plan: &UpstreamInfoPlan, + peer: Option<&PeerLink>, + 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: plan.last_success_at.is_some(), + node: peer.as_ref().map(|peer| UpstreamNode { + id: peer.node.id.clone(), + name: peer.node.name.clone(), + software: peer.node.software.clone(), + }), + observed_at, + stale, + connection_revision: plan.connection.revision.clone(), + sync: sync_state(plan, now), + last_error: plan.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 && snapshot::budget_exhausted(window, now), + 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, + } + } + + pub(crate) fn receipt(record: &UpstreamInfoPlan) -> UpstreamRefreshReceipt { + UpstreamRefreshReceipt { + account_id: record.connection.account_id.as_str().to_owned(), + connection_revision: record.connection.revision.clone(), + request_generation: record.request_generation, + state: sync_state(record, SystemTime::now()).state, + } + } +} + +fn sync_state(record: &UpstreamInfoPlan, now: SystemTime) -> UpstreamSync { + UpstreamSync { + state: if record.claimed_until.is_some_and(|expiry| expiry > now) { + UpstreamSyncState::Running + } else if record.request_generation > record.completed_generation { + UpstreamSyncState::Queued + } else { + UpstreamSyncState::Idle + }, + request_generation: record.request_generation, + completed_generation: record.completed_generation, + } +} diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/scheduling.rs b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/scheduling.rs new file mode 100644 index 000000000..cc98a8319 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/scheduling.rs @@ -0,0 +1,195 @@ +//! 将互联观测压缩为按模型索引的调度事实,保留各层独立的有效期 + +use std::{collections::HashMap, sync::Arc, time::SystemTime}; + +use gateway_protocol::peer::{PeerComponentStatus, PeerLink, PeerModelCoverage, PeerModelState}; + +use super::snapshot; + +use gateway_core::account::UpstreamSchedulingSignal; + +#[derive(Clone, Copy)] +struct Validity { + observed_at: SystemTime, + expires_at: SystemTime, +} + +impl Validity { + fn component(observed: Option<&str>, expires: Option<&str>) -> Option { + 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, +} + +struct AccountQuota { + validity: Validity, + windows: Vec, +} + +impl AccountQuota { + fn remaining_percent(&self, now: SystemTime) -> Option { + 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>, +} + +pub(super) struct SchedulingObservation { + validity: Validity, + exhausted_budgets: Vec, + models: HashMap>, +} + +impl SchedulingObservation { + pub(super) fn new(peer: &PeerLink, now: SystemTime) -> Option { + 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) + { + for window in [&usage.daily, &usage.weekly] { + if snapshot::budget_exhausted(window, now) { + 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>::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::>>::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 { + 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)), + }) + } +} diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/service.rs b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/service.rs new file mode 100644 index 000000000..d719e1531 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/service.rs @@ -0,0 +1,518 @@ +//! 后台计划协调与仅供 Smart 排序的内存观测 + +use std::{ + collections::{BTreeMap, HashMap}, + sync::{Arc, RwLock}, + time::{Duration, SystemTime}, +}; + +use futures::{StreamExt as _, TryStreamExt as _, stream}; +use gateway_core::{ + account::{ + LoadedCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore, + UpstreamConnection, UpstreamInfoClaim, UpstreamInfoCompletion, UpstreamInfoFailure, + UpstreamInfoPlan, UpstreamInfoRecord, UpstreamInfoSource, UpstreamInfoStore, + UpstreamSchedulingSignal, + }, + error::{StoreError, StoreErrorKind}, + routing::ProviderKind, + task::WorkerCycleContext, +}; + +use crate::model::provider_credentials::ProviderQuota; +use gateway_protocol::peer::PeerLink; + +use crate::ports::{ + peer::{PeerConnection, PeerTransport}, + provider::{ProviderAdmin, ProviderAdminRegistry}, +}; + +use super::{scheduling::SchedulingObservation, snapshot}; + +const BATCH_SIZE: u32 = 100; +const FETCH_BATCH: u32 = 20; +const SNAPSHOT_READ_BATCH: usize = 2; +const CONCURRENCY: usize = 4; +const MINIMUM_INTERVAL: Duration = Duration::from_secs(15); + +type CachedSummary = (UpstreamInfoPlan, Option>); + +struct CachedObservation { + connection: UpstreamConnection, + completed_generation: u64, + last_success_at: Option, + checked_at: SystemTime, + scheduling: Option>, + summary: Option>, +} + +impl CachedObservation { + fn new( + plan: &UpstreamInfoPlan, + scheduling: Option>, + summary: Option>, + now: SystemTime, + ) -> Self { + Self { + connection: plan.connection.clone(), + completed_generation: plan.completed_generation, + last_success_at: plan.last_success_at, + checked_at: now, + scheduling, + summary, + } + } + + fn matches(&self, plan: &UpstreamInfoPlan) -> bool { + self.connection == plan.connection + && self.completed_generation == plan.completed_generation + && self.last_success_at == plan.last_success_at + // 周期刷新不增加请求代次,撤销授权仍须立即丢弃此前的观测 + && (plan.last_error != Some(UpstreamInfoFailure::Unauthorized) || self.summary.is_none()) + } +} + +pub(crate) struct PeerInfoService { + accounts: Arc, + store: Arc, + providers: BTreeMap>, + transport: Arc, + cursors: tokio::sync::Mutex>>, + cache_refresh: tokio::sync::Mutex<()>, + observations: RwLock>, +} + +impl PeerInfoService { + pub(crate) fn new( + accounts: Arc, + store: Arc, + providers: &ProviderAdminRegistry, + transport: Arc, + ) -> Self { + Self { + accounts, + store, + providers: providers.registered(), + transport, + cursors: tokio::sync::Mutex::new(BTreeMap::new()), + cache_refresh: tokio::sync::Mutex::new(()), + observations: RwLock::new(HashMap::new()), + } + } + + pub(crate) async fn account_changed(&self, id: &ProviderAccountId) -> Result<(), StoreError> { + let loaded = self.accounts.load_current_credential(id).await?; + self.reconcile(&loaded).await + } + + pub(crate) fn forget(&self, id: &ProviderAccountId) { + let previous = self + .observations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(id); + drop(previous); + } + + async fn reconcile(&self, loaded: &LoadedCredential) -> Result<(), StoreError> { + let connection = UpstreamConnection::for_account(&loaded.account); + let enabled = self.connection(loaded).is_some(); + self.store + .reconcile( + &connection, + enabled && loaded.account.enabled(), + SystemTime::now(), + ) + .await?; + let _refresh = self.cache_refresh.lock().await; + let mut observations = self + .observations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = if !enabled + || !loaded.account.enabled() + || observations + .get(loaded.account.id()) + .is_some_and(|cached| cached.connection != connection) + { + observations.remove(loaded.account.id()) + } else { + None + }; + drop(observations); + drop(previous); + Ok(()) + } + + pub(crate) async fn request_refresh( + &self, + id: &ProviderAccountId, + ) -> Result { + let loaded = self.accounts.load_current_credential(id).await?; + if self.connection(&loaded).is_none() { + return Err(StoreError::new(StoreErrorKind::InvalidData)); + } + self.reconcile(&loaded).await?; + self.store + .request_refresh( + &UpstreamConnection::for_account(&loaded.account), + SystemTime::now(), + MINIMUM_INTERVAL, + ) + .await + } + + pub(crate) async fn read_quota( + &self, + id: &ProviderAccountId, + ) -> Result, StoreError> { + let Some(connection) = self.current_connection(id).await? else { + return Ok(None); + }; + Ok(self + .read(std::slice::from_ref(id)) + .await? + .iter() + .find(|record| record.plan.connection == connection) + .map(|record| Self::project(record, SystemTime::now()))) + } + + pub(crate) async fn read_summary( + &self, + id: &ProviderAccountId, + ) -> Result, StoreError> { + let Some(connection) = self.current_connection(id).await? else { + return Ok(None); + }; + Ok(self + .read_summaries(std::slice::from_ref(id)) + .await? + .into_iter() + .find(|(current, _)| *current == connection) + .map(|(_, quota)| quota)) + } + + async fn current_connection( + &self, + id: &ProviderAccountId, + ) -> Result, StoreError> { + let account = self + .accounts + .get_account(id) + .await? + .ok_or_else(|| StoreError::new(StoreErrorKind::InvalidData))?; + if !self + .providers + .get(account.provider()) + .is_some_and(|provider| { + provider.supports_peer_connection(account.authentication_kind()) + }) + { + return Ok(None); + } + Ok(Some(UpstreamConnection::for_account(&account))) + } + + pub(crate) async fn read( + &self, + ids: &[ProviderAccountId], + ) -> Result, StoreError> { + self.store.read(ids).await + } + + pub(super) async fn synchronize(&self, context: &WorkerCycleContext) -> Result<(), StoreError> { + let now = SystemTime::now(); + // 过期观测保留轻量版本,避免每轮重读旧 JSON;长期未扫描到的账号释放缓存 + let expired = 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::>(); + drop(expired); + let mut failure = None; + for provider in self.providers.keys() { + if context.cancellation().is_cancelled() { + break; + } + // 一个 Provider 的账号读取失败不阻止其他 Provider 本轮同步 + if let Err(error) = self.synchronize_provider(provider, context).await { + failure.get_or_insert(error); + } + } + failure.map_or(Ok(()), Err) + } + + fn connection(&self, loaded: &LoadedCredential) -> Option { + self.providers + .get(loaded.account.provider())? + .peer_connection(loaded) + .ok() + .flatten() + } + + async fn synchronize_provider( + &self, + provider: &ProviderKind, + context: &WorkerCycleContext, + ) -> Result<(), StoreError> { + let mut cursors = self.cursors.lock().await; + let cursor = cursors.entry(provider.clone()).or_default(); + let candidates = self + .store + .list_candidates( + provider, + self.providers[provider].peer_authentication_kinds(), + cursor.as_ref(), + BATCH_SIZE, + ) + .await?; + *cursor = if candidates.len() < BATCH_SIZE as usize { + None + } else { + candidates.last().map(|loaded| loaded.account.id().clone()) + }; + drop(cursors); + let mut ids = Vec::with_capacity(candidates.len()); + for loaded in candidates { + if context.cancellation().is_cancelled() { + return Ok(()); + } + match self.reconcile(&loaded).await { + Ok(()) => ids.push(loaded.account.id().clone()), + Err(error) + if matches!( + error.kind(), + StoreErrorKind::Conflict | StoreErrorKind::InvalidData + ) => {} + Err(error) => return Err(error), + } + } + self.refresh_cache(&ids).await?; + let pending = self + .store + .claim_due( + provider, + SystemTime::now(), + FETCH_BATCH, + Duration::from_secs(60), + ) + .await?; + stream::iter(pending) + .map(|record| self.synchronize_one(record)) + .buffer_unordered(CONCURRENCY) + .try_collect::>() + .await?; + Ok(()) + } + + async fn synchronize_one(&self, claim: UpstreamInfoClaim) -> Result<(), StoreError> { + let record = claim.plan; + let loaded = match self + .accounts + .load_current_credential(&record.connection.account_id) + .await + { + Ok(loaded) => loaded, + Err(error) + if matches!( + error.kind(), + StoreErrorKind::Conflict | StoreErrorKind::InvalidData + ) => + { + return Ok(()); + } + Err(error) => return Err(error), + }; + if UpstreamConnection::for_account(&loaded.account) != record.connection + || !loaded.account.enabled() + { + return self.reconcile(&loaded).await; + } + let Some(authentication) = self.connection(&loaded) else { + return self.reconcile(&loaded).await; + }; + 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 delay = match result { + Ok(_) => Duration::from_secs(240 + stable_jitter(loaded.account.id().as_str())), + Err(UpstreamInfoFailure::Unauthorized | UpstreamInfoFailure::Unsupported) => { + Duration::from_secs(30 * 60) + } + Err(_) => Duration::from_secs( + 30_u64 + .saturating_mul(1_u64 << record.consecutive_failures.min(6)) + .min(30 * 60), + ), + } + .max(retry_after.unwrap_or_default()); + let completion = UpstreamInfoCompletion { + connection: record.connection.clone(), + request_generation: record.request_generation, + claim_token: claim.claim_token, + completed_at: now, + next_attempt_at: now + delay, + result, + }; + if self.store.complete(completion).await? { + self.refresh_cache(std::slice::from_ref(loaded.account.id())) + .await?; + } + Ok(()) + } + + pub(crate) async fn read_summaries( + &self, + ids: &[ProviderAccountId], + ) -> Result, StoreError> { + let plans = self.store.read_plans(ids).await?; + let cached = { + let observations = self + .observations + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + plans + .into_iter() + .map(|plan| { + let cached = observations.get(&plan.connection.account_id)?; + cached + .matches(&plan) + .then(|| (plan, cached.summary.clone())) + }) + .collect::>>() + }; + let summaries = match cached { + Some(summaries) => summaries, + None => self.refresh_cache(ids).await?, + }; + let now = SystemTime::now(); + Ok(summaries + .into_iter() + .map(|(plan, summary)| { + let quota = Self::project_peer(&plan, summary.as_deref(), now); + (plan.connection, quota) + }) + .collect()) + } + + async fn refresh_cache( + &self, + ids: &[ProviderAccountId], + ) -> Result, StoreError> { + let _refresh = self.cache_refresh.lock().await; + let mut plans = self.store.read_plans(ids).await?; + 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 plan in &plans { + if let Some(cached) = observations.get_mut(&plan.connection.account_id) + && cached.matches(plan) + { + cached.checked_at = now; + continue; + } + if plan.last_success_at.is_none() + || plan.last_error == Some(UpstreamInfoFailure::Unauthorized) + { + replaced.push(observations.insert( + plan.connection.account_id.clone(), + CachedObservation::new(plan, None, None, now), + )); + } else { + changed.push(plan.connection.account_id.clone()); + } + } + } + // 调度投影可能被其他请求持有,替换后的最后一次释放也留在锁外 + drop(replaced); + // 完整观测可接近协议大小上限,投影后及时释放,避免同时物化整页 JSON + for batch in changed.chunks(SNAPSHOT_READ_BATCH) { + for record in self.store.read(batch).await? { + if let Some(plan) = plans + .iter_mut() + .find(|plan| plan.connection.account_id == record.plan.connection.account_id) + { + *plan = record.plan.clone(); + } + self.cache(&record); + } + } + let observations = self + .observations + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Ok(plans + .into_iter() + .map(|plan| { + let summary = observations + .get(&plan.connection.account_id) + .filter(|cached| cached.matches(&plan)) + .and_then(|cached| cached.summary.clone()); + (plan, summary) + }) + .collect()) + } + + fn cache(&self, record: &UpstreamInfoRecord) { + let now = SystemTime::now(); + let mut peer = record.snapshot.as_ref().and_then(snapshot::decode); + let scheduling = peer + .as_ref() + .and_then(|peer| SchedulingObservation::new(peer, now)) + .filter(|observation| observation.fresh(now)) + .map(Arc::new); + let summary = peer.take().map(|mut peer| { + peer.quota = Default::default(); + peer.models = Vec::new(); + Arc::new(peer) + }); + let cached = CachedObservation::new(&record.plan, scheduling, summary, now); + let previous = self + .observations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(record.plan.connection.account_id.clone(), cached); + drop(previous); + } +} + +impl UpstreamInfoSource for PeerInfoService { + /// 数据面只取不可变投影;模型查询与有效期判断不持有共享锁 + fn signal( + &self, + account: &ProviderAccount, + model: &str, + now: SystemTime, + ) -> Option { + 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) + } +} + +fn stable_jitter(id: &str) -> u64 { + id.bytes().fold(0_u64, |hash, byte| { + hash.wrapping_mul(31).wrapping_add(u64::from(byte)) + }) % 60 +} diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/snapshot.rs b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/snapshot.rs new file mode 100644 index 000000000..d990b05e2 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/snapshot.rs @@ -0,0 +1,292 @@ +//! 互联响应的有界校验、能力授权与时间有效性解释 + +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 { + DateTime::parse_from_rfc3339(value) + .ok() + .map(SystemTime::from) +} + +pub(super) fn datetime(value: &str) -> Option> { + 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::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 { + 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 + || !valid_text(&peer.node.id, 256) + || !valid_text(&peer.node.name, 256) + || !valid_text(&peer.node.software, 128) + || 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::() + > 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::(); + 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::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 { + 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::() + .ok() + .filter(|value| value.is_finite() && *value >= 0.0) +} + +pub(super) fn budget_exhausted( + window: &gateway_protocol::peer::PeerBudgetWindow, + now: SystemTime, +) -> bool { + let Some(total) = window.total.as_deref() else { + return false; + }; + if compare_decimal(total, "0") != std::cmp::Ordering::Greater + || window + .resets_at + .as_deref() + .and_then(timestamp) + .is_some_and(|reset| reset <= now) + { + return false; + } + window + .remaining + .as_deref() + .is_some_and(|remaining| compare_decimal(remaining, "0") == std::cmp::Ordering::Equal) + || compare_decimal(&window.used, total) != std::cmp::Ordering::Less +} + +// 金额触顶比较保留十进制精度,百分比展示的浮点近似不能成为耗尽依据 +fn compare_decimal(left: &str, right: &str) -> std::cmp::Ordering { + let parts = |value: &str| { + let (integer, fractional) = value.split_once('.').unwrap_or((value, "")); + ( + integer.trim_start_matches('0').to_owned(), + fractional.to_owned(), + ) + }; + let (left_integer, left_fractional) = parts(left); + let (right_integer, right_fractional) = parts(right); + left_integer + .len() + .cmp(&right_integer.len()) + .then_with(|| left_integer.cmp(&right_integer)) + .then_with(|| { + let width = left_fractional.len().max(right_fractional.len()); + left_fractional + .bytes() + .chain(std::iter::repeat(b'0')) + .take(width) + .cmp( + right_fractional + .bytes() + .chain(std::iter::repeat(b'0')) + .take(width), + ) + }) +} diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/worker.rs b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/worker.rs new file mode 100644 index 000000000..b3b96bc02 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/worker.rs @@ -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) -> Result { + 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); + +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)), + } + }) + } +} diff --git a/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs b/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs index 27dc69aab..7256626af 100644 --- a/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs @@ -29,6 +29,7 @@ pub(crate) struct DefaultPluginAccountAccess { providers: ProviderAdminRegistry, accounts: Arc, snapshot: Arc, + upstream_info: Option>, } impl DefaultPluginAccountAccess { @@ -37,11 +38,13 @@ impl DefaultPluginAccountAccess { providers: ProviderAdminRegistry, accounts: Arc, snapshot: Arc, + upstream_info: Option>, ) -> Self { Self { providers, accounts, snapshot, + upstream_info, } } @@ -51,14 +54,19 @@ impl DefaultPluginAccountAccess { refresh: bool, ) -> Result { 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,6 +173,7 @@ impl PluginAccountAccess for DefaultPluginAccountAccess { .credential .credential_revision; publish_credentials_and_observe_quota( + self.upstream_info.as_deref(), &provider, self.snapshot.as_ref(), result.config_revision, @@ -208,6 +217,7 @@ impl PluginAccountAccess for DefaultPluginAccountAccess { .credential_revision .ok_or_else(|| AdminError::internal("账号保存未返回凭据版本"))?; publish_credentials_and_observe_quota( + self.upstream_info.as_deref(), &provider, self.snapshot.as_ref(), result.config_revision, diff --git a/backend/crates/gateway-admin/src/use_case/plugin_resources.rs b/backend/crates/gateway-admin/src/use_case/plugin_resources.rs index 1d1c57ebc..753194040 100644 --- a/backend/crates/gateway-admin/src/use_case/plugin_resources.rs +++ b/backend/crates/gateway-admin/src/use_case/plugin_resources.rs @@ -83,6 +83,7 @@ impl PluginResourceAccess for DefaultPluginResourceAccess { resource_key, groups, NewClientKey { + peer_sharing: Default::default(), id, name: command.name, label: None, diff --git a/backend/crates/gateway-admin/src/use_case/proxies.rs b/backend/crates/gateway-admin/src/use_case/proxies.rs index 8f55e6403..d1b05178e 100644 --- a/backend/crates/gateway-admin/src/use_case/proxies.rs +++ b/backend/crates/gateway-admin/src/use_case/proxies.rs @@ -87,6 +87,7 @@ pub(crate) struct DefaultProxiesService { probe: Arc, snapshot: Arc, providers: ProviderAdminRegistry, + upstream_info: Option>, test_slots: Semaphore, } @@ -96,12 +97,14 @@ impl DefaultProxiesService { probe: Arc, snapshot: Arc, providers: ProviderAdminRegistry, + upstream_info: Option>, ) -> 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, diff --git a/backend/crates/gateway-admin/tests/main.rs b/backend/crates/gateway-admin/tests/main.rs index 1c16d4732..791a1fc08 100644 --- a/backend/crates/gateway-admin/tests/main.rs +++ b/backend/crates/gateway-admin/tests/main.rs @@ -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, ) -> ProviderQuotaWindow { ProviderQuotaWindow { + upstream: None, key: group.to_owned(), group: group.to_owned(), label: group.to_owned(), diff --git a/backend/crates/gateway-admin/tests/model/provider_credentials.rs b/backend/crates/gateway-admin/tests/model/provider_credentials.rs index 76ee2e575..07c200a52 100644 --- a/backend/crates/gateway-admin/tests/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/tests/model/provider_credentials.rs @@ -86,6 +86,7 @@ fn selected_usage_window(quota: &ProviderQuota) -> Option<(&str, AccountUsagePer fn usage_quota(windows: Vec) -> ProviderQuota { ProviderQuota { + upstream: None, credits: None, plan_type: None, observed_at: None, @@ -98,6 +99,7 @@ fn usage_quota(windows: Vec) -> 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, diff --git a/backend/crates/gateway-admin/tests/model/quota_forecast.rs b/backend/crates/gateway-admin/tests/model/quota_forecast.rs index 894dbea73..81f03d9be 100644 --- a/backend/crates/gateway-admin/tests/model/quota_forecast.rs +++ b/backend/crates/gateway-admin/tests/model/quota_forecast.rs @@ -15,6 +15,7 @@ fn now() -> DateTime { 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) -> ProviderQuota { ProviderQuota { + upstream: None, credits: None, plan_type: None, observed_at: Some(now()), diff --git a/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs b/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs index 429e6ad55..e1a275b04 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs @@ -1473,6 +1473,7 @@ async fn plugin_account_save_publishes_native_credential_change() { ProviderAdminRegistry::new([provider.clone() as Arc]).unwrap(), store.clone(), snapshot, + None, ); let result = access .save( @@ -1516,6 +1517,7 @@ async fn plugin_account_reads_resolve_the_authoritative_provider_from_the_accoun ProviderAdminRegistry::new([registered_provider as Arc]).unwrap(), store, Arc::new(RecordingPluginAccountPublication(events.clone())), + None, ); let account_id = ProviderAccountId::new("acct_test").expect("account ID"); @@ -1563,6 +1565,7 @@ async fn plugin_account_save_rejects_stale_authentication_kind_before_commit() { ProviderAdminRegistry::new([provider as Arc]).unwrap(), store.clone(), Arc::new(RecordingPluginAccountPublication(events.clone())), + None, ); let error = access @@ -1692,9 +1695,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(), @@ -1996,6 +2001,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 +2045,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 +2205,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 +2256,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 +2310,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 +2404,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 +2426,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 +2478,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 +2558,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 +2656,163 @@ 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, UpstreamSync, + UpstreamSyncState, + }; + + 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, + node: None, + observed_at: Some(Utc::now()), + stale: false, + connection_revision: "1".to_owned(), + sync: UpstreamSync { + state: UpstreamSyncState::Idle, + request_generation: 1, + completed_generation: 1, + }, + 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::>(); + 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 +2864,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 +2935,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 +2951,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 +2970,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 +3028,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 +3051,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 +3333,7 @@ pub(super) fn document() -> ProviderDocument { fn empty_quota() -> ProviderQuota { ProviderQuota { + upstream: None, credits: None, plan_type: None, observed_at: None, @@ -3591,6 +3778,7 @@ async fn plugin_quota_refresh_uses_native_provider_without_changing_configuratio ProviderAdminRegistry::new([provider.clone() as Arc]).unwrap(), store, Arc::new(RecordingPluginAccountPublication(events.clone())), + None, ); let id = ProviderAccountId::new("acct_test").unwrap(); access.get_quota(&id).await.unwrap(); diff --git a/backend/crates/gateway-admin/tests/use_case/accounts/reset_credit_automation.rs b/backend/crates/gateway-admin/tests/use_case/accounts/reset_credit_automation.rs index f5a7cb086..41d671c89 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts/reset_credit_automation.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts/reset_credit_automation.rs @@ -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() }, diff --git a/backend/crates/gateway-admin/tests/use_case/client_keys.rs b/backend/crates/gateway-admin/tests/use_case/client_keys.rs index 517656095..7dbdecf9b 100644 --- a/backend/crates/gateway-admin/tests/use_case/client_keys.rs +++ b/backend/crates/gateway-admin/tests/use_case/client_keys.rs @@ -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(), diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index db0e4be5a..8eaee14c2 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -10,6 +10,7 @@ mod credentials; mod freeze_recovery; mod import_tasks; mod observability; +mod peer_link; mod plugin_update; mod plugins; mod proxies; @@ -286,6 +287,7 @@ impl AdminHarness { Arc::new(plugins::TestPluginPorts), ), gateway_admin::AdminRuntimePorts { + upstream_info: None, timezone: Default::default(), service_middleware: self.service_middleware, plugin_preparation: Arc::new(plugins::TestPluginPorts), @@ -909,6 +911,7 @@ impl SettingsStore for UnavailableStore { } struct UnavailableProvider { + peer_enabled: bool, kind: ProviderKind, dashboard_profile: Option, calculated_billing: Option, @@ -931,6 +934,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 +947,28 @@ pub(super) fn test_provider(kind: &str) -> Arc { #[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, 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 +1057,7 @@ impl ProviderAdmin for UnavailableProvider { pub(super) fn dashboard_profile_provider() -> Arc { Arc::new(UnavailableProvider { + peer_enabled: false, kind: ProviderKind::new("openai").expect("provider kind"), dashboard_profile: Some(DashboardWireProfile { provider: "openai".to_owned(), @@ -1074,6 +1101,7 @@ pub(super) fn calculated_billing_provider() -> Arc { .expect("test billing amount"), }; Arc::new(UnavailableProvider { + peer_enabled: false, kind: ProviderKind::new("openai").expect("provider kind"), dashboard_profile: None, calculated_billing: Some( diff --git a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs index 02825a9ac..0aed3dc25 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs @@ -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(), diff --git a/backend/crates/gateway-admin/tests/use_case/peer_link/mod.rs b/backend/crates/gateway-admin/tests/use_case/peer_link/mod.rs new file mode 100644 index 000000000..af7a1ecee --- /dev/null +++ b/backend/crates/gateway-admin/tests/use_case/peer_link/mod.rs @@ -0,0 +1,3 @@ +//! 互联共享与上游同步用例测试 + +mod upstream; diff --git a/backend/crates/gateway-admin/tests/use_case/peer_link/upstream/mod.rs b/backend/crates/gateway-admin/tests/use_case/peer_link/upstream/mod.rs new file mode 100644 index 000000000..703e6e9a6 --- /dev/null +++ b/backend/crates/gateway-admin/tests/use_case/peer_link/upstream/mod.rs @@ -0,0 +1,398 @@ +//! 公共互联按 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}, + time::{Duration, SystemTime}, +}; + +#[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 + }); + let registry = ProviderAdminRegistry::new(providers).unwrap(); + let accounts = Arc::new(TestAccounts(vec![ + account("acct_first", "openai", "peer_test"), + account("acct_second", "xai", "peer_test"), + account("acct_native", "xai", "oauth"), + ])); + let store = Arc::new(TestPlans { + accounts: accounts.clone(), + records: Mutex::default(), + failed_provider, + }); + let transport = Arc::new(TestTransport(Mutex::new(Vec::new()))); + let mut bundle = + initialize_peer_upstream(accounts.clone(), store, ®istry, transport.clone()) + .unwrap(); + let reader = UpstreamInfoReader::default(); + assert!( + reader + .signal(&accounts.0[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 &accounts.0[..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().node.unwrap().name, + account.provider().as_str() + ); + assert!( + reader + .signal(account, "model", SystemTime::now()) + .unwrap() + .exhausted + ); + } + assert!( + reader + .signal(&accounts.0[2], "model", SystemTime::now()) + .is_none() + ); + drop(worker); + drop(bundle); + assert!( + reader + .signal(&accounts.0[0], "model", SystemTime::now()) + .is_none() + ); + } +} + +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>); + +#[async_trait] +impl PeerTransport for TestTransport { + async fn fetch( + &self, + account: &ProviderAccount, + connection: &PeerConnection, + ) -> Result, 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, + "node":{"id":account.id().as_str(),"name":account.provider().as_str(),"software":"cpr"}, + "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", + "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 TestPlans { + failed_provider: Option<&'static str>, + accounts: Arc, + records: Mutex>, +} + +#[async_trait] +impl UpstreamInfoStore for TestPlans { + async fn list_candidates( + &self, + provider: &ProviderKind, + kinds: &[&str], + after: Option<&ProviderAccountId>, + limit: u32, + ) -> Result, StoreError> { + if Some(provider.as_str()) == self.failed_provider { + return Err(StoreError::new(StoreErrorKind::Unavailable)); + } + Ok(self + .accounts + .0 + .iter() + .filter(|account| { + account.provider() == provider + && kinds.contains(&account.authentication_kind()) + && after.is_none_or(|id| account.id() > id) + }) + .take(limit as usize) + .map(loaded) + .collect()) + } + async fn reconcile( + &self, + connection: &UpstreamConnection, + enabled: bool, + now: SystemTime, + ) -> Result<(), StoreError> { + assert!(enabled); + self.records + .lock() + .unwrap() + .entry(connection.account_id.clone()) + .or_insert_with(|| UpstreamInfoRecord { + plan: UpstreamInfoPlan { + connection: connection.clone(), + request_generation: 1, + completed_generation: 0, + next_attempt_at: now, + claimed_until: None, + last_attempt_at: None, + last_success_at: None, + last_error: None, + consecutive_failures: 0, + }, + snapshot: None, + }); + Ok(()) + } + async fn request_refresh( + &self, + _: &UpstreamConnection, + _: SystemTime, + _: Duration, + ) -> Result { + panic!("automatic discovery does not enqueue a manual refresh") + } + async fn read_plans( + &self, + ids: &[ProviderAccountId], + ) -> Result, StoreError> { + Ok(self + .read(ids) + .await? + .into_iter() + .map(|record| record.plan) + .collect()) + } + async fn read(&self, ids: &[ProviderAccountId]) -> Result, StoreError> { + let records = self.records.lock().unwrap(); + Ok(ids + .iter() + .filter_map(|id| records.get(id).cloned()) + .collect()) + } + async fn claim_due( + &self, + provider: &ProviderKind, + now: SystemTime, + limit: u32, + ttl: Duration, + ) -> Result, StoreError> { + Ok(self + .records + .lock() + .unwrap() + .values_mut() + .filter(|record| { + self.accounts.0.iter().any(|account| { + account.id() == &record.plan.connection.account_id + && account.provider() == provider + }) && record.plan.next_attempt_at <= now + }) + .take(limit as usize) + .map(|record| { + record.plan.claimed_until = Some(now + ttl); + record.plan.last_attempt_at = Some(now); + UpstreamInfoClaim { + plan: record.plan.clone(), + claim_token: uuid::Uuid::new_v4(), + } + }) + .collect()) + } + async fn complete(&self, completion: UpstreamInfoCompletion) -> Result { + let mut records = self.records.lock().unwrap(); + let record = records.get_mut(&completion.connection.account_id).unwrap(); + assert_eq!(record.plan.connection, completion.connection); + record.snapshot = Some(completion.result.unwrap()); + record.plan.completed_generation = completion.request_generation; + record.plan.last_success_at = Some(completion.completed_at); + record.plan.claimed_until = None; + record.plan.next_attempt_at = completion.next_attempt_at; + Ok(true) + } +} + +struct TestAccounts(Vec); + +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, StoreError> { + Ok(self + .0 + .iter() + .find(|account| account.id() == account_id) + .cloned()) + } + async fn list_accounts(&self) -> Result, StoreError> { + panic!("unused account operation") + } + async fn list_for_provider( + &self, + _provider: &ProviderKind, + ) -> Result, StoreError> { + panic!("unused account operation") + } + async fn list_refresh_candidates( + &self, + _query: ProviderRefreshQuery, + ) -> Result, StoreError> { + panic!("unused account operation") + } + async fn load_credential( + &self, + _account: &ProviderAccountId, + _expected_revision: CredentialRevision, + ) -> Result { + panic!("unused account operation") + } + async fn load_current_credential( + &self, + id: &ProviderAccountId, + ) -> Result { + self.0 + .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 { + panic!("unused account operation") + } + async fn get_quotas( + &self, + _accounts: &[ProviderAccountId], + ) -> Result, StoreError> { + panic!("unused account operation") + } + async fn compare_and_swap_quota( + &self, + _observation: QuotaObservation, + ) -> Result { + panic!("unused account operation") + } + async fn touch_quota_observation( + &self, + _touch: QuotaObservationTouch, + ) -> Result { + panic!("unused account operation") + } + async fn apply_quota_access( + &self, + _change: QuotaAccessChange, + ) -> Result { + 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") + } +} diff --git a/backend/crates/gateway-admin/tests/use_case/proxies.rs b/backend/crates/gateway-admin/tests/use_case/proxies.rs index 51b7b2562..ec2ba77b4 100644 --- a/backend/crates/gateway-admin/tests/use_case/proxies.rs +++ b/backend/crates/gateway-admin/tests/use_case/proxies.rs @@ -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, diff --git a/backend/crates/gateway-api/src/admin/accounts/handlers.rs b/backend/crates/gateway-api/src/admin/accounts/handlers.rs index 105f8ea66..4f27e114c 100644 --- a/backend/crates/gateway-api/src/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/src/admin/accounts/handlers.rs @@ -17,6 +17,10 @@ where get(super::concurrency::list::), ) .route("/api/admin/accounts/detail", get(account_detail::)) + .route( + "/api/admin/accounts/upstream-info/refresh", + post(super::upstream_info::refresh::), + ) .route("/api/admin/accounts/export", get(export_accounts::)) .route("/api/admin/accounts/import", post(import_accounts::)) .route("/api/admin/accounts/refresh", post(refresh_account::)) diff --git a/backend/crates/gateway-api/src/admin/accounts/mod.rs b/backend/crates/gateway-api/src/admin/accounts/mod.rs index e955bf843..623d724d8 100644 --- a/backend/crates/gateway-api/src/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/src/admin/accounts/mod.rs @@ -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::{ diff --git a/backend/crates/gateway-api/src/admin/accounts/presenter.rs b/backend/crates/gateway-api/src/admin/accounts/presenter.rs index 60a76b6c8..622dbfac8 100644 --- a/backend/crates/gateway-api/src/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/src/admin/accounts/presenter.rs @@ -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() diff --git a/backend/crates/gateway-api/src/admin/accounts/upstream_info.rs b/backend/crates/gateway-api/src/admin/accounts/upstream_info.rs new file mode 100644 index 000000000..869b1ae46 --- /dev/null +++ b/backend/crates/gateway-api/src/admin/accounts/upstream_info.rs @@ -0,0 +1,191 @@ +//! 互联快照展示与异步刷新 HTTP 合同 + +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 node: Option, + pub observed_at: Option, + pub observed_at_display: String, + pub observed_at_relative: Option, + pub stale: bool, + pub connection_revision: String, + pub sync: UpstreamSyncView, + pub last_error: Option, + pub pool: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct UpstreamNodeView { + pub id: String, + pub name: String, + pub software: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpstreamSyncView { + pub state: String, + pub request_generation: u64, + pub completed_generation: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpstreamPoolView { + pub account_count: u32, + pub states: UpstreamPoolStatesView, + pub plans: Vec, +} + +#[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(Serialize)] +#[serde(rename_all = "camelCase")] +struct RefreshReceiptView { + account_id: String, + connection_revision: String, + request_generation: u64, + state: String, +} + +#[derive(Serialize)] +struct RefreshData { + items: Vec, +} + +#[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, + time: crate::time::TimePresenter, +) -> AccountUpstreamInfoView { + AccountUpstreamInfoView { + discovered: info.discovered, + node: info.node.map(|node| UpstreamNodeView { + id: node.id, + name: node.name, + software: node.software, + }), + 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, + sync: UpstreamSyncView { + state: info.sync.state.as_str().to_owned(), + request_generation: info.sync.request_generation, + completed_generation: info.sync.completed_generation, + }, + 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(), + }), + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct RefreshRequest { + account_ids: Vec, +} + +pub(super) async fn refresh( + _auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result +where + S: crate::auth::SessionState + Send + Sync, +{ + if request.account_ids.is_empty() || request.account_ids.len() > 200 { + return Err(map_wire_error(WireValidationError::new("accountIds"))); + } + let ids = request + .account_ids + .into_iter() + .collect::>() + .into_iter() + .map(|id| { + require_account_id(&id, "accountIds")?; + ProviderAccountId::new(id).map_err(|_| WireValidationError::new("accountIds")) + }) + .collect::, _>>() + .map_err(map_wire_error)?; + let items = state + .admin_services() + .accounts() + .refresh_upstream_info(ids) + .await + .map_err(map_service_error)?; + let items = items + .into_iter() + .map(|item| RefreshReceiptView { + account_id: item.account_id, + connection_revision: item.connection_revision, + request_generation: item.request_generation, + state: item.state.as_str().to_owned(), + }) + .collect(); + Ok(AdminResponse::new( + StatusCode::ACCEPTED, + AdminEnvelope::ok(RefreshData { items }), + )) +} diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index 0cd0da5a0..de69d81c8 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -266,6 +266,7 @@ pub struct AccountSummaryView { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AccountView { + pub upstream: Option, pub capabilities: AccountCapabilitiesView, pub outbound_proxy_id: Option, pub outbound_proxy_endpoint: Option, @@ -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, + pub amount: Option, + pub observed_at_display: Option, + pub stale: bool, pub key: String, pub group: String, pub limit_id: Option, @@ -433,6 +439,7 @@ pub struct AccountQuotaWindowView { #[serde(rename_all = "snake_case")] pub enum AccountUsageWindowKind { General, + Daily, Weekly, Monthly, Quota, diff --git a/backend/crates/gateway-api/src/admin/client_keys.rs b/backend/crates/gateway-api/src/admin/client_keys.rs index f92823772..4594b61a9 100644 --- a/backend/crates/gateway-api/src/admin/client_keys.rs +++ b/backend/crates/gateway-api/src/admin/client_keys.rs @@ -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>, @@ -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, #[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>, @@ -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, diff --git a/backend/crates/gateway-api/src/admin/observability/presenter.rs b/backend/crates/gateway-api/src/admin/observability/presenter.rs index 227a9df80..678bb753f 100644 --- a/backend/crates/gateway-api/src/admin/observability/presenter.rs +++ b/backend/crates/gateway-api/src/admin/observability/presenter.rs @@ -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(), diff --git a/backend/crates/gateway-api/src/admin/observability/wire.rs b/backend/crates/gateway-api/src/admin/observability/wire.rs index 47215d31c..36a64271f 100644 --- a/backend/crates/gateway-api/src/admin/observability/wire.rs +++ b/backend/crates/gateway-api/src/admin/observability/wire.rs @@ -402,6 +402,7 @@ pub enum DashboardAccountMetric { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct DashboardAccountUsageView { + pub upstream: Option, pub id: String, pub provider: String, pub authentication_kind: String, diff --git a/backend/crates/gateway-api/src/admin/presenter.rs b/backend/crates/gateway-api/src/admin/presenter.rs index 86022bc6c..7d312b455 100644 --- a/backend/crates/gateway-api/src/admin/presenter.rs +++ b/backend/crates/gateway-api/src/admin/presenter.rs @@ -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}") } diff --git a/backend/crates/gateway-api/src/admin/proxies.rs b/backend/crates/gateway-api/src/admin/proxies.rs index 0766907ae..44ebe3d9e 100644 --- a/backend/crates/gateway-api/src/admin/proxies.rs +++ b/backend/crates/gateway-api/src/admin/proxies.rs @@ -135,6 +135,7 @@ impl From for ProxyTestView { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ProxyAccountView { + upstream: Option, id: String, name: String, email: Option, @@ -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, diff --git a/backend/crates/gateway-api/src/openai/mod.rs b/backend/crates/gateway-api/src/openai/mod.rs index eca2f90a7..5e92e79ac 100644 --- a/backend/crates/gateway-api/src/openai/mod.rs +++ b/backend/crates/gateway-api/src/openai/mod.rs @@ -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; diff --git a/backend/crates/gateway-api/src/openai/peer_link/mod.rs b/backend/crates/gateway-api/src/openai/peer_link/mod.rs new file mode 100644 index 000000000..f613c1391 --- /dev/null +++ b/backend/crates/gateway-api/src/openai/peer_link/mod.rs @@ -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 { + 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, 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::::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 +} diff --git a/backend/crates/gateway-api/src/openai/peer_link/wire.rs b/backend/crates/gateway-api/src/openai/peer_link/wire.rs new file mode 100644 index 000000000..8d836735d --- /dev/null +++ b/backend/crates/gateway-api/src/openai/peer_link/wire.rs @@ -0,0 +1,136 @@ +//! 将已脱敏的互联领域观测投影为版本化协议 + +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, PeerNode, 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, + node: PeerNode { + id: export.node.id, + name: export.node.name, + software: "codex-proxy-rs".to_owned(), + }, + 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(), + daily: budget_window( + export.budget.limits.daily_usd, + export.budget.daily_used_usd, + export.budget.daily_resets_at, + ), + weekly: budget_window( + export.budget.limits.weekly_usd, + export.budget.weekly_used_usd, + export.budget.weekly_resets_at, + ), + }), + }, + 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) -> PeerBudgetWindow { + let limited = total != Decimal::ZERO; + PeerBudgetWindow { + total: limited.then(|| total.canonical()), + used: used.canonical(), + remaining: limited.then(|| total.saturating_sub(used).canonical()), + resets_at: resets_at.map(|time| DateTime::::from(time).to_rfc3339()), + } +} diff --git a/backend/crates/gateway-api/src/openai/router.rs b/backend/crates/gateway-api/src/openai/router.rs index f7afe3a38..8d234f25b 100644 --- a/backend/crates/gateway-api/src/openai/router.rs +++ b/backend/crates/gateway-api/src/openai/router.rs @@ -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 { @@ -40,6 +41,7 @@ pub(crate) fn router() -> Router { // 官方 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 diff --git a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs index 3249d0b1c..1ee82d4d9 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs @@ -9,6 +9,73 @@ use tower::ServiceExt as _; use super::super::{AdminTestFixture, AdminTestState}; +#[tokio::test] +async fn upstream_refresh_requires_admin_and_returns_an_accepted_generation() { + 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, ids, expected) in [ + ( + false, + serde_json::json!(["acct_cooldown"]), + StatusCode::UNAUTHORIZED, + ), + (true, serde_json::json!([]), StatusCode::BAD_REQUEST), + ( + true, + serde_json::json!(["invalid"]), + StatusCode::BAD_REQUEST, + ), + ( + true, + serde_json::json!(["acct_cooldown", "acct_cooldown"]), + StatusCode::ACCEPTED, + ), + ] { + let mut request = Request::builder() + .method("POST") + .uri("/api/admin/accounts/upstream-info/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::() + .with_state(fixture.state()) + .oneshot( + request + .body(Body::from( + serde_json::json!({"accountIds":ids}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), expected); + if expected == StatusCode::ACCEPTED { + 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"]["items"], + serde_json::json!([{"accountId":"acct_cooldown", "connectionRevision":"connection-7", "requestGeneration":7, "state":"queued"}]) + ); + } + } +} + #[tokio::test] async fn account_model_reads_and_refreshes_preserve_provider_observation_time() { use chrono::{DateTime, Utc}; @@ -307,6 +374,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, diff --git a/backend/crates/gateway-api/tests/admin/accounts/mod.rs b/backend/crates/gateway-api/tests/admin/accounts/mod.rs index 935508161..ba1ebec5b 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/mod.rs @@ -527,10 +527,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"); } } diff --git a/backend/crates/gateway-api/tests/admin/accounts/presenter.rs b/backend/crates/gateway-api/tests/admin/accounts/presenter.rs index e5c8dc009..5839af1ab 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/presenter.rs @@ -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); diff --git a/backend/crates/gateway-api/tests/admin/client_keys.rs b/backend/crates/gateway-api/tests/admin/client_keys.rs index e23589717..fb983ea4c 100644 --- a/backend/crates/gateway-api/tests/admin/client_keys.rs +++ b/backend/crates/gateway-api/tests/admin/client_keys.rs @@ -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"), diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 50bc832c5..5f8a2c2dc 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -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, @@ -113,6 +119,7 @@ pub(super) struct AdminTestFixture { pub dashboard_summary_range: Arc>>, pub provider_error: Arc>>, pub provider_quota: Arc>>, + pub provider_quota_gate: Arc>>>, pub provider_models: Arc>>, pub account: Arc>>, pub account_usage: Arc>>, @@ -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> = 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, @@ -997,6 +1011,15 @@ fn mutation( #[async_trait] impl ClientKeyStore for MemoryClientKeyStore { + async fn peer_node_identity( + &self, + ) -> AdminStoreResult { + Ok(gateway_admin::model::peer_link::PeerNodeIdentity { + id: "test-peer-node".to_owned(), + name: "测试节点".to_owned(), + }) + } + async fn update_client_key_budget_limits( &self, _: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits, @@ -1071,6 +1094,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(), @@ -1612,6 +1636,7 @@ struct UnusedProvider { kind: ProviderKind, error: Arc>>, quota: Arc>>, + quota_gate: Arc>>>, models: Arc>>, } @@ -1620,12 +1645,14 @@ impl UnusedProvider { kind: &str, error: Arc>>, quota: Arc>>, + quota_gate: Arc>>>, models: Arc>>, ) -> Self { Self { kind: ProviderKind::new(kind).expect("provider kind"), error, quota, + quota_gate, models, } } @@ -1633,6 +1660,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 { + 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 +1764,11 @@ impl ProviderAdmin for UnusedProvider { &self, _: gateway_admin::model::provider_credentials::ProviderQuotaRequest, ) -> Result { + 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); } diff --git a/backend/crates/gateway-api/tests/admin/observability/response.rs b/backend/crates/gateway-api/tests/admin/observability/response.rs index 07dc65272..bbc1c431f 100644 --- a/backend/crates/gateway-api/tests/admin/observability/response.rs +++ b/backend/crates/gateway-api/tests/admin/observability/response.rs @@ -1770,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(), @@ -1796,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(), @@ -1969,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(), diff --git a/backend/crates/gateway-api/tests/admin/proxies.rs b/backend/crates/gateway-api/tests/admin/proxies.rs index a686ae2d8..e2c3d38b2 100644 --- a/backend/crates/gateway-api/tests/admin/proxies.rs +++ b/backend/crates/gateway-api/tests/admin/proxies.rs @@ -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 }]) ); diff --git a/backend/crates/gateway-api/tests/admin/upstream_info.rs b/backend/crates/gateway-api/tests/admin/upstream_info.rs new file mode 100644 index 000000000..1bda6d6d8 --- /dev/null +++ b/backend/crates/gateway-api/tests/admin/upstream_info.rs @@ -0,0 +1,58 @@ +//! 账号 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>>); + +impl UpstreamInfoSource for TestPeerInfo { + fn signal( + &self, + _: &ProviderAccount, + _: &str, + _: SystemTime, + ) -> Option { + None + } +} + +#[async_trait] +impl PeerUpstreamService for TestPeerInfo { + async fn account_changed(&self, _: &ProviderAccountId) -> Result<(), StoreError> { + Ok(()) + } + fn forget(&self, _: &ProviderAccountId) {} + async fn request_refresh( + &self, + account_id: &ProviderAccountId, + ) -> Result { + Ok( + gateway_admin::model::upstream_info::UpstreamRefreshReceipt { + account_id: account_id.as_str().to_owned(), + connection_revision: "connection-7".to_owned(), + request_generation: 7, + state: gateway_admin::model::upstream_info::UpstreamSyncState::Queued, + }, + ) + } + async fn read_quota(&self, _: &ProviderAccountId) -> Result, StoreError> { + Ok(self.0.lock().unwrap().clone()) + } + async fn read_summary( + &self, + _: &ProviderAccountId, + ) -> Result, StoreError> { + Ok(self.0.lock().unwrap().clone()) + } + async fn read_quotas( + &self, + requests: &[ProviderQuotaReadRequest<'_>], + ) -> Vec> { + let quota = self.0.lock().unwrap().clone().unwrap_or_default(); + requests.iter().map(|_| Ok(quota.clone())).collect() + } +} diff --git a/backend/crates/gateway-api/tests/key_usage/fixtures.rs b/backend/crates/gateway-api/tests/key_usage/fixtures.rs index 318c66f99..b6400ba39 100644 --- a/backend/crates/gateway-api/tests/key_usage/fixtures.rs +++ b/backend/crates/gateway-api/tests/key_usage/fixtures.rs @@ -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(), diff --git a/backend/crates/gateway-api/tests/openai/mod.rs b/backend/crates/gateway-api/tests/openai/mod.rs index 8b2889326..fd3e61cd6 100644 --- a/backend/crates/gateway-api/tests/openai/mod.rs +++ b/backend/crates/gateway-api/tests/openai/mod.rs @@ -9,6 +9,7 @@ mod images; mod live; mod middleware; mod models; +mod peer_link; mod responses; mod router; mod search; diff --git a/backend/crates/gateway-api/tests/openai/peer_link/catalog.rs b/backend/crates/gateway-api/tests/openai/peer_link/catalog.rs new file mode 100644 index 000000000..2edb055c0 --- /dev/null +++ b/backend/crates/gateway-api/tests/openai/peer_link/catalog.rs @@ -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> { + Box::pin(async { Ok(self.0.clone()) }) + } + + fn current_config_revision(&self) -> BoxFuture<'_, Result> { + 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::new("openai").unwrap(), + ProviderCatalogGeneration::new(1), + )] + .into() + } + + fn query_model_capabilities( + &self, + _: &ProviderKind, + ) -> BoxFuture<'_, Result, 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) + ); +} diff --git a/backend/crates/gateway-api/tests/openai/peer_link/mod.rs b/backend/crates/gateway-api/tests/openai/peer_link/mod.rs new file mode 100644 index 000000000..aaa774e72 --- /dev/null +++ b/backend/crates/gateway-api/tests/openai/peer_link/mod.rs @@ -0,0 +1,534 @@ +//! 验证互联导出的授权范围、过期观测和独立于推理准入的只读合同 + +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 { + 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["usage"]["data"]["daily"]["remaining"], "0"); + 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 + ); +} diff --git a/backend/crates/gateway-core/src/account/mod.rs b/backend/crates/gateway-core/src/account/mod.rs index f217dd676..2ccab47f5 100644 --- a/backend/crates/gateway-core/src/account/mod.rs +++ b/backend/crates/gateway-core/src/account/mod.rs @@ -19,10 +19,12 @@ pub mod scope; mod selection; mod smart_scheduling; mod store; +mod upstream; pub use error::CredentialError; pub use model::*; pub(crate) use selection::smart_score; pub use selection::*; pub use smart_scheduling::{SmartSchedulingConfig, SmartSchedulingConfigError}; -pub use store::ProviderAccountStore; +pub use store::{ProviderAccountStore, UpstreamInfoStore}; +pub use upstream::*; diff --git a/backend/crates/gateway-core/src/account/scope.rs b/backend/crates/gateway-core/src/account/scope.rs index 19e842c13..fcaf29e19 100644 --- a/backend/crates/gateway-core/src/account/scope.rs +++ b/backend/crates/gateway-core/src/account/scope.rs @@ -48,6 +48,7 @@ pub struct RuntimeAccount { provider_kind: ProviderKind, group_ids: Arc>, model_access: super::AccountModelAccess, + direct_authentication_kind: Option, } 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) -> 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, directory: Arc, @@ -258,6 +278,31 @@ pub struct FrozenAccountScope { } impl FrozenAccountScope { + /// 枚举冻结权限实际允许的账号,保留所有既有直连和分组约束 + pub fn allowed_account_ids(&self) -> impl Iterator { + 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 { diff --git a/backend/crates/gateway-core/src/account/selection.rs b/backend/crates/gateway-core/src/account/selection.rs index ecec5a796..552c110a8 100644 --- a/backend/crates/gateway-core/src/account/selection.rs +++ b/backend/crates/gateway-core/src/account/selection.rs @@ -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); } diff --git a/backend/crates/gateway-core/src/account/store.rs b/backend/crates/gateway-core/src/account/store.rs index 03c01498c..d1185b5ba 100644 --- a/backend/crates/gateway-core/src/account/store.rs +++ b/backend/crates/gateway-core/src/account/store.rs @@ -1,6 +1,7 @@ //! Provider 账号持久化端口 use async_trait::async_trait; +use std::time::{Duration, SystemTime}; use crate::error::StoreError; use crate::identity::ProviderKind; @@ -9,7 +10,8 @@ use super::{ AccountStateChange, CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, LoadedCredential, NewProviderAccount, ProviderAccount, ProviderAccountId, ProviderAccountUpdate, ProviderRefreshQuery, QuotaAccessChange, QuotaObservation, - QuotaObservationTouch, QuotaWriteOutcome, + QuotaObservationTouch, QuotaWriteOutcome, UpstreamConnection, UpstreamInfoClaim, + UpstreamInfoCompletion, UpstreamInfoPlan, UpstreamInfoRecord, }; /// `provider_accounts` 的数据库中立端口 @@ -87,3 +89,56 @@ pub trait ProviderAccountStore: Send + Sync { async fn delete_account(&self, account: &ProviderAccountId) -> Result<(), StoreError>; } + +/// 与 Provider 无关的计划持久化端口,所有方法均只访问本地存储 +#[async_trait] +pub trait UpstreamInfoStore: Send + Sync { + /// 按账号 ID 游标扫描 Provider 声明支持互联的认证种类;通用存储不判断认证协议 + async fn list_candidates( + &self, + provider: &ProviderKind, + authentication_kinds: &[&str], + after: Option<&ProviderAccountId>, + limit: u32, + ) -> Result, StoreError>; + + /// 新连接创建立即到期的计划;身份变化清空旧观测;停用则删除计划与观测 + /// 写入前须按当前账号事实核对连接身份;匹配失败返回 Conflict + async fn reconcile( + &self, + connection: &UpstreamConnection, + enabled: bool, + now: SystemTime, + ) -> Result<(), StoreError>; + + /// 请求代次合并;正在领取或排队的计划不重复递增,失败退避不能被手动绕过 + async fn request_refresh( + &self, + connection: &UpstreamConnection, + now: SystemTime, + minimum_interval: Duration, + ) -> Result; + + /// 批量读取同步元数据,用于核对缓存观测版本,不读取快照正文 + async fn read_plans( + &self, + accounts: &[ProviderAccountId], + ) -> Result, StoreError>; + + async fn read( + &self, + accounts: &[ProviderAccountId], + ) -> Result, StoreError>; + + /// 原子领取到期计划并设置租期;每次领取生成不能复用的领取标识 + async fn claim_due( + &self, + provider: &ProviderKind, + now: SystemTime, + limit: u32, + lease_duration: Duration, + ) -> Result, StoreError>; + + /// 返回 false 代表连接、领取标识或代次已经变化;Unauthorized 必须同时清空观测 + async fn complete(&self, completion: UpstreamInfoCompletion) -> Result; +} diff --git a/backend/crates/gateway-core/src/account/upstream.rs b/backend/crates/gateway-core/src/account/upstream.rs new file mode 100644 index 000000000..5b9223b4f --- /dev/null +++ b/backend/crates/gateway-core/src/account/upstream.rs @@ -0,0 +1,161 @@ +//! 上游互联观测与后台计划;原始协议由控制面解释,不改变账号自身额度状态 + +use std::{ + sync::{Arc, OnceLock, Weak}, + time::SystemTime, +}; + +use sha2::{Digest, Sha256}; + +use super::{CredentialRevision, OpaqueProviderData, 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, PartialEq, Eq)] +pub struct UpstreamInfoPlan { + pub connection: UpstreamConnection, + pub request_generation: u64, + pub completed_generation: u64, + pub next_attempt_at: SystemTime, + pub claimed_until: Option, + pub last_attempt_at: Option, + pub last_success_at: Option, + pub last_error: Option, + pub consecutive_failures: u32, +} + +/// 同一连接最后成功观测与独立同步计划,失败不覆盖旧观测 +#[derive(Debug, Clone, PartialEq)] +pub struct UpstreamInfoRecord { + pub plan: UpstreamInfoPlan, + pub snapshot: Option, +} + +/// 每次领取使用独立标识,计划删除重建也不能接受旧执行者的结果 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpstreamInfoClaim { + pub plan: UpstreamInfoPlan, + pub claim_token: uuid::Uuid, +} + +/// 完整观测只在连接身份、领取标识与代次仍匹配时写入 +#[derive(Debug, Clone, PartialEq)] +pub struct UpstreamInfoCompletion { + pub connection: UpstreamConnection, + pub request_generation: u64, + pub claim_token: uuid::Uuid, + pub completed_at: SystemTime, + pub next_attempt_at: SystemTime, + pub result: Result, +} + +/// 调度侧只消费已发布的中立观测,不读取协议、凭据或持久存储 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct UpstreamSchedulingSignal { + pub exhausted: bool, + pub remaining_percent: Option, +} + +pub trait UpstreamInfoSource: Send + Sync { + fn signal( + &self, + account: &ProviderAccount, + model: &str, + now: SystemTime, + ) -> Option; +} + +/// 组合根在管理服务就绪后绑定一次;弱引用避免 Provider 与管理服务互相保活 +#[derive(Default)] +pub struct UpstreamInfoReader { + source: OnceLock>, +} + +impl UpstreamInfoReader { + pub fn bind( + &self, + source: &Arc, + ) -> 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 { + self.source.get()?.upgrade()?.signal(account, model, now) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("upstream information source is already bound")] +pub struct UpstreamInfoAlreadyBound; diff --git a/backend/crates/gateway-core/src/engine/provider.rs b/backend/crates/gateway-core/src/engine/provider.rs index aa3dc1c57..8ed8f454d 100644 --- a/backend/crates/gateway-core/src/engine/provider.rs +++ b/backend/crates/gateway-core/src/engine/provider.rs @@ -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) diff --git a/backend/crates/gateway-core/src/policy/mod.rs b/backend/crates/gateway-core/src/policy/mod.rs index 03d708a4b..adc1db083 100644 --- a/backend/crates/gateway-core/src/policy/mod.rs +++ b/backend/crates/gateway-core/src/policy/mod.rs @@ -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, defaults: Arc, 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() diff --git a/backend/crates/gateway-core/src/policy/peer_sharing.rs b/backend/crates/gateway-core/src/policy/peer_sharing.rs new file mode 100644 index 000000000..4a99b5794 --- /dev/null +++ b/backend/crates/gateway-core/src/policy/peer_sharing.rs @@ -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(()) + } +} diff --git a/backend/crates/gateway-core/src/routing/catalog.rs b/backend/crates/gateway-core/src/routing/catalog.rs index 38ed9b626..e41ed43d2 100644 --- a/backend/crates/gateway-core/src/routing/catalog.rs +++ b/backend/crates/gateway-core/src/routing/catalog.rs @@ -149,6 +149,11 @@ pub struct ProviderCatalogUnavailable; /// 快照编译与对账使用的对象安全目录端口 pub trait ProviderCatalogPort: Send + Sync { + /// Provider 解释认证类型,快照只冻结直连事实;未知扩展不默认获得转供资格 + fn is_direct_account(&self, _provider: &ProviderKind, _authentication_kind: &str) -> bool { + false + } + /// 目录是否完整到足以根据缺项拒绝请求;发现型目录交由上游验证模型名 fn model_catalog_is_exhaustive(&self, _provider: &ProviderKind) -> bool { true diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index ea561d91b..b79d9f202 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -10,7 +10,8 @@ use crate::account::{FastMode, ProviderAccountId}; use crate::concurrency::ConcurrencyQueuePolicy; use crate::operation::{Operation, OperationKind}; use crate::policy::{ - ClientApiKeyId, ClientPolicy, CodexClientMinVersions, PlaintextClientApiKey, RateLimits, + ClientApiKeyId, ClientPeerSharing, ClientPolicy, CodexClientMinVersions, PlaintextClientApiKey, + RateLimits, }; use crate::settings::{SettingsValues, compiled::CompiledSettings}; use crate::validation::RoutingError; @@ -29,6 +30,7 @@ type ModelCatalogAccounts = BTreeMap, request_profiles: BTreeMap, key_id: ClientApiKeyId, @@ -38,6 +40,12 @@ pub struct SnapshotClientPolicyFacts { } impl SnapshotClientPolicyFacts { + #[must_use] + pub const fn with_peer_sharing(mut self, sharing: ClientPeerSharing) -> Self { + self.peer_sharing = sharing; + self + } + #[must_use] pub fn with_name_snapshot(mut self, name: Option) -> Self { self.name_snapshot = name; @@ -61,6 +69,7 @@ impl SnapshotClientPolicyFacts { limits: RateLimits, ) -> Self { Self { + peer_sharing: ClientPeerSharing::default(), key_id, name_snapshot: None, request_profiles: BTreeMap::new(), @@ -112,6 +121,7 @@ pub struct SnapshotProviderAccountFacts { account_id: ProviderAccountId, provider_kind: String, model_access: crate::account::AccountModelAccess, + authentication_kind: String, } impl SnapshotProviderAccountFacts { @@ -121,11 +131,18 @@ impl SnapshotProviderAccountFacts { account_id, provider_kind: provider_kind.into(), model_access: crate::account::AccountModelAccess::all(), + authentication_kind: String::new(), } } } impl SnapshotProviderAccountFacts { + #[must_use] + pub fn with_authentication_kind(mut self, authentication_kind: String) -> Self { + self.authentication_kind = authentication_kind; + self + } + #[must_use] pub fn with_model_access(mut self, model_access: crate::account::AccountModelAccess) -> Self { self.model_access = model_access; @@ -472,12 +489,17 @@ async fn compile_runtime_snapshot( for account in facts.provider_accounts { let provider_kind = ProviderKind::new(account.provider_kind) .map_err(|_| RuntimeSnapshotCompileError::InvalidData)?; + let direct_account = + catalogs.is_direct_account(&provider_kind, &account.authentication_kind); // 账号归属独立于当前执行器集合;Provider 撤下后仍保留账号和分组,路由只选已注册执行器 if accounts .insert( account.account_id.clone(), RuntimeAccount::new(provider_kind, BTreeSet::new()) - .with_model_access(account.model_access), + .with_model_access(account.model_access) + .with_direct_authentication_kind( + direct_account.then_some(account.authentication_kind), + ), ) .is_some() { @@ -503,7 +525,10 @@ async fn compile_runtime_snapshot( .get_mut(&account_id) .ok_or(RuntimeSnapshotCompileError::InvalidData)?; *account = RuntimeAccount::new(account.provider_kind().clone(), group_ids) - .with_model_access(account.model_access().clone()); + .with_model_access(account.model_access().clone()) + .with_direct_authentication_kind( + account.direct_authentication_kind().map(str::to_owned), + ); } let account_directory = Arc::new(RuntimeAccountDirectory::new(accounts)); @@ -559,7 +584,8 @@ async fn compile_runtime_snapshot( true, policy.limits, ) - .with_name_snapshot(policy.name_snapshot), + .with_name_snapshot(policy.name_snapshot) + .with_peer_sharing(policy.peer_sharing), ); } @@ -1014,6 +1040,43 @@ impl RuntimeSnapshot { && !self.is_known_upstream_model(&upstream_model)) } + /// 通用模型候选资格,不以原生目录来源限制推理,也不替代操作能力与实时选号检查 + #[must_use] + pub fn allows_public_model_for_account( + &self, + public_model: &PublicModelId, + scope: &FrozenAccountScope, + account: &ProviderAccountId, + ) -> bool { + let Some(provider) = scope.account_provider(account) else { + return false; + }; + let upstream = self.mapped_model(public_model.as_str()); + if !scope.allows_model(account, &upstream) + || !self.contains_public_model_for_provider(public_model, provider) + { + return false; + } + let Ok(upstream) = UpstreamModelId::from_client_wire(upstream) else { + return false; + }; + let native = self + .provider_models + .get(provider) + .is_some_and(|models| models.contains_key(&upstream)); + // 仅由适配器贡献的模型仍受声明账号范围限制,不能借发现型目录扩大资格 + native + || !self + .contributed_models() + .iter() + .any(|model| &model.provider == provider && &model.id == public_model) + || self.contributed_models().iter().any(|model| { + &model.provider == provider + && &model.id == public_model + && model.accounts.contains(account) + }) + } + fn is_known_upstream_model(&self, upstream_model: &UpstreamModelId) -> bool { self.provider_models .values() diff --git a/backend/crates/gateway-core/tests/account/mod.rs b/backend/crates/gateway-core/tests/account/mod.rs index 4757794f4..c86858264 100644 --- a/backend/crates/gateway-core/tests/account/mod.rs +++ b/backend/crates/gateway-core/tests/account/mod.rs @@ -3,6 +3,7 @@ mod location; mod model_access; mod proxy; +mod scope; mod selection; mod selection_diagnostics; mod smart_scheduling; diff --git a/backend/crates/gateway-core/tests/account/scope.rs b/backend/crates/gateway-core/tests/account/scope.rs new file mode 100644 index 000000000..0c7424236 --- /dev/null +++ b/backend/crates/gateway-core/tests/account/scope.rs @@ -0,0 +1,95 @@ +//! 互联直连限制与分组、模型和请求局部范围的交集 + +use super::*; +use gateway_core::account::{AccountModelAccess, AccountModelAccessMode}; +use gateway_core::routing::{AccountGroupId, RoutingGroupSnapshot}; + +#[test] +fn direct_scope_intersects_groups_models_and_request_restrictions() { + let group = AccountGroupId::new("grp_11111111111111111111111111111111").unwrap(); + let provider = ProviderKind::new("openai").unwrap(); + let direct = ProviderAccountId::new("acct_direct").unwrap(); + let forwarded = ProviderAccountId::new("acct_forwarded").unwrap(); + let unshared = ProviderAccountId::new("acct_unshared").unwrap(); + let directory = Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([ + ( + direct.clone(), + RuntimeAccount::new(provider.clone(), BTreeSet::from([group.clone()])) + .with_direct_authentication_kind(Some("oauth".into())) + .with_model_access( + AccountModelAccess::new( + AccountModelAccessMode::Allowlist, + vec!["gpt-5".into()], + ) + .unwrap(), + ), + ), + ( + forwarded.clone(), + RuntimeAccount::new(provider.clone(), BTreeSet::from([group.clone()])), + ), + ( + unshared.clone(), + RuntimeAccount::new(provider.clone(), BTreeSet::new()) + .with_direct_authentication_kind(Some("oauth".into())), + ), + ]))); + let scope = FrozenAccountScope::new( + directory.clone(), + ClientRoutingScope::restricted( + vec![RoutingGroupSnapshot::new(group.clone(), "共享组".into())], + BTreeSet::from([group]), + BTreeSet::from([provider]), + ) + .unwrap(), + ) + .with_direct_accounts_only(true); + assert!(scope.allows_model(&direct, "gpt-5")); + assert!(!scope.allows_model(&direct, "unshared-model")); + assert!(!scope.allows(&forwarded)); + assert!(!scope.allows(&unshared)); + let restricted = scope.restricted_to(&BTreeSet::new(), &BTreeSet::from([forwarded])); + assert!(restricted.provider_kinds().is_empty()); + assert!(!restricted.allows(&direct)); + assert!(!scope.excluding_account(direct.clone()).allows(&direct)); + assert!( + !FrozenAccountScope::new(directory, ClientRoutingScope::AllAccounts) + .with_direct_accounts_only(true) + .allows(&direct) + ); +} + +#[test] +fn direct_scope_rejects_current_proxy_credentials_before_snapshot_publication() { + let group = AccountGroupId::new("grp_11111111111111111111111111111111").unwrap(); + let provider = ProviderKind::new("openai").unwrap(); + let id = ProviderAccountId::new("acct_direct").unwrap(); + let directory = Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([( + id.clone(), + RuntimeAccount::new(provider.clone(), BTreeSet::from([group.clone()])) + .with_direct_authentication_kind(Some("oauth".into())), + )]))); + let scope = FrozenAccountScope::new( + directory, + ClientRoutingScope::restricted( + vec![RoutingGroupSnapshot::new(group.clone(), "共享组".into())], + BTreeSet::from([group]), + BTreeSet::from([provider.clone()]), + ) + .unwrap(), + ) + .with_direct_accounts_only(true); + let current = |kind: &str| { + ProviderAccount::new( + id.clone(), + provider.clone(), + "direct".into(), + None, + kind.into(), + CredentialRevision::new(2).unwrap(), + None, + ) + }; + assert!(scope.allows_current_account(¤t("oauth"))); + assert!(!scope.allows_current_account(¤t("api_key"))); +} diff --git a/backend/crates/gateway-core/tests/routing/snapshot.rs b/backend/crates/gateway-core/tests/routing/snapshot.rs index bd7004b2b..aab41dc0d 100644 --- a/backend/crates/gateway-core/tests/routing/snapshot.rs +++ b/backend/crates/gateway-core/tests/routing/snapshot.rs @@ -183,6 +183,23 @@ fn same_name_native_and_adapted_sources_keep_account_capabilities_separate() { let scope = native.all_account_scope(); let b_only = Arc::new(scope.restricted_to(&Default::default(), &[b.clone()].into())); assert!(native.public_models_for_scope(&b_only).is_empty()); + assert!(native.allows_public_model_for_account(&id, &b_only, &b)); + let c = ProviderAccountId::new("acct_c").unwrap(); + assert!(!native.allows_public_model_for_account(&id, &scope, &c)); + assert!(!native.allows_public_model_for_account(&id, &b_only, &c)); + assert!( + native + .plan( + &id, + &super::operation(), + b_only.clone(), + &Default::default() + ) + .unwrap() + .candidates()[0] + .account_scope() + .allows(&b) + ); let snapshot = native.with_extensions(Some(ExtensionSetReference::new( ExtensionSetId::new("adapted".into()).unwrap(), Arc::new(ModelLease(vec![contribution.clone()])), @@ -277,7 +294,10 @@ fn adapted_models_limit_visibility_and_routing_to_capable_authorized_accounts() use gateway_core::{ account::ProviderAccountId, operation::{Feature, GenerateRequest, Operation, ProtocolPayload}, - routing::{ContributedModel, SupportLevel}, + routing::{ + ClientRoutingScope, ContributedModel, FrozenAccountScope, RuntimeAccount, + RuntimeAccountDirectory, SupportLevel, + }, }; let account = ProviderAccountId::new("acct_openai").unwrap(); let provider = ProviderKind::new("openai").unwrap(); @@ -296,6 +316,16 @@ fn adapted_models_limit_visibility_and_routing_to_capable_authorized_accounts() ))); let scope = super::account_scope(); assert!(snapshot.public_models_for_scope(&scope).contains(&id)); + assert!(snapshot.allows_public_model_for_account(&id, &scope, &account)); + let undeclared = ProviderAccountId::new("acct_undeclared").unwrap(); + let all = FrozenAccountScope::new( + Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([( + undeclared.clone(), + RuntimeAccount::new(ProviderKind::new("openai").unwrap(), Default::default()), + )]))), + ClientRoutingScope::all_accounts(), + ); + assert!(!snapshot.allows_public_model_for_account(&id, &all, &undeclared)); assert!( snapshot .public_model_profiles_for_scope(&scope) @@ -1349,6 +1379,89 @@ fn facts(config_revision: u64, observed_current_revision: u64) -> SnapshotFacts facts_with_min_versions(config_revision, observed_current_revision, None, None) } +#[test] +fn peer_policy_compilation_keeps_only_explicitly_shared_direct_accounts() { + use gateway_core::{ + account::ProviderAccountId, policy::ClientPeerSharing, routing::AccountGroupId, + }; + struct DirectCatalog; + impl ProviderCatalogPort for DirectCatalog { + fn is_direct_account(&self, provider: &ProviderKind, kind: &str) -> bool { + provider.as_str() == "alpha" && kind == "oauth" + } + fn catalog_generations(&self) -> BTreeMap { + BTreeMap::from([( + ProviderKind::new("alpha").unwrap(), + ProviderCatalogGeneration::new(1), + )]) + } + fn query_model_capabilities( + &self, + _: &ProviderKind, + ) -> BoxFuture<'_, Result, ProviderCatalogUnavailable>> + { + Box::pin(async { Ok(Vec::new()) }) + } + } + let group = AccountGroupId::new("grp_11111111111111111111111111111111").unwrap(); + let direct = ProviderAccountId::new("acct_direct").unwrap(); + let proxy = ProviderAccountId::new("acct_proxy").unwrap(); + for (bound, enabled, expected) in [ + (true, true, true), + (true, false, false), + (false, true, false), + ] { + let facts = SnapshotFacts::new( + revision(1), + revision(1), + SettingsValues::new(3, "smart", BTreeMap::new(), None, None), + vec![ + SnapshotClientPolicyFacts::new( + ClientApiKeyId::new("key_peer").unwrap(), + PlaintextClientApiKey::new("sk_peer_test").unwrap(), + if bound { + vec![group.clone()] + } else { + Vec::new() + }, + RateLimits::unlimited(), + ) + .with_peer_sharing(ClientPeerSharing { + enabled: true, + ..Default::default() + }), + ], + vec![SnapshotAccountGroupFacts::new( + group.clone(), + "共享组".into(), + enabled, + )], + vec![ + SnapshotProviderAccountFacts::new(direct.clone(), "alpha") + .with_authentication_kind("oauth".into()), + SnapshotProviderAccountFacts::new(proxy.clone(), "alpha") + .with_authentication_kind("api_key".into()), + ], + vec![ + SnapshotAccountGroupMemberFacts::new(group.clone(), direct.clone()), + SnapshotAccountGroupMemberFacts::new(group.clone(), proxy.clone()), + ], + ); + let snapshot = block_on( + RuntimeSnapshotCompiler::new( + Arc::new(TestSnapshotStore::new(Ok(facts))), + Arc::new(DirectCatalog), + ) + .compile(), + ) + .unwrap(); + let scope = snapshot.client_policies().next().unwrap().account_scope(); + assert_eq!(scope.allows(&direct), expected); + assert!(!scope.allows(&proxy)); + assert_eq!(scope.provider_kinds().is_empty(), !expected); + } +} + fn facts_with_min_versions( config_revision: u64, observed_current_revision: u64, diff --git a/backend/crates/gateway-host/Cargo.toml b/backend/crates/gateway-host/Cargo.toml index 931fc001a..98d8cb21e 100644 --- a/backend/crates/gateway-host/Cargo.toml +++ b/backend/crates/gateway-host/Cargo.toml @@ -18,6 +18,7 @@ flate2 = "1.1.9" futures = "0.3.32" gateway-admin = { path = "../gateway-admin" } gateway-core = { path = "../gateway-core" } +gateway-protocol = { path = "../gateway-protocol" } hex = "0.4.3" http = "1.4.0" http-body-util = "0.1.3" diff --git a/backend/crates/gateway-host/src/lib.rs b/backend/crates/gateway-host/src/lib.rs index 698be7fd3..82f05d369 100644 --- a/backend/crates/gateway-host/src/lib.rs +++ b/backend/crates/gateway-host/src/lib.rs @@ -6,6 +6,7 @@ pub mod config; mod logging; pub mod official_plugins; pub mod outbound; +pub mod peer; pub mod plugin_distribution; pub mod pricing; pub mod process; diff --git a/backend/crates/gateway-host/src/peer.rs b/backend/crates/gateway-host/src/peer.rs new file mode 100644 index 000000000..eb6ae31d1 --- /dev/null +++ b/backend/crates/gateway-host/src/peer.rs @@ -0,0 +1,109 @@ +//! 互联请求复用账号出口和证书策略,重定向不会携带密钥跨站点转发 + +use std::time::{Duration, SystemTime}; + +use futures::StreamExt as _; +use gateway_core::account::{ProviderAccount, UpstreamInfoFailure}; +use secrecy::ExposeSecret as _; + +use gateway_admin::ports::peer::{PeerConnection, PeerFetchFailure, PeerTransport}; +use std::sync::Arc; + +type ClientBuilder = dyn Fn(reqwest::ClientBuilder) -> Result + Send + Sync; + +/// 组合根注入证书策略;出口、超时、重定向和响应上限由 Host 统一执行 +pub struct HttpPeerTransport { + build_client: Arc, +} + +impl HttpPeerTransport { + pub fn with_client_builder(build: F) -> Self + where + F: Fn(reqwest::ClientBuilder) -> Result + Send + Sync + 'static, + { + Self { + build_client: Arc::new(move |builder| build(builder).map_err(|_| ())), + } + } +} + +use gateway_protocol::peer::MAX_RESPONSE_BYTES as BODY_LIMIT; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + +#[async_trait::async_trait] +impl PeerTransport for HttpPeerTransport { + async fn fetch( + &self, + account: &ProviderAccount, + authentication: &PeerConnection, + ) -> Result, PeerFetchFailure> { + let mut builder = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(REQUEST_TIMEOUT) + .timeout(REQUEST_TIMEOUT); + if let Some(proxy) = account.outbound_proxy() { + builder = builder.proxy( + reqwest::Proxy::all(proxy.expose_url()) + .map_err(|_| UpstreamInfoFailure::Unavailable)?, + ); + } + let client = (self.build_client)(builder).map_err(|_| UpstreamInfoFailure::Unavailable)?; + let response = client + .get(format!( + "{}/cpr/link", + authentication.base_url.trim_end_matches('/') + )) + .bearer_auth(authentication.bearer_token.expose_secret()) + .header(reqwest::header::ACCEPT, "application/json") + .timeout(REQUEST_TIMEOUT) + .send() + .await + .map_err(|_| UpstreamInfoFailure::Unavailable)?; + if !response.status().is_success() { + let kind = match response.status().as_u16() { + 401 | 403 => UpstreamInfoFailure::Unauthorized, + 404 | 405 | 501 => UpstreamInfoFailure::Unsupported, + 429 => UpstreamInfoFailure::RateLimited, + _ => UpstreamInfoFailure::Unavailable, + }; + let retry_after = response + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| { + value + .parse::() + .ok() + .map(Duration::from_secs) + .or_else(|| { + chrono::DateTime::parse_from_rfc2822(value) + .ok() + .and_then(|time| { + SystemTime::from(time) + .duration_since(SystemTime::now()) + .ok() + }) + }) + }) + .map(|value| value.min(Duration::from_secs(24 * 60 * 60))); + return Err(PeerFetchFailure { kind, retry_after }); + } + if response + .content_length() + .is_some_and(|length| length > BODY_LIMIT as u64) + { + return Err(UpstreamInfoFailure::InvalidResponse.into()); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| UpstreamInfoFailure::Unavailable)?; + if bytes.len().saturating_add(chunk.len()) > BODY_LIMIT { + return Err(UpstreamInfoFailure::InvalidResponse.into()); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/callback/key_facts.rs b/backend/crates/gateway-plugin/runtime/tests/callback/key_facts.rs index a677b1378..ab1a9938c 100644 --- a/backend/crates/gateway-plugin/runtime/tests/callback/key_facts.rs +++ b/backend/crates/gateway-plugin/runtime/tests/callback/key_facts.rs @@ -52,6 +52,7 @@ async fn key_facts_read_current_database_groups_without_secrets() { .client_keys() .update_client_key( UpdateClientKey { + peer_sharing: None, request_profile_override_updates: Default::default(), id: ClientApiKeyId::new(id).unwrap(), name: "facts".into(), diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/data.rs b/backend/crates/gateway-plugin/runtime/tests/generation/data.rs index 6c4c0b60b..fda99e110 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/data.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/data.rs @@ -131,6 +131,7 @@ impl PluginAccountAccess for Facts { self.reads.fetch_add(1, Ordering::SeqCst); assert_eq!(account.as_str(), "acct_facts"); Ok(ProviderQuota { + upstream: None, credits: Some(ProviderQuotaCredits { has_credits: true, unlimited: false, @@ -140,6 +141,7 @@ impl PluginAccountAccess for Facts { observed_at: None, refresh_token_expires_at: None, windows: vec![ProviderQuotaWindow { + upstream: None, key: "weekly".into(), group: "private group".into(), label: "private label".into(), diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs b/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs index 56c1604a9..de6a486b2 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs @@ -65,6 +65,7 @@ async fn import_account( crate::support::native::admin_registry(), environment.store.admin_ports().accounts(), core.snapshot_control(), + None, ); let result = access .save( diff --git a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs index 11ffbd3a7..bf1eb876b 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs @@ -99,6 +99,7 @@ impl Environment { .client_keys() .create_client_key( NewClientKey { + peer_sharing: Default::default(), request_profile_overrides: BTreeMap::new(), id: ClientApiKeyId::new(id).unwrap(), name: format!("fixture {id}"), @@ -494,6 +495,7 @@ impl Environment { super::native::admin_registry(), self.store.admin_ports().accounts(), core.snapshot_control(), + None, ); runtime.bind_account_ports(&access).unwrap(); self.plugin_accounts @@ -582,6 +584,7 @@ impl Environment { ClientConfig::default(), self.store.admin_ports(), gateway_admin::AdminRuntimePorts { + upstream_info: None, timezone: Default::default(), service_middleware: { let snapshots = core.snapshots(); diff --git a/backend/crates/gateway-protocol/src/lib.rs b/backend/crates/gateway-protocol/src/lib.rs index e3866300d..c729e17fe 100644 --- a/backend/crates/gateway-protocol/src/lib.rs +++ b/backend/crates/gateway-protocol/src/lib.rs @@ -4,3 +4,6 @@ /// OpenAI wire contract pub mod openai; + +/// CPR 互联只读观测协议 +pub mod peer; diff --git a/backend/crates/gateway-protocol/src/peer.rs b/backend/crates/gateway-protocol/src/peer.rs new file mode 100644 index 000000000..d8e9ee988 --- /dev/null +++ b/backend/crates/gateway-protocol/src/peer.rs @@ -0,0 +1,184 @@ +//! CPR 互联的版本化只读线协议,不包含授权、采集或调度策略 + +use serde::{Deserialize, Serialize}; + +pub const OBJECT: &str = "cpr.peer"; +pub const SCHEMA_VERSION: u32 = 1; +pub const MAX_ACCOUNTS: usize = 3_000; +pub const MAX_QUOTA_WINDOWS: usize = 6_000; +pub const MAX_MODELS: usize = 2_000; +pub const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024; + +/// 可选能力按当前认证主体公开,未知能力可以忽略 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PeerLink { + pub object: String, + pub schema_version: u32, + pub node: PeerNode, + pub capabilities: Vec, + pub observed_at: String, + pub expires_at: String, + pub usage: PeerUsage, + pub pool: PeerPool, + #[serde(default)] + pub quota: PeerQuota, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub models: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeerNode { + pub id: String, + pub name: String, + pub software: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PeerComponentStatus { + Available, + Unavailable, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeerUsage { + pub status: PeerComponentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeerKeyUsage { + pub unit: String, + pub daily: PeerBudgetWindow, + pub weekly: PeerBudgetWindow, +} + +/// 金额保留十进制字符串,不限额时 total 与 remaining 为 null +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeerBudgetWindow { + pub total: Option, + pub used: String, + pub remaining: Option, + #[serde(rename = "resetsAt")] + pub resets_at: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PeerPoolDisclosure { + Summary, + #[default] + #[serde(other)] + None, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct PeerPool { + pub disclosure: PeerPoolDisclosure, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub states: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plans: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct PeerPoolStates { + pub ready: u32, + pub cooldown: u32, + pub exhausted: u32, + pub disabled: u32, + pub error: u32, + pub unknown: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeerPlanCount { + pub name: String, + pub count: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PeerQuotaDisclosure { + Accounts, + #[default] + #[serde(other)] + None, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct PeerQuota { + pub disclosure: PeerQuotaDisclosure, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub accounts: Vec, +} + +/// 匿名标识在授权 Key 内稳定,窗口仍保留账号与模型归属 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PeerQuotaAccount { + pub id: String, + pub models: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + pub windows: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PeerQuotaWindow { + pub key: String, + pub group: String, + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub used_percent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reset_at: Option, + #[serde(default)] + pub limit_reached: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PeerModelCoverage { + Complete, + #[serde(other)] + Partial, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PeerModelState { + Available, + Exhausted, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeerModelAvailability { + pub model: String, + pub coverage: PeerModelCoverage, + pub state: PeerModelState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, +} diff --git a/backend/crates/gateway-store/src/bundle.rs b/backend/crates/gateway-store/src/bundle.rs index dc07d42c6..c39afacfb 100644 --- a/backend/crates/gateway-store/src/bundle.rs +++ b/backend/crates/gateway-store/src/bundle.rs @@ -11,6 +11,7 @@ pub struct StoreBundle { admin_ports: AdminStorePorts, core_ports: CoreStorePorts, provider_ports: ProviderStorePorts, + upstream_info: Arc, retention: Arc, worker_leader_lease: Arc, health_probes: Vec>, @@ -36,6 +37,11 @@ impl StoreBundle { self.core_ports.clone() } + #[must_use] + pub fn upstream_info(&self) -> Arc { + Arc::clone(&self.upstream_info) + } + #[must_use] pub fn provider_ports(&self) -> ProviderStorePorts { self.provider_ports.clone() @@ -143,6 +149,8 @@ async fn connect( postgres::PgProviderAccountRepository::new(pool.clone()) .with_account_error_protection(cooldowns.clone()), ); + let upstream_info: Arc = + provider_accounts.clone(); let account_store: Arc = provider_accounts; let credential_leases = @@ -331,6 +339,7 @@ async fn connect( admin_ports, core_ports, provider_ports, + upstream_info, retention, worker_leader_lease, health_probes, diff --git a/backend/crates/gateway-store/src/postgres/client_keys.rs b/backend/crates/gateway-store/src/postgres/client_keys.rs index 2cd367f41..c4e3bcf9b 100644 --- a/backend/crates/gateway-store/src/postgres/client_keys.rs +++ b/backend/crates/gateway-store/src/postgres/client_keys.rs @@ -32,7 +32,7 @@ use gateway_core::{ engine::budget::{ClientBudgetLimits, ClientBudgetStatus}, engine::execution::ClientApiKeyUsageSink, metering::Decimal, - policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits}, + policy::{ClientApiKeyId, ClientPeerSharing, PlaintextClientApiKey, RateLimits}, routing::{AccountGroupId, ProviderKind}, task::{DaemonTask, WorkerDaemonContext, WorkerTaskError}, }; @@ -53,6 +53,7 @@ const CLIENT_API_KEY_LAST_USED_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs( #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClientApiKeySnapshot { + pub peer_sharing: ClientPeerSharing, pub name_snapshot: Option, pub request_profiles: std::collections::BTreeMap< gateway_core::routing::ProviderKind, @@ -73,6 +74,7 @@ impl ClientApiKeySnapshot { requests_per_minute: i64, ) -> StoreResult { Ok(Self { + peer_sharing: ClientPeerSharing::default(), name_snapshot: None, request_profiles: std::collections::BTreeMap::new(), id: ClientApiKeyId::new(id) @@ -118,6 +120,7 @@ impl fmt::Debug for ClientApiKeySecret { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClientApiKeyRecord { + pub peer_sharing: ClientPeerSharing, pub request_profile_overrides: BTreeMap, pub id: String, pub name: String, @@ -279,6 +282,7 @@ pub struct ClientApiKeyPage { #[derive(Clone)] pub struct NewClientApiKey { + pub peer_sharing: ClientPeerSharing, pub request_profile_overrides: BTreeMap, pub id: String, pub name: String, @@ -307,12 +311,16 @@ impl NewClientApiKey { require_nonempty(ENTITY, "name", &self.name)?; validate_group_ids(&self.group_ids)?; validate_request_profiles(self.request_profile_overrides.values())?; + self.peer_sharing + .validate(!self.group_ids.is_empty()) + .map_err(|_| invalid("invalid peer sharing scope"))?; validate_key(&self.key) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateClientApiKeyDetails { + pub peer_sharing: Option, pub request_profile_override_updates: BTreeMap>, pub id: String, pub name: String, @@ -378,7 +386,7 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository { query.validate()?; let total = count_client_api_keys(&self.pool, query.search.as_deref()).await?; let mut statement = QueryBuilder::::new( - "select k.id, k.name, k.label, k.provider_request_profiles_json as request_profile_overrides, + "select k.id, k.name, k.label, k.peer_sharing_json, k.provider_request_profiles_json as request_profile_overrides, left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled, k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at, k.updated_at, '[]'::jsonb as groups, '{}'::text[] as provider_kinds @@ -438,7 +446,7 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository { async fn get_client_api_key(&self, id: &str) -> StoreResult> { require_nonempty(ENTITY, "id", id)?; let record = sqlx::query( - "select k.id, k.name, k.label, k.provider_request_profiles_json as request_profile_overrides, + "select k.id, k.name, k.label, k.peer_sharing_json, k.provider_request_profiles_json as request_profile_overrides, left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled, k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at, k.updated_at, coalesce(groups.groups, '[]'::jsonb) as groups, @@ -722,6 +730,20 @@ impl PgAdminClientKeyStore { #[async_trait] impl ClientKeyStore for PgAdminClientKeyStore { + async fn peer_node_identity( + &self, + ) -> AdminStoreResult { + let (id, name) = sqlx::query_as::<_, (String, String)>( + "select id::text, name from peer_node where singleton", + ) + .fetch_one(&self.keys.pool) + .await + .map_err(|source| { + admin_store_error("peer node", postgres_unavailable("load peer node", source)) + })?; + Ok(gateway_admin::model::peer_link::PeerNodeIdentity { id, name }) + } + async fn update_client_key_budget_limits( &self, command: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits, @@ -889,6 +911,7 @@ impl ClientKeyStore for PgAdminClientKeyStore { .control_plane .create_client_api_key( NewClientApiKey { + peer_sharing: command.peer_sharing, request_profile_overrides: command.request_profile_overrides, id: id.as_str().to_owned(), name: command.name, @@ -918,6 +941,7 @@ impl ClientKeyStore for PgAdminClientKeyStore { "daily_limit_usd", "weekly_limit_usd", "provider_request_profiles_json", + "peer_sharing_json", ] .into_iter() .map(str::to_owned) @@ -939,6 +963,7 @@ impl ClientKeyStore for PgAdminClientKeyStore { .control_plane .update_client_api_key( UpdateClientApiKeyDetails { + peer_sharing: command.peer_sharing, request_profile_override_updates: command.request_profile_override_updates, id: id.as_str().to_owned(), name: command.name, @@ -966,6 +991,7 @@ impl ClientKeyStore for PgAdminClientKeyStore { "daily_limit_usd", "weekly_limit_usd", "provider_request_profiles_json", + "peer_sharing_json", ] .into_iter() .map(str::to_owned) @@ -1096,6 +1122,7 @@ fn admin_client_key_cursor(cursor: ClientApiKeyCursor) -> AdminStoreResult AdminStoreResult { Ok(AdminClientKeyRecord { + peer_sharing: record.peer_sharing, request_profile_overrides: record.request_profile_overrides, id: ClientApiKeyId::new(record.id) .map_err(|_| admin_store_error(ENTITY, invalid("invalid client key id")))?, @@ -1148,8 +1175,8 @@ pub(crate) async fn insert_client_api_key_in_transaction( sqlx::query( "insert into client_api_keys ( id, name, label, key, enabled, max_concurrency, requests_per_minute, - last_used_at, created_at, updated_at, daily_limit_usd, weekly_limit_usd, provider_request_profiles_json - ) values ($1, $2, $3, $4, true, $5, $6, null, now(), now(), $7::text::numeric, $8::text::numeric, $9::jsonb)", + last_used_at, created_at, updated_at, daily_limit_usd, weekly_limit_usd, provider_request_profiles_json, peer_sharing_json + ) values ($1, $2, $3, $4, true, $5, $6, null, now(), now(), $7::text::numeric, $8::text::numeric, $9::jsonb, $10::jsonb)", ) .bind(&key.id) .bind(key.name.trim()) @@ -1160,6 +1187,7 @@ pub(crate) async fn insert_client_api_key_in_transaction( .bind(key.budget.daily_usd.canonical()) .bind(key.budget.weekly_usd.canonical()) .bind(sqlx::types::Json(request_profile_overrides)) + .bind(sqlx::types::Json(key.peer_sharing)) .execute(&mut **transaction) .await .map_err(|error| { @@ -1187,6 +1215,28 @@ pub(crate) async fn update_client_api_key_in_transaction( ) -> StoreResult<()> { key.validate()?; ensure_client_key_name_available(transaction, &key.id, key.name.trim()).await?; + // 控制面行锁已串行化配置写入,按同一事务里的保留配置验证分组修改 + let sharing = match key.peer_sharing { + Some(sharing) => sharing, + None => { + sqlx::query_scalar::<_, sqlx::types::Json>( + "select peer_sharing_json from client_api_keys where id = $1", + ) + .bind(&key.id) + .fetch_optional(&mut **transaction) + .await + .map_err(|source| postgres_unavailable("load client peer sharing", source))? + .ok_or_else(|| StoreError::NotFound { + source: None, + entity: ENTITY, + id: key.id.clone(), + })? + .0 + } + }; + sharing + .validate(!key.group_ids.is_empty()) + .map_err(|_| invalid("invalid peer sharing scope"))?; let removed_profiles = key .request_profile_override_updates .keys() @@ -1211,7 +1261,8 @@ pub(crate) async fn update_client_api_key_in_transaction( daily_limit_usd = coalesce($6::text::numeric, daily_limit_usd), weekly_limit_usd = coalesce($7::text::numeric, weekly_limit_usd), provider_request_profiles_json = (provider_request_profiles_json - $8::text[]) - || $9::jsonb + || $9::jsonb, + peer_sharing_json = coalesce($10::jsonb, peer_sharing_json) where id = $1", ) .bind(&key.id) @@ -1223,6 +1274,7 @@ pub(crate) async fn update_client_api_key_in_transaction( .bind(key.weekly_limit_usd.map(|amount| amount.canonical())) .bind(removed_profiles) .bind(sqlx::types::Json(replacement_profiles)) + .bind(key.peer_sharing.map(sqlx::types::Json)) .execute(&mut **transaction) .await .map_err(|source| postgres_unavailable("update client API key in transaction", source))?; @@ -1420,6 +1472,10 @@ fn client_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult, _>("peer_sharing_json") + .map_err(|source| invalid("invalid peer sharing").with_source(source))? + .0, request_profile_overrides: decode_request_profiles( row.try_get:: admin_observability::DashboardAccountUsage { admin_observability::DashboardAccountUsage { + upstream: None, account_id: usage.account_id, provider_kind: usage.provider_kind, authentication_kind: usage.authentication_kind, diff --git a/backend/crates/gateway-store/src/postgres/plugins/resources.rs b/backend/crates/gateway-store/src/postgres/plugins/resources.rs index 98082ece3..a7acc248f 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/resources.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/resources.rs @@ -203,6 +203,7 @@ impl PluginResourceStore for PgPluginStore { return Err(denied()); } let key = NewClientApiKey { + peer_sharing: Default::default(), request_profile_overrides: command.request_profile_overrides, id: command.id.as_str().to_owned(), name: command.name, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs index 1bc9b9033..c9f1210ad 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs @@ -230,6 +230,23 @@ impl PgAdminAccountStore { #[async_trait] impl AccountStore for PgAdminAccountStore { + async fn load_accounts_in_enabled_groups( + &self, + account_ids: &[CoreProviderAccountId], + group_ids: &[AccountGroupId], + ) -> AdminStoreResult> { + let ids = account_ids + .iter() + .map(|id| id.as_str().to_owned()) + .collect::>(); + let records = self + .accounts + .load_account_summaries_in_enabled_groups(&ids, group_ids) + .await + .map_err(|error| admin_store_error(ENTITY, error))?; + records.into_iter().map(admin_account_record).collect() + } + async fn account_exists(&self, account_id: &str) -> AdminStoreResult { sqlx::query_scalar("select exists(select 1 from provider_accounts where id = $1)") .bind(account_id) diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs index 490365751..888e4c0e9 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs @@ -63,6 +63,7 @@ mod repository; mod reset_credit_automation; mod rows; mod runtime; +mod upstream_info; pub use admin_adapter::*; pub(crate) use admin_queries::*; diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs index 13aab8f46..41252da60 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs @@ -1210,3 +1210,42 @@ pub(super) async fn rotate_provider_account_admin_in_transaction( credential_revision, }) } + +impl PgProviderAccountRepository { + pub(super) async fn load_account_summaries_in_enabled_groups( + &self, + account_ids: &[String], + group_ids: &[AccountGroupId], + ) -> StoreResult> { + if account_ids.is_empty() || group_ids.is_empty() { + return Ok(Vec::new()); + } + let group_ids = group_ids + .iter() + .map(AccountGroupId::as_str) + .collect::>(); + let rows = sqlx::query( + "select coalesce(proxy_enabled, true) as outbound_proxy_enabled, coalesce(max_websocket_connections, 0) as outbound_proxy_max_websocket_connections, auto_location, detected_location_json, location_country, location_region, location_city, location_timezone, outbound_proxy_id, outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, + upstream_account_id, plan_type, authentication_kind, credential_revision, has_refresh_token, + access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state, + credential_observed_at, quota_access_state, quota_evidence, + quota_access_observed_at, quota_reset_at, + quota_observed_at, last_error_reason, last_error_message, created_at, updated_at + from provider_accounts + left join (select id as location_proxy_id, enabled as proxy_enabled, max_websocket_connections, auto_location, detected_location_json, location_country, location_region, location_city, location_timezone from outbound_proxies) proxy_location + on outbound_proxy_id = location_proxy_id + where id = any($1::text[]) and exists ( + select 1 from account_group_accounts membership + join account_groups account_group on account_group.id = membership.account_group_id + where membership.provider_account_id = provider_accounts.id + and account_group.enabled and account_group.id = any($2::text[]) + ) order by id", + ) + .bind(account_ids) + .bind(group_ids) + .fetch_all(&self.pool) + .await + .map_err(|source| postgres_unavailable("load account summaries", source))?; + rows.into_iter().map(account_summary_from_row).collect() + } +} diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/upstream_info.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/upstream_info.rs new file mode 100644 index 000000000..d66c53c1b --- /dev/null +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/upstream_info.rs @@ -0,0 +1,401 @@ +//! 互联观测与同步计划的事务持久化,领取租约与连接身份共同阻止迟到写入 + +use std::time::{Duration, SystemTime}; + +use gateway_core::account::{ + UpstreamConnection, UpstreamInfoClaim, UpstreamInfoCompletion, UpstreamInfoFailure, + UpstreamInfoPlan, UpstreamInfoRecord, UpstreamInfoStore, +}; +use sqlx::postgres::PgRow; + +use super::*; + +const MAX_SNAPSHOT_BYTES: usize = 4 * 1024 * 1024; +const PLAN_COLUMNS: &str = "account_id, credential_revision, connection_revision, + request_generation, completed_generation, next_attempt_at, claimed_until, + last_attempt_at, last_success_at, last_error, consecutive_failures"; + +#[async_trait] +impl UpstreamInfoStore for PgProviderAccountRepository { + async fn list_candidates( + &self, + provider: &ProviderKind, + authentication_kinds: &[&str], + after: Option<&CoreProviderAccountId>, + limit: u32, + ) -> Result, CoreStoreError> { + let ids = sqlx::query_scalar::<_, String>( + "select id from provider_accounts + where provider_kind = $1 and authentication_kind = any($4::text[]) + and ($2::text is null or id > $2) + order by id limit $3", + ) + .bind(provider.as_str()) + .bind(after.map(CoreProviderAccountId::as_str)) + .bind(i64::from(limit.min(200))) + .bind(authentication_kinds) + .fetch_all(&self.pool) + .await + .map_err(database_error)?; + if ids.is_empty() { + return Ok(Vec::new()); + } + sqlx::query(ACCOUNT_SELECT_BY_IDS) + .bind(ids) + .bind(provider.as_str()) + .fetch_all(&self.pool) + .await + .map_err(database_error)? + .into_iter() + .map(|row| { + let record = account_record_from_row(row).map_err(crate::core_store_error)?; + Ok(LoadedCredential { + account: core_account_from_summary(record.summary)?, + credential: PlaintextCredential::new( + record.provider_credentials_json.fields().clone(), + ), + }) + }) + .collect() + } + + async fn reconcile( + &self, + connection: &UpstreamConnection, + enabled: bool, + now: SystemTime, + ) -> Result<(), CoreStoreError> { + let mut transaction = self.pool.begin().await.map_err(database_error)?; + let account = lock_connection(&mut transaction, connection).await?; + if enabled && account.enabled() { + ensure_plan(&mut transaction, connection, now).await?; + } else { + sqlx::query("delete from provider_account_upstream_info where account_id = $1") + .bind(connection.account_id.as_str()) + .execute(&mut *transaction) + .await + .map_err(database_error)?; + } + transaction.commit().await.map_err(database_error) + } + + async fn request_refresh( + &self, + connection: &UpstreamConnection, + now: SystemTime, + minimum_interval: Duration, + ) -> Result { + let mut transaction = self.pool.begin().await.map_err(database_error)?; + if !lock_connection(&mut transaction, connection) + .await? + .enabled() + { + return Err(CoreStoreError::new(CoreStoreErrorKind::Conflict)); + } + ensure_plan(&mut transaction, connection, now).await?; + let current = sqlx::query(sqlx::AssertSqlSafe(format!( + "select {PLAN_COLUMNS} from provider_account_upstream_info + where account_id = $1 for update" + ))) + .bind(connection.account_id.as_str()) + .fetch_one(&mut *transaction) + .await + .map_err(database_error) + .and_then(|row| plan_from_row(&row))?; + let record = if current.request_generation > current.completed_generation { + current + } else { + let mut next = current + .last_attempt_at + .and_then(|last| last.checked_add(minimum_interval)) + .unwrap_or(now) + .max(now); + if current.last_error.is_some() { + next = next.max(current.next_attempt_at); + } + sqlx::query(sqlx::AssertSqlSafe(format!( + "update provider_account_upstream_info + set request_generation = request_generation + 1, next_attempt_at = $2 + where account_id = $1 returning {PLAN_COLUMNS}" + ))) + .bind(connection.account_id.as_str()) + .bind(DateTime::::from(next)) + .fetch_one(&mut *transaction) + .await + .map_err(database_error) + .and_then(|row| plan_from_row(&row))? + }; + transaction.commit().await.map_err(database_error)?; + Ok(record) + } + + async fn read_plans( + &self, + accounts: &[CoreProviderAccountId], + ) -> Result, CoreStoreError> { + if accounts.is_empty() { + return Ok(Vec::new()); + } + let ids = accounts + .iter() + .map(CoreProviderAccountId::as_str) + .collect::>(); + sqlx::query(sqlx::AssertSqlSafe(format!( + "select {PLAN_COLUMNS} from provider_account_upstream_info + where account_id = any($1::text[])" + ))) + .bind(ids) + .fetch_all(&self.pool) + .await + .map_err(database_error)? + .iter() + .map(plan_from_row) + .collect() + } + + async fn read( + &self, + accounts: &[CoreProviderAccountId], + ) -> Result, CoreStoreError> { + if accounts.is_empty() { + return Ok(Vec::new()); + } + let ids = accounts + .iter() + .map(CoreProviderAccountId::as_str) + .collect::>(); + sqlx::query( + "select * from provider_account_upstream_info where account_id = any($1::text[])", + ) + .bind(ids) + .fetch_all(&self.pool) + .await + .map_err(database_error)? + .into_iter() + .map(record_from_row) + .collect() + } + + async fn claim_due( + &self, + provider: &ProviderKind, + now: SystemTime, + limit: u32, + lease_duration: Duration, + ) -> Result, CoreStoreError> { + let until = now + .checked_add(lease_duration) + .filter(|until| *until > now) + .ok_or_else(|| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?; + // 领取标识隔离计划删除重建,代次继续用于刷新等待与租约回收 + sqlx::query(sqlx::AssertSqlSafe(format!( + "with due as ( + select info.account_id as due_account_id from provider_account_upstream_info info + join provider_accounts account on account.id = info.account_id + where account.provider_kind = $1 and account.enabled + and account.credential_revision = info.credential_revision + and info.next_attempt_at <= $2 + and (info.claimed_until is null or info.claimed_until <= $2) + order by info.next_attempt_at, info.account_id + for update of info skip locked limit $3 + ) + update provider_account_upstream_info info + set request_generation = case when info.claimed_until is not null + then info.request_generation + 1 + else greatest(info.request_generation, info.completed_generation + 1) end, + claimed_until = $4, claim_token = gen_random_uuid(), last_attempt_at = $2 + from due where info.account_id = due.due_account_id + returning {PLAN_COLUMNS}, claim_token" + ))) + .bind(provider.as_str()) + .bind(DateTime::::from(now)) + .bind(i64::from(limit.min(200))) + .bind(DateTime::::from(until)) + .fetch_all(&self.pool) + .await + .map_err(database_error)? + .into_iter() + .map(|row| { + Ok(UpstreamInfoClaim { + plan: plan_from_row(&row)?, + claim_token: row.try_get("claim_token").map_err(database_error)?, + }) + }) + .collect() + } + + async fn complete(&self, completion: UpstreamInfoCompletion) -> Result { + let failure = completion.result.as_ref().err().copied(); + let snapshot = match completion.result { + Ok(data) => Some( + JsonObject::try_from_value( + "upstream snapshot", + serde_json::Value::Object(data.into_inner()), + MAX_SNAPSHOT_BYTES, + ) + .map_err(crate::core_store_error)?, + ), + Err(_) => None, + }; + let mut transaction = self.pool.begin().await.map_err(database_error)?; + let account = match lock_connection(&mut transaction, &completion.connection).await { + Ok(account) => account, + Err(error) if error.kind() == CoreStoreErrorKind::Conflict => return Ok(false), + Err(error) => return Err(error), + }; + if !account.enabled() { + return Ok(false); + } + let result = sqlx::query( + "update provider_account_upstream_info + set completed_generation = request_generation, claimed_until = null, claim_token = null, + next_attempt_at = $4, last_error = $5, + consecutive_failures = case when $5::text is null then 0 + else least(consecutive_failures::bigint + 1, 2147483647)::integer end, + last_success_at = case when $5::text is null then $6 else last_success_at end, + snapshot_json = case when $5::text is null then $7::jsonb + when $5 = 'unauthorized' then null else snapshot_json end + where account_id = $1 and connection_revision = $2 and request_generation = $3 + and claimed_until > $6 and claim_token = $8", + ) + .bind(completion.connection.account_id.as_str()) + .bind(&completion.connection.revision) + .bind( + i64::try_from(completion.request_generation) + .map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?, + ) + .bind(DateTime::::from(completion.next_attempt_at)) + .bind(failure.map(UpstreamInfoFailure::as_str)) + .bind(DateTime::::from(completion.completed_at)) + .bind(snapshot.as_ref().map(|value| sqlx::types::Json(value.fields()))) + .bind(completion.claim_token) + .execute(&mut *transaction) + .await + .map_err(database_error)?; + transaction.commit().await.map_err(database_error)?; + Ok(result.rows_affected() == 1) + } +} + +async fn lock_connection( + transaction: &mut Transaction<'_, Postgres>, + expected: &UpstreamConnection, +) -> Result { + // 托管代理修改会在同一事务更新账号的解析后 URL,账号行锁同时保护出口事实 + let query = format!("{ACCOUNT_SELECT} for update of provider_accounts"); + let row = sqlx::query(sqlx::AssertSqlSafe(query)) + .bind(expected.account_id.as_str()) + .fetch_optional(&mut **transaction) + .await + .map_err(database_error)? + .ok_or_else(|| CoreStoreError::new(CoreStoreErrorKind::Conflict))?; + let record = account_record_from_row(row).map_err(crate::core_store_error)?; + let account = core_account_from_summary(record.summary)?; + if UpstreamConnection::for_account(&account) != *expected { + return Err(CoreStoreError::new(CoreStoreErrorKind::Conflict)); + } + Ok(account) +} + +async fn ensure_plan( + transaction: &mut Transaction<'_, Postgres>, + connection: &UpstreamConnection, + now: SystemTime, +) -> Result<(), CoreStoreError> { + sqlx::query( + "insert into provider_account_upstream_info + (account_id, credential_revision, connection_revision, next_attempt_at) + values ($1, $2, $3, $4) + on conflict (account_id) do update + set credential_revision = excluded.credential_revision, + connection_revision = excluded.connection_revision, + request_generation = provider_account_upstream_info.request_generation + 1, + completed_generation = provider_account_upstream_info.request_generation, + next_attempt_at = excluded.next_attempt_at, claimed_until = null, claim_token = null, + last_attempt_at = null, last_success_at = null, last_error = null, + consecutive_failures = 0, snapshot_json = null + where provider_account_upstream_info.connection_revision <> excluded.connection_revision", + ) + .bind(connection.account_id.as_str()) + .bind( + i64::try_from(connection.credential_revision.get()) + .map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?, + ) + .bind(&connection.revision) + .bind(DateTime::::from(now)) + .execute(&mut **transaction) + .await + .map_err(database_error)?; + Ok(()) +} + +fn record_from_row(row: PgRow) -> Result { + let snapshot: Option>> = + row.try_get("snapshot_json").map_err(database_error)?; + Ok(UpstreamInfoRecord { + plan: plan_from_row(&row)?, + snapshot: snapshot.map(|json| OpaqueProviderData::new(json.0)), + }) +} + +fn plan_from_row(row: &PgRow) -> Result { + let invalid = || CoreStoreError::new(CoreStoreErrorKind::InvalidData); + let error: Option = row.try_get("last_error").map_err(database_error)?; + let last_error = match error.as_deref() { + None => None, + Some("unauthorized") => Some(UpstreamInfoFailure::Unauthorized), + Some("unsupported") => Some(UpstreamInfoFailure::Unsupported), + Some("rate_limited") => Some(UpstreamInfoFailure::RateLimited), + Some("invalid_response") => Some(UpstreamInfoFailure::InvalidResponse), + Some("unavailable") => Some(UpstreamInfoFailure::Unavailable), + Some(_) => return Err(invalid()), + }; + Ok(UpstreamInfoPlan { + connection: UpstreamConnection { + account_id: CoreProviderAccountId::new( + row.try_get::("account_id") + .map_err(database_error)?, + ) + .map_err(|_| invalid())?, + credential_revision: CoreCredentialRevision::new(read_generation( + row, + "credential_revision", + )?) + .map_err(|_| invalid())?, + revision: row.try_get("connection_revision").map_err(database_error)?, + }, + request_generation: read_generation(row, "request_generation")?, + completed_generation: read_generation(row, "completed_generation")?, + next_attempt_at: row + .try_get::, _>("next_attempt_at") + .map_err(database_error)? + .into(), + claimed_until: row + .try_get::>, _>("claimed_until") + .map_err(database_error)? + .map(Into::into), + last_attempt_at: row + .try_get::>, _>("last_attempt_at") + .map_err(database_error)? + .map(Into::into), + last_success_at: row + .try_get::>, _>("last_success_at") + .map_err(database_error)? + .map(Into::into), + last_error, + consecutive_failures: u32::try_from( + row.try_get::("consecutive_failures") + .map_err(database_error)?, + ) + .map_err(|_| invalid())?, + }) +} + +fn read_generation(row: &PgRow, column: &str) -> Result { + u64::try_from(row.try_get::(column).map_err(database_error)?) + .map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData)) +} + +fn database_error(source: sqlx::Error) -> CoreStoreError { + CoreStoreError::caused_by(CoreStoreErrorKind::Unavailable, source) +} diff --git a/backend/crates/gateway-store/src/postgres/proxies.rs b/backend/crates/gateway-store/src/postgres/proxies.rs index eb9c6910c..3342926f0 100644 --- a/backend/crates/gateway-store/src/postgres/proxies.rs +++ b/backend/crates/gateway-store/src/postgres/proxies.rs @@ -507,6 +507,7 @@ impl ProxyStore for PgProxyRepository { .into_iter() .map(|row| { Ok(ProxyAccountRef { + upstream: None, id: row.try_get("id").map_err(|source| invalid().with_source(source))?, name: row.try_get("name").map_err(|source| invalid().with_source(source))?, email: row.try_get("email").map_err(|source| invalid().with_source(source))?, diff --git a/backend/crates/gateway-store/src/postgres/snapshot.rs b/backend/crates/gateway-store/src/postgres/snapshot.rs index 86ee388f1..8318ed975 100644 --- a/backend/crates/gateway-store/src/postgres/snapshot.rs +++ b/backend/crates/gateway-store/src/postgres/snapshot.rs @@ -42,6 +42,7 @@ pub struct SnapshotAccountGroupData { pub struct SnapshotProviderAccountData { pub id: String, pub provider_kind: String, + pub authentication_kind: String, pub model_access: gateway_core::account::AccountModelAccess, } @@ -147,6 +148,7 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository { ) .with_name_snapshot(key.name_snapshot) .with_request_profiles(key.request_profiles) + .with_peer_sharing(key.peer_sharing) }) .collect(); let account_groups = data @@ -166,6 +168,7 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository { .map(|id| { SnapshotProviderAccountFacts::new(id, account.provider_kind) .with_model_access(account.model_access) + .with_authentication_kind(account.authentication_kind) }) .map_err(|_| SnapshotStoreError::unavailable()) }) @@ -310,12 +313,13 @@ async fn load_client_keys( i64, sqlx::types::Json>>, String, + sqlx::types::Json, ), >( "select k.id, k.key, coalesce(array_agg(kg.account_group_id order by kg.account_group_id) filter (where kg.account_group_id is not null), '{}') as group_ids, - k.max_concurrency, k.requests_per_minute, k.provider_request_profiles_json, k.name + k.max_concurrency, k.requests_per_minute, k.provider_request_profiles_json, k.name, k.peer_sharing_json from client_api_keys k left join client_api_key_groups kg on kg.client_api_key_id = k.id where k.enabled @@ -330,6 +334,7 @@ async fn load_client_keys( let mut key = ClientApiKeySnapshot::from_persisted(row.0, row.1, row.2, row.3, row.4)?; key.request_profiles = decode_request_profiles(row.5.0)?; key.name_snapshot = Some(row.6); + key.peer_sharing = row.7.0; Ok(key) }) .collect() @@ -365,20 +370,22 @@ async fn load_provider_accounts( sqlx::query_as::< _, ( + String, String, String, sqlx::types::Json, ), - >("select id, provider_kind, model_access_json from provider_accounts order by id") + >("select id, provider_kind, authentication_kind, model_access_json from provider_accounts order by id") .fetch_all(&mut **transaction) .await .map_err(|source| postgres_unavailable("load snapshot provider accounts", source)) .map(|rows| { rows.into_iter() .map( - |(id, provider_kind, model_access)| SnapshotProviderAccountData { + |(id, provider_kind, authentication_kind, model_access)| SnapshotProviderAccountData { id, provider_kind, + authentication_kind, model_access: model_access.0, }, ) diff --git a/backend/crates/gateway-store/src/redis/admin_account_runtime.rs b/backend/crates/gateway-store/src/redis/admin_account_runtime.rs index 4b17aa2a2..a9c30ac47 100644 --- a/backend/crates/gateway-store/src/redis/admin_account_runtime.rs +++ b/backend/crates/gateway-store/src/redis/admin_account_runtime.rs @@ -4,7 +4,6 @@ use std::collections::BTreeMap; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use futures::{StreamExt as _, TryStreamExt as _}; use gateway_admin::{ model::accounts::AccountRuntimeSnapshot, ports::store::{AccountRuntimeStore, AdminStoreResult}, @@ -57,34 +56,8 @@ impl AccountRuntimeStore for RedisAdminAccountRuntimeStore { &self, account_ids: &[String], ) -> AdminStoreResult { - let reads = futures::stream::iter(account_ids.iter().cloned()) - .map(|account| async move { - self.cooldowns - .read_account_runtime(&account) - .await - .map(|runtime| (account, runtime)) - }) - .buffer_unordered(32) - .try_collect::>() - .await - .map_err(|error| crate::admin_store_error("account runtime", error))?; - let mut cooldown = BTreeMap::new(); - let mut protection_rules = BTreeMap::new(); - for (account, (current, freeze)) in reads { - if let Some(current) = current { - cooldown.insert( - account.clone(), - gateway_core::account::AccountCooldown { - until: current.cooldown_until.into(), - kind: current.kind, - }, - ); - } - if let Some(freeze) = freeze { - protection_rules.insert(account.clone(), freeze.protection_rules); - } - } - let in_flight = self + let mut snapshot = self.account_rate_limits(account_ids).await?; + snapshot.in_flight = self .leases .credential_runtime_signals(account_ids) .await @@ -95,11 +68,17 @@ impl AccountRuntimeStore for RedisAdminAccountRuntimeStore { .map(|signal| (signal.resource_id, u64::from(signal.in_flight))) .collect() }); - Ok(AccountRuntimeSnapshot { - protection_rules, - cooldown, - in_flight, - }) + Ok(snapshot) + } + + async fn account_rate_limits( + &self, + account_ids: &[String], + ) -> AdminStoreResult { + self.cooldowns + .account_cooldowns(account_ids) + .await + .map_err(|error| crate::admin_store_error("account runtime", error)) } async fn active_freezes( diff --git a/backend/crates/gateway-store/src/redis/credential_cooldown.rs b/backend/crates/gateway-store/src/redis/credential_cooldown.rs index 69010081f..641ff35b0 100644 --- a/backend/crates/gateway-store/src/redis/credential_cooldown.rs +++ b/backend/crates/gateway-store/src/redis/credential_cooldown.rs @@ -279,8 +279,44 @@ impl RedisCredentialCooldownRepository { } pub(crate) async fn active_cooldowns(&self) -> StoreResult { + self.read_cooldown_accounts(self.all_cooldown_accounts().await?) + .await + } + + pub(crate) async fn account_cooldowns( + &self, + accounts: &[String], + ) -> StoreResult { + if accounts.is_empty() { + return Ok(AccountRuntimeSnapshot::default()); + } + // 索引与冷却事实由 Lua 原子维护,只读取范围内实际有状态的账号 + let (cooldowns, protections): (Vec>, Vec>) = redis::pipe() + .cmd("ZMSCORE") + .arg(self.active_index_key()) + .arg(accounts) + .cmd("ZMSCORE") + .arg(self.protection_index_key()) + .arg(accounts) + .query_async(&mut self.connection.clone()) + .await + .map_err(|error| redis_unavailable("read scoped account cooldown index", error))?; + let active = accounts + .iter() + .zip(cooldowns) + .zip(protections) + .filter(|((_, cooldown), protection)| cooldown.is_some() || protection.is_some()) + .map(|((account, _), _)| account.clone()) + .collect(); + self.read_cooldown_accounts(active).await + } + + async fn read_cooldown_accounts( + &self, + accounts: Vec, + ) -> StoreResult { use futures::{StreamExt as _, TryStreamExt as _}; - let reads = futures::stream::iter(self.all_cooldown_accounts().await?) + let reads = futures::stream::iter(accounts) .map(|account| async move { self.read_account_runtime(&account) .await diff --git a/backend/crates/gateway-store/tests/postgres/account_groups.rs b/backend/crates/gateway-store/tests/postgres/account_groups.rs index 92298b711..806095324 100644 --- a/backend/crates/gateway-store/tests/postgres/account_groups.rs +++ b/backend/crates/gateway-store/tests/postgres/account_groups.rs @@ -199,6 +199,7 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip let (scope_revision, widened) = keys .update_client_key( UpdateClientKey { + peer_sharing: None, request_profile_override_updates: Default::default(), daily_limit_usd: None, weekly_limit_usd: None, @@ -225,6 +226,7 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip let (restricted_revision, restricted) = keys .update_client_key( UpdateClientKey { + peer_sharing: None, request_profile_override_updates: Default::default(), daily_limit_usd: None, weekly_limit_usd: None, @@ -441,6 +443,7 @@ async fn group_costs_should_include_statusless_websocket_but_reject_statusless_h fn new_key(id: &str, group_ids: Vec) -> NewClientKey { let marker = char::from(id.as_bytes().last().copied().unwrap_or(b'k')); NewClientKey { + peer_sharing: Default::default(), request_profile_overrides: Default::default(), budget: Default::default(), id: client_key_id(id), diff --git a/backend/crates/gateway-store/tests/postgres/client_budgets.rs b/backend/crates/gateway-store/tests/postgres/client_budgets.rs index 358c02ec4..b48f1c472 100644 --- a/backend/crates/gateway-store/tests/postgres/client_budgets.rs +++ b/backend/crates/gateway-store/tests/postgres/client_budgets.rs @@ -746,6 +746,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() { .await .unwrap(); let update = UpdateClientKey { + peer_sharing: None, request_profile_override_updates: Default::default(), id: ClientApiKeyId::new("key").unwrap(), name: "key".to_owned(), @@ -765,6 +766,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() { admin .update_client_key( UpdateClientKey { + peer_sharing: None, request_profile_override_updates: Default::default(), daily_limit_usd: None, weekly_limit_usd: None, @@ -789,6 +791,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() { admin .update_client_key( UpdateClientKey { + peer_sharing: None, request_profile_override_updates: Default::default(), daily_limit_usd: Some("0".parse().unwrap()), weekly_limit_usd: None, diff --git a/backend/crates/gateway-store/tests/postgres/client_keys.rs b/backend/crates/gateway-store/tests/postgres/client_keys.rs index 399d576ec..188e7ba54 100644 --- a/backend/crates/gateway-store/tests/postgres/client_keys.rs +++ b/backend/crates/gateway-store/tests/postgres/client_keys.rs @@ -25,6 +25,139 @@ use gateway_store::postgres::{ use super::TestDatabase; +#[tokio::test] +async fn peer_sharing_persists_across_reads_and_cannot_lose_its_group_scope() { + use gateway_admin::{ + model::{ + MutationActor, MutationContext, + account_groups::{AccountGroupColor, NewAccountGroup}, + client_keys::{NewClientKey, UpdateClientKey}, + }, + ports::store::{AccountGroupStore as _, AdminStoreErrorKind}, + }; + use gateway_core::{ + account::FastMode, + policy::{ClientPeerSharing, RateLimits}, + routing::AccountGroupId, + }; + use gateway_store::postgres::{ + ObservabilityQueryBudget, PgAccountGroupRepository, PgRuntimeSnapshotRepository, + RuntimeSnapshotRepository as _, + }; + let Some(database) = TestDatabase::create("peer_key_sharing").await else { + return; + }; + let context = MutationContext { + actor: MutationActor::System, + request_id: "peer-sharing-test".into(), + }; + let group = AccountGroupId::new(format!("grp_{}", "2".repeat(32))).unwrap(); + let groups = PgAccountGroupRepository::new( + database.pool.clone(), + ObservabilityQueryBudget::try_new(4, Duration::from_secs(10), Duration::from_secs(10)) + .unwrap(), + ); + groups + .create_account_group( + NewAccountGroup { + id: group.clone(), + name: "Shared".into(), + description: None, + color: AccountGroupColor::parse("#2563EBFF").unwrap(), + fast_mode: FastMode::Default, + allow_image_generation: true, + }, + &context, + ) + .await + .unwrap(); + let store = PgAdminClientKeyStore::new(database.pool.clone()); + let sharing = ClientPeerSharing { + enabled: true, + share_pool: true, + share_quota: true, + share_plans: false, + }; + let id = ClientApiKeyId::new("key_peer_shared").unwrap(); + store + .create_client_key( + NewClientKey { + peer_sharing: sharing, + request_profile_overrides: Default::default(), + id: id.clone(), + name: "Peer".into(), + label: None, + group_ids: vec![group.clone()], + limits: RateLimits::unlimited(), + budget: Default::default(), + plaintext: "synthetic-peer-sharing-key".into(), + }, + &context, + ) + .await + .unwrap(); + assert_eq!( + store + .get_client_key(&id) + .await + .unwrap() + .unwrap() + .peer_sharing, + sharing + ); + let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone()) + .load_runtime_snapshot() + .await + .unwrap(); + assert_eq!(snapshot.client_api_keys[0].peer_sharing, sharing); + let first_identity = store.peer_node_identity().await.unwrap(); + assert_eq!( + first_identity, + PgAdminClientKeyStore::new(database.pool.clone()) + .peer_node_identity() + .await + .unwrap() + ); + let mut update = UpdateClientKey { + peer_sharing: None, + request_profile_override_updates: Default::default(), + id: id.clone(), + name: "Renamed peer".into(), + label: None, + group_ids: Vec::new(), + limits: RateLimits::unlimited(), + daily_limit_usd: None, + weekly_limit_usd: None, + }; + let error = store + .update_client_key(update.clone(), &context) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminStoreErrorKind::Invalid); + assert_eq!( + store + .get_client_key(&id) + .await + .unwrap() + .unwrap() + .groups + .len(), + 1 + ); + update.group_ids = vec![group]; + let (_, updated) = store + .update_client_key(update.clone(), &context) + .await + .unwrap(); + assert_eq!(updated.peer_sharing, sharing); + update.group_ids.clear(); + update.peer_sharing = Some(ClientPeerSharing::default()); + let (_, updated) = store.update_client_key(update, &context).await.unwrap(); + assert!(!updated.peer_sharing.enabled); + assert!(updated.groups.is_empty()); + database.close().await; +} + #[tokio::test] async fn custom_keys_persist_exactly_and_keep_short_keys_masked() { use gateway_admin::model::{MutationActor, MutationContext, client_keys::NewClientKey}; @@ -53,6 +186,7 @@ async fn custom_keys_persist_exactly_and_keep_short_keys_masked() { let (_, record) = store .create_client_key( NewClientKey { + peer_sharing: Default::default(), request_profile_overrides: Default::default(), id: id.clone(), name: format!("Custom {index}"), @@ -108,6 +242,7 @@ async fn duplicate_custom_keys_conflict_atomically_without_extra_audits() { }; let key = "custom-key-case-sensitive!"; let command = |id: &str| NewClientKey { + peer_sharing: Default::default(), request_profile_overrides: Default::default(), id: ClientApiKeyId::new(id).unwrap(), name: id.to_owned(), @@ -144,6 +279,7 @@ async fn duplicate_custom_keys_conflict_atomically_without_extra_audits() { #[test] fn generated_client_key_format_remains_valid() { let key = NewClientApiKey { + peer_sharing: Default::default(), request_profile_overrides: Default::default(), budget: Default::default(), id: "key-1".to_owned(), @@ -177,6 +313,7 @@ async fn client_key_names_are_checked_atomically_on_create_and_rename() { request_id: "duplicate-name-test".to_owned(), }; let create = |id: &str, name: &str| NewClientKey { + peer_sharing: Default::default(), request_profile_overrides: Default::default(), id: ClientApiKeyId::new(id).unwrap(), name: name.to_owned(), @@ -187,6 +324,7 @@ async fn client_key_names_are_checked_atomically_on_create_and_rename() { plaintext: format!("synthetic-credential-{id}"), }; let update = |id: ClientApiKeyId, name: &str| UpdateClientKey { + peer_sharing: None, request_profile_override_updates: Default::default(), id, name: name.to_owned(), @@ -650,6 +788,7 @@ async fn dedicated_reveal_returns_plaintext_without_debug_exposure() { fn client_key_debug_redacts_plaintext() { let secret = format!("sk_{}", "s".repeat(43)); let key = NewClientApiKey { + peer_sharing: Default::default(), request_profile_overrides: Default::default(), budget: Default::default(), id: "key-1".to_owned(), @@ -723,6 +862,7 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance let (_, record) = store .create_client_key( NewClientKey { + peer_sharing: Default::default(), id: id.clone(), name: "profile".to_owned(), label: None, @@ -745,6 +885,7 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance ); assert_eq!(record.request_profile_overrides.get(&xai), Some(&profile)); let update = |override_value: Option>| UpdateClientKey { + peer_sharing: None, id: id.clone(), name: "profile".to_owned(), label: None, diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/admin_adapter.rs index f70870e73..411cc5730 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/admin_adapter.rs @@ -2,6 +2,115 @@ use super::*; +#[tokio::test] +async fn scoped_account_reads_follow_current_enabled_groups_without_losing_admin_details() { + let Some(database) = TestDatabase::create("account_group_scope").await else { + return; + }; + let repository = PgProviderAccountRepository::new(database.pool.clone()); + for id in ["acct_first", "acct_second", "acct_outside"] { + repository + .insert_provider_account(account(id, id)) + .await + .unwrap(); + } + let active = AccountGroupId::new(format!("grp_{}", "1".repeat(32))).unwrap(); + let disabled = AccountGroupId::new(format!("grp_{}", "2".repeat(32))).unwrap(); + sqlx::query( + "insert into account_groups (id, name, color, enabled, created_at, updated_at) + values ($1, 'Visible group', '#2563EBFF', true, now(), now()), + ($2, 'Disabled group', '#2563EBFF', false, now(), now())", + ) + .bind(active.as_str()) + .bind(disabled.as_str()) + .execute(&database.pool) + .await + .unwrap(); + sqlx::query( + "insert into account_group_accounts (account_group_id, provider_account_id, created_at) + values ($1, 'acct_first', now()), ($1, 'acct_outside', now()), + ($2, 'acct_first', now()), ($2, 'acct_second', now())", + ) + .bind(active.as_str()) + .bind(disabled.as_str()) + .execute(&database.pool) + .await + .unwrap(); + let store = admin_account_store(&database.pool); + let ids = ["acct_first", "acct_second"].map(|id| ProviderAccountId::new(id).unwrap()); + assert!( + store + .load_accounts_in_enabled_groups(&ids, &[]) + .await + .unwrap() + .is_empty() + ); + assert!( + store + .load_accounts_in_enabled_groups(&ids, std::slice::from_ref(&disabled)) + .await + .unwrap() + .is_empty() + ); + let accounts = store + .load_accounts_in_enabled_groups(&ids, &[active.clone(), disabled]) + .await + .unwrap(); + assert_eq!( + accounts + .iter() + .map(|account| account.id.as_str()) + .collect::>(), + ["acct_first"] + ); + assert!(accounts[0].groups.is_empty()); + let detail = store + .load_account("acct_first", AccountRuntimeSnapshot::default()) + .await + .unwrap() + .unwrap(); + assert_eq!(detail.account.groups.len(), 2); + assert!( + detail + .account + .groups + .iter() + .any(|group| group.id == active && group.name == "Visible group") + ); + + sqlx::query("update account_groups set enabled = false where id = $1") + .bind(active.as_str()) + .execute(&database.pool) + .await + .unwrap(); + assert!( + store + .load_accounts_in_enabled_groups(&ids, std::slice::from_ref(&active)) + .await + .unwrap() + .is_empty() + ); + sqlx::query("update account_groups set enabled = true where id = $1") + .bind(active.as_str()) + .execute(&database.pool) + .await + .unwrap(); + sqlx::query("update account_group_accounts set provider_account_id = 'acct_second' where account_group_id = $1 and provider_account_id = 'acct_first'") + .bind(active.as_str()).execute(&database.pool).await.unwrap(); + let accounts = store + .load_accounts_in_enabled_groups(&ids, &[active]) + .await + .unwrap(); + assert_eq!( + accounts + .iter() + .map(|account| account.id.as_str()) + .collect::>(), + ["acct_second"] + ); + database.close().await; +} + #[tokio::test] async fn account_existence_does_not_require_capacity_settings() { let Some(database) = TestDatabase::create("account_existence").await else { diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs index 6c5844dea..0a58d715a 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs @@ -12,6 +12,7 @@ mod deletion; mod protection_revision; mod quota_forecast; mod timestamps; +mod upstream_info; mod usage_projection; use chrono::{TimeDelta, Utc}; @@ -1876,7 +1877,8 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it() assert_eq!(imported.account_ids, ["acct_admin_upsert"]); sqlx::query( "update provider_accounts - set provider_quota_json = '{}'::jsonb, quota_observed_at = now(), updated_at = now() + set provider_quota_json = '{}'::jsonb, quota_observed_at = now(), + updated_at = greatest(updated_at, now()) where id = 'acct_admin_upsert'", ) .execute(&database.pool) diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/upstream_info.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/upstream_info.rs new file mode 100644 index 000000000..874c5b7f8 --- /dev/null +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/upstream_info.rs @@ -0,0 +1,487 @@ +//! 验证互联同步计划的并发领取、租约回收、快照保留与连接写入围栏 + +use super::*; +use gateway_core::account::{ + UpstreamConnection, UpstreamInfoClaim, UpstreamInfoCompletion, UpstreamInfoFailure, + UpstreamInfoStore, +}; + +fn now() -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000) +} + +async fn fixture( + label: &str, +) -> Option<( + TestDatabase, + PgProviderAccountRepository, + UpstreamConnection, +)> { + let database = TestDatabase::create(label).await?; + let repository = PgProviderAccountRepository::new(database.pool.clone()); + let mut input = account("acct_peer", "peer-fixture"); + input.authentication_kind = "api_key".into(); + repository.insert_provider_account(input).await.unwrap(); + let loaded = repository + .load_current_credential(&ProviderAccountId::new("acct_peer").unwrap()) + .await + .unwrap(); + let connection = UpstreamConnection::for_account(&loaded.account); + repository + .reconcile(&connection, true, now()) + .await + .unwrap(); + Some((database, repository, connection)) +} + +fn observation() -> OpaqueProviderData { + OpaqueProviderData::new( + json!({"fixture": "unchanged observation"}) + .as_object() + .unwrap() + .clone(), + ) +} + +fn completion( + claim: &UpstreamInfoClaim, + at: u64, + result: Result, +) -> UpstreamInfoCompletion { + UpstreamInfoCompletion { + connection: claim.plan.connection.clone(), + request_generation: claim.plan.request_generation, + claim_token: claim.claim_token, + completed_at: now() + Duration::from_secs(at), + next_attempt_at: now() + Duration::from_secs(at + 300), + result, + } +} + +#[tokio::test] +async fn concurrent_claim_and_expired_lease_allow_only_the_current_generation_to_complete() { + let Some((database, repository, connection)) = fixture("peer_claim").await else { + return; + }; + let provider = ProviderKind::new("openai").unwrap(); + let (first, second) = tokio::join!( + repository.claim_due(&provider, now(), 20, Duration::from_secs(5)), + repository.claim_due(&provider, now(), 20, Duration::from_secs(5)), + ); + let claimed = first + .unwrap() + .into_iter() + .chain(second.unwrap()) + .collect::>(); + assert_eq!(claimed.len(), 1); + let recovered = repository + .claim_due( + &provider, + now() + Duration::from_secs(6), + 20, + Duration::from_secs(60), + ) + .await + .unwrap(); + assert_eq!(recovered.len(), 1); + assert!(recovered[0].plan.request_generation > claimed[0].plan.request_generation); + assert!( + !repository + .complete(completion(&claimed[0], 7, Ok(observation()))) + .await + .unwrap() + ); + assert!( + repository + .complete(completion(&recovered[0], 7, Ok(observation()))) + .await + .unwrap() + ); + assert!( + !repository + .complete(completion(&recovered[0], 8, Ok(observation()))) + .await + .unwrap() + ); + let restarted = PgProviderAccountRepository::new(database.pool.clone()); + let saved = restarted + .read(&[connection.account_id]) + .await + .unwrap() + .remove(0); + assert_eq!(saved.snapshot, Some(observation())); + assert_eq!( + saved.plan.completed_generation, + recovered[0].plan.request_generation + ); + database.close().await; +} + +#[tokio::test] +async fn recreated_plan_rejects_a_previous_claim_even_when_its_generation_matches() { + let Some((database, repository, connection)) = fixture("peer_recreated").await else { + return; + }; + let provider = ProviderKind::new("openai").unwrap(); + let previous = repository + .claim_due(&provider, now(), 1, Duration::from_secs(60)) + .await + .unwrap() + .remove(0); + repository + .set_provider_account_enabled(connection.account_id.as_str(), false) + .await + .unwrap(); + repository + .reconcile(&connection, false, now()) + .await + .unwrap(); + repository + .set_provider_account_enabled(connection.account_id.as_str(), true) + .await + .unwrap(); + repository + .reconcile(&connection, true, now()) + .await + .unwrap(); + let current = repository + .claim_due(&provider, now(), 1, Duration::from_secs(60)) + .await + .unwrap() + .remove(0); + assert_eq!( + previous.plan.request_generation, + current.plan.request_generation + ); + assert!( + !repository + .complete(completion(&previous, 1, Ok(observation()))) + .await + .unwrap() + ); + assert!( + repository + .complete(completion(¤t, 2, Ok(observation()))) + .await + .unwrap() + ); + database.close().await; +} + +#[tokio::test] +async fn queued_and_claimed_refreshes_preserve_the_last_large_observation() { + let Some((database, repository, connection)) = fixture("peer_plan_reads").await else { + return; + }; + let provider = ProviderKind::new("openai").unwrap(); + let snapshot = OpaqueProviderData::new( + json!({"payload": "x".repeat(2 * 1024 * 1024)}) + .as_object() + .unwrap() + .clone(), + ); + let first = repository + .claim_due(&provider, now(), 1, Duration::from_secs(60)) + .await + .unwrap() + .remove(0); + assert!( + repository + .complete(completion(&first, 1, Ok(snapshot.clone()))) + .await + .unwrap() + ); + let queued = repository + .request_refresh( + &connection, + now() + Duration::from_secs(2), + Duration::from_secs(15), + ) + .await + .unwrap(); + let pending = repository + .read_plans(std::slice::from_ref(&connection.account_id)) + .await + .unwrap() + .remove(0); + assert_eq!(pending, queued); + let claimed = repository + .claim_due( + &provider, + queued.next_attempt_at, + 1, + Duration::from_secs(60), + ) + .await + .unwrap() + .remove(0); + assert_eq!(claimed.plan.request_generation, queued.request_generation); + let saved = repository + .read(&[connection.account_id]) + .await + .unwrap() + .remove(0); + assert_eq!(saved.snapshot, Some(snapshot)); + assert_eq!( + saved.plan.last_success_at, + Some(now() + Duration::from_secs(1)) + ); + database.close().await; +} + +#[tokio::test] +async fn refresh_deduplicates_preserves_failure_backoff_and_clears_revoked_observations() { + let Some((database, repository, connection)) = fixture("peer_refresh").await else { + return; + }; + let provider = ProviderKind::new("openai").unwrap(); + let first = repository + .claim_due(&provider, now(), 1, Duration::from_secs(60)) + .await + .unwrap() + .remove(0); + repository + .complete(completion(&first, 1, Ok(observation()))) + .await + .unwrap(); + let requested = repository + .request_refresh( + &connection, + now() + Duration::from_secs(2), + Duration::from_secs(15), + ) + .await + .unwrap(); + assert_eq!(requested.next_attempt_at, now() + Duration::from_secs(15)); + let duplicate = repository + .request_refresh( + &connection, + now() + Duration::from_secs(3), + Duration::from_secs(15), + ) + .await + .unwrap(); + assert_eq!(requested.request_generation, duplicate.request_generation); + let second = repository + .claim_due( + &provider, + requested.next_attempt_at, + 1, + Duration::from_secs(60), + ) + .await + .unwrap() + .remove(0); + repository + .complete(completion( + &second, + 16, + Err(UpstreamInfoFailure::Unavailable), + )) + .await + .unwrap(); + let retained = repository + .read(std::slice::from_ref(&connection.account_id)) + .await + .unwrap() + .remove(0); + assert_eq!(retained.snapshot, Some(observation())); + assert_eq!( + retained.plan.last_success_at, + Some(now() + Duration::from_secs(1)) + ); + let retry = repository + .request_refresh( + &connection, + now() + Duration::from_secs(17), + Duration::from_secs(15), + ) + .await + .unwrap(); + assert_eq!(retry.next_attempt_at, retained.plan.next_attempt_at); + assert!( + repository + .claim_due( + &provider, + now() + Duration::from_secs(30), + 1, + Duration::from_secs(60) + ) + .await + .unwrap() + .is_empty() + ); + let third = repository + .claim_due(&provider, retry.next_attempt_at, 1, Duration::from_secs(60)) + .await + .unwrap() + .remove(0); + repository + .complete(completion( + &third, + 317, + Err(UpstreamInfoFailure::Unauthorized), + )) + .await + .unwrap(); + let revoked = repository + .read(std::slice::from_ref(&connection.account_id)) + .await + .unwrap() + .remove(0); + assert!(revoked.snapshot.is_none()); + assert_eq!( + revoked.plan.last_error, + Some(UpstreamInfoFailure::Unauthorized) + ); + database.close().await; +} + +#[tokio::test] +async fn changed_credentials_and_proxy_reject_old_results_before_reconciliation() { + let Some((database, repository, original)) = fixture("peer_connection").await else { + return; + }; + let provider = ProviderKind::new("openai").unwrap(); + let first = repository + .claim_due(&provider, now(), 1, Duration::from_secs(60)) + .await + .unwrap() + .remove(0); + sqlx::query( + "update provider_accounts set credential_revision = credential_revision + 1 where id = $1", + ) + .bind(original.account_id.as_str()) + .execute(&database.pool) + .await + .unwrap(); + assert!( + !repository + .complete(completion(&first, 1, Ok(observation()))) + .await + .unwrap() + ); + assert_eq!( + repository + .request_refresh(&original, now(), Duration::ZERO) + .await + .unwrap_err() + .kind(), + gateway_core::error::StoreErrorKind::Conflict + ); + let loaded = repository + .load_current_credential(&original.account_id) + .await + .unwrap(); + let rotated = UpstreamConnection::for_account(&loaded.account); + repository.reconcile(&rotated, true, now()).await.unwrap(); + let second = repository + .claim_due(&provider, now(), 1, Duration::from_secs(60)) + .await + .unwrap() + .remove(0); + assert!(second.plan.request_generation > first.plan.request_generation); + sqlx::query( + "update provider_accounts set outbound_proxy_url = 'http://127.0.0.1:8123' where id = $1", + ) + .bind(original.account_id.as_str()) + .execute(&database.pool) + .await + .unwrap(); + assert!( + !repository + .complete(completion(&second, 2, Ok(observation()))) + .await + .unwrap() + ); + let loaded = repository + .load_current_credential(&original.account_id) + .await + .unwrap(); + let changed_proxy = UpstreamConnection::for_account(&loaded.account); + repository + .reconcile(&changed_proxy, true, now()) + .await + .unwrap(); + let third = repository + .claim_due(&provider, now(), 1, Duration::from_secs(60)) + .await + .unwrap() + .remove(0); + assert!( + repository + .complete(completion(&third, 3, Ok(observation()))) + .await + .unwrap() + ); + repository + .reconcile(&changed_proxy, false, now()) + .await + .unwrap(); + assert!( + repository + .read(&[original.account_id]) + .await + .unwrap() + .is_empty() + ); + assert!( + !repository + .complete(completion(&third, 4, Ok(observation()))) + .await + .unwrap() + ); + database.close().await; +} + +#[tokio::test] +async fn discovery_filters_by_declared_provider_and_authentication_kinds() { + let Some((database, repository, _)) = fixture("peer_discovery").await else { + return; + }; + let mut input = account("acct_peer_xai", "generic-peer"); + input.provider_kind = "xai".into(); + input.authentication_kind = "peer_test".into(); + repository.insert_provider_account(input).await.unwrap(); + let xai = ProviderKind::new("xai").unwrap(); + let candidates = repository + .list_candidates(&xai, &["peer_test"], None, 100) + .await + .unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].account.id().as_str(), "acct_peer_xai"); + assert!( + repository + .list_candidates(&xai, &[], None, 100) + .await + .unwrap() + .is_empty() + ); + assert!( + repository + .list_candidates(&xai, &["api_key"], None, 100) + .await + .unwrap() + .is_empty() + ); + assert!( + repository + .list_candidates(&xai, &["peer_test"], Some(candidates[0].account.id()), 100) + .await + .unwrap() + .is_empty() + ); + assert!( + repository + .list_candidates( + &ProviderKind::new("openai").unwrap(), + &["peer_test"], + None, + 100 + ) + .await + .unwrap() + .is_empty() + ); + database.close().await; +} diff --git a/backend/crates/gateway-store/tests/postgres/snapshot.rs b/backend/crates/gateway-store/tests/postgres/snapshot.rs index d2c089837..6c648f44f 100644 --- a/backend/crates/gateway-store/tests/postgres/snapshot.rs +++ b/backend/crates/gateway-store/tests/postgres/snapshot.rs @@ -10,6 +10,7 @@ use super::TestDatabase; #[test] fn client_key_snapshot_debug_redacts_plaintext_key() { let policy = ClientApiKeySnapshot { + peer_sharing: Default::default(), name_snapshot: None, request_profiles: Default::default(), id: ClientApiKeyId::new("key-1").expect("client key ID"), diff --git a/backend/crates/gateway-store/tests/redis/credential_cooldown.rs b/backend/crates/gateway-store/tests/redis/credential_cooldown.rs index 77d01e625..95bf8f77f 100644 --- a/backend/crates/gateway-store/tests/redis/credential_cooldown.rs +++ b/backend/crates/gateway-store/tests/redis/credential_cooldown.rs @@ -363,6 +363,21 @@ async fn capacity_freeze_survives_due_time_and_runtime_restart_until_probe_succe panic!("status snapshot: {error}"); }); assert!(snapshot.cooldown[&frozen.provider_account_id].is_active(SystemTime::now())); + let scoped = runtime + .account_rate_limits(&[frozen.provider_account_id.clone(), "acct_absent".to_owned()]) + .await + .expect("scoped cooldown snapshot"); + assert_eq!(scoped.cooldown.len(), 1); + assert!(scoped.cooldown[&frozen.provider_account_id].is_active(SystemTime::now())); + assert!(scoped.in_flight.is_none()); + assert!( + runtime + .account_rate_limits(&["acct_absent".to_owned()]) + .await + .unwrap() + .cooldown + .is_empty() + ); assert!( snapshot.cooldown[&frozen.provider_account_id] .kind diff --git a/backend/crates/providers/openai/Cargo.toml b/backend/crates/providers/openai/Cargo.toml index bf9a53cd9..7a2f49057 100644 --- a/backend/crates/providers/openai/Cargo.toml +++ b/backend/crates/providers/openai/Cargo.toml @@ -74,6 +74,7 @@ openssl = "=0.10.75" openssl-sys = { version = "=0.9.111", features = ["vendored"] } [dev-dependencies] +gateway-host = { path = "../../gateway-host" } allocation-counter = "0.8.1" tempfile = "3.27.0" tokio = { version = "1.53.3", default-features = false, features = ["rt-multi-thread", "test-util"] } diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin/mod.rs similarity index 96% rename from backend/crates/providers/openai/src/admin.rs rename to backend/crates/providers/openai/src/admin/mod.rs index 35940247e..82bb41cc9 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin/mod.rs @@ -1,5 +1,7 @@ //! OpenAI 管理边界:Provider preparation 与 Redis OAuth pending 适配 +mod quota; + use std::collections::BTreeMap; use std::sync::Arc; use std::time::SystemTime; @@ -166,6 +168,16 @@ impl OpenAiAdminProvider { #[async_trait] impl ProviderAdmin for OpenAiAdminProvider { + fn is_direct_account(&self, authentication_kind: &str) -> bool { + crate::credential::is_direct_account_kind(authentication_kind) + } + + fn peer_model_eligible(&self, account: &AccountRecord, model: &str) -> Option { + account_from_record(account).ok().map(|account| { + crate::credential::CodexSelectionModel::Requested(model).allows_account(&account) + }) + } + fn compile_privacy_policy( &self, policy: &gateway_core::settings::privacy::CodexPrivacyPolicy, @@ -704,7 +716,7 @@ impl ProviderAdmin for OpenAiAdminProvider { if refresh { return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported)); } - return Ok(project_quota(None, account.quota().is_exhausted())); + return Ok(project_quota(None, false)); } let snapshot = if refresh { let snapshot = Some( @@ -728,70 +740,33 @@ impl ProviderAdmin for OpenAiAdminProvider { &self, requests: &[ProviderQuotaReadRequest<'_>], ) -> Vec> { - let identities = requests - .iter() - .map(|request| { - if request.account.provider_kind != self.provider_kind { - return Err(provider_admin_error(ProviderAdminErrorKind::NotFound)); - } - let id = ProviderAccountId::new(request.account.id.clone()) - .map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?; - let revision = - CredentialRevision::new(request.account.credential_revision.get()) - .map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?; - Ok((id, revision)) - }) - .collect::>(); - let ids = requests - .iter() - .zip(&identities) - .filter(|(request, _)| { - request.account.authentication_kind - != crate::credential::CODEX_AUTHENTICATION_KIND_API_KEY - }) - .filter_map(|(_, identity)| identity.as_ref().ok().map(|(id, _)| id.clone())) - .collect::>(); - let observations = if ids.is_empty() { - Ok(BTreeMap::new()) - } else { - self.accounts - .get_quotas(&ids) - .await - .map_err(|error| map_quota_error(error.into())) - .map(|values| { - values - .into_iter() - .map(|observation| (observation.account_id.clone(), observation)) - .collect::>() - }) - }; - requests - .iter() - .zip(identities) - .map(|(request, identity)| { - let (id, revision) = identity?; - let snapshot = if request.account.authentication_kind - == crate::credential::CODEX_AUTHENTICATION_KIND_API_KEY - { - None - } else { - observations - .as_ref() - .map_err(Clone::clone)? - .get(&id) - .map(|observation| { - self.quota.project_observation(&id, revision, observation) - }) - .transpose() - .map_err(map_quota_error)? - .flatten() - }; - Ok(project_quota( - snapshot, - request.account.quota.is_exhausted(), - )) - }) - .collect() + self.read_quota_snapshots(requests).await + } + + fn peer_authentication_kinds(&self) -> &[&str] { + &[crate::credential::CODEX_AUTHENTICATION_KIND_API_KEY] + } + + fn peer_connection( + &self, + loaded: &gateway_core::account::LoadedCredential, + ) -> Result, ProviderAdminError> { + if loaded.account.provider() != &self.provider_kind + || !self.supports_peer_connection(loaded.account.authentication_kind()) + { + return Ok(None); + } + let credential = CodexCredentialCodec::decode(&loaded.credential) + .map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?; + match credential.authentication { + crate::credential::CodexRuntimeAuthentication::ApiKey(authentication) => { + Ok(Some(gateway_admin::ports::peer::PeerConnection { + base_url: authentication.configuration.base_url, + bearer_token: authentication.secret, + })) + } + crate::credential::CodexRuntimeAuthentication::OAuth(_) => Ok(None), + } } fn quota_forecast_observation( @@ -1225,6 +1200,7 @@ fn account_matches_record(account: &ProviderAccount, record: &AccountRecord) -> fn empty_quota() -> ProviderQuota { ProviderQuota { + upstream: None, credits: None, plan_type: None, observed_at: None, @@ -1264,6 +1240,7 @@ fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota .iter() .filter(|window| should_project_quota_window(window)) .map(|window| ProviderQuotaWindow { + upstream: None, key: window.key().to_owned(), group: quota_group(window.kind()).to_owned(), label: codex_quota_window_label(window.kind(), window.role(), window.window_seconds()), @@ -1287,6 +1264,7 @@ fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota // 在窗口全部过期后继续维持限流 let limit_reached = quota_windows_limit_reached(&windows); ProviderQuota { + upstream: None, plan_type: snapshot.plan_type().map(str::to_owned), observed_at: Some(DateTime::::from(snapshot.observed_at())), refresh_token_expires_at: None, diff --git a/backend/crates/providers/openai/src/admin/quota.rs b/backend/crates/providers/openai/src/admin/quota.rs new file mode 100644 index 000000000..a43b8f0a0 --- /dev/null +++ b/backend/crates/providers/openai/src/admin/quota.rs @@ -0,0 +1,75 @@ +//! 管理端批量读取原生额度,不在列表查询中访问上游 + +use super::*; + +impl OpenAiAdminProvider { + pub(super) async fn read_quota_snapshots( + &self, + requests: &[ProviderQuotaReadRequest<'_>], + ) -> Vec> { + let identities = requests + .iter() + .map(|request| { + if request.account.provider_kind != self.provider_kind { + return Err(provider_admin_error(ProviderAdminErrorKind::NotFound)); + } + let id = ProviderAccountId::new(request.account.id.clone()) + .map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?; + let revision = + CredentialRevision::new(request.account.credential_revision.get()) + .map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?; + Ok((id, revision)) + }) + .collect::>(); + let ids = requests + .iter() + .zip(&identities) + .filter(|(request, _)| { + request.account.authentication_kind + != crate::credential::CODEX_AUTHENTICATION_KIND_API_KEY + }) + .filter_map(|(_, identity)| identity.as_ref().ok().map(|(id, _)| id.clone())) + .collect::>(); + let observations = if ids.is_empty() { + Ok(BTreeMap::new()) + } else { + self.accounts + .get_quotas(&ids) + .await + .map_err(|error| map_quota_error(error.into())) + .map(|values| { + values + .into_iter() + .map(|observation| (observation.account_id.clone(), observation)) + .collect::>() + }) + }; + requests + .iter() + .zip(identities) + .map(|(request, identity)| { + let (id, revision) = identity?; + let snapshot = if request.account.authentication_kind + == crate::credential::CODEX_AUTHENTICATION_KIND_API_KEY + { + return Ok(project_quota(None, false)); + } else { + observations + .as_ref() + .map_err(Clone::clone)? + .get(&id) + .map(|observation| { + self.quota.project_observation(&id, revision, observation) + }) + .transpose() + .map_err(map_quota_error)? + .flatten() + }; + Ok(project_quota( + snapshot, + request.account.quota.is_exhausted(), + )) + }) + .collect() + } +} diff --git a/backend/crates/providers/openai/src/credential/mod.rs b/backend/crates/providers/openai/src/credential/mod.rs index f6bf2eb5d..fb84fa0bb 100644 --- a/backend/crates/providers/openai/src/credential/mod.rs +++ b/backend/crates/providers/openai/src/credential/mod.rs @@ -78,3 +78,8 @@ pub use types::{ }; mod diagnostics; + +/// 直连资格由 Provider 拥有,执行快照与只读导出使用同一判断 +pub(crate) fn is_direct_account_kind(authentication_kind: &str) -> bool { + authentication_kind == CODEX_AUTHENTICATION_KIND_OAUTH +} diff --git a/backend/crates/providers/openai/src/credential/security.rs b/backend/crates/providers/openai/src/credential/security.rs index 96a6c4789..044d8dbfa 100644 --- a/backend/crates/providers/openai/src/credential/security.rs +++ b/backend/crates/providers/openai/src/credential/security.rs @@ -171,17 +171,7 @@ impl CodexCredentialCodec { pub fn decode( credential: &PlaintextCredential, ) -> Result { - let value = Value::Object(credential.expose_to_provider().clone()); - if serde_json::to_vec(&value) - .map_err(|_| CodexCredentialDataError::Invalid)? - .len() - > MAX_CREDENTIAL_BYTES - { - return Err(CodexCredentialDataError::TooLarge); - } - let data = serde_json::from_value::(value) - .map_err(|_| CodexCredentialDataError::Invalid)?; - validate(&data)?; + let data = Self::decode_complete(credential)?; let transport = match &data { CodexCredentialData::OAuth(data) => data.transport, CodexCredentialData::ApiKey(data) => data.transport, @@ -237,7 +227,19 @@ impl CodexCredentialCodec { pub fn decode_complete( credential: &PlaintextCredential, ) -> Result { - let value = Value::Object(credential.expose_to_provider().clone()); + let mut value = credential.expose_to_provider().clone(); + if serde_json::to_vec(&value) + .map_err(|_| CodexCredentialDataError::Invalid)? + .len() + > MAX_CREDENTIAL_BYTES + { + return Err(CodexCredentialDataError::TooLarge); + } + // 已保存的 API Key 凭据可带旧开关,自动发现不再读取或写入该设置 + if value.contains_key("api_key") { + value.remove("upstream_info_enabled"); + } + let value = Value::Object(value); let data = serde_json::from_value::(value) .map_err(|_| CodexCredentialDataError::Invalid)?; validate(&data)?; diff --git a/backend/crates/providers/openai/src/credential/selector.rs b/backend/crates/providers/openai/src/credential/selector.rs index 503ffa355..cdab17674 100644 --- a/backend/crates/providers/openai/src/credential/selector.rs +++ b/backend/crates/providers/openai/src/credential/selector.rs @@ -9,9 +9,10 @@ use gateway_core::error::ErrorSource; use gateway_core::account::{ AccountAffinity, AccountCandidate, AccountCapacitySnapshot, AccountEligibilityPolicy, - AccountErrorReason, AccountFeedbackStats, AccountRuntimeSignals, AccountSchedulingBlocker, - AccountSelectionContext, AccountSelectionPolicy, AccountSelector, AccountStatus, - CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId, QuotaEvidence, + AccountErrorReason, AccountFeedbackStats, AccountQuotaSignals, AccountRuntimeSignals, + AccountSchedulingBlocker, AccountSelectionContext, AccountSelectionPolicy, AccountSelector, + AccountStatus, CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId, + QuotaEvidence, RotationStrategy, }; use gateway_core::concurrency::{CapacityWait, ConcurrencyWaitQueue, QueueRejection, WaitPriority}; use gateway_core::engine::{AttemptContext, ContinuationAttempt, policy::AccountPolicyError}; @@ -134,6 +135,7 @@ pub struct CodexCredentialSelector { session_affinity: Arc, session_exclusions: Arc, quota: Arc, + upstream_info: Option>, cookie_policy: CodexCookiePolicy, risk_recovery: Mutex>, account_feedback: Arc, @@ -281,6 +283,7 @@ impl CodexCredentialSelector { session_affinity, session_exclusions, quota, + upstream_info: None, cookie_policy, risk_recovery: Mutex::new(HashMap::new()), waiting: ConcurrencyWaitQueue::default(), @@ -310,6 +313,42 @@ impl CodexCredentialSelector { .await } + #[must_use] + pub(crate) fn with_upstream_info( + mut self, + upstream_info: Arc, + ) -> Self { + self.upstream_info = Some(upstream_info); + self + } + + fn quota_signals( + &self, + account: &ProviderAccount, + model: CodexSelectionModel<'_>, + strategy: RotationStrategy, + ) -> Option { + if account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH { + return self.quota.scheduling_signals(account); + } + if strategy != RotationStrategy::Smart { + return None; + } + let signal = + self.upstream_info + .as_ref()? + .signal(account, model.model()?, SystemTime::now())?; + let rank = if signal.exhausted { + Some(0) + } else { + signal + .remaining_percent + .map(|percent| (percent.clamp(0.0, 100.0) * 100.0).round() as u64) + }; + // 互联观测只提供 Smart 额度分,不修改真实错误、冷却或账号可用状态 + Some(AccountQuotaSignals::new(None, rank)) + } + pub(crate) async fn select_with_cyber_policy( &self, request: &SelectCodexCredential<'_>, @@ -684,7 +723,11 @@ impl CodexCredentialSelector { failure_rate_basis_points: None, first_output_latency_ms: None, }) - .with_provider_quota(self.quota.scheduling_signals(&account)) + .with_provider_quota(self.quota_signals( + &account, + model, + request.attempt.account_selection_policy().strategy(), + )) .with_rate_limit(rate_limits.get(account.id()).copied().flatten()) .with_runtime_health(health.0, health.1); AccountCandidate { account, signals } diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index 887facbdb..ca23accff 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -63,6 +63,7 @@ pub struct ProviderBundle { pub async fn initialize( config: OpenAiConfig, ports: ProviderStorePorts, + upstream_info: Arc, ) -> Result { let provider_kind = ProviderKind::new("openai").map_err(|_| OpenAiInitializeError::InvalidProviderKind)?; @@ -165,16 +166,19 @@ pub async fn initialize( http.clone(), config.base_url().to_owned(), )); - let selector = Arc::new(CodexCredentialSelector::new( - provider_kind.clone(), - repository.clone(), - Arc::clone(&leases), - session_affinity, - session_exclusions, - Arc::clone("a), - Arc::clone(&account_feedback), - CodexCookiePolicy::official().map_err(|_| OpenAiInitializeError::CookiePolicy)?, - )); + let selector = Arc::new( + CodexCredentialSelector::new( + provider_kind.clone(), + repository.clone(), + Arc::clone(&leases), + session_affinity, + session_exclusions, + Arc::clone("a), + Arc::clone(&account_feedback), + CodexCookiePolicy::official().map_err(|_| OpenAiInitializeError::CookiePolicy)?, + ) + .with_upstream_info(upstream_info), + ); let core_provider: Arc = Arc::new( CodexProvider::new( selector, diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index 1965ffdb6..b5e564f49 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -408,6 +408,10 @@ impl fmt::Debug for CodexProvider { #[async_trait] impl Provider for CodexProvider { + fn is_direct_account(&self, authentication_kind: &str) -> bool { + crate::credential::is_direct_account_kind(authentication_kind) + } + fn compile_privacy_policy( &self, policy: &gateway_core::settings::privacy::CodexPrivacyPolicy, diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin/mod.rs similarity index 96% rename from backend/crates/providers/openai/tests/admin.rs rename to backend/crates/providers/openai/tests/admin/mod.rs index 8dbad64a6..539112218 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin/mod.rs @@ -1,5 +1,7 @@ //! 验证 OpenAI 管理能力、请求画像、Bundle 组装与额度投影 +mod peer_info; + use std::collections::{BTreeMap, BTreeSet}; use std::num::NonZeroU32; use std::sync::{Arc, Mutex}; @@ -69,9 +71,13 @@ const COMPLETED_SESSION_SSE: &str = concat!( #[tokio::test] async fn plan_display_should_distinguish_pro_tiers_and_preserve_other_official_names() { let config = valid_config(); - let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) - .await - .unwrap(); + let bundle = provider_openai::initialize( + config.config.clone(), + provider_ports(), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), + ) + .await + .unwrap(); let admin = bundle.admin_provider(); for (raw, display) in [ ("prolite", "ProLite"), @@ -105,9 +111,13 @@ async fn plan_display_should_distinguish_pro_tiers_and_preserve_other_official_n #[tokio::test] async fn account_capabilities_distinguish_oauth_from_api_key_and_unknown_credentials() { let config = valid_config(); - let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) - .await - .unwrap(); + let bundle = provider_openai::initialize( + config.config.clone(), + provider_ports(), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), + ) + .await + .unwrap(); let provider = bundle.admin_provider(); let id = ProviderAccountId::new("acct_capabilities").unwrap(); let oauth = provider.account_capabilities(&id, "oauth"); @@ -128,9 +138,13 @@ async fn account_capabilities_distinguish_oauth_from_api_key_and_unknown_credent #[tokio::test] async fn custom_identity_preview_and_dashboard_share_the_exact_request_profile() { let config = valid_config(); - let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) - .await - .unwrap(); + let bundle = provider_openai::initialize( + config.config.clone(), + provider_ports(), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), + ) + .await + .unwrap(); let provider = bundle.admin_provider(); let user_agent = "codex_exec/0.156.1 (Ubuntu 24.4.0; x86_64) xterm-256color (codex_exec; 0.156.1)"; @@ -157,9 +171,13 @@ async fn custom_identity_preview_and_dashboard_share_the_exact_request_profile() #[tokio::test] async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions_once() { let config = valid_config(); - let mut bundle = provider_openai::initialize(config.config.clone(), provider_ports()) - .await - .expect("OpenAI bundle"); + let mut bundle = provider_openai::initialize( + config.config.clone(), + provider_ports(), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), + ) + .await + .expect("OpenAI bundle"); assert_eq!(bundle.core_provider().name(), "openai"); assert_eq!(bundle.admin_provider().provider_kind().as_str(), "openai"); @@ -232,11 +250,16 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions #[tokio::test] async fn quota_forecast_observation_reuses_protocol_parser_and_keeps_window_identity() { let config = valid_config(); - let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) - .await - .unwrap(); + let bundle = provider_openai::initialize( + config.config.clone(), + provider_ports(), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), + ) + .await + .unwrap(); let provider = bundle.admin_provider(); let mut window = gateway_admin::model::provider_credentials::ProviderQuotaWindow { + upstream: None, key: "codex:604800s".to_owned(), group: "shortTerm".to_owned(), label: "周额度".to_owned(), @@ -332,6 +355,7 @@ async fn initialized_provider_keeps_thread_spawn_transport_conversations_distinc let bundle = provider_openai::initialize( config.config.clone(), provider_ports_with(store, Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("initialized OpenAI provider"); @@ -400,9 +424,13 @@ async fn copying_builtin_prices_keeps_cache_read_and_write_fallback_costs() { OpenAiBillingUsage, openai_billing_breakdown, openai_billing_breakdown_with_override, }; let config = valid_config(); - let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) - .await - .unwrap(); + let bundle = provider_openai::initialize( + config.config.clone(), + provider_ports(), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), + ) + .await + .unwrap(); let prices = bundle.admin_provider().pricing_catalog(); for model in [ "gpt-4", @@ -444,9 +472,13 @@ async fn copying_builtin_prices_keeps_cache_read_and_write_fallback_costs() { #[tokio::test] async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing() { let config = valid_config(); - let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) - .await - .expect("OpenAI bundle"); + let bundle = provider_openai::initialize( + config.config.clone(), + provider_ports(), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), + ) + .await + .expect("OpenAI bundle"); let admin = bundle.admin_provider(); let baseline = admin.dashboard_wire_profile().expect("official baseline"); assert_eq!( @@ -528,9 +560,13 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing() #[tokio::test] async fn openai_legacy_billing_should_not_infer_long_context_flag() { let config = valid_config(); - let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) - .await - .expect("OpenAI bundle"); + let bundle = provider_openai::initialize( + config.config.clone(), + provider_ports(), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), + ) + .await + .expect("OpenAI bundle"); let billing = bundle .admin_provider() .calculated_billing(&ProviderBillingInput { @@ -614,9 +650,13 @@ async fn reset_credit_explicit_http_rejection_should_preserve_the_raw_body() { #[tokio::test] async fn openai_core_provider_projects_codex_request_observation_without_routing_side_effects() { let config = valid_config(); - let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) - .await - .expect("OpenAI bundle"); + let bundle = provider_openai::initialize( + config.config.clone(), + provider_ports(), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), + ) + .await + .expect("OpenAI bundle"); let payload = ProtocolPayload::json_object( "openai", Map::from_iter([ @@ -654,6 +694,7 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne Arc::new(MemoryAccountStore::default()), Arc::clone(&pending), ), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("OpenAI bundle"); @@ -757,6 +798,7 @@ async fn openai_reauthorization_pending_payload_reuses_the_account_installation_ let bundle = provider_openai::initialize( config.config.clone(), provider_ports_with(accounts, Arc::clone(&pending)), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("OpenAI bundle"); @@ -838,6 +880,7 @@ async fn openai_admin_reads_a_full_quota_page_in_one_store_call() { let bundle = provider_openai::initialize( config.config.clone(), provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("OpenAI bundle"); @@ -935,6 +978,7 @@ async fn openai_admin_batch_quota_isolates_stale_and_invalid_observations() { let bundle = provider_openai::initialize( config.config.clone(), provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("OpenAI bundle"); @@ -1007,6 +1051,7 @@ async fn openai_admin_provider_projects_cached_quota_models_and_canonical_export Arc::new(TestOAuthPending::default()), catalog_cache, ), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("OpenAI bundle"); @@ -1110,6 +1155,7 @@ async fn openai_admin_quota_refresh_updates_the_account_plan() { let bundle = provider_openai::initialize( config.config, provider_ports_with(store.clone(), Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .unwrap(); @@ -1151,6 +1197,7 @@ async fn openai_admin_quota_projects_credit_balance_from_refresh_and_cached_obse let bundle = provider_openai::initialize( config.config, provider_ports_with(store.clone(), Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .unwrap(); @@ -1257,6 +1304,7 @@ async fn openai_admin_projects_free_plan_from_cached_quota_when_account_claims_o let bundle = provider_openai::initialize( config.config.clone(), provider_ports_with(store, Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("OpenAI bundle"); @@ -1356,6 +1404,7 @@ async fn openai_admin_provider_projects_official_codex_quota_and_independent_buc let bundle = provider_openai::initialize( config.config.clone(), provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("OpenAI bundle"); @@ -1476,6 +1525,7 @@ async fn openai_admin_keeps_confirmed_exhaustion_separate_from_raw_usage_display let bundle = provider_openai::initialize( config.config.clone(), provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("OpenAI bundle"); @@ -1588,6 +1638,7 @@ async fn openai_admin_preserves_expired_window_usage_and_exhaustion_attribution( let bundle = provider_openai::initialize( config.config.clone(), provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("OpenAI bundle"); @@ -1667,6 +1718,7 @@ async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit( let bundle = provider_openai::initialize( config.config.clone(), provider_ports_with(store, Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("OpenAI bundle"); @@ -1709,9 +1761,13 @@ async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit( #[tokio::test] async fn initialized_provider_reports_a_safe_pat_format_error_before_network_access() { let config = valid_config(); - let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) - .await - .expect("OpenAI bundle"); + let bundle = provider_openai::initialize( + config.config.clone(), + provider_ports(), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), + ) + .await + .expect("OpenAI bundle"); let error = bundle .admin_provider() .prepare_import(PrepareCredentialImport { @@ -1753,6 +1809,7 @@ async fn openai_rotation_preserves_the_new_access_token_jwt_expiration() { let bundle = provider_openai::initialize( config.config.clone(), provider_ports_with(store, Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("OpenAI bundle"); @@ -1811,6 +1868,7 @@ async fn reset_credit_admin( let bundle = provider_openai::initialize( config.config.clone(), provider_ports_with(store, Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("OpenAI reset-credit bundle"); @@ -1904,6 +1962,7 @@ pub(crate) async fn initialized_test_bundle( provider_openai::initialize( config.config.clone(), provider_ports_with(accounts, Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("initialized provider") @@ -2347,6 +2406,7 @@ mod errors { let bundle = provider_openai::initialize( config.config, provider_ports_with(store.clone(), Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .expect("OpenAI bundle"); @@ -2641,10 +2701,13 @@ mod errors { assert!(!format!("{error:?}").contains("invalid-success-secret-marker")); config.config.auth.oauth_token_endpoint = "http://127.0.0.1:0/oauth/token".to_owned(); - let bundle = - provider_openai::initialize(config.config, refresh_ports(store.clone(), false)) - .await - .unwrap(); + let bundle = provider_openai::initialize( + config.config, + refresh_ports(store.clone(), false), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), + ) + .await + .unwrap(); let error = bundle .admin_provider() .prepare_refresh(command()) @@ -2708,10 +2771,13 @@ mod errors { .await; let mut config = valid_config(); config.config.auth.oauth_token_endpoint = format!("{}/oauth/token", server.uri()); - let bundle = - provider_openai::initialize(config.config.clone(), refresh_ports(store.clone(), busy)) - .await - .unwrap(); + let bundle = provider_openai::initialize( + config.config.clone(), + refresh_ports(store.clone(), busy), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), + ) + .await + .unwrap(); (bundle, store, config) } @@ -2780,6 +2846,7 @@ async fn api_key_admin_exposes_only_configuration_and_preserves_key_when_rotatin let bundle = provider_openai::initialize( config.config.clone(), provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .unwrap(); @@ -2863,6 +2930,7 @@ async fn oauth_transport_settings_preserve_tokens_refresh_schedule_and_health() let bundle = provider_openai::initialize( config.config.clone(), provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), ) .await .unwrap(); @@ -2958,9 +3026,13 @@ async fn usage_quote_reuses_native_override_and_reports_incomplete_input() { use gateway_admin::ports::provider::ProviderUsagePricingInput; use gateway_core::metering::{ModelPriceOverride, Usage}; let config = valid_config(); - let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) - .await - .unwrap(); + let bundle = provider_openai::initialize( + config.config.clone(), + provider_ports(), + Arc::new(gateway_core::account::UpstreamInfoReader::default()), + ) + .await + .unwrap(); let admin = bundle.admin_provider(); let mut input = ProviderUsagePricingInput { upstream_model_id: "gpt-6-sol".into(), diff --git a/backend/crates/providers/openai/tests/admin/peer_info.rs b/backend/crates/providers/openai/tests/admin/peer_info.rs new file mode 100644 index 000000000..c9172161b --- /dev/null +++ b/backend/crates/providers/openai/tests/admin/peer_info.rs @@ -0,0 +1,1099 @@ +//! OpenAI 凭据适配、公共互联服务、Host 传输与调度只读端口的集成验证 + +use super::*; +use gateway_core::{ + account::{ + LoadedCredential, UpstreamConnection, UpstreamInfoClaim, UpstreamInfoCompletion, + UpstreamInfoFailure, UpstreamInfoPlan, UpstreamInfoRecord, UpstreamInfoStore, + }, + error::{StoreError, StoreErrorKind}, + task::WorkerCycleContext, +}; +use provider_openai::credential::ResponsesTransport; + +pub(super) struct MemoryUpstreamInfoStore { + accounts: Arc, + records: Mutex>, + claims: Mutex>, + snapshot_reads: std::sync::atomic::AtomicUsize, + largest_snapshot_read: std::sync::atomic::AtomicUsize, +} + +impl MemoryUpstreamInfoStore { + pub(super) fn new(accounts: Arc) -> Self { + Self { + accounts, + records: Mutex::new(BTreeMap::new()), + claims: Mutex::new(BTreeMap::new()), + snapshot_reads: std::sync::atomic::AtomicUsize::new(0), + largest_snapshot_read: std::sync::atomic::AtomicUsize::new(0), + } + } + + async fn current(&self, connection: &UpstreamConnection) -> Result<(), StoreError> { + let account = self + .accounts + .get_account(&connection.account_id) + .await? + .ok_or_else(|| StoreError::new(StoreErrorKind::InvalidData))?; + if UpstreamConnection::for_account(&account) != *connection { + return Err(StoreError::new(StoreErrorKind::Conflict)); + } + Ok(()) + } + + fn due_now(&self, id: &ProviderAccountId) { + self.records + .lock() + .unwrap() + .get_mut(id) + .unwrap() + .plan + .next_attempt_at = SystemTime::now() - Duration::from_secs(1); + } +} + +#[async_trait::async_trait] +impl UpstreamInfoStore for MemoryUpstreamInfoStore { + async fn list_candidates( + &self, + provider: &ProviderKind, + authentication_kinds: &[&str], + after: Option<&ProviderAccountId>, + limit: u32, + ) -> Result, StoreError> { + let accounts = self.accounts.list_for_provider(provider).await?; + let mut result = Vec::new(); + for account in accounts + .into_iter() + .filter(|account| { + authentication_kinds.contains(&account.authentication_kind()) + && after.is_none_or(|after| account.id() > after) + }) + .take(limit as usize) + { + result.push(self.accounts.load_current_credential(account.id()).await?); + } + Ok(result) + } + + async fn reconcile( + &self, + connection: &UpstreamConnection, + enabled: bool, + now: SystemTime, + ) -> Result<(), StoreError> { + self.current(connection).await?; + let mut claims = self.claims.lock().unwrap(); + let mut records = self.records.lock().unwrap(); + if !enabled { + claims.remove(&connection.account_id); + records.remove(&connection.account_id); + return Ok(()); + } + if records + .get(&connection.account_id) + .is_none_or(|record| record.plan.connection != *connection) + { + records.insert( + connection.account_id.clone(), + UpstreamInfoRecord { + plan: UpstreamInfoPlan { + connection: connection.clone(), + request_generation: 1, + completed_generation: 0, + next_attempt_at: now, + claimed_until: None, + last_attempt_at: None, + last_success_at: None, + last_error: None, + consecutive_failures: 0, + }, + snapshot: None, + }, + ); + } + Ok(()) + } + + async fn request_refresh( + &self, + connection: &UpstreamConnection, + now: SystemTime, + minimum_interval: Duration, + ) -> Result { + self.current(connection).await?; + let mut records = self.records.lock().unwrap(); + let record = records + .get_mut(&connection.account_id) + .ok_or_else(|| StoreError::new(StoreErrorKind::InvalidData))?; + if record.plan.request_generation == record.plan.completed_generation { + record.plan.request_generation += 1; + let earliest = record + .plan + .last_attempt_at + .map_or(now, |time| time + minimum_interval) + .max(now); + record.plan.next_attempt_at = if record.plan.last_error.is_some() { + record.plan.next_attempt_at.max(earliest) + } else { + earliest + }; + } + Ok(record.plan.clone()) + } + + async fn read_plans( + &self, + accounts: &[ProviderAccountId], + ) -> Result, StoreError> { + let records = self.records.lock().unwrap(); + Ok(accounts + .iter() + .filter_map(|id| records.get(id).map(|record| record.plan.clone())) + .collect()) + } + + async fn read( + &self, + accounts: &[ProviderAccountId], + ) -> Result, StoreError> { + self.snapshot_reads + .fetch_add(accounts.len(), std::sync::atomic::Ordering::Relaxed); + self.largest_snapshot_read + .fetch_max(accounts.len(), std::sync::atomic::Ordering::Relaxed); + let records = self.records.lock().unwrap(); + Ok(accounts + .iter() + .filter_map(|id| records.get(id).cloned()) + .collect()) + } + + async fn claim_due( + &self, + _: &ProviderKind, + now: SystemTime, + limit: u32, + lease_duration: Duration, + ) -> Result, StoreError> { + let mut claims = self.claims.lock().unwrap(); + let mut records = self.records.lock().unwrap(); + Ok(records + .values_mut() + .filter(|record| { + record.plan.next_attempt_at <= now + && record.plan.claimed_until.is_none_or(|expiry| expiry <= now) + }) + .take(limit as usize) + .map(|record| { + if record.plan.request_generation == record.plan.completed_generation + || record.plan.claimed_until.is_some() + { + record.plan.request_generation += 1; + } + record.plan.claimed_until = Some(now + lease_duration); + record.plan.last_attempt_at = Some(now); + let claim_token = uuid::Uuid::new_v4(); + claims.insert(record.plan.connection.account_id.clone(), claim_token); + UpstreamInfoClaim { + plan: record.plan.clone(), + claim_token, + } + }) + .collect()) + } + + async fn complete(&self, completion: UpstreamInfoCompletion) -> Result { + if self.current(&completion.connection).await.is_err() { + return Ok(false); + } + let mut claims = self.claims.lock().unwrap(); + let mut records = self.records.lock().unwrap(); + let Some(record) = records.get_mut(&completion.connection.account_id) else { + return Ok(false); + }; + if claims.get(&completion.connection.account_id) != Some(&completion.claim_token) + || record + .plan + .claimed_until + .is_none_or(|expiry| expiry <= completion.completed_at) + || record.plan.connection != completion.connection + || record.plan.request_generation != completion.request_generation + { + return Ok(false); + } + claims.remove(&completion.connection.account_id); + record.plan.completed_generation = completion.request_generation; + record.plan.claimed_until = None; + record.plan.next_attempt_at = completion.next_attempt_at; + match completion.result { + Ok(snapshot) => { + record.snapshot = Some(snapshot); + record.plan.last_success_at = Some(completion.completed_at); + record.plan.last_error = None; + record.plan.consecutive_failures = 0; + } + Err(error) => { + record.plan.last_error = Some(error); + record.plan.consecutive_failures += 1; + if error == UpstreamInfoFailure::Unauthorized { + record.snapshot = None; + } + } + } + Ok(true) + } +} + +fn peer_document() -> Value { + let now = Utc::now(); + json!({ + "object":"cpr.peer", "schema_version":1, + "node":{"id":"synthetic-peer","name":"测试上游","software":"cpr"}, + "observed_at":now.to_rfc3339(), "expires_at":(now + chrono::Duration::minutes(5)).to_rfc3339(), + "capabilities":["key_usage", "pool_summary"], + "usage":{"status":"available","observed_at":now.to_rfc3339(),"data":{"unit":"USD","daily":{"total":"10","used":"2","remaining":"8","resetsAt":(now + chrono::Duration::days(1)).to_rfc3339()},"weekly":{"total":"50","used":"5","remaining":"45","resetsAt":(now + chrono::Duration::days(7)).to_rfc3339()}}}, + "pool":{"disclosure":"summary","account_count":2,"states":{"ready":2,"cooldown":0,"exhausted":0,"disabled":0,"error":0,"unknown":0},"plans":[{"name":"plus","count":2}]}, + "quota":{"disclosure":"none"},"models":[] + }) +} + +async fn fixture( + server: &MockServer, +) -> ( + PeerFixture, + Arc, + Arc, + ProviderAccountId, + TestOpenAiConfig, +) { + let accounts = Arc::new(MemoryAccountStore::default()); + accounts + .seed_api_key( + "acct_peer", + format!("{}/custom/v1", server.uri()), + ResponsesTransport::Http, + ) + .await; + let id = ProviderAccountId::new("acct_peer").unwrap(); + let upstream = Arc::new(MemoryUpstreamInfoStore::new(accounts.clone())); + let config = valid_config(); + let bundle = peer_fixture(config.config.clone(), accounts.clone(), upstream.clone()).await; + (bundle, accounts, upstream, id, config) +} + +fn worker(bundle: &mut PeerFixture) -> gateway_core::task::WorkerRegistration { + bundle + .take_worker_contributions() + .into_iter() + .find_map(|contribution| match contribution { + WorkerContribution::Registration(registration) + if registration.id.owner() == "peer-info" => + { + Some(registration) + } + _ => None, + }) + .unwrap() +} + +async fn cycle(worker: &gateway_core::task::WorkerRegistration) { + let WorkerRunnable::Scheduled { task, .. } = &worker.runnable else { + panic!("scheduled") + }; + task.run_cycle(WorkerCycleContext::new( + worker.id.clone(), + None, + CancellationToken::new(), + )) + .await + .unwrap(); +} + +async fn quota( + bundle: &PeerFixture, + id: &ProviderAccountId, +) -> gateway_admin::model::provider_credentials::ProviderQuota { + match bundle.peer.service().read_quota(id).await.unwrap() { + Some(quota) => quota, + None => bundle + .provider + .admin_provider() + .quota(ProviderQuotaRequest { + account_id: id.clone(), + refresh: false, + rolling_usage: None, + }) + .await + .unwrap(), + } +} + +#[tokio::test] +async fn key_usage_retains_unlimited_windows_without_pool_disclosure() { + for weekly_total in [None, Some("50")] { + let server = MockServer::start().await; + let mut document = peer_document(); + document["capabilities"] = json!(["key_usage"]); + document["pool"] = json!({"disclosure":"none"}); + document["usage"]["data"]["daily"] = + json!({"total":null,"used":"0","remaining":null,"resetsAt":null}); + document["usage"]["data"]["weekly"]["total"] = json!(weekly_total); + document["usage"]["data"]["weekly"]["remaining"] = json!(weekly_total.map(|_| "45")); + Mock::given(method("GET")) + .and(path("/custom/v1/cpr/link")) + .respond_with(ResponseTemplate::new(200).set_body_json(document)) + .expect(1) + .mount(&server) + .await; + let (mut bundle, _, _, id, _config) = fixture(&server).await; + cycle(&worker(&mut bundle)).await; + let observed = quota(&bundle, &id).await; + assert!(observed.upstream.unwrap().pool.is_none()); + assert_eq!(observed.windows.len(), 2); + let daily = &observed.windows[0]; + assert_eq!(daily.key, "peer:budget:daily"); + assert_eq!(daily.used_percent, None); + assert!(!daily.limit_reached); + let amount = daily.upstream.as_ref().unwrap().amount.as_ref().unwrap(); + assert_eq!(amount.used, "0"); + assert!(amount.total.is_none()); + assert!(amount.remaining.is_none()); + let weekly = &observed.windows[1]; + assert_eq!(weekly.key, "peer:budget:weekly"); + assert_eq!(weekly.used_percent, weekly_total.map(|_| 10.0)); + let amount = weekly.upstream.as_ref().unwrap().amount.as_ref().unwrap(); + assert_eq!(amount.used, "5"); + assert_eq!(amount.total.as_deref(), weekly_total); + let summary = bundle + .peer + .service() + .read_summary(&id) + .await + .unwrap() + .unwrap(); + assert_eq!(summary.windows, observed.windows); + } +} + +#[tokio::test] +async fn refresh_and_reads_are_local_and_worker_owns_the_upstream_request() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/custom/v1/cpr/link")) + .and(wiremock::matchers::header( + "authorization", + "Bearer sk-api-test-only", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(peer_document())) + .expect(1) + .mount(&server) + .await; + let (mut bundle, _, _, id, _config) = fixture(&server).await; + let admin = bundle.admin_provider(); + admin.account_changed(&id).await.unwrap(); + let first = admin.request_refresh(&id).await.unwrap(); + let second = admin.request_refresh(&id).await.unwrap(); + assert_eq!(first.request_generation, second.request_generation); + assert!(quota(&bundle, &id).await.upstream.unwrap().node.is_none()); + assert!(server.received_requests().await.unwrap().is_empty()); + cycle(&worker(&mut bundle)).await; + let observed = quota(&bundle, &id).await; + assert_eq!(observed.upstream.unwrap().node.unwrap().name, "测试上游"); + assert_eq!(observed.windows[0].used_percent, Some(20.0)); + assert_eq!( + observed.windows[0].local_usage_attribution, + QuotaLocalUsageAttribution::AccountWide + ); + assert!(observed.windows[0].local_usage.is_none()); + assert_eq!( + observed.windows[0] + .upstream + .as_ref() + .unwrap() + .amount + .as_ref() + .unwrap() + .remaining + .as_deref(), + Some("8") + ); + assert_eq!(server.received_requests().await.unwrap().len(), 1); +} + +#[tokio::test] +async fn native_windows_preserve_account_and_bucket_identity_without_pool_averaging() { + let server = MockServer::start().await; + let mut document = peer_document(); + document["capabilities"] = serde_json::json!(["quota_windows"]); + let observed_at = document["observed_at"].clone(); + let expires_at = document["expires_at"].clone(); + let window = |bucket: &str, percent: f64| { + serde_json::json!({ + "key": "weekly", "group": "model", "label": "周额度", + "limit_id": bucket, "limit_name": bucket, "used_percent": percent, + "window_seconds": 604800, "limit_reached": false + }) + }; + document["quota"] = serde_json::json!({ + "disclosure": "accounts", + "accounts": [ + { "id": "first", "models": ["gpt-5.4"], "observed_at": observed_at, + "expires_at": expires_at, "windows": [window("Codex", 10.0), window("Other", 30.0)] }, + { "id": "second", "models": ["gpt-5.4"], "observed_at": observed_at, + "expires_at": expires_at, "windows": [window("Codex", 90.0)] } + ] + }); + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_json(document)) + .mount(&server) + .await; + let (mut bundle, _, _, id, _) = fixture(&server).await; + cycle(&worker(&mut bundle)).await; + let observed = quota(&bundle, &id).await; + assert_eq!(observed.windows.len(), 3); + assert_eq!( + observed + .windows + .iter() + .map(|window| window + .upstream + .as_ref() + .and_then(|upstream| upstream.account_id.as_deref())) + .collect::>(), + vec![Some("first"), Some("first"), Some("second")] + ); + assert_eq!( + observed + .windows + .iter() + .map(|window| window.used_percent) + .collect::>(), + vec![Some(10.0), Some(30.0), Some(90.0)] + ); + let scopes: std::collections::BTreeSet<_> = observed + .windows + .iter() + .filter_map(|window| window.limit_id.as_deref()) + .collect(); + assert_eq!(scopes.len(), 3); + assert!( + observed + .windows + .iter() + .all(|window| window.local_usage.is_none() + && window.upstream.as_ref().unwrap().amount.is_none()) + ); +} + +#[tokio::test] +async fn transient_failure_preserves_observation_and_revocation_erases_it() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_json(peer_document())) + .mount(&server) + .await; + let (mut bundle, _, store, id, _config) = fixture(&server).await; + let worker = worker(&mut bundle); + cycle(&worker).await; + let observed_at = quota(&bundle, &id).await.observed_at; + server.reset().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + store.due_now(&id); + cycle(&worker).await; + let failed = quota(&bundle, &id).await; + assert_eq!(failed.observed_at, observed_at); + assert_eq!( + failed.upstream.unwrap().last_error.as_deref(), + Some("unavailable") + ); + assert_eq!(failed.windows.len(), 2); + let receipt = bundle.admin_provider().request_refresh(&id).await.unwrap(); + assert!(receipt.request_generation > 0); + assert!( + store.read(std::slice::from_ref(&id)).await.unwrap()[0] + .plan + .next_attempt_at + > SystemTime::now() + ); + server.reset().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(403)) + .mount(&server) + .await; + store.due_now(&id); + cycle(&worker).await; + let revoked = quota(&bundle, &id).await; + assert!(revoked.windows.is_empty()); + assert!(revoked.upstream.unwrap().node.is_none()); +} + +async fn wait_for_request(server: &MockServer) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if !server.received_requests().await.unwrap().is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn disabling_during_an_in_flight_request_fences_the_late_observation() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_millis(200)) + .set_body_json(peer_document()), + ) + .mount(&server) + .await; + let (mut bundle, accounts, store, id, _config) = fixture(&server).await; + let worker = Arc::new(worker(&mut bundle)); + let running = tokio::spawn({ + let worker = worker.clone(); + async move { + cycle(&worker).await; + } + }); + wait_for_request(&server).await; + accounts.set_enabled(&id, false).await.unwrap(); + bundle.admin_provider().account_changed(&id).await.unwrap(); + running.await.unwrap(); + assert!( + store + .read(std::slice::from_ref(&id)) + .await + .unwrap() + .is_empty() + ); + assert!(quota(&bundle, &id).await.upstream.is_none()); +} + +#[tokio::test] +async fn cancellation_leaves_the_persisted_claim_for_restart_recovery() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_secs(2)) + .set_body_json(peer_document()), + ) + .mount(&server) + .await; + let (mut bundle, _, store, id, _config) = fixture(&server).await; + let worker = worker(&mut bundle); + let cancellation = CancellationToken::new(); + let context = WorkerCycleContext::new(worker.id.clone(), None, cancellation.clone()); + let running = tokio::spawn(async move { + let WorkerRunnable::Scheduled { task, .. } = worker.runnable else { + panic!("scheduled") + }; + task.run_cycle(context).await.unwrap(); + }); + wait_for_request(&server).await; + cancellation.cancel(); + tokio::time::timeout(Duration::from_millis(100), running) + .await + .unwrap() + .unwrap(); + let record = store + .read(std::slice::from_ref(&id)) + .await + .unwrap() + .remove(0); + assert!(record.plan.claimed_until.is_some()); + assert_eq!(record.plan.completed_generation, 0); + assert!(record.snapshot.is_none()); +} + +#[tokio::test] +async fn invalid_or_redirected_responses_never_replace_the_last_valid_snapshot() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_json(peer_document())) + .mount(&server) + .await; + let (mut bundle, _, store, id, _config) = fixture(&server).await; + let worker = worker(&mut bundle); + cycle(&worker).await; + let observed_at = quota(&bundle, &id).await.observed_at; + let mut wrong_schema = peer_document(); + wrong_schema["schema_version"] = json!(2); + let mut wrong_object = peer_document(); + wrong_object["object"] = json!("other.peer"); + let mut negative = peer_document(); + negative["usage"]["data"]["daily"]["used"] = json!("-20"); + let mut future = peer_document(); + future["observed_at"] = json!((Utc::now() + chrono::Duration::minutes(10)).to_rfc3339()); + let mut huge_pool = peer_document(); + huge_pool["pool"]["account_count"] = json!(100_000_000); + huge_pool["pool"]["states"]["ready"] = json!(100_000_000); + let mut conflicting_states = peer_document(); + conflicting_states["pool"]["states"]["ready"] = json!(3); + let mut conflicting_plans = peer_document(); + conflicting_plans["pool"]["plans"] = + json!([{"name":"Pro","count":2},{"name":"Plus","count":2}]); + let responses = [ + ResponseTemplate::new(200).set_body_json(huge_pool), + ResponseTemplate::new(200).set_body_json(conflicting_states), + ResponseTemplate::new(200).set_body_json(conflicting_plans), + ResponseTemplate::new(200).set_body_json(wrong_schema), + ResponseTemplate::new(200).set_body_json(wrong_object), + ResponseTemplate::new(200).set_body_json(negative), + ResponseTemplate::new(200).set_body_json(future), + ResponseTemplate::new(200).set_body_raw( + "x".repeat(gateway_protocol::peer::MAX_RESPONSE_BYTES + 1), + "application/json", + ), + ResponseTemplate::new(302) + .insert_header("location", format!("{}/never-follow", server.uri())), + ]; + for response in responses { + server.reset().await; + Mock::given(method("GET")) + .and(path("/custom/v1/cpr/link")) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + Mock::given(path("/never-follow")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + store.due_now(&id); + cycle(&worker).await; + let quota = quota(&bundle, &id).await; + assert_eq!(quota.observed_at, observed_at); + assert_eq!(quota.windows.len(), 2); + assert!(quota.upstream.unwrap().last_error.is_some()); + server.verify().await; + } +} + +#[tokio::test] +async fn smart_prefers_another_account_when_key_budget_is_exhausted_without_pool_disclosure() { + let server = MockServer::start().await; + let mut peer = peer_document(); + peer["capabilities"] = json!(["key_usage"]); + peer["pool"] = json!({"disclosure":"none"}); + peer["usage"]["data"]["daily"]["used"] = json!("10.0000"); + peer["usage"]["data"]["daily"]["remaining"] = json!("0.0000"); + Mock::given(method("GET")) + .and(path("/custom/v1/cpr/link")) + .respond_with(ResponseTemplate::new(200).set_body_json(peer)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/available/v1/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(COMPLETED_SESSION_SSE), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/custom/v1/responses")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&server) + .await; + let (mut bundle, accounts, _, _, _config) = fixture(&server).await; + accounts + .seed_api_key( + "acct_available", + format!("{}/available/v1", server.uri()), + ResponsesTransport::Http, + ) + .await; + cycle(&worker(&mut bundle)).await; + let provider = ProviderKind::new("openai").unwrap(); + let model = UpstreamModelId::new("gpt-5.4").unwrap(); + let scope = Arc::new(FrozenAccountScope::new( + Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([ + ( + ProviderAccountId::new("acct_peer").unwrap(), + RuntimeAccount::new(provider.clone(), BTreeSet::new()), + ), + ( + ProviderAccountId::new("acct_available").unwrap(), + RuntimeAccount::new(provider.clone(), BTreeSet::new()), + ), + ]))), + ClientRoutingScope::all_accounts(), + )); + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + json!({"model":"gpt-5.4", "input":"budget routing"}) + .as_object() + .unwrap() + .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, + model, + ModelCapabilities::new(BTreeSet::from([operation.kind()]), Some(32_000)) + .with_upstream_feature_validation(), + )], + Vec::new(), + ) + .unwrap(); + let plan = snapshot + .plan( + &PublicModelId::new("gpt-5.4").unwrap(), + &operation, + scope.clone(), + &RoutingContext::default(), + ) + .unwrap(); + let context = AttemptContext::new( + RequestAttemptContext::new( + ModelRequestId::new("req_peer_smart").unwrap(), + ClientApiKeyId::new("key_peer_smart").unwrap(), + ), + NonZeroU32::new(1).unwrap(), + SystemTime::now() + Duration::from_secs(5), + account_policy(), + AccountAttemptContext::new(BTreeSet::new(), None, None).with_account_scope(scope), + None, + CancellationToken::new(), + ); + let mut response = bundle + .core_provider() + .execute( + ProviderRequest::new(operation, plan.candidates()[0].clone()), + context, + ) + .await + .unwrap(); + assert_eq!( + response.metadata().provider_account_id().as_str(), + "acct_available" + ); + while let Some(event) = response.next().await { + event.unwrap(); + } + server.verify().await; +} + +#[tokio::test] +async fn lists_reuse_versioned_summaries_and_details_keep_node_windows() { + let server = MockServer::start().await; + let mut document = peer_document(); + document["capabilities"] = json!(["key_usage", "pool_summary", "quota_windows"]); + document["quota"] = json!({"disclosure":"accounts","accounts":[{ + "id":"node-one", "models":["gpt-5.4"], + "observed_at":document["observed_at"], "expires_at":document["expires_at"], + "windows":[{"key":"weekly","group":"general","label":"周额度","used_percent":40,"limit_reached":false}] + }]}); + Mock::given(method("GET")) + .and(path("/custom/v1/cpr/link")) + .respond_with(ResponseTemplate::new(200).set_body_json(document)) + .expect(1) + .mount(&server) + .await; + let (mut bundle, accounts, store, id, config) = fixture(&server).await; + let worker = worker(&mut bundle); + cycle(&worker).await; + let account = account_record(&accounts.account(id.as_str()).unwrap()); + let requests = [ProviderQuotaReadRequest { + account: &account, + rolling_usage: None, + }]; + let read_count = store + .snapshot_reads + .load(std::sync::atomic::Ordering::Relaxed); + for _ in 0..3 { + let summary = bundle + .admin_provider() + .read_quotas(&requests) + .await + .pop() + .unwrap() + .unwrap(); + assert_eq!(summary.windows.len(), 2); + assert!( + summary.windows.iter().all(|window| window + .upstream + .as_ref() + .unwrap() + .account_id + .is_none()) + ); + assert!(summary.upstream.as_ref().unwrap().discovered); + assert_eq!(summary.upstream.unwrap().pool.unwrap().account_count, 2); + let single = bundle + .admin_provider() + .read_summary(&id) + .await + .unwrap() + .unwrap(); + assert_eq!(single.windows.len(), 2); + assert!(single.upstream.as_ref().unwrap().discovered); + cycle(&worker).await; + } + assert_eq!( + store + .snapshot_reads + .load(std::sync::atomic::Ordering::Relaxed), + read_count + ); + let detail = quota(&bundle, &id).await; + assert_eq!(detail.windows.len(), 3); + assert_eq!( + detail.windows[2] + .upstream + .as_ref() + .unwrap() + .account_id + .as_deref(), + Some("node-one") + ); + + let restored = peer_fixture(config.config.clone(), accounts, store.clone()).await; + let summary = restored + .admin_provider() + .read_quotas(&requests) + .await + .pop() + .unwrap() + .unwrap(); + assert_eq!(summary.windows.len(), 2); + let read_count = store + .snapshot_reads + .load(std::sync::atomic::Ordering::Relaxed); + { + let mut records = store.records.lock().unwrap(); + let record = records.get_mut(&id).unwrap(); + record.plan.completed_generation += 1; + let mut snapshot = record + .snapshot + .as_ref() + .unwrap() + .expose_to_provider() + .clone(); + snapshot.get_mut("node").unwrap()["name"] = json!("更新后的节点"); + record.snapshot = Some(OpaqueProviderData::new(snapshot)); + } + let summary = restored + .admin_provider() + .read_quotas(&requests) + .await + .pop() + .unwrap() + .unwrap(); + assert_eq!(summary.upstream.unwrap().node.unwrap().name, "更新后的节点"); + assert_eq!( + store + .snapshot_reads + .load(std::sync::atomic::Ordering::Relaxed), + read_count + 1 + ); + { + let mut records = store.records.lock().unwrap(); + let record = records.get_mut(&id).unwrap(); + record.plan.last_error = Some(UpstreamInfoFailure::Unauthorized); + record.snapshot = None; + } + let summary = restored + .admin_provider() + .read_quotas(&requests) + .await + .pop() + .unwrap() + .unwrap(); + assert!(summary.windows.is_empty()); + let info = summary.upstream.unwrap(); + assert!(info.discovered); + assert!(info.node.is_none()); + assert_eq!( + store + .snapshot_reads + .load(std::sync::atomic::Ordering::Relaxed), + read_count + 1 + ); +} + +#[tokio::test] +async fn ordinary_api_upstreams_remain_undiscovered_and_keep_the_detection_backoff() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/custom/v1/cpr/link")) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&server) + .await; + let (mut bundle, _, store, id, _config) = fixture(&server).await; + let worker = worker(&mut bundle); + cycle(&worker).await; + cycle(&worker).await; + let info = quota(&bundle, &id).await.upstream.unwrap(); + assert!(!info.discovered); + assert!(info.node.is_none()); + assert_eq!(info.last_error.as_deref(), Some("unsupported")); + let plan = store + .read_plans(std::slice::from_ref(&id)) + .await + .unwrap() + .pop() + .unwrap(); + assert!(plan.next_attempt_at > SystemTime::now() + Duration::from_secs(29 * 60)); +} + +#[tokio::test] +async fn cold_lists_bound_full_snapshot_batches_and_reuse_every_hydrated_summary() { + let server = MockServer::start().await; + let (bundle, accounts, store, first_id, _config) = fixture(&server).await; + let mut records = Vec::new(); + for index in 0..20 { + let id = if index == 0 { + first_id.clone() + } else { + let id = format!("acct_peer_{index}"); + accounts + .seed_api_key( + &id, + format!("{}/custom/v1", server.uri()), + ResponsesTransport::Http, + ) + .await; + ProviderAccountId::new(id).unwrap() + }; + let account = accounts.account(id.as_str()).unwrap(); + let connection = UpstreamConnection::for_account(&account); + let now = SystemTime::now(); + store.reconcile(&connection, true, now).await.unwrap(); + let mut document = peer_document(); + document["node"]["name"] = json!(format!("peer-{index}")); + { + let mut persisted = store.records.lock().unwrap(); + let record = persisted.get_mut(&id).unwrap(); + record.plan.completed_generation = record.plan.request_generation; + record.plan.last_success_at = Some(now); + record.snapshot = Some(OpaqueProviderData::new( + document.as_object().unwrap().clone(), + )); + } + records.push(account_record(&account)); + } + let requests = records + .iter() + .map(|account| ProviderQuotaReadRequest { + account, + rolling_usage: None, + }) + .collect::>(); + for _ in 0..3 { + let quotas = bundle.admin_provider().read_quotas(&requests).await; + assert_eq!(quotas.len(), records.len()); + for (index, quota) in quotas.into_iter().enumerate() { + let quota = quota.unwrap(); + assert_eq!(quota.windows.len(), 2); + assert_eq!( + quota.upstream.unwrap().node.unwrap().name, + format!("peer-{index}") + ); + } + assert_eq!( + store + .snapshot_reads + .load(std::sync::atomic::Ordering::Relaxed), + records.len() + ); + } + assert!( + store + .largest_snapshot_read + .load(std::sync::atomic::Ordering::Relaxed) + <= 2 + ); + + { + let mut persisted = store.records.lock().unwrap(); + for account in [&records[0], records.last().unwrap()] { + let record = persisted + .get_mut(&ProviderAccountId::new(account.id.clone()).unwrap()) + .unwrap(); + record.plan.request_generation += 1; + record.plan.completed_generation = record.plan.request_generation; + } + } + let quotas = bundle.admin_provider().read_quotas(&requests).await; + assert!(quotas.into_iter().all(|quota| quota.is_ok())); + assert_eq!( + store + .snapshot_reads + .load(std::sync::atomic::Ordering::Relaxed), + records.len() + 2 + ); +} + +struct PeerFixture { + provider: provider_openai::ProviderBundle, + peer: gateway_admin::PeerUpstreamBundle, +} + +impl PeerFixture { + fn admin_provider(&self) -> Arc { + self.peer.service() + } + + fn core_provider(&self) -> Arc { + self.provider.core_provider() + } + + fn take_worker_contributions(&mut self) -> Vec { + self.peer.take_worker_contributions() + } +} + +async fn peer_fixture( + config: provider_openai::OpenAiConfig, + accounts: Arc, + store: Arc, +) -> PeerFixture { + let reader = Arc::new(gateway_core::account::UpstreamInfoReader::default()); + let provider = provider_openai::initialize( + config, + provider_ports_with_catalog( + accounts.clone(), + Arc::new(TestOAuthPending::default()), + Arc::new(TestCatalogCache::default()), + ), + reader.clone(), + ) + .await + .unwrap(); + let registry = + gateway_admin::ports::provider::ProviderAdminRegistry::new([provider.admin_provider()]) + .unwrap(); + let peer = gateway_admin::initialize_peer_upstream( + accounts.clone(), + store, + ®istry, + Arc::new(gateway_host::peer::HttpPeerTransport::with_client_builder( + provider_openai::build_reqwest_client_with_custom_ca, + )), + ) + .unwrap(); + reader.bind(&peer.scheduling_source()).unwrap(); + PeerFixture { provider, peer } +} diff --git a/backend/crates/providers/openai/tests/credential/contract/mod.rs b/backend/crates/providers/openai/tests/credential/contract/mod.rs index 1f849974e..735f076b2 100644 --- a/backend/crates/providers/openai/tests/credential/contract/mod.rs +++ b/backend/crates/providers/openai/tests/credential/contract/mod.rs @@ -324,6 +324,40 @@ fn codec_persists_tokens_as_plaintext_provider_json() { ); } +#[test] +fn api_key_codec_reads_persisted_settings_without_restoring_manual_discovery() { + use gateway_core::account::PlaintextCredential; + + for enabled in [false, true] { + let value = serde_json::json!({ + "schema_version": 1, + "installation_id": "00000000-0000-4000-8000-000000000001", + "base_url": "https://relay.example/v1", + "api_key": "sk-test-only", + "transport": "http", + "upstream_info_enabled": enabled, + }); + let credential = PlaintextCredential::new(value.as_object().unwrap().clone()); + let runtime = CodexCredentialCodec::decode(&credential).unwrap(); + assert!(matches!( + runtime.authentication, + provider_openai::credential::CodexRuntimeAuthentication::ApiKey(_) + )); + let decoded = CodexCredentialCodec::decode_complete(&credential).unwrap(); + let encoded = CodexCredentialCodec::encode_complete(decoded).unwrap(); + assert!( + !encoded + .expose_to_provider() + .contains_key("upstream_info_enabled") + ); + let mut invalid = encoded.expose_to_provider().clone(); + invalid.insert("unknown_field".into(), serde_json::json!(true)); + let invalid = PlaintextCredential::new(invalid); + assert!(CodexCredentialCodec::decode(&invalid).is_err()); + assert!(CodexCredentialCodec::decode_complete(&invalid).is_err()); + } +} + #[test] fn codec_reimport_preserves_existing_installation_id_for_the_same_principal() { let existing = CodexCredentialCodec::encode_new( diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index 98dfadea3..81f2c8ef7 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -321,6 +321,16 @@ impl XaiAdminProvider { #[async_trait] impl ProviderAdmin for XaiAdminProvider { + fn peer_model_eligible(&self, account: &AccountRecord, _model: &str) -> Option { + Some(crate::credential::is_direct_account_kind( + &account.authentication_kind, + )) + } + + fn is_direct_account(&self, authentication_kind: &str) -> bool { + crate::credential::is_direct_account_kind(authentication_kind) + } + fn account_capabilities( &self, _account_id: &ProviderAccountId, @@ -1184,6 +1194,7 @@ fn project_quota( ) -> ProviderQuota { let Some(snapshot) = snapshot else { return ProviderQuota { + upstream: None, credits: None, plan_type: None, observed_at: None, @@ -1221,6 +1232,7 @@ fn project_quota( ); } ProviderQuotaWindow { + upstream: None, key: billing.period_type().unwrap_or("billing").to_owned(), group: quota_group(period_kind).to_owned(), label: xai_quota_window_label(period_kind, window_seconds), @@ -1239,6 +1251,7 @@ fn project_quota( } } else { ProviderQuotaWindow { + upstream: None, key: "free-rolling-24h".to_owned(), group: "shortTerm".to_owned(), label: "日限额".to_owned(), @@ -1255,6 +1268,7 @@ fn project_quota( } }; ProviderQuota { + upstream: None, credits: None, plan_type: billing.plan_type().map(str::to_owned), observed_at: Some(snapshot.observed_at()), diff --git a/backend/crates/providers/xai/src/credential/mod.rs b/backend/crates/providers/xai/src/credential/mod.rs index 7c1580334..738b4ca61 100644 --- a/backend/crates/providers/xai/src/credential/mod.rs +++ b/backend/crates/providers/xai/src/credential/mod.rs @@ -74,3 +74,8 @@ pub use verification::{ FailClosedTokenVerifier, TokenCandidate, TokenVerificationContext, TokenVerifier, VerificationEvidence, VerificationFlow, VerificationFuture, VerificationMethod, }; + +/// 直连资格由 Provider 拥有,执行快照与只读导出使用同一判断 +pub(crate) fn is_direct_account_kind(authentication_kind: &str) -> bool { + authentication_kind == XAI_AUTHENTICATION_KIND_OAUTH +} diff --git a/backend/crates/providers/xai/src/provider/mod.rs b/backend/crates/providers/xai/src/provider/mod.rs index 083567ba0..546a7f001 100644 --- a/backend/crates/providers/xai/src/provider/mod.rs +++ b/backend/crates/providers/xai/src/provider/mod.rs @@ -154,6 +154,10 @@ impl GrokBuildProvider { #[async_trait] impl Provider for GrokBuildProvider { + fn is_direct_account(&self, authentication_kind: &str) -> bool { + crate::credential::is_direct_account_kind(authentication_kind) + } + fn resolve_request_profile( &self, configuration: &gateway_core::account::OpaqueProviderData, diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index 472a2b7ff..6a421c813 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -32,3 +32,4 @@ b49832e205405de7ef078f3b65c0c19f2c030b8984d42411598738b6b6c146bf 0031_proxy_and a0e409402611dbc76bb71393b8eda7c717756c18974dbabca305fdcb4b115f31 0032_usage_and_scheduling.sql 592b80972af3bd8b3527b42da3703ca1106ecaf8fd5273c676189ca28fcbd067 0033_proxy_and_reset_credits.sql 9c15a6b6c1f9eab8ad478772f98f8fc4a997b825d58d12cffe1a15579c5785b3 0034_account_error_protection.sql +fafc5dd7c9e02227bf615279d47f9983e9d8e843f021dc74cdb6d4565a1984da 0035_cpr_link.sql diff --git a/backend/migrations/0035_cpr_link.sql b/backend/migrations/0035_cpr_link.sql new file mode 100644 index 000000000..08d23a4d4 --- /dev/null +++ b/backend/migrations/0035_cpr_link.sql @@ -0,0 +1,32 @@ +-- 互联公开权限与本节点身份 +alter table client_api_keys + add column peer_sharing_json jsonb not null + default '{"enabled":false,"sharePool":false,"shareQuota":false,"sharePlans":false}'; + +create table peer_node ( + singleton boolean primary key default true check (singleton), + id uuid not null default gen_random_uuid(), + name text not null default 'CPR 节点' +); +insert into peer_node (singleton) values (true); + +-- 每个连接仅保留当前观测与可恢复计划,删除账号时一并清理 +create table provider_account_upstream_info ( + account_id text primary key references provider_accounts(id) on delete cascade, + credential_revision bigint not null check (credential_revision > 0), + connection_revision text not null check (connection_revision ~ '^[a-f0-9]{64}$'), + request_generation bigint not null default 1 check (request_generation > 0), + completed_generation bigint not null default 0 check (completed_generation >= 0), + next_attempt_at timestamptz not null, + claimed_until timestamptz, + claim_token uuid, + last_attempt_at timestamptz, + last_success_at timestamptz, + last_error text check (last_error in ('unauthorized', 'unsupported', 'rate_limited', 'invalid_response', 'unavailable')), + consecutive_failures integer not null default 0 check (consecutive_failures >= 0), + snapshot_json jsonb check (jsonb_typeof(snapshot_json) = 'object'), + constraint upstream_info_generation_ck check (completed_generation <= request_generation), + constraint upstream_info_claim_ck check ((claim_token is null) = (claimed_until is null)) +); +create index provider_account_upstream_info_due_idx + on provider_account_upstream_info (next_attempt_at, account_id); diff --git a/deploy/README.md b/deploy/README.md index 5a5868c19..b4867a6c9 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -237,6 +237,18 @@ PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet` 示例配置均填写 `us`,修改后重启应用生效。身份与位置字段见 [运行设置 API](../docs/api.md#8-运行设置) 后台账号操作使用 Provider 自己的官方画像;发布资料刷新不改写用户选择或 `config.yaml` +### CPR 互联 + +在上游的「API 密钥」中为专用 Key 选择账号分组并开启「CPR 互联」,按需公开账号池、额度和套餐信息。 +共享 Key 的实际推理只使用这些分组中的直连 OAuth 账号;分组内的 API Key 转供账号不会参与 + +在下游导入或编辑 OpenAI API Key 账号,填写上游 API 地址和该 Key,后台自动发现 CPR 互联能力。 +地址包含自定义前缀时保留完整前缀,后台在该地址下请求 `cpr/link`。 +列表与详情显示已保存的观测,手动刷新提交后台任务;旧观测和查询失败分别提示,不要求上游实时可用 + +第三方网关通过插件实现相同协议也可被识别,不按软件名称判定兼容。 +额度观测仅作为 Smart 选择的参考,不能保证下一次请求成功;协议和公开边界见 [CPR 互联 API](../docs/api.md#cpr-互联只读协议) + ### 批量维护代理 在“代理管理 → 新增代理”切换到“批量”,每行粘贴一个完整代理 URL、`host:port` 或 `host:port:用户名:密码`,最多 100 条。 diff --git a/docs/api.md b/docs/api.md index e49db8041..c118c254e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -43,7 +43,7 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全 运行设置可以分别配置 `minCodexDesktopVersion` 与 `minCodexCliVersion`。两者只接受 SemVer,`null` 表示不限制,比较版本时忽略 `+` 后的构建元数据。API 在 Client Key 鉴权成功后识别官方客户端请求头; -适用门禁的客户端没有合法版本,或版本低于对应门槛时,除只读 `/v1/usage` 外的 `/v1/*` HTTP 请求和新 WebSocket +适用门禁的客户端没有合法版本,或版本低于对应门槛时,除只读 `/v1/usage`、`/v1/cpr/link` 外的 `/v1/*` HTTP 请求和新 WebSocket 握手在访问上游前返回 `400 Bad Request`,按不可重试的请求错误处理。 未知客户端保持兼容,不应用版本门禁 @@ -223,6 +223,7 @@ WebSocket message 和 frame 不设置网关私有长度上限;请求字段与 | `POST` | `/v1/providers/{provider}/models/{model}/count_tokens` | 调用指定 Provider/model 声明的精确 Token 计数;请求和响应均为 JSON | | `GET` / `POST` | `/v1/providers/{provider}/http/{endpoint}` | 调用指定 Provider 预先声明的固定 HTTP 操作;不是任意 URL 代理 | | `GET` | `/v1/usage` | 查询当前 Client Key 的日与周额度,仅使用网关已结算的 USD 账本 | +| `GET` | `/v1/cpr/link` | 返回当前 Key 获准公开的 CPR 互联节点、预算及直连账号池观测 | ### Chat Completions 请求 @@ -716,6 +717,89 @@ OpenAI 选号阶段确认本次可选账号全部额度耗尽时,HTTP 返回 ` 缺失、非法、已禁用或已删除的 Key 返回 OpenAI 风格 `401` 错误;未知查询参数返回 `400 invalid_usage_query`, 读取账本失败返回 `503 usage_unavailable`,不会用零余额掩盖故障 +### CPR 互联只读协议 + +`GET {api_base}/cpr/link` 使用同一数据面入口认证;CPR 默认路径为 `/v1/cpr/link`,传入当前下游使用的推理 Key。 +只有该 Key 显式开启 `peerSharing.enabled` 且绑定非空分组时可查询。接口不接受 Cookie、管理 API Key 或查询参数, +不执行推理、占用推理并发/RPM、更新最近使用时间或刷新上游;Key 预算耗尽后仍可查询 + +协议识别依赖 `object: "cpr.peer"`、`schema_version: 1` 和 `capabilities`,不依赖 `node.software`。 +第三方项目或插件可实现同一协议;未知能力可以忽略,不支持的版本应按不兼容处理。 +响应直接返回 JSON,所有状态均带 `Cache-Control: no-store`。节点 ID 在本节点持久化,名称与软件名仅用于展示 + +只公开预算的完整响应形态如下: + +```json +{ + "object": "cpr.peer", + "schema_version": 1, + "node": { "id": "7527ed09-c917-445f-9355-de6501803ccf", "name": "CPR 节点", "software": "codex-proxy-rs" }, + "capabilities": ["key_usage"], + "observed_at": "2026-10-29T12:00:00Z", + "expires_at": "2026-10-29T12:05:00Z", + "usage": { + "status": "available", + "observed_at": "2026-10-29T12:00:00Z", + "data": { + "unit": "USD", + "daily": { "total": "1", "used": "0.6", "remaining": "0.4", "resetsAt": "2026-10-29T16:00:00Z" }, + "weekly": { "total": null, "used": "2", "remaining": null, "resetsAt": null } + } + }, + "pool": { "disclosure": "none" }, + "quota": { "disclosure": "none" } +} +``` + +`usage.data` 沿用 [API Key 额度查询](#api-key-额度查询) 的十进制金额、窗口及不限额语义。 +`usage.status` 可为 `available`、`unavailable` 或 `unknown`;没有可用数据时不把缺失值解释为零余额 + +| 能力 | 数据合同 | 公开条件 | +| --- | --- | --- | +| `key_usage` | `usage` 为当前认证 Key 的本地预算账本 | `enabled` | +| `pool_summary` | `pool.disclosure: "summary"`,含 `observed_at`、`account_count` 与 `states`;状态计数为 `ready`、`cooldown`、`exhausted`、`disabled`、`error`、`unknown` | `sharePool` | +| 套餐分布 | `pool.plans: [{ name, count }]`;未授权时省略,不返回账号与套餐的逐项对应关系 | `sharePlans`,且已公开账号池 | +| `quota_windows` | `quota.disclosure: "accounts"`,`accounts` 保留匿名账号及各自窗口;未授权时只返回 `disclosure: "none"` | `shareQuota` | +| `model_availability` | `models` 按当前 Key 与请求到该节点的公开模型汇总,见下 | `shareQuota` | + +账号额度示例: + +```json +{ + "id": "df306c3718c99d3615352e41d32751d3", + "models": ["gpt-5.4"], + "observed_at": "2026-10-29T11:58:00Z", + "expires_at": "2026-10-29T12:28:00Z", + "windows": [{ "key": "weekly", "group": "shortTerm", "label": "周额度", "role": "secondary", "window_seconds": 604800, "used_percent": 47, "reset_at": "2026-10-31T16:00:00Z", "limit_reached": false }] +} +``` + +匿名 `id` 在同一 Key 下稳定,不能当作本地账号 ID。返回结果不含 Key、邮箱、账号名称、代理或凭据正文。 +窗口的 `key`、`group`、`label`、可选 `limit_id`/`limit_name` 与可选 `role`(`primary`、`secondary`、`monthly`)保留原生展示口径; +`window_seconds`、`used_percent`、`reset_at` 均可缺失。不同账号或不同周期的百分比不能相加, +不能从百分比推算 Token 总额,也不能用本地请求用量填充上游额度 + +模型汇总条目形如 `{ "model": "gpt-5.4", "coverage": "complete", "state": "available", "observed_at": "...", "expires_at": "..." }`。 +`coverage` 为 `complete` 或 `partial`,`state` 为 `available`、`exhausted` 或 `unknown`。 +只有该模型所有候选的资格与额度都明确、有效且全部耗尽,才能给出 `complete + exhausted`。 +它是通用模型额度参考,不承诺 Images、Live 等各操作的额外资格或即时容量;过期、缺失或部分覆盖保持中性, +请求仍需执行当前节点的正常授权与调度 + +顶层 `observed_at` 是此次本地快照组装时间,默认有效期 5 分钟;账号窗口与模型汇总保留各自的原始观测时间, +不因查询或转发而更新。各组件有效期最多为原始观测后 30 分钟,并受重置边界约束;过去的重置时间意味着旧窗口过期, +不意味着额度已经恢复。接入端应按各组件时间判断,不能用新的顶层时间延长旧观测 + +共享范围同时受当前 Key 的公开开关、分组及冻结权限限制,只包含 Provider 明确声明的直连 OAuth 账号。 +互联 Key 的实际推理也使用该直连范围,不把 API Key 转接账号再次作为共享池,协议不递归查询其他节点。 +接口不分页、不截断账号集后声称完整;共享范围最多 3000 个账号、额度窗口总计最多 6000 个、模型最多 2000 个。 +任一数量超限、响应超过 4 MiB 或本地读取失败返回 `503 peer_snapshot_unavailable` + +缺失、非法、禁用或删除的 Key 返回 `401`;未开启共享或没有显式分组返回 `403 peer_sharing_disabled`; +未知参数返回 `400 invalid_peer_query`。每个进程对每个认证 Key 独立限制为每分钟 30 次。 +涉及账号池、额度或模型聚合的查询每个进程最多同时执行 8 个,满额时立即返回 +`429 peer_query_rate_limited` 和 `Retry-After: 1`;仅查询 Key 预算不占用聚合槽位。 +每 Key 限流同样返回 `429 peer_query_rate_limited`,`Retry-After` 表示剩余等待秒数;两类限制均不消耗推理请求配额 + ### Codex Live 语音通话 语音通话复用数据面 Client Key 鉴权,并按该 Key 的账号分组选路。通话引导是一次性请求,走与其他 @@ -874,6 +958,7 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 | `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游;Provider 未提供额度能力时返回空额度投影 | | `GET` | `/api/admin/accounts/quota-forecast` | `accountId` | 按需读取周/月容量预测、源窗口剩余估算与采样依据,不刷新上游额度 | | `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 访问 Provider 并刷新额度,同时同步额度所属状态 | +| `POST` | `/api/admin/accounts/upstream-info/refresh` | `{ accountIds }` | 为已识别互联能力的 API Key 账号保存刷新计划,返回 HTTP 202,不等待上游 | | `GET` | `/api/admin/accounts/personal-info` | `accountId` | 按需汇聚 Provider 上游个人资料、累计活动与订阅信息,不更新额度或 credential | | `GET` | `/api/admin/accounts/profile-avatar` | `accountId`、可选 `version` | 返回支持头像能力的 Provider 字节流,需要管理员会话;`version` 仅区分浏览器缓存 | | `GET` | `/api/admin/accounts/reset-credits` | `accountId` | 查询支持该能力的 Provider 重置卡,不读取本地库存 | @@ -952,6 +1037,58 @@ OpenAI 主动额度刷新和正常响应携带的明确套餐会同步到账号 浏览器打开的第三方 OAuth 授权页仍使用浏览器自身网络。 账号出口与连接隔离见 [架构说明](architecture.md#账号出站代理) +### 上游互联快照 + +启用的 OpenAI API Key 账号由后台主动识别互联能力,使用该账号的 API 前缀、Bearer 密钥和出站代理 +访问 `{base_url}/cpr/link`;地址可指向 CPR 或实现相同协议的其他项目。普通上游不支持该协议时退避重试,不影响模型请求 + +账号列表返回已保存观测的 Key 预算窗口与池汇总;详情和额度 GET 另含池内逐账号窗口。 +查询这些接口不会访问上游,展开节点视图时通过账号详情读取窗口。 +`account.upstream` 未建立计划或账号停用时为 `null`;有计划时包含: + +| 字段 | 含义 | +| --- | --- | +| `discovered` | 当前连接已成功识别兼容协议,首次识别前为 `false`;界面仅为已识别连接展示互联信息 | +| `node` | 已验证协议返回的 `{ id, name, software }`,首次同步前或授权被拒绝时为 `null` | +| `observedAt`、`observedAtDisplay`、`observedAtRelative` | 上游观测时间及服务端格式化结果,不代表本次页面查询时间 | +| `stale` | 快照是否已过期;过期值仅供查看 | +| `connectionRevision` | 当前连接身份的不可解释标识,地址、密钥或出口变化后应停止等待旧身份 | +| `sync` | `{ state, requestGeneration, completedGeneration }`,状态为 `idle`、`queued` 或 `running` | +| `lastError` | 最近同步失败分类:`unauthorized`、`unsupported`、`rate_limited`、`invalid_response`、`unavailable`,成功时为 `null` | +| `pool` | 上游授权公开的账号数量、状态数量及套餐数量,未公开时为 `null` | + +互联额度复用 `quota.windows`。可用的上游 Key 账本始终保留日、周窗口,不限额也保留已用金额。 +窗口返回 `amount: { usedDisplay, remainingDisplay, totalDisplay }`,字符串包含单位,美元使用 `$` 前缀; +不限额的总额与剩余额显示为 `∞`,`usedPercent` 为 `null`,金额不换算成 Token。 +Key 消费窗口的 `localUsage` 按各自 `[resetAt - windowSeconds, resetAt)` 边界聚合本机请求, +与上游金额和百分比分开展示;未开启或边界缺失时不查询。 +上游池内的原生窗口通过 `sourceAccountId` 保留授权 Key 内稳定的匿名账号归属, +不相加为统一余额,`localUsage` 保持缺失;Key 消费窗口与本机账号窗口不返回该字段。 +互联窗口返回独立的 `observedAtDisplay` 和 `stale` + +`POST /api/admin/accounts/upstream-info/refresh` 使用管理员鉴权,接受 1–200 个账号 ID,重复 ID 合并。 +成功返回 HTTP 202,响应 `data` 为: + +```json +{ + "items": [ + { + "accountId": "acct_...", + "connectionRevision": "", + "requestGeneration": 3, + "state": "queued" + } + ] +} +``` + +202 表示计划已保存,不表示上游查询成功。已排队或运行的请求合并为同一代次;调用者后续读取原有 +账号接口,在连接身份相同且 `completedGeneration >= requestGeneration` 时结束等待,再检查 +`lastError` 与快照时间。手动刷新遵守最小间隔和失败退避,不能通过反复提交绕过上游限流 + +网络错误、限流或无效响应保留同一连接的旧观测;401/403 或连接身份变化清除旧观测。 +单个账号停用时停止同步并清理互联观测,重新启用后建立计划 + ### 账号模型限制 账号列表和详情返回 `modelAccess: { mode, models }`,模型 ID 区分大小写并精确匹配: @@ -1011,10 +1148,12 @@ Search 的选号限制不约束插件在选号后主动改写正文。 添加和测试在各项的 `httpStatus` 返回这些状态,其他单项接口使用 HTTP 状态码 代理列表只返回关联账号数量。关联账号按需查询,每项包含 `id`、`name`、`email`、`provider`、`enabled`、 -`authenticationKind`、`planType`、`planTypeDisplay` 和 `groups: [{ id, name, color, enabled }]`, +`authenticationKind`、`planType`、`planTypeDisplay`、`upstream` 和 `groups: [{ id, name, color, enabled }]`, 不返回账号凭据。默认每页 20 条,按名称、ID 稳定排序;搜索不区分大小写,匹配名称或邮箱的字面子串。 不存在的代理返回 404,未绑定账号或没有匹配结果时返回空页。数量与当前页对应同一查询快照 +`upstream` 复用账号列表的互联观测结构和发现状态语义,只读取本地观测,不触发远端刷新 + 移除关联账号只清除指定账号的代理绑定与连接地址,使其改为直连,保留凭据、调度参数与分组。 若账号已不再绑定请求中的代理,则返回 409 删除代理会自动解除全部关联账号的代理绑定与连接地址,账号保留其余设置并改为直连。 @@ -1187,8 +1326,13 @@ API Key 账号与 OAuth 共用现有 Responses、Images 和 standalone Search 包括 Responses Lite 和原生压缩协议的透传,实际支持情况由上游决定;不提供独立 compact 路由。 模型目录、连接测试和本地用量统计可用;OAuth 刷新/重新授权、ChatGPT 额度、个人资料、订阅和重置卡不适用。 客户端的 `image_generation` 和 `X-OpenAI-Actor-Authorization` 声明不改变账号的实际能力。 -账号列表和详情的 API Key `usage` 汇总该账号创建后仍保留的本地请求记录,`usage.windowKind` 为 `general`,管理端按当前语言显示通用额度; -日志清理会影响累计范围,不代表上游余额。OAuth 账号仍按实际周/月额度窗口统计 +未识别为 CPR 互联的 API Key 账号,其列表和详情 `usage` 汇总账号创建后仍保留的本地请求记录, +`usage.windowKind` 为 `general`,管理端显示累计用量。日志清理会影响累计范围,不代表上游余额 + +互联账号按上游 Key 最长的消费窗口统计本机 Token、费用和模型用量,有周额度时使用周窗口,只有日额度时使用日窗口, +`usage.windowKind` 分别为 `weekly` 或 `daily`。查询边界同账号额度窗口,为 `[resetAt - windowSeconds, resetAt)`; +未开启或缺少重置边界时显示无数据,不回退到历史累计,也不采用池内其他账号的周期。 +OAuth 账号仍按实际周/月额度窗口统计 批量导入 AT / RT 使用 `accounts` JSON 数组,最多 200 项,不接受纯文本 token 列表。例如: @@ -1317,13 +1461,14 @@ OAuth start 使用: 不凭旧快照恢复账号 - 正常 Responses 请求会解析上游响应的 rate-limit headers,合并进同一 quota 快照并同步状态。Free、 K12 等套餐共用该状态机;套餐只参与账号展示和按套餐隔离的模型目录 cache,不存在 K12 专属额度路径 -- 账号详情的 Token 统计、模型排行和列表 Token 汇总优先使用账号级周额度窗口,无可统计的周窗口时 +- OAuth 账号详情的 Token 统计、模型排行和列表 Token 汇总优先使用账号级周额度窗口,无可统计的周窗口时 使用月额度窗口;`usage.windowKind` 随选中的窗口返回 `weekly` 或 `monthly`。查询边界 严格为 `[resetAt - windowSeconds, resetAt)`,不是自然周/月或最近 7/30 天;额度刷新若返回了更早的 重置时间,会按新边界重新聚合。没有边界完整、可归属到账号的周/月窗口时显示无数据,`usage.windowKind` 为 `quota` (周/月额度窗口),不回退到 5 小时、日窗口或历史累计。 金额原值保持完整精度,USD 展示值 - 小于 1 美元时最多保留四位小数,其余保留两位 + 小于 1 美元时最多保留四位小数,其余最多保留两位,小数末尾不补零。 + 额度百分比展示最多保留一位小数,小数末尾不补零;数值为零时返回 `0%` ### 周/月额度预测 @@ -1540,12 +1685,18 @@ HTTP 返回 `403` 和 `image_generation_disabled`,WebSocket 每个 `response.c | `POST` | `/api/admin/client-keys/delete` | `{ id }` | 删除 | 创建字段为 `name`、可选 `label`、`groupIds`、`maxConcurrency`、`requestsPerMinute`、可选 -`dailyLimitUsd`、`weeklyLimitUsd`、`customKey` 和 `providerRequestProfileOverrides`。更新请求携带 `id`,不接受 `customKey`。 +`dailyLimitUsd`、`weeklyLimitUsd`、`customKey`、`providerRequestProfileOverrides` 和 `peerSharing`。更新请求携带 `id`,不接受 `customKey`。 `groupIds` 必须显式提交:空数组派生 `routingScope: "all"`,非空数组派生 `routingScope: "groups"`。响应同时返回分组引用 `groups`,以及从当前有效账号池派生、仅供展示的 `providerKinds`。创建和 reveal 响应会返回完整明文 Key,调用方 必须立即安全保存 +`peerSharing` 为 `{ enabled, sharePool, shareQuota, sharePlans }`,各项均为布尔值且默认 `false`。 +创建时省略保持关闭;更新时省略或 `null` 保留整个配置,提交对象时替换完整配置,对象内省略项为 `false`。 +列表、创建及更新响应均返回完整对象。`sharePlans` 需要 `sharePool`;开启 `enabled` 必须提交非空 `groupIds`, +开启后推理和 [CPR 互联导出](#cpr-互联只读协议) 都限制到这些组内的直连 OAuth 账号。 +账号池、原生额度窗口、套餐分布分别按对应开关公开;关闭共享不改变已保存的其他开关值 + `providerRequestProfileOverrides` 按 Provider ID 保存身份选择,列表返回同名对象。创建时各项为完整配置对象, 未提供的 Provider 跟随通用设置;更新时省略某项保留原值,设为 `null` 清除该项覆盖。最多 256 项,每项最多 64 KiB。 独立配置整体覆盖通用设置,不逐字段继承;切换全局配置不会影响独立 Key。目录与选择规则见 [Provider 客户端身份](#provider-客户端身份) @@ -2162,6 +2313,9 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt ## 10. Dashboard、用量与错误 +Dashboard 的当前账号摘要包含 `upstream`,结构与账号列表一致,用于统一互联身份展示; +`planType` / `planTypeDisplay` 仍保留原生订阅含义。历史请求记录保持请求发生时的身份与订阅快照 + | 方法 | 路由 | 说明 | | --- | --- | --- | | `GET` | `/api/admin/dashboard/summary` | Dashboard 汇总;支持 `kind` 与统一时间范围参数 | diff --git a/docs/architecture.md b/docs/architecture.md index e2e668c29..52ae7c84e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,12 +59,12 @@ flowchart TB | --- | --- | | `backend/apps/gateway` | 读取顶层配置、连接 Bundle、注册 Provider 与 Worker | | `backend/apps/plugin-cli`(包名 `codex-proxy-plugin-cli`) | `cpr-plugin package` 校验作者清单并生成平台元数据、资源摘要与归档;内部仅依赖 SDK,不参与网关运行时加载 | -| `gateway-protocol` | 跨层共享的 OpenAI wire contract、SSE 编解码与无业务 owner 的解析事实,不依赖其他 workspace crate | +| `gateway-protocol` | 跨层共享的 OpenAI 与 CPR 互联线协议、SSE 编解码与无业务 owner 的解析事实,不依赖其他 workspace crate | | `gateway-core` | operation、canonical event、请求快照、路由、admission、attempt 协调、交付边界和计量 | -| `gateway-admin` | 管理领域、Key 用量查询、Provider/Store 端口、审计语义、备份与历史保留策略 | +| `gateway-admin` | 管理领域、Key 用量、互联共享查询与上游同步、Provider/Store 端口、审计语义、备份与历史保留策略 | | `gateway-api` | HTTP/WS/SSE 解码与交付、Admin 与 Key 用量 wire、静态 Web UI;不直接访问 Store 或具体 Provider | | `gateway-store` | PostgreSQL、Redis、S3/R2、`pg_dump` 适配器;不拥有业务策略 | -| `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督与历史保留任务、系统更新及外部价格源适配 | +| `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督与历史保留任务、系统更新、互联 HTTP 与外部价格源适配 | | `gateway-plugin/sdk`(包名 `gateway-plugin-sdk`) | 公开插件清单与线协议,独立于网关领域;异步收发通过可选 `io` feature 提供 | | `gateway-plugin/runtime`(包名 `gateway-plugin-runtime`) | 插件包校验、能力适配、双向 RPC 与发布集合;通过 Host 管理子进程和受管 HTTP | | `providers/openai` | OpenAI OAuth、账号选择、目录、额度、Responses/Images/Search transport | @@ -706,11 +706,15 @@ API 只输出各入口所需的字段白名单,不复用管理员的宽响应 Client Key 与账号分组形成授权范围: -- 没有分组关联表示 `AllAccounts` +- 普通 Client Key 没有分组关联表示 `AllAccounts` - 有关联时只允许已启用分组成员的并集 - 已绑定分组为空或全部禁用时得到空池,绝不回退为全部账号 - 分组可以包含多个 Provider,账号也可以属于多个分组 +开启 CPR 互联共享的 Key 必须显式绑定分组,并且实际推理范围只包含 Provider 确认的直连认证账号。 +Core 将直连认证类型与分组权限一起冻结;删除最后一个分组得到空池,当前凭据类型变化也不能绕过旧快照的直连限制。 +内置 OpenAI 和 xAI 声明 OAuth 账号为直连,未知 Provider 默认不获得共享资格;互联 API Key 账号不向下一层转供 + Core 按映射后的模型冻结 Provider 归属:已知模型只在明确声明该模型且能力匹配的 Provider 中路由, 再应用账号授权、模型政策和请求约束;账号不可用或所属 Provider 被排除不能扩大归属范围。 同名模型的多个所属 Provider 保持标识顺序,重试仍受发送与重放安全边界约束。 @@ -741,6 +745,9 @@ Core 定义、校验并统一拥有评分规则,内置选号与诊断复用同 - 排队系数只在选择等待队列时结合其他评分和进程内实时等待人数,不影响立即选号分数与容差;系数为零时选最短队列。 `ConcurrencyWaitQueue` 在同一锁内读取队长并入队,保持已有位置、同级 FIFO、取消回收、容量和共享等待预算;Provider 传入候选须已限定续写范围 - 健康分按已发送尝试的结果计算账号级平滑失败率并随时间衰减;明确容量拒绝的平滑权重为 0.4,其他可计分失败和成功为 0.2 +- CPR 互联的观测只进入现有额度分:当前连接 Key 的新鲜预算耗尽,或请求模型完整覆盖的账号池明确耗尽时降低分数。 + 单个账号的原生窗口可提供剩余比例,多账号窗口不合并成池百分比;未知、过期和覆盖不完整的观测保持中性。 + 数据面只读 Provider 的本地缓存,不触发远端查询,不修改真实冷却、权重、并发、亲和或账号可用状态 - 插件显式选号使用候选校验和实际亲和结果;过滤后委托沿用内置评分、权重和亲和,过滤结果同时约束重选与等待。 临时容量阻塞与策略排除分开,全部过滤直接拒绝请求,后续评估不继承上次过滤结果 @@ -929,6 +936,7 @@ PostgreSQL 周期对账才是正确性基础 | --- | --- | --- | | 账号、credential、分组、Client Key、设置、审计、请求与备份记录 | PostgreSQL | 业务持久化事实 | | Client Key 金额窗口与费用事件 | PostgreSQL | 准入与幂等结算的权威账本,独立于请求观测与日志保留策略 | +| CPR 互联节点身份、共享权限、上游观测与同步计划 | PostgreSQL | 节点身份重启后稳定;每个连接仅保留当前快照和可恢复计划,原始观测时间与本地同步时间分开保存 | | 插件包体、安装来源、制品接受事实、实例配置与私有状态 | PostgreSQL | 完整信任决定绑定已接受摘要;进程内发布集合由持久化事实构建 | | admission、lease、cooldown、circuit、会话亲和、continuation、失败轮次、OAuth pending、目录 cache | Redis | 可重建、可过期的协调状态 | | 控制面统一登录会话与登录限流桶 | Redis | AuthService 唯一拥有;保存 Admin / Key 身份、绑定 ID、绝对有效期和计数,不保存原始凭据 | @@ -992,6 +1000,24 @@ Admin 仅为开启自动使用的账号保存卡片 ID、到期时间、下一 上游明确返回 `nothing_to_reset` 时清除未决键并保留卡片,下一次消费使用新键;未确认操作始终沿原键完成确认。 计划写入通过版本比较拒绝过期覆盖,全局提前时间或周额度上限变更唤醒本地重算,关闭自动使用保留未决键供重新开启后恢复 +### CPR 互联观测 + +共享端由 Admin 汇聚已授权分组中的本地账号事实、Provider 只读额度和当前 Key 费用账本,API 负责版本化线协议输出。 +导出同时核对请求冻结权限和当前共享设置、分组及认证方式,不回源查询,不输出账号凭据或本地账号标识。 +协议按版本和能力识别,第三方实现与内置节点使用相同合同;共享端不转发接入端的远端快照。 +Admin 为账号聚合设置独立并发上限,满额立即返回可重试的限流响应;模型展开按响应字节预算提前拒绝必然超限的组合,批量投影定期让出执行时间,避免重查询占满共享数据库与执行资源 + +Admin 的 `peer_link` 分别拥有共享导出与上游消费用例;上游消费统一处理发现、协议校验、同步、展示和调度投影。 +Provider 声明可连接的认证种类,并从本轮凭据中提取连接材料;当前 OpenAI API Key 提供该适配,xAI 原生账号继续参与共享导出。 +Host 执行有界 HTTP 请求,复用组合根注入的证书策略和账号出口;Store 只持久化不透明观测及同步计划,不按厂商或认证种类硬编码候选。 +计划查询与领取只读元数据,每次领取带独立 token,删除重建计划后的旧执行者不能提交观测。 +Admin 按连接身份及完成版本缓存管理摘要与调度投影,完整观测分批读取,解析和投影在锁外完成。 +Core 定义中立调度信号与只读端口,组合根绑定观测来源;Provider 选号不持有控制面服务,弱引用避免互相保活。 +账号和凭据提交后的共用通知同时失效原生 Provider 与互联派生状态,插件账号写入复用同一流程。 +账号列表、概览与代理关联账号核对观测版本后复用预算与池摘要,详情读取逐节点窗口;手动刷新仅入队,完成后仍通过原列表或详情读取结果。 +界面复用现有额度窗口,保留上游原始观测时间;Key 金额、原生账号百分比和本机 Token 用量分别解释。 +请求与字段合同见 [CPR 互联 API](api.md#cpr-互联只读协议) + ## 10. 观测与后台任务 管理观测查询与统计 Worker 共用 PostgreSQL 连接预算,为请求执行保留连接容量。每项操作使用一个受预算约束的连接,排队计入总操作预算; @@ -1100,9 +1126,14 @@ Worker 由各 Bundle 贡献、由 Host 统一监督: - Host:历史保留任务,持有调度、单轮行数/批数/时长预算、取消和日志;Admin 校验[保留窗口](api.md#历史保留),Store 执行有界批量删除 - Core:`runtime` owner 的 RuntimeSnapshot 周期对账和 Redis change 订阅 - Admin:S3/R2 备份 daemon,负责调度、执行、删除收敛与保留清理; - 以及账号错误保护恢复 worker(到期解除或探测恢复) + 以及账号错误保护恢复 worker(到期解除或探测恢复)、公共 CPR 互联同步 - Provider:credential refresh、quota/catalog 健康、账号预热和官方版本/etag 检查 +互联同步使用账号出口的受管 HTTP 客户端,不跟随跳转;网络等待不持有数据库事务。 +开启、连接变化、周期扫描和手动刷新共用持久计划,单轮领取和并发均有界,失败遵守退避及上游 `Retry-After`。 +Store 按连接身份与领取代次比较后写入;凭据、地址或有效代理变化使旧结果失效,取消留下的租约到期后可重新领取。 +同一连接的临时失败保留原始观测,授权拒绝清除公开数据;成功同步不会刷新历史额度的观测时间 + Daemon 每次运行持有独立的取消与成功进度上下文,实际工作或依赖检查成功后由 owner 上报。 Host 据此清除连续失败与当前错误、重置重启退避,并保持 `Running`;仅重启不会恢复健康, 已退出运行的通知不影响新运行。周期任务按调度间隔检查成功新鲜度,daemon 的空闲或长任务 diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index 6f0fd1c37..b2dd42cec 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -14,6 +14,10 @@ export type AccountErrorReason | 'account_banned' export interface AccountQuotaWindow { + sourceAccountId?: string | null + amount?: { usedDisplay: string, remainingDisplay: string, totalDisplay: string } | null + observedAtDisplay?: string | null + stale?: boolean key: string group: string limitId: string | null @@ -96,7 +100,7 @@ export interface AccountModelUsage { } export interface AccountUsage { - windowKind: 'general' | 'weekly' | 'monthly' | 'quota' + windowKind: 'general' | 'daily' | 'weekly' | 'monthly' | 'quota' requestCount: number | null requestCountDisplay: string inputTokens: number | null @@ -136,6 +140,7 @@ export interface AccountModelAccess { } export interface Account { + upstream: AccountUpstream | null capabilities: AccountCapabilities outboundProxyId: string | null outboundProxyEndpoint: string | null @@ -177,6 +182,34 @@ export interface Account { groups: AccountGroupRef[] } +export interface AccountUpstream { + discovered: boolean + node: { id: string, name: string, software: string } | null + observedAt: string | null + observedAtDisplay: string + observedAtRelative: RelativeTimeView | null + stale: boolean + connectionRevision: string + sync: { + state: 'idle' | 'queued' | 'running' + requestGeneration: number + completedGeneration: number + } + lastError: 'unauthorized' | 'unsupported' | 'rate_limited' | 'invalid_response' | 'unavailable' | null + pool: { + accountCount: number + states: { ready: number, cooldown: number, exhausted: number, disabled: number, error: number, unknown: number } + plans: Array<{ name: string, count: number }> + } | null +} + +export interface AccountUpstreamRefreshItem { + accountId: string + connectionRevision: string + requestGeneration: number + state: 'queued' | 'running' +} + export interface AccountCapabilities { quota: boolean quotaRefresh: boolean @@ -596,6 +629,15 @@ export function refreshAccountQuota(data: AccountIdParam, options: RequestOption }) } +export function refreshAccountUpstreamInfo(data: { accountIds: string[] }, options: RequestOptions = {}) { + return request<{ items: AccountUpstreamRefreshItem[] }>({ + url: '/api/admin/accounts/upstream-info/refresh', + method: 'POST', + data, + ...options, + }) +} + export function getAccountResetCredits(data: AccountIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/reset-credits', diff --git a/frontend/src/api/modules/dashboard.ts b/frontend/src/api/modules/dashboard.ts index dce7bc1e9..c83331791 100644 --- a/frontend/src/api/modules/dashboard.ts +++ b/frontend/src/api/modules/dashboard.ts @@ -1,6 +1,6 @@ import type { RequestOptions } from '../request' import type { RelativeTimeView } from '../time' -import type { AccountQuotaWindow } from './accounts' +import type { AccountQuotaWindow, AccountUpstream } from './accounts' import type { AnalyticsMetadata } from './analytics' import type { UsageListRecord } from './usage' import request from '../request' @@ -141,6 +141,7 @@ export interface DashboardAccountRequestBucket { } export interface DashboardAccountUsage { + upstream: AccountUpstream | null id: string provider: string authenticationKind: string diff --git a/frontend/src/api/modules/keys.ts b/frontend/src/api/modules/keys.ts index cdd8fb7a4..3bf01e3b8 100644 --- a/frontend/src/api/modules/keys.ts +++ b/frontend/src/api/modules/keys.ts @@ -6,7 +6,15 @@ import request from '../request' export type ApiKeyRoutingScope = 'all' | 'groups' export type ApiKeyBudgetPeriod = 'daily' | 'weekly' | 'all' +export interface ApiKeyPeerSharing { + enabled: boolean + sharePool: boolean + shareQuota: boolean + sharePlans: boolean +} + export interface ApiKey { + peerSharing: ApiKeyPeerSharing providerRequestProfileOverrides: ProviderRequestProfiles openaiClientProfileOverride: ClientProfileSelection | null xaiClientProfileOverride: XaiClientProfileSelection | null @@ -69,6 +77,7 @@ interface ApiKeyListParams { } export interface ApiKeyWriteParam { + peerSharing: ApiKeyPeerSharing name: string label: string | null groupIds: string[] diff --git a/frontend/src/api/modules/proxies.ts b/frontend/src/api/modules/proxies.ts index 8ba409ec2..cc1365b7f 100644 --- a/frontend/src/api/modules/proxies.ts +++ b/frontend/src/api/modules/proxies.ts @@ -1,4 +1,5 @@ import type { RequestOptions } from '../request' +import type { AccountUpstream } from './accounts' import type { AccountGroupRef } from './groups' import type { RequestLocation } from './settings' import request from '../request' @@ -53,6 +54,7 @@ interface ProxyPage { } export interface OutboundProxyAccount { + upstream: AccountUpstream | null id: string name: string email: string | null diff --git a/frontend/src/components/AppBrandMark.vue b/frontend/src/components/AppBrandMark.vue index baa40fcc6..678e79fed 100644 --- a/frontend/src/components/AppBrandMark.vue +++ b/frontend/src/components/AppBrandMark.vue @@ -1,6 +1,10 @@ diff --git a/frontend/src/components/HelpPopover.vue b/frontend/src/components/HelpPopover.vue new file mode 100644 index 000000000..4e1d36fdb --- /dev/null +++ b/frontend/src/components/HelpPopover.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/account/AccountPlanBadge.vue b/frontend/src/components/account/AccountPlanBadge.vue index f5a9e71fc..8050fde06 100644 --- a/frontend/src/components/account/AccountPlanBadge.vue +++ b/frontend/src/components/account/AccountPlanBadge.vue @@ -1,12 +1,15 @@ -
- {{ t('accounts.quota.unobserved') }} -
+ diff --git a/frontend/src/components/account/account-usage-window/presenter.ts b/frontend/src/components/account/account-usage-window/presenter.ts index 83c75aeaa..22630180e 100644 --- a/frontend/src/components/account/account-usage-window/presenter.ts +++ b/frontend/src/components/account/account-usage-window/presenter.ts @@ -72,7 +72,7 @@ const variantDefinitions: Record< labelClass: 'truncate text-cp-text-quaternary', valueClass: 'text-[10px] leading-[14px] font-heavy', trackOffsetClass: 'mt-auto', - trackShapeClass: 'h-[3px] w-full overflow-hidden rounded-full', + trackShapeClass: 'h-1 w-full overflow-hidden rounded-full', minimumBarWidth: '3px', compactValues: true, labelTooltip: true, @@ -221,7 +221,7 @@ function usageWindowMode( window: AccountQuotaWindow | undefined, localUsage: AccountLocalUsage | null, ): AccountUsageWindowMode { - if (typeof window?.usedPercent === 'number') + if (typeof window?.usedPercent === 'number' || window?.amount) return 'quota' if (localUsage) return 'local' @@ -239,9 +239,39 @@ function localTokenDisplay(localUsage: AccountLocalUsage | null) { return formatInteger(total) } -export function quotaWindowLocalUsageDisplay(window: AccountQuotaWindow) { +export function quotaWindowDetails(window: AccountQuotaWindow) { const localUsage = accountLocalUsage(window.localUsage) - return localTokenDisplay(localUsage) || null + const tokenDisplay = localTokenDisplay(localUsage) || null + const amountDisplay = window.amount?.usedDisplay ?? null + const hasConsumption = Boolean(tokenDisplay || amountDisplay) + || (!window.amount && localUsage?.totalTokens == null) + return { + stale: window.stale, + observationDisplay: [ + window.stale + ? t('accounts.upstream.stale') + : null, + window.observedAtDisplay && window.observedAtDisplay !== '—' + ? t('accounts.upstream.observedAt', { time: window.observedAtDisplay }) + : null, + ].filter(Boolean).join(' · '), + localUsageDisplay: tokenDisplay, + amountUsedDisplay: amountDisplay, + quotaValueDisplay: window.amount && window.usedPercent === null + ? window.amount.totalDisplay + : window.usedPercentDisplay, + consumptionDisplay: hasConsumption + ? [ + amountDisplay, + tokenDisplay || (!window.amount && localUsage?.totalTokens == null + ? '—' + : null), + ].filter(Boolean).join(' · ') + : null, + resetAtDisplay: window.amount && window.resetAtDisplay === '—' + ? t('accounts.upstream.startsOnNextUse') + : window.resetAtDisplay, + } } const quotaWindowPeriods = [ @@ -352,7 +382,7 @@ function rollingWindowDurationDisplay(windowSeconds: number | null | undefined) export function quotaWindowPresentation(window: AccountQuotaWindow, minimumWidth: string) { const percent = clamp(window.usedPercent ?? 0, 0, 100) - const tone = quotaWindowTone(window.usedPercent) + const tone = quotaWindowTone(window.stale ? null : window.usedPercent) return { barStyle: { width: `${percent}%`, @@ -365,7 +395,7 @@ export function quotaWindowPresentation(window: AccountQuotaWindow, minimumWidth } } -function quotaWindowTone(usedPercent: number | null): QuotaWindowTone { +export function quotaWindowTone(usedPercent: number | null): QuotaWindowTone { if (usedPercent === null) return 'unknown' // 每个窗口按自身的已用比例分档,剩余不足 30% / 10% 时分别预警 diff --git a/frontend/src/components/charts/BaseChart.vue b/frontend/src/components/charts/BaseChart.vue index c4deaba6b..b1da2f2bd 100644 --- a/frontend/src/components/charts/BaseChart.vue +++ b/frontend/src/components/charts/BaseChart.vue @@ -17,6 +17,7 @@ const props = withDefaults( }, ) +const emit = defineEmits<{ ready: [chart: EChartsType] }>() const chartElement = useTemplateRef('chart') const chart = shallowRef() let pendingInitElement: HTMLElement | undefined @@ -34,6 +35,7 @@ function ensureChart(element: HTMLElement) { return chart.value = init(element, undefined, { renderer: 'canvas' }) applyOption(props.option) + emit('ready', chart.value) } const { pause: pausePendingInit, resume: resumePendingInit } = useRafFn( diff --git a/frontend/src/i18n/locales/en/accounts.json b/frontend/src/i18n/locales/en/accounts.json index 4546af395..afa872a05 100644 --- a/frontend/src/i18n/locales/en/accounts.json +++ b/frontend/src/i18n/locales/en/accounts.json @@ -38,7 +38,6 @@ "windowConsumption": "Window consumption", "resetTime": "Reset time", "localTokens": "Locally accumulated tokens", - "unavailableHelp": "Upstream quota is unavailable, the green bar does not represent remaining quota", "totalTokensFor": "Total tokens for {windowLabelDisplay}", "usageRateHelp": "Current usage percentage for the most recently used quota", "usageRate": "Usage rate", @@ -292,6 +291,7 @@ "totalCharges": "Total cost", "noModelUsageYet": "No model usage yet", "windows": { + "daily": "Daily usage", "general": "Total usage", "weekly": "Weekly usage", "monthly": "Monthly usage", @@ -611,6 +611,25 @@ "pasteAtLeastOne": "Paste at least one {label}", "missingProvider": "The import file contains no {provider} accounts", "importFilesMustBeJsonObjects": "Import files must be JSON objects" + }, + "upstream": { + "account": "Account {number}", + "poolChart": "Shared pool account quotas", + "poolKeyboard": "Arrow keys select an account, plus and minus zoom, Home shows the full pool", + "startsOnNextUse": "Starts on next use", + "link": "CPR Link", + "observedAt": "Updated {time}", + "stale": "Data expired", + "accepted": "Query is still incomplete; refresh later to view the result", + "unauthorized": "Upstream access denied", + "unsupported": "Upstream does not support CPR Link", + "rateLimited": "Upstream query rate limited", + "invalidResponse": "Invalid upstream data", + "unavailable": "Upstream unavailable", + "pool": "Shared pool", + "ready": "available", + "nodeQuota": "Node quota", + "quota": "Upstream quota" } } } diff --git a/frontend/src/i18n/locales/en/keys.json b/frontend/src/i18n/locales/en/keys.json index bcdb12339..8c88e3b5b 100644 --- a/frontend/src/i18n/locales/en/keys.json +++ b/frontend/src/i18n/locales/en/keys.json @@ -77,7 +77,13 @@ }, "enterApiKeyName": "Please enter API key name", "invalidKey": "Use visible HTTP header characters, without spaces or line breaks", - "requestsPerMinute": "Requests per minute" + "requestsPerMinute": "Requests per minute", + "peerSharing": "CPR Link", + "peerSharingHelp": "Allow other nodes to read this key’s budget; requests use only direct accounts in the selected groups", + "sharePool": "Share account pool summary", + "shareQuota": "Share account quotas", + "sharePlans": "Share plan breakdown", + "peerGroupRequired": "Select account groups for this linked key" }, "fields": { "copyFullKey": "Copy full key", diff --git a/frontend/src/i18n/locales/zh-Hans/accounts.json b/frontend/src/i18n/locales/zh-Hans/accounts.json index e1b907e57..72a3e984e 100644 --- a/frontend/src/i18n/locales/zh-Hans/accounts.json +++ b/frontend/src/i18n/locales/zh-Hans/accounts.json @@ -38,7 +38,6 @@ "windowConsumption": "窗口消耗", "resetTime": "重置时间", "localTokens": "本地累计总 Token", - "unavailableHelp": "上游额度未提供,绿色条不表示剩余额度", "totalTokensFor": "{windowLabelDisplay}总 Token", "usageRateHelp": "最近使用额度的当前已用比例", "usageRate": "使用率", @@ -292,6 +291,7 @@ "totalCharges": "总计费:", "noModelUsageYet": "暂无模型用量", "windows": { + "daily": "日用量", "general": "累计用量", "weekly": "周用量", "monthly": "月用量", @@ -611,6 +611,25 @@ "pasteAtLeastOne": "请至少粘贴一个 {label}", "missingProvider": "批量导入文件不包含 {provider} 账号文档", "importFilesMustBeJsonObjects": "导入文件必须是 JSON 对象" + }, + "upstream": { + "account": "账号 {number}", + "poolChart": "共享池账号额度", + "poolKeyboard": "左右键选择账号,加减键缩放,Home 键查看全池", + "startsOnNextUse": "下次使用时开启", + "link": "CPR 互联", + "observedAt": "{time}更新", + "stale": "数据已过期", + "accepted": "查询尚未完成,稍后刷新可查看结果", + "unauthorized": "上游未授权", + "unsupported": "上游暂不支持互联", + "rateLimited": "上游查询受限", + "invalidResponse": "上游数据异常", + "unavailable": "上游暂不可用", + "pool": "共享池", + "ready": "可用", + "nodeQuota": "节点额度", + "quota": "上游额度" } } } diff --git a/frontend/src/i18n/locales/zh-Hans/keys.json b/frontend/src/i18n/locales/zh-Hans/keys.json index 576e69871..7a467fc6b 100644 --- a/frontend/src/i18n/locales/zh-Hans/keys.json +++ b/frontend/src/i18n/locales/zh-Hans/keys.json @@ -77,7 +77,13 @@ }, "enterApiKeyName": "请输入 API密钥名称", "invalidKey": "自定义 API密钥只能包含 HTTP 可传输的可见字符,不能包含空格或换行", - "requestsPerMinute": "每分钟请求数" + "requestsPerMinute": "每分钟请求数", + "peerSharing": "CPR 互联", + "peerSharingHelp": "允许其他节点查询此 Key 的额度,模型请求仅使用所选分组中的直连账号", + "sharePool": "共享账号池概览", + "shareQuota": "共享账号额度", + "sharePlans": "共享套餐构成", + "peerGroupRequired": "请为互联 Key 选择账号分组" }, "fields": { "copyFullKey": "复制完整密钥", diff --git a/frontend/src/i18n/locales/zh-Hant/accounts.json b/frontend/src/i18n/locales/zh-Hant/accounts.json index 9e5f4be76..0d5339485 100644 --- a/frontend/src/i18n/locales/zh-Hant/accounts.json +++ b/frontend/src/i18n/locales/zh-Hant/accounts.json @@ -38,7 +38,6 @@ "windowConsumption": "窗口消耗", "resetTime": "重置時間", "localTokens": "本地累計總 Token", - "unavailableHelp": "上游額度未提供,綠色條不表示剩餘額度", "totalTokensFor": "{windowLabelDisplay}總 Token", "usageRateHelp": "最近使用額度的當前已用比例", "usageRate": "使用率", @@ -292,6 +291,7 @@ "totalCharges": "總計費:", "noModelUsageYet": "暫無模型用量", "windows": { + "daily": "日用量", "general": "累計用量", "weekly": "周用量", "monthly": "月用量", @@ -611,6 +611,25 @@ "pasteAtLeastOne": "請至少粘貼一個 {label}", "missingProvider": "批量導入文件不包含 {provider} 賬號文檔", "importFilesMustBeJsonObjects": "導入文件必須是 JSON 對象" + }, + "upstream": { + "account": "帳號 {number}", + "poolChart": "共享池帳號額度", + "poolKeyboard": "左右鍵選擇帳號,加減鍵縮放,Home 鍵查看全池", + "startsOnNextUse": "下次使用時開啟", + "link": "CPR 互聯", + "observedAt": "{time}更新", + "stale": "資料已過期", + "accepted": "查詢尚未完成,稍後重新整理可查看結果", + "unauthorized": "上游未授權", + "unsupported": "上游暫不支援互聯", + "rateLimited": "上游查詢受限", + "invalidResponse": "上游資料異常", + "unavailable": "上游暫不可用", + "pool": "共享池", + "ready": "可用", + "nodeQuota": "節點額度", + "quota": "上游額度" } } } diff --git a/frontend/src/i18n/locales/zh-Hant/keys.json b/frontend/src/i18n/locales/zh-Hant/keys.json index 46a3d1347..16d92b569 100644 --- a/frontend/src/i18n/locales/zh-Hant/keys.json +++ b/frontend/src/i18n/locales/zh-Hant/keys.json @@ -77,7 +77,13 @@ }, "enterApiKeyName": "請輸入 API密鑰名稱", "invalidKey": "自定義 API密鑰只能包含 HTTP 可傳輸的可見字符,不能包含空格或換行", - "requestsPerMinute": "每分鐘請求數" + "requestsPerMinute": "每分鐘請求數", + "peerSharing": "CPR 互聯", + "peerSharingHelp": "允許其他節點查詢此 Key 的額度,模型請求僅使用所選分組中的直連帳號", + "sharePool": "共享帳號池概覽", + "shareQuota": "共享帳號額度", + "sharePlans": "共享方案組成", + "peerGroupRequired": "請為互聯 Key 選擇帳號分組" }, "fields": { "copyFullKey": "複製完整密鑰", diff --git a/frontend/src/utils/account-upstream.ts b/frontend/src/utils/account-upstream.ts new file mode 100644 index 000000000..cc40c9c72 --- /dev/null +++ b/frontend/src/utils/account-upstream.ts @@ -0,0 +1,14 @@ +import type { AccountUpstream } from '@/api' +import { t } from '@/i18n' + +export function accountUpstreamErrorMessage(reason: AccountUpstream['lastError']): string { + if (!reason) + return '' + return { + unauthorized: t('accounts.upstream.unauthorized'), + unsupported: t('accounts.upstream.unsupported'), + rate_limited: t('accounts.upstream.rateLimited'), + invalid_response: t('accounts.upstream.invalidResponse'), + unavailable: t('accounts.upstream.unavailable'), + }[reason] +} diff --git a/frontend/src/views/accounts/components/AccountEditModal.vue b/frontend/src/views/accounts/components/AccountEditModal.vue index 3bb1f7741..ddd25c2a5 100644 --- a/frontend/src/views/accounts/components/AccountEditModal.vue +++ b/frontend/src/views/accounts/components/AccountEditModal.vue @@ -23,6 +23,7 @@ defineProps<{ const emit = defineEmits<{ save: [] + afterLeave: [] }>() const { t } = useI18n({ useScope: 'global' }) @@ -50,8 +51,9 @@ const selectedGroupIds = defineModel('selectedGroupIds', { required: t :title="t('accounts.editor.title')" width="42rem" :show-close="!saving" :close-on-click-modal="!saving" :close-on-press-escape="!saving" + @closed="emit('afterLeave')" > -
+
@@ -62,7 +64,7 @@ const selectedGroupIds = defineModel('selectedGroupIds', { required: t show-notes />
- + - & Partial> + & Partial> const props = withDefaults( defineProps<{ @@ -90,7 +90,7 @@ const avatarToneClass = computed(() => { :class="metaGapClass" > - +
{ :class="metaGapClass" > - +
diff --git a/frontend/src/views/accounts/components/account-create-modal/index.vue b/frontend/src/views/accounts/components/account-create-modal/index.vue index 79c748891..a1fc42113 100644 --- a/frontend/src/views/accounts/components/account-create-modal/index.vue +++ b/frontend/src/views/accounts/components/account-create-modal/index.vue @@ -37,7 +37,7 @@ const props = withDefaults(defineProps<{ account: null, }) -const emit = defineEmits<{ create: [], generateOauth: [] }>() +const emit = defineEmits<{ create: [], generateOauth: [], afterLeave: [] }>() const { t } = useI18n({ useScope: 'global' }) @@ -95,6 +95,7 @@ function continueToImport() { :type="view.modal.type" :width="view.modal.width" :show-close="!busy" :close-on-click-modal="!busy" :close-on-press-escape="!busy" + @closed="emit('afterLeave')" >