From fada9bc0bda501f8a377ce275d7b1ba74bca6a82 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sun, 1 Nov 2026 20:35:59 +0800 Subject: [PATCH] refactor: clarify module boundaries and naming Organize backend modules by responsibility, simplify runtime layers, and keep shared contracts with their owning modules. Split frontend account APIs and separate shared types from request implementations. Strengthen architecture checks for module dependencies and test layout. Co-authored-by: Codex --- backend/apps/gateway/tests/architecture.rs | 226 ++++- .../crates/gateway-admin/src/backup/task.rs | 24 +- backend/crates/gateway-admin/src/bootstrap.rs | 340 +++++++ .../gateway-admin/src/freeze_recovery.rs | 34 +- backend/crates/gateway-admin/src/lib.rs | 424 +-------- .../src/reset_credit_automation.rs | 125 --- .../gateway-admin/src/reset_credit_worker.rs | 91 ++ .../accounts/reset_credit_automation.rs | 66 +- .../src/use_case/import_tasks.rs | 23 +- .../crates/gateway-admin/src/use_case/mod.rs | 2 +- .../gateway-admin/src/use_case/system.rs | 2 +- .../{plugin_update.rs => system_preflight.rs} | 0 .../gateway-admin/tests/use_case/mod.rs | 2 +- .../{plugin_update.rs => system_preflight.rs} | 0 .../src/admin/observability/query.rs | 12 +- backend/crates/gateway-api/src/admin/wire.rs | 20 +- .../crates/gateway-api/src/client_error.rs | 228 +++++ backend/crates/gateway-api/src/lib.rs | 21 + .../crates/gateway-api/src/middleware/mod.rs | 2 +- .../src/openai/chat_completions/delivery.rs | 3 +- .../src/openai/chat_completions/response.rs | 2 +- .../crates/gateway-api/src/openai/endpoint.rs | 2 +- .../crates/gateway-api/src/openai/error.rs | 194 +--- .../gateway-api/src/openai/responses/error.rs | 33 +- .../gateway-api/src/openai/responses/http.rs | 2 +- .../gateway-api/src/openai/responses/mod.rs | 3 +- .../src/openai/responses/response.rs | 2 +- .../openai/responses/websocket/protocol.rs | 6 +- backend/crates/gateway-api/src/time.rs | 17 +- backend/crates/gateway-host/src/config.rs | 22 +- backend/crates/gateway-host/src/lib.rs | 29 +- .../crates/gateway-host/src/logging/mod.rs | 2 +- .../gateway-host/src/system_update/config.rs | 2 +- .../runtime/src/callback/private_state.rs | 45 +- .../src/{compatibility.rs => deprecation.rs} | 0 .../runtime/src/generation/configuration.rs | 2 +- .../generation/{prepare => }/diagnostics.rs | 2 +- .../src/generation/{prepare => }/instance.rs | 6 +- .../generation/{prepare => }/maintenance.rs | 0 .../generation/{prepare => }/management.rs | 0 .../runtime/src/generation/mod.rs | 13 +- .../runtime/src/generation/prepare/mod.rs | 12 - .../src/generation/{prepare => }/runtime.rs | 4 +- .../src/generation/{prepare => }/set.rs | 0 .../src/generation/{prepare => }/snapshot.rs | 9 +- .../src/generation/{prepare => }/state.rs | 0 .../crates/gateway-plugin/runtime/src/lib.rs | 6 +- .../runtime/src/package/inspection.rs | 5 +- .../gateway-plugin/runtime/src/package/mod.rs | 4 + .../runtime/src/package/state_schema.rs | 52 ++ .../gateway-plugin/runtime/src/rpc/calls.rs | 2 +- .../gateway-plugin/runtime/src/rpc/mod.rs | 2 + .../gateway-plugin/runtime/src/rpc/session.rs | 4 +- .../runtime/src/{ => rpc}/stream/bridge.rs | 5 +- .../src/{ => rpc}/stream/flow_control.rs | 2 +- .../runtime/src/rpc/stream/mod.rs | 7 + .../gateway-plugin/runtime/src/stream/mod.rs | 7 - .../generation/{prepare => }/diagnostics.rs | 16 +- .../generation/{prepare => }/instance.rs | 59 +- .../runtime/tests/generation/maintenance.rs | 155 +++- .../generation/{prepare => }/management.rs | 9 +- .../runtime/tests/generation/mod.rs | 18 +- .../tests/generation/prepare/maintenance.rs | 157 ---- .../runtime/tests/generation/prepare/mod.rs | 21 - .../tests/generation/{prepare => }/runtime.rs | 4 +- .../tests/generation/{prepare => }/set.rs | 16 +- .../generation/{prepare => }/snapshot.rs | 82 +- .../gateway-plugin/sdk/src/client/http.rs | 2 +- .../sdk/src/client/middleware/body.rs | 2 +- .../sdk/src/client/middleware/http.rs | 2 +- .../sdk/src/client/middleware/websocket.rs | 2 +- .../gateway-plugin/sdk/src/client/mod.rs | 2 +- .../src/client/{read.rs => pending_read.rs} | 0 .../sdk/src/client/upstream_adapter.rs | 2 +- .../gateway-plugin/sdk/tests/client/mod.rs | 2 +- .../tests/client/{read.rs => pending_read.rs} | 0 backend/crates/gateway-store/src/error.rs | 156 ++++ backend/crates/gateway-store/src/lib.rs | 12 +- .../src/postgres/admin_security_audit.rs | 27 + backend/crates/gateway-store/src/value.rs | 187 +--- .../providers/openai/src/credential/admin.rs | 2 +- .../src/credential/{security.rs => codec.rs} | 0 .../providers/openai/src/credential/mod.rs | 10 +- .../providers/openai/src/credential/oauth.rs | 2 +- .../openai/src/credential/quota/mod.rs | 2 +- .../openai/src/credential/repository.rs | 2 +- .../openai/src/credential/selector.rs | 2 +- backend/crates/providers/openai/src/lib.rs | 5 +- .../providers/openai/src/provider/mod.rs | 2 - .../providers/openai/src/transport/client.rs | 63 +- .../openai/src/transport/client_catalog.rs | 75 ++ .../{client_sse.rs => client_stream.rs} | 132 +-- .../openai/src/transport/endpoints.rs | 3 + .../providers/openai/src/transport/mod.rs | 3 +- backend/crates/providers/xai/src/admin.rs | 4 +- .../providers/xai/src/credential/discovery.rs | 3 +- .../providers/xai/src/credential/mod.rs | 9 +- .../providers/xai/src/credential/token.rs | 2 +- backend/crates/providers/xai/src/lib.rs | 37 +- .../src/{credential/http.rs => oauth_http.rs} | 6 +- .../xai/src/{credential => }/secret.rs | 2 +- .../providers/xai/src/transport/canonical.rs | 2 +- .../crates/providers/xai/src/transport/mod.rs | 146 +--- .../providers/xai/src/transport/network.rs | 6 +- .../xai/src/transport/upstream_error.rs | 144 +++ .../providers/xai/tests/credential/mod.rs | 2 - backend/crates/providers/xai/tests/main.rs | 2 + .../{credential/http.rs => oauth_http.rs} | 2 +- .../xai/tests/{credential => }/secret.rs | 2 +- docs/architecture.md | 3 +- frontend/src/api/index.ts | 4 + frontend/src/api/modules/accounts.ts | 827 ------------------ .../src/api/modules/accounts/credentials.ts | 183 ++++ frontend/src/api/modules/accounts/index.ts | 114 +++ .../src/api/modules/accounts/management.ts | 178 ++++ frontend/src/api/modules/accounts/models.ts | 43 + .../src/api/modules/accounts/personal-info.ts | 93 ++ frontend/src/api/modules/accounts/quota.ts | 55 ++ .../src/api/modules/accounts/reset-credits.ts | 75 ++ frontend/src/api/modules/accounts/types.ts | 216 +++++ frontend/src/api/modules/dashboard.ts | 2 +- frontend/src/api/modules/proxies.ts | 10 +- frontend/src/api/modules/settings/index.ts | 11 +- frontend/src/api/request/index.ts | 19 +- frontend/src/api/request/plugins/error.ts | 2 +- frontend/src/api/request/plugins/feedback.ts | 2 +- frontend/src/api/request/plugins/index.ts | 15 - frontend/src/api/request/plugins/raw.ts | 19 +- frontend/src/api/request/plugins/retry.ts | 2 +- frontend/src/api/request/plugins/session.ts | 3 +- frontend/src/api/request/types.ts | 43 + frontend/src/api/types/location.ts | 12 + frontend/src/api/{ => types}/time.ts | 0 frontend/src/utils/relative-time.ts | 2 +- 134 files changed, 3019 insertions(+), 2669 deletions(-) create mode 100644 backend/crates/gateway-admin/src/bootstrap.rs delete mode 100644 backend/crates/gateway-admin/src/reset_credit_automation.rs create mode 100644 backend/crates/gateway-admin/src/reset_credit_worker.rs rename backend/crates/gateway-admin/src/use_case/{plugin_update.rs => system_preflight.rs} (100%) rename backend/crates/gateway-admin/tests/use_case/{plugin_update.rs => system_preflight.rs} (100%) create mode 100644 backend/crates/gateway-api/src/client_error.rs rename backend/crates/gateway-plugin/runtime/src/{compatibility.rs => deprecation.rs} (100%) rename backend/crates/gateway-plugin/runtime/src/generation/{prepare => }/diagnostics.rs (99%) rename backend/crates/gateway-plugin/runtime/src/generation/{prepare => }/instance.rs (98%) rename backend/crates/gateway-plugin/runtime/src/generation/{prepare => }/maintenance.rs (100%) rename backend/crates/gateway-plugin/runtime/src/generation/{prepare => }/management.rs (100%) delete mode 100644 backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs rename backend/crates/gateway-plugin/runtime/src/generation/{prepare => }/runtime.rs (98%) rename backend/crates/gateway-plugin/runtime/src/generation/{prepare => }/set.rs (100%) rename backend/crates/gateway-plugin/runtime/src/generation/{prepare => }/snapshot.rs (98%) rename backend/crates/gateway-plugin/runtime/src/generation/{prepare => }/state.rs (100%) create mode 100644 backend/crates/gateway-plugin/runtime/src/package/state_schema.rs rename backend/crates/gateway-plugin/runtime/src/{ => rpc}/stream/bridge.rs (96%) rename backend/crates/gateway-plugin/runtime/src/{ => rpc}/stream/flow_control.rs (97%) create mode 100644 backend/crates/gateway-plugin/runtime/src/rpc/stream/mod.rs delete mode 100644 backend/crates/gateway-plugin/runtime/src/stream/mod.rs rename backend/crates/gateway-plugin/runtime/tests/generation/{prepare => }/diagnostics.rs (93%) rename backend/crates/gateway-plugin/runtime/tests/generation/{prepare => }/instance.rs (94%) rename backend/crates/gateway-plugin/runtime/tests/generation/{prepare => }/management.rs (90%) delete mode 100644 backend/crates/gateway-plugin/runtime/tests/generation/prepare/maintenance.rs delete mode 100644 backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs rename backend/crates/gateway-plugin/runtime/tests/generation/{prepare => }/runtime.rs (86%) rename backend/crates/gateway-plugin/runtime/tests/generation/{prepare => }/set.rs (92%) rename backend/crates/gateway-plugin/runtime/tests/generation/{prepare => }/snapshot.rs (89%) rename backend/crates/gateway-plugin/sdk/src/client/{read.rs => pending_read.rs} (100%) rename backend/crates/gateway-plugin/sdk/tests/client/{read.rs => pending_read.rs} (100%) create mode 100644 backend/crates/gateway-store/src/error.rs rename backend/crates/providers/openai/src/credential/{security.rs => codec.rs} (100%) create mode 100644 backend/crates/providers/openai/src/transport/client_catalog.rs rename backend/crates/providers/openai/src/transport/{client_sse.rs => client_stream.rs} (86%) rename backend/crates/providers/xai/src/{credential/http.rs => oauth_http.rs} (96%) rename backend/crates/providers/xai/src/{credential => }/secret.rs (94%) create mode 100644 backend/crates/providers/xai/src/transport/upstream_error.rs rename backend/crates/providers/xai/tests/{credential/http.rs => oauth_http.rs} (89%) rename backend/crates/providers/xai/tests/{credential => }/secret.rs (85%) delete mode 100644 frontend/src/api/modules/accounts.ts create mode 100644 frontend/src/api/modules/accounts/credentials.ts create mode 100644 frontend/src/api/modules/accounts/index.ts create mode 100644 frontend/src/api/modules/accounts/management.ts create mode 100644 frontend/src/api/modules/accounts/models.ts create mode 100644 frontend/src/api/modules/accounts/personal-info.ts create mode 100644 frontend/src/api/modules/accounts/quota.ts create mode 100644 frontend/src/api/modules/accounts/reset-credits.ts create mode 100644 frontend/src/api/modules/accounts/types.ts delete mode 100644 frontend/src/api/request/plugins/index.ts create mode 100644 frontend/src/api/request/types.ts create mode 100644 frontend/src/api/types/location.ts rename frontend/src/api/{ => types}/time.ts (100%) 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) {