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 <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-01 20:35:59 +08:00
co-authored by Codex
parent 27b9bbe994
commit fada9bc0bd
134 changed files with 3019 additions and 2669 deletions
+186 -40
View File
@@ -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::<String, BTreeSet<String>>::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<String>,
module_path: Vec<String>,
#[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<String, String> {
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<String, String> {
let modules = syntax
.items
.iter()
.filter_map(|item| match item {
Item::Mod(module) => Some(module.ident.to_string()),
_ => None,
})
.collect::<BTreeSet<_>>();
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<String>,
module_path: Vec<String>,
root_reexports: Option<&'a BTreeMap<String, String>>,
}
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<String, String>) -> 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<String>) {
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<String>,
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),
}
}
@@ -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<BackupError> 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<WorkerContribution, 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(self),
},
)
.map_err(|_| AdminError::internal("备份 Worker 注册信息不合法"))?;
Ok(WorkerContribution::Registration(registration))
}
}
impl DaemonTask for BackupTask {
@@ -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<AdminBundle, AdminError> {
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<dyn PluginAccountAccess>,
) -> Result<AdminBundle, AdminError> {
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<Arc<dyn PluginAccountAccess>>,
) -> Result<AdminBundle, AdminError> {
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<dyn ports::store::ObservabilityStore> = 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<dyn AccountsService>,
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<dyn ports::store::AccountStore>,
snapshot: Arc<dyn gateway_core::runtime::SnapshotControl>,
upstream_info: Option<Arc<dyn ports::peer::PeerUpstreamService>>,
) -> Arc<dyn PluginAccountAccess> {
Arc::new(use_case::plugin_accounts::DefaultPluginAccountAccess::new(
providers,
accounts,
snapshot,
upstream_info,
))
}
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口
#[must_use]
pub fn initialize_plugin_client_keys(
providers: ports::provider::ProviderAdminRegistry,
store: Arc<dyn ports::store::ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Arc<dyn PluginClientKeyAccess> {
let service: Arc<dyn ClientKeyService> =
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<dyn ports::plugin_resources::PluginResourceStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Arc<dyn ports::plugin_resources::PluginResourceAccess> {
Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot })
}
/// 服务端与 CLI 复用账号用例,不随命令启动后台调度
#[must_use]
#[expect(
clippy::too_many_arguments,
reason = "账号存储、发布、探测与互联分别通过独立端口注入"
)]
pub fn initialize_accounts(
accounts: Arc<dyn ports::store::AccountStore>,
account_runtime: Arc<dyn ports::store::AccountRuntimeStore>,
settings: Arc<dyn ports::store::SettingsStore>,
timezone: gateway_core::time::DeploymentTimeZone,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
upstream_info: Option<Arc<dyn ports::peer::PeerUpstreamService>>,
) -> Arc<dyn AccountsService> {
Arc::new(DefaultAccountsService::new(
accounts,
account_runtime,
settings,
timezone,
providers,
snapshot,
probe,
upstream_info,
))
}
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布
pub fn initialize_settings(
store: Arc<dyn ports::store::SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
pricing_source: Arc<dyn ports::pricing::PricingSource>,
) -> Arc<dyn SettingsService> {
Arc::new(DefaultSettingsService::new(
store,
snapshot,
providers,
pricing_source,
))
}
/// HTTP、插件及 CLI 共用的用量领域用例,宿主提供已验证的调用主体
#[must_use]
pub fn initialize_usage_data(
store: Arc<dyn ports::usage_data::UsageDataStore>,
settings: Arc<dyn ports::store::SettingsStore>,
providers: ProviderAdminRegistry,
) -> Arc<dyn UsageDataService> {
Arc::new(use_case::usage_data::DefaultUsageDataService::new(
store, settings, providers,
))
}
@@ -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<WorkerContribution, AdminError> {
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<AccountErrorProtection> {
let settings = self.deps.settings.load_runtime_settings().await.ok()?;
+12 -412
View File
@@ -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<dyn SystemOperations>,
pub client_key_verifier: Arc<dyn ClientKeyVerifier>,
}
/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化
///
/// # Errors
///
/// 配置非法或默认管理员初始化失败时返回错误
pub async fn initialize(
config: AdminConfig,
client_config: ClientConfig,
store: AdminStorePorts,
runtime: AdminRuntimePorts,
) -> Result<AdminBundle, AdminError> {
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<dyn PluginAccountAccess>,
) -> Result<AdminBundle, AdminError> {
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<Arc<dyn PluginAccountAccess>>,
) -> Result<AdminBundle, AdminError> {
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<dyn ports::store::ObservabilityStore> = 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<dyn AccountsService>,
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<dyn ports::store::AccountStore>,
snapshot: Arc<dyn gateway_core::runtime::SnapshotControl>,
upstream_info: Option<Arc<dyn ports::peer::PeerUpstreamService>>,
) -> Arc<dyn PluginAccountAccess> {
Arc::new(use_case::plugin_accounts::DefaultPluginAccountAccess::new(
providers,
accounts,
snapshot,
upstream_info,
))
}
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口
#[must_use]
pub fn initialize_plugin_client_keys(
providers: ports::provider::ProviderAdminRegistry,
store: Arc<dyn ports::store::ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Arc<dyn PluginClientKeyAccess> {
let service: Arc<dyn ClientKeyService> =
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<dyn ports::plugin_resources::PluginResourceStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Arc<dyn ports::plugin_resources::PluginResourceAccess> {
Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot })
}
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`
fn backup_worker_contribution(
task: backup::task::BackupTask,
) -> Result<Vec<WorkerContribution>, 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<Vec<WorkerContribution>, 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<dyn ports::store::AccountStore>,
account_runtime: Arc<dyn ports::store::AccountRuntimeStore>,
settings: Arc<dyn ports::store::SettingsStore>,
timezone: gateway_core::time::DeploymentTimeZone,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
upstream_info: Option<Arc<dyn ports::peer::PeerUpstreamService>>,
) -> Arc<dyn AccountsService> {
Arc::new(DefaultAccountsService::new(
accounts,
account_runtime,
settings,
timezone,
providers,
snapshot,
probe,
upstream_info,
))
}
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布
pub fn initialize_settings(
store: Arc<dyn ports::store::SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
pricing_source: Arc<dyn ports::pricing::PricingSource>,
) -> Arc<dyn SettingsService> {
Arc::new(DefaultSettingsService::new(
store,
snapshot,
providers,
pricing_source,
))
}
/// HTTP、插件及 CLI 共用的用量领域用例,宿主提供已验证的调用主体
#[must_use]
pub fn initialize_usage_data(
store: Arc<dyn ports::usage_data::UsageDataStore>,
settings: Arc<dyn ports::store::SettingsStore>,
providers: ProviderAdminRegistry,
) -> Arc<dyn UsageDataService> {
Arc::new(use_case::usage_data::DefaultUsageDataService::new(
store, settings, providers,
))
}
@@ -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<Utc>,
timezone: DeploymentTimeZone,
random: u64,
) -> Option<DateTime<Utc>> {
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<Utc> {
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<DateTime<Utc>> {
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<Utc>,
) {
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<dyn AccountsService>,
pub(crate) store: Arc<dyn AccountStore>,
pub(crate) settings: Arc<dyn SettingsStore>,
}
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::<Vec<_>>();
tokio::select! {
() = context.cancellation().cancelled() => {},
_ = tokio::time::timeout(Duration::from_secs(30), work) => {},
}
Ok(())
})
}
}
@@ -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<dyn AccountsService>,
pub(crate) store: Arc<dyn AccountStore>,
pub(crate) settings: Arc<dyn SettingsStore>,
}
impl ResetCreditAutomationTask {
pub(crate) fn into_worker(self) -> Result<WorkerContribution, AdminError> {
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::<Vec<_>>();
tokio::select! {
() = context.cancellation().cancelled() => {},
_ = tokio::time::timeout(Duration::from_secs(30), work) => {},
}
Ok(())
})
}
}
@@ -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<Utc>,
timezone: DeploymentTimeZone,
random: u64,
) -> Option<DateTime<Utc>> {
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<Utc> {
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<DateTime<Utc>> {
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<Utc>) {
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)
})
};
}
@@ -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<DefaultImportTasksService>);
impl ImportTaskWorker {
pub(crate) fn into_worker(self) -> Result<WorkerContribution, AdminError> {
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 {
@@ -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;
@@ -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;
@@ -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::{
@@ -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<Option<DateTime<Utc>>, 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<i64>) -> Result<Option<u16>, WireValidationError> {
value
+1 -19
View File
@@ -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;
@@ -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<String>,
}
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<Value>) {
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<Value> {
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",
),
}
}
+21
View File
@@ -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 {
@@ -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() {
@@ -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};
@@ -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 {
@@ -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,
};
+5 -189
View File
@@ -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<Value>) {
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<Value> {
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<Value>) {
)
}
/// 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",
),
}
}
@@ -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<String>,
}
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 {
@@ -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(),
@@ -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,
@@ -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";
@@ -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,
+14 -3
View File
@@ -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<Option<DateTime<Utc>>, 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<i64>,
timezone: DeploymentTimeZone,
default_period: &str,
) -> Result<gateway_admin::model::observability::TimeRange, crate::admin::WireValidationError> {
use crate::admin::{WireValidationError, observability::parse_datetime};
) -> Result<gateway_admin::model::observability::TimeRange, WireValidationError> {
use gateway_admin::model::observability::{CalendarPeriod, TimeRange};
if start.is_some() || end.is_some() {
if period.is_some() || as_of.is_some() {
+1 -21
View File
@@ -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<Option<String>, ConfigError> {
match env::var(name) {
Ok(value) if value.trim().is_empty() => Err(ConfigError::InvalidEnvironment(name)),
+28 -1
View File
@@ -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};
@@ -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),
}
/// 初始化日志;按各通道的保留窗口清理,不以分片数量淘汰记录
@@ -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";
@@ -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<PluginStateOwner>,
) -> Result<Self, AdminError> {
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<PluginStateConfiguration, AdminError> {
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<BTreeMap<String, jsonschema::Validator>, 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"),
@@ -103,7 +103,7 @@ pub(super) fn validate(
}
Ok((
configuration,
crate::callback::private_state::configuration(manifest)?,
crate::package::state_configuration(manifest)?,
))
}
@@ -1,6 +1,6 @@
//! 将已发布与准备中的实例事实投影为运行诊断,不参与候选提交
use super::super::restart_circuit::RestartIdentity;
use super::restart_circuit::RestartIdentity;
use super::{
PluginRuntime,
set::{PreparedSet, instance_fingerprint},
@@ -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
@@ -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};
@@ -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};
@@ -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<gateway_host::process::ProcessSupervisor>,
pub(super) validators: Arc<Semaphore>,
pub(super) configuration_schemas: Arc<super::super::configuration::ConfigurationSchemas>,
pub(super) configuration_schemas: Arc<super::configuration::ConfigurationSchemas>,
pub(super) log_slots: Arc<Semaphore>,
pub(super) account_ports: Arc<PluginAccountPortSlot>,
pub(super) client_key_ports: Arc<PluginClientKeyPortSlot>,
@@ -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)?;
@@ -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};
@@ -101,8 +101,7 @@ impl PackageInspector {
.collect::<Result<_, _>>()?,
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<Vec<gateway_admin::model::plugins::instances::PluginApiDeprecation>, AdminError>
{
crate::compatibility::warnings(
crate::deprecation::warnings(
metadata
.contributes
.iter()
@@ -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};
@@ -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<PluginStateConfiguration, AdminError> {
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<BTreeMap<String, jsonschema::Validator>, 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()
}
@@ -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<State>,
@@ -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;
@@ -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<Shared>,
@@ -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<Vec<u8>>,
terminal: oneshot::Sender<Result<(), RpcError>>,
window: ReceiveWindow,
@@ -1,6 +1,6 @@
//! 插件流的字节与帧数窗口校验、序号推进及信用归还
use crate::RpcError;
use super::super::RpcError;
/// 字节与帧数一起授予,避免攻击者用大量单字节分块突破队列上限
pub(super) struct ReceiveWindow {
@@ -0,0 +1,7 @@
//! RPC 会话内的响应流桥接与接收流量控制
mod bridge;
mod flow_control;
pub use bridge::RpcStream;
pub(super) use bridge::StreamIngress;
@@ -1,7 +0,0 @@
//! 插件 RPC 流桥接与接收流量控制的公共入口
mod bridge;
mod flow_control;
pub use bridge::RpcStream;
pub(crate) use bridge::StreamIngress;
@@ -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;
}
@@ -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;
}
@@ -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<Value> {
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<Value> {
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;
}
@@ -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())
@@ -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");
}
@@ -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<Value> {
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<Value> {
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;
}
@@ -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");
}
@@ -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
@@ -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;
}
@@ -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;
}
@@ -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::{
@@ -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;
@@ -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 分开表达;结束由流完成表示,不插入协议正文
@@ -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::{
@@ -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;
@@ -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,
@@ -2,6 +2,6 @@
mod frame;
mod http;
mod pending_read;
mod plugin;
mod read;
mod session;
+156
View File
@@ -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<gateway_core::error::ErrorSource>,
},
#[error("{entity} {id} was not found")]
NotFound {
entity: &'static str,
id: String,
source: Option<gateway_core::error::ErrorSource>,
},
#[error("store conflict for {entity} {id}: {kind:?}")]
Conflict {
entity: &'static str,
id: String,
kind: ConflictKind,
source: Option<gateway_core::error::ErrorSource>,
},
#[error("invalid persisted {entity}: {message}")]
InvalidData {
entity: &'static str,
message: String,
source: Option<gateway_core::error::ErrorSource>,
},
}
impl StoreError {
/// 本地校验可没有来源;转换已有失败时附加真实原因
#[must_use]
pub(crate) fn with_source(
mut self,
error: impl Into<gateway_core::error::ErrorSource>,
) -> 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<gateway_core::error::ErrorSource>,
) -> 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<T> = Result<T, StoreError>;
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)
}
+9 -3
View File
@@ -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};
@@ -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<String>,
) -> 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<Option<String>>;
+8 -179
View File
@@ -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<gateway_core::error::ErrorSource>,
},
#[error("{entity} {id} was not found")]
NotFound {
entity: &'static str,
id: String,
source: Option<gateway_core::error::ErrorSource>,
},
#[error("store conflict for {entity} {id}: {kind:?}")]
Conflict {
entity: &'static str,
id: String,
kind: ConflictKind,
source: Option<gateway_core::error::ErrorSource>,
},
#[error("invalid persisted {entity}: {message}")]
InvalidData {
entity: &'static str,
message: String,
source: Option<gateway_core::error::ErrorSource>,
},
}
impl StoreError {
/// 本地校验可没有来源;转换已有失败时附加真实原因
#[must_use]
pub(crate) fn with_source(
mut self,
error: impl Into<gateway_core::error::ErrorSource>,
) -> 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<gateway_core::error::ErrorSource>,
) -> 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<T> = Result<T, StoreError>;
pub(crate) fn store_revision(revision: AdminRevision) -> AdminStoreResult<Revision> {
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<AdminRevisi
})
}
pub(crate) fn mutation_audit(
context: &MutationContext,
operation: gateway_admin::model::audit::MutationAuditOperation,
entity_ref: &str,
changed_fields: Vec<String>,
) -> 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)
}
@@ -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,
};
@@ -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,
@@ -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,
@@ -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,
@@ -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";
@@ -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,
};
+3 -2
View File
@@ -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,
@@ -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 {
@@ -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<String>,
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<CodexWebSocketPool>) -> Self {
self.websocket_pool = Some(pool);
self
}
/// 附加当前 attempt 经 Core 复核的业务请求头
#[must_use]
pub(crate) fn with_middleware_headers(
mut self,
middleware_headers: Vec<gateway_core::engine::middleware::MiddlewareHeader>,
) -> 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<super::routing_affinity::ChatGptRoutingAffinity>,
@@ -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<CodexModelCatalogSnapshot> {
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())?
}
})
}
}
@@ -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<String>,
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<CodexWebSocketPool>) -> Self {
self.websocket_pool = Some(pool);
self
}
/// 附加当前 attempt 经 Core 复核的业务请求头
#[must_use]
pub(crate) fn with_middleware_headers(
mut self,
middleware_headers: Vec<gateway_core::engine::middleware::MiddlewareHeader>,
) -> 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<CodexModelCatalogSnapshot> {
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 {
@@ -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`
@@ -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;
+2 -2
View File
@@ -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);
@@ -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 {
@@ -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,
@@ -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,
+21 -16
View File
@@ -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,
@@ -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 {
/// 执行一次请求,不做隐式重试

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