diff --git a/backend/apps/gateway/tests/architecture.rs b/backend/apps/gateway/tests/architecture.rs index 66e7cff15..bf7aedf08 100644 --- a/backend/apps/gateway/tests/architecture.rs +++ b/backend/apps/gateway/tests/architecture.rs @@ -70,6 +70,7 @@ fn gateway_core_depends_on_no_http_db_redis_or_provider_crate() { #[test] fn core_value_owners_do_not_depend_on_execution_or_routing() { let root = backend_root().join("crates/gateway-core/src"); + let reexports = read_root_reexport_owners(&root); for relative in super::rust_files(&root) { let allowed: Option<&[&str]> = match relative.to_str().expect("source path") { "validation.rs" => Some(&[]), @@ -96,7 +97,7 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() { let Some(allowed) = allowed else { continue }; let source = fs::read_to_string(root.join(&relative)).expect("read core source"); let syntax = syn::parse_file(&source).expect("parse core source"); - let mut references = CrateReferences::in_module(&relative); + let mut references = CrateReferences::in_module(&relative).with_root_reexports(&reexports); references.visit_file(&syntax); // 相对路径中的 self 指向当前 owner,不是跨 owner 依赖 references.owners.remove(&references.module_path[0]); @@ -111,17 +112,24 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() { } #[test] -fn core_owner_paths_form_a_dag() { - let root = backend_root().join("crates/gateway-core/src"); +fn domain_owner_paths_form_a_dag() { + for member in ["crates/gateway-core", "crates/gateway-admin"] { + assert_owner_paths_form_a_dag(member); + } +} + +fn assert_owner_paths_form_a_dag(member: &str) { + let root = backend_root().join(member).join("src"); + let reexports = read_root_reexport_owners(&root); let mut graph = BTreeMap::>::new(); for relative in super::rust_files(&root) { if relative == Path::new("lib.rs") { continue; } - let mut references = CrateReferences::in_module(&relative); + let mut references = CrateReferences::in_module(&relative).with_root_reexports(&reexports); let owner = references.module_path[0].clone(); - let source = fs::read_to_string(root.join(&relative)).expect("read core source"); - references.visit_file(&syn::parse_file(&source).expect("parse core source")); + let source = fs::read_to_string(root.join(&relative)).expect("read owner source"); + references.visit_file(&syn::parse_file(&source).expect("parse owner source")); references.owners.remove(&owner); graph.entry(owner).or_default().extend(references.owners); } @@ -131,7 +139,7 @@ fn core_owner_paths_form_a_dag() { while let Some(dependency) = pending.pop() { assert_ne!( &dependency, owner, - "Core owner dependency cycle reaches {owner}" + "{member} owner dependency cycle reaches {owner}" ); if visited.insert(dependency.clone()) && let Some(next) = graph.get(&dependency) @@ -143,7 +151,44 @@ fn core_owner_paths_form_a_dag() { } #[test] -fn core_reference_scan_resolves_groups_reexports_and_relative_paths() { +fn shared_owners_do_not_depend_on_their_callers() { + let boundaries: &[(&str, &str, &[&str])] = &[ + ("crates/providers/xai", "transport", &["credential"]), + ("crates/providers/openai", "transport", &["provider"]), + ("crates/gateway-api", "time", &["admin"]), + ( + "crates/gateway-api", + "client_error", + &["admin", "openai", "middleware"], + ), + ("crates/gateway-api", "middleware", &["openai"]), + ("crates/gateway-host", "system_update", &["config"]), + ("crates/gateway-plugin/runtime", "package", &["callback"]), + ]; + for &(member, owner, forbidden) in boundaries { + let root = backend_root().join(member).join("src"); + let reexports = read_root_reexport_owners(&root); + for relative in super::rust_files(&root) { + let mut references = + CrateReferences::in_module(&relative).with_root_reexports(&reexports); + if references.module_path.first().map(String::as_str) != Some(owner) { + continue; + } + let source = fs::read_to_string(root.join(&relative)).expect("read owner source"); + references.visit_file(&syn::parse_file(&source).expect("parse owner source")); + for dependency in forbidden { + assert!( + !references.owners.contains(*dependency), + "{member}/src/{} must not depend on crate::{dependency}", + relative.display() + ); + } + } + } +} + +#[test] +fn owner_reference_scan_resolves_groups_reexports_and_relative_paths() { let syntax = syn::parse_file( r#" pub use crate::{engine::AttemptContext, settings::SettingsValues as Values}; @@ -167,13 +212,86 @@ fn core_reference_scan_resolves_groups_reexports_and_relative_paths() { ); } -#[derive(Default)] -struct CrateReferences { - owners: BTreeSet, - module_path: Vec, +#[test] +fn owner_reference_scan_resolves_crate_root_reexports() { + let root = syn::parse_file( + r#" + mod provider; + mod transport; + mod secret; + pub use provider::{OFFICIAL_URL, Options as ProviderOptions}; + pub use self::transport::{self as network, Request}; + pub use crate::secret::SecretValue; + pub use external::Unrelated; + "#, + ) + .expect("parse root reexport fixture"); + let syntax = syn::parse_file( + r#" + use crate::{OFFICIAL_URL, ProviderOptions, network::Client}; + use super::super::SecretValue; + fn request() -> crate::Request { unimplemented!() } + "#, + ) + .expect("parse reexport consumer fixture"); + let reexports = root_reexport_owners(&root); + assert!(!reexports.contains_key("Unrelated")); + let mut references = CrateReferences::in_module(Path::new("consumer/request.rs")) + .with_root_reexports(&reexports); + references.visit_file(&syntax); + assert_eq!( + references.owners, + BTreeSet::from([ + "provider".to_owned(), + "transport".to_owned(), + "secret".to_owned(), + ]) + ); } -impl<'ast> Visit<'ast> for CrateReferences { +fn read_root_reexport_owners(root: &Path) -> BTreeMap { + let source = fs::read_to_string(root.join("lib.rs")).expect("read crate root"); + root_reexport_owners(&syn::parse_file(&source).expect("parse crate root")) +} + +// 只解析根模块的显式重导出,不推断 glob、宏展开或完整 Rust 名称解析 +fn root_reexport_owners(syntax: &syn::File) -> BTreeMap { + let modules = syntax + .items + .iter() + .filter_map(|item| match item { + Item::Mod(module) => Some(module.ident.to_string()), + _ => None, + }) + .collect::>(); + let mut reexports = BTreeMap::new(); + for item in &syntax.items { + if let Item::Use(item) = item + && !matches!(item.vis, syn::Visibility::Inherited) + { + visit_use_leaves(&item.tree, &mut Vec::new(), &mut |path, name| { + let owner = path + .iter() + .find(|segment| !matches!(segment.as_str(), "crate" | "self")); + if let (Some(owner), Some(name)) = (owner, name) + && modules.contains(owner) + { + reexports.insert(name.to_owned(), owner.clone()); + } + }); + } + } + reexports +} + +#[derive(Default)] +struct CrateReferences<'a> { + owners: BTreeSet, + module_path: Vec, + root_reexports: Option<&'a BTreeMap>, +} + +impl<'ast> Visit<'ast> for CrateReferences<'_> { fn visit_path(&mut self, path: &'ast syn::Path) { self.record( &path @@ -186,11 +304,13 @@ impl<'ast> Visit<'ast> for CrateReferences { } fn visit_item_use(&mut self, item: &'ast syn::ItemUse) { - self.use_tree(&item.tree, &mut Vec::new()); + visit_use_leaves(&item.tree, &mut Vec::new(), &mut |path, _| { + self.record(path); + }); } } -impl CrateReferences { +impl<'a> CrateReferences<'a> { fn in_module(relative: &Path) -> Self { let mut module = relative .with_extension("") @@ -208,13 +328,26 @@ impl CrateReferences { Self { owners: BTreeSet::new(), module_path: module, + root_reexports: None, } } + fn with_root_reexports(mut self, reexports: &'a BTreeMap) -> Self { + self.root_reexports = Some(reexports); + self + } + + fn root_owner(&self, name: &str) -> String { + self.root_reexports + .and_then(|reexports| reexports.get(name)) + .cloned() + .unwrap_or_else(|| name.to_owned()) + } + fn record(&mut self, path: &[String]) { let Some(first) = path.first() else { return }; let owner = if first == "crate" { - path.get(1).cloned() + path.get(1).map(|name| self.root_owner(name)) } else if first == "self" || first == "super" { let mut module = self.module_path.clone(); let mut rest = path; @@ -227,7 +360,10 @@ impl CrateReferences { } rest = &rest[1..]; } - module.first().or_else(|| rest.first()).cloned() + module + .first() + .cloned() + .or_else(|| rest.first().map(|name| self.root_owner(name))) } else { None }; @@ -235,31 +371,41 @@ impl CrateReferences { self.owners.insert(owner); } } +} - fn use_tree(&mut self, tree: &syn::UseTree, prefix: &mut Vec) { - match tree { - syn::UseTree::Path(path) => { - prefix.push(path.ident.to_string()); - self.use_tree(&path.tree, prefix); - prefix.pop(); - } - syn::UseTree::Name(name) => { - prefix.push(name.ident.to_string()); - self.record(prefix); - prefix.pop(); - } - syn::UseTree::Rename(rename) => { - prefix.push(rename.ident.to_string()); - self.record(prefix); - prefix.pop(); - } - syn::UseTree::Group(group) => { - for item in &group.items { - self.use_tree(item, prefix); - } - } - syn::UseTree::Glob(_) => self.record(prefix), +fn visit_use_leaves( + tree: &syn::UseTree, + prefix: &mut Vec, + visit: &mut impl FnMut(&[String], Option<&str>), +) { + match tree { + syn::UseTree::Path(path) => { + prefix.push(path.ident.to_string()); + visit_use_leaves(&path.tree, prefix, visit); + prefix.pop(); } + syn::UseTree::Name(name) => { + let name = name.ident.to_string(); + let export = if name == "self" { + prefix.last().cloned() + } else { + Some(name.clone()) + }; + prefix.push(name); + visit(prefix, export.as_deref()); + prefix.pop(); + } + syn::UseTree::Rename(rename) => { + prefix.push(rename.ident.to_string()); + visit(prefix, Some(&rename.rename.to_string())); + prefix.pop(); + } + syn::UseTree::Group(group) => { + for item in &group.items { + visit_use_leaves(item, prefix, visit); + } + } + syn::UseTree::Glob(_) => visit(prefix, None), } } diff --git a/backend/crates/gateway-admin/src/backup/task.rs b/backend/crates/gateway-admin/src/backup/task.rs index 7eed3f312..8edb4262f 100644 --- a/backend/crates/gateway-admin/src/backup/task.rs +++ b/backend/crates/gateway-admin/src/backup/task.rs @@ -11,11 +11,15 @@ use chrono::{DateTime, Utc}; use futures::future::BoxFuture; use gateway_core::{ lifecycle::CancellationToken, - task::{DaemonTask, WorkerDaemonContext, WorkerTaskError}, + task::{ + DaemonRestartPolicy, DaemonTask, WorkerContribution, WorkerDaemonContext, WorkerId, + WorkerKind, WorkerRegistration, WorkerRunnable, WorkerTaskError, + }, }; use tracing::{info, warn}; use crate::{ + model::AdminError, model::backup::{ BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStatusTransition, BackupStorageConfig, BackupTriggerKind, build_backup_seed, code, @@ -34,6 +38,8 @@ impl From for WorkerTaskError { use super::policy::{BackupSchedule, decide_retention}; +const BACKUP_WORKER_OWNER: &str = "backup"; + /// 循环间隔:管理员修改 Cron 后该时长内生效;无工作时可取消等待 const BACKUP_LOOP_INTERVAL: Duration = Duration::from_secs(30); /// 每个循环最多执行的保留删除数量 @@ -72,6 +78,22 @@ impl BackupTask { self.timezone = timezone; self } + + pub(crate) fn into_worker(self) -> Result { + let id = WorkerId::try_new(WorkerKind::Backup, BACKUP_WORKER_OWNER) + .map_err(|_| AdminError::internal("备份 Worker ID 不合法"))?; + let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60)) + .map_err(|_| AdminError::internal("备份 Worker 重启策略不合法"))?; + let registration = WorkerRegistration::try_new( + id, + WorkerRunnable::Daemon { + restart, + task: Box::new(self), + }, + ) + .map_err(|_| AdminError::internal("备份 Worker 注册信息不合法"))?; + Ok(WorkerContribution::Registration(registration)) + } } impl DaemonTask for BackupTask { diff --git a/backend/crates/gateway-admin/src/bootstrap.rs b/backend/crates/gateway-admin/src/bootstrap.rs new file mode 100644 index 000000000..ea1bcd2ce --- /dev/null +++ b/backend/crates/gateway-admin/src/bootstrap.rs @@ -0,0 +1,340 @@ +//! 管理服务与后台任务的启动组装,窄端口供服务器和 CLI 复用 + +use std::{path::Path, sync::Arc}; + +use gateway_core::{engine::probe::AccountProbe, runtime::SnapshotControl}; + +use crate::{ + AccountsService, AdminBundle, AdminConfig, AdminRuntimePorts, AdminServices, ClientConfig, + ClientKeyService, CredentialsService, PluginDistributionPorts, PluginManagementService, + PluginsService, SettingsService, UsageDataService, backup, freeze_recovery, + model::AdminError, + ports::{ + self, plugin_accounts::PluginAccountAccess, plugin_client_keys::PluginClientKeyAccess, + provider::ProviderAdminRegistry, store::AdminStorePorts, + }, + public_service, reset_credit_worker, use_case, + use_case::{ + account_groups::DefaultAccountGroupService, + accounts::DefaultAccountsService, + auth::{AuthService, DefaultAuthService}, + backup::DefaultBackupService, + client_distribution::DefaultClientDistributionService, + client_keys::DefaultClientKeyService, + observability::DefaultObservabilityService, + settings::DefaultSettingsService, + system::DefaultSystemService, + }, +}; + +/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化 +/// +/// # Errors +/// +/// 配置非法或默认管理员初始化失败时返回错误 +pub async fn initialize( + config: AdminConfig, + client_config: ClientConfig, + store: AdminStorePorts, + runtime: AdminRuntimePorts, +) -> Result { + initialize_inner(config, client_config, store, runtime, None).await +} + +/// 使用组合根已绑定给 Runtime 的同一账号窄端口,避免为完整 Admin 重建第二实例 +pub async fn initialize_with_plugin_accounts( + config: AdminConfig, + client_config: ClientConfig, + store: AdminStorePorts, + runtime: AdminRuntimePorts, + plugin_accounts: Arc, +) -> Result { + initialize_inner(config, client_config, store, runtime, Some(plugin_accounts)).await +} + +async fn initialize_inner( + mut config: AdminConfig, + mut client_config: ClientConfig, + store: AdminStorePorts, + runtime: AdminRuntimePorts, + plugin_accounts: Option>, +) -> Result { + let AdminRuntimePorts { + upstream_info, + timezone, + service_middleware, + plugin_preparation, + plugin_management, + published_snapshot, + plugin_distribution, + plugin_inspector, + pricing_source, + providers, + snapshot, + account_probe: probe, + proxy_probe, + client_distribution, + system, + client_key_verifier, + } = runtime; + config + .resolve_and_validate(Path::new(".")) + .map_err(|error| AdminError::invalid(error.to_string()))?; + client_config + .resolve_and_validate(Path::new(".")) + .map_err(|error| AdminError::invalid(error.to_string()))?; + + let auth = Arc::new(DefaultAuthService::new( + &config, + client_config.session_ttl_minutes, + store.auth(), + client_key_verifier.clone(), + )); + auth.ensure_default_admin(config.default_password.expose()) + .await?; + + let accounts = initialize_accounts( + store.accounts(), + store.account_runtime(), + store.settings(), + timezone, + providers.clone(), + snapshot.clone(), + probe.clone(), + upstream_info.clone(), + ); + let backup_ports = store.backup(); + let backups = Arc::new(DefaultBackupService::new( + backup_ports.repository(), + backup_ports.object_store(), + store.auth(), + snapshot.clone(), + timezone, + )); + let backup_task = backup::task::BackupTask::new( + backup_ports.repository(), + backup_ports.dump(), + backup_ports.object_store(), + ) + .with_timezone(timezone); + let system_preflight = Arc::new( + use_case::system_preflight::PluginSystemUpdatePreflight::new( + store.plugins(), + plugin_inspector.clone(), + ), + ); + let system = Arc::new(DefaultSystemService::new(system, system_preflight)); + let observations: Arc = Arc::new( + use_case::observability::CachedObservabilityStore::new(store.observability()), + ); + let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new( + auth.clone(), + client_key_verifier, + store.client_keys(), + observations.clone(), + system.clone(), + timezone, + )); + let credentials = Arc::new(CredentialsService::new( + providers.clone(), + store.accounts(), + store.proxies(), + snapshot.clone(), + upstream_info.clone(), + )); + let plugin_accounts = plugin_accounts.unwrap_or_else(|| { + initialize_plugin_accounts( + providers.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()); + let settings = initialize_settings( + store.settings(), + snapshot.clone(), + providers.clone(), + pricing_source, + ); + let usage_data = initialize_usage_data(store.usage_data(), store.settings(), providers.clone()); + let mut public_services = public_service::Registry::new(service_middleware); + public_services.register_settings(&settings)?; + 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(), + providers.clone(), + )), + timezone, + public_services: Arc::new(public_services), + plugin_management: Arc::new(PluginManagementService::new( + plugin_management, + store.plugins(), + published_snapshot.clone(), + )), + plugins: Arc::new(PluginsService::new( + store.plugins(), + plugin_inspector, + PluginDistributionPorts::new(plugin_distribution, store.proxies()), + snapshot.clone(), + plugin_preparation, + published_snapshot, + store.plugin_state(), + )), + key_usage, + proxies: Arc::new(use_case::proxies::DefaultProxiesService::new( + store.proxies(), + proxy_probe, + snapshot.clone(), + providers.clone(), + upstream_info.clone(), + )), + auth, + accounts: accounts.clone(), + account_groups: Arc::new(DefaultAccountGroupService::new( + store.account_groups(), + store.account_runtime(), + snapshot.clone(), + )), + client_keys: Arc::new(DefaultClientKeyService::new( + store.client_keys(), + snapshot.clone(), + providers.clone(), + )), + client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)), + observability: Arc::new(DefaultObservabilityService::new( + observations.clone(), + store.accounts(), + store.settings(), + providers.clone(), + timezone, + system.clone(), + upstream_info, + )), + settings, + usage_data, + system, + credentials, + _plugin_accounts: plugin_accounts, + import_tasks, + backups, + }; + let freeze_recovery = + freeze_recovery::FreezeRecoveryTask::new(freeze_recovery::FreezeRecoveryDeps { + accounts: Arc::clone(&accounts) as Arc, + store: store.accounts(), + runtime: store.account_runtime(), + settings: store.settings(), + }); + let worker_contributions = vec![ + backup_task.into_worker()?, + import_task.into_worker()?, + freeze_recovery.into_worker()?, + reset_credit_worker::ResetCreditAutomationTask { + accounts, + store: store.accounts(), + settings: store.settings(), + } + .into_worker()?, + ]; + Ok(AdminBundle { + services, + worker_contributions, + }) +} + +/// CLI 只组合账号用例,不初始化管理员、管理服务或后台任务;写入仍复用同一事务与审计 +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, + upstream_info, + )) +} + +/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口 +#[must_use] +pub fn initialize_plugin_client_keys( + providers: ports::provider::ProviderAdminRegistry, + store: Arc, + snapshot: Arc, +) -> Arc { + let service: Arc = + Arc::new(DefaultClientKeyService::new(store, snapshot, providers)); + Arc::new(use_case::plugin_client_keys::DefaultPluginClientKeyAccess::new(service)) +} + +/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核 +#[must_use] +pub fn initialize_plugin_resources( + store: Arc, + snapshot: Arc, +) -> Arc { + Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot }) +} + +/// 服务端与 CLI 复用账号用例,不随命令启动后台调度 +#[must_use] +#[expect( + clippy::too_many_arguments, + reason = "账号存储、发布、探测与互联分别通过独立端口注入" +)] +pub fn initialize_accounts( + accounts: Arc, + account_runtime: Arc, + settings: Arc, + timezone: gateway_core::time::DeploymentTimeZone, + providers: ProviderAdminRegistry, + snapshot: Arc, + probe: Arc, + upstream_info: Option>, +) -> Arc { + Arc::new(DefaultAccountsService::new( + accounts, + account_runtime, + settings, + timezone, + providers, + snapshot, + probe, + upstream_info, + )) +} + +/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布 +pub fn initialize_settings( + store: Arc, + snapshot: Arc, + providers: ProviderAdminRegistry, + pricing_source: Arc, +) -> Arc { + Arc::new(DefaultSettingsService::new( + store, + snapshot, + providers, + pricing_source, + )) +} + +/// HTTP、插件及 CLI 共用的用量领域用例,宿主提供已验证的调用主体 +#[must_use] +pub fn initialize_usage_data( + store: Arc, + settings: Arc, + providers: ProviderAdminRegistry, +) -> Arc { + Arc::new(use_case::usage_data::DefaultUsageDataService::new( + store, settings, providers, + )) +} diff --git a/backend/crates/gateway-admin/src/freeze_recovery.rs b/backend/crates/gateway-admin/src/freeze_recovery.rs index d969aee8a..00bdecfd2 100644 --- a/backend/crates/gateway-admin/src/freeze_recovery.rs +++ b/backend/crates/gateway-admin/src/freeze_recovery.rs @@ -8,9 +8,13 @@ use chrono::{DateTime, Utc}; use futures::StreamExt as _; use gateway_core::account::{AccountErrorProtection, ProviderAccountId}; use gateway_core::routing::UpstreamModelId; -use gateway_core::task::{ScheduledTask, WorkerCycleContext, WorkerTaskError}; +use gateway_core::task::{ + ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerId, WorkerKind, + WorkerLeaseRequest, WorkerRegistration, WorkerRunnable, WorkerSchedule, WorkerTaskError, +}; use tracing::warn; +use crate::model::AdminError; use crate::model::accounts::{ AccountConnectionTestEvent, AccountFreeze, AccountPageItem, AccountRuntimeSnapshot, }; @@ -42,6 +46,34 @@ impl FreezeRecoveryTask { Self { deps } } + pub(crate) fn into_worker(self) -> Result { + let id = WorkerId::try_new( + WorkerKind::AccountFreezeRecovery, + FREEZE_RECOVERY_WORKER_OWNER, + ) + .map_err(|_| AdminError::internal("冻结恢复 Worker ID 不合法"))?; + let schedule = WorkerSchedule::try_new( + FREEZE_RECOVERY_INTERVAL, + WORKER_INITIAL_BACKOFF, + WORKER_MAXIMUM_BACKOFF, + WORKER_LEASE_TTL, + WORKER_LEASE_RENEWAL, + ) + .map_err(|_| AdminError::internal("冻结恢复 Worker 调度配置不合法"))?; + let lease = WorkerLeaseRequest::try_new(id.clone(), WORKER_LEASE_TTL) + .map_err(|_| AdminError::internal("冻结恢复 Worker 租约配置不合法"))?; + let registration = WorkerRegistration::try_new( + id, + WorkerRunnable::Scheduled { + schedule, + lease: Some(lease), + task: Box::new(self), + }, + ) + .map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?; + Ok(WorkerContribution::Registration(registration)) + } + /// 无法读取或校验配置时跳过本轮,不能把未知状态当作允许解冻 async fn freeze_policy(&self) -> Option { let settings = self.deps.settings.load_runtime_settings().await.ok()?; diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index 1013b73c5..599842179 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -2,26 +2,22 @@ //! //! 本 crate 不包含 HTTP wire、数据库实现或具体 Provider 实现 -use std::{fmt, path::Path, sync::Arc, time::Duration}; +use std::{fmt, path::Path, sync::Arc}; use gateway_core::{ - engine::execution::ClientKeyVerifier, - engine::probe::AccountProbe, - runtime::SnapshotControl, - task::{ - DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerLeaseRequest, - WorkerRegistration, WorkerRunnable, WorkerSchedule, - }, + engine::execution::ClientKeyVerifier, engine::probe::AccountProbe, runtime::SnapshotControl, + task::WorkerContribution, }; use secrecy::{ExposeSecret as _, SecretString}; use serde::Deserialize; pub mod backup; +mod bootstrap; pub mod freeze_recovery; pub mod model; pub mod ports; pub mod public_service; -mod reset_credit_automation; +mod reset_credit_worker; mod use_case; pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService}; @@ -44,18 +40,15 @@ pub use use_case::{ usage_data::UsageDataService, }; -use model::AdminError; +pub use bootstrap::{ + initialize, initialize_accounts, initialize_plugin_accounts, initialize_plugin_client_keys, + initialize_plugin_resources, initialize_settings, initialize_usage_data, + initialize_with_plugin_accounts, +}; + use ports::{ client_distribution::ClientDistributionResolver, plugin_accounts::PluginAccountAccess, - plugin_client_keys::PluginClientKeyAccess, provider::ProviderAdminRegistry, - store::AdminStorePorts, system::SystemOperations, -}; -use use_case::{ - account_groups::DefaultAccountGroupService, accounts::DefaultAccountsService, - auth::DefaultAuthService, backup::DefaultBackupService, - client_distribution::DefaultClientDistributionService, client_keys::DefaultClientKeyService, - observability::DefaultObservabilityService, settings::DefaultSettingsService, - system::DefaultSystemService, + provider::ProviderAdminRegistry, system::SystemOperations, }; const MINIMUM_INITIAL_PASSWORD_BYTES: usize = 12; @@ -70,7 +63,6 @@ const WEAK_ADMIN_PASSWORDS: &[&str] = &[ "codex-proxy-rs", ]; -const BACKUP_WORKER_OWNER: &str = "backup"; const DEFAULT_CLIENT_SESSION_TTL_MINUTES: u64 = 24 * 60; /// 只用于首次幂等创建默认管理员的启动密码 @@ -366,395 +358,3 @@ pub struct AdminRuntimePorts { pub system: Arc, pub client_key_verifier: Arc, } - -/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化 -/// -/// # Errors -/// -/// 配置非法或默认管理员初始化失败时返回错误 -pub async fn initialize( - config: AdminConfig, - client_config: ClientConfig, - store: AdminStorePorts, - runtime: AdminRuntimePorts, -) -> Result { - initialize_inner(config, client_config, store, runtime, None).await -} - -/// 使用组合根已绑定给 Runtime 的同一账号窄端口,避免为完整 Admin 重建第二实例 -pub async fn initialize_with_plugin_accounts( - config: AdminConfig, - client_config: ClientConfig, - store: AdminStorePorts, - runtime: AdminRuntimePorts, - plugin_accounts: Arc, -) -> Result { - initialize_inner(config, client_config, store, runtime, Some(plugin_accounts)).await -} - -async fn initialize_inner( - mut config: AdminConfig, - mut client_config: ClientConfig, - store: AdminStorePorts, - runtime: AdminRuntimePorts, - plugin_accounts: Option>, -) -> Result { - let AdminRuntimePorts { - upstream_info, - timezone, - service_middleware, - plugin_preparation, - plugin_management, - published_snapshot, - plugin_distribution, - plugin_inspector, - pricing_source, - providers, - snapshot, - account_probe: probe, - proxy_probe, - client_distribution, - system, - client_key_verifier, - } = runtime; - config - .resolve_and_validate(Path::new(".")) - .map_err(|error| AdminError::invalid(error.to_string()))?; - client_config - .resolve_and_validate(Path::new(".")) - .map_err(|error| AdminError::invalid(error.to_string()))?; - let registry = providers; - - let auth = Arc::new(DefaultAuthService::new( - &config, - client_config.session_ttl_minutes, - store.auth(), - client_key_verifier.clone(), - )); - auth.ensure_default_admin(config.default_password.expose()) - .await?; - - let accounts = initialize_accounts( - store.accounts(), - store.account_runtime(), - store.settings(), - timezone, - registry.clone(), - snapshot.clone(), - probe.clone(), - upstream_info.clone(), - ); - let backup_ports = store.backup(); - let backups = Arc::new(DefaultBackupService::new( - backup_ports.repository(), - backup_ports.object_store(), - store.auth(), - snapshot.clone(), - timezone, - )); - let backup_task = backup::task::BackupTask::new( - backup_ports.repository(), - backup_ports.dump(), - backup_ports.object_store(), - ) - .with_timezone(timezone); - let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new( - store.plugins(), - plugin_inspector.clone(), - )); - let system = Arc::new(DefaultSystemService::new(system, system_preflight)); - let observations: Arc = Arc::new( - use_case::observability::CachedObservabilityStore::new(store.observability()), - ); - let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new( - auth.clone(), - client_key_verifier, - store.client_keys(), - observations.clone(), - system.clone(), - timezone, - )); - let credentials = Arc::new(CredentialsService::new( - registry.clone(), - 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(), - 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()); - let settings = initialize_settings( - store.settings(), - snapshot.clone(), - registry.clone(), - pricing_source, - ); - let usage_data = initialize_usage_data(store.usage_data(), store.settings(), registry.clone()); - let mut public_services = public_service::Registry::new(service_middleware); - public_services.register_settings(&settings)?; - 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( - plugin_management, - store.plugins(), - published_snapshot.clone(), - )), - plugins: Arc::new(PluginsService::new( - store.plugins(), - plugin_inspector, - PluginDistributionPorts::new(plugin_distribution, store.proxies()), - snapshot.clone(), - plugin_preparation, - published_snapshot, - store.plugin_state(), - )), - key_usage, - proxies: Arc::new(use_case::proxies::DefaultProxiesService::new( - store.proxies(), - proxy_probe, - snapshot.clone(), - registry.clone(), - upstream_info.clone(), - )), - auth, - accounts: accounts.clone(), - account_groups: Arc::new(DefaultAccountGroupService::new( - store.account_groups(), - store.account_runtime(), - snapshot.clone(), - )), - client_keys: Arc::new(DefaultClientKeyService::new( - store.client_keys(), - snapshot.clone(), - registry.clone(), - )), - client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)), - observability: Arc::new(DefaultObservabilityService::new( - observations.clone(), - store.accounts(), - store.settings(), - registry.clone(), - timezone, - system.clone(), - upstream_info, - )), - settings, - usage_data, - system, - credentials, - _plugin_accounts: plugin_accounts, - import_tasks, - backups, - }; - let freeze_recovery = - freeze_recovery::FreezeRecoveryTask::new(freeze_recovery::FreezeRecoveryDeps { - accounts: Arc::clone(&accounts) as Arc, - store: store.accounts(), - runtime: store.account_runtime(), - settings: store.settings(), - }); - let mut worker_contributions = backup_worker_contribution(backup_task)?; - let id = WorkerId::try_new(WorkerKind::AccountImport, "admin") - .map_err(|_| AdminError::internal("导入 Worker ID 不合法"))?; - let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60)) - .map_err(|_| AdminError::internal("导入 Worker 重启策略不合法"))?; - let registration = WorkerRegistration::try_new( - id, - WorkerRunnable::Daemon { - restart, - task: Box::new(import_task), - }, - ) - .map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?; - worker_contributions.push(WorkerContribution::Registration(registration)); - worker_contributions.extend(freeze_recovery_worker_contribution(freeze_recovery)?); - let id = WorkerId::try_new(WorkerKind::ResetCreditAutomation, "admin") - .map_err(|_| AdminError::internal("重置卡 Worker ID 不合法"))?; - let schedule = WorkerSchedule::try_new( - Duration::from_secs(5), - Duration::from_secs(5), - Duration::from_secs(60), - Duration::from_secs(90), - Duration::from_secs(30), - ) - .map_err(|_| AdminError::internal("重置卡 Worker 调度配置不合法"))?; - let lease = WorkerLeaseRequest::try_new(id.clone(), Duration::from_secs(90)) - .map_err(|_| AdminError::internal("重置卡 Worker 租约配置不合法"))?; - worker_contributions.push(WorkerContribution::Registration( - WorkerRegistration::try_new( - id, - WorkerRunnable::Scheduled { - schedule, - lease: Some(lease), - task: Box::new(reset_credit_automation::ResetCreditAutomationTask { - accounts, - store: store.accounts(), - settings: store.settings(), - }), - }, - ) - .map_err(|_| AdminError::internal("重置卡 Worker 注册失败"))?, - )); - Ok(AdminBundle { - services, - worker_contributions, - }) -} - -/// CLI 只组合账号用例,不初始化管理员、管理服务或后台任务;写入仍复用同一事务与审计 -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, - upstream_info, - )) -} - -/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口 -#[must_use] -pub fn initialize_plugin_client_keys( - providers: ports::provider::ProviderAdminRegistry, - store: Arc, - snapshot: Arc, -) -> Arc { - let service: Arc = - Arc::new(DefaultClientKeyService::new(store, snapshot, providers)); - Arc::new(use_case::plugin_client_keys::DefaultPluginClientKeyAccess::new(service)) -} - -/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核 -#[must_use] -pub fn initialize_plugin_resources( - store: Arc, - snapshot: Arc, -) -> Arc { - Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot }) -} - -/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup` -fn backup_worker_contribution( - task: backup::task::BackupTask, -) -> Result, AdminError> { - let id = WorkerId::try_new(WorkerKind::Backup, BACKUP_WORKER_OWNER) - .map_err(|_| AdminError::internal("备份 Worker ID 不合法"))?; - let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60)) - .map_err(|_| AdminError::internal("备份 Worker 重启策略不合法"))?; - let registration = WorkerRegistration::try_new( - id, - WorkerRunnable::Daemon { - restart, - task: Box::new(task), - }, - ) - .map_err(|_| AdminError::internal("备份 Worker 注册信息不合法"))?; - Ok(vec![WorkerContribution::Registration(registration)]) -} - -/// 冻结恢复 Worker 注册:按固定周期扫描活跃冻结,owner 固定 -fn freeze_recovery_worker_contribution( - task: freeze_recovery::FreezeRecoveryTask, -) -> Result, AdminError> { - let id = WorkerId::try_new( - WorkerKind::AccountFreezeRecovery, - freeze_recovery::FREEZE_RECOVERY_WORKER_OWNER, - ) - .map_err(|_| AdminError::internal("冻结恢复 Worker ID 不合法"))?; - let schedule = WorkerSchedule::try_new( - freeze_recovery::FREEZE_RECOVERY_INTERVAL, - freeze_recovery::WORKER_INITIAL_BACKOFF, - freeze_recovery::WORKER_MAXIMUM_BACKOFF, - freeze_recovery::WORKER_LEASE_TTL, - freeze_recovery::WORKER_LEASE_RENEWAL, - ) - .map_err(|_| AdminError::internal("冻结恢复 Worker 调度配置不合法"))?; - let lease = WorkerLeaseRequest::try_new(id.clone(), freeze_recovery::WORKER_LEASE_TTL) - .map_err(|_| AdminError::internal("冻结恢复 Worker 租约配置不合法"))?; - let registration = WorkerRegistration::try_new( - id, - WorkerRunnable::Scheduled { - schedule, - lease: Some(lease), - task: Box::new(task), - }, - ) - .map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?; - Ok(vec![WorkerContribution::Registration(registration)]) -} - -/// 服务端与 CLI 复用账号用例,不随命令启动后台调度 -#[must_use] -#[expect( - clippy::too_many_arguments, - reason = "账号存储、发布、探测与互联分别通过独立端口注入" -)] -pub fn initialize_accounts( - accounts: Arc, - account_runtime: Arc, - settings: Arc, - timezone: gateway_core::time::DeploymentTimeZone, - providers: ProviderAdminRegistry, - snapshot: Arc, - probe: Arc, - upstream_info: Option>, -) -> Arc { - Arc::new(DefaultAccountsService::new( - accounts, - account_runtime, - settings, - timezone, - providers, - snapshot, - probe, - upstream_info, - )) -} - -/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布 -pub fn initialize_settings( - store: Arc, - snapshot: Arc, - providers: ProviderAdminRegistry, - pricing_source: Arc, -) -> Arc { - Arc::new(DefaultSettingsService::new( - store, - snapshot, - providers, - pricing_source, - )) -} - -/// HTTP、插件及 CLI 共用的用量领域用例,宿主提供已验证的调用主体 -#[must_use] -pub fn initialize_usage_data( - store: Arc, - settings: Arc, - providers: ProviderAdminRegistry, -) -> Arc { - Arc::new(use_case::usage_data::DefaultUsageDataService::new( - store, settings, providers, - )) -} diff --git a/backend/crates/gateway-admin/src/reset_credit_automation.rs b/backend/crates/gateway-admin/src/reset_credit_automation.rs deleted file mode 100644 index f8a45369c..000000000 --- a/backend/crates/gateway-admin/src/reset_credit_automation.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! 每日错峰发现与已知卡片的临期执行,周期只读取本地到期计划 - -use crate::{ - model::reset_credit_automation::{PlannedResetCredit, ResetCreditAutomation}, - ports::store::{AccountStore, SettingsStore}, - use_case::accounts::AccountsService, -}; -use chrono::{DateTime, Utc}; -use futures::StreamExt as _; -use gateway_core::{ - account::ProviderAccountId, - task::{ScheduledTask, WorkerCycleContext, WorkerTaskError}, - time::DeploymentTimeZone, -}; -use sha2::{Digest as _, Sha256}; -use std::{sync::Arc, time::Duration}; - -/// 当地次日随机发现时间;随机数由调用者提供,结果持久化后不会因重启重抽 -pub(crate) fn next_discovery( - now: DateTime, - timezone: DeploymentTimeZone, - random: u64, -) -> Option> { - let start = timezone.days_after(now, 1)?; - let end = timezone.days_after(now, 2)?; - let seconds = u64::try_from((end - start).num_seconds()).ok()?; - if seconds == 0 { - return None; - } - start.checked_add_signed(chrono::Duration::seconds( - i64::try_from(random % seconds).ok()?, - )) -} - -/// 提前窗口的前 10% 用于账号错峰,最多一分钟,其余时间保留给网络失败恢复 -pub(crate) fn consume_at( - account_id: &str, - credit: &PlannedResetCredit, - lead_minutes: u32, -) -> DateTime { - let lead_seconds = i64::from(lead_minutes) * 60; - let mut hash = Sha256::new(); - hash.update(account_id.as_bytes()); - hash.update([0]); - hash.update(credit.id.as_bytes()); - let digest = hash.finalize(); - let offset = - i64::from(u16::from_be_bytes([digest[0], digest[1]])) % (lead_seconds / 10).clamp(1, 60); - credit.expires_at - chrono::Duration::seconds(lead_seconds - offset) -} - -pub(crate) fn next_consumption( - record: &ResetCreditAutomation, - lead_minutes: u32, -) -> Option> { - record - .state - .credits - .iter() - .map(|credit| consume_at(&record.account_id, credit, lead_minutes)) - .min() -} - -pub(crate) fn reschedule( - record: &mut ResetCreditAutomation, - lead_minutes: u32, - now: DateTime, -) { - record.next_run_at = if let Some(retry) = record.state.retry_at { - retry - } else if record.state.pending.is_some() { - now - } else { - next_consumption(record, lead_minutes).map_or(record.state.next_discovery_at, |due| { - due.min(record.state.next_discovery_at) - }) - }; -} - -pub(crate) struct ResetCreditAutomationTask { - pub(crate) accounts: Arc, - pub(crate) store: Arc, - pub(crate) settings: Arc, -} - -impl ScheduledTask for ResetCreditAutomationTask { - fn run_cycle( - &self, - context: WorkerCycleContext, - ) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> { - Box::pin(async move { - let settings = self - .settings - .load_runtime_settings() - .await - .map_err(|_| WorkerTaskError::safe("重置卡设置读取失败"))?; - // 单轮有界,积压沿到期顺序继续;并发复用已有账号刷新配置 - let due = self - .store - .due_reset_credit_automations(Utc::now(), 100) - .await - .map_err(|_| WorkerTaskError::safe("重置卡计划读取失败"))?; - let work = futures::stream::iter(due) - .map(|account_id| async move { - let id = ProviderAccountId::new(account_id.clone()) - .map_err(|_| WorkerTaskError::safe("重置卡账号 ID 不合法"))?; - if let Err(error) = self.accounts.reconcile_reset_credit_automation(id).await { - tracing::warn!( - account_id, - error_kind = error.kind().as_str(), - "自动重置卡任务失败" - ); - } - Ok::<_, WorkerTaskError>(()) - }) - .buffer_unordered(settings.values.refresh_concurrency.max(1) as usize) - .collect::>(); - tokio::select! { - () = context.cancellation().cancelled() => {}, - _ = tokio::time::timeout(Duration::from_secs(30), work) => {}, - } - Ok(()) - }) - } -} diff --git a/backend/crates/gateway-admin/src/reset_credit_worker.rs b/backend/crates/gateway-admin/src/reset_credit_worker.rs new file mode 100644 index 000000000..5451da251 --- /dev/null +++ b/backend/crates/gateway-admin/src/reset_credit_worker.rs @@ -0,0 +1,91 @@ +//! 自动重置卡 Worker 的到期扫描、并发预算与取消,消费编排由账号用例负责 + +use crate::{ + model::AdminError, + ports::store::{AccountStore, SettingsStore}, + use_case::accounts::AccountsService, +}; +use chrono::Utc; +use futures::StreamExt as _; +use gateway_core::{ + account::ProviderAccountId, + task::{ + ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerId, WorkerKind, + WorkerLeaseRequest, WorkerRegistration, WorkerRunnable, WorkerSchedule, WorkerTaskError, + }, +}; +use std::{sync::Arc, time::Duration}; + +pub(crate) struct ResetCreditAutomationTask { + pub(crate) accounts: Arc, + pub(crate) store: Arc, + pub(crate) settings: Arc, +} + +impl ResetCreditAutomationTask { + pub(crate) fn into_worker(self) -> Result { + let id = WorkerId::try_new(WorkerKind::ResetCreditAutomation, "admin") + .map_err(|_| AdminError::internal("重置卡 Worker ID 不合法"))?; + let schedule = WorkerSchedule::try_new( + Duration::from_secs(5), + Duration::from_secs(5), + Duration::from_secs(60), + Duration::from_secs(90), + Duration::from_secs(30), + ) + .map_err(|_| AdminError::internal("重置卡 Worker 调度配置不合法"))?; + let lease = WorkerLeaseRequest::try_new(id.clone(), Duration::from_secs(90)) + .map_err(|_| AdminError::internal("重置卡 Worker 租约配置不合法"))?; + let registration = WorkerRegistration::try_new( + id, + WorkerRunnable::Scheduled { + schedule, + lease: Some(lease), + task: Box::new(self), + }, + ) + .map_err(|_| AdminError::internal("重置卡 Worker 注册失败"))?; + Ok(WorkerContribution::Registration(registration)) + } +} + +impl ScheduledTask for ResetCreditAutomationTask { + fn run_cycle( + &self, + context: WorkerCycleContext, + ) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> { + Box::pin(async move { + let settings = self + .settings + .load_runtime_settings() + .await + .map_err(|_| WorkerTaskError::safe("重置卡设置读取失败"))?; + // 单轮有界,积压沿到期顺序继续;并发复用已有账号刷新配置 + let due = self + .store + .due_reset_credit_automations(Utc::now(), 100) + .await + .map_err(|_| WorkerTaskError::safe("重置卡计划读取失败"))?; + let work = futures::stream::iter(due) + .map(|account_id| async move { + let id = ProviderAccountId::new(account_id.clone()) + .map_err(|_| WorkerTaskError::safe("重置卡账号 ID 不合法"))?; + if let Err(error) = self.accounts.reconcile_reset_credit_automation(id).await { + tracing::warn!( + account_id, + error_kind = error.kind().as_str(), + "自动重置卡任务失败" + ); + } + Ok::<_, WorkerTaskError>(()) + }) + .buffer_unordered(settings.values.refresh_concurrency.max(1) as usize) + .collect::>(); + tokio::select! { + () = context.cancellation().cancelled() => {}, + _ = tokio::time::timeout(Duration::from_secs(30), work) => {}, + } + Ok(()) + }) + } +} diff --git a/backend/crates/gateway-admin/src/use_case/accounts/reset_credit_automation.rs b/backend/crates/gateway-admin/src/use_case/accounts/reset_credit_automation.rs index 62e788041..daf92dc65 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts/reset_credit_automation.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts/reset_credit_automation.rs @@ -1,7 +1,13 @@ -//! 重置卡自动使用的账号锁、持久幂等键、查询缓存与重试编排 +//! 重置卡查询、消费与自动使用排程,统一账号锁、持久幂等键和重试编排 use super::*; +use crate::model::{MutationActor, reset_credit_automation::*}; +use chrono::{DateTime, Utc}; +use gateway_core::time::DeploymentTimeZone; +use rand_core::{OsRng, RngCore as _}; +use sha2::{Digest as _, Sha256}; + impl DefaultAccountsService { pub(super) async fn reset_credits_upstream( &self, @@ -51,13 +57,6 @@ impl DefaultAccountsService { } } -use crate::{ - model::{MutationActor, reset_credit_automation::*}, - reset_credit_automation::{consume_at, next_consumption, next_discovery, reschedule}, -}; -use chrono::{DateTime, Utc}; -use rand_core::{OsRng, RngCore as _}; - impl DefaultAccountsService { async fn automation_settings( &self, @@ -511,3 +510,54 @@ impl DefaultAccountsService { Ok(()) } } + +/// 当地次日随机发现时间;随机数由调用者提供,结果持久化后不会因重启重抽 +fn next_discovery( + now: DateTime, + timezone: DeploymentTimeZone, + random: u64, +) -> Option> { + let start = timezone.days_after(now, 1)?; + let end = timezone.days_after(now, 2)?; + let seconds = u64::try_from((end - start).num_seconds()).ok()?; + if seconds == 0 { + return None; + } + start.checked_add_signed(chrono::Duration::seconds( + i64::try_from(random % seconds).ok()?, + )) +} + +/// 提前窗口的前 10% 用于账号错峰,最多一分钟,其余时间保留给网络失败恢复 +fn consume_at(account_id: &str, credit: &PlannedResetCredit, lead_minutes: u32) -> DateTime { + let lead_seconds = i64::from(lead_minutes) * 60; + let mut hash = Sha256::new(); + hash.update(account_id.as_bytes()); + hash.update([0]); + hash.update(credit.id.as_bytes()); + let digest = hash.finalize(); + let offset = + i64::from(u16::from_be_bytes([digest[0], digest[1]])) % (lead_seconds / 10).clamp(1, 60); + credit.expires_at - chrono::Duration::seconds(lead_seconds - offset) +} + +fn next_consumption(record: &ResetCreditAutomation, lead_minutes: u32) -> Option> { + record + .state + .credits + .iter() + .map(|credit| consume_at(&record.account_id, credit, lead_minutes)) + .min() +} + +fn reschedule(record: &mut ResetCreditAutomation, lead_minutes: u32, now: DateTime) { + record.next_run_at = if let Some(retry) = record.state.retry_at { + retry + } else if record.state.pending.is_some() { + now + } else { + next_consumption(record, lead_minutes).map_or(record.state.next_discovery_at, |due| { + due.min(record.state.next_discovery_at) + }) + }; +} diff --git a/backend/crates/gateway-admin/src/use_case/import_tasks.rs b/backend/crates/gateway-admin/src/use_case/import_tasks.rs index a06b1512c..0f94b0bb6 100644 --- a/backend/crates/gateway-admin/src/use_case/import_tasks.rs +++ b/backend/crates/gateway-admin/src/use_case/import_tasks.rs @@ -9,7 +9,10 @@ use std::{ use chrono::Utc; use futures::{FutureExt as _, StreamExt as _, future::BoxFuture, stream::FuturesUnordered}; -use gateway_core::task::{DaemonTask, WorkerDaemonContext, WorkerTaskError}; +use gateway_core::task::{ + DaemonRestartPolicy, DaemonTask, WorkerContribution, WorkerDaemonContext, WorkerId, WorkerKind, + WorkerRegistration, WorkerRunnable, WorkerTaskError, +}; use tokio::{sync::Notify, time::Instant}; use uuid::Uuid; @@ -328,6 +331,24 @@ impl ImportTasksService for DefaultImportTasksService { pub(crate) struct ImportTaskWorker(pub Arc); +impl ImportTaskWorker { + pub(crate) fn into_worker(self) -> Result { + let id = WorkerId::try_new(WorkerKind::AccountImport, "admin") + .map_err(|_| AdminError::internal("导入 Worker ID 不合法"))?; + let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60)) + .map_err(|_| AdminError::internal("导入 Worker 重启策略不合法"))?; + let registration = WorkerRegistration::try_new( + id, + WorkerRunnable::Daemon { + restart, + task: Box::new(self), + }, + ) + .map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?; + Ok(WorkerContribution::Registration(registration)) + } +} + impl DaemonTask for ImportTaskWorker { fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> { Box::pin(async move { diff --git a/backend/crates/gateway-admin/src/use_case/mod.rs b/backend/crates/gateway-admin/src/use_case/mod.rs index be8cafe4d..b1bdb4de6 100644 --- a/backend/crates/gateway-admin/src/use_case/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/mod.rs @@ -14,11 +14,11 @@ pub mod peer_link; pub mod plugin_accounts; pub mod plugin_client_keys; pub mod plugin_resources; -pub(crate) mod plugin_update; pub mod plugins; pub mod proxies; pub mod settings; pub mod system; +pub(crate) mod system_preflight; pub mod usage_data; mod credential_mutation; diff --git a/backend/crates/gateway-admin/src/use_case/system.rs b/backend/crates/gateway-admin/src/use_case/system.rs index bc83aaca1..d115d914e 100644 --- a/backend/crates/gateway-admin/src/use_case/system.rs +++ b/backend/crates/gateway-admin/src/use_case/system.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use super::plugin_update::{ConfirmedPluginRestart, PluginSystemUpdatePreflight}; +use super::system_preflight::{ConfirmedPluginRestart, PluginSystemUpdatePreflight}; use crate::model::{MutationContext, system::SystemRestartPlan}; use async_trait::async_trait; diff --git a/backend/crates/gateway-admin/src/use_case/plugin_update.rs b/backend/crates/gateway-admin/src/use_case/system_preflight.rs similarity index 100% rename from backend/crates/gateway-admin/src/use_case/plugin_update.rs rename to backend/crates/gateway-admin/src/use_case/system_preflight.rs diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index aea192824..3c995c780 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -11,11 +11,11 @@ mod freeze_recovery; mod import_tasks; mod observability; mod peer_link; -mod plugin_update; mod plugins; mod proxies; mod settings; mod system; +mod system_preflight; mod usage_data; use std::{ diff --git a/backend/crates/gateway-admin/tests/use_case/plugin_update.rs b/backend/crates/gateway-admin/tests/use_case/system_preflight.rs similarity index 100% rename from backend/crates/gateway-admin/tests/use_case/plugin_update.rs rename to backend/crates/gateway-admin/tests/use_case/system_preflight.rs diff --git a/backend/crates/gateway-api/src/admin/observability/query.rs b/backend/crates/gateway-api/src/admin/observability/query.rs index 96104d837..aafab5dc4 100644 --- a/backend/crates/gateway-api/src/admin/observability/query.rs +++ b/backend/crates/gateway-api/src/admin/observability/query.rs @@ -2,6 +2,8 @@ use super::*; +pub use crate::time::parse_datetime; + /// 观测列表默认页大小 pub const DEFAULT_PAGE_SIZE: u16 = 50; /// 观测列表允许的最大页大小 @@ -256,16 +258,6 @@ impl DiagnosticDimension { } } -/// 解析 RFC3339 时间;错误不回显原始值 -pub fn parse_datetime(value: Option<&str>) -> Result>, WireValidationError> { - let Some(value) = trimmed(value) else { - return Ok(None); - }; - DateTime::parse_from_rfc3339(value) - .map(|value| Some(value.with_timezone(&Utc))) - .map_err(|_| WireValidationError::new("timeRange")) -} - /// 解析 HTTP 状态码 pub fn parse_status(value: Option) -> Result, WireValidationError> { value diff --git a/backend/crates/gateway-api/src/admin/wire.rs b/backend/crates/gateway-api/src/admin/wire.rs index d1f136673..350da4816 100644 --- a/backend/crates/gateway-api/src/admin/wire.rs +++ b/backend/crates/gateway-api/src/admin/wire.rs @@ -9,25 +9,7 @@ use axum::{ }; use serde::{Deserialize, Serialize}; -/// 管理端 wire 字段校验错误;只携带稳定字段名,不回显输入值 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct WireValidationError { - field: &'static str, -} - -impl WireValidationError { - /// 构造字段校验错误 - #[must_use] - pub const fn new(field: &'static str) -> Self { - Self { field } - } - - /// 返回未通过校验的字段名 - #[must_use] - pub const fn field(self) -> &'static str { - self.field - } -} +pub use crate::WireValidationError; /// 成功响应的稳定业务码 pub const ADMIN_OK_CODE: u32 = 200; diff --git a/backend/crates/gateway-api/src/client_error.rs b/backend/crates/gateway-api/src/client_error.rs new file mode 100644 index 000000000..60ac82420 --- /dev/null +++ b/backend/crates/gateway-api/src/client_error.rs @@ -0,0 +1,228 @@ +//! 数据面与公共 HTTP 中间件共用的客户端错误合同和重试投影 + +use axum::{ + Json, + http::{HeaderValue, StatusCode}, + response::{IntoResponse, Response}, +}; +use gateway_core::error::{GatewayError, GatewayErrorKind}; +use serde::Serialize; +use serde_json::{Value, json}; + +/// OpenAI 风格的协议错误 JSON +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProtocolErrorBody { + /// 错误对象 + pub error: ProtocolError, +} + +/// 不包含请求正文或 prompt 的错误对象 +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProtocolError { + /// OpenAI 错误类别 + #[serde(rename = "type")] + pub kind: &'static str, + /// 稳定机器码 + pub code: &'static str, + /// 安全的可读信息 + pub message: String, + /// 出错字段;不包含字段值 + #[serde(skip_serializing_if = "Option::is_none")] + pub param: Option, +} + +impl ProtocolErrorBody { + /// 序列化为 JSON value + #[must_use] + pub fn into_value(self) -> Value { + serde_json::json!({ "error": self.error }) + } +} + +/// OpenAI 风格错误响应 +pub fn openai_error_response( + status: StatusCode, + message: &str, + error_type: &str, + code: &str, +) -> (StatusCode, Json) { + let body = json!({ + "error": {"message": message, "type": error_type, "code": code} + }); + match capacity_error_for_client(&body) { + Some(body) => (StatusCode::SERVICE_UNAVAILABLE, Json(body)), + None => (status, Json(body)), + } +} + +/// 无重试提示的容量拒绝及旧版 Codex 限流错误需要可重试投影 +/// 仅在交付边界转换,内部仍保留真实上游事实 +pub(crate) fn client_error_code(code: &str) -> &str { + match code { + "server_is_overloaded" | "slow_down" => "server_error", + _ => code, + } +} + +/// 为 HTTP 错误或流内失败保留原有字段,仅投影客户端需要的重试信号 +pub(crate) fn capacity_error_for_client(data: &Value) -> Option { + const CODE_PATHS: [&str; 2] = ["/error/code", "/response/error/code"]; + let capacity_code = |value: &Value| { + value + .as_str() + .is_some_and(|code| client_error_code(code) != code) + }; + if !CODE_PATHS + .iter() + .any(|path| data.pointer(path).is_some_and(capacity_code)) + { + return None; + } + let mut projected = data.clone(); + for path in CODE_PATHS { + if let Some(code) = projected.pointer_mut(path) + && capacity_code(code) + { + *code = Value::String("server_error".to_owned()); + } + } + // WS 包装错误按 HTTP 状态分类;保留 400/429 会使客户端终止重试 + for field in ["status", "status_code"] { + if let Some(status) = projected.get_mut(field) + && status.is_number() + { + *status = json!(503); + } + } + Some(projected) +} + +/// Gateway 错误的 OpenAI HTTP 表达 +pub fn gateway_error_response(error: &GatewayError) -> Response { + let (status, default_type, default_code) = gateway_error_contract(error.kind()); + let mut response = openai_error_response( + status, + error.client_message(), + error.client_error_type().unwrap_or(default_type), + error.client_error_code().unwrap_or(default_code), + ) + .into_response(); + if let Some(delay) = error.retry_after() + && let Ok(value) = HeaderValue::from_str( + &delay + .as_secs() + .saturating_add(u64::from(delay.subsec_nanos() > 0)) + .max(1) + .to_string(), + ) + { + response + .headers_mut() + .insert(axum::http::header::RETRY_AFTER, value); + } + response +} + +/// Gateway 错误稳定映射,供 HTTP、SSE 和 WebSocket 共用 +#[must_use] +pub const fn gateway_error_contract( + kind: GatewayErrorKind, +) -> (StatusCode, &'static str, &'static str) { + match kind { + GatewayErrorKind::InvalidRequest => ( + StatusCode::BAD_REQUEST, + "invalid_request_error", + "invalid_request", + ), + GatewayErrorKind::Unsupported => ( + StatusCode::BAD_REQUEST, + "invalid_request_error", + "unsupported_capability", + ), + GatewayErrorKind::Unauthorized => ( + StatusCode::UNAUTHORIZED, + "invalid_request_error", + "invalid_api_key", + ), + GatewayErrorKind::PolicyDenied => ( + StatusCode::FORBIDDEN, + "invalid_request_error", + "policy_denied", + ), + GatewayErrorKind::ModelNotFound => ( + StatusCode::NOT_FOUND, + "invalid_request_error", + "model_not_found", + ), + GatewayErrorKind::NoAvailableProvider => ( + StatusCode::SERVICE_UNAVAILABLE, + "server_error", + "no_available_provider", + ), + GatewayErrorKind::AccountCapacityUnavailable => ( + StatusCode::SERVICE_UNAVAILABLE, + "server_error", + "account_capacity_unavailable", + ), + GatewayErrorKind::ProviderInfrastructureUnavailable => ( + StatusCode::SERVICE_UNAVAILABLE, + "server_error", + "provider_infrastructure_unavailable", + ), + GatewayErrorKind::MiddlewareCapacityUnavailable => ( + StatusCode::SERVICE_UNAVAILABLE, + "server_error", + "middleware_capacity_unavailable", + ), + GatewayErrorKind::ConcurrencyQueueFull => ( + StatusCode::TOO_MANY_REQUESTS, + "rate_limit_error", + "concurrency_queue_full", + ), + GatewayErrorKind::ConcurrencyQueueTimeout => ( + StatusCode::TOO_MANY_REQUESTS, + "rate_limit_error", + "concurrency_queue_timeout", + ), + GatewayErrorKind::RateLimited => ( + StatusCode::TOO_MANY_REQUESTS, + "rate_limit_error", + "rate_limit_exceeded", + ), + GatewayErrorKind::BudgetExhausted => ( + StatusCode::TOO_MANY_REQUESTS, + "usage_limit_reached", + "usage_limit_reached", + ), + GatewayErrorKind::UpstreamUnavailable => ( + StatusCode::BAD_GATEWAY, + "server_error", + "upstream_unavailable", + ), + GatewayErrorKind::MessageTooBig => ( + StatusCode::PAYLOAD_TOO_LARGE, + "invalid_request_error", + "message_too_big", + ), + GatewayErrorKind::Timeout => ( + StatusCode::GATEWAY_TIMEOUT, + "server_error", + "request_timeout", + ), + GatewayErrorKind::Cancelled => ( + StatusCode::REQUEST_TIMEOUT, + "server_error", + "request_cancelled", + ), + GatewayErrorKind::Internal => ( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "internal_error", + ), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "internal_error", + ), + } +} diff --git a/backend/crates/gateway-api/src/lib.rs b/backend/crates/gateway-api/src/lib.rs index ea85ab0d8..78cef67c7 100644 --- a/backend/crates/gateway-api/src/lib.rs +++ b/backend/crates/gateway-api/src/lib.rs @@ -29,6 +29,7 @@ use crate::openai::service::OpenAiService; pub mod admin; pub mod auth; +mod client_error; mod health; mod key_usage; mod middleware; @@ -40,6 +41,26 @@ mod time; pub use time::{RequestBucketView, TimePresenter}; +/// HTTP wire 字段校验错误;只携带稳定字段名,不回显输入值 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WireValidationError { + field: &'static str, +} + +impl WireValidationError { + /// 构造字段校验错误 + #[must_use] + pub const fn new(field: &'static str) -> Self { + Self { field } + } + + /// 返回未通过校验的字段名 + #[must_use] + pub const fn field(self) -> &'static str { + self.field + } +} + /// API-owned HTTP 与静态资源配置 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct ApiConfig { diff --git a/backend/crates/gateway-api/src/middleware/mod.rs b/backend/crates/gateway-api/src/middleware/mod.rs index 46a766234..cee708b22 100644 --- a/backend/crates/gateway-api/src/middleware/mod.rs +++ b/backend/crates/gateway-api/src/middleware/mod.rs @@ -262,7 +262,7 @@ async fn handle( Err(MiddlewareError::Gateway(error)) if error.kind() == GatewayErrorKind::MiddlewareCapacityUnavailable => { - crate::openai::error::gateway_error_response(&error) + crate::client_error::gateway_error_response(&error) } Err(error) => { let status = if error.is_rejected() { diff --git a/backend/crates/gateway-api/src/openai/chat_completions/delivery.rs b/backend/crates/gateway-api/src/openai/chat_completions/delivery.rs index 17e11248e..5e602b6a4 100644 --- a/backend/crates/gateway-api/src/openai/chat_completions/delivery.rs +++ b/backend/crates/gateway-api/src/openai/chat_completions/delivery.rs @@ -15,7 +15,8 @@ use gateway_core::{ use gateway_protocol::openai::sse::{DONE_SSE_FRAME, SseEvent, SseEventDecoder, sse_frame_is_done}; use serde_json::Value; -use crate::openai::{middleware::error_response, responses::ProtocolErrorBody}; +use crate::client_error::ProtocolErrorBody; +use crate::openai::middleware::error_response; use super::response::{ChatIdentity, ChatStreamEncoder, project_response}; diff --git a/backend/crates/gateway-api/src/openai/chat_completions/response.rs b/backend/crates/gateway-api/src/openai/chat_completions/response.rs index 489823c39..db6e05aa3 100644 --- a/backend/crates/gateway-api/src/openai/chat_completions/response.rs +++ b/backend/crates/gateway-api/src/openai/chat_completions/response.rs @@ -8,7 +8,7 @@ use gateway_protocol::openai::sse::encode_sse_event; use serde::Serialize; use serde_json::{Value, json}; -use crate::openai::responses::{ProtocolError, ProtocolErrorBody}; +use crate::client_error::{ProtocolError, ProtocolErrorBody}; #[derive(Debug, Clone, Serialize)] pub(in crate::openai) struct ChatIdentity { diff --git a/backend/crates/gateway-api/src/openai/endpoint.rs b/backend/crates/gateway-api/src/openai/endpoint.rs index ce7b00b3b..86dd20f9f 100644 --- a/backend/crates/gateway-api/src/openai/endpoint.rs +++ b/backend/crates/gateway-api/src/openai/endpoint.rs @@ -16,6 +16,7 @@ use gateway_core::{ operation::Operation, }; +use crate::client_error::{ProtocolError, ProtocolErrorBody}; use crate::middleware::headers::encode_headers; use crate::openai::middleware::{ ExpectedBody, PendingExecution, RequestInput, buffered_response, error_response, @@ -25,7 +26,6 @@ use gateway_core::event::{ProviderEvent, ProviderResponseHeader}; use super::{ error::{engine_error_response, gateway_error_response, protocol_error_response}, - responses::{ProtocolError, ProtocolErrorBody}, service::OpenAiService, }; diff --git a/backend/crates/gateway-api/src/openai/error.rs b/backend/crates/gateway-api/src/openai/error.rs index d0e9e8288..01cf91e85 100644 --- a/backend/crates/gateway-api/src/openai/error.rs +++ b/backend/crates/gateway-api/src/openai/error.rs @@ -8,72 +8,18 @@ use axum::{ }; use gateway_core::{ engine::EngineError, - error::{GatewayError, GatewayErrorKind, ProviderErrorKind}, + error::ProviderErrorKind, policy::{ClientVersionRejection, CodexClientKind}, }; use serde_json::{Value, json}; pub use gateway_core::engine::execution::gateway_error_from_engine; -use super::responses::ProtocolErrorBody; +use crate::client_error::{ProtocolErrorBody, capacity_error_for_client}; -/// OpenAI 风格错误响应 -pub fn openai_error_response( - status: StatusCode, - message: &str, - error_type: &str, - code: &str, -) -> (StatusCode, Json) { - let body = json!({ - "error": {"message": message, "type": error_type, "code": code} - }); - match capacity_error_for_client(&body) { - Some(body) => (StatusCode::SERVICE_UNAVAILABLE, Json(body)), - None => (status, Json(body)), - } -} - -/// 无重试提示的容量拒绝及旧版 Codex 限流错误需要可重试投影 -/// 仅在交付边界转换,内部仍保留真实上游事实 -pub(super) fn client_error_code(code: &str) -> &str { - match code { - "server_is_overloaded" | "slow_down" => "server_error", - _ => code, - } -} - -/// 为 HTTP 错误或流内失败保留原有字段,仅投影客户端需要的重试信号 -pub(super) fn capacity_error_for_client(data: &Value) -> Option { - const CODE_PATHS: [&str; 2] = ["/error/code", "/response/error/code"]; - let capacity_code = |value: &Value| { - value - .as_str() - .is_some_and(|code| client_error_code(code) != code) - }; - if !CODE_PATHS - .iter() - .any(|path| data.pointer(path).is_some_and(capacity_code)) - { - return None; - } - let mut projected = data.clone(); - for path in CODE_PATHS { - if let Some(code) = projected.pointer_mut(path) - && capacity_code(code) - { - *code = Value::String("server_error".to_owned()); - } - } - // WS 包装错误按 HTTP 状态分类;保留 400/429 会使客户端终止重试 - for field in ["status", "status_code"] { - if let Some(status) = projected.get_mut(field) - && status.is_number() - { - *status = json!(503); - } - } - Some(projected) -} +pub use crate::client_error::{ + gateway_error_contract, gateway_error_response, openai_error_response, +}; /// 严格协议 decoder/encoder 返回的安全错误 body pub fn protocol_error_response(status: StatusCode, body: ProtocolErrorBody) -> Response { @@ -151,32 +97,6 @@ pub fn model_not_found_response() -> (StatusCode, Json) { ) } -/// Gateway 错误的 OpenAI HTTP 表达 -pub fn gateway_error_response(error: &GatewayError) -> Response { - let (status, default_type, default_code) = gateway_error_contract(error.kind()); - let mut response = openai_error_response( - status, - error.client_message(), - error.client_error_type().unwrap_or(default_type), - error.client_error_code().unwrap_or(default_code), - ) - .into_response(); - if let Some(delay) = error.retry_after() - && let Ok(value) = HeaderValue::from_str( - &delay - .as_secs() - .saturating_add(u64::from(delay.subsec_nanos() > 0)) - .max(1) - .to_string(), - ) - { - response - .headers_mut() - .insert(axum::http::header::RETRY_AFTER, value); - } - response -} - /// 在下游尚未提交时交付 Provider 的 HTTP 失败响应,并应用客户端容量恢复合同 pub fn engine_error_response(error: &EngineError) -> Response { let capacity_unavailable = matches!(error, EngineError::Provider(error) @@ -233,107 +153,3 @@ pub fn engine_error_response(error: &EngineError) -> Response { } response } - -/// Gateway 错误稳定映射,供 HTTP、SSE 和 WebSocket 共用 -#[must_use] -pub const fn gateway_error_contract( - kind: GatewayErrorKind, -) -> (StatusCode, &'static str, &'static str) { - match kind { - GatewayErrorKind::InvalidRequest => ( - StatusCode::BAD_REQUEST, - "invalid_request_error", - "invalid_request", - ), - GatewayErrorKind::Unsupported => ( - StatusCode::BAD_REQUEST, - "invalid_request_error", - "unsupported_capability", - ), - GatewayErrorKind::Unauthorized => ( - StatusCode::UNAUTHORIZED, - "invalid_request_error", - "invalid_api_key", - ), - GatewayErrorKind::PolicyDenied => ( - StatusCode::FORBIDDEN, - "invalid_request_error", - "policy_denied", - ), - GatewayErrorKind::ModelNotFound => ( - StatusCode::NOT_FOUND, - "invalid_request_error", - "model_not_found", - ), - GatewayErrorKind::NoAvailableProvider => ( - StatusCode::SERVICE_UNAVAILABLE, - "server_error", - "no_available_provider", - ), - GatewayErrorKind::AccountCapacityUnavailable => ( - StatusCode::SERVICE_UNAVAILABLE, - "server_error", - "account_capacity_unavailable", - ), - GatewayErrorKind::ProviderInfrastructureUnavailable => ( - StatusCode::SERVICE_UNAVAILABLE, - "server_error", - "provider_infrastructure_unavailable", - ), - GatewayErrorKind::MiddlewareCapacityUnavailable => ( - StatusCode::SERVICE_UNAVAILABLE, - "server_error", - "middleware_capacity_unavailable", - ), - GatewayErrorKind::ConcurrencyQueueFull => ( - StatusCode::TOO_MANY_REQUESTS, - "rate_limit_error", - "concurrency_queue_full", - ), - GatewayErrorKind::ConcurrencyQueueTimeout => ( - StatusCode::TOO_MANY_REQUESTS, - "rate_limit_error", - "concurrency_queue_timeout", - ), - GatewayErrorKind::RateLimited => ( - StatusCode::TOO_MANY_REQUESTS, - "rate_limit_error", - "rate_limit_exceeded", - ), - GatewayErrorKind::BudgetExhausted => ( - StatusCode::TOO_MANY_REQUESTS, - "usage_limit_reached", - "usage_limit_reached", - ), - GatewayErrorKind::UpstreamUnavailable => ( - StatusCode::BAD_GATEWAY, - "server_error", - "upstream_unavailable", - ), - GatewayErrorKind::MessageTooBig => ( - StatusCode::PAYLOAD_TOO_LARGE, - "invalid_request_error", - "message_too_big", - ), - GatewayErrorKind::Timeout => ( - StatusCode::GATEWAY_TIMEOUT, - "server_error", - "request_timeout", - ), - GatewayErrorKind::Cancelled => ( - StatusCode::REQUEST_TIMEOUT, - "server_error", - "request_cancelled", - ), - GatewayErrorKind::Internal => ( - StatusCode::INTERNAL_SERVER_ERROR, - "server_error", - "internal_error", - ), - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - "server_error", - "internal_error", - ), - } -} diff --git a/backend/crates/gateway-api/src/openai/responses/error.rs b/backend/crates/gateway-api/src/openai/responses/error.rs index 9469ee0d9..d7e079d64 100644 --- a/backend/crates/gateway-api/src/openai/responses/error.rs +++ b/backend/crates/gateway-api/src/openai/responses/error.rs @@ -1,39 +1,8 @@ //! OpenAI Responses adapter 的稳定错误 contract -use serde::Serialize; -use serde_json::Value; +use crate::client_error::{ProtocolError, ProtocolErrorBody}; use thiserror::Error; -/// OpenAI 风格的协议错误 JSON -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ProtocolErrorBody { - /// 错误对象 - pub error: ProtocolError, -} - -/// 不包含请求正文或 prompt 的错误对象 -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ProtocolError { - /// OpenAI 错误类别 - #[serde(rename = "type")] - pub kind: &'static str, - /// 稳定机器码 - pub code: &'static str, - /// 安全的可读信息 - pub message: String, - /// 出错字段;不包含字段值 - #[serde(skip_serializing_if = "Option::is_none")] - pub param: Option, -} - -impl ProtocolErrorBody { - /// 序列化为 JSON value - #[must_use] - pub fn into_value(self) -> Value { - serde_json::json!({ "error": self.error }) - } -} - /// Responses 请求解码错误 #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum RequestDecodeError { diff --git a/backend/crates/gateway-api/src/openai/responses/http.rs b/backend/crates/gateway-api/src/openai/responses/http.rs index f0f73d06b..66c682f30 100644 --- a/backend/crates/gateway-api/src/openai/responses/http.rs +++ b/backend/crates/gateway-api/src/openai/responses/http.rs @@ -943,7 +943,7 @@ impl StreamingExecutionBody { Bytes::from(response_failed_sse_event_with_id( self.encoder.response_id(), error.client_error_type().unwrap_or(default_type), - super::super::error::client_error_code( + crate::client_error::client_error_code( error.client_error_code().unwrap_or(default_code), ), error.client_message(), diff --git a/backend/crates/gateway-api/src/openai/responses/mod.rs b/backend/crates/gateway-api/src/openai/responses/mod.rs index ff34dcaa8..ad17a770d 100644 --- a/backend/crates/gateway-api/src/openai/responses/mod.rs +++ b/backend/crates/gateway-api/src/openai/responses/mod.rs @@ -8,8 +8,9 @@ mod response; mod validation; pub mod websocket; +pub use crate::client_error::{ProtocolError, ProtocolErrorBody}; pub(crate) use crate::request_context::request_client_context; -pub use error::{ProtocolError, ProtocolErrorBody, RequestDecodeError, ResponseEncodeError}; +pub use error::{RequestDecodeError, ResponseEncodeError}; pub(in crate::openai) use http::{HttpResponseAdapter, execute_prepared_response}; pub(crate) use http::{ ResponseAuthorization, ResponsesHttpRequest, execute_prepared_responses, responses, diff --git a/backend/crates/gateway-api/src/openai/responses/response.rs b/backend/crates/gateway-api/src/openai/responses/response.rs index d1e64e7bd..293a8f752 100644 --- a/backend/crates/gateway-api/src/openai/responses/response.rs +++ b/backend/crates/gateway-api/src/openai/responses/response.rs @@ -10,7 +10,7 @@ use gateway_protocol::openai::sse::{ use serde_json::Value; use super::error::ResponseEncodeError; -use crate::openai::error::capacity_error_for_client; +use crate::client_error::capacity_error_for_client; const OPENAI_PROTOCOL: &str = "openai"; diff --git a/backend/crates/gateway-api/src/openai/responses/websocket/protocol.rs b/backend/crates/gateway-api/src/openai/responses/websocket/protocol.rs index 61d71aa58..7553dcc64 100644 --- a/backend/crates/gateway-api/src/openai/responses/websocket/protocol.rs +++ b/backend/crates/gateway-api/src/openai/responses/websocket/protocol.rs @@ -1,5 +1,7 @@ //! Responses WebSocket 入站请求与下行事件的纯协议映射 +use crate::client_error::capacity_error_for_client; + use axum::http::{HeaderName, HeaderValue, StatusCode}; use gateway_core::engine::EngineError; use gateway_core::error::ProviderErrorKind; @@ -7,9 +9,7 @@ use gateway_core::event::ProviderResponseHeader; use serde_json::{Map, Value, json}; use thiserror::Error; -use crate::openai::error::{ - capacity_error_for_client, gateway_error_contract, gateway_error_from_engine, -}; +use crate::openai::error::{gateway_error_contract, gateway_error_from_engine}; use super::super::{ DecodedResponsesRequest, ProtocolErrorBody, RequestDecodeError, diff --git a/backend/crates/gateway-api/src/time.rs b/backend/crates/gateway-api/src/time.rs index 182fb931e..3b0710286 100644 --- a/backend/crates/gateway-api/src/time.rs +++ b/backend/crates/gateway-api/src/time.rs @@ -1,8 +1,20 @@ -//! 管理与自助页面共用的时间展示投影 +//! 管理与自助页面共用的时间查询解析与展示投影 use chrono::{DateTime, Utc}; use gateway_core::time::DeploymentTimeZone; +use crate::WireValidationError; + +/// 解析 RFC3339 时间;错误不回显原始值 +pub fn parse_datetime(value: Option<&str>) -> Result>, WireValidationError> { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + DateTime::parse_from_rfc3339(value) + .map(|value| Some(value.with_timezone(&Utc))) + .map_err(|_| WireValidationError::new("timeRange")) +} + pub(crate) fn query_range( start: Option<&str>, end: Option<&str>, @@ -10,8 +22,7 @@ pub(crate) fn query_range( as_of: Option, timezone: DeploymentTimeZone, default_period: &str, -) -> Result { - use crate::admin::{WireValidationError, observability::parse_datetime}; +) -> Result { use gateway_admin::model::observability::{CalendarPeriod, TimeRange}; if start.is_some() || end.is_some() { if period.is_some() || as_of.is_some() { diff --git a/backend/crates/gateway-host/src/config.rs b/backend/crates/gateway-host/src/config.rs index bf188b751..66fd7d414 100644 --- a/backend/crates/gateway-host/src/config.rs +++ b/backend/crates/gateway-host/src/config.rs @@ -8,7 +8,7 @@ use serde::Deserialize; use serde::de::DeserializeOwned; use tracing_subscriber::EnvFilter; -use crate::system_update::SystemUpdateConfig; +use crate::{ConfigError, system_update::SystemUpdateConfig}; const CONFIG_RELATIVE_PATH: &str = "deploy/config.yaml"; const SERVER_HOST_ENV: &str = "CPR_SERVER_HOST"; @@ -216,26 +216,6 @@ pub struct FileLoggingConfig { pub max_file_size_mb: u64, } -#[derive(Debug, thiserror::Error)] -pub enum ConfigError { - #[error("配置文件 {path} 的字段 {field:?} 不合法")] - InvalidValue { path: PathBuf, field: String }, - #[error("current directory is unavailable")] - CurrentDirectory, - #[error("deploy/config.yaml was not found")] - ConfigFileNotFound, - #[error("configuration path is invalid")] - InvalidConfigPath, - #[error("configuration document is invalid: {path}")] - InvalidDocument { path: PathBuf }, - #[error("配置文件 {path} 缺少必填字段 {field:?}")] - MissingField { path: PathBuf, field: String }, - #[error("configuration field is invalid: {0}")] - InvalidField(&'static str), - #[error("environment variable is invalid: {0}")] - InvalidEnvironment(&'static str), -} - fn optional_environment_value(name: &'static str) -> Result, ConfigError> { match env::var(name) { Ok(value) if value.trim().is_empty() => Err(ConfigError::InvalidEnvironment(name)), diff --git a/backend/crates/gateway-host/src/lib.rs b/backend/crates/gateway-host/src/lib.rs index 82f05d369..4ada55ff4 100644 --- a/backend/crates/gateway-host/src/lib.rs +++ b/backend/crates/gateway-host/src/lib.rs @@ -28,7 +28,34 @@ use gateway_core::lifecycle::CancellationToken; use gateway_core::lifecycle::ConnectionLifecycle; use gateway_core::task::{WorkerContribution, WorkerLeaderLeasePort}; -pub use config::{ConfigError, HostConfig, LoadableConfig, load_config}; +pub use config::{HostConfig, LoadableConfig, load_config}; + +/// Host 配置加载与各能力配置校验共用的脱敏错误 +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("配置文件 {path} 的字段 {field:?} 不合法")] + InvalidValue { + path: std::path::PathBuf, + field: String, + }, + #[error("current directory is unavailable")] + CurrentDirectory, + #[error("deploy/config.yaml was not found")] + ConfigFileNotFound, + #[error("configuration path is invalid")] + InvalidConfigPath, + #[error("configuration document is invalid: {path}")] + InvalidDocument { path: std::path::PathBuf }, + #[error("配置文件 {path} 缺少必填字段 {field:?}")] + MissingField { + path: std::path::PathBuf, + field: String, + }, + #[error("configuration field is invalid: {0}")] + InvalidField(&'static str), + #[error("environment variable is invalid: {0}")] + InvalidEnvironment(&'static str), +} use self::client_distribution::RgAdguardClientDistribution; use self::logging::{LogGuard, initialize_logging}; diff --git a/backend/crates/gateway-host/src/logging/mod.rs b/backend/crates/gateway-host/src/logging/mod.rs index cc33d94c9..e9ad8ad41 100644 --- a/backend/crates/gateway-host/src/logging/mod.rs +++ b/backend/crates/gateway-host/src/logging/mod.rs @@ -46,7 +46,7 @@ pub enum LogError { #[error("logging size limit is too large")] SizeOverflow, #[error(transparent)] - InvalidConfig(#[from] crate::config::ConfigError), + InvalidConfig(#[from] crate::ConfigError), } /// 初始化日志;按各通道的保留窗口清理,不以分片数量淘汰记录 diff --git a/backend/crates/gateway-host/src/system_update/config.rs b/backend/crates/gateway-host/src/system_update/config.rs index e0d7f5db3..e96fe86e5 100644 --- a/backend/crates/gateway-host/src/system_update/config.rs +++ b/backend/crates/gateway-host/src/system_update/config.rs @@ -8,7 +8,7 @@ use std::{ use serde::Deserialize; use super::{OperationError, conflict, internal}; -use crate::config::ConfigError; +use crate::ConfigError; const APP_BINARY_NAME: &str = "codex-proxy-rs"; const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com/repos"; diff --git a/backend/crates/gateway-plugin/runtime/src/callback/private_state.rs b/backend/crates/gateway-plugin/runtime/src/callback/private_state.rs index 3ffc3c93c..82b636dcb 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/private_state.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/private_state.rs @@ -12,7 +12,7 @@ use gateway_admin::{ instances::PluginInstance, state::{ DeletePluginState, PluginStateConfiguration, PluginStateOwner, - PluginStateOwnerRequest, PluginStateSchema, PutPluginState, + PluginStateOwnerRequest, PutPluginState, }, }, }, @@ -25,7 +25,6 @@ use gateway_plugin_sdk::{ StatePutRequest, StatePutResult, StateRecord, }, }; -use sha2::{Digest as _, Sha256}; use crate::RpcReply; @@ -43,7 +42,7 @@ impl PluginPrivateState { configuration: PluginStateConfiguration, owner: Option, ) -> Result { - let validators = validators(manifest)?; + let validators = crate::package::state_validators(manifest)?; Ok(Self { store, configuration, @@ -175,46 +174,6 @@ impl PluginPrivateState { } } -pub(crate) fn configuration(manifest: &Manifest) -> Result { - let _ = validators(manifest)?; - let mut namespaces = Vec::with_capacity(manifest.state.len()); - for state in &manifest.state { - let mut schema = state.schema.clone(); - schema.sort_all_objects(); - let encoded = serde_json::to_vec(&schema) - .map_err(|_| AdminError::invalid("插件状态 schema 无法编码"))?; - namespaces.push(PluginStateSchema { - namespace: state.namespace.clone(), - schema_version: state.schema_version, - schema_sha256: hex::encode(Sha256::digest(encoded)), - schema: state.schema.clone(), - maximum_records: state.maximum_records, - maximum_bytes: state.maximum_bytes, - maximum_value_bytes: state.maximum_value_bytes, - migrates_from: state.migrates_from.clone(), - }); - } - namespaces.sort_by(|left, right| left.namespace.cmp(&right.namespace)); - Ok(PluginStateConfiguration { namespaces }) -} - -fn validators(manifest: &Manifest) -> Result, AdminError> { - manifest - .state - .iter() - .map(|state| { - let validator = jsonschema::options() - .offline() - .with_pattern_options( - jsonschema::PatternOptions::fancy_regex().backtrack_limit(20_000), - ) - .build(&state.schema) - .map_err(|_| AdminError::invalid("插件状态 schema 无效或引用了外部资源"))?; - Ok((state.namespace.clone(), validator)) - }) - .collect() -} - fn map_store_fault(error: PluginStateStoreError) -> PluginFault { let (code, message) = match error.kind() { PluginStateStoreErrorKind::Invalid => (ErrorCode::InvalidInput, "state input is invalid"), diff --git a/backend/crates/gateway-plugin/runtime/src/compatibility.rs b/backend/crates/gateway-plugin/runtime/src/deprecation.rs similarity index 100% rename from backend/crates/gateway-plugin/runtime/src/compatibility.rs rename to backend/crates/gateway-plugin/runtime/src/deprecation.rs diff --git a/backend/crates/gateway-plugin/runtime/src/generation/configuration.rs b/backend/crates/gateway-plugin/runtime/src/generation/configuration.rs index d6b44e68e..34d40bd99 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/configuration.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/configuration.rs @@ -103,7 +103,7 @@ pub(super) fn validate( } Ok(( configuration, - crate::callback::private_state::configuration(manifest)?, + crate::package::state_configuration(manifest)?, )) } diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/diagnostics.rs b/backend/crates/gateway-plugin/runtime/src/generation/diagnostics.rs similarity index 99% rename from backend/crates/gateway-plugin/runtime/src/generation/prepare/diagnostics.rs rename to backend/crates/gateway-plugin/runtime/src/generation/diagnostics.rs index 8c21ed31a..d8ce549f9 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/diagnostics.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/diagnostics.rs @@ -1,6 +1,6 @@ //! 将已发布与准备中的实例事实投影为运行诊断,不参与候选提交 -use super::super::restart_circuit::RestartIdentity; +use super::restart_circuit::RestartIdentity; use super::{ PluginRuntime, set::{PreparedSet, instance_fingerprint}, diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs b/backend/crates/gateway-plugin/runtime/src/generation/instance.rs similarity index 98% rename from backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs rename to backend/crates/gateway-plugin/runtime/src/generation/instance.rs index f83d6bf06..f3a90e4b7 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/instance.rs @@ -1,6 +1,6 @@ //! 准备单个插件实例的进程、RPC 会话与能力贡献 -use super::super::restart_circuit::RestartIdentity; +use super::restart_circuit::RestartIdentity; use super::{ PluginRuntime, set::{PreparedContributions, PreparedInstance, instance_fingerprint}, @@ -100,7 +100,7 @@ impl PluginRuntime { } let manifest = package.manifest(); let (configuration, state_configuration) = - super::super::configuration::validate(&instance, manifest, &schemas)?; + super::configuration::validate(&instance, manifest, &schemas)?; crate::adapter::observer::validate_bindings(manifest, &instance.bindings)?; crate::adapter::policy::validate_bindings(manifest, &instance.bindings)?; crate::adapter::middleware::validate_bindings(manifest, &instance.bindings)?; @@ -139,7 +139,7 @@ impl PluginRuntime { .plugin_id() .map_err(|_| AdminError::invalid("插件身份无效"))?; let instance_id = instance.id.clone(); - crate::compatibility::report(&manifest, &instance_id)?; + crate::deprecation::report(&manifest, &instance_id)?; let restart_identity = RestartIdentity::new(instance_id.clone(), authorization_binding.clone()); let owner = self diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/maintenance.rs b/backend/crates/gateway-plugin/runtime/src/generation/maintenance.rs similarity index 100% rename from backend/crates/gateway-plugin/runtime/src/generation/prepare/maintenance.rs rename to backend/crates/gateway-plugin/runtime/src/generation/maintenance.rs diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/management.rs b/backend/crates/gateway-plugin/runtime/src/generation/management.rs similarity index 100% rename from backend/crates/gateway-plugin/runtime/src/generation/prepare/management.rs rename to backend/crates/gateway-plugin/runtime/src/generation/management.rs diff --git a/backend/crates/gateway-plugin/runtime/src/generation/mod.rs b/backend/crates/gateway-plugin/runtime/src/generation/mod.rs index e2e2ab492..c1a7ad8b0 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/mod.rs @@ -1,8 +1,15 @@ -//! 插件发布代次的配置校验、实例准备与重启熔断入口 +//! 插件发布代次的配置校验、实例生命周期与运行时端口入口 mod configuration; -mod prepare; +mod diagnostics; +mod instance; +mod maintenance; +mod management; mod restart_circuit; +mod runtime; +mod set; +mod snapshot; +mod state; -pub use prepare::{PluginRuntime, PluginRuntimeConfig}; pub use restart_circuit::PluginRestartCircuitConfig; +pub use runtime::{PluginRuntime, PluginRuntimeConfig}; diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs b/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs deleted file mode 100644 index c22a17afd..000000000 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! 插件运行时端口与代次准备的公共入口 - -mod diagnostics; -mod instance; -mod maintenance; -mod management; -mod runtime; -mod set; -mod snapshot; -mod state; - -pub use runtime::{PluginRuntime, PluginRuntimeConfig}; diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/runtime.rs b/backend/crates/gateway-plugin/runtime/src/generation/runtime.rs similarity index 98% rename from backend/crates/gateway-plugin/runtime/src/generation/prepare/runtime.rs rename to backend/crates/gateway-plugin/runtime/src/generation/runtime.rs index d708eb950..64f8bef8c 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/runtime.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/runtime.rs @@ -1,6 +1,6 @@ //! 插件运行时配置、端口装配与整体关闭;候选和调用均复用同一状态 -use super::super::restart_circuit::{PluginRestartCircuitConfig, RestartCircuits, RestartIdentity}; +use super::restart_circuit::{PluginRestartCircuitConfig, RestartCircuits, RestartIdentity}; use super::{ diagnostics::PreparationDiagnostic, set::{PreparedInstance, PreparedSet}, @@ -65,7 +65,7 @@ pub struct PluginRuntime { shutdown_lock: Mutex<()>, pub(super) processes: Arc, pub(super) validators: Arc, - pub(super) configuration_schemas: Arc, + pub(super) configuration_schemas: Arc, pub(super) log_slots: Arc, pub(super) account_ports: Arc, pub(super) client_key_ports: Arc, diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/set.rs b/backend/crates/gateway-plugin/runtime/src/generation/set.rs similarity index 100% rename from backend/crates/gateway-plugin/runtime/src/generation/prepare/set.rs rename to backend/crates/gateway-plugin/runtime/src/generation/set.rs diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs b/backend/crates/gateway-plugin/runtime/src/generation/snapshot.rs similarity index 98% rename from backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs rename to backend/crates/gateway-plugin/runtime/src/generation/snapshot.rs index 137890645..7d14e6aa0 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/snapshot.rs @@ -363,12 +363,7 @@ impl PluginPreparation for PluginRuntime { let schemas = Arc::clone(&self.configuration_schemas); tokio::task::spawn_blocking(move || { let _slot = slot; - super::super::configuration::configuration_ready( - &instance, - &schema, - &secret_fields, - &schemas, - ) + super::configuration::configuration_ready(&instance, &schema, &secret_fields, &schemas) }) .await .map_err(|_| AdminError::internal("插件校验任务失败"))? @@ -397,7 +392,7 @@ impl PluginPreparation for PluginRuntime { ValidatedPackage::read(artifact.archive, Some(&instance.artifact_sha256), limits) .map_err(|_| AdminError::invalid("插件制品校验失败"))?; let (_, state) = - super::super::configuration::validate(&instance, package.manifest(), &schemas)?; + super::configuration::validate(&instance, package.manifest(), &schemas)?; crate::adapter::observer::validate_bindings(package.manifest(), &instance.bindings)?; crate::adapter::policy::validate_bindings(package.manifest(), &instance.bindings)?; crate::adapter::middleware::validate_bindings(package.manifest(), &instance.bindings)?; diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/state.rs b/backend/crates/gateway-plugin/runtime/src/generation/state.rs similarity index 100% rename from backend/crates/gateway-plugin/runtime/src/generation/prepare/state.rs rename to backend/crates/gateway-plugin/runtime/src/generation/state.rs diff --git a/backend/crates/gateway-plugin/runtime/src/lib.rs b/backend/crates/gateway-plugin/runtime/src/lib.rs index 0a3cd21e2..6d78903aa 100644 --- a/backend/crates/gateway-plugin/runtime/src/lib.rs +++ b/backend/crates/gateway-plugin/runtime/src/lib.rs @@ -2,17 +2,15 @@ mod adapter; mod callback; -mod compatibility; mod contribution; +mod deprecation; mod generation; mod package; mod rpc; -mod stream; pub use adapter::command_line::{PluginCommandError, PluginCommandOutput, PluginCommandSession}; pub use generation::{PluginRestartCircuitConfig, PluginRuntime, PluginRuntimeConfig}; pub use package::{ PackageError, PackageInspector, PackageLimits, PreparedPackage, ValidatedPackage, }; -pub use rpc::{CallbackHandler, RpcError, RpcLimits, RpcReply, RpcSession}; -pub use stream::RpcStream; +pub use rpc::{CallbackHandler, RpcError, RpcLimits, RpcReply, RpcSession, RpcStream}; diff --git a/backend/crates/gateway-plugin/runtime/src/package/inspection.rs b/backend/crates/gateway-plugin/runtime/src/package/inspection.rs index 2fa17d411..445abcd67 100644 --- a/backend/crates/gateway-plugin/runtime/src/package/inspection.rs +++ b/backend/crates/gateway-plugin/runtime/src/package/inspection.rs @@ -101,8 +101,7 @@ impl PackageInspector { .collect::>()?, configuration_schema: manifest.configuration_schema.clone(), secret_fields: manifest.secret_fields.iter().cloned().collect(), - state_namespaces: crate::callback::private_state::configuration(manifest)? - .namespaces, + state_namespaces: super::state_configuration(manifest)?.namespaces, }; let requirements = super::compatibility::requirements(manifest)?; Ok(( @@ -138,7 +137,7 @@ impl PluginPackageInspector for PackageInspector { metadata: &PluginArtifactMetadata, ) -> Result, AdminError> { - crate::compatibility::warnings( + crate::deprecation::warnings( metadata .contributes .iter() diff --git a/backend/crates/gateway-plugin/runtime/src/package/mod.rs b/backend/crates/gateway-plugin/runtime/src/package/mod.rs index 823c0ac92..b58b4f0e3 100644 --- a/backend/crates/gateway-plugin/runtime/src/package/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/package/mod.rs @@ -4,6 +4,7 @@ mod cache; mod compatibility; mod icon; mod inspection; +mod state_schema; mod validation; pub use cache::PreparedPackage; @@ -13,4 +14,7 @@ pub(crate) use compatibility::{ }; pub use inspection::PackageInspector; pub(crate) use inspection::inspection_error; +pub(crate) use state_schema::{ + configuration as state_configuration, validators as state_validators, +}; pub use validation::{PackageError, PackageLimits, ValidatedPackage}; diff --git a/backend/crates/gateway-plugin/runtime/src/package/state_schema.rs b/backend/crates/gateway-plugin/runtime/src/package/state_schema.rs new file mode 100644 index 000000000..3b2b971fd --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/package/state_schema.rs @@ -0,0 +1,52 @@ +//! 将清单私有状态声明编译为配置、稳定摘要与离线校验器 + +use std::collections::BTreeMap; + +use gateway_admin::model::{ + AdminError, + plugins::state::{PluginStateConfiguration, PluginStateSchema}, +}; +use gateway_plugin_sdk::Manifest; +use sha2::{Digest as _, Sha256}; + +pub(crate) fn configuration(manifest: &Manifest) -> Result { + let _ = validators(manifest)?; + let mut namespaces = Vec::with_capacity(manifest.state.len()); + for state in &manifest.state { + let mut schema = state.schema.clone(); + schema.sort_all_objects(); + let encoded = serde_json::to_vec(&schema) + .map_err(|_| AdminError::invalid("插件状态 schema 无法编码"))?; + namespaces.push(PluginStateSchema { + namespace: state.namespace.clone(), + schema_version: state.schema_version, + schema_sha256: hex::encode(Sha256::digest(encoded)), + schema: state.schema.clone(), + maximum_records: state.maximum_records, + maximum_bytes: state.maximum_bytes, + maximum_value_bytes: state.maximum_value_bytes, + migrates_from: state.migrates_from.clone(), + }); + } + namespaces.sort_by(|left, right| left.namespace.cmp(&right.namespace)); + Ok(PluginStateConfiguration { namespaces }) +} + +pub(crate) fn validators( + manifest: &Manifest, +) -> Result, AdminError> { + manifest + .state + .iter() + .map(|state| { + let validator = jsonschema::options() + .offline() + .with_pattern_options( + jsonschema::PatternOptions::fancy_regex().backtrack_limit(20_000), + ) + .build(&state.schema) + .map_err(|_| AdminError::invalid("插件状态 schema 无效或引用了外部资源"))?; + Ok((state.namespace.clone(), validator)) + }) + .collect() +} diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/calls.rs b/backend/crates/gateway-plugin/runtime/src/rpc/calls.rs index 1079d2f18..fc675c42d 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/calls.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/calls.rs @@ -18,8 +18,8 @@ use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, mpsc, oneshot, watch} use super::{ CallbackHandler, RpcError, RpcLimits, RpcReply, RpcSessionLoad, admission::ExecutionCapacity, + stream::StreamIngress, }; -use crate::stream::StreamIngress; pub(crate) struct Shared { state: Mutex, diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/mod.rs b/backend/crates/gateway-plugin/runtime/src/rpc/mod.rs index f9c8d2df5..edfff2ea9 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/mod.rs @@ -5,8 +5,10 @@ mod calls; mod contract; mod dispatch; mod session; +mod stream; pub(crate) use calls::Shared; pub use contract::{CallbackHandler, RpcError, RpcLimits, RpcReply}; pub(crate) use contract::{RpcSessionDiagnostic, RpcSessionLifecycle, RpcSessionLoad}; pub use session::RpcSession; +pub use stream::RpcStream; diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/session.rs b/backend/crates/gateway-plugin/runtime/src/rpc/session.rs index 27ba36554..c76be32f8 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/session.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/session.rs @@ -15,9 +15,9 @@ use tokio::sync::{Mutex as AsyncMutex, OwnedSemaphorePermit, Semaphore, mpsc}; use super::{ CallbackHandler, RpcError, RpcLimits, RpcReply, RpcSessionDiagnostic, RpcSessionLifecycle, - RpcSessionLoad, Shared, dispatch, + RpcSessionLoad, RpcStream, Shared, dispatch, stream::StreamIngress, }; -use crate::{PreparedPackage, RpcStream, stream::StreamIngress}; +use crate::PreparedPackage; pub struct RpcSession { shared: Arc, diff --git a/backend/crates/gateway-plugin/runtime/src/stream/bridge.rs b/backend/crates/gateway-plugin/runtime/src/rpc/stream/bridge.rs similarity index 96% rename from backend/crates/gateway-plugin/runtime/src/stream/bridge.rs rename to backend/crates/gateway-plugin/runtime/src/rpc/stream/bridge.rs index 98df3029d..6f1afe41f 100644 --- a/backend/crates/gateway-plugin/runtime/src/stream/bridge.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/stream/bridge.rs @@ -6,11 +6,10 @@ use tokio::sync::{OwnedSemaphorePermit, mpsc, oneshot}; use gateway_plugin_sdk::client::CallDeadline; -use crate::{RpcError, RpcReply, RpcSession, rpc::Shared}; - +use super::super::{RpcError, RpcReply, RpcSession, Shared}; use super::flow_control::ReceiveWindow; -pub(crate) struct StreamIngress { +pub(in crate::rpc) struct StreamIngress { chunks: mpsc::Sender>, terminal: oneshot::Sender>, window: ReceiveWindow, diff --git a/backend/crates/gateway-plugin/runtime/src/stream/flow_control.rs b/backend/crates/gateway-plugin/runtime/src/rpc/stream/flow_control.rs similarity index 97% rename from backend/crates/gateway-plugin/runtime/src/stream/flow_control.rs rename to backend/crates/gateway-plugin/runtime/src/rpc/stream/flow_control.rs index 042b9fe19..2b6127516 100644 --- a/backend/crates/gateway-plugin/runtime/src/stream/flow_control.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/stream/flow_control.rs @@ -1,6 +1,6 @@ //! 插件流的字节与帧数窗口校验、序号推进及信用归还 -use crate::RpcError; +use super::super::RpcError; /// 字节与帧数一起授予,避免攻击者用大量单字节分块突破队列上限 pub(super) struct ReceiveWindow { diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/stream/mod.rs b/backend/crates/gateway-plugin/runtime/src/rpc/stream/mod.rs new file mode 100644 index 000000000..a1425dfb6 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/rpc/stream/mod.rs @@ -0,0 +1,7 @@ +//! RPC 会话内的响应流桥接与接收流量控制 + +mod bridge; +mod flow_control; + +pub use bridge::RpcStream; +pub(super) use bridge::StreamIngress; diff --git a/backend/crates/gateway-plugin/runtime/src/stream/mod.rs b/backend/crates/gateway-plugin/runtime/src/stream/mod.rs deleted file mode 100644 index cdc083b17..000000000 --- a/backend/crates/gateway-plugin/runtime/src/stream/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! 插件 RPC 流桥接与接收流量控制的公共入口 - -mod bridge; -mod flow_control; - -pub use bridge::RpcStream; -pub(crate) use bridge::StreamIngress; diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/diagnostics.rs b/backend/crates/gateway-plugin/runtime/tests/generation/diagnostics.rs similarity index 93% rename from backend/crates/gateway-plugin/runtime/tests/generation/prepare/diagnostics.rs rename to backend/crates/gateway-plugin/runtime/tests/generation/diagnostics.rs index 4d2975ac8..368f33667 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/diagnostics.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/diagnostics.rs @@ -11,7 +11,7 @@ use gateway_core::routing::extensions::ExtensionPreparationPort; #[tokio::test] async fn diagnostics_distinguish_running_failed_preparation_and_draining_generations() { - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let snapshot = store.snapshot.lock().unwrap().clone(); let active = PluginPreparation::prepare(&runtime, snapshot.clone()) .await @@ -88,12 +88,12 @@ async fn diagnostics_distinguish_running_failed_preparation_and_draining_generat assert_eq!(draining.draining_revisions, [1]); drop(active); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] async fn preparing_diagnostics_never_wait_for_the_serialized_prepare_io() { - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let source = store.snapshot.lock().unwrap().clone(); let mut candidate = source.clone(); candidate.config_revision = Revision::new(2).unwrap(); @@ -123,12 +123,12 @@ async fn preparing_diagnostics_never_wait_for_the_serialized_prepare_io() { let candidate = preparation.await.expect("delayed candidate"); drop(candidate); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] async fn diagnostics_report_a_published_process_fault_without_plugin_details() { - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let mut snapshot = store.snapshot.lock().unwrap().clone(); snapshot.instances[0].configuration = serde_json::json!({"exit_after_ready_ms":20}); let active = PluginPreparation::prepare(&runtime, snapshot.clone()) @@ -165,12 +165,12 @@ async fn diagnostics_report_a_published_process_fault_without_plugin_details() { ); drop(active); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] async fn a_published_process_crash_preserves_the_serving_snapshot_and_other_plugin_status() { - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let snapshot = { let mut snapshot = store.snapshot.lock().unwrap(); let mut failed = snapshot.instances[0].clone(); @@ -202,5 +202,5 @@ async fn a_published_process_crash_preserves_the_serving_snapshot_and_other_plug PluginInstanceRuntimeStatus::Faulted ); drop(generation); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/instance.rs b/backend/crates/gateway-plugin/runtime/tests/generation/instance.rs similarity index 94% rename from backend/crates/gateway-plugin/runtime/tests/generation/prepare/instance.rs rename to backend/crates/gateway-plugin/runtime/tests/generation/instance.rs index 30fdc60df..2801156e3 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/instance.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/instance.rs @@ -21,7 +21,7 @@ fn startup_count(marker: &Path) -> usize { #[tokio::test] async fn cold_preparation_overlaps_startup_with_bounded_concurrency() { - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let mut snapshot = store.snapshot.lock().unwrap().clone(); let template = snapshot.instances[0].clone(); let gate = cache.path().join("startup-gate"); @@ -65,21 +65,20 @@ async fn cold_preparation_overlaps_startup_with_bounded_concurrency() { #[tokio::test] async fn registration_with_a_changed_contribution_id_is_rejected() { - let (cache, store, runtime) = super::super::setup_with_contributions(Contributions::from([ - crate::support::contribution( + let (cache, store, runtime) = + super::setup_with_contributions(Contributions::from([crate::support::contribution( Capability::Observer, vec![Stage::Observation], Vec::new(), Vec::new(), - ), - ])) - .await; + )])) + .await; let mut snapshot = store.snapshot.lock().unwrap().clone(); let candidate = PluginPreparation::prepare(&runtime, snapshot.clone()) .await .expect("the declared contribution prepares before its registration id changes"); drop(candidate); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; snapshot.instances[0].configuration = serde_json::json!({"registration_mismatch":true}); let error = PluginPreparation::prepare(&runtime, snapshot) @@ -97,7 +96,7 @@ async fn restart_circuit_caps_short_lived_crashes_and_a_new_revision_recovers() maximum_failures: NonZeroUsize::new(3).unwrap(), stability_window: Duration::from_secs(1), }; - let (cache, store, runtime) = super::super::setup_with_restart_circuit(restart_circuit).await; + let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await; let controls = tempfile::tempdir().unwrap(); let marker = controls.path().join("starts.jsonl"); let mut snapshot = store.snapshot.lock().unwrap().clone(); @@ -165,7 +164,7 @@ async fn restart_circuit_caps_short_lived_crashes_and_a_new_revision_recovers() drop(active); drop(disabled_generation); drop(replacement); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] @@ -174,7 +173,7 @@ async fn restart_circuit_resets_after_a_stable_incarnation() { maximum_failures: NonZeroUsize::new(3).unwrap(), stability_window: Duration::from_millis(80), }; - let (cache, store, runtime) = super::super::setup_with_restart_circuit(restart_circuit).await; + let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await; let controls = tempfile::tempdir().unwrap(); let marker = controls.path().join("stable-reset-starts.jsonl"); let exit = controls.path().join("stable-exit"); @@ -217,7 +216,7 @@ async fn restart_circuit_resets_after_a_stable_incarnation() { assert_eq!(startup_count(&marker), 5); drop(active); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] @@ -226,7 +225,7 @@ async fn restart_circuit_ignores_planned_generation_shutdown() { maximum_failures: NonZeroUsize::MIN, stability_window: Duration::from_secs(60), }; - let (cache, store, runtime) = super::super::setup_with_restart_circuit(restart_circuit).await; + let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await; let controls = tempfile::tempdir().unwrap(); let marker = controls.path().join("planned-shutdown-starts.jsonl"); let mut snapshot = store.snapshot.lock().unwrap().clone(); @@ -252,7 +251,7 @@ async fn restart_circuit_ignores_planned_generation_shutdown() { drop(first); drop(second); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] @@ -261,7 +260,7 @@ async fn restart_circuit_counts_exit_before_the_candidate_is_indexed() { maximum_failures: NonZeroUsize::new(3).unwrap(), stability_window: Duration::from_secs(1), }; - let (cache, store, runtime) = super::super::setup_with_restart_circuit(restart_circuit).await; + let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await; let controls = tempfile::tempdir().unwrap(); let marker = controls.path().join("registration-exits.jsonl"); let mut snapshot = store.snapshot.lock().unwrap().clone(); @@ -300,7 +299,7 @@ async fn restart_circuit_counts_exit_before_the_candidate_is_indexed() { .map(|failure| failure.code.as_str()), Some("restart_circuit_open") ); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] @@ -309,7 +308,7 @@ async fn restart_circuit_counts_a_started_process_that_exits_during_handshake() maximum_failures: NonZeroUsize::new(2).unwrap(), stability_window: Duration::from_millis(10), }; - let (cache, store, runtime) = super::super::setup_with_restart_circuit(restart_circuit).await; + let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await; let controls = tempfile::tempdir().unwrap(); let marker = controls.path().join("handshake-exits.jsonl"); let mut snapshot = store.snapshot.lock().unwrap().clone(); @@ -336,7 +335,7 @@ async fn restart_circuit_counts_a_started_process_that_exits_during_handshake() 2, "host-side gating must happen before another process spawn" ); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] @@ -345,7 +344,7 @@ async fn reused_session_failure_counts_once_across_multiple_generations() { maximum_failures: NonZeroUsize::new(3).unwrap(), stability_window: Duration::from_millis(50), }; - let (cache, store, runtime) = super::super::setup_with_restart_circuit(restart_circuit).await; + let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await; let controls = tempfile::tempdir().unwrap(); let marker = controls.path().join("shared-generations.jsonl"); let exit = controls.path().join("shared-exit"); @@ -419,7 +418,7 @@ async fn reused_session_failure_counts_once_across_multiple_generations() { drop(retained); drop(recovered); drop(published); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] @@ -428,7 +427,7 @@ async fn abandoned_candidate_keeps_shared_process_failure_in_restart_budget() { maximum_failures: NonZeroUsize::MIN, stability_window: Duration::from_secs(1), }; - let (cache, store, runtime) = super::super::setup_with_restart_circuit(restart_circuit).await; + let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await; let controls = tempfile::tempdir().unwrap(); let marker = controls.path().join("abandoned-candidate.jsonl"); let old_exit = controls.path().join("abandoned-candidate-old-exit"); @@ -508,7 +507,7 @@ async fn abandoned_candidate_keeps_shared_process_failure_in_restart_budget() { drop(recovered); drop(published); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] @@ -527,7 +526,7 @@ async fn incompatible_start_requires_scoped_confirmation_and_rechecks_after_rest contribution.1.version = 3; } let (cache, store, initial) = - super::super::setup_with_contributions(Contributions::from([contribution])).await; + super::setup_with_contributions(Contributions::from([contribution])).await; drop(initial); let host = if mismatch == "host" { "3.0.0" } else { "1.0.0" }; let runtime = compatibility_runtime(&cache, store.clone(), host); @@ -624,7 +623,7 @@ async fn incompatible_start_requires_scoped_confirmation_and_rechecks_after_rest drop(confirmed); drop(restored); runtime.shutdown().await; - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; let restarted = compatibility_runtime(&cache, store.clone(), host); let restored = @@ -645,7 +644,7 @@ async fn incompatible_start_requires_scoped_confirmation_and_rechecks_after_rest assert_eq!(startup_count(&other_marker), 0); drop(restored); restarted.shutdown().await; - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } } @@ -681,7 +680,7 @@ async fn unloadable_packages_have_no_guards_and_cannot_be_forced_to_start() { use sha2::{Digest as _, Sha256}; use std::{collections::BTreeMap, sync::Arc}; for defect in ["rpc", "manifest", "platform", "archive", "digest"] { - let (cache, store, initial) = super::super::setup().await; + let (cache, store, initial) = super::setup().await; drop(initial); let mut store = Arc::try_unwrap(store).ok().unwrap(); let mut broken = store.artifacts.values().next().unwrap().clone(); @@ -808,13 +807,13 @@ async fn unloadable_packages_have_no_guards_and_cannot_be_forced_to_start() { assert!(restored.is_ready()); drop(restored); runtime.shutdown().await; - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } } #[tokio::test] async fn declared_registration_budget_is_not_cut_short_by_process_preparation_timeout() { - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let mut snapshot = store.snapshot.lock().unwrap().clone(); snapshot.instances[0].configuration = serde_json::json!({ "registration_delay_ms":31_000, @@ -829,12 +828,12 @@ async fn declared_registration_budget_is_not_cut_short_by_process_preparation_ti .expect("ready process may register beyond the startup control deadline"); assert!(candidate.is_ready()); drop(candidate); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] async fn shutdown_cancels_long_registration_and_reclaims_the_unpublished_candidate() { - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let runtime = std::sync::Arc::new(runtime); let markers = tempfile::tempdir().unwrap(); let started = markers.path().join("registration"); @@ -861,5 +860,5 @@ async fn shutdown_cancels_long_registration_and_reclaims_the_unpublished_candida "shutdown must cancel candidate preparation instead of waiting for its long budget", ); assert!(preparing.await.unwrap().is_err()); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs b/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs index de6a486b2..4388cf988 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs @@ -1,23 +1,26 @@ -//! 验证插件维护任务的资源创建、重试及事务归属和幂等性 +//! 验证维护任务的跨发布保活、取消顺序、资源创建重试及事务归属和幂等性 use crate::support::environment::{Environment, mutation}; use gateway_admin::{ model::{ + Revision, account_groups::{AccountGroupColor, CreateAccountGroup}, plugin_resources::{GroupMembersChange, ManagedKeyConfig, PluginResourceOwner}, - plugins::instances::PluginInstanceRuntimeStatus, + plugins::instances::{PluginInstanceRuntimeStatus, PluginInstanceSnapshot}, }, - ports::plugin_resources::PluginResourceAccess, + ports::{plugin_resources::PluginResourceAccess, plugins::PluginPreparation}, }; -use gateway_core::task::WorkerDaemonContext; use gateway_core::{ account::FastMode, lifecycle::CancellationToken, - task::{WorkerContribution, WorkerRunnable}, + routing::{ConfigRevision, RuntimeSnapshot}, + runtime::RuntimeSnapshotHandle, + task::{WorkerContribution, WorkerDaemonContext, WorkerRunnable}, }; use gateway_plugin_runtime::PluginRuntime; +use gateway_plugin_sdk::{Capability, Contributions, Stage}; use serde_json::{Value, json}; -use std::{sync::Arc, time::Duration}; +use std::{path::Path, sync::Arc, time::Duration}; fn group(name: &str) -> CreateAccountGroup { CreateAccountGroup { @@ -201,6 +204,50 @@ async fn wait_done(path: &std::path::Path, after: usize, phase: &str) -> Value { }) } +async fn publish( + runtime: &PluginRuntime, + snapshots: &RuntimeSnapshotHandle, + instances: PluginInstanceSnapshot, +) { + let revision = ConfigRevision::new(instances.config_revision.get()).unwrap(); + let prepared = PluginPreparation::prepare(runtime, instances) + .await + .unwrap(); + snapshots.publish( + RuntimeSnapshot::new( + revision, + gateway_core::settings::SettingsValues::new(1, "smart", Default::default(), None, None), + vec![], + vec![], + vec![], + ) + .unwrap() + .with_extensions(Some(prepared)), + ); +} + +fn records(path: &Path) -> Vec { + std::fs::read_to_string(path) + .unwrap_or_default() + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect() +} + +async fn wait_records(path: &Path, count: usize) -> Vec { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let entries = records(path); + if entries.len() >= count { + return entries; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|error| panic!("missing maintenance records: {error}; {:?}", records(path))) +} + #[tokio::test] async fn unused_maintenance_response_does_not_stop_the_host_worker() { let Some(environment) = Environment::create().await else { @@ -509,3 +556,99 @@ async fn resource_transactions_enforce_ownership_current_revision_and_idempotent drop(runtime); environment.close().await; } + +#[tokio::test] +async fn unrelated_publications_preserve_inflight_maintenance_and_replacement_waits_for_cancel() { + let (cache, store, runtime) = + super::setup_with_contributions(Contributions::from([crate::support::contribution( + Capability::Maintenance, + vec![Stage::Maintenance], + vec![], + vec![], + )])) + .await; + let runtime = Arc::new(runtime); + let markers = tempfile::tempdir().unwrap(); + let mut instances = store.snapshot.lock().unwrap().clone(); + let template = instances.instances[0].clone(); + instances.instances = ["a", "b"] + .into_iter() + .map(|id| { + let mut instance = template.clone(); + instance.id = id.into(); + instance.configuration = json!({ + "maintenance_marker":markers.path().join(id), + "execution_cancelled_marker":markers.path().join(id), + "maintenance_wait_marker":markers.path().join(format!("release-{id}")), + "call_context_marker":markers.path().join(format!("context-{id}")), + "call_budgets_ms":{"maintenance":600_000}, + }); + instance + }) + .collect(); + let snapshots = RuntimeSnapshotHandle::default(); + publish(&runtime, &snapshots, instances.clone()).await; + let WorkerContribution::Registration(registration) = + runtime.maintenance_worker(snapshots.clone()).unwrap() + else { + panic!("maintenance worker registration") + }; + let WorkerRunnable::Daemon { task, .. } = registration.runnable else { + panic!("maintenance daemon") + }; + let stop = CancellationToken::new(); + let running = tokio::spawn({ + let stop = stop.clone(); + async move { task.run(WorkerDaemonContext::new(stop)).await.unwrap() } + }); + let a = markers.path().join("a"); + let b = markers.path().join("b"); + assert_eq!(wait_records(&a, 1).await[0]["phase"], "start"); + assert_eq!(wait_records(&b, 1).await[0]["phase"], "start"); + let contexts = records(&markers.path().join("context-a")); + let maintenance = contexts + .iter() + .find(|entry| entry["method"] == "plugin.reconcile") + .expect("maintenance call context"); + assert_eq!(maintenance["context"]["timeout_ms"], 600_000); + + instances.config_revision = Revision::new(2).unwrap(); + instances.instances[1].revision = instances.config_revision; + publish(&runtime, &snapshots, instances.clone()).await; + let changed = wait_records(&b, 3).await; + assert!( + changed[1].get("call_id").is_some(), + "旧维护必须先完成取消确认" + ); + assert_eq!(changed[2]["phase"], "start"); + assert_eq!(records(&a).len(), 1, "无关实例发布不得重启 A 的在途维护"); + + instances.config_revision = Revision::new(3).unwrap(); + instances.instances[1].revision = instances.config_revision; + instances.instances[1].enabled = false; + publish(&runtime, &snapshots, instances.clone()).await; + assert!(wait_records(&b, 4).await[3].get("call_id").is_some()); + instances.config_revision = Revision::new(4).unwrap(); + instances.instances[1].revision = instances.config_revision; + instances.instances[1].enabled = true; + publish(&runtime, &snapshots, instances.clone()).await; + assert_eq!(wait_records(&b, 5).await[4]["phase"], "start"); + assert_eq!(records(&a).len(), 1, "停用与恢复 B 不得取消 A"); + + // 多次发布通知合并为一次后续对账,首轮仍使用原调用 ID 完成 + std::fs::write(markers.path().join("release-a"), []).unwrap(); + let completed = wait_records(&a, 4).await; + assert_eq!(completed[1]["phase"], "done"); + assert_eq!(completed[1]["id"], completed[0]["id"]); + assert_eq!(completed[2]["phase"], "start"); + assert_eq!(completed[3]["phase"], "done"); + assert_ne!(completed[2]["id"], completed[0]["id"]); + assert_eq!(completed.len(), 4); + + stop.cancel(); + running.await.unwrap(); + assert!(wait_records(&b, 6).await[5].get("call_id").is_some()); + snapshots.suspend(); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/management.rs b/backend/crates/gateway-plugin/runtime/tests/generation/management.rs similarity index 90% rename from backend/crates/gateway-plugin/runtime/tests/generation/prepare/management.rs rename to backend/crates/gateway-plugin/runtime/tests/generation/management.rs index e674a5fd1..9e2c22503 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/management.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/management.rs @@ -9,15 +9,14 @@ use gateway_plugin_sdk::Stage; #[tokio::test] async fn management_needs_no_binding_and_rejects_stale_execution_identity_configuration() { - let (_cache, store, runtime) = super::super::setup_with_contributions(Contributions::from([ - crate::support::contribution( + let (_cache, store, runtime) = + super::setup_with_contributions(Contributions::from([crate::support::contribution( Capability::Management, vec![Stage::Management], Vec::new(), Vec::new(), - ), - ])) - .await; + )])) + .await; let mut instance = store.snapshot.lock().unwrap().instances[0].clone(); assert!( PluginPreparation::validate(&runtime, instance.clone()) diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs index 614a2652a..fd23bf5a4 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs @@ -3,11 +3,16 @@ mod authentication; mod configuration; mod data; +mod diagnostics; +mod instance; mod maintenance; +mod management; mod observer; mod policy; -mod prepare; mod private_state; +mod runtime; +mod set; +mod snapshot; mod upstream_adapter; use gateway_admin::{ @@ -25,6 +30,7 @@ use gateway_plugin_sdk::Contributions; use std::{ collections::BTreeMap, sync::{Arc, Mutex}, + time::Duration, }; use crate::support::store::Store; @@ -114,3 +120,13 @@ async fn wait_until_empty(cache: &std::path::Path) { .await .unwrap(); } + +async fn wait_until_unready(reference: &gateway_core::routing::extensions::ExtensionSetReference) { + tokio::time::timeout(Duration::from_secs(2), async { + while reference.is_ready() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("worker exit observed"); +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/maintenance.rs b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/maintenance.rs deleted file mode 100644 index ee7ba4b95..000000000 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/maintenance.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! 验证维护任务跨发布保活、通知合并及实例替换后的取消顺序 - -use gateway_admin::{ - model::{Revision, plugins::instances::PluginInstanceSnapshot}, - ports::plugins::PluginPreparation, -}; -use gateway_core::{ - lifecycle::CancellationToken, - routing::{ConfigRevision, RuntimeSnapshot}, - runtime::RuntimeSnapshotHandle, - task::{WorkerContribution, WorkerDaemonContext, WorkerRunnable}, -}; -use gateway_plugin_runtime::PluginRuntime; -use gateway_plugin_sdk::{Capability, Contributions, Stage}; -use serde_json::{Value, json}; -use std::{path::Path, sync::Arc, time::Duration}; - -async fn publish( - runtime: &PluginRuntime, - snapshots: &RuntimeSnapshotHandle, - instances: PluginInstanceSnapshot, -) { - let revision = ConfigRevision::new(instances.config_revision.get()).unwrap(); - let prepared = PluginPreparation::prepare(runtime, instances) - .await - .unwrap(); - snapshots.publish( - RuntimeSnapshot::new( - revision, - gateway_core::settings::SettingsValues::new(1, "smart", Default::default(), None, None), - vec![], - vec![], - vec![], - ) - .unwrap() - .with_extensions(Some(prepared)), - ); -} - -fn records(path: &Path) -> Vec { - std::fs::read_to_string(path) - .unwrap_or_default() - .lines() - .filter_map(|line| serde_json::from_str(line).ok()) - .collect() -} - -async fn wait_records(path: &Path, count: usize) -> Vec { - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let entries = records(path); - if entries.len() >= count { - return entries; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .unwrap_or_else(|error| panic!("missing maintenance records: {error}; {:?}", records(path))) -} - -#[tokio::test] -async fn unrelated_publications_preserve_inflight_maintenance_and_replacement_waits_for_cancel() { - let (cache, store, runtime) = super::super::setup_with_contributions(Contributions::from([ - crate::support::contribution( - Capability::Maintenance, - vec![Stage::Maintenance], - vec![], - vec![], - ), - ])) - .await; - let runtime = Arc::new(runtime); - let markers = tempfile::tempdir().unwrap(); - let mut instances = store.snapshot.lock().unwrap().clone(); - let template = instances.instances[0].clone(); - instances.instances = ["a", "b"] - .into_iter() - .map(|id| { - let mut instance = template.clone(); - instance.id = id.into(); - instance.configuration = json!({ - "maintenance_marker":markers.path().join(id), - "execution_cancelled_marker":markers.path().join(id), - "maintenance_wait_marker":markers.path().join(format!("release-{id}")), - "call_context_marker":markers.path().join(format!("context-{id}")), - "call_budgets_ms":{"maintenance":600_000}, - }); - instance - }) - .collect(); - let snapshots = RuntimeSnapshotHandle::default(); - publish(&runtime, &snapshots, instances.clone()).await; - let WorkerContribution::Registration(registration) = - runtime.maintenance_worker(snapshots.clone()).unwrap() - else { - panic!("maintenance worker registration") - }; - let WorkerRunnable::Daemon { task, .. } = registration.runnable else { - panic!("maintenance daemon") - }; - let stop = CancellationToken::new(); - let running = tokio::spawn({ - let stop = stop.clone(); - async move { task.run(WorkerDaemonContext::new(stop)).await.unwrap() } - }); - let a = markers.path().join("a"); - let b = markers.path().join("b"); - assert_eq!(wait_records(&a, 1).await[0]["phase"], "start"); - assert_eq!(wait_records(&b, 1).await[0]["phase"], "start"); - let contexts = records(&markers.path().join("context-a")); - let maintenance = contexts - .iter() - .find(|entry| entry["method"] == "plugin.reconcile") - .expect("maintenance call context"); - assert_eq!(maintenance["context"]["timeout_ms"], 600_000); - - instances.config_revision = Revision::new(2).unwrap(); - instances.instances[1].revision = instances.config_revision; - publish(&runtime, &snapshots, instances.clone()).await; - let changed = wait_records(&b, 3).await; - assert!( - changed[1].get("call_id").is_some(), - "旧维护必须先完成取消确认" - ); - assert_eq!(changed[2]["phase"], "start"); - assert_eq!(records(&a).len(), 1, "无关实例发布不得重启 A 的在途维护"); - - instances.config_revision = Revision::new(3).unwrap(); - instances.instances[1].revision = instances.config_revision; - instances.instances[1].enabled = false; - publish(&runtime, &snapshots, instances.clone()).await; - assert!(wait_records(&b, 4).await[3].get("call_id").is_some()); - instances.config_revision = Revision::new(4).unwrap(); - instances.instances[1].revision = instances.config_revision; - instances.instances[1].enabled = true; - publish(&runtime, &snapshots, instances.clone()).await; - assert_eq!(wait_records(&b, 5).await[4]["phase"], "start"); - assert_eq!(records(&a).len(), 1, "停用与恢复 B 不得取消 A"); - - // 多次发布通知合并为一次后续对账,首轮仍使用原调用 ID 完成 - std::fs::write(markers.path().join("release-a"), []).unwrap(); - let completed = wait_records(&a, 4).await; - assert_eq!(completed[1]["phase"], "done"); - assert_eq!(completed[1]["id"], completed[0]["id"]); - assert_eq!(completed[2]["phase"], "start"); - assert_eq!(completed[3]["phase"], "done"); - assert_ne!(completed[2]["id"], completed[0]["id"]); - assert_eq!(completed.len(), 4); - - stop.cancel(); - running.await.unwrap(); - assert!(wait_records(&b, 6).await[5].get("call_id").is_some()); - snapshots.suspend(); - runtime.shutdown().await; - super::super::wait_until_empty(cache.path()).await; -} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs deleted file mode 100644 index 008302184..000000000 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! 插件发布准备各职责的集成测试入口 - -mod diagnostics; -mod instance; -mod maintenance; -mod management; -mod runtime; -mod set; -mod snapshot; - -use std::time::Duration; - -async fn wait_until_unready(reference: &gateway_core::routing::extensions::ExtensionSetReference) { - tokio::time::timeout(Duration::from_secs(2), async { - while reference.is_ready() { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("worker exit observed"); -} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/runtime.rs b/backend/crates/gateway-plugin/runtime/tests/generation/runtime.rs similarity index 86% rename from backend/crates/gateway-plugin/runtime/tests/generation/prepare/runtime.rs rename to backend/crates/gateway-plugin/runtime/tests/generation/runtime.rs index ee3e633dd..c9ba98b0c 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/runtime.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/runtime.rs @@ -5,7 +5,7 @@ use gateway_core::routing::extensions::ExtensionPreparationPort; #[tokio::test] async fn runtime_shutdown_reaps_live_generation_and_rejects_late_preparation() { - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let published = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) .await .unwrap(); @@ -14,7 +14,7 @@ async fn runtime_shutdown_reaps_live_generation_and_rejects_late_preparation() { runtime.shutdown().await; assert!(!published.is_ready()); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; assert!( ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) .await diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/set.rs b/backend/crates/gateway-plugin/runtime/tests/generation/set.rs similarity index 92% rename from backend/crates/gateway-plugin/runtime/tests/generation/prepare/set.rs rename to backend/crates/gateway-plugin/runtime/tests/generation/set.rs index 12abf81c1..675eb6dd1 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/set.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/set.rs @@ -7,7 +7,7 @@ use gateway_core::routing::extensions::ExtensionPreparationPort; #[tokio::test] async fn candidate_is_reused_after_commit_and_released_when_no_generation_owner_remains() { - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let mut snapshot = store.snapshot.lock().unwrap().clone(); snapshot.instances[0].configuration = serde_json::from_str(r#"{"z":1,"a":{"z":2,"a":3}}"#).unwrap(); @@ -30,7 +30,7 @@ async fn candidate_is_reused_after_commit_and_released_when_no_generation_owner_ drop(candidate); assert_eq!(std::fs::read_dir(cache.path()).unwrap().count(), 1); drop(published); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } fn starts(path: &std::path::Path) -> usize { @@ -41,7 +41,7 @@ fn starts(path: &std::path::Path) -> usize { async fn updating_b_reuses_a_and_c_until_their_last_set_is_released() { // 两种释放顺序都必须保住另一集合仍使用的实例 for release_old_first in [true, false] { - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let markers = tempfile::tempdir().unwrap(); let mut snapshot = store.snapshot.lock().unwrap().clone(); let template = snapshot.instances[0].clone(); @@ -92,14 +92,14 @@ async fn updating_b_reuses_a_and_c_until_their_last_set_is_released() { .unwrap(); assert!(retained.is_ready()); drop(retained); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } } #[tokio::test] async fn failed_partial_candidate_does_not_close_reused_instances() { let (cache, store, runtime) = - super::super::setup_with_contributions(gateway_plugin_sdk::Contributions::from([ + super::setup_with_contributions(gateway_plugin_sdk::Contributions::from([ crate::support::contribution( gateway_plugin_sdk::Capability::Observer, vec![gateway_plugin_sdk::Stage::Observation], @@ -136,13 +136,13 @@ async fn failed_partial_candidate_does_not_close_reused_instances() { drop(old); assert!(repaired.is_ready()); drop(repaired); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] async fn targeted_quiescence_stops_the_shared_revision_without_stopping_its_replacement() { use gateway_admin::ports::plugins::PluginStateLifecycle; - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let mut snapshot = store.snapshot.lock().unwrap().clone(); let previous = snapshot.instances[0].clone(); let old = PluginPreparation::prepare(&runtime, snapshot.clone()) @@ -172,5 +172,5 @@ async fn targeted_quiescence_stops_the_shared_revision_without_stopping_its_repl drop(old); drop(shared); drop(new); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/snapshot.rs b/backend/crates/gateway-plugin/runtime/tests/generation/snapshot.rs similarity index 89% rename from backend/crates/gateway-plugin/runtime/tests/generation/prepare/snapshot.rs rename to backend/crates/gateway-plugin/runtime/tests/generation/snapshot.rs index 060c614b9..2972cb8e2 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/snapshot.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/snapshot.rs @@ -25,7 +25,7 @@ async fn adapted_catalog_freezes_adapter_accounts_and_preserves_the_active_gener vec!["openai".into()], ); let (cache, store, runtime) = - super::super::setup_with_contributions(Contributions::from([catalog, adapter])).await; + super::setup_with_contributions(Contributions::from([catalog, adapter])).await; let mut snapshot = store.snapshot.lock().unwrap().clone(); snapshot.instances[0].bindings = vec![PluginCapabilityBinding { contribution: "test.example.upstreamAdapter".into(), @@ -186,7 +186,7 @@ async fn adapted_catalog_freezes_adapter_accounts_and_preserves_the_active_gener drop(disabled); drop(active); runtime.shutdown().await; - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] @@ -198,7 +198,7 @@ async fn adapted_catalog_requires_its_registered_adapter() { vec![], ); let (cache, store, runtime) = - super::super::setup_with_contributions(Contributions::from([catalog])).await; + super::setup_with_contributions(Contributions::from([catalog])).await; let mut snapshot = store.snapshot.lock().unwrap().clone(); snapshot.instances[0].configuration = serde_json::json!({ "model_catalog": {"aliases":[], "models":[{ @@ -211,7 +211,7 @@ async fn adapted_catalog_requires_its_registered_adapter() { .unwrap_err(); assert_eq!(error.message(), "适配模型必须关联同实例的静态账号适配器"); runtime.shutdown().await; - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] @@ -225,14 +225,12 @@ async fn initial_registration_http_uses_the_prepared_settings_without_a_publicat .mount(&server) .await; let (cache, store, runtime) = - super::super::setup_with_contributions(Contributions::from([ - crate::support::contribution( - Capability::ModelCatalog, - vec![Stage::Registration], - vec![], - vec![], - ), - ])) + super::setup_with_contributions(Contributions::from([crate::support::contribution( + Capability::ModelCatalog, + vec![Stage::Registration], + vec![], + vec![], + )])) .await; store.snapshot.lock().unwrap().instances[0].configuration = serde_json::json!({ "registration_http": {"params": {"method":"POST", "url":server.uri(), "headers":[]}, "body":"x".repeat(64)}, @@ -248,21 +246,20 @@ async fn initial_registration_http_uses_the_prepared_settings_without_a_publicat assert_eq!(prepared.model_aliases().len(), usize::from(limit == 64)); drop(prepared); runtime.shutdown().await; - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } } #[tokio::test] async fn model_catalog_is_frozen_without_entering_the_request_chain() { - let (cache, store, runtime) = super::super::setup_with_contributions(Contributions::from([ - crate::support::contribution( + let (cache, store, runtime) = + super::setup_with_contributions(Contributions::from([crate::support::contribution( Capability::ModelCatalog, vec![Stage::Registration], vec![], vec![], - ), - ])) - .await; + )])) + .await; store.snapshot.lock().unwrap().instances[0].configuration = serde_json::json!({ "model_catalog": {"aliases": [{"id": "my-model", "provider": "openai", "model": "upstream-model"}], "models": []} }); @@ -314,7 +311,7 @@ async fn model_catalog_is_frozen_without_entering_the_request_chain() { drop(disabled); drop(reused); drop(generation); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] @@ -329,14 +326,12 @@ async fn model_catalog_prepares_plugin_aliases_without_semantic_validation() { serde_json::json!([{"id":"alias","provider":"openai","model":"target","capabilities":[]}]), ] { let (cache, store, runtime) = - super::super::setup_with_contributions(Contributions::from([ - crate::support::contribution( - Capability::ModelCatalog, - vec![Stage::Registration], - vec![], - vec![], - ), - ])) + super::setup_with_contributions(Contributions::from([crate::support::contribution( + Capability::ModelCatalog, + vec![Stage::Registration], + vec![], + vec![], + )])) .await; let mut snapshot = store.snapshot.lock().unwrap().clone(); snapshot.instances[0].configuration = @@ -348,18 +343,17 @@ async fn model_catalog_prepares_plugin_aliases_without_semantic_validation() { assert_eq!(prepared.model_aliases().len(), expected); drop(prepared); runtime.shutdown().await; - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } - let (cache, store, runtime) = super::super::setup_with_contributions(Contributions::from([ - crate::support::contribution( + let (cache, store, runtime) = + super::setup_with_contributions(Contributions::from([crate::support::contribution( Capability::ModelCatalog, vec![Stage::Registration], vec![], vec![], - ), - ])) - .await; + )])) + .await; let mut snapshot = store.snapshot.lock().unwrap().clone(); snapshot.instances[0].configuration = serde_json::json!({ "model_catalog":{"aliases":[{"id":"alias","provider":"openai","model":"target"}],"models":[]} @@ -375,12 +369,12 @@ async fn model_catalog_prepares_plugin_aliases_without_semantic_validation() { assert_eq!(prepared.model_aliases()[1].owner, "instance-two"); drop(prepared); runtime.shutdown().await; - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] async fn failed_candidate_leaves_the_previous_generation_ready() { - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let snapshot = store.snapshot.lock().unwrap().clone(); let active = PluginPreparation::prepare(&runtime, snapshot.clone()) .await @@ -394,12 +388,12 @@ async fn failed_candidate_leaves_the_previous_generation_ready() { ); assert!(active.is_ready()); drop(active); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] async fn disabled_missing_artifacts_do_not_block_the_remaining_generation_or_offline_recovery() { - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let mut snapshot = store.snapshot.lock().unwrap().clone(); let mut missing = snapshot.instances[0].clone(); missing.id = "missing".into(); @@ -411,18 +405,18 @@ async fn disabled_missing_artifacts_do_not_block_the_remaining_generation_or_off .unwrap(); assert!(active.is_ready()); drop(active); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; let restored = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) .await .unwrap(); assert!(restored.is_ready()); drop(restored); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] async fn preparation_cannot_mix_a_newer_persisted_revision_with_an_older_snapshot() { - let (_cache, _store, runtime) = super::super::setup().await; + let (_cache, _store, runtime) = super::setup().await; assert!( ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(2).unwrap()) .await @@ -432,7 +426,7 @@ async fn preparation_cannot_mix_a_newer_persisted_revision_with_an_older_snapsho #[tokio::test] async fn restoring_a_failed_plugin_keeps_other_instances_ready_and_reports_only_its_failure() { - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let snapshot = { let mut snapshot = store.snapshot.lock().unwrap(); let mut failed = snapshot.instances[0].clone(); @@ -472,12 +466,12 @@ async fn restoring_a_failed_plugin_keeps_other_instances_ready_and_reports_only_ ); drop(reused); drop(generation); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } #[tokio::test] async fn an_existing_failed_plugin_does_not_prevent_editing_an_unrelated_instance() { - let (cache, store, runtime) = super::super::setup().await; + let (cache, store, runtime) = super::setup().await; let mut snapshot = store.snapshot.lock().unwrap().clone(); let mut failed = snapshot.instances[0].clone(); failed.id = "failed-plugin".into(); @@ -490,5 +484,5 @@ async fn an_existing_failed_plugin_does_not_prevent_editing_an_unrelated_instanc .expect("only the instance being changed must successfully prepare"); assert!(candidate.can_serve()); drop(candidate); - super::super::wait_until_empty(cache.path()).await; + super::wait_until_empty(cache.path()).await; } diff --git a/backend/crates/gateway-plugin/sdk/src/client/http.rs b/backend/crates/gateway-plugin/sdk/src/client/http.rs index 1b63a3f48..9c321b5ce 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/http.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/http.rs @@ -2,7 +2,7 @@ use serde::{Serialize, de::DeserializeOwned}; -use super::{HostClient, HostReply, SessionError, read::PendingRead}; +use super::{HostClient, HostReply, SessionError, pending_read::PendingRead}; use crate::{ ErrorCode, PluginFault, call::{ diff --git a/backend/crates/gateway-plugin/sdk/src/client/middleware/body.rs b/backend/crates/gateway-plugin/sdk/src/client/middleware/body.rs index c2097cc55..ec577e28a 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/middleware/body.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/body.rs @@ -16,7 +16,7 @@ use super::super::session::{ HostClient, PullResponseFuture, PullResponseStream, ResponseStream, SessionError, StreamSender, }; use super::invalid_input; -use crate::client::read::PendingRead; +use crate::client::pending_read::PendingRead; const MAXIMUM_MAPPED_FRAMES_PER_SOURCE: usize = 64; const MAXIMUM_MAPPED_BYTES_PER_SOURCE: usize = 8 * 1024 * 1024; diff --git a/backend/crates/gateway-plugin/sdk/src/client/middleware/http.rs b/backend/crates/gateway-plugin/sdk/src/client/middleware/http.rs index fd4a1a29a..c3de63b45 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/middleware/http.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/http.rs @@ -13,7 +13,7 @@ use crate::{ HANDLE_METHOD, MiddlewareBodyClose, MiddlewareBodyRead, MiddlewareHeader, NEXT_METHOD, http as wire, }, - client::{HostReply, read::PendingRead}, + client::{HostReply, pending_read::PendingRead}, }; /// HTTP 数据帧与 trailers 分开表达;结束由流完成表示,不插入协议正文 diff --git a/backend/crates/gateway-plugin/sdk/src/client/middleware/websocket.rs b/backend/crates/gateway-plugin/sdk/src/client/middleware/websocket.rs index 9b02de2a1..435e6c8cd 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/middleware/websocket.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/websocket.rs @@ -2,7 +2,7 @@ use super::super::session::{CallCancellation, CallReply, HostClient, PluginCall, SessionError}; use super::{MiddlewareInput, MiddlewareOutput, invalid_input}; -use crate::client::{HostReply, read::PendingRead}; +use crate::client::{HostReply, pending_read::PendingRead}; use crate::{ CallContext, ErrorCode, PluginFault, Stage, call::middleware::{ diff --git a/backend/crates/gateway-plugin/sdk/src/client/mod.rs b/backend/crates/gateway-plugin/sdk/src/client/mod.rs index 24e444806..f8043121b 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/mod.rs @@ -5,8 +5,8 @@ mod frame; mod http; mod keys; mod middleware; +mod pending_read; mod plugin; -mod read; mod resources; mod session; mod upstream_adapter; diff --git a/backend/crates/gateway-plugin/sdk/src/client/read.rs b/backend/crates/gateway-plugin/sdk/src/client/pending_read.rs similarity index 100% rename from backend/crates/gateway-plugin/sdk/src/client/read.rs rename to backend/crates/gateway-plugin/sdk/src/client/pending_read.rs diff --git a/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs b/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs index 503f346e5..259d32813 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs @@ -5,7 +5,7 @@ use tokio::sync::{Mutex, OnceCell}; use super::{ HostClient, HostReply, SessionError, http::{callback, empty_callback, invalid}, - read::PendingRead, + pending_read::PendingRead, }; use crate::{ ErrorCode, PluginFault, diff --git a/backend/crates/gateway-plugin/sdk/tests/client/mod.rs b/backend/crates/gateway-plugin/sdk/tests/client/mod.rs index 551836fae..6b0c32a91 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/mod.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/mod.rs @@ -2,6 +2,6 @@ mod frame; mod http; +mod pending_read; mod plugin; -mod read; mod session; diff --git a/backend/crates/gateway-plugin/sdk/tests/client/read.rs b/backend/crates/gateway-plugin/sdk/tests/client/pending_read.rs similarity index 100% rename from backend/crates/gateway-plugin/sdk/tests/client/read.rs rename to backend/crates/gateway-plugin/sdk/tests/client/pending_read.rs diff --git a/backend/crates/gateway-store/src/error.rs b/backend/crates/gateway-store/src/error.rs new file mode 100644 index 000000000..e0ade5acc --- /dev/null +++ b/backend/crates/gateway-store/src/error.rs @@ -0,0 +1,156 @@ +//! Store 的基础设施错误分类、来源保留与领域端口映射 + +use gateway_admin::ports::store::{AdminStoreError, AdminStoreErrorKind}; + +/// 发生错误的基础设施边界 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StoreBackend { + PostgreSql, + Redis, +} + +/// 上层状态机需要区分的稳定冲突类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConflictKind { + StaleRevision, + DuplicateName, + AlreadyFinalized, + RequestNotRunning, + InvalidTransition, + LeaseLost, + FencingTokenStale, +} + +/// Store adapter 的稳定错误边界 +#[derive(Debug, Clone, thiserror::Error)] +pub enum StoreError { + #[error("{backend:?} store is unavailable: {message}")] + Unavailable { + backend: StoreBackend, + message: String, + source: Option, + }, + #[error("{entity} {id} was not found")] + NotFound { + entity: &'static str, + id: String, + source: Option, + }, + #[error("store conflict for {entity} {id}: {kind:?}")] + Conflict { + entity: &'static str, + id: String, + kind: ConflictKind, + source: Option, + }, + #[error("invalid persisted {entity}: {message}")] + InvalidData { + entity: &'static str, + message: String, + source: Option, + }, +} + +impl StoreError { + /// 本地校验可没有来源;转换已有失败时附加真实原因 + #[must_use] + pub(crate) fn with_source( + mut self, + error: impl Into, + ) -> Self { + let error = error.into(); + match &mut self { + Self::Unavailable { source, .. } + | Self::InvalidData { source, .. } + | Self::Conflict { source, .. } + | Self::NotFound { source, .. } => *source = Some(error), + } + self + } + + pub(crate) fn with_cleanup( + mut self, + cleanup: impl Into, + ) -> Self { + let source = match &mut self { + Self::Unavailable { source, .. } + | Self::InvalidData { source, .. } + | Self::Conflict { source, .. } + | Self::NotFound { source, .. } => source, + }; + *source = Some(gateway_core::error::ErrorSource::cleanup( + source.take(), + cleanup, + )); + self + } + + fn admin_kind(&self) -> AdminStoreErrorKind { + match self { + Self::NotFound { .. } => AdminStoreErrorKind::NotFound, + Self::Conflict { + kind: ConflictKind::StaleRevision, + .. + } => AdminStoreErrorKind::StaleRevision, + Self::Conflict { + kind: ConflictKind::DuplicateName, + .. + } => AdminStoreErrorKind::DuplicateName, + Self::Conflict { .. } => AdminStoreErrorKind::Conflict, + Self::InvalidData { .. } => AdminStoreErrorKind::Invalid, + Self::Unavailable { .. } => AdminStoreErrorKind::Unavailable, + } + } + + fn core_kind(&self) -> gateway_core::error::StoreErrorKind { + use gateway_core::error::StoreErrorKind; + match self { + Self::Unavailable { .. } => StoreErrorKind::Unavailable, + Self::Conflict { .. } => StoreErrorKind::Conflict, + Self::NotFound { .. } | Self::InvalidData { .. } => StoreErrorKind::InvalidData, + } + } +} + +pub type StoreResult = Result; +pub(crate) fn admin_store_error(resource: &'static str, error: StoreError) -> AdminStoreError { + AdminStoreError::new(error.admin_kind(), resource, "store operation failed").with_source(error) +} + +pub(crate) fn postgres_unavailable( + operation: &'static str, + source: impl std::error::Error + Send + Sync + 'static, +) -> StoreError { + StoreError::Unavailable { + backend: StoreBackend::PostgreSql, + message: operation.to_owned(), + source: Some(gateway_core::error::ErrorSource::new(source)), + } +} + +pub(crate) fn redis_unavailable( + operation: &'static str, + source: impl std::error::Error + Send + Sync + 'static, +) -> StoreError { + StoreError::Unavailable { + backend: StoreBackend::Redis, + message: operation.to_owned(), + source: Some(gateway_core::error::ErrorSource::new(source)), + } +} + +/// Provider 存储端口共用不可用分类,实际后端及原因由来源链保留 +pub(crate) fn provider_unavailable( + operation: &'static str, + source: impl std::error::Error + Send + Sync + 'static, +) -> gateway_core::provider_ports::ProviderStoreError { + gateway_core::provider_ports::ProviderStoreError::caused_by( + gateway_core::provider_ports::ProviderStoreErrorKind::Unavailable, + operation, + source, + ) +} + +pub(crate) fn core_store_error(error: StoreError) -> gateway_core::error::StoreError { + gateway_core::error::StoreError::caused_by(error.core_kind(), error) +} diff --git a/backend/crates/gateway-store/src/lib.rs b/backend/crates/gateway-store/src/lib.rs index f85ddfcda..2c206570b 100644 --- a/backend/crates/gateway-store/src/lib.rs +++ b/backend/crates/gateway-store/src/lib.rs @@ -4,16 +4,16 @@ //! 本 crate 只负责把 PostgreSQL 业务表 //! 和可丢失 Redis 状态映射为明确的基础设施操作 +use std::fmt; use std::sync::Arc; use std::time::{Duration, SystemTime}; -use std::{fmt, num::NonZeroU64}; +use gateway_admin::model::MutationContext; use gateway_admin::model::auth::{AdminAuditEvent as AdminAuditModel, AuthSession, SessionSubject}; use gateway_admin::model::settings::{ AdminApiKey, AdminApiKeyMutation, ModelMappings, ReplaceRuntimeSettings, RotationStrategy as AdminRotationStrategy, RuntimeSettings as AdminRuntimeSettings, }; -use gateway_admin::model::{MutationContext, Revision as AdminRevision}; use gateway_admin::ports::backup::BackupStorePorts; use gateway_admin::ports::store::{ AdminAccountStorePorts, AdminStoreError, AdminStoreErrorKind, AdminStorePorts, @@ -28,11 +28,11 @@ use gateway_core::task::{ WorkerSchedule, WorkerTaskError, }; use serde::Deserialize; -use serde_json::{Map, Value}; mod admin_adapter; mod bundle; mod config; +mod error; mod lease_renewal; mod value; mod workers; @@ -44,6 +44,12 @@ pub mod redis; pub(crate) use admin_adapter::*; pub use bundle::*; pub use config::*; +pub use error::{ConflictKind, StoreBackend, StoreError, StoreResult}; +pub(crate) use error::{ + admin_store_error, core_store_error, postgres_unavailable, provider_unavailable, + redis_unavailable, +}; +pub(crate) use postgres::mutation_audit; pub use value::*; pub(crate) use workers::*; pub use workers::{CommandStoreDrainError, PostgresHealthProbe, RequestAnalyticsCycle}; diff --git a/backend/crates/gateway-store/src/postgres/admin_security_audit.rs b/backend/crates/gateway-store/src/postgres/admin_security_audit.rs index 814589c66..8afcf6940 100644 --- a/backend/crates/gateway-store/src/postgres/admin_security_audit.rs +++ b/backend/crates/gateway-store/src/postgres/admin_security_audit.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use gateway_admin::model::MutationContext; use sqlx::{PgPool, Postgres, Transaction}; use crate::{Revision, StoreError, StoreResult, postgres_unavailable, require_nonempty}; @@ -79,6 +80,32 @@ impl AdminAuditEvent { } } +pub(crate) fn mutation_audit( + context: &MutationContext, + operation: gateway_admin::model::audit::MutationAuditOperation, + entity_ref: &str, + changed_fields: Vec, +) -> AdminAuditEvent { + let event = gateway_admin::model::audit::MutationAuditIntent { + operation, + entity_ref, + } + .event(context, changed_fields); + AdminAuditEvent { + id: event.id, + actor_kind: event.actor_kind.into(), + actor_admin_user_id: event.actor_admin_user_id, + actor_ref: event.actor_ref, + admin_request_id: event.request_id, + action: event.action, + entity_kind: event.entity_kind, + entity_ref: event.entity_ref, + config_revision: None, + changed_fields: event.changed_fields, + created_at: event.occurred_at, + } +} + #[async_trait] pub trait AdminSecurityAuditRepository: Send + Sync { async fn password_hash(&self, admin_user_id: &str) -> StoreResult>; diff --git a/backend/crates/gateway-store/src/value.rs b/backend/crates/gateway-store/src/value.rs index 088af8893..6da5428d9 100644 --- a/backend/crates/gateway-store/src/value.rs +++ b/backend/crates/gateway-store/src/value.rs @@ -1,118 +1,15 @@ -//! Store 值类型、错误与跨层映射 +//! Store 值对象、持久化校验与 revision 转换 -use super::*; +use std::{fmt, num::NonZeroU64}; -/// 发生错误的基础设施边界 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StoreBackend { - PostgreSql, - Redis, -} +use gateway_admin::{ + model::Revision as AdminRevision, + ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, +}; +use serde_json::{Map, Value}; -/// 上层状态机需要区分的稳定冲突类型 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConflictKind { - StaleRevision, - DuplicateName, - AlreadyFinalized, - RequestNotRunning, - InvalidTransition, - LeaseLost, - FencingTokenStale, -} +use crate::{StoreError, StoreResult, admin_store_error}; -/// Store adapter 的稳定错误边界 -#[derive(Debug, Clone, thiserror::Error)] -pub enum StoreError { - #[error("{backend:?} store is unavailable: {message}")] - Unavailable { - backend: StoreBackend, - message: String, - source: Option, - }, - #[error("{entity} {id} was not found")] - NotFound { - entity: &'static str, - id: String, - source: Option, - }, - #[error("store conflict for {entity} {id}: {kind:?}")] - Conflict { - entity: &'static str, - id: String, - kind: ConflictKind, - source: Option, - }, - #[error("invalid persisted {entity}: {message}")] - InvalidData { - entity: &'static str, - message: String, - source: Option, - }, -} - -impl StoreError { - /// 本地校验可没有来源;转换已有失败时附加真实原因 - #[must_use] - pub(crate) fn with_source( - mut self, - error: impl Into, - ) -> Self { - let error = error.into(); - match &mut self { - Self::Unavailable { source, .. } - | Self::InvalidData { source, .. } - | Self::Conflict { source, .. } - | Self::NotFound { source, .. } => *source = Some(error), - } - self - } - - pub(crate) fn with_cleanup( - mut self, - cleanup: impl Into, - ) -> Self { - let source = match &mut self { - Self::Unavailable { source, .. } - | Self::InvalidData { source, .. } - | Self::Conflict { source, .. } - | Self::NotFound { source, .. } => source, - }; - *source = Some(gateway_core::error::ErrorSource::cleanup( - source.take(), - cleanup, - )); - self - } - - fn admin_kind(&self) -> AdminStoreErrorKind { - match self { - Self::NotFound { .. } => AdminStoreErrorKind::NotFound, - Self::Conflict { - kind: ConflictKind::StaleRevision, - .. - } => AdminStoreErrorKind::StaleRevision, - Self::Conflict { - kind: ConflictKind::DuplicateName, - .. - } => AdminStoreErrorKind::DuplicateName, - Self::Conflict { .. } => AdminStoreErrorKind::Conflict, - Self::InvalidData { .. } => AdminStoreErrorKind::Invalid, - Self::Unavailable { .. } => AdminStoreErrorKind::Unavailable, - } - } - - fn core_kind(&self) -> gateway_core::error::StoreErrorKind { - use gateway_core::error::StoreErrorKind; - match self { - Self::Unavailable { .. } => StoreErrorKind::Unavailable, - Self::Conflict { .. } => StoreErrorKind::Conflict, - Self::NotFound { .. } | Self::InvalidData { .. } => StoreErrorKind::InvalidData, - } - } -} - -pub type StoreResult = Result; pub(crate) fn store_revision(revision: AdminRevision) -> AdminStoreResult { Revision::new(revision.get()).map_err(|error| admin_store_error("config revision", error)) } @@ -127,36 +24,6 @@ pub(crate) fn admin_revision(revision: Revision) -> AdminStoreResult, -) -> postgres::AdminAuditEvent { - let event = gateway_admin::model::audit::MutationAuditIntent { - operation, - entity_ref, - } - .event(context, changed_fields); - postgres::AdminAuditEvent { - id: event.id, - actor_kind: event.actor_kind.into(), - actor_admin_user_id: event.actor_admin_user_id, - actor_ref: event.actor_ref, - admin_request_id: event.request_id, - action: event.action, - entity_kind: event.entity_kind, - entity_ref: event.entity_ref, - config_revision: None, - changed_fields: event.changed_fields, - created_at: event.occurred_at, - } -} - -pub(crate) fn admin_store_error(resource: &'static str, error: StoreError) -> AdminStoreError { - AdminStoreError::new(error.admin_kind(), resource, "store operation failed").with_source(error) -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Revision(NonZeroU64); @@ -246,41 +113,3 @@ pub(crate) fn require_nonempty( Ok(()) } } - -pub(crate) fn postgres_unavailable( - operation: &'static str, - source: impl std::error::Error + Send + Sync + 'static, -) -> StoreError { - StoreError::Unavailable { - backend: StoreBackend::PostgreSql, - message: operation.to_owned(), - source: Some(gateway_core::error::ErrorSource::new(source)), - } -} - -pub(crate) fn redis_unavailable( - operation: &'static str, - source: impl std::error::Error + Send + Sync + 'static, -) -> StoreError { - StoreError::Unavailable { - backend: StoreBackend::Redis, - message: operation.to_owned(), - source: Some(gateway_core::error::ErrorSource::new(source)), - } -} - -/// Provider 存储端口共用不可用分类,实际后端及原因由来源链保留 -pub(crate) fn provider_unavailable( - operation: &'static str, - source: impl std::error::Error + Send + Sync + 'static, -) -> gateway_core::provider_ports::ProviderStoreError { - gateway_core::provider_ports::ProviderStoreError::caused_by( - gateway_core::provider_ports::ProviderStoreErrorKind::Unavailable, - operation, - source, - ) -} - -pub(crate) fn core_store_error(error: StoreError) -> gateway_core::error::StoreError { - gateway_core::error::StoreError::caused_by(error.core_kind(), error) -} diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index c614e9912..c1c10b003 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -25,8 +25,8 @@ use serde_json::Value; use thiserror::Error; use super::api_key::{ApiKeyCredentialData, CODEX_AUTHENTICATION_KIND_API_KEY}; +use super::codec::CodexCredentialCodec; use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery}; -use super::security::CodexCredentialCodec; use super::token_client::{ OpenAiTokenClient, PersonalAccessTokenError, RefreshFailure, TokenRefresher, }; diff --git a/backend/crates/providers/openai/src/credential/security.rs b/backend/crates/providers/openai/src/credential/codec.rs similarity index 100% rename from backend/crates/providers/openai/src/credential/security.rs rename to backend/crates/providers/openai/src/credential/codec.rs diff --git a/backend/crates/providers/openai/src/credential/mod.rs b/backend/crates/providers/openai/src/credential/mod.rs index fb84fa0bb..705f59bd6 100644 --- a/backend/crates/providers/openai/src/credential/mod.rs +++ b/backend/crates/providers/openai/src/credential/mod.rs @@ -4,6 +4,7 @@ mod admin; mod affinity; mod api_key; mod catalog; +mod codec; mod cookie; mod eligibility; mod oauth; @@ -12,7 +13,6 @@ mod quota; mod recovery_log; mod refresh; mod repository; -mod security; mod selector; pub mod token_client; mod types; @@ -41,6 +41,10 @@ pub use catalog::{ CodexCatalogScope, CodexCredentialCatalogError, CodexCredentialCatalogService, CodexCredentialCatalogSnapshot, CodexPlanCatalog, }; +pub use codec::{ + CodexCredentialCodec, CodexCredentialDataError, CodexRuntimeAuthentication, + CodexRuntimeCredential, +}; pub use cookie::{CodexCookiePolicy, CookiePolicyError}; pub use oauth::{ CodexOAuthAdmin, CodexOAuthAdminError, CodexOAuthAdminService, CodexOAuthAuthorizationStarted, @@ -62,10 +66,6 @@ pub use refresh::{ DueCodexCredential, }; pub use repository::{CodexCredentialRepository, CredentialRepositoryError}; -pub use security::{ - CodexCredentialCodec, CodexCredentialDataError, CodexRuntimeAuthentication, - CodexRuntimeCredential, -}; pub use selector::{ CodexAccountFailure, CodexCredentialLease, CodexCredentialSelector, CredentialSelectionError, SelectCodexCredential, diff --git a/backend/crates/providers/openai/src/credential/oauth.rs b/backend/crates/providers/openai/src/credential/oauth.rs index 0435fc120..08ce5a77d 100644 --- a/backend/crates/providers/openai/src/credential/oauth.rs +++ b/backend/crates/providers/openai/src/credential/oauth.rs @@ -25,8 +25,8 @@ use super::admin::{ CodexCredentialAdmin, CodexCredentialAdminError, PreparedCodexCredentialRotation, UnresolvedCodexOAuthCredential, }; +use super::codec::CodexCredentialCodec; use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery}; -use super::security::CodexCredentialCodec; use super::token_client::{ AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant, OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI, diff --git a/backend/crates/providers/openai/src/credential/quota/mod.rs b/backend/crates/providers/openai/src/credential/quota/mod.rs index dbee47133..ea3afbbfe 100644 --- a/backend/crates/providers/openai/src/credential/quota/mod.rs +++ b/backend/crates/providers/openai/src/credential/quota/mod.rs @@ -55,8 +55,8 @@ use crate::transport::{ CodexRateLimitResetCredits, CodexRateLimitResetCreditsConsumeResult, CodexRequestContext, }; +use super::codec::CodexRuntimeCredential; use super::repository::{CodexCredentialRepository, CredentialRepositoryError}; -use super::security::CodexRuntimeCredential; use document::{ DEFAULT_CODEX_LIMIT_ID, RateLimitSnapshotsByLimitId, normalize_quota_window_placeholders, observed_account_plan, diff --git a/backend/crates/providers/openai/src/credential/repository.rs b/backend/crates/providers/openai/src/credential/repository.rs index f4a554ba1..07b89e5d1 100644 --- a/backend/crates/providers/openai/src/credential/repository.rs +++ b/backend/crates/providers/openai/src/credential/repository.rs @@ -14,7 +14,7 @@ use gateway_core::routing::ProviderKind; use secrecy::ExposeSecret; use thiserror::Error; -use super::security::{CodexCredentialCodec, CodexCredentialDataError, CodexRuntimeCredential}; +use super::codec::{CodexCredentialCodec, CodexCredentialDataError, CodexRuntimeCredential}; use super::types::{CodexCredentialData, CodexOAuthSecret}; const PROVIDER_NAME: &str = "openai"; diff --git a/backend/crates/providers/openai/src/credential/selector.rs b/backend/crates/providers/openai/src/credential/selector.rs index ee4cc6843..5dfd5cfa8 100644 --- a/backend/crates/providers/openai/src/credential/selector.rs +++ b/backend/crates/providers/openai/src/credential/selector.rs @@ -28,12 +28,12 @@ use thiserror::Error; use url::Url; use super::affinity::CodexSessionAffinity; +use super::codec::CodexRuntimeAuthentication; use super::cookie::CodexCookiePolicy; use super::eligibility::CodexSelectionModel; use super::quota::CodexCredentialQuotaService; use super::refresh::refresh_recovery_deadline; use super::repository::{CodexCredentialRepository, CredentialRepositoryError}; -use super::security::CodexRuntimeAuthentication; use super::types::{ CODEX_AUTHENTICATION_KIND_OAUTH, CodexCookie, CodexCookieCaptureOutcome, RuntimeCodexCookie, }; diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index ca23accff..a5bb19f65 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -39,13 +39,14 @@ pub use jitter::{ catalog_refresh_jitter, quota_failure_refresh_delay, server_error_retry_jitter, uniform_delay, }; pub use provider::{ - CodexProvider, CodexProviderConfigError, CodexProviderTransport, OFFICIAL_CODEX_BASE_PATH, - OFFICIAL_CODEX_BASE_URL, openai_failure_affects_account_score, + CodexProvider, CodexProviderConfigError, CodexProviderTransport, + openai_failure_affects_account_score, }; pub mod credential; pub mod transport; +pub use transport::endpoints::{OFFICIAL_CODEX_BASE_PATH, OFFICIAL_CODEX_BASE_URL}; pub use transport::tls::{build_reqwest_client_with_custom_ca, ensure_rustls_provider}; pub use transport::{ CodexCanonicalDecoder, CodexCanonicalError, CodexRequestEncodeError, OpenAiBillingUsage, diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index b5e564f49..bcf749a2d 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -128,8 +128,6 @@ const WEBSOCKET_HTTP_FALLBACK_THRESHOLD_BYTES: usize = 15 * 1024 * 1024; const MAX_COOKIE_HEADER_BYTES: usize = 16 * 1024; // 额度拒绝后的 usage 补查自身执行时长上限;结算等待的随机延迟见 jitter 模块 const QUOTA_FAILURE_REFRESH_TIMEOUT: Duration = Duration::from_secs(5); -pub const OFFICIAL_CODEX_BASE_PATH: &str = "/backend-api"; -pub const OFFICIAL_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CodexProviderTransport { diff --git a/backend/crates/providers/openai/src/transport/client.rs b/backend/crates/providers/openai/src/transport/client.rs index eceb14b68..3cd40221f 100644 --- a/backend/crates/providers/openai/src/transport/client.rs +++ b/backend/crates/providers/openai/src/transport/client.rs @@ -1,4 +1,4 @@ -//! Codex HTTP/SSE 上游客户端、请求头构造、TLS 与自定义 CA +//! Codex 上游客户端的共享状态、连接配置、请求头与传输合同 use std::{ collections::HashMap, @@ -720,6 +720,67 @@ pub struct CodexBackendClient { } impl CodexBackendClient { + pub(crate) const fn profile_state(&self) -> &CodexWireProfileState { + &self.profile + } + + /// 请求只持有自己的画像副本;连接池和 HTTP client 继续共享既有资源 + pub fn with_request_profile(mut self, profile: super::profile::CodexWireProfile) -> Self { + self.profile = CodexWireProfileState::new(profile); + self + } + + /// 构造客户端 + pub fn new( + client: Client, + base_url: impl Into, + profile: CodexWireProfileState, + ) -> Self { + let base_url = base_url.into().trim_end_matches('/').to_string(); + Self { + routing_affinity: None, + privacy: None, + timezone: Default::default(), + connection_budget: None, + direct_client: client.clone(), + client, + websocket_origin_key: websocket_origin_key(&base_url), + outbound_proxy: None, + egress_key: String::new(), + middleware_headers: Vec::new(), + outbound_middleware: None, + base_url, + official_base_url: super::endpoints::OFFICIAL_CODEX_BASE_URL.to_owned(), + protocol: OpenAiUpstreamProtocol::Codex, + profile, + websocket_pool: None, + websocket_origin_breaker: WebSocketOriginBreaker::default(), + } + } + + /// 为 Responses WebSocket 请求启用连接池 + pub fn with_websocket_pool(mut self, pool: Arc) -> Self { + self.websocket_pool = Some(pool); + self + } + + /// 附加当前 attempt 经 Core 复核的业务请求头 + #[must_use] + pub(crate) fn with_middleware_headers( + mut self, + middleware_headers: Vec, + ) -> Self { + self.middleware_headers = middleware_headers; + self + } + + /// 驱逐指定账号的 Responses WebSocket 池连接 + pub async fn evict_websocket_account(&self, account_id: &str) { + if let Some(pool) = &self.websocket_pool { + pool.evict_account(account_id).await; + } + } + pub(crate) fn with_routing_affinity( mut self, affinity: Option, diff --git a/backend/crates/providers/openai/src/transport/client_catalog.rs b/backend/crates/providers/openai/src/transport/client_catalog.rs new file mode 100644 index 000000000..707f1b821 --- /dev/null +++ b/backend/crates/providers/openai/src/transport/client_catalog.rs @@ -0,0 +1,75 @@ +//! Codex 与 Responses API 模型目录的 HTTP 读取与客户端版本协商 + +use gateway_protocol::openai::events::retry_after_seconds_from_body; + +use super::{ + catalog::{CodexModelCatalogSnapshot, catalog_etag, parse_codex_model_catalog}, + client::{ + CodexBackendClient, CodexBackendTransport, CodexClientError, CodexClientResult, + CodexRequestContext, OpenAiUpstreamProtocol, retry_after_seconds, + }, + diagnostics::CodexUpstreamSendPhase, + endpoints::endpoint_url, + response_meta, +}; + +impl CodexBackendClient { + /// 客户端目录按调用方版本协商;后台目录仍使用经过核验的服务端画像版本 + pub async fn fetch_models_with_context( + &self, + context: CodexRequestContext<'_>, + client_version: Option<&str>, + ) -> CodexClientResult { + let path = match self.protocol { + OpenAiUpstreamProtocol::Codex => "codex/models", + OpenAiUpstreamProtocol::ResponsesApi => "models", + }; + let profile = self.profile.snapshot(); + let headers = self.model_request_headers(&profile, context)?; + let mut request = self + .client + .get(endpoint_url(&self.base_url, path)) + .headers(headers); + let client_version = client_version.or_else(|| { + (self.protocol == OpenAiUpstreamProtocol::Codex) + .then_some(profile.codex_version.as_str()) + }); + if let Some(client_version) = client_version { + request = request.query(&[("client_version", client_version)]); + } + let response = request.send().await?; + let status = response.status(); + let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers()) + .with_response_origin(Some(response.url().clone())); + let set_cookie_headers = response_meta::set_cookie_headers(response.headers()); + let retry_after_seconds = retry_after_seconds(response.headers(), None); + let etag = status + .is_success() + .then(|| catalog_etag(response.headers())) + .transpose()? + .flatten(); + let body = response.bytes().await?; + if !status.is_success() { + let body = String::from_utf8_lossy(&body).into_owned(); + return Err(CodexClientError::Upstream { + status, + retry_after_seconds: retry_after_seconds + .or_else(|| retry_after_seconds_from_body(&body)), + body, + client_response: None, + diagnostics: Box::new(diagnostics), + set_cookie_headers, + rate_limit_headers: Vec::new(), + transport: CodexBackendTransport::HttpSse, + transport_metrics: Box::default(), + send_phase: CodexUpstreamSendPhase::AfterPayload, + }); + } + Ok(match self.protocol { + OpenAiUpstreamProtocol::Codex => parse_codex_model_catalog(&body, etag.as_deref())?, + OpenAiUpstreamProtocol::ResponsesApi => { + super::catalog::parse_api_model_catalog(&body, etag.as_deref())? + } + }) + } +} diff --git a/backend/crates/providers/openai/src/transport/client_sse.rs b/backend/crates/providers/openai/src/transport/client_stream.rs similarity index 86% rename from backend/crates/providers/openai/src/transport/client_sse.rs rename to backend/crates/providers/openai/src/transport/client_stream.rs index 7f618dc83..f3c80b076 100644 --- a/backend/crates/providers/openai/src/transport/client_sse.rs +++ b/backend/crates/providers/openai/src/transport/client_stream.rs @@ -11,17 +11,15 @@ use gateway_protocol::openai::{ sse::{SseEventDecoder, SseFrame}, }; use reqwest::{ - Client, Response as ReqwestResponse, + Response as ReqwestResponse, header::{CONTENT_ENCODING, CONTENT_TYPE, HeaderMap, HeaderValue}, }; use tokio_tungstenite::tungstenite::handshake::client::generate_key; use crate::transport::{ - catalog::{CodexModelCatalogSnapshot, catalog_etag, parse_codex_model_catalog}, diagnostics::CodexUpstreamSendPhase, endpoints::endpoint_url, headers::websocket_header_pairs, - profile::CodexWireProfileState, protocol::{ responses::{ CodexResponsesRequest, TransportRequirement, is_replaceable_response_prelude, @@ -34,10 +32,10 @@ use crate::transport::{ }, response_meta, websocket::{ - CodexWebSocketConnection, CodexWebSocketExchangeError, CodexWebSocketPool, - CodexWebSocketPoolKey, DEFAULT_STREAM_IDLE_TIMEOUT, WEBSOCKET_FAST_PATH_BUDGET, - WebSocketFastPath, WebSocketOriginBreaker, execute_prepared_response_create_request_stream, - post_send_ambiguous, prepare_response_create_request_with_pool, websocket_audit_dir, + CodexWebSocketConnection, CodexWebSocketExchangeError, CodexWebSocketPoolKey, + DEFAULT_STREAM_IDLE_TIMEOUT, WEBSOCKET_FAST_PATH_BUDGET, WebSocketFastPath, + execute_prepared_response_create_request_stream, post_send_ambiguous, + prepare_response_create_request_with_pool, websocket_audit_dir, write_websocket_audit_artifact_from_env, }, }; @@ -45,67 +43,6 @@ use crate::transport::{ use super::client::*; impl CodexBackendClient { - pub(crate) const fn profile_state(&self) -> &CodexWireProfileState { - &self.profile - } - - /// 请求只持有自己的画像副本;连接池和 HTTP client 继续共享既有资源 - pub fn with_request_profile(mut self, profile: super::profile::CodexWireProfile) -> Self { - self.profile = CodexWireProfileState::new(profile); - self - } - - /// 构造客户端 - pub fn new( - client: Client, - base_url: impl Into, - profile: CodexWireProfileState, - ) -> Self { - let base_url = base_url.into().trim_end_matches('/').to_string(); - Self { - routing_affinity: None, - privacy: None, - timezone: Default::default(), - connection_budget: None, - direct_client: client.clone(), - client, - websocket_origin_key: websocket_origin_key(&base_url), - outbound_proxy: None, - egress_key: String::new(), - middleware_headers: Vec::new(), - outbound_middleware: None, - base_url, - official_base_url: crate::OFFICIAL_CODEX_BASE_URL.to_owned(), - protocol: OpenAiUpstreamProtocol::Codex, - profile, - websocket_pool: None, - websocket_origin_breaker: WebSocketOriginBreaker::default(), - } - } - - /// 为 Responses WebSocket 请求启用连接池 - pub fn with_websocket_pool(mut self, pool: Arc) -> Self { - self.websocket_pool = Some(pool); - self - } - - /// 附加当前 attempt 经 Core 复核的业务请求头 - #[must_use] - pub(crate) fn with_middleware_headers( - mut self, - middleware_headers: Vec, - ) -> Self { - self.middleware_headers = middleware_headers; - self - } - - /// 驱逐指定账号的 Responses WebSocket 池连接 - pub async fn evict_websocket_account(&self, account_id: &str) { - if let Some(pool) = &self.websocket_pool { - pool.evict_account(account_id).await; - } - } - /// 发送 Responses SSE 请求并返回 live SSE 流(HTTP SSE fallback) pub(crate) async fn create_response_stream_http_sse( &self, @@ -661,65 +598,6 @@ impl CodexBackendClient { } Some(key) } - - /// 客户端目录按调用方版本协商;后台目录仍使用经过核验的服务端画像版本 - pub async fn fetch_models_with_context( - &self, - context: CodexRequestContext<'_>, - client_version: Option<&str>, - ) -> CodexClientResult { - let path = match self.protocol { - OpenAiUpstreamProtocol::Codex => "codex/models", - OpenAiUpstreamProtocol::ResponsesApi => "models", - }; - let profile = self.profile.snapshot(); - let headers = self.model_request_headers(&profile, context)?; - let mut request = self - .client - .get(endpoint_url(&self.base_url, path)) - .headers(headers); - let client_version = client_version.or_else(|| { - (self.protocol == OpenAiUpstreamProtocol::Codex) - .then_some(profile.codex_version.as_str()) - }); - if let Some(client_version) = client_version { - request = request.query(&[("client_version", client_version)]); - } - let response = request.send().await?; - let status = response.status(); - let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers()) - .with_response_origin(Some(response.url().clone())); - let set_cookie_headers = response_meta::set_cookie_headers(response.headers()); - let retry_after_seconds = retry_after_seconds(response.headers(), None); - let etag = status - .is_success() - .then(|| catalog_etag(response.headers())) - .transpose()? - .flatten(); - let body = response.bytes().await?; - if !status.is_success() { - let body = String::from_utf8_lossy(&body).into_owned(); - return Err(CodexClientError::Upstream { - status, - retry_after_seconds: retry_after_seconds - .or_else(|| retry_after_seconds_from_body(&body)), - body, - client_response: None, - diagnostics: Box::new(diagnostics), - set_cookie_headers, - rate_limit_headers: Vec::new(), - transport: CodexBackendTransport::HttpSse, - transport_metrics: Box::default(), - send_phase: CodexUpstreamSendPhase::AfterPayload, - }); - } - Ok(match self.protocol { - OpenAiUpstreamProtocol::Codex => parse_codex_model_catalog(&body, etag.as_deref())?, - OpenAiUpstreamProtocol::ResponsesApi => { - super::catalog::parse_api_model_catalog(&body, etag.as_deref())? - } - }) - } } fn is_websocket_replayable_prelude(frame: &[u8]) -> bool { diff --git a/backend/crates/providers/openai/src/transport/endpoints.rs b/backend/crates/providers/openai/src/transport/endpoints.rs index a36cb154a..77f2d1e92 100644 --- a/backend/crates/providers/openai/src/transport/endpoints.rs +++ b/backend/crates/providers/openai/src/transport/endpoints.rs @@ -1,5 +1,8 @@ //! Codex 上游端点常量、基础地址校验与请求 URL 构造 +pub const OFFICIAL_CODEX_BASE_PATH: &str = "/backend-api"; +pub const OFFICIAL_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api"; + /// `/codex/responses` pub const CODEX_RESPONSES_PATH: &str = "/codex/responses"; /// `/codex/images/generations` diff --git a/backend/crates/providers/openai/src/transport/mod.rs b/backend/crates/providers/openai/src/transport/mod.rs index febd5520c..e3b9d0971 100644 --- a/backend/crates/providers/openai/src/transport/mod.rs +++ b/backend/crates/providers/openai/src/transport/mod.rs @@ -4,9 +4,10 @@ mod automatic_context; pub mod canonical; pub mod catalog; pub mod client; +mod client_catalog; mod client_json; mod client_live; -mod client_sse; +mod client_stream; pub(crate) mod connection; mod correlation; pub mod diagnostics; diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index 81f2c8ef7..d09c8176b 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -44,7 +44,6 @@ use serde_json::{Map, Number, Value}; use sha2::{Digest as _, Sha256}; use uuid::Uuid; -use crate::XaiWireProfileState; use crate::credential::{ FailureClass, GrokAccountProfile, GrokCredentialAdmin, GrokCredentialCatalogError, GrokCredentialCatalogService, GrokCredentialQuotaService, GrokCredentialRefreshError, @@ -53,10 +52,11 @@ use crate::credential::{ GrokOAuthImportMetadata, GrokOAuthImportTokens, GrokOAuthSecret, GrokQuotaError, GrokQuotaPeriodKind, GrokQuotaSnapshot, OAuthError, PendingAuthorization, PreparedGrokCredentialRotation, PreparedGrokCredentialRotationGuard, RedirectUriAllowlist, - RotateManagedGrokCredential, SecretValue, VerifiedGrokAccount, VerifiedTokenSet, + RotateManagedGrokCredential, VerifiedGrokAccount, VerifiedTokenSet, }; use crate::transport::profile::{GrokCliReleaseSnapshot, GrokCliReleaseStatus}; use crate::transport::{GROK_CLI_BASE_URL, XAI_PROVIDER_NAME, grok_billing_breakdown_with_tier}; +use crate::{SecretValue, XaiWireProfileState}; const PENDING_SCHEMA_VERSION: u64 = 3; const PENDING_TTL: TimeDelta = TimeDelta::minutes(30); diff --git a/backend/crates/providers/xai/src/credential/discovery.rs b/backend/crates/providers/xai/src/credential/discovery.rs index c2a4cc865..41603f47f 100644 --- a/backend/crates/providers/xai/src/credential/discovery.rs +++ b/backend/crates/providers/xai/src/credential/discovery.rs @@ -3,10 +3,9 @@ use serde::Deserialize; use url::Url; +use crate::oauth_http::MAX_OAUTH_RESPONSE_BYTES; use crate::{GrokOAuthConfig, OAuthError, OAuthOperation, ProtocolViolation}; -pub(crate) const MAX_OAUTH_RESPONSE_BYTES: usize = 64 * 1024; - /// issuer 与端点均已通过官方 Grok Build origin 策略校验的发现文档 #[derive(Debug, Clone, PartialEq, Eq)] pub struct DiscoveryDocument { diff --git a/backend/crates/providers/xai/src/credential/mod.rs b/backend/crates/providers/xai/src/credential/mod.rs index 738b4ca61..68a69daf0 100644 --- a/backend/crates/providers/xai/src/credential/mod.rs +++ b/backend/crates/providers/xai/src/credential/mod.rs @@ -4,16 +4,14 @@ mod authorization_code; mod catalog; mod client; mod config; -pub(crate) mod discovery; +mod discovery; mod error; -mod http; mod import; mod oidc_verifier; mod pkce; mod quota; mod refresh; mod repository; -mod secret; mod selector; mod token; mod types; @@ -36,10 +34,6 @@ pub use error::{ CallbackRejection, ConfigError, FailureClass, OAuthError, OAuthErrorCode, OAuthOperation, ProtocolViolation, VerificationFailure, }; -pub use http::{ - FormField, FormValue, HttpHeader, HttpMethod, OAuthHttpRequest, OAuthHttpResponse, - OAuthHttpTransport, TransportFailure, TransportFailureKind, TransportFuture, -}; pub use import::{ GrokOAuthImportCandidate, GrokOAuthImportDocument, GrokOAuthImportEntry, GrokOAuthImportError, GrokOAuthImportMetadata, GrokOAuthImportTokens, @@ -59,7 +53,6 @@ pub use repository::{ GrokAccountExport, GrokCredentialAdmin, GrokCredentialLifecycle, GrokCredentialRepository, GrokCredentialRepositoryError, VerifiedGrokAccount, }; -pub use secret::SecretValue; pub use selector::GrokAccountSessionSelector; pub use token::{ OAuthPrincipal, RefreshTokenGrant, RefreshedTokenSet, VerifiedTokenSet, parse_oauth_error, diff --git a/backend/crates/providers/xai/src/credential/token.rs b/backend/crates/providers/xai/src/credential/token.rs index 1691be7e2..2245ee82b 100644 --- a/backend/crates/providers/xai/src/credential/token.rs +++ b/backend/crates/providers/xai/src/credential/token.rs @@ -5,7 +5,7 @@ use std::time::Duration; use serde::Deserialize; -use crate::credential::discovery::MAX_OAUTH_RESPONSE_BYTES; +use crate::oauth_http::MAX_OAUTH_RESPONSE_BYTES; use crate::{ ConfigError, OAuthError, OAuthErrorCode, OAuthHttpResponse, OAuthOperation, ProtocolViolation, SecretValue, VerificationEvidence, diff --git a/backend/crates/providers/xai/src/lib.rs b/backend/crates/providers/xai/src/lib.rs index 96afa76fe..45597fe45 100644 --- a/backend/crates/providers/xai/src/lib.rs +++ b/backend/crates/providers/xai/src/lib.rs @@ -2,8 +2,10 @@ mod admin; mod credential; +mod oauth_http; mod provider; mod reasoning_replay; +mod secret; pub mod transport; use std::sync::Arc; @@ -24,30 +26,33 @@ pub use transport::{XaiWireProfile, XaiWireProfileState}; pub use credential::{ AllowedRedirectUri, AuthorizationCallback, AuthorizationCodeGrant, CallbackRejection, ConfigError, CreateGrokCredential, DiscoveryDocument, DueGrokCredential, - FailClosedTokenVerifier, FailureClass, FormField, FormValue, GROK_FREE_ROLLING_WINDOW_SECONDS, - GrokAccountExport, GrokAccountProfile, GrokAccountSessionSelector, GrokBillingPresentation, - GrokCatalogCache, GrokCatalogCacheError, GrokCatalogScope, GrokCredentialAdmin, - GrokCredentialCatalogCache, GrokCredentialCatalogError, GrokCredentialCatalogSeed, - GrokCredentialCatalogService, GrokCredentialLifecycle, GrokCredentialQuotaService, - GrokCredentialRecord, GrokCredentialRecovery, GrokCredentialRecoveryOutcome, - GrokCredentialRefreshError, GrokCredentialRefreshOutcome, GrokCredentialRefreshService, - GrokCredentialRefresher, GrokCredentialRepository, GrokCredentialRepositoryError, - GrokOAuthClient, GrokOAuthConfig, GrokOAuthImportCandidate, GrokOAuthImportDocument, - GrokOAuthImportEntry, GrokOAuthImportError, GrokOAuthImportMetadata, GrokOAuthImportTokens, - GrokOAuthRefreshClient, GrokOAuthSecret, GrokPlanCatalog, GrokQuotaError, GrokQuotaPeriodKind, - GrokQuotaSnapshot, GrokRefreshFailure, GrokRefreshTokens, HttpHeader, HttpMethod, OAuthError, - OAuthErrorCode, OAuthHttpRequest, OAuthHttpResponse, OAuthHttpTransport, OAuthOperation, - OAuthPrincipal, OFFICIAL_CLIENT_ID, OFFICIAL_ISSUER, OFFICIAL_REDIRECT_URI, OFFICIAL_SCOPES, + FailClosedTokenVerifier, FailureClass, GROK_FREE_ROLLING_WINDOW_SECONDS, GrokAccountExport, + GrokAccountProfile, GrokAccountSessionSelector, GrokBillingPresentation, GrokCatalogCache, + GrokCatalogCacheError, GrokCatalogScope, GrokCredentialAdmin, GrokCredentialCatalogCache, + GrokCredentialCatalogError, GrokCredentialCatalogSeed, GrokCredentialCatalogService, + GrokCredentialLifecycle, GrokCredentialQuotaService, GrokCredentialRecord, + GrokCredentialRecovery, GrokCredentialRecoveryOutcome, GrokCredentialRefreshError, + GrokCredentialRefreshOutcome, GrokCredentialRefreshService, GrokCredentialRefresher, + GrokCredentialRepository, GrokCredentialRepositoryError, GrokOAuthClient, GrokOAuthConfig, + GrokOAuthImportCandidate, GrokOAuthImportDocument, GrokOAuthImportEntry, GrokOAuthImportError, + GrokOAuthImportMetadata, GrokOAuthImportTokens, GrokOAuthRefreshClient, GrokOAuthSecret, + GrokPlanCatalog, GrokQuotaError, GrokQuotaPeriodKind, GrokQuotaSnapshot, GrokRefreshFailure, + GrokRefreshTokens, OAuthError, OAuthErrorCode, OAuthOperation, OAuthPrincipal, + OFFICIAL_CLIENT_ID, OFFICIAL_ISSUER, OFFICIAL_REDIRECT_URI, OFFICIAL_SCOPES, PendingAuthorization, Pkce, PreparedGrokCredentialRotation, PreparedGrokCredentialRotationGuard, ProtocolViolation, RedirectUriAllowlist, RefreshTokenGrant, RefreshedTokenSet, ReqwestOidcTokenVerifier, RotateGrokCredential, - RotateManagedGrokCredential, SecretValue, TokenCandidate, TokenVerificationContext, - TokenVerifier, TransportFailure, TransportFailureKind, TransportFuture, + RotateManagedGrokCredential, TokenCandidate, TokenVerificationContext, TokenVerifier, UpdateGrokCredentialState, VerificationEvidence, VerificationFailure, VerificationFlow, VerificationFuture, VerificationMethod, VerifiedGrokAccount, VerifiedTokenSet, parse_oauth_error, parse_refresh_success, }; +pub use oauth_http::{ + FormField, FormValue, HttpHeader, HttpMethod, OAuthHttpRequest, OAuthHttpResponse, + OAuthHttpTransport, TransportFailure, TransportFailureKind, TransportFuture, +}; pub use provider::GrokBuildProvider; +pub use secret::SecretValue; pub use transport::profile::{ GROK_CLI_RELEASE_URL, GrokCliReleaseError, GrokCliReleaseService, GrokCliReleaseSnapshot, GrokCliReleaseStatus, GrokCliReleaseTransport, diff --git a/backend/crates/providers/xai/src/credential/http.rs b/backend/crates/providers/xai/src/oauth_http.rs similarity index 96% rename from backend/crates/providers/xai/src/credential/http.rs rename to backend/crates/providers/xai/src/oauth_http.rs index ce6f4a916..4ceed9635 100644 --- a/backend/crates/providers/xai/src/credential/http.rs +++ b/backend/crates/providers/xai/src/oauth_http.rs @@ -1,4 +1,4 @@ -//! xAI OAuth HTTP 传输端口,以及区分公开值和敏感值的请求响应类型 +//! xAI OAuth 业务与网络适配共用的 HTTP 合同和响应体边界 use std::fmt; use std::future::Future; @@ -9,6 +9,8 @@ use zeroize::Zeroizing; use crate::SecretValue; +pub(crate) const MAX_OAUTH_RESPONSE_BYTES: usize = 64 * 1024; + /// OAuth 协议所需的 HTTP 方法 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HttpMethod { @@ -250,7 +252,7 @@ pub type TransportFuture<'a> = /// OAuth 协议请求的可替换 HTTP 边界 /// -/// 实现方需在本 crate 之外保证 TLS、有界响应体与 proxy/IP 亲和性, +/// 网络适配器负责 TLS、有界响应体与 proxy/IP 亲和性, /// 且不得记录 form 值或响应体 pub trait OAuthHttpTransport: Send + Sync { /// 执行一次请求,不做隐式重试 diff --git a/backend/crates/providers/xai/src/credential/secret.rs b/backend/crates/providers/xai/src/secret.rs similarity index 94% rename from backend/crates/providers/xai/src/credential/secret.rs rename to backend/crates/providers/xai/src/secret.rs index a92e2b9b2..dbb4c53ff 100644 --- a/backend/crates/providers/xai/src/credential/secret.rs +++ b/backend/crates/providers/xai/src/secret.rs @@ -1,4 +1,4 @@ -//! xAI 敏感字符串的脱敏展示、内存清理与恒定时间比较 +//! xAI 凭据与上游请求共用的敏感字符串、脱敏展示与内存清理 use std::fmt; diff --git a/backend/crates/providers/xai/src/transport/canonical.rs b/backend/crates/providers/xai/src/transport/canonical.rs index 547796cee..2edd328f8 100644 --- a/backend/crates/providers/xai/src/transport/canonical.rs +++ b/backend/crates/providers/xai/src/transport/canonical.rs @@ -20,7 +20,7 @@ use gateway_protocol::openai::sse::{SseEvent, SseEventDecoder}; use serde_json::Value; use super::request::{GrokResponseTransform, GrokResponsesRequest}; -use super::{classify_grok_quota_failure, scrub_account_fingerprints}; +use super::upstream_error::{classify_grok_quota_failure, scrub_account_fingerprints}; const CONTENTS_PER_OUTPUT: u32 = 1_024; const LONG_CONTEXT_THRESHOLD: u64 = 200_000; diff --git a/backend/crates/providers/xai/src/transport/mod.rs b/backend/crates/providers/xai/src/transport/mod.rs index 6c7549b50..bd4deb1b7 100644 --- a/backend/crates/providers/xai/src/transport/mod.rs +++ b/backend/crates/providers/xai/src/transport/mod.rs @@ -1,4 +1,4 @@ -//! Grok Build CLI Responses provider 边界 +//! xAI 上游 HTTP、SSE、请求编码与会话传输的模块门面 pub(crate) mod canonical; pub(crate) mod catalog; @@ -12,6 +12,7 @@ pub(crate) mod network; pub(crate) mod profile; mod request; mod session; +mod upstream_error; pub use canonical::{ GrokCanonicalDecoder, grok_billing_breakdown, grok_billing_breakdown_with_tier, @@ -57,145 +58,4 @@ pub use session::{ GrokSessionSelector, GrokSessionSelectorError, GrokSessionSelectorFuture, SelectedGrokSession, }; -const UUID_TEXT_LEN: usize = 36; -const FREE_USAGE_SIGNALS: &[&str] = &[ - "subscription:free-usage-exhausted", - "subscription_free_usage_exhausted", - "free-usage-exhausted", - "free_usage_exhausted", - "usage-limit-exceeded", - "usage_limit_exceeded", - "free-tier-limit", - "free_tier_limit", - "used all the included free usage", - "used all your free usage", - "free quota", - "no remaining free", - "out of free", - "usage resets over a rolling", - "额度耗尽", - "额度用完", - "额度不足", - "额度已用尽", - "免费额度", - "免费用量", - "用量耗尽", - "配额耗尽", - "配额不足", - "模型额度", -]; -const ACCOUNT_QUOTA_SIGNALS: &[&str] = &[ - "personal-team-blocked:spending-limit", - "personal_team_blocked_spending_limit", - "quota_exceeded", - "insufficient_quota", -]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum GrokQuotaFailureKind { - Account, - FreeAccount, - FreeModelUsage, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum GrokQuotaSignal { - Account, - FreeUsage, -} - -pub(crate) fn classify_grok_quota_failure( - code: Option<&str>, - error_type: Option<&str>, - message: Option<&str>, -) -> Option { - let code = code.map(str::to_ascii_lowercase); - let error_type = error_type.map(str::to_ascii_lowercase); - let message = message.map(str::to_ascii_lowercase); - // `code` 与 `type` 是稳定的上游字段,冲突的可读消息不能覆盖它们 - // 消息仅用于回退判断,或细化已确认免费额度信号的滚动窗口 - let structured_signal = code - .as_deref() - .and_then(classify_quota_signal) - .or_else(|| error_type.as_deref().and_then(classify_quota_signal)); - match structured_signal { - Some(GrokQuotaSignal::Account) => Some(GrokQuotaFailureKind::Account), - Some(GrokQuotaSignal::FreeUsage) => Some(free_usage_failure_kind(message.as_deref())), - None => classify_message_quota_failure(message.as_deref()), - } -} - -fn classify_quota_signal(value: &str) -> Option { - if contains_any(value, ACCOUNT_QUOTA_SIGNALS) { - Some(GrokQuotaSignal::Account) - } else if contains_any(value, FREE_USAGE_SIGNALS) { - Some(GrokQuotaSignal::FreeUsage) - } else { - None - } -} - -fn classify_message_quota_failure(message: Option<&str>) -> Option { - match message.and_then(classify_quota_signal) { - Some(GrokQuotaSignal::Account) => Some(GrokQuotaFailureKind::Account), - Some(GrokQuotaSignal::FreeUsage) => Some(free_usage_failure_kind(message)), - None => None, - } -} - -fn free_usage_failure_kind(message: Option<&str>) -> GrokQuotaFailureKind { - if message.is_some_and(|value| value.contains("for model")) { - GrokQuotaFailureKind::FreeModelUsage - } else { - GrokQuotaFailureKind::FreeAccount - } -} - -fn contains_any(value: &str, needles: &[&str]) -> bool { - needles.iter().any(|needle| value.contains(needle)) -} - -/// 把 message 中的 UUID 替换为占位符,控制字符归一为空格, -/// 使文本满足客户端可见错误的约束且不携带可定位账号的标识 -fn scrub_account_fingerprints(message: &str) -> String { - let bytes = message.as_bytes(); - let mut scrubbed = String::with_capacity(message.len()); - let mut index = 0; - while index < message.len() { - if uuid_at(bytes, index) { - scrubbed.push_str("[redacted]"); - index += UUID_TEXT_LEN; - continue; - } - let character = message[index..].chars().next().expect("char boundary"); - scrubbed.push(if character.is_control() { - ' ' - } else { - character - }); - index += character.len_utf8(); - } - scrubbed -} - -fn uuid_at(bytes: &[u8], index: usize) -> bool { - let Some(candidate) = bytes.get(index..index + UUID_TEXT_LEN) else { - return false; - }; - if index > 0 && bytes[index - 1].is_ascii_alphanumeric() { - return false; - } - if bytes - .get(index + UUID_TEXT_LEN) - .is_some_and(u8::is_ascii_alphanumeric) - { - return false; - } - candidate.iter().enumerate().all(|(offset, byte)| { - if matches!(offset, 8 | 13 | 18 | 23) { - *byte == b'-' - } else { - byte.is_ascii_hexdigit() - } - }) -} +pub(crate) use upstream_error::{GrokQuotaFailureKind, classify_grok_quota_failure}; diff --git a/backend/crates/providers/xai/src/transport/network.rs b/backend/crates/providers/xai/src/transport/network.rs index d7795f298..1b99bfc8d 100644 --- a/backend/crates/providers/xai/src/transport/network.rs +++ b/backend/crates/providers/xai/src/transport/network.rs @@ -16,7 +16,7 @@ use serde::Deserialize; use serde_json::Value; use url::Url; -use crate::credential::discovery::MAX_OAUTH_RESPONSE_BYTES; +use crate::oauth_http::MAX_OAUTH_RESPONSE_BYTES; use crate::{ GrokBillingRequest, GrokBillingTransport, GrokBillingTransportError, GrokBillingTransportErrorKind, GrokBillingTransportFuture, GrokBillingTransportResponse, @@ -34,7 +34,9 @@ use gateway_core::event::UpstreamHttpVersion; use gateway_core::upstream::UpstreamSendState; use super::egress::{EgressCache, MAX_CACHED_EGRESS_STATES}; -use super::{GrokQuotaFailureKind, classify_grok_quota_failure, scrub_account_fingerprints}; +use super::upstream_error::{ + GrokQuotaFailureKind, classify_grok_quota_failure, scrub_account_fingerprints, +}; use gateway_core::account::OutboundProxy; pub(crate) const OFFICIAL_OAUTH_HOST: &str = "auth.x.ai"; diff --git a/backend/crates/providers/xai/src/transport/upstream_error.rs b/backend/crates/providers/xai/src/transport/upstream_error.rs new file mode 100644 index 000000000..0fa3ab362 --- /dev/null +++ b/backend/crates/providers/xai/src/transport/upstream_error.rs @@ -0,0 +1,144 @@ +//! xAI 上游错误的额度分类与客户端可见账号标识脱敏 + +const UUID_TEXT_LEN: usize = 36; +const FREE_USAGE_SIGNALS: &[&str] = &[ + "subscription:free-usage-exhausted", + "subscription_free_usage_exhausted", + "free-usage-exhausted", + "free_usage_exhausted", + "usage-limit-exceeded", + "usage_limit_exceeded", + "free-tier-limit", + "free_tier_limit", + "used all the included free usage", + "used all your free usage", + "free quota", + "no remaining free", + "out of free", + "usage resets over a rolling", + "额度耗尽", + "额度用完", + "额度不足", + "额度已用尽", + "免费额度", + "免费用量", + "用量耗尽", + "配额耗尽", + "配额不足", + "模型额度", +]; +const ACCOUNT_QUOTA_SIGNALS: &[&str] = &[ + "personal-team-blocked:spending-limit", + "personal_team_blocked_spending_limit", + "quota_exceeded", + "insufficient_quota", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GrokQuotaFailureKind { + Account, + FreeAccount, + FreeModelUsage, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GrokQuotaSignal { + Account, + FreeUsage, +} + +pub(crate) fn classify_grok_quota_failure( + code: Option<&str>, + error_type: Option<&str>, + message: Option<&str>, +) -> Option { + let code = code.map(str::to_ascii_lowercase); + let error_type = error_type.map(str::to_ascii_lowercase); + let message = message.map(str::to_ascii_lowercase); + // `code` 与 `type` 是稳定的上游字段,冲突的可读消息不能覆盖它们 + // 消息仅用于回退判断,或细化已确认免费额度信号的滚动窗口 + let structured_signal = code + .as_deref() + .and_then(classify_quota_signal) + .or_else(|| error_type.as_deref().and_then(classify_quota_signal)); + match structured_signal { + Some(GrokQuotaSignal::Account) => Some(GrokQuotaFailureKind::Account), + Some(GrokQuotaSignal::FreeUsage) => Some(free_usage_failure_kind(message.as_deref())), + None => classify_message_quota_failure(message.as_deref()), + } +} + +fn classify_quota_signal(value: &str) -> Option { + if contains_any(value, ACCOUNT_QUOTA_SIGNALS) { + Some(GrokQuotaSignal::Account) + } else if contains_any(value, FREE_USAGE_SIGNALS) { + Some(GrokQuotaSignal::FreeUsage) + } else { + None + } +} + +fn classify_message_quota_failure(message: Option<&str>) -> Option { + match message.and_then(classify_quota_signal) { + Some(GrokQuotaSignal::Account) => Some(GrokQuotaFailureKind::Account), + Some(GrokQuotaSignal::FreeUsage) => Some(free_usage_failure_kind(message)), + None => None, + } +} + +fn free_usage_failure_kind(message: Option<&str>) -> GrokQuotaFailureKind { + if message.is_some_and(|value| value.contains("for model")) { + GrokQuotaFailureKind::FreeModelUsage + } else { + GrokQuotaFailureKind::FreeAccount + } +} + +fn contains_any(value: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| value.contains(needle)) +} + +/// 把 message 中的 UUID 替换为占位符,控制字符归一为空格, +/// 使文本满足客户端可见错误的约束且不携带可定位账号的标识 +pub(super) fn scrub_account_fingerprints(message: &str) -> String { + let bytes = message.as_bytes(); + let mut scrubbed = String::with_capacity(message.len()); + let mut index = 0; + while index < message.len() { + if uuid_at(bytes, index) { + scrubbed.push_str("[redacted]"); + index += UUID_TEXT_LEN; + continue; + } + let character = message[index..].chars().next().expect("char boundary"); + scrubbed.push(if character.is_control() { + ' ' + } else { + character + }); + index += character.len_utf8(); + } + scrubbed +} + +fn uuid_at(bytes: &[u8], index: usize) -> bool { + let Some(candidate) = bytes.get(index..index + UUID_TEXT_LEN) else { + return false; + }; + if index > 0 && bytes[index - 1].is_ascii_alphanumeric() { + return false; + } + if bytes + .get(index + UUID_TEXT_LEN) + .is_some_and(u8::is_ascii_alphanumeric) + { + return false; + } + candidate.iter().enumerate().all(|(offset, byte)| { + if matches!(offset, 8 | 13 | 18 | 23) { + *byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} diff --git a/backend/crates/providers/xai/tests/credential/mod.rs b/backend/crates/providers/xai/tests/credential/mod.rs index df4f292fd..61243c6aa 100644 --- a/backend/crates/providers/xai/tests/credential/mod.rs +++ b/backend/crates/providers/xai/tests/credential/mod.rs @@ -6,14 +6,12 @@ mod client; mod config; mod contract; mod discovery; -mod http; mod import; mod oidc_verifier; mod pkce; mod quota; mod refresh; mod repository; -mod secret; mod selector; mod token; mod verification; diff --git a/backend/crates/providers/xai/tests/main.rs b/backend/crates/providers/xai/tests/main.rs index 9853bbe81..da9d1f7f3 100644 --- a/backend/crates/providers/xai/tests/main.rs +++ b/backend/crates/providers/xai/tests/main.rs @@ -2,6 +2,8 @@ mod admin; mod credential; +mod oauth_http; mod provider; +mod secret; mod support; mod transport; diff --git a/backend/crates/providers/xai/tests/credential/http.rs b/backend/crates/providers/xai/tests/oauth_http.rs similarity index 89% rename from backend/crates/providers/xai/tests/credential/http.rs rename to backend/crates/providers/xai/tests/oauth_http.rs index 6bc0bccc9..af5bf0336 100644 --- a/backend/crates/providers/xai/tests/credential/http.rs +++ b/backend/crates/providers/xai/tests/oauth_http.rs @@ -1,4 +1,4 @@ -//! 验证 OAuth HTTP 请求的诊断输出隐藏敏感表单值 +//! 验证共享 OAuth HTTP 请求的诊断输出隐藏敏感表单值 use url::Url; diff --git a/backend/crates/providers/xai/tests/credential/secret.rs b/backend/crates/providers/xai/tests/secret.rs similarity index 85% rename from backend/crates/providers/xai/tests/credential/secret.rs rename to backend/crates/providers/xai/tests/secret.rs index 8b211dd64..dd3eeb927 100644 --- a/backend/crates/providers/xai/tests/credential/secret.rs +++ b/backend/crates/providers/xai/tests/secret.rs @@ -1,4 +1,4 @@ -//! 验证敏感字符串的诊断脱敏与恒定时间比较结果 +//! 验证共享敏感字符串的诊断脱敏与恒定时间比较结果 use provider_xai::SecretValue; diff --git a/docs/architecture.md b/docs/architecture.md index 4f379497e..bf0a114b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -368,6 +368,7 @@ Client Key 费用账本独立累计各次 attempt 的实际费用,不能因请 | 入口 | 职责 | | --- | --- | | `api/modules/` | 页面 HTTP 合同,模块名对应 `views/` 一级目录;复杂模块在同名目录内按功能拆分,业务类型由所属模块定义 | +| `api/types/` | 跨业务模块共享的接口类型,如时间展示与请求位置;不依赖请求实现或业务模块 | | `api/request/` | 请求入口与插件链;负责会话恢复、重试、错误提示、错误归一化和原始/流式响应处理,页面不重复解释响应信封 | | `stores/modules/`、`router/`、`plugins/` | 应用级登录与界面状态、路由和初始化;服务端数据的权威仍在后端 | | `views/<页面>/index.vue`、`views/<页面>/components/` | 页面组合与局部交互;复杂组件按职责拆成目录入口和相邻模块 | @@ -1143,7 +1144,7 @@ Worker 由各 Bundle 贡献、由 Host 统一监督: - Host:历史保留任务,持有调度、单轮行数/批数/时长预算、取消和日志;Admin 校验[保留窗口](api.md#历史保留),Store 执行有界批量删除 - Core:`runtime` owner 的 RuntimeSnapshot 周期对账和 Redis change 订阅 - Admin:S3/R2 备份 daemon,负责调度、执行、删除收敛与保留清理; - 以及账号错误保护恢复 worker(到期解除或探测恢复)、公共 CPR 互联同步 + 以及账号错误保护恢复 worker(到期解除或探测恢复)、重置卡自动使用和公共 CPR 互联同步 - Provider:credential refresh、quota/catalog 健康、账号预热和官方版本/etag 检查 互联同步使用账号出口的受管 HTTP 客户端,不跟随跳转;网络等待不持有数据库事务。 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index ab78e1afe..d365b1b92 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -24,3 +24,7 @@ export * from './modules/settings' // 使用记录 export * from './modules/usage' + +// 跨模块共享的接口类型 +export type { RequestLocation, RequestLocationDetection } from './types/location' +export type { RelativeTimeView } from './types/time' diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts deleted file mode 100644 index a4973cc93..000000000 --- a/frontend/src/api/modules/accounts.ts +++ /dev/null @@ -1,827 +0,0 @@ -import type { RequestOptions } from '../request' -import type { RelativeTimeView } from '../time' -import type { AccountGroupRef } from './groups' -import request from '../request' - -export type AccountStatus - = 'normal' | 'quota_exhausted' | 'rate_limited' | 'disabled' | 'error' - -export type AccountErrorReason - = 'account_unverified' - | 'access_token_expired' - | 'credential_expired' - | 'credential_invalid' - | '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 - limitName: string | null - role: 'primary' | 'secondary' | 'monthly' | null - windowSeconds: number | null - labelDisplay: string - windowLabelDisplay: string - usedPercent: number | null - usedPercentDisplay: string - limitReached: boolean - localUsage?: unknown - resetAtDisplay: string -} - -export interface AccountQuotaCredits { - hasCredits: boolean - unlimited: boolean - balance: string | null -} - -export interface AccountProtectionRule { - id: string - name: string -} - -export interface AccountQuota { - refreshedAt: string | null - refreshedAtRelative: RelativeTimeView | null - refreshedAtDisplay: string - limitReached: boolean - // 429 临时限流(Redis 冷却)到期时间;非限流中为 null。 - rateLimitedUntil: string | null - rateLimitRecoveryDisplay: string | null - rateLimitReason: 'upstream_rate_limit' | 'error_protection' | null - protectionRules: AccountProtectionRule[] - recoveryProbeRequired: boolean - windows: AccountQuotaWindow[] - credits: AccountQuotaCredits | null -} - -export interface AccountCurrencyCost { - currency: string - estimatedAmount: string - estimatedAmountDisplay: string -} - -export interface AccountModelUsage { - model: string - requestCount: number - requestCountDisplay: string - successRate: number | null - successRateDisplay: string - inputTokens: number | null - inputTokensDisplay: string - outputTokens: number | null - outputTokensDisplay: string - cachedInputTokens: number | null - cachedInputTokensDisplay: string - imageInputTokens: number | null - imageInputTokensDisplay: string - imageOutputTokens: number | null - imageOutputTokensDisplay: string - imageRequestCount: number - imageRequestCountDisplay: string - imageRequestFailedCount: number - imageRequestFailedCountDisplay: string - totalTokens: number | null - totalTokensDisplay: string - billingAmountUsd: string | null - billingAmountUsdDisplay: string - costEstimateStatus: string - knownCostCount: number - partialCostCount: number - unknownCostCount: number - costs: AccountCurrencyCost[] - lastUsedAt: string - lastUsedAtDisplay: string - lastUsedAtFullDisplay: string | null -} - -export interface AccountUsage { - windowKind: 'general' | 'daily' | 'weekly' | 'monthly' | 'quota' - requestCount: number | null - requestCountDisplay: string - inputTokens: number | null - inputTokensDisplay: string - outputTokens: number | null - outputTokensDisplay: string - cachedInputTokens: number | null - cachedInputTokensDisplay: string - reasoningOutputTokens: number | null - reasoningOutputTokensDisplay: string - imageInputTokens: number | null - imageInputTokensDisplay: string - imageOutputTokens: number | null - imageOutputTokensDisplay: string - imageRequestCount: number | null - imageRequestCountDisplay: string - imageRequestFailedCount: number | null - imageRequestFailedCountDisplay: string - totalTokens: number | null - totalTokensDisplay: string - cacheWriteInputTokens: number | null - cacheWriteInputTokensDisplay: string - lastUsedAt: string | null - lastUsedAtDisplay: string - lastUsedAtFullDisplay: string | null - costEstimateStatus: string - knownCostCount: number | null - partialCostCount: number | null - unknownCostCount: number | null - costs: AccountCurrencyCost[] - models: AccountModelUsage[] -} - -export interface AccountModelAccess { - mode: 'all' | 'allowlist' | 'denylist' - models: string[] -} - -export interface Account { - upstream: AccountUpstream | null - capabilities: AccountCapabilities - outboundProxyId: string | null - outboundProxyEndpoint: string | null - id: string - name: string - notes: string | null - provider: string - resourceRef: string - email: string | null - accountId: string | null - userId: string | null - label: string | null - planType: string | null - planTypeDisplay: string - authenticationKind: string - hasRefreshToken: boolean - status: AccountStatus - errorReason: AccountErrorReason | null - errorMessage: string | null - enabled: boolean - concurrencyLimit: number | null - capacity: { - usedSlots: number | null - totalSlots: number | null - } - weight: number - modelAccess: AccountModelAccess - accessTokenExpiresAt: string | null - accessTokenExpiresAtDisplay: string | null - refreshTokenExpiresAt: string | null - nextRefreshAt: string | null - nextRefreshAtDisplay: string | null - addedAt: string - addedAtDisplay: string - updatedAt: string - updatedAtDisplay: string - quota: AccountQuota - usage: AccountUsage - groups: AccountGroupRef[] -} - -export interface AccountUpstream { - discovered: boolean - observedAt: string | null - observedAtDisplay: string - observedAtRelative: RelativeTimeView | null - stale: boolean - connectionRevision: string - 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 AccountCapabilities { - quota: boolean - quotaRefresh: boolean - profile: boolean - subscription: boolean - avatar: boolean - resetCredits: boolean - consumeResetCredit: boolean -} - -export interface AccountQuotaForecast { - period: 'weekly' | 'monthly' - targetDays: number - extrapolated: boolean - source: { - label: string - usedPercent: number | null - usedPercentDisplay: string - observedAt: string | null - observedAtDisplay: string - resetAt: string - tokensDisplay: string - usdDisplay: string - } | null - unavailableReason: string | null - lowSample: boolean - incompleteCost: boolean - incompleteTokens: boolean - estimatedTokens: number | null - estimatedTokensDisplay: string - estimatedUsd: number | null - estimatedUsdDisplay: string -} - -export interface AccountQuotaForecastResponse { - accountId: string - generatedAt: string - forecasts: AccountQuotaForecast[] -} - -export interface AccountPageMeta { - page: number - pageSize: number - total: number - totalPages: number -} - -export interface AccountSummary { - total: number - normal: number - quotaExhausted: number - rateLimited: number - disabled: number - error: number -} - -export interface AccountListResponse { - items: Account[] - page: AccountPageMeta - summary: AccountSummary -} - -export interface AccountRefreshResponse { - account: Account - result?: string - error?: string -} - -export interface AccountQuotaResponse { - account: Account -} - -export interface AccountProfileStatisticsSummary { - totalTextTokens: number | null - peakTokens: number | null - longestTaskDurationMs: number | null - currentStreakDays: number | null - longestStreakDays: number | null -} - -export interface AccountProfileDailyUsage { - date: string - tokens: number -} - -export interface AccountProfileInvocation { - type: string - pluginId: string | null - pluginName: string | null - skillId: string | null - skillName: string | null - usageCount: number | null -} - -export interface AccountProfileActivityInsights { - fastModePercent: number | null - reasoningEffort: string | null - reasoningEffortPercent: number | null - skillsExplored: number | null - totalSkillsUsed: number | null - totalThreads: number | null - invocations: AccountProfileInvocation[] | null -} - -export interface AccountSubscription { - startsAt: string | null - startsAtDisplay: string | null - expiresAt: string - expiresAtDisplay: string - willRenew: boolean | null - billingPeriod: string | null - billingCurrency: string | null - observedAt: string - observedAtDisplay: string -} - -export interface ProfileActivityCalendar { - rangeLabel: string - weeks: Array<{ key: string, monthLabel: string | null, cells: Array<{ date: string, dateDisplay: string, tokens: number, isFuture: boolean }> }> -} - -export interface AccountProfileStatisticsResponse { - activityCalendar: ProfileActivityCalendar | null - displayName: string | null - username: string | null - imageUrl: string | null - hasStatsError: boolean - summary: AccountProfileStatisticsSummary - dailyUsage: AccountProfileDailyUsage[] | null - activityInsights: AccountProfileActivityInsights -} - -export interface AccountPersonalInfoResponse { - profile: AccountProfileStatisticsResponse | null - profileError: string | null - subscription: AccountSubscription | null -} - -export interface AccountResetCredit { - id: string - status: string | null - title: string | null - expiresAt: string | null - expiresAtDisplay: string | null - resetType: string | null -} - -export interface AccountResetCreditsResponse { - availableCount: number - credits: AccountResetCredit[] -} - -export interface AccountResetCreditResultResponse { - code: string - credit: AccountResetCredit | null -} - -export interface AccountModelsResponse { - models: Array<{ id: string, label: string }> - observedAt?: string | null - observedAtDisplay?: string -} - -export interface AccountModelCatalogResponse { - modelCount: number - observedAt: string - /** Codex `model_catalog_json` 的文件正文,原样落盘即可被客户端加载。 */ - catalog: unknown -} - -export interface AccountImportResponse { - importedCount: number - accountIds: string[] -} - -export type ImportItemStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'unknown' | 'skipped' - -export interface AccountImportTask { - taskId: string - createdAt: string - createdAtDisplay: string - finishedAt: string | null - finishedAtDisplay: string | null - stopRequested: boolean - total: number - counts: Record & { importedAccounts: number } -} - -export interface AccountImportTaskItem { - index: number - provider: string - status: ImportItemStatus - accountIds: string[] - message: string | null -} - -export interface AccountImportTaskDetail extends AccountImportTask { - items: AccountImportTaskItem[] -} - -export interface AccountOAuthCompleteResponse { - accountId: string -} - -export interface AccountUpdateResponse { - accountId: string - configRevision: number -} - -export interface AccountBatchUpdateResponse { - accountIds: string[] - configRevision: number -} - -export interface AccountDeletionResponse { - deletedCount: number - accountIds: string[] -} - -export interface AccountOAuthStartResponse { - flowId: string - authorizationUrl: string - expiresAt: string -} - -// 请求参数类型:仅定义 API 边界的形状,调用方不依赖显式声明。 -interface AccountListParams { - page: number - pageSize: number - search?: string - provider?: string - status?: string - groupId?: string - sortBy?: string - sortDirection?: string -} - -interface AccountIdParam { - accountId: string -} - -interface AccountResetCreditConsumeParam extends AccountIdParam { - creditId?: string - redeemRequestId: string -} - -interface AccountUpdateParam { - connection?: { baseUrl?: string, transport: ApiKeyConfiguration['transport'], apiKey?: string } - outboundProxyUrl?: string - outboundProxyId?: string - accountId: string - name?: string - notes?: string - enabled: boolean - concurrencyLimit: number | null - weight: number - modelAccess?: AccountModelAccess - groupIds: string[] -} - -interface AccountBatchUpdateParam { - outboundProxyUrl?: string - outboundProxyId?: string - accountIds: string[] - enabled?: boolean - concurrencyLimit?: number | null - weight?: number - modelAccess?: AccountModelAccess - groupIds?: string[] -} - -interface AccountDeleteParams { - provider: string - accountIds: string[] -} - -export interface AccountImportSettings { - name?: string - notes?: string - enabled: boolean - concurrencyLimit: number | null - weight: number - modelAccess?: AccountModelAccess - groupIds: string[] -} - -export interface AccountImportParam { - outboundProxyId?: string - settings?: AccountImportSettings - provider: string - data: unknown -} - -interface AccountImportTaskIdParam { - taskId: string -} - -interface CreateAccountImportTaskParam { - submissionId: string - items: AccountImportParam[] -} - -interface AccountOAuthStartParam { - outboundProxyUrl?: string - outboundProxyId?: string - provider: string - name: string - accountId?: string -} - -interface AccountOAuthCompleteParam { - settings?: AccountImportSettings - provider: string - flowId: string - callbackUrl: string -} - -interface AccountExportParam { - accountIds: string - confirm: string -} - -interface AccountExportResponse { - exportedAt: string - fileName: string - documents: unknown[] -} - -interface AccountImportTaskListResponse { - items: AccountImportTask[] -} - -interface AccountDetailResponse { - account: Account - credentialConfiguration?: Record -} - -interface AccountResetCreditAutomationParam extends AccountIdParam { - enabled: boolean -} - -interface AccountConcurrencyParams extends AccountIdParam { - page: number - pageSize: number -} - -export function getAccounts(data: AccountListParams, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts', - method: 'GET', - params: data, - ...options, - }) -} - -export function exportAccounts(data: AccountExportParam) { - return request({ - url: '/api/admin/accounts/export', - method: 'GET', - params: data, - }) -} - -export function refreshAccount(data: AccountIdParam) { - return request({ - url: '/api/admin/accounts/refresh', - method: 'POST', - data, - }) -} - -export function recoverAccount(data: AccountIdParam) { - return request({ - url: '/api/admin/accounts/recover', - method: 'POST', - data, - }) -} - -export function getAccountPersonalInfo(data: AccountIdParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/personal-info', - method: 'GET', - params: data, - ...options, - }) -} - -export function getAccountQuotaForecast(data: AccountIdParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/quota-forecast', - method: 'GET', - params: data, - ...options, - }) -} - -export function accountProfileAvatarUrl(accountId: string, sourceUrl: string) { - const params = new URLSearchParams({ - accountId, - version: stableAvatarVersion(sourceUrl), - }) - return `/api/admin/accounts/profile-avatar?${params.toString()}` -} - -function stableAvatarVersion(value: string) { - let hash = 0 - for (const character of value) - hash = (hash * 33 + (character.codePointAt(0) ?? 0)) % 2_147_483_647 - return hash.toString(36) -} - -export function refreshAccountQuota(data: AccountIdParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/quota/refresh', - method: 'POST', - data, - ...options, - }) -} - -export function getAccountResetCredits(data: AccountIdParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/reset-credits', - method: 'GET', - params: data, - ...options, - }) -} - -export function consumeAccountResetCredit(data: AccountResetCreditConsumeParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/reset-credits', - method: 'POST', - data, - ...options, - }) -} - -export function getAccountModels(data: AccountIdParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/models', - method: 'GET', - params: data, - ...options, - }) -} - -export function getAccountModelCatalog(data: AccountIdParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/models/catalog', - method: 'GET', - params: data, - ...options, - }) -} - -export function refreshAccountModels(data: AccountIdParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/models/refresh', - method: 'POST', - data, - ...options, - }) -} - -export function importAccounts(data: AccountImportParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/import', - method: 'POST', - ...providerInputBody(data), - ...options, - }) -} - -export function createAccountImportTask(data: CreateAccountImportTaskParam) { - return request({ - url: '/api/admin/accounts/import-tasks', - method: 'POST', - ...providerInputBody(data), - }) -} - -export function getAccountImportTasks(options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/import-tasks', - method: 'GET', - ...options, - }) -} - -export function getAccountImportTask(data: AccountImportTaskIdParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/import-tasks/detail', - method: 'GET', - params: data, - ...options, - }) -} - -export function stopAccountImportTask(data: AccountImportTaskIdParam) { - return request({ - url: '/api/admin/accounts/import-tasks/stop', - method: 'POST', - data, - }) -} - -export function updateAccount(data: AccountUpdateParam) { - return request({ - url: '/api/admin/accounts/update', - method: 'POST', - data, - }) -} - -export function batchUpdateAccounts(data: AccountBatchUpdateParam) { - return request({ - url: '/api/admin/accounts/batch-update', - method: 'POST', - data, - }) -} - -export function deleteAccounts(data: AccountDeleteParams, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/delete', - method: 'POST', - data, - ...options, - }) -} - -export function startAccountOAuth(data: AccountOAuthStartParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/oauth/start', - method: 'POST', - ...providerInputBody(data), - ...options, - }) -} - -function providerInputBody(data: object) { - // Provider 文档是不透明 JSON,先编码,避免 HTTP 客户端合并配置时过滤特殊字段名。 - return { - headers: { 'Content-Type': 'application/json' }, - data: JSON.stringify(data), - } -} - -export function completeAccountOAuth(data: AccountOAuthCompleteParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/oauth/complete', - method: 'POST', - data, - ...options, - }) -} - -export interface ApiKeyConfiguration { - base_url: string - transport: 'http' | 'prefer_websocket' -} - -export function getAccountDetail(data: AccountIdParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/detail', - method: 'GET', - params: data, - ...options, - }) -} - -export interface AccountResetCreditAutomation { - enabled: boolean - leadMinutes: number - nextDiscoveryAtDisplay: string | null - nextConsumeAtDisplay: string | null - lastResult: string | null -} - -export function getAccountResetCreditAutomation(data: AccountIdParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/reset-credits/automation', - method: 'GET', - params: data, - ...options, - }) -} - -export function setAccountResetCreditAutomation(data: AccountResetCreditAutomationParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/reset-credits/automation', - method: 'POST', - data, - ...options, - }) -} - -export interface AccountConcurrencyEntry { - leaseId: string - requestId: string | null - clientKeyId: string | null - clientKeyName: string | null - model: string | null - clientIp: string | null - startedAt: string | null - startedAtDisplay: string | null - occupiedMs: number | null -} - -export interface AccountConcurrencyPage { - accountId: string - entries: AccountConcurrencyEntry[] - total: number - page: number - pageSize: number - observedAt: string -} - -export function getAccountConcurrency(data: AccountConcurrencyParams, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/concurrency', - method: 'GET', - params: data, - ...options, - }) -} diff --git a/frontend/src/api/modules/accounts/credentials.ts b/frontend/src/api/modules/accounts/credentials.ts new file mode 100644 index 000000000..fd1fa0146 --- /dev/null +++ b/frontend/src/api/modules/accounts/credentials.ts @@ -0,0 +1,183 @@ +import type { RequestOptions } from '../../request' +import type { AccountIdParam, AccountModelAccess, AccountRefreshResponse } from './types' +import request from '../../request' + +export interface AccountImportResponse { + importedCount: number + accountIds: string[] +} + +export type ImportItemStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'unknown' | 'skipped' + +export interface AccountImportTask { + taskId: string + createdAt: string + createdAtDisplay: string + finishedAt: string | null + finishedAtDisplay: string | null + stopRequested: boolean + total: number + counts: Record & { importedAccounts: number } +} + +export interface AccountImportTaskItem { + index: number + provider: string + status: ImportItemStatus + accountIds: string[] + message: string | null +} + +export interface AccountImportTaskDetail extends AccountImportTask { + items: AccountImportTaskItem[] +} + +export interface AccountOAuthCompleteResponse { + accountId: string +} + +export interface AccountOAuthStartResponse { + flowId: string + authorizationUrl: string + expiresAt: string +} + +export interface AccountImportSettings { + name?: string + notes?: string + enabled: boolean + concurrencyLimit: number | null + weight: number + modelAccess?: AccountModelAccess + groupIds: string[] +} + +export interface AccountImportParam { + outboundProxyId?: string + settings?: AccountImportSettings + provider: string + data: unknown +} + +interface AccountImportTaskIdParam { + taskId: string +} + +interface CreateAccountImportTaskParam { + submissionId: string + items: AccountImportParam[] +} + +interface AccountOAuthStartParam { + outboundProxyUrl?: string + outboundProxyId?: string + provider: string + name: string + accountId?: string +} + +interface AccountOAuthCompleteParam { + settings?: AccountImportSettings + provider: string + flowId: string + callbackUrl: string +} + +interface AccountExportParam { + accountIds: string + confirm: string +} + +interface AccountExportResponse { + exportedAt: string + fileName: string + documents: unknown[] +} + +interface AccountImportTaskListResponse { + items: AccountImportTask[] +} + +export function exportAccounts(data: AccountExportParam) { + return request({ + url: '/api/admin/accounts/export', + method: 'GET', + params: data, + }) +} + +export function refreshAccount(data: AccountIdParam) { + return request({ + url: '/api/admin/accounts/refresh', + method: 'POST', + data, + }) +} + +export function importAccounts(data: AccountImportParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/import', + method: 'POST', + ...providerInputBody(data), + ...options, + }) +} + +export function createAccountImportTask(data: CreateAccountImportTaskParam) { + return request({ + url: '/api/admin/accounts/import-tasks', + method: 'POST', + ...providerInputBody(data), + }) +} + +export function getAccountImportTasks(options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/import-tasks', + method: 'GET', + ...options, + }) +} + +export function getAccountImportTask(data: AccountImportTaskIdParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/import-tasks/detail', + method: 'GET', + params: data, + ...options, + }) +} + +export function stopAccountImportTask(data: AccountImportTaskIdParam) { + return request({ + url: '/api/admin/accounts/import-tasks/stop', + method: 'POST', + data, + }) +} + +export function startAccountOAuth(data: AccountOAuthStartParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/oauth/start', + method: 'POST', + ...providerInputBody(data), + ...options, + }) +} + +function providerInputBody(data: object) { + // Provider 文档是不透明 JSON,先编码,避免 HTTP 客户端合并配置时过滤特殊字段名。 + return { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(data), + } +} + +export function completeAccountOAuth(data: AccountOAuthCompleteParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/oauth/complete', + method: 'POST', + data, + ...options, + }) +} diff --git a/frontend/src/api/modules/accounts/index.ts b/frontend/src/api/modules/accounts/index.ts new file mode 100644 index 000000000..5608692d3 --- /dev/null +++ b/frontend/src/api/modules/accounts/index.ts @@ -0,0 +1,114 @@ +export { + completeAccountOAuth, + createAccountImportTask, + exportAccounts, + getAccountImportTask, + getAccountImportTasks, + importAccounts, + refreshAccount, + startAccountOAuth, + stopAccountImportTask, +} from './credentials' + +export type { + AccountImportParam, + AccountImportResponse, + AccountImportSettings, + AccountImportTask, + AccountImportTaskDetail, + AccountImportTaskItem, + AccountOAuthCompleteResponse, + AccountOAuthStartResponse, + ImportItemStatus, +} from './credentials' + +export { + batchUpdateAccounts, + deleteAccounts, + getAccountConcurrency, + getAccountDetail, + getAccounts, + recoverAccount, + updateAccount, +} from './management' + +export type { + AccountBatchUpdateResponse, + AccountConcurrencyEntry, + AccountConcurrencyPage, + AccountDeletionResponse, + AccountListResponse, + AccountPageMeta, + AccountSummary, + AccountUpdateResponse, + ApiKeyConfiguration, +} from './management' + +export { + getAccountModelCatalog, + getAccountModels, + refreshAccountModels, +} from './models' + +export type { + AccountModelCatalogResponse, + AccountModelsResponse, +} from './models' + +export { + accountProfileAvatarUrl, + getAccountPersonalInfo, +} from './personal-info' + +export type { + AccountPersonalInfoResponse, + AccountProfileActivityInsights, + AccountProfileDailyUsage, + AccountProfileInvocation, + AccountProfileStatisticsResponse, + AccountProfileStatisticsSummary, + AccountSubscription, + ProfileActivityCalendar, +} from './personal-info' + +export { + getAccountQuotaForecast, + refreshAccountQuota, +} from './quota' + +export type { + AccountQuotaForecast, + AccountQuotaForecastResponse, + AccountQuotaResponse, +} from './quota' + +export { + consumeAccountResetCredit, + getAccountResetCreditAutomation, + getAccountResetCredits, + setAccountResetCreditAutomation, +} from './reset-credits' + +export type { + AccountResetCredit, + AccountResetCreditAutomation, + AccountResetCreditResultResponse, + AccountResetCreditsResponse, +} from './reset-credits' + +export type { + Account, + AccountCapabilities, + AccountCurrencyCost, + AccountErrorReason, + AccountModelAccess, + AccountModelUsage, + AccountProtectionRule, + AccountQuota, + AccountQuotaCredits, + AccountQuotaWindow, + AccountRefreshResponse, + AccountStatus, + AccountUpstream, + AccountUsage, +} from './types' diff --git a/frontend/src/api/modules/accounts/management.ts b/frontend/src/api/modules/accounts/management.ts new file mode 100644 index 000000000..cefbfc64d --- /dev/null +++ b/frontend/src/api/modules/accounts/management.ts @@ -0,0 +1,178 @@ +import type { RequestOptions } from '../../request' +import type { Account, AccountIdParam, AccountModelAccess, AccountRefreshResponse } from './types' +import request from '../../request' + +export interface AccountPageMeta { + page: number + pageSize: number + total: number + totalPages: number +} + +export interface AccountSummary { + total: number + normal: number + quotaExhausted: number + rateLimited: number + disabled: number + error: number +} + +export interface AccountListResponse { + items: Account[] + page: AccountPageMeta + summary: AccountSummary +} + +export interface AccountUpdateResponse { + accountId: string + configRevision: number +} + +export interface AccountBatchUpdateResponse { + accountIds: string[] + configRevision: number +} + +export interface AccountDeletionResponse { + deletedCount: number + accountIds: string[] +} + +// 请求参数类型:仅定义 API 边界的形状,调用方不依赖显式声明。 +interface AccountListParams { + page: number + pageSize: number + search?: string + provider?: string + status?: string + groupId?: string + sortBy?: string + sortDirection?: string +} + +interface AccountUpdateParam { + connection?: { baseUrl?: string, transport: ApiKeyConfiguration['transport'], apiKey?: string } + outboundProxyUrl?: string + outboundProxyId?: string + accountId: string + name?: string + notes?: string + enabled: boolean + concurrencyLimit: number | null + weight: number + modelAccess?: AccountModelAccess + groupIds: string[] +} + +interface AccountBatchUpdateParam { + outboundProxyUrl?: string + outboundProxyId?: string + accountIds: string[] + enabled?: boolean + concurrencyLimit?: number | null + weight?: number + modelAccess?: AccountModelAccess + groupIds?: string[] +} + +interface AccountDeleteParams { + provider: string + accountIds: string[] +} + +interface AccountDetailResponse { + account: Account + credentialConfiguration?: Record +} + +interface AccountConcurrencyParams extends AccountIdParam { + page: number + pageSize: number +} + +export interface ApiKeyConfiguration { + base_url: string + transport: 'http' | 'prefer_websocket' +} + +export interface AccountConcurrencyEntry { + leaseId: string + requestId: string | null + clientKeyId: string | null + clientKeyName: string | null + model: string | null + clientIp: string | null + startedAt: string | null + startedAtDisplay: string | null + occupiedMs: number | null +} + +export interface AccountConcurrencyPage { + accountId: string + entries: AccountConcurrencyEntry[] + total: number + page: number + pageSize: number + observedAt: string +} + +export function getAccounts(data: AccountListParams, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts', + method: 'GET', + params: data, + ...options, + }) +} + +export function getAccountDetail(data: AccountIdParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/detail', + method: 'GET', + params: data, + ...options, + }) +} + +export function recoverAccount(data: AccountIdParam) { + return request({ + url: '/api/admin/accounts/recover', + method: 'POST', + data, + }) +} + +export function updateAccount(data: AccountUpdateParam) { + return request({ + url: '/api/admin/accounts/update', + method: 'POST', + data, + }) +} + +export function batchUpdateAccounts(data: AccountBatchUpdateParam) { + return request({ + url: '/api/admin/accounts/batch-update', + method: 'POST', + data, + }) +} + +export function deleteAccounts(data: AccountDeleteParams, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/delete', + method: 'POST', + data, + ...options, + }) +} + +export function getAccountConcurrency(data: AccountConcurrencyParams, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/concurrency', + method: 'GET', + params: data, + ...options, + }) +} diff --git a/frontend/src/api/modules/accounts/models.ts b/frontend/src/api/modules/accounts/models.ts new file mode 100644 index 000000000..dc00a6675 --- /dev/null +++ b/frontend/src/api/modules/accounts/models.ts @@ -0,0 +1,43 @@ +import type { RequestOptions } from '../../request' +import type { AccountIdParam } from './types' +import request from '../../request' + +export interface AccountModelsResponse { + models: Array<{ id: string, label: string }> + observedAt?: string | null + observedAtDisplay?: string +} + +export interface AccountModelCatalogResponse { + modelCount: number + observedAt: string + /** Codex `model_catalog_json` 的文件正文,原样落盘即可被客户端加载。 */ + catalog: unknown +} + +export function getAccountModels(data: AccountIdParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/models', + method: 'GET', + params: data, + ...options, + }) +} + +export function getAccountModelCatalog(data: AccountIdParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/models/catalog', + method: 'GET', + params: data, + ...options, + }) +} + +export function refreshAccountModels(data: AccountIdParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/models/refresh', + method: 'POST', + data, + ...options, + }) +} diff --git a/frontend/src/api/modules/accounts/personal-info.ts b/frontend/src/api/modules/accounts/personal-info.ts new file mode 100644 index 000000000..b6ef39f88 --- /dev/null +++ b/frontend/src/api/modules/accounts/personal-info.ts @@ -0,0 +1,93 @@ +import type { RequestOptions } from '../../request' +import type { AccountIdParam } from './types' +import request from '../../request' + +export interface AccountProfileStatisticsSummary { + totalTextTokens: number | null + peakTokens: number | null + longestTaskDurationMs: number | null + currentStreakDays: number | null + longestStreakDays: number | null +} + +export interface AccountProfileDailyUsage { + date: string + tokens: number +} + +export interface AccountProfileInvocation { + type: string + pluginId: string | null + pluginName: string | null + skillId: string | null + skillName: string | null + usageCount: number | null +} + +export interface AccountProfileActivityInsights { + fastModePercent: number | null + reasoningEffort: string | null + reasoningEffortPercent: number | null + skillsExplored: number | null + totalSkillsUsed: number | null + totalThreads: number | null + invocations: AccountProfileInvocation[] | null +} + +export interface AccountSubscription { + startsAt: string | null + startsAtDisplay: string | null + expiresAt: string + expiresAtDisplay: string + willRenew: boolean | null + billingPeriod: string | null + billingCurrency: string | null + observedAt: string + observedAtDisplay: string +} + +export interface ProfileActivityCalendar { + rangeLabel: string + weeks: Array<{ key: string, monthLabel: string | null, cells: Array<{ date: string, dateDisplay: string, tokens: number, isFuture: boolean }> }> +} + +export interface AccountProfileStatisticsResponse { + activityCalendar: ProfileActivityCalendar | null + displayName: string | null + username: string | null + imageUrl: string | null + hasStatsError: boolean + summary: AccountProfileStatisticsSummary + dailyUsage: AccountProfileDailyUsage[] | null + activityInsights: AccountProfileActivityInsights +} + +export interface AccountPersonalInfoResponse { + profile: AccountProfileStatisticsResponse | null + profileError: string | null + subscription: AccountSubscription | null +} + +export function getAccountPersonalInfo(data: AccountIdParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/personal-info', + method: 'GET', + params: data, + ...options, + }) +} + +export function accountProfileAvatarUrl(accountId: string, sourceUrl: string) { + const params = new URLSearchParams({ + accountId, + version: stableAvatarVersion(sourceUrl), + }) + return `/api/admin/accounts/profile-avatar?${params.toString()}` +} + +function stableAvatarVersion(value: string) { + let hash = 0 + for (const character of value) + hash = (hash * 33 + (character.codePointAt(0) ?? 0)) % 2_147_483_647 + return hash.toString(36) +} diff --git a/frontend/src/api/modules/accounts/quota.ts b/frontend/src/api/modules/accounts/quota.ts new file mode 100644 index 000000000..0b739e422 --- /dev/null +++ b/frontend/src/api/modules/accounts/quota.ts @@ -0,0 +1,55 @@ +import type { RequestOptions } from '../../request' +import type { Account, AccountIdParam } from './types' +import request from '../../request' + +export interface AccountQuotaForecast { + period: 'weekly' | 'monthly' + targetDays: number + extrapolated: boolean + source: { + label: string + usedPercent: number | null + usedPercentDisplay: string + observedAt: string | null + observedAtDisplay: string + resetAt: string + tokensDisplay: string + usdDisplay: string + } | null + unavailableReason: string | null + lowSample: boolean + incompleteCost: boolean + incompleteTokens: boolean + estimatedTokens: number | null + estimatedTokensDisplay: string + estimatedUsd: number | null + estimatedUsdDisplay: string +} + +export interface AccountQuotaForecastResponse { + accountId: string + generatedAt: string + forecasts: AccountQuotaForecast[] +} + +export interface AccountQuotaResponse { + account: Account +} + +export function getAccountQuotaForecast(data: AccountIdParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/quota-forecast', + method: 'GET', + params: data, + ...options, + }) +} + +export function refreshAccountQuota(data: AccountIdParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/quota/refresh', + method: 'POST', + data, + ...options, + }) +} diff --git a/frontend/src/api/modules/accounts/reset-credits.ts b/frontend/src/api/modules/accounts/reset-credits.ts new file mode 100644 index 000000000..cceafe51e --- /dev/null +++ b/frontend/src/api/modules/accounts/reset-credits.ts @@ -0,0 +1,75 @@ +import type { RequestOptions } from '../../request' +import type { AccountIdParam } from './types' +import request from '../../request' + +export interface AccountResetCredit { + id: string + status: string | null + title: string | null + expiresAt: string | null + expiresAtDisplay: string | null + resetType: string | null +} + +export interface AccountResetCreditsResponse { + availableCount: number + credits: AccountResetCredit[] +} + +export interface AccountResetCreditResultResponse { + code: string + credit: AccountResetCredit | null +} + +export interface AccountResetCreditAutomation { + enabled: boolean + leadMinutes: number + nextDiscoveryAtDisplay: string | null + nextConsumeAtDisplay: string | null + lastResult: string | null +} + +interface AccountResetCreditConsumeParam extends AccountIdParam { + creditId?: string + redeemRequestId: string +} + +interface AccountResetCreditAutomationParam extends AccountIdParam { + enabled: boolean +} + +export function getAccountResetCredits(data: AccountIdParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/reset-credits', + method: 'GET', + params: data, + ...options, + }) +} + +export function consumeAccountResetCredit(data: AccountResetCreditConsumeParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/reset-credits', + method: 'POST', + data, + ...options, + }) +} + +export function getAccountResetCreditAutomation(data: AccountIdParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/reset-credits/automation', + method: 'GET', + params: data, + ...options, + }) +} + +export function setAccountResetCreditAutomation(data: AccountResetCreditAutomationParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/reset-credits/automation', + method: 'POST', + data, + ...options, + }) +} diff --git a/frontend/src/api/modules/accounts/types.ts b/frontend/src/api/modules/accounts/types.ts new file mode 100644 index 000000000..0e596b5d9 --- /dev/null +++ b/frontend/src/api/modules/accounts/types.ts @@ -0,0 +1,216 @@ +import type { RelativeTimeView } from '../../types/time' +import type { AccountGroupRef } from '../groups' + +export type AccountStatus + = 'normal' | 'quota_exhausted' | 'rate_limited' | 'disabled' | 'error' + +export type AccountErrorReason + = 'account_unverified' + | 'access_token_expired' + | 'credential_expired' + | 'credential_invalid' + | '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 + limitName: string | null + role: 'primary' | 'secondary' | 'monthly' | null + windowSeconds: number | null + labelDisplay: string + windowLabelDisplay: string + usedPercent: number | null + usedPercentDisplay: string + limitReached: boolean + localUsage?: unknown + resetAtDisplay: string +} + +export interface AccountQuotaCredits { + hasCredits: boolean + unlimited: boolean + balance: string | null +} + +export interface AccountProtectionRule { + id: string + name: string +} + +export interface AccountQuota { + refreshedAt: string | null + refreshedAtRelative: RelativeTimeView | null + refreshedAtDisplay: string + limitReached: boolean + // 429 临时限流(Redis 冷却)到期时间;非限流中为 null。 + rateLimitedUntil: string | null + rateLimitRecoveryDisplay: string | null + rateLimitReason: 'upstream_rate_limit' | 'error_protection' | null + protectionRules: AccountProtectionRule[] + recoveryProbeRequired: boolean + windows: AccountQuotaWindow[] + credits: AccountQuotaCredits | null +} + +export interface AccountCurrencyCost { + currency: string + estimatedAmount: string + estimatedAmountDisplay: string +} + +export interface AccountModelUsage { + model: string + requestCount: number + requestCountDisplay: string + successRate: number | null + successRateDisplay: string + inputTokens: number | null + inputTokensDisplay: string + outputTokens: number | null + outputTokensDisplay: string + cachedInputTokens: number | null + cachedInputTokensDisplay: string + imageInputTokens: number | null + imageInputTokensDisplay: string + imageOutputTokens: number | null + imageOutputTokensDisplay: string + imageRequestCount: number + imageRequestCountDisplay: string + imageRequestFailedCount: number + imageRequestFailedCountDisplay: string + totalTokens: number | null + totalTokensDisplay: string + billingAmountUsd: string | null + billingAmountUsdDisplay: string + costEstimateStatus: string + knownCostCount: number + partialCostCount: number + unknownCostCount: number + costs: AccountCurrencyCost[] + lastUsedAt: string + lastUsedAtDisplay: string + lastUsedAtFullDisplay: string | null +} + +export interface AccountUsage { + windowKind: 'general' | 'daily' | 'weekly' | 'monthly' | 'quota' + requestCount: number | null + requestCountDisplay: string + inputTokens: number | null + inputTokensDisplay: string + outputTokens: number | null + outputTokensDisplay: string + cachedInputTokens: number | null + cachedInputTokensDisplay: string + reasoningOutputTokens: number | null + reasoningOutputTokensDisplay: string + imageInputTokens: number | null + imageInputTokensDisplay: string + imageOutputTokens: number | null + imageOutputTokensDisplay: string + imageRequestCount: number | null + imageRequestCountDisplay: string + imageRequestFailedCount: number | null + imageRequestFailedCountDisplay: string + totalTokens: number | null + totalTokensDisplay: string + cacheWriteInputTokens: number | null + cacheWriteInputTokensDisplay: string + lastUsedAt: string | null + lastUsedAtDisplay: string + lastUsedAtFullDisplay: string | null + costEstimateStatus: string + knownCostCount: number | null + partialCostCount: number | null + unknownCostCount: number | null + costs: AccountCurrencyCost[] + models: AccountModelUsage[] +} + +export interface AccountModelAccess { + mode: 'all' | 'allowlist' | 'denylist' + models: string[] +} + +export interface Account { + upstream: AccountUpstream | null + capabilities: AccountCapabilities + outboundProxyId: string | null + outboundProxyEndpoint: string | null + id: string + name: string + notes: string | null + provider: string + resourceRef: string + email: string | null + accountId: string | null + userId: string | null + label: string | null + planType: string | null + planTypeDisplay: string + authenticationKind: string + hasRefreshToken: boolean + status: AccountStatus + errorReason: AccountErrorReason | null + errorMessage: string | null + enabled: boolean + concurrencyLimit: number | null + capacity: { + usedSlots: number | null + totalSlots: number | null + } + weight: number + modelAccess: AccountModelAccess + accessTokenExpiresAt: string | null + accessTokenExpiresAtDisplay: string | null + refreshTokenExpiresAt: string | null + nextRefreshAt: string | null + nextRefreshAtDisplay: string | null + addedAt: string + addedAtDisplay: string + updatedAt: string + updatedAtDisplay: string + quota: AccountQuota + usage: AccountUsage + groups: AccountGroupRef[] +} + +export interface AccountUpstream { + discovered: boolean + observedAt: string | null + observedAtDisplay: string + observedAtRelative: RelativeTimeView | null + stale: boolean + connectionRevision: string + 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 AccountCapabilities { + quota: boolean + quotaRefresh: boolean + profile: boolean + subscription: boolean + avatar: boolean + resetCredits: boolean + consumeResetCredit: boolean +} + +export interface AccountRefreshResponse { + account: Account + result?: string + error?: string +} + +export interface AccountIdParam { + accountId: string +} diff --git a/frontend/src/api/modules/dashboard.ts b/frontend/src/api/modules/dashboard.ts index c83331791..3fd9bc1ea 100644 --- a/frontend/src/api/modules/dashboard.ts +++ b/frontend/src/api/modules/dashboard.ts @@ -1,5 +1,5 @@ import type { RequestOptions } from '../request' -import type { RelativeTimeView } from '../time' +import type { RelativeTimeView } from '../types/time' import type { AccountQuotaWindow, AccountUpstream } from './accounts' import type { AnalyticsMetadata } from './analytics' import type { UsageListRecord } from './usage' diff --git a/frontend/src/api/modules/proxies.ts b/frontend/src/api/modules/proxies.ts index cc1365b7f..63442ad01 100644 --- a/frontend/src/api/modules/proxies.ts +++ b/frontend/src/api/modules/proxies.ts @@ -1,15 +1,9 @@ import type { RequestOptions } from '../request' +import type { RequestLocation, RequestLocationDetection } from '../types/location' import type { AccountUpstream } from './accounts' import type { AccountGroupRef } from './groups' -import type { RequestLocation } from './settings' import request from '../request' -export type ProxyLocationDetection - = | { status: 'notRequested' } - | { status: 'detected', location: RequestLocation } - | { status: 'failed', message: string } - | { status: 'conflict' } - export interface DetectedProxyLocation { location: RequestLocation exitIpv4: string | null @@ -18,7 +12,7 @@ export interface DetectedProxyLocation { } export interface OutboundProxyTest { - location: ProxyLocationDetection + location: RequestLocationDetection success: boolean latencyMs: number exitIp: string | null diff --git a/frontend/src/api/modules/settings/index.ts b/frontend/src/api/modules/settings/index.ts index f57ab7508..c0ef445a0 100644 --- a/frontend/src/api/modules/settings/index.ts +++ b/frontend/src/api/modules/settings/index.ts @@ -1,5 +1,5 @@ import type { RequestOptions } from '../../request' -import type { ProxyLocationDetection } from '../proxies' +import type { RequestLocation, RequestLocationDetection } from '../../types/location' import type { CodexPrivacyPolicy } from './privacy' import type { ClientProfileSelection, ProviderRequestProfiles, ProviderRequestProfileUpdates, XaiClientProfileSelection } from './profiles' import type { AccountErrorProtection } from './protection' @@ -11,15 +11,8 @@ export * from './privacy' export * from './protection' export * from './system' -export interface RequestLocation { - country: string - region: string - city: string - timezone: string -} - export function detectRequestLocation() { - return request({ + return request({ url: '/api/admin/settings/location/detect', method: 'POST', timeout: 25000, diff --git a/frontend/src/api/request/index.ts b/frontend/src/api/request/index.ts index 2e29d4478..cfb6fa213 100644 --- a/frontend/src/api/request/index.ts +++ b/frontend/src/api/request/index.ts @@ -1,6 +1,4 @@ -import type { AxiosRequestConfig } from 'axios' -import type { RequestContext, RequestNext, RequestPlugin } from './plugins' -import type { RawResponse, RawResponseOptions } from './plugins/raw' +import type { RawResponse, RawResponseOptions, RequestConfig, RequestContext, RequestNext, RequestPlugin } from './types' import axios from 'axios' import { API_BASE_URL, API_TIMEOUT_MS } from '../constants' import { errorPlugin } from './plugins/error' @@ -10,21 +8,8 @@ import { retryPlugin } from './plugins/retry' import { sessionPlugin } from './plugins/session' export { ApiError } from './plugins/error' -export type { RawResponse, RawResponseMeta, RawResponseOptions } from './plugins/raw' export { resetUnauthorizedHandling, setSessionRecoveryHandler, setUnauthorizedHandler } from './plugins/session' - -export interface RequestOptions { - // 静默只关闭提示,不吞掉异常或绕过会话恢复 - silent?: boolean - signal?: AbortSignal - timeout?: number - // 读取默认重试,写操作须由调用方确认幂等后开启 - retry?: boolean - // 认证接口自身跳过会话恢复,避免循环等待 - skipSessionRecovery?: boolean -} - -export type RequestConfig = AxiosRequestConfig & RequestOptions +export type { RawResponse, RawResponseMeta, RawResponseOptions, RequestConfig, RequestOptions } from './types' const http = axios.create({ baseURL: API_BASE_URL, diff --git a/frontend/src/api/request/plugins/error.ts b/frontend/src/api/request/plugins/error.ts index bf9584428..ad00c2ad4 100644 --- a/frontend/src/api/request/plugins/error.ts +++ b/frontend/src/api/request/plugins/error.ts @@ -1,5 +1,5 @@ import type { AxiosError, AxiosResponse, AxiosResponseHeaders, RawAxiosResponseHeaders } from 'axios' -import type { RequestContext, RequestNext } from './index' +import type { RequestContext, RequestNext } from '../types' import { isAxiosError } from 'axios' import { t } from '@/i18n' diff --git a/frontend/src/api/request/plugins/feedback.ts b/frontend/src/api/request/plugins/feedback.ts index 1fdfb82d9..3aaa1b002 100644 --- a/frontend/src/api/request/plugins/feedback.ts +++ b/frontend/src/api/request/plugins/feedback.ts @@ -1,4 +1,4 @@ -import type { RequestContext, RequestNext } from './index' +import type { RequestContext, RequestNext } from '../types' import { notification as ZNotification } from '@/utils/notification' import { ApiError, isSessionRequired } from './error' diff --git a/frontend/src/api/request/plugins/index.ts b/frontend/src/api/request/plugins/index.ts deleted file mode 100644 index ed61afa45..000000000 --- a/frontend/src/api/request/plugins/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AxiosResponse } from 'axios' -import type { RequestConfig } from '../index' -import type { RawResponseOptions } from './raw' - -export interface RequestContext { - config: RequestConfig - raw?: RawResponseOptions - stream?: boolean - // 会话插件补充身份代际检查,重试与提示消费同一请求有效性 - isCurrent: () => boolean -} - -export type RequestNext = () => Promise> - -export type RequestPlugin = (context: RequestContext, next: RequestNext) => Promise> diff --git a/frontend/src/api/request/plugins/raw.ts b/frontend/src/api/request/plugins/raw.ts index 95f8f55aa..2ad958998 100644 --- a/frontend/src/api/request/plugins/raw.ts +++ b/frontend/src/api/request/plugins/raw.ts @@ -1,24 +1,7 @@ -import type { RequestContext, RequestNext } from './index' +import type { RawResponse, RawResponseMeta, RequestContext, RequestNext } from '../types' import { t } from '@/i18n' import { ApiError, normalizeApiResponseError } from './error' -export interface RawResponseMeta { - status: number - header: (name: string) => string | undefined -} - -export interface RawResponse { - status: number - contentType: string - body: ArrayBuffer -} - -export interface RawResponseOptions { - // 原始字节或宿主错误的正文上限;成功流由消费方约束,不在请求层缓冲 - maximumBytes: number - accept: (response: RawResponseMeta) => boolean -} - export async function rawPlugin(context: RequestContext, next: RequestNext) { const options = context.raw if (!options) diff --git a/frontend/src/api/request/plugins/retry.ts b/frontend/src/api/request/plugins/retry.ts index ead0a820c..96997eb89 100644 --- a/frontend/src/api/request/plugins/retry.ts +++ b/frontend/src/api/request/plugins/retry.ts @@ -1,4 +1,4 @@ -import type { RequestContext, RequestNext } from './index' +import type { RequestContext, RequestNext } from '../types' import { t } from '@/i18n' import { ApiError, isTransientApiError } from './error' diff --git a/frontend/src/api/request/plugins/session.ts b/frontend/src/api/request/plugins/session.ts index 36b22d82b..37d17a97d 100644 --- a/frontend/src/api/request/plugins/session.ts +++ b/frontend/src/api/request/plugins/session.ts @@ -1,5 +1,4 @@ -import type { RequestConfig } from '../index' -import type { RequestContext, RequestNext } from './index' +import type { RequestConfig, RequestContext, RequestNext } from '../types' import { t } from '@/i18n' import { notification as ZNotification } from '@/utils/notification' import { ApiError, isSessionRequired } from './error' diff --git a/frontend/src/api/request/types.ts b/frontend/src/api/request/types.ts new file mode 100644 index 000000000..d4629df85 --- /dev/null +++ b/frontend/src/api/request/types.ts @@ -0,0 +1,43 @@ +import type { AxiosRequestConfig, AxiosResponse } from 'axios' + +export interface RequestOptions { + // 静默只关闭提示,不吞掉异常或绕过会话恢复 + silent?: boolean + signal?: AbortSignal + timeout?: number + // 读取默认重试,写操作须由调用方确认幂等后开启 + retry?: boolean + // 认证接口自身跳过会话恢复,避免循环等待 + skipSessionRecovery?: boolean +} + +export type RequestConfig = AxiosRequestConfig & RequestOptions + +export interface RawResponseMeta { + status: number + header: (name: string) => string | undefined +} + +export interface RawResponse { + status: number + contentType: string + body: ArrayBuffer +} + +export interface RawResponseOptions { + // 原始字节或宿主错误的正文上限;成功流由消费方约束,不在请求层缓冲 + maximumBytes: number + accept: (response: RawResponseMeta) => boolean +} + +export interface RequestContext { + config: RequestConfig + raw?: RawResponseOptions + stream?: boolean + // 会话插件补充身份代际检查,重试与提示消费同一请求有效性 + isCurrent: () => boolean +} + +export type RequestNext = () => Promise> + +export type RequestPlugin = (context: RequestContext, next: RequestNext) => Promise> diff --git a/frontend/src/api/types/location.ts b/frontend/src/api/types/location.ts new file mode 100644 index 000000000..53cddfc83 --- /dev/null +++ b/frontend/src/api/types/location.ts @@ -0,0 +1,12 @@ +export interface RequestLocation { + country: string + region: string + city: string + timezone: string +} + +export type RequestLocationDetection + = | { status: 'notRequested' } + | { status: 'detected', location: RequestLocation } + | { status: 'failed', message: string } + | { status: 'conflict' } diff --git a/frontend/src/api/time.ts b/frontend/src/api/types/time.ts similarity index 100% rename from frontend/src/api/time.ts rename to frontend/src/api/types/time.ts diff --git a/frontend/src/utils/relative-time.ts b/frontend/src/utils/relative-time.ts index cbbb65aba..799ee15a6 100644 --- a/frontend/src/utils/relative-time.ts +++ b/frontend/src/utils/relative-time.ts @@ -1,4 +1,4 @@ -import type { RelativeTimeView } from '@/api/time' +import type { RelativeTimeView } from '@/api' import { t } from '@/i18n' export function formatRelativeTime(value: RelativeTimeView | null | undefined) {