Compare commits

...
14 Commits
Author SHA1 Message Date
ZyyandCodex fef52b3717 chore(release): v3.19.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-01 23:24:57 +08:00
ZyyandCodex 45e72682e6 refactor(store): persist warmup progress in runtime settings
Replace warmup slot history with a monotonic UTC cursor and consolidate unreleased scheduling migrations.

Co-authored-by: Codex <noreply@openai.com>
2026-10-01 23:21:48 +08:00
90e241f74b feat(openai): configure Guardian concurrency priority at runtime (#306)
Configure Guardian reservations through persisted runtime settings and frozen request policies. Combine concurrency controls and queues in the admin UI, with the scheduling strategy shown first.

Prioritize Guardian account waiters, recheck displaced queue heads before lease acquisition, and report request-specific available capacity. Preserve account, key and wait-budget limits.

Co-authored-by: Yongzhao Chen <yongzhao.derek@gmail.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-01 22:49:11 +08:00
ZyyandCodex a0e8f768dd fix(ci): align container timezone checks with UTC
Assert the UTC PostgreSQL timezone and runtime timezone configured by the deployment files. Keep the application business timezone independent from the infrastructure checks.

Co-authored-by: Codex <noreply@openai.com>
2026-10-01 21:40:27 +08:00
ZyyandCodex 318f8a748e fix(modal): preserve content through close transitions
Keep modal content and request state stable until the exit transition completes, and defer credential cleanup until after leave. Update the UI dependency and submodules to v0.3.2, including the plugin workbench consumer.

Co-authored-by: Codex <noreply@openai.com>
2026-10-01 21:40:22 +08:00
ZyyandCodex 0dcba14572 refactor(bootstrap): separate startup modes and plugin wiring
Co-authored-by: Codex <noreply@openai.com>
2026-10-01 16:00:07 +08:00
ZyyandCodex ab3a2e30c5 feat(time): unify deployment timezone across display and scheduling
Use one validated IANA timezone for backend display projections, calendar queries, budget windows, warmup slots, backups, and logs. Remove frontend date formatting and the independent backup timezone setting.

Preserve UTC instants and active budget windows, persist warmup deduplication, and advance backup schedules transactionally. Cover DST boundaries, timezone changes, invalid calendar anchors, and account cooldown displays.

Co-authored-by: Codex <noreply@openai.com>
2026-10-01 15:53:22 +08:00
ZyyandCodex 0490e28cfc feat(accounts): show Codex credit balances in quota reset dialog
Expose upstream credit facts through the account quota API while preserving
decimal balances and distinguishing zero, unlimited, and missing values.
Clear stale balances on passive updates without changing quota availability.

Display credits below reset cards, label OpenAI quota as Codex quota, and
standardize the account quota action icons.

Co-authored-by: Codex <noreply@openai.com>
2026-10-01 13:22:20 +08:00
ZyyandCodex 9c10676764 chore(release): v3.18.3
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 22:20:06 +08:00
ZyyandCodex caa6c8ffc8 refactor(plugin): keep external quota cycles out of host ledger (#332)
Restore the pre-window host implementation and document plugin-owned policy and presentation. Keep native reset RPC coverage and run plugin integration tests with the standard isolated test services.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 21:47:50 +08:00
9d651856be fix(outbound): avoid duplicate Host headers on HTTP/2 (#337)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 19:26:58 +08:00
ZyyandCodex d10b8890e0 fix(admin): improve quota forecast and subscription display
Move the quota forecast entry after reset credits and propagate account updates from the quota panel. Include each error account current subscription in troubleshooting and unify subscription labels.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 18:45:08 +08:00
ZyyandCodex d97c6e064b fix(keys): restart budget windows on use after reset (#335)
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 17:54:47 +08:00
ZyyandCodex f85801edb3 fix(usage): balance diagnostic column widths
Size diagnostic names by dimension and share available width across metric columns. Keep API key names compact.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 04:33:21 +08:00
285 changed files with 6057 additions and 2491 deletions
+4 -3
View File
@@ -117,8 +117,9 @@ runs:
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml)
"${compose[@]}" up -d --no-build --wait postgres redis
# 基础设施使用 UTC,业务展示和调度时区由应用配置独立控制。
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = Asia/Shanghai
psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = UTC
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
"CREATE TABLE cpr_ci_persistence_probe (value text PRIMARY KEY); INSERT INTO cpr_ci_persistence_probe VALUES ('persisted');"
@@ -148,8 +149,8 @@ runs:
test "$status" = 204
test "$("${compose[@]}" exec -T codex-proxy-rs id -u | tr -d '\r')" = 10001
test "$("${compose[@]}" exec -T codex-proxy-rs id -g | tr -d '\r')" = 10001
test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0800
test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = Asia/Shanghai
test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0000
test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = UTC
"${compose[@]}" exec -T codex-proxy-rs sh -c \
'touch /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write && rm /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write'
+2
View File
@@ -1731,6 +1731,7 @@ dependencies = [
"async-trait",
"base64 0.23.1",
"bytes",
"chrono",
"chrono-tz",
"futures",
"futures-timer",
@@ -1779,6 +1780,7 @@ dependencies = [
"serde",
"serde_ignored",
"serde_json",
"serde_path_to_error",
"sha2 0.11.0",
"tar",
"tempfile",
-459
View File
@@ -1,459 +0,0 @@
//! 网关唯一组合根:加载包级配置并连接各 Bundle。
use gateway_core::engine::provider::{ProviderRegistry, RegistryError};
use gateway_host::{ConfigError, HostConfig, LoadableConfig};
use serde::Deserialize;
const CONFIG_SCHEMA_VERSION: u32 = 1;
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。
/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验。
#[derive(Debug, Deserialize)]
pub struct GatewayConfig {
schema_version: u32,
host: HostConfig,
store: gateway_store::StoreConfig,
admin: gateway_admin::AdminConfig,
#[serde(default)]
client: gateway_admin::ClientConfig,
api: gateway_api::ApiConfig,
#[serde(default)]
openai: provider_openai::OpenAiConfig,
}
impl LoadableConfig for GatewayConfig {
const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"];
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
if self.schema_version != CONFIG_SCHEMA_VERSION {
return Err(ConfigError::InvalidField("schema_version"));
}
self.api
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.host
.resolve_and_validate(source_dir, &self.api.asset_directory)?;
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
self.store
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("store"))?;
self.admin
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("admin"))?;
self.client
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("client"))?;
self.openai
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
Ok(())
}
}
/// 按冻结顺序初始化全部 Bundle,并把进程阻塞权交给 Host。
pub async fn run() -> Result<(), BootstrapError> {
launch(None).await.map(|_| ())
}
/// 这里只接收插件命名空间内的参数,不转交宿主配置或其他启动 secret。
pub struct PluginCommand {
pub instance_id: Option<String>,
pub name: Option<String>,
pub arguments: Vec<String>,
}
impl PluginCommand {
fn is_help(&self) -> bool {
self.name.is_none()
|| self
.arguments
.iter()
.any(|value| matches!(value.as_str(), "--help" | "-h"))
}
}
pub async fn plugin_command(
command: PluginCommand,
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
launch(Some(command)).await
}
async fn launch(
command: Option<PluginCommand>,
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
macro_rules! or_shutdown {
($runtime:expr, $result:expr) => {
match $result {
Ok(value) => value,
Err(error) => {
$runtime.shutdown().await;
return Err(error.into());
}
}
};
}
let config = gateway_host::load_config::<GatewayConfig>()?;
let GatewayConfig {
schema_version: _,
host,
store,
admin,
client,
api,
openai,
} = config;
let plugin_cache = host.runtime_data_dir().join("plugins");
let host = if command.is_some() {
gateway_host::initialize_command_line(host).await?
} else {
gateway_host::initialize(host).await?
};
host.report_startup_ready("Host");
let help = command.as_ref().is_some_and(PluginCommand::is_help);
let mut store = if help {
gateway_store::initialize_read_only(store).await?
} else if command.is_some() {
gateway_store::initialize_command_line(store).await?
} else {
gateway_store::initialize(store).await?
};
host.report_startup_ready("Store");
let version = host
.system_operations()
.version()
.await
.map_err(|_| ConfigError::InvalidField("host.version"))?
.version;
let plugin_http = std::sync::Arc::new(gateway_host::outbound::HttpClient::new()?);
if let Some(command) = command.as_ref().filter(|_| help) {
let runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
let commands = or_shutdown!(runtime, runtime.prepare_command_line().await);
let stdout = commands.help(command.instance_id.as_deref(), command.name.as_deref());
commands.shutdown().await;
runtime.shutdown().await;
return Ok(gateway_plugin_runtime::PluginCommandOutput {
stdout: stdout?,
stderr: String::new(),
exit_code: 0,
saved_accounts: 0,
});
}
let provider_ports = store.provider_ports();
let mut openai = provider_openai::initialize(openai, provider_ports.clone()).await?;
host.report_startup_ready("OpenAI Provider");
let mut xai = provider_xai::initialize(provider_ports).await?;
host.report_startup_ready("xAI Provider");
let providers = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
let plugin_runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
let admin_providers = gateway_admin::ports::provider::ProviderAdminRegistry::new([
openai.admin_provider(),
xai.admin_provider(),
])
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?;
if let Some(command) = command {
let instance_id = or_shutdown!(
plugin_runtime,
command
.instance_id
.as_deref()
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件实例 ID 缺失"))
);
let name = or_shutdown!(
plugin_runtime,
command
.name
.as_deref()
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件命令名缺失"))
);
let core = gateway_core::prepare_command_plane(
store.core_ports(),
providers.clone(),
Some(plugin_runtime.clone()),
Some(plugin_runtime.observer_registry()),
Some(plugin_runtime.policy_registry()),
Some(plugin_runtime.middleware_registry()),
);
let accounts = gateway_admin::initialize_plugin_accounts(
admin_providers.clone(),
store.admin_ports().accounts(),
core.snapshot_control(),
);
or_shutdown!(plugin_runtime, plugin_runtime.bind_account_ports(&accounts));
let keys = gateway_admin::initialize_plugin_client_keys(
admin_providers.clone(),
store.admin_ports().client_keys(),
core.snapshot_control(),
);
or_shutdown!(plugin_runtime, plugin_runtime.bind_client_key_ports(&keys));
let plugin_resources = gateway_admin::initialize_plugin_resources(
store.admin_ports().plugin_resources(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_resource_ports(&plugin_resources)
);
let core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
);
let settings = gateway_admin::initialize_settings(
store.admin_ports().settings(),
core.snapshot_control(),
admin_providers,
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
);
let mut services = gateway_admin::service::Registry::new({
let snapshots = core.snapshots();
let middleware = plugin_runtime.middleware_registry();
std::sync::Arc::new(move || {
let snapshot = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(snapshot.extensions()?)
})
});
or_shutdown!(plugin_runtime, services.register_settings(&settings));
let services = std::sync::Arc::new(services);
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
or_shutdown!(plugin_runtime, store.start_command_line_writes());
let result = commands
.execute_cancellable(instance_id, name, &command.arguments, &host.cancellation())
.await;
commands.shutdown().await;
plugin_runtime.shutdown().await;
store.shutdown_command_line_writes().await?;
return Ok(result?);
}
let core = gateway_core::prepare(
store.core_ports(),
providers,
Some(plugin_runtime.clone()),
Some(plugin_runtime.observer_registry()),
Some(plugin_runtime.policy_registry()),
Some(plugin_runtime.middleware_registry()),
Some(plugin_runtime.frontend_authentication_registry()),
);
let plugin_accounts = gateway_admin::initialize_plugin_accounts(
admin_providers.clone(),
store.admin_ports().accounts(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_account_ports(&plugin_accounts)
);
let plugin_keys = gateway_admin::initialize_plugin_client_keys(
admin_providers.clone(),
store.admin_ports().client_keys(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_client_key_ports(&plugin_keys)
);
let plugin_resources = gateway_admin::initialize_plugin_resources(
store.admin_ports().plugin_resources(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_resource_ports(&plugin_resources)
);
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
);
host.report_startup_ready("Core");
let host_version = or_shutdown!(
plugin_runtime,
version
.trim_start_matches('v')
.parse()
.map_err(|_| ConfigError::InvalidField("host.version"))
);
let plugin_inspector = std::sync::Arc::new(gateway_plugin_runtime::PackageInspector::new(
gateway_plugin_runtime::PackageLimits::default(),
host_version,
));
let plugin_distribution = std::sync::Arc::new(or_shutdown!(
plugin_runtime,
gateway_host::plugin_distribution::HttpPluginDistribution::new(plugin_http)
));
let mut admin = or_shutdown!(
plugin_runtime,
gateway_admin::initialize_with_plugin_accounts(
admin,
client,
store.admin_ports(),
gateway_admin::AdminRuntimePorts {
service_middleware: {
let snapshots = core.snapshots();
let middleware = plugin_runtime.middleware_registry();
std::sync::Arc::new(move || {
let snapshot = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(snapshot.extensions()?)
})
},
providers: admin_providers,
plugin_preparation: plugin_runtime.clone(),
plugin_management: plugin_runtime.clone(),
published_snapshot: core.snapshots(),
plugin_inspector,
plugin_distribution,
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
snapshot: core.snapshot_control(),
account_probe: core.account_probe(),
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
client_distribution: host.client_distribution_resolver(),
system: host.system_operations(),
client_key_verifier: core.client_key_verifier(),
},
plugin_accounts,
)
.await
);
let official_files = host.official_plugin_release_files();
let official_identity = host.official_plugin_release_identity();
let official_import = or_shutdown!(
plugin_runtime,
admin
.services()
.plugins()
.import_official_release(
official_files.as_ref(),
&official_identity,
&gateway_admin::model::MutationContext {
actor: gateway_admin::model::MutationActor::System,
request_id: "startup-official-plugin-import".to_owned(),
},
)
.await
);
if official_import.artifacts > 0 {
host.report_startup_ready("Official plugins");
}
host.report_startup_ready("Admin");
let mut probes = store.health_probes();
probes.extend(core.health_probes());
probes.push(host.logging_health_probe());
let api = or_shutdown!(
plugin_runtime,
gateway_api::initialize(
api,
core.execution_service(),
admin.services(),
probes,
host.worker_health(),
host.connection_lifecycle(),
)
)
.with_middleware({
let snapshots = core.snapshots();
let middleware = plugin_runtime.middleware_registry();
move |snapshot| {
if let Some(snapshot) = snapshot {
return middleware.resolve(snapshot.extensions()?);
}
// 数据面未就绪时,管理与诊断路由仍可使用发布候选中的插件。
let diagnostic = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(diagnostic.extensions()?)
}
});
let http_dispatcher = api.dispatcher();
or_shutdown!(plugin_runtime, plugin_runtime.bind_http(&http_dispatcher));
host.report_startup_ready("API");
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_services(&admin.services().public_services())
);
let mut plan = store.take_worker_contributions();
plan.push(gateway_host::retention::worker(store.retention())?);
plan.extend(core.take_worker_contributions());
plan.extend(openai.take_worker_contributions());
plan.extend(xai.take_worker_contributions());
plan.extend(admin.take_worker_contributions());
plan.push(plugin_runtime.maintenance_worker(core.snapshots())?);
or_shutdown!(
plugin_runtime,
host.start_workers(plan, store.worker_leader_lease())
);
host.report_startup_ready("Workers");
let served = host.serve(api.router()).await;
plugin_runtime.shutdown().await;
served?;
Ok(gateway_plugin_runtime::PluginCommandOutput {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
saved_accounts: 0,
})
}
fn prepare_plugins(
store: &gateway_store::StoreBundle,
plugin_cache: std::path::PathBuf,
version: &str,
http: std::sync::Arc<gateway_host::outbound::HttpClient>,
) -> Result<std::sync::Arc<gateway_plugin_runtime::PluginRuntime>, BootstrapError> {
Ok(std::sync::Arc::new(
gateway_plugin_runtime::PluginRuntime::new(
store.admin_ports().plugins(),
store.admin_ports().plugin_state(),
gateway_plugin_runtime::PluginRuntimeConfig {
cache_directory: plugin_cache,
host_version: version
.trim_start_matches('v')
.parse()
.map_err(|_| ConfigError::InvalidField("host.version"))?,
package_limits: gateway_plugin_runtime::PackageLimits::default(),
rpc_limits: gateway_plugin_runtime::RpcLimits::default(),
restart_circuit: Default::default(),
},
http,
std::sync::Arc::new(gateway_host::process::ProcessSupervisor::default()),
)
.with_oauth_pending(store.provider_ports().oauth_pending()),
))
}
/// 组合根只保留包级错误分类,不展开内部实现或敏感配置。
#[derive(Debug, thiserror::Error)]
pub enum BootstrapError {
#[error(transparent)]
Worker(#[from] gateway_core::task::WorkerDefinitionError),
#[error(transparent)]
PluginCommand(#[from] gateway_plugin_runtime::PluginCommandError),
#[error(transparent)]
Config(#[from] gateway_host::ConfigError),
#[error(transparent)]
Host(#[from] gateway_host::HostError),
#[error(transparent)]
Outbound(#[from] gateway_host::outbound::HttpError),
#[error(transparent)]
Store(#[from] gateway_store::StoreError),
#[error(transparent)]
CommandStoreDrain(#[from] gateway_store::CommandStoreDrainError),
#[error(transparent)]
OpenAi(#[from] provider_openai::OpenAiInitializeError),
#[error(transparent)]
Xai(#[from] provider_xai::XaiInitializeError),
#[error(transparent)]
Registry(#[from] RegistryError),
#[error(transparent)]
Core(#[from] gateway_core::CoreError),
#[error(transparent)]
Admin(#[from] gateway_admin::model::AdminError),
#[error(transparent)]
Api(#[from] gateway_api::ApiError),
}
@@ -0,0 +1,134 @@
//! 插件帮助只读启动;命令仅组装命令平面和必要写泵。
use super::{
BootstrapError, GatewayConfig,
plugins::{ManagementPorts, or_shutdown, prepare_plugins, service_middleware},
startup::{Environment, Mode, Providers},
};
/// 这里只接收插件命名空间内的参数,不转交宿主配置或其他启动 secret。
pub struct PluginCommand {
pub instance_id: Option<String>,
pub name: Option<String>,
pub arguments: Vec<String>,
}
impl PluginCommand {
fn is_help(&self) -> bool {
self.name.is_none()
|| self
.arguments
.iter()
.any(|value| matches!(value.as_str(), "--help" | "-h"))
}
}
pub async fn plugin_command(
command: PluginCommand,
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
let config = gateway_host::load_config::<GatewayConfig>()?;
if command.is_help() {
let environment = Environment::initialize(config.host, config.store, Mode::Help).await?;
help(command, environment).await
} else {
let environment = Environment::initialize(config.host, config.store, Mode::Command).await?;
let providers = Providers::initialize(config.openai, &environment).await?;
execute(command, environment, providers).await
}
}
async fn help(
command: PluginCommand,
environment: Environment,
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
let runtime = prepare_plugins(
&environment.store,
environment.plugin_cache,
&environment.version,
environment.plugin_http,
)?;
let commands = or_shutdown!(runtime, runtime.prepare_command_line().await);
let stdout = commands.help(command.instance_id.as_deref(), command.name.as_deref());
commands.shutdown().await;
runtime.shutdown().await;
Ok(gateway_plugin_runtime::PluginCommandOutput {
stdout: stdout?,
stderr: String::new(),
exit_code: 0,
saved_accounts: 0,
})
}
async fn execute(
command: PluginCommand,
environment: Environment,
providers: Providers,
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
let Environment {
host,
mut store,
plugin_cache,
version,
plugin_http,
..
} = environment;
let plugin_runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http)?;
let instance_id = or_shutdown!(
plugin_runtime,
command
.instance_id
.as_deref()
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件实例 ID 缺失"))
);
let name = or_shutdown!(
plugin_runtime,
command
.name
.as_deref()
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件命令名缺失"))
);
let core = gateway_core::prepare_command_plane(
store.core_ports(),
providers.core,
Some(plugin_runtime.clone()),
Some(plugin_runtime.observer_registry()),
Some(plugin_runtime.policy_registry()),
Some(plugin_runtime.middleware_registry()),
);
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
let core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
);
let settings = gateway_admin::initialize_settings(
store.admin_ports().settings(),
core.snapshot_control(),
providers.admin,
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
);
let mut services = gateway_admin::service::Registry::new(service_middleware(
core.snapshots(),
&plugin_runtime,
));
or_shutdown!(plugin_runtime, services.register_settings(&settings));
let services = std::sync::Arc::new(services);
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
let result = async {
store.start_command_line_writes()?;
Ok::<_, BootstrapError>(
commands
.execute_cancellable(instance_id, name, &command.arguments, &host.cancellation())
.await?,
)
}
.await;
commands.shutdown().await;
plugin_runtime.shutdown().await;
store.shutdown_command_line_writes().await?;
result
}
@@ -0,0 +1,48 @@
use gateway_host::{ConfigError, HostConfig, LoadableConfig};
use serde::Deserialize;
const CONFIG_SCHEMA_VERSION: u32 = 1;
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。
/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验。
#[derive(Debug, Deserialize)]
pub struct GatewayConfig {
schema_version: u32,
pub(super) host: HostConfig,
pub(super) store: gateway_store::StoreConfig,
pub(super) admin: gateway_admin::AdminConfig,
#[serde(default)]
pub(super) client: gateway_admin::ClientConfig,
pub(super) api: gateway_api::ApiConfig,
#[serde(default)]
pub(super) openai: provider_openai::OpenAiConfig,
}
impl LoadableConfig for GatewayConfig {
const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"];
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
if self.schema_version != CONFIG_SCHEMA_VERSION {
return Err(ConfigError::InvalidField("schema_version"));
}
self.api
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.host
.resolve_and_validate(source_dir, &self.api.asset_directory)?;
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
self.store
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("store"))?;
self.admin
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("admin"))?;
self.client
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("client"))?;
self.openai
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
Ok(())
}
}
+42
View File
@@ -0,0 +1,42 @@
//! 网关唯一组合根:按启动模式连接各 Bundle。
mod command;
mod config;
mod plugins;
mod server;
mod startup;
pub use command::{PluginCommand, plugin_command};
pub use config::GatewayConfig;
pub use server::run;
/// 组合根只保留包级错误分类,不展开内部实现或敏感配置。
#[derive(Debug, thiserror::Error)]
pub enum BootstrapError {
#[error(transparent)]
Worker(#[from] gateway_core::task::WorkerDefinitionError),
#[error(transparent)]
PluginCommand(#[from] gateway_plugin_runtime::PluginCommandError),
#[error(transparent)]
Config(#[from] gateway_host::ConfigError),
#[error(transparent)]
Host(#[from] gateway_host::HostError),
#[error(transparent)]
Outbound(#[from] gateway_host::outbound::HttpError),
#[error(transparent)]
Store(#[from] gateway_store::StoreError),
#[error(transparent)]
CommandStoreDrain(#[from] gateway_store::CommandStoreDrainError),
#[error(transparent)]
OpenAi(#[from] provider_openai::OpenAiInitializeError),
#[error(transparent)]
Xai(#[from] provider_xai::XaiInitializeError),
#[error(transparent)]
Registry(#[from] gateway_core::engine::provider::RegistryError),
#[error(transparent)]
Core(#[from] gateway_core::CoreError),
#[error(transparent)]
Admin(#[from] gateway_admin::model::AdminError),
#[error(transparent)]
Api(#[from] gateway_api::ApiError),
}
@@ -0,0 +1,108 @@
//! 插件 Runtime 的组装与回调资源持有;服务和命令模式复用同一组管理端口。
use std::sync::Arc;
use gateway_admin::{
model::AdminError,
ports::{
plugin_accounts::PluginAccountAccess, plugin_client_keys::PluginClientKeyAccess,
plugin_resources::PluginResourceAccess, provider::ProviderAdminRegistry,
},
};
use gateway_core::runtime::{RuntimeSnapshotHandle, SnapshotControl};
use gateway_host::ConfigError;
use gateway_plugin_runtime::PluginRuntime;
use super::BootstrapError;
// 必须在调用方仍持有 Core 和回调资源时异步关闭,不能先退出该作用域再清理。
macro_rules! or_shutdown {
($runtime:expr, $result:expr) => {
match $result {
Ok(value) => value,
Err(error) => {
$runtime.shutdown().await;
return Err(error.into());
}
}
};
}
pub(super) use or_shutdown;
pub(super) struct ManagementPorts {
// Runtime 保存 Weak;组合根持有这些 Arc,直到插件全部关闭。
pub(super) accounts: Arc<dyn PluginAccountAccess>,
keys: Arc<dyn PluginClientKeyAccess>,
resources: Arc<dyn PluginResourceAccess>,
}
impl ManagementPorts {
pub(super) fn new(
store: &gateway_store::StoreBundle,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
let ports = store.admin_ports();
Self {
accounts: gateway_admin::initialize_plugin_accounts(
providers.clone(),
ports.accounts(),
snapshot.clone(),
),
keys: gateway_admin::initialize_plugin_client_keys(
providers,
ports.client_keys(),
snapshot.clone(),
),
resources: gateway_admin::initialize_plugin_resources(
ports.plugin_resources(),
snapshot,
),
}
}
pub(super) fn bind(&self, runtime: &PluginRuntime) -> Result<(), AdminError> {
runtime.bind_account_ports(&self.accounts)?;
runtime.bind_client_key_ports(&self.keys)?;
runtime.bind_resource_ports(&self.resources)
}
}
pub(super) fn service_middleware(
snapshots: RuntimeSnapshotHandle,
runtime: &PluginRuntime,
) -> gateway_admin::service::PlanSource {
let middleware = runtime.middleware_registry();
Arc::new(move || {
let snapshot = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(snapshot.extensions()?)
})
}
pub(super) fn prepare_plugins(
store: &gateway_store::StoreBundle,
plugin_cache: std::path::PathBuf,
version: &str,
http: Arc<gateway_host::outbound::HttpClient>,
) -> Result<Arc<gateway_plugin_runtime::PluginRuntime>, BootstrapError> {
Ok(Arc::new(
gateway_plugin_runtime::PluginRuntime::new(
store.admin_ports().plugins(),
store.admin_ports().plugin_state(),
gateway_plugin_runtime::PluginRuntimeConfig {
cache_directory: plugin_cache,
host_version: version
.trim_start_matches('v')
.parse()
.map_err(|_| ConfigError::InvalidField("host.version"))?,
package_limits: gateway_plugin_runtime::PackageLimits::default(),
rpc_limits: gateway_plugin_runtime::RpcLimits::default(),
restart_circuit: Default::default(),
},
http,
Arc::new(gateway_host::process::ProcessSupervisor::default()),
)
.with_oauth_pending(store.provider_ports().oauth_pending()),
))
}
@@ -0,0 +1,164 @@
//! 服务模式完成全部组装后交给 Host 监听,并在请求和 Worker 排空后关闭插件。
use gateway_host::ConfigError;
use super::{
BootstrapError, GatewayConfig,
plugins::{ManagementPorts, or_shutdown, prepare_plugins, service_middleware},
startup::{Environment, Mode, Providers},
};
/// 按冻结顺序初始化全部 Bundle,并把进程阻塞权交给 Host。
pub async fn run() -> Result<(), BootstrapError> {
let config = gateway_host::load_config::<GatewayConfig>()?;
let environment = Environment::initialize(config.host, config.store, Mode::Server).await?;
let mut providers = Providers::initialize(config.openai, &environment).await?;
let Environment {
host,
mut store,
timezone,
plugin_cache,
version,
plugin_http,
} = environment;
let plugin_runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
let core = gateway_core::prepare(
store.core_ports(),
providers.core,
Some(plugin_runtime.clone()),
Some(plugin_runtime.observer_registry()),
Some(plugin_runtime.policy_registry()),
Some(plugin_runtime.middleware_registry()),
Some(plugin_runtime.frontend_authentication_registry()),
);
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
);
host.report_startup_ready("Core");
let host_version = or_shutdown!(
plugin_runtime,
version
.trim_start_matches('v')
.parse()
.map_err(|_| ConfigError::InvalidField("host.version"))
);
let plugin_inspector = std::sync::Arc::new(gateway_plugin_runtime::PackageInspector::new(
gateway_plugin_runtime::PackageLimits::default(),
host_version,
));
let plugin_distribution = std::sync::Arc::new(or_shutdown!(
plugin_runtime,
gateway_host::plugin_distribution::HttpPluginDistribution::new(plugin_http)
));
let mut admin = or_shutdown!(
plugin_runtime,
gateway_admin::initialize_with_plugin_accounts(
config.admin,
config.client,
store.admin_ports(),
gateway_admin::AdminRuntimePorts {
timezone,
service_middleware: service_middleware(core.snapshots(), &plugin_runtime),
providers: providers.admin,
plugin_preparation: plugin_runtime.clone(),
plugin_management: plugin_runtime.clone(),
published_snapshot: core.snapshots(),
plugin_inspector,
plugin_distribution,
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
snapshot: core.snapshot_control(),
account_probe: core.account_probe(),
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
client_distribution: host.client_distribution_resolver(),
system: host.system_operations(),
client_key_verifier: core.client_key_verifier(),
},
management.accounts.clone(),
)
.await
);
let official_files = host.official_plugin_release_files();
let official_identity = host.official_plugin_release_identity();
let official_import = or_shutdown!(
plugin_runtime,
admin
.services()
.plugins()
.import_official_release(
official_files.as_ref(),
&official_identity,
&gateway_admin::model::MutationContext {
actor: gateway_admin::model::MutationActor::System,
request_id: "startup-official-plugin-import".to_owned(),
},
)
.await
);
if official_import.artifacts > 0 {
host.report_startup_ready("Official plugins");
}
host.report_startup_ready("Admin");
let mut probes = store.health_probes();
probes.extend(core.health_probes());
probes.push(host.logging_health_probe());
let api = or_shutdown!(
plugin_runtime,
gateway_api::initialize(
config.api,
core.execution_service(),
admin.services(),
probes,
host.worker_health(),
host.connection_lifecycle(),
)
)
.with_middleware({
let snapshots = core.snapshots();
let middleware = plugin_runtime.middleware_registry();
move |snapshot| {
if let Some(snapshot) = snapshot {
return middleware.resolve(snapshot.extensions()?);
}
// 数据面未就绪时,管理与诊断路由仍可使用发布候选中的插件。
let diagnostic = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(diagnostic.extensions()?)
}
});
let http_dispatcher = api.dispatcher();
or_shutdown!(plugin_runtime, plugin_runtime.bind_http(&http_dispatcher));
host.report_startup_ready("API");
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_services(&admin.services().public_services())
);
let mut plan = store.take_worker_contributions();
plan.push(or_shutdown!(
plugin_runtime,
gateway_host::retention::worker(store.retention())
));
plan.extend(core.take_worker_contributions());
plan.extend(providers.openai.take_worker_contributions());
plan.extend(providers.xai.take_worker_contributions());
plan.extend(admin.take_worker_contributions());
plan.push(or_shutdown!(
plugin_runtime,
plugin_runtime.maintenance_worker(core.snapshots())
));
or_shutdown!(
plugin_runtime,
host.start_workers(plan, store.worker_leader_lease())
);
host.report_startup_ready("Workers");
let served = host.serve(api.router()).await;
plugin_runtime.shutdown().await;
Ok(served?)
}
@@ -0,0 +1,94 @@
//! 启动模式共享的基础设施与 Provider 组装,不启动 HTTP 或 Worker。
use std::{path::PathBuf, sync::Arc};
use gateway_admin::ports::provider::ProviderAdminRegistry;
use gateway_core::{engine::provider::ProviderRegistry, time::DeploymentTimeZone};
use gateway_host::{ConfigError, HostBundle, HostConfig, outbound::HttpClient};
use gateway_store::{StoreBundle, StoreConfig};
use super::BootstrapError;
pub(super) enum Mode {
Server,
Command,
Help,
}
pub(super) struct Environment {
pub(super) host: HostBundle,
pub(super) store: StoreBundle,
pub(super) timezone: DeploymentTimeZone,
pub(super) plugin_cache: PathBuf,
pub(super) version: String,
pub(super) plugin_http: Arc<HttpClient>,
}
impl Environment {
pub(super) async fn initialize(
host: HostConfig,
store: StoreConfig,
mode: Mode,
) -> Result<Self, BootstrapError> {
let timezone = host.timezone;
let store = store.with_timezone(timezone);
let plugin_cache = host.runtime_data_dir().join("plugins");
let host = match mode {
Mode::Server => gateway_host::initialize(host).await?,
Mode::Command | Mode::Help => gateway_host::initialize_command_line(host).await?,
};
host.report_startup_ready("Host");
let store = match mode {
Mode::Server => gateway_store::initialize(store).await?,
Mode::Command => gateway_store::initialize_command_line(store).await?,
Mode::Help => gateway_store::initialize_read_only(store).await?,
};
host.report_startup_ready("Store");
let version = host
.system_operations()
.version()
.await
.map_err(|_| ConfigError::InvalidField("host.version"))?
.version;
let plugin_http = Arc::new(HttpClient::new()?);
Ok(Self {
host,
store,
timezone,
plugin_cache,
version,
plugin_http,
})
}
}
pub(super) struct Providers {
pub(super) openai: provider_openai::ProviderBundle,
pub(super) xai: provider_xai::ProviderBundle,
pub(super) core: ProviderRegistry,
pub(super) admin: ProviderAdminRegistry,
}
impl Providers {
pub(super) async fn initialize(
config: provider_openai::OpenAiConfig,
environment: &Environment,
) -> Result<Self, BootstrapError> {
let ports = environment.store.provider_ports();
let openai =
provider_openai::initialize(config.with_timezone(environment.timezone), ports.clone())
.await?;
environment.host.report_startup_ready("OpenAI Provider");
let xai = provider_xai::initialize(ports).await?;
environment.host.report_startup_ready("xAI Provider");
let core = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
let admin = ProviderAdminRegistry::new([openai.admin_provider(), xai.admin_provider()])
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?;
Ok(Self {
openai,
xai,
core,
admin,
})
}
}
@@ -0,0 +1,49 @@
use std::{fs, process::Command};
#[test]
fn basic_cli_options_do_not_load_configuration_and_reject_unexpected_arguments() {
let directory = tempfile::tempdir().unwrap();
fs::create_dir(directory.path().join("deploy")).unwrap();
fs::write(directory.path().join("deploy/config.yaml"), "invalid: [").unwrap();
for flag in ["--help", "-h", "help", "--version", "-V"] {
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.arg(flag)
.current_dir(directory.path())
.env_clear()
.output()
.unwrap();
assert!(output.status.success(), "{flag}");
assert!(!output.stdout.is_empty());
assert!(output.stderr.is_empty());
}
for args in [
vec!["unknown"],
vec!["--help", "extra"],
vec!["serve", "extra"],
] {
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.args(args)
.current_dir(directory.path())
.env_clear()
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(!String::from_utf8_lossy(&output.stderr).contains("configuration"));
}
}
#[cfg(unix)]
#[test]
fn non_utf8_command_is_not_interpreted_as_serve() {
use std::os::unix::ffi::OsStringExt as _;
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.arg(std::ffi::OsString::from_vec(vec![0xff]))
.env_clear()
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("command must be UTF-8"));
}
@@ -3,205 +3,12 @@ use std::{fs, process::Command};
use codex_proxy_rs::bootstrap::GatewayConfig;
use gateway_host::LoadableConfig;
const CONFIG_EXAMPLE: &str = include_str!("../../../../deploy/config.example.yaml");
const CONFIG_EXAMPLE: &str = include_str!("../../../../../deploy/config.example.yaml");
const POSTGRES_PASSWORD: &str = "111111111111111111111111111111111111111111111111";
const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222";
const ADMIN_PASSWORD: &str = "test-admin-password";
const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD";
#[test]
fn basic_cli_options_do_not_load_configuration_and_reject_unexpected_arguments() {
let directory = tempfile::tempdir().unwrap();
fs::create_dir(directory.path().join("deploy")).unwrap();
fs::write(directory.path().join("deploy/config.yaml"), "invalid: [").unwrap();
for flag in ["--help", "-h", "help", "--version", "-V"] {
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.arg(flag)
.current_dir(directory.path())
.env_clear()
.output()
.unwrap();
assert!(output.status.success(), "{flag}");
assert!(!output.stdout.is_empty());
assert!(output.stderr.is_empty());
}
for args in [
vec!["unknown"],
vec!["--help", "extra"],
vec!["serve", "extra"],
] {
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.args(args)
.current_dir(directory.path())
.env_clear()
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(!String::from_utf8_lossy(&output.stderr).contains("configuration"));
}
}
#[cfg(unix)]
#[test]
fn non_utf8_command_is_not_interpreted_as_serve() {
use std::os::unix::ffi::OsStringExt as _;
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.arg(std::ffi::OsString::from_vec(vec![0xff]))
.env_clear()
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("command must be UTF-8"));
}
#[tokio::test]
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
use gateway_admin::ports::proxy::ProxyProbe;
use gateway_core::account::OutboundProxy;
use gateway_host::proxy_probe::HttpProxyProbe;
use std::{sync::Arc, time::Duration};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio_rustls::{
TlsAcceptor,
rustls::{
ServerConfig,
pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
},
};
const CHILD_ENV: &str = "CPR_TEST_PROXY_TLS_DIRECTORY";
let Ok(directory) = std::env::var(CHILD_ENV) else {
let directory = tempfile::tempdir().unwrap();
generate_proxy_test_certificates(directory.path());
// 使用子进程隔离环境变量,避免并行测试读取到临时 CA 配置。
for ca_env in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
let mut child = Command::new(std::env::current_exe().unwrap());
child
.args([
"--exact",
"bootstrap::proxy_probe_should_use_provider_custom_ca_for_https_proxies",
"--nocapture",
])
.env(CHILD_ENV, directory.path())
.env_remove("CODEX_CA_CERTIFICATE")
.env_remove("SSL_CERT_FILE")
.env(ca_env, directory.path().join("ca.pem"));
if ca_env == "CODEX_CA_CERTIFICATE" {
child.env(
"SSL_CERT_FILE",
directory.path().join("missing-fallback.pem"),
);
}
let output = child.output().unwrap();
assert!(
output.status.success(),
"{ca_env}: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
return;
};
provider_openai::ensure_rustls_provider();
let directory = std::path::Path::new(&directory);
let certificate = CertificateDer::from_pem_file(directory.join("server.pem")).unwrap();
let key = PrivateKeyDer::from_pem_file(directory.join("server.key")).unwrap();
let acceptor = TlsAcceptor::from(Arc::new(
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![certificate], key)
.unwrap(),
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy =
OutboundProxy::parse(&format!("https://{}", listener.local_addr().unwrap())).unwrap();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut stream = acceptor.accept(socket).await.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
assert!(request.len() < 8192);
request.push(stream.read_u8().await.unwrap());
}
assert!(request.starts_with(b"GET http://unresolvable.invalid/ip "));
let body = "{\"ip\":\"203.0.113.8\"}";
stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
stream.shutdown().await.unwrap();
});
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
let result = probe.test(&proxy, false).await;
assert!(result.success, "{}", result.message);
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
tokio::time::timeout(Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
fn generate_proxy_test_certificates(directory: &std::path::Path) {
let openssl = |args: &[&str]| {
let output = Command::new("openssl")
.args(args)
.current_dir(directory)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
openssl(&[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
"ca.key",
"-out",
"ca.pem",
"-days",
"1",
"-subj",
"/CN=Proxy Test CA",
]);
openssl(&[
"req",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
"server.key",
"-out",
"server.csr",
"-subj",
"/CN=localhost",
]);
fs::write(directory.join("extensions"), "subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n").unwrap();
openssl(&[
"x509",
"-req",
"-in",
"server.csr",
"-CA",
"ca.pem",
"-CAkey",
"ca.key",
"-CAcreateserial",
"-out",
"server.pem",
"-days",
"1",
"-extfile",
"extensions",
]);
}
#[test]
fn config_loader_should_load_complete_terminal_example() {
parse_config(&valid_config()).expect("terminal config example");
@@ -230,7 +37,7 @@ fn config_loader_should_share_resolved_assets_with_system_update() {
child
.args([
"--exact",
"bootstrap::config_loader_should_share_resolved_assets_with_system_update",
"bootstrap::config::config_loader_should_share_resolved_assets_with_system_update",
])
.env(CHILD_ENV, case)
.env_remove("CPR_WEB_DIST_DIR");
@@ -292,7 +99,7 @@ fn config_loader_should_apply_only_explicit_topology_overrides() {
let status = Command::new(std::env::current_exe().expect("current test executable"))
.args([
"--exact",
"bootstrap::config_loader_should_apply_only_explicit_topology_overrides",
"bootstrap::config::config_loader_should_apply_only_explicit_topology_overrides",
])
.env(TOPOLOGY_CHILD_ENV, "1")
.env("CPR_SERVER_HOST", "127.0.0.1")
@@ -374,7 +181,15 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
gateway_host::load_config::<GatewayConfig>().expect("startup configuration");
return;
}
for case in ["normal", "unused", "missing"] {
for case in [
"normal",
"unused",
"missing",
"timezoneEmpty",
"timezoneInvalid",
"timezoneNull",
"timezoneType",
] {
let mut document = valid_config_document();
if case == "unused" {
document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET);
@@ -384,6 +199,14 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
.unwrap()
.remove("port");
}
if case.starts_with("timezone") {
document["host"]["timezone"] = match case {
"timezoneEmpty" => serde_json::json!(""),
"timezoneInvalid" => serde_json::json!(UNUSED_SECRET),
"timezoneNull" => serde_json::json!(null),
_ => serde_json::json!(8),
};
}
let directory = tempfile::tempdir().unwrap();
fs::create_dir(directory.path().join("deploy")).unwrap();
fs::write(
@@ -394,7 +217,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
let output = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"bootstrap::config_loader_should_report_startup_configuration_diagnostics",
"bootstrap::config::config_loader_should_report_startup_configuration_diagnostics",
"--nocapture",
])
.env(CHILD_ENV, "1")
@@ -404,7 +227,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.success(),
case != "missing",
case != "missing" && !case.starts_with("timezone"),
"{case}: {stderr}"
);
match case {
@@ -413,6 +236,9 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
"{stderr}"
),
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
case if case.starts_with("timezone") => {
assert!(stderr.contains("host.timezone"), "{stderr}")
}
_ => assert!(!stderr.contains("警告"), "{stderr}"),
}
assert!(!stderr.contains(UNUSED_SECRET), "{stderr}");
@@ -580,3 +406,18 @@ fn parse_config(config: &str) -> Result<(GatewayConfig, tempfile::TempDir), Stri
.map_err(|error| error.to_string())?;
Ok((config, directory))
}
#[test]
fn deployment_timezone_defaults_and_valid_override_are_loaded() {
let mut document = valid_config_document();
document["host"].as_object_mut().unwrap().remove("timezone");
let (_config, _directory) = parse_config(&document.to_string()).unwrap();
let host: gateway_host::config::HostConfig =
serde_json::from_value(document["host"].clone()).unwrap();
assert_eq!(host.timezone.name(), "Asia/Shanghai");
document["host"]["timezone"] = serde_json::json!("Asia/Kathmandu");
let (_config, _directory) = parse_config(&document.to_string()).unwrap();
let host: gateway_host::config::HostConfig =
serde_json::from_value(document["host"].clone()).unwrap();
assert_eq!(host.timezone.name(), "Asia/Kathmandu");
}
@@ -0,0 +1,3 @@
mod command;
mod config;
mod server;
@@ -0,0 +1,146 @@
use std::{fs, process::Command};
#[tokio::test]
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
use gateway_admin::ports::proxy::ProxyProbe;
use gateway_core::account::OutboundProxy;
use gateway_host::proxy_probe::HttpProxyProbe;
use std::{sync::Arc, time::Duration};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio_rustls::{
TlsAcceptor,
rustls::{
ServerConfig,
pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
},
};
const CHILD_ENV: &str = "CPR_TEST_PROXY_TLS_DIRECTORY";
let Ok(directory) = std::env::var(CHILD_ENV) else {
let directory = tempfile::tempdir().unwrap();
generate_proxy_test_certificates(directory.path());
// 使用子进程隔离环境变量,避免并行测试读取到临时 CA 配置。
for ca_env in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
let mut child = Command::new(std::env::current_exe().unwrap());
child
.args([
"--exact",
"bootstrap::server::proxy_probe_should_use_provider_custom_ca_for_https_proxies",
"--nocapture",
])
.env(CHILD_ENV, directory.path())
.env_remove("CODEX_CA_CERTIFICATE")
.env_remove("SSL_CERT_FILE")
.env(ca_env, directory.path().join("ca.pem"));
if ca_env == "CODEX_CA_CERTIFICATE" {
child.env(
"SSL_CERT_FILE",
directory.path().join("missing-fallback.pem"),
);
}
let output = child.output().unwrap();
assert!(
output.status.success(),
"{ca_env}: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
return;
};
provider_openai::ensure_rustls_provider();
let directory = std::path::Path::new(&directory);
let certificate = CertificateDer::from_pem_file(directory.join("server.pem")).unwrap();
let key = PrivateKeyDer::from_pem_file(directory.join("server.key")).unwrap();
let acceptor = TlsAcceptor::from(Arc::new(
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![certificate], key)
.unwrap(),
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy =
OutboundProxy::parse(&format!("https://{}", listener.local_addr().unwrap())).unwrap();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut stream = acceptor.accept(socket).await.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
assert!(request.len() < 8192);
request.push(stream.read_u8().await.unwrap());
}
assert!(request.starts_with(b"GET http://unresolvable.invalid/ip "));
let body = "{\"ip\":\"203.0.113.8\"}";
stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
stream.shutdown().await.unwrap();
});
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
let result = probe.test(&proxy, false).await;
assert!(result.success, "{}", result.message);
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
tokio::time::timeout(Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
fn generate_proxy_test_certificates(directory: &std::path::Path) {
let openssl = |args: &[&str]| {
let output = Command::new("openssl")
.args(args)
.current_dir(directory)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
openssl(&[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
"ca.key",
"-out",
"ca.pem",
"-days",
"1",
"-subj",
"/CN=Proxy Test CA",
]);
openssl(&[
"req",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
"server.key",
"-out",
"server.csr",
"-subj",
"/CN=localhost",
]);
fs::write(directory.join("extensions"), "subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n").unwrap();
openssl(&[
"x509",
"-req",
"-in",
"server.csr",
"-CA",
"ca.pem",
"-CAkey",
"ca.key",
"-CAcreateserial",
"-out",
"server.pem",
"-days",
"1",
"-extfile",
"extensions",
]);
}
+10 -2
View File
@@ -18,7 +18,12 @@ fn app_tree_matches_frozen_terminal_manifest() {
assert_eq!(
rust_files(&root.join("src")),
BTreeSet::from([
PathBuf::from("bootstrap.rs"),
PathBuf::from("bootstrap/command.rs"),
PathBuf::from("bootstrap/config.rs"),
PathBuf::from("bootstrap/mod.rs"),
PathBuf::from("bootstrap/plugins.rs"),
PathBuf::from("bootstrap/server.rs"),
PathBuf::from("bootstrap/startup.rs"),
PathBuf::from("lib.rs"),
PathBuf::from("main.rs"),
]),
@@ -27,7 +32,10 @@ fn app_tree_matches_frozen_terminal_manifest() {
rust_files(&root.join("tests")),
BTreeSet::from([
PathBuf::from("architecture.rs"),
PathBuf::from("bootstrap.rs"),
PathBuf::from("bootstrap/command.rs"),
PathBuf::from("bootstrap/config.rs"),
PathBuf::from("bootstrap/mod.rs"),
PathBuf::from("bootstrap/server.rs"),
PathBuf::from("main.rs"),
]),
);
@@ -21,20 +21,23 @@ pub struct BackupSchedule {
}
impl BackupSchedule {
/// 解析并校验 5 段 Cron 与 IANA 时区。
/// 按部署时区解析并校验 5 段 Cron。
///
/// # Errors
///
/// Cron 非法或时区不是 IANA 名称时返回 [`BackupError`]。
pub fn parse(cron_expression: &str, schedule_timezone: &str) -> Result<Self, BackupError> {
/// Cron 非法时返回 [`BackupError`]。
pub fn parse(
cron_expression: &str,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<Self, BackupError> {
let expression = format!("0 {cron_expression}");
let schedule = Schedule::from_str(&expression).map_err(|_| {
BackupError::new(code::INVALID_CRON, "Cron 表达式必须是 5 段格式".to_owned())
})?;
let timezone = schedule_timezone.parse::<Tz>().map_err(|_| {
BackupError::new(code::INVALID_TIMEZONE, "时区必须是 IANA 名称".to_owned())
})?;
Ok(Self { schedule, timezone })
Ok(Self {
schedule,
timezone: timezone.iana(),
})
}
/// 返回最接近 `at`(含 `at` 所在分钟)的最近一次触发时间。
@@ -49,9 +52,7 @@ impl BackupSchedule {
// 检查窗口内是否存在触发;不存在则直接返回 None。
let low_instant = instant(low_min, self.timezone)?;
if self
.schedule
.after(&low_instant)
.next()
.next_after(low_instant.to_utc())
.is_none_or(|firing| firing.timestamp() > high_min * 60 + 59)
{
return None;
@@ -79,19 +80,21 @@ impl BackupSchedule {
/// 返回第一个严格晚于 `at` 的触发时间。
#[must_use]
pub fn next_after(&self, at: DateTime<Utc>) -> Option<DateTime<Utc>> {
let local = at.with_timezone(&self.timezone);
self.schedule
.after(&local)
.next()
.map(|firing| firing.with_timezone(&Utc))
let local = at.with_timezone(&self.timezone).naive_local().and_utc();
self.schedule.after(&local).find_map(|firing| {
let instant = self
.timezone
.from_local_datetime(&firing.naive_utc())
.earliest()?
.to_utc();
(instant > at).then_some(instant)
})
}
/// `after(u 分钟)` 返回的第一个触发时间(分钟索引),用于二分搜索。
fn firing_after(&self, minute_index: i64) -> Option<i64> {
let instant = instant(minute_index, self.timezone)?;
self.schedule
.after(&instant)
.next()
let instant = instant(minute_index, self.timezone)?.to_utc();
self.next_after(instant)
.map(|firing| firing.timestamp().div_euclid(60))
}
}
+49 -14
View File
@@ -44,6 +44,7 @@ const PENDING_DELETION_BATCH: u32 = 20;
/// 备份 Daemon 任务。
pub struct BackupTask {
timezone: gateway_core::time::DeploymentTimeZone,
repository: Arc<dyn BackupRepository>,
dump: Arc<dyn DatabaseDumpPort>,
object_store: Arc<dyn BackupObjectStorePort>,
@@ -58,11 +59,18 @@ impl BackupTask {
object_store: Arc<dyn BackupObjectStorePort>,
) -> Self {
Self {
timezone: Default::default(),
repository,
dump,
object_store,
}
}
#[must_use]
pub fn with_timezone(mut self, timezone: gateway_core::time::DeploymentTimeZone) -> Self {
self.timezone = timezone;
self
}
}
impl DaemonTask for BackupTask {
@@ -112,13 +120,29 @@ impl BackupTask {
return Ok(());
}
let cron = settings.cron_expression.as_deref().unwrap_or_default();
let timezone = settings.schedule_timezone.as_deref().unwrap_or_default();
if cron.is_empty() || timezone.is_empty() {
let timezone = self.timezone.name();
if cron.is_empty() {
return Ok(());
}
let schedule = BackupSchedule::parse(cron, timezone)
let schedule = BackupSchedule::parse(cron, self.timezone)
.map_err(|_| WorkerTaskError::safe("backup schedule is invalid"))?;
if settings.schedule_timezone.as_deref() != Some(timezone) {
// 时区切换只初始化未来游标,不把旧时区计划当作应补跑任务。
if let Some(next_run_at) = schedule.next_after(now) {
self.repository
.advance_schedule_cursor(
next_run_at,
cron,
settings.schedule_timezone.as_deref(),
settings.next_run_at,
timezone,
)
.await
.map_err(repo_error)?;
}
return Ok(());
}
let due = match settings.next_run_at {
Some(next_run_at) if next_run_at <= now => true,
Some(_) => false,
@@ -127,7 +151,13 @@ impl BackupTask {
if let Some(next_run_at) = schedule.next_after(now) {
let _ = self
.repository
.advance_schedule_cursor(next_run_at, cron, timezone)
.advance_schedule_cursor(
next_run_at,
cron,
Some(timezone),
settings.next_run_at,
timezone,
)
.await
.map_err(repo_error)?;
}
@@ -145,7 +175,13 @@ impl BackupTask {
if let Some(next_run_at) = schedule.next_after(now) {
let _ = self
.repository
.advance_schedule_cursor(next_run_at, cron, timezone)
.advance_schedule_cursor(
next_run_at,
cron,
Some(timezone),
settings.next_run_at,
timezone,
)
.await
.map_err(repo_error)?;
}
@@ -168,20 +204,19 @@ impl BackupTask {
.map_err(infra_error)?;
let inserted = self
.repository
.insert_scheduled_record(seed)
.insert_scheduled_record(
seed,
schedule.next_after(now),
cron,
timezone,
settings.next_run_at,
)
.await
.map_err(repo_error)?;
if inserted {
info!(scheduled_at = %scheduled_at, "计划备份任务已创建");
} else {
warn!(scheduled_at = %scheduled_at, "计划时间点冲突,跳过并推进游标");
}
if let Some(next_run_at) = schedule.next_after(now) {
let _ = self
.repository
.advance_schedule_cursor(next_run_at, cron, timezone)
.await
.map_err(repo_error)?;
warn!(scheduled_at = %scheduled_at, "计划已变化或存在冲突,本轮未创建任务");
}
Ok(())
}
+14 -1
View File
@@ -187,6 +187,7 @@ pub enum AdminConfigError {
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
#[derive(Clone)]
pub struct AdminServices {
timezone: gateway_core::time::DeploymentTimeZone,
public_services: Arc<service::Registry>,
plugins: Arc<PluginsService>,
plugin_management: Arc<PluginManagementService>,
@@ -207,6 +208,11 @@ pub struct AdminServices {
}
impl AdminServices {
#[must_use]
pub const fn timezone(&self) -> gateway_core::time::DeploymentTimeZone {
self.timezone
}
pub fn public_services(&self) -> Arc<service::Registry> {
self.public_services.clone()
}
@@ -318,6 +324,7 @@ impl AdminBundle {
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
pub struct AdminRuntimePorts {
pub timezone: gateway_core::time::DeploymentTimeZone,
pub service_middleware: service::PlanSource,
pub plugin_preparation: Arc<dyn ports::plugins::PluginPreparation>,
pub plugin_management: Arc<dyn ports::plugin_management::PluginManagement>,
@@ -367,6 +374,7 @@ async fn initialize_inner(
plugin_accounts: Option<Arc<dyn PluginAccountAccess>>,
) -> Result<AdminBundle, AdminError> {
let AdminRuntimePorts {
timezone,
service_middleware,
plugin_preparation,
plugin_management,
@@ -413,12 +421,14 @@ async fn initialize_inner(
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(),
@@ -431,6 +441,7 @@ async fn initialize_inner(
store.client_keys(),
store.observability(),
system.clone(),
timezone,
));
let credentials = Arc::new(CredentialsService::new(
registry.clone(),
@@ -452,6 +463,7 @@ async fn initialize_inner(
let mut public_services = service::Registry::new(service_middleware);
public_services.register_settings(&settings)?;
let services = AdminServices {
timezone,
public_services: Arc::new(public_services),
plugin_management: Arc::new(PluginManagementService::new(
plugin_management,
@@ -492,6 +504,7 @@ async fn initialize_inner(
store.accounts(),
store.settings(),
registry.clone(),
timezone,
)),
settings,
system,
@@ -5,7 +5,6 @@ pub const INVALID_CONFIG: &str = "backup.invalid_config";
/// Cron 表达式非法。
pub const INVALID_CRON: &str = "backup.invalid_cron";
/// IANA 时区非法。
pub const INVALID_TIMEZONE: &str = "backup.invalid_timezone";
/// 记录不存在。
pub const RECORD_NOT_FOUND: &str = "backup.record_not_found";
/// 已存在活跃任务,拒绝创建。
@@ -183,7 +183,6 @@ impl fmt::Debug for UpdateBackupStorageCommand {
pub struct UpdateBackupScheduleCommand {
pub schedule_enabled: bool,
pub cron_expression: String,
pub schedule_timezone: String,
pub retention_days: u32,
pub retention_count: u32,
}
@@ -203,6 +202,7 @@ pub struct BackupSettings {
pub force_path_style: bool,
pub schedule_enabled: bool,
pub cron_expression: Option<String>,
/// 记录持久化游标采用的时区,由服务端维护,不是独立部署设置。
pub schedule_timezone: Option<String>,
pub retention_days: u32,
pub retention_count: u32,
@@ -2,18 +2,27 @@
use std::str::FromStr;
use chrono::{DateTime, TimeDelta, Timelike as _, Utc};
use chrono::{DateTime, TimeDelta, Utc};
use super::{AdminModelError, PageSize};
/// 观测日界所用的东八区(UTC+8)固定偏移秒数。
const CHINA_OFFSET_SECONDS: i64 = 8 * 60 * 60;
/// 页面筛选表达自然日范围,具体 UTC 边界由部署时区解析。
#[derive(Debug, Clone, Copy)]
pub enum CalendarPeriod {
Today,
SevenDays,
ThirtyDays,
}
/// 将 UTC 时刻截断到东八区(UTC+8)当日零点,返回值仍为 UTC。
#[must_use]
pub fn china_day_start(value: DateTime<Utc>) -> DateTime<Utc> {
let elapsed = (value.timestamp() + CHINA_OFFSET_SECONDS).rem_euclid(24 * 60 * 60);
value - TimeDelta::seconds(elapsed) - TimeDelta::nanoseconds(i64::from(value.nanosecond()))
impl CalendarPeriod {
pub fn parse(value: &str) -> Result<Self, AdminModelError> {
match value {
"today" => Ok(Self::Today),
"7d" => Ok(Self::SevenDays),
"30d" => Ok(Self::ThirtyDays),
_ => Err(AdminModelError::InvalidTimeRange),
}
}
}
/// 外部观测查询的 UTC 时间范围。
@@ -24,6 +33,25 @@ pub struct TimeRange {
}
impl TimeRange {
pub fn calendar_at(
period: CalendarPeriod,
end: DateTime<Utc>,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<Self, AdminModelError> {
let days = match period {
CalendarPeriod::Today => 0,
CalendarPeriod::SevenDays => 6,
CalendarPeriod::ThirtyDays => 29,
};
let start = timezone
.days_before(end, days)
.ok_or(AdminModelError::InvalidTimeRange)?;
// 自然日刚开始时允许空快照,不借用前一天或伪造未来终点。
if start == end {
return Ok(Self { start, end });
}
Self::new(start, end)
}
/// 创建最长 366 天的正时间范围。
///
/// # Errors
@@ -809,6 +837,8 @@ pub struct OpsError {
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_plan_type: Option<String>,
pub provider_account_plan_type_display: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -826,11 +826,20 @@ pub struct ProviderQuota {
pub observed_at: Option<DateTime<Utc>>,
pub refresh_token_expires_at: Option<DateTime<Utc>>,
pub windows: Vec<ProviderQuotaWindow>,
pub credits: Option<ProviderQuotaCredits>,
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号五态派生。
pub limit_reached: bool,
pub provider_data: Option<ProviderDocument>,
}
/// 上游点数余额的展示事实,保留原始十进制文本以避免精度损失。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderQuotaCredits {
pub has_credits: bool,
pub unlimited: bool,
pub balance: Option<String>,
}
/// 空值和 `unknown` 代表未提供套餐;新的套餐标识仍按明确值保留。
pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
value.filter(|value| {
@@ -38,6 +38,7 @@ pub struct RuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
@@ -77,6 +78,7 @@ pub struct ReplaceRuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
@@ -182,6 +184,7 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
rotation_strategy: settings.rotation_strategy,
@@ -55,6 +55,7 @@ pub trait BackupRepository: Send + Sync {
command: UpdateBackupScheduleCommand,
next_run_at: Option<DateTime<Utc>>,
context: &MutationContext,
timezone: gateway_core::time::DeploymentTimeZone,
) -> AdminStoreResult<BackupSettings>;
/// CAS 记录探针成功;`storage_revision` 与当前配置不一致时丢弃结果(返回 `false`)。
@@ -67,8 +68,16 @@ pub trait BackupRepository: Send + Sync {
/// 插入 queued 任务;存在活跃任务时返回 `backup.active_task_conflict`。
async fn insert_backup_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<BackupRecord>;
/// 幂等插入 scheduled 任务;同一时间点或活跃冲突时返回 `false`(调用方推进游标即可)。
async fn insert_scheduled_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<bool>;
/// 核对启停、Cron、时区及旧游标后,同事务推进游标并插入 scheduled 任务。
/// 配置已变化时不写入;同一时间点或活跃冲突时只推进游标,返回 `false`。
async fn insert_scheduled_record(
&self,
seed: BackupRecordSeed,
next_run_at: Option<DateTime<Utc>>,
expected_cron: &str,
expected_timezone: &str,
expected_next_run_at: Option<DateTime<Utc>>,
) -> AdminStoreResult<bool>;
/// 分页查询记录。
async fn list_backup_records(
@@ -111,12 +120,14 @@ pub trait BackupRepository: Send + Sync {
/// 远端删除成功后硬删除记录行。
async fn delete_record(&self, id: &str) -> AdminStoreResult<()>;
/// 条件推进计划游标;仅当计划表达式和时区仍是 `expected_*` 时才写入,返回是否推进。
/// 条件推进计划游标;仅当启用状态、计划表达式、时区及旧游标仍匹配时才写入,返回是否推进。
async fn advance_schedule_cursor(
&self,
next_run_at: DateTime<Utc>,
expected_cron: &str,
expected_timezone: &str,
expected_timezone: Option<&str>,
expected_next_run_at: Option<DateTime<Utc>>,
timezone: &str,
) -> AdminStoreResult<bool>;
/// 按完成时间倒序读取 completed 计划备份,供保留策略扫描(最多 `limit` 条)。
@@ -286,6 +297,7 @@ impl BackupRepository for UnavailableBackupRepository {
_command: UpdateBackupScheduleCommand,
_next_run_at: Option<DateTime<Utc>>,
_context: &MutationContext,
_timezone: gateway_core::time::DeploymentTimeZone,
) -> AdminStoreResult<BackupSettings> {
Err(disabled_store())
}
@@ -302,7 +314,14 @@ impl BackupRepository for UnavailableBackupRepository {
) -> AdminStoreResult<BackupRecord> {
Err(disabled_store())
}
async fn insert_scheduled_record(&self, _seed: BackupRecordSeed) -> AdminStoreResult<bool> {
async fn insert_scheduled_record(
&self,
_seed: BackupRecordSeed,
_next_run_at: Option<DateTime<Utc>>,
_expected_cron: &str,
_expected_timezone: &str,
_expected_next_run_at: Option<DateTime<Utc>>,
) -> AdminStoreResult<bool> {
Err(disabled_store())
}
async fn list_backup_records(
@@ -352,7 +371,9 @@ impl BackupRepository for UnavailableBackupRepository {
&self,
_next_run_at: DateTime<Utc>,
_expected_cron: &str,
_expected_timezone: &str,
_expected_timezone: Option<&str>,
_expected_next_run_at: Option<DateTime<Utc>>,
_timezone: &str,
) -> AdminStoreResult<bool> {
Err(disabled_store())
}
@@ -1052,6 +1052,7 @@ fn map_reset_credits_error_after_refresh(
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影。
fn empty_quota() -> ProviderQuota {
ProviderQuota {
credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -76,6 +76,7 @@ pub trait BackupService: Send + Sync {
}
pub(crate) struct DefaultBackupService {
timezone: gateway_core::time::DeploymentTimeZone,
repository: Arc<dyn BackupRepository>,
object_store: Arc<dyn BackupObjectStorePort>,
auth: Arc<dyn AuthStore>,
@@ -89,8 +90,10 @@ impl DefaultBackupService {
object_store: Arc<dyn BackupObjectStorePort>,
auth: Arc<dyn AuthStore>,
snapshot: Arc<dyn SnapshotControl>,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Self {
Self {
timezone,
repository,
object_store,
auth,
@@ -128,22 +131,20 @@ impl BackupService for DefaultBackupService {
context: &MutationContext,
command: UpdateBackupScheduleCommand,
) -> Result<BackupSettings, AdminError> {
validate_schedule(&command)?;
let schedule =
crate::backup::policy::BackupSchedule::parse(&command.cron_expression, self.timezone)
.map_err(map_backup_error)?;
let next_run_at = if command.schedule_enabled {
Some(
crate::backup::policy::BackupSchedule::parse(
&command.cron_expression,
&command.schedule_timezone,
)
.map_err(map_backup_error)?
.next_after(Utc::now())
.ok_or_else(|| AdminError::bad_request("无法从计划推导下次执行时间"))?,
schedule
.next_after(Utc::now())
.ok_or_else(|| AdminError::bad_request("无法从计划推导下次执行时间"))?,
)
} else {
None
};
self.repository
.update_schedule_settings(command, next_run_at, context)
.update_schedule_settings(command, next_run_at, context, self.timezone)
.await
.map_err(|error| map_store_error(error, "backup schedule"))
}
@@ -408,23 +409,10 @@ fn validate_length(field: &str, value: &str, max: usize) -> Result<(), AdminErro
Ok(())
}
/// 校验调度配置并解析 Cron 与时区。
fn validate_schedule(command: &UpdateBackupScheduleCommand) -> Result<(), AdminError> {
if command.cron_expression.trim().is_empty() {
return Err(AdminError::bad_request("cronExpression 不能为空"));
}
crate::backup::policy::BackupSchedule::parse(
&command.cron_expression,
&command.schedule_timezone,
)
.map_err(map_backup_error)?;
Ok(())
}
/// 备份基础设施错误到既有 `AdminErrorKind` 的唯一边界映射。
fn map_backup_error(error: BackupError) -> AdminError {
match error.code() {
code::INVALID_CONFIG | code::INVALID_CRON | code::INVALID_TIMEZONE => {
code::INVALID_CONFIG | code::INVALID_CRON => {
AdminError::bad_request(error.message().to_owned())
}
code::RECORD_NOT_FOUND => AdminError::not_found(error.message().to_owned()),
@@ -3,7 +3,6 @@
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use crate::{
AuthService, SystemService,
@@ -15,9 +14,7 @@ use crate::{
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
KeyUsageRecordsQuery,
},
observability::{
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
},
observability::{OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery},
system::SystemVersion,
},
ports::store::{ClientKeyStore, ObservabilityStore},
@@ -62,6 +59,7 @@ pub trait KeyUsageService: Send + Sync {
}
pub(crate) struct DefaultKeyUsageService {
timezone: gateway_core::time::DeploymentTimeZone,
auth: Arc<dyn AuthService>,
verifier: Arc<dyn ClientKeyVerifier>,
keys: Arc<dyn ClientKeyStore>,
@@ -76,8 +74,10 @@ impl DefaultKeyUsageService {
keys: Arc<dyn ClientKeyStore>,
observations: Arc<dyn ObservabilityStore>,
system: Arc<dyn SystemService>,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Self {
Self {
timezone,
auth,
verifier,
keys,
@@ -181,10 +181,13 @@ impl KeyUsageService for DefaultKeyUsageService {
return Ok(None);
};
let filter = usage_filter(&id, query.model);
let now = Utc::now();
// 健康条始终展示北京时间今日,不随历史范围或模型筛选改变。
let now = query.range.end;
// 健康条始终展示部署时区今日,不随历史范围或模型筛选改变。
let today = TimeRange {
start: china_day_start(now),
start: self
.timezone
.day_start(now)
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?,
end: now,
};
let (overview, trend, health_points) = futures::try_join!(
@@ -198,7 +201,7 @@ impl KeyUsageService for DefaultKeyUsageService {
key,
overview,
trend,
health_timeline: health_timeline_at(&health_points, now),
health_timeline: health_timeline_at(&health_points, now, self.timezone)?,
}))
}
@@ -8,7 +8,7 @@ use std::{
};
use async_trait::async_trait;
use chrono::{DateTime, Duration, Timelike as _, Utc};
use chrono::{DateTime, Duration, Utc};
use futures::TryStreamExt;
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
@@ -24,7 +24,7 @@ use crate::{
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost,
UsageInsightsCostPoint, UsageInsightsHealth, UsageInsightsHealthPoint,
UsageInsightsPerformance, UsageInsightsPerformancePoint, UsageOverview, UsagePage,
UsageQuery, UsageSummary, china_day_start,
UsageQuery, UsageSummary,
},
provider_credentials::ProviderQuotaRequest,
},
@@ -37,7 +37,6 @@ use crate::{
use super::{map_provider_error, map_store_error};
const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4;
const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10;
const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3;
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 99.0;
@@ -126,6 +125,7 @@ pub trait ObservabilityService: Send + Sync {
}
pub(crate) struct DefaultObservabilityService {
timezone: gateway_core::time::DeploymentTimeZone,
store: Arc<dyn ObservabilityStore>,
accounts: Arc<dyn AccountStore>,
settings: Arc<dyn SettingsStore>,
@@ -140,8 +140,10 @@ impl DefaultObservabilityService {
accounts: Arc<dyn AccountStore>,
settings: Arc<dyn SettingsStore>,
providers: ProviderAdminRegistry,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Self {
Self {
timezone,
store,
accounts,
settings,
@@ -169,8 +171,14 @@ impl DefaultObservabilityService {
.providers
.plan_type_display(&account.provider_kind, account.plan_type.as_deref());
}
let today_start = china_day_start(observation.range.end);
let yesterday_start = today_start - Duration::days(1);
let today_start = self
.timezone
.day_start(observation.range.end)
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
let yesterday_start = self
.timezone
.days_before(observation.range.end, 1)
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
let today =
dashboard_period_metrics(&observation.trend, today_start, observation.range.end);
let yesterday = dashboard_period_metrics(&observation.trend, yesterday_start, today_start);
@@ -191,7 +199,8 @@ impl DefaultObservabilityService {
let average_first_token_latency_ms =
average(first_token_latency_sum_ms, first_token_latency_count);
let trend = trend(TrendKind::Usage, observation.trend.clone())?;
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
let health_timeline =
health_timeline_at(&observation.trend, observation.range.end, self.timezone)?;
let wire_profiles = self
.providers
.dashboard_wire_profiles(&settings.request_profiles);
@@ -382,10 +391,19 @@ impl ObservabilityService for DefaultObservabilityService {
}
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError> {
self.store
let mut page = self
.store
.list_ops_errors(query)
.await
.map_err(|error| map_store_error(error, "operations errors"))
.map_err(|error| map_store_error(error, "operations errors"))?;
for error in &mut page.items {
error.provider_account_plan_type_display =
error.provider_kind.as_deref().and_then(|provider| {
self.providers
.plan_type_display(provider, error.provider_account_plan_type.as_deref())
});
}
Ok(page)
}
}
@@ -852,15 +870,28 @@ impl DefaultObservabilityService {
}
}
/// 按指定时刻计算中国自然日的 96 个 15 分钟健康桶。
#[must_use]
/// 按部署时区的自然日计算 15 分钟健康桶,桶数随夏令时变化。
pub(super) fn health_timeline_at(
records: &[RequestMetricPoint],
now: DateTime<Utc>,
) -> HealthTimeline {
let current_slot = quarter_hour_start(now);
let start = china_day_start(now);
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<HealthTimeline, AdminError> {
let start = timezone
.day_start(now)
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
let end = timezone
.days_after(now, 1)
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
let current_slot = start
+ Duration::minutes(
(now - start)
.num_minutes()
.div_euclid(HEALTH_TIMELINE_SLOT_MINUTES)
* HEALTH_TIMELINE_SLOT_MINUTES,
);
let slots = ((end - start).num_minutes() + HEALTH_TIMELINE_SLOT_MINUTES - 1)
/ HEALTH_TIMELINE_SLOT_MINUTES;
let mut buckets = (0..slots)
.map(|index| {
(
start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index),
@@ -872,7 +903,13 @@ pub(super) fn health_timeline_at(
if record.bucket_start < start || record.bucket_start > now {
continue;
}
let record_slot = quarter_hour_start(record.bucket_start);
let record_slot = start
+ Duration::minutes(
(record.bucket_start - start)
.num_minutes()
.div_euclid(HEALTH_TIMELINE_SLOT_MINUTES)
* HEALTH_TIMELINE_SLOT_MINUTES,
);
if let Some((_, bucket)) = buckets
.iter_mut()
.find(|(bucket_start, _)| *bucket_start == record_slot)
@@ -888,7 +925,7 @@ pub(super) fn health_timeline_at(
totals.add_window(*bucket);
totals
});
HealthTimeline {
Ok(HealthTimeline {
reliability_percent: health_reliability(totals),
status: health_status(totals, false),
success_requests: totals.success_requests,
@@ -909,7 +946,7 @@ pub(super) fn health_timeline_at(
caller_error_requests: bucket.caller_error_requests,
})
.collect(),
}
})
}
fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, AdminError> {
@@ -1173,10 +1210,3 @@ fn service_failure_count(metrics: &RequestMetrics) -> u64 {
.failure_count
.saturating_sub(metrics.caller_error_count)
}
fn quarter_hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
let elapsed = value
.timestamp()
.rem_euclid(HEALTH_TIMELINE_SLOT_MINUTES * 60);
value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond()))
}
@@ -38,15 +38,15 @@ fn record(
}
#[test]
fn parse_rejects_bad_cron_and_timezone() {
assert!(BackupSchedule::parse("61 * * * *", "Asia/Shanghai").is_err());
assert!(BackupSchedule::parse("0 2 * * *", "Not/AZone").is_err());
assert!(BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").is_ok());
fn parse_rejects_invalid_cron() {
assert!(BackupSchedule::parse("61 * * * *", "Asia/Shanghai".parse().unwrap()).is_err());
assert!(BackupSchedule::parse("0 2 * * *", "Asia/Shanghai".parse().unwrap()).is_ok());
}
#[test]
fn next_after_is_strictly_later() {
let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").unwrap();
let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai".parse().unwrap()).unwrap();
let at = Utc.with_ymd_and_hms(2026, 8, 1, 18, 0, 0).unwrap();
let next = schedule.next_after(at).unwrap();
assert_eq!(next, Utc.with_ymd_and_hms(2026, 8, 2, 18, 0, 0).unwrap());
@@ -54,7 +54,7 @@ fn next_after_is_strictly_later() {
#[test]
fn last_firing_is_at_or_before() {
let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").unwrap();
let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai".parse().unwrap()).unwrap();
// 北京时间 02:00 = 前一日 18:00 UTC。
let before = Utc.with_ymd_and_hms(2026, 8, 1, 17, 59, 0).unwrap();
assert_eq!(
@@ -69,7 +69,8 @@ fn last_firing_is_at_or_before() {
#[test]
fn dst_spring_forward_skips_missing_hour() {
let schedule = BackupSchedule::parse("30 2 * * *", "America/New_York").unwrap();
let schedule =
BackupSchedule::parse("30 2 * * *", "America/New_York".parse().unwrap()).unwrap();
let at = Utc.with_ymd_and_hms(2026, 3, 8, 6, 29, 0).unwrap(); // 01:29 EST
let next = schedule.next_after(at).unwrap();
assert_eq!(next, Utc.with_ymd_and_hms(2026, 3, 9, 6, 30, 0).unwrap());
@@ -169,3 +170,27 @@ fn disabled_thresholds_delete_nothing() {
];
assert!(decide_retention(0, 0, now, &records).is_empty());
}
#[test]
fn cron_skips_dst_gap_and_runs_only_first_repeated_wall_time() {
let zone = "America/New_York".parse().unwrap();
let gap = BackupSchedule::parse("30 2 * * *", zone).unwrap();
assert_eq!(
gap.next_after("2026-03-08T05:00:00Z".parse().unwrap()),
Some("2026-03-09T06:30:00Z".parse().unwrap())
);
let fold = BackupSchedule::parse("30 1 * * *", zone).unwrap();
let first = "2026-11-01T05:30:00Z".parse().unwrap();
assert_eq!(
fold.next_after("2026-11-01T04:00:00Z".parse().unwrap()),
Some(first)
);
assert_eq!(
fold.next_after(first),
Some("2026-11-02T06:30:00Z".parse().unwrap())
);
assert_eq!(
fold.last_firing_at_or_before("2026-11-01T06:45:00Z".parse().unwrap()),
Some(first)
);
}
@@ -166,11 +166,12 @@ impl BackupRepository for FakeBackupRepository {
command: UpdateBackupScheduleCommand,
next_run_at: Option<DateTime<Utc>>,
_context: &MutationContext,
timezone: gateway_core::time::DeploymentTimeZone,
) -> AdminStoreResult<BackupSettings> {
let mut settings = self.settings.lock().expect("settings");
settings.schedule_enabled = command.schedule_enabled;
settings.cron_expression = Some(command.cron_expression);
settings.schedule_timezone = Some(command.schedule_timezone);
settings.schedule_timezone = Some(timezone.name().to_owned());
settings.retention_days = command.retention_days;
settings.retention_count = command.retention_count;
settings.next_run_at = next_run_at;
@@ -205,7 +206,23 @@ impl BackupRepository for FakeBackupRepository {
Ok(record)
}
async fn insert_scheduled_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<bool> {
async fn insert_scheduled_record(
&self,
seed: BackupRecordSeed,
next_run_at: Option<DateTime<Utc>>,
expected_cron: &str,
expected_timezone: &str,
expected_next_run_at: Option<DateTime<Utc>>,
) -> AdminStoreResult<bool> {
let mut settings = self.settings.lock().expect("settings");
if !settings.schedule_enabled
|| settings.cron_expression.as_deref() != Some(expected_cron)
|| settings.schedule_timezone.as_deref() != Some(expected_timezone)
|| settings.next_run_at != expected_next_run_at
{
return Ok(false);
}
settings.next_run_at = next_run_at;
let mut records = self.records.lock().expect("records");
if records.iter().any(|record| record.status.is_active())
|| records.iter().any(|record| {
@@ -391,11 +408,21 @@ impl BackupRepository for FakeBackupRepository {
async fn advance_schedule_cursor(
&self,
next_run_at: DateTime<Utc>,
_expected_cron: &str,
_expected_timezone: &str,
expected_cron: &str,
expected_timezone: Option<&str>,
expected_next_run_at: Option<DateTime<Utc>>,
timezone: &str,
) -> AdminStoreResult<bool> {
let mut settings = self.settings.lock().expect("settings");
if !settings.schedule_enabled
|| settings.cron_expression.as_deref() != Some(expected_cron)
|| settings.schedule_timezone.as_deref() != expected_timezone
|| settings.next_run_at != expected_next_run_at
{
return Ok(false);
}
settings.next_run_at = Some(next_run_at);
settings.schedule_timezone = Some(timezone.to_owned());
Ok(true)
}
@@ -183,14 +183,14 @@ async fn retention_cleans_expired_scheduled_backups() {
expires_at: None,
};
repository
.insert_scheduled_record(old_seed.clone())
.insert_backup_record(old_seed.clone())
.await
.expect("insert old");
// 旧备份 40 天前完成(释放活跃名额),再插入新备份。
let now = Utc::now();
repository.set_completed(&backup_id("old"), now - Duration::days(40));
repository
.insert_scheduled_record(new_seed.clone())
.insert_backup_record(new_seed.clone())
.await
.expect("insert new");
// 新备份 1 天前完成。
@@ -213,12 +213,13 @@ async fn retention_cleans_expired_scheduled_backups() {
UpdateBackupScheduleCommand {
schedule_enabled: false,
cron_expression: "0 2 * * *".to_owned(),
schedule_timezone: "Asia/Shanghai".to_owned(),
retention_days: 30,
retention_count: 0,
},
None,
&system_context(),
Default::default(),
)
.await
.expect("set retention");
@@ -235,3 +236,26 @@ async fn retention_cleans_expired_scheduled_backups() {
assert!(object_store.object(&old_seed.object_key).is_none());
assert!(object_store.object(&new_seed.object_key).is_some());
}
#[tokio::test]
async fn timezone_change_rebases_future_cursor_without_running_old_due_backup() {
let mut settings = configured_settings();
settings.schedule_enabled = true;
settings.cron_expression = Some("0 2 * * *".to_owned());
settings.schedule_timezone = Some("Asia/Shanghai".to_owned());
settings.next_run_at = Some(Utc::now() - Duration::days(1));
let repository = Arc::new(FakeBackupRepository::new(settings));
let task = BackupTask::new(
repository.clone(),
Arc::new(FakeDumpPort::new()),
Arc::new(FakeObjectStore::new()),
)
.with_timezone("UTC".parse().unwrap());
task.run_cycle(&CancellationToken::new()).await.unwrap();
let settings = repository.load_settings().await.unwrap();
assert_eq!(settings.schedule_timezone.as_deref(), Some("UTC"));
assert!(settings.next_run_at.unwrap() > Utc::now());
assert!(repository.all_records().is_empty());
task.run_cycle(&CancellationToken::new()).await.unwrap();
assert!(repository.all_records().is_empty());
}
@@ -129,6 +129,7 @@ fn provider_document_debug_should_not_expose_opaque_material() {
#[test]
fn representative_quota_should_prefer_short_window_and_highest_usage() {
let quota = ProviderQuota {
credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -150,6 +151,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
let quota = ProviderQuota {
credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -187,6 +189,7 @@ fn usage_window_should_not_use_daily_rolling_usage_for_weekly_statistics() {
models: Vec::new(),
};
let quota = ProviderQuota {
credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -215,6 +218,7 @@ fn usage_window_should_not_use_daily_rolling_usage_for_weekly_statistics() {
#[test]
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
let mut quota = ProviderQuota {
credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -243,6 +247,7 @@ fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
reached.limit_reached = true;
let mut quota = ProviderQuota {
credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -84,6 +84,7 @@ fn selected_usage_window(quota: &ProviderQuota) -> Option<(&str, AccountUsagePer
fn usage_quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
ProviderQuota {
credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -133,6 +134,7 @@ fn usage_window(key: &str, group: &str, seconds: u64) -> ProviderQuotaWindow {
#[test]
fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
let mut quota = ProviderQuota {
credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -56,6 +56,7 @@ fn window(key: &str, days: u64) -> ProviderQuotaWindow {
fn quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
ProviderQuota {
credits: None,
plan_type: None,
observed_at: Some(now()),
refresh_token_expires_at: None,
@@ -1114,6 +1114,7 @@ impl SettingsStore for StaticSettingsStore {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
@@ -1655,6 +1656,7 @@ async fn accounts_recover_disabled_should_only_enable_scheduling_and_preserve_qu
);
}
let quota = ProviderQuota {
credits: None,
observed_at: Some(Utc::now()),
windows: vec![ProviderQuotaWindow {
key: "primary".to_owned(),
@@ -1993,6 +1995,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
let events = events();
let openai = FakeProviderAdmin::new("openai", events.clone());
openai.set_quota(ProviderQuota {
credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
@@ -2125,6 +2128,7 @@ async fn accounts_list_should_map_unknown_credential_to_error_not_normal() {
async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view() {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
@@ -2173,6 +2177,7 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view()
async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
@@ -2224,6 +2229,7 @@ async fn quota_forecast_reads_raw_snapshot_and_limits_usage_to_observation_time(
let observed = now - TimeDelta::hours(1);
let reset = now + TimeDelta::days(1);
provider.set_quota(ProviderQuota {
credits: None,
observed_at: Some(observed),
limit_reached: true,
windows: vec![ProviderQuotaWindow {
@@ -2378,6 +2384,7 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
let added = now - TimeDelta::hours(5);
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
credits: None,
plan_type: Some("pro".to_owned()),
observed_at: Some(observed),
windows: vec![ProviderQuotaWindow {
@@ -2524,6 +2531,7 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
let provider = FakeProviderAdmin::new("openai", events());
let reset_at = Utc::now() + TimeDelta::hours(1);
provider.set_quota(ProviderQuota {
credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
@@ -2681,6 +2689,7 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
let provider = FakeProviderAdmin::new("openai", events());
let reset_at = Utc::now() + TimeDelta::days(7);
provider.set_quota(ProviderQuota {
credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
@@ -2982,6 +2991,7 @@ pub(super) fn document() -> ProviderDocument {
fn empty_quota() -> ProviderQuota {
ProviderQuota {
credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -37,6 +37,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
@@ -268,6 +268,7 @@ impl AdminHarness {
Arc::new(plugins::TestPluginPorts),
),
gateway_admin::AdminRuntimePorts {
timezone: Default::default(),
service_middleware: self.service_middleware,
plugin_preparation: Arc::new(plugins::TestPluginPorts),
plugin_management: Arc::new(plugins::TestPluginPorts),
@@ -20,7 +20,7 @@ use gateway_admin::{
DiagnosticsObservation, Granularity, HealthStatus, LatencyPercentiles, OpsErrorPage,
OpsErrorQuery, PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange,
TrendKind, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
UsageListRecord, UsageOverview, UsagePage, UsageQuery, china_day_start,
UsageListRecord, UsageOverview, UsagePage, UsageQuery,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
@@ -49,7 +49,9 @@ fn external_observability_range_rejects_over_366_days_and_reversed_range() {
#[tokio::test]
async fn health_timeline_should_keep_exactly_china_day_quarter_hour_slots() {
let now = Utc::now();
let day_start = china_day_start(now);
let day_start = gateway_core::time::DeploymentTimeZone::default()
.day_start(now)
.unwrap();
let current_slot = quarter_hour_start(now);
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
store.replace_trend(vec![
@@ -300,9 +302,20 @@ async fn dashboard_summary_should_coalesce_concurrent_requests_in_one_short_wind
let end_bucket = Utc::now().timestamp().div_euclid(2) * 2;
let first_end = DateTime::from_timestamp(end_bucket, 100_000_000).expect("first end");
let second_end = DateTime::from_timestamp(end_bucket, 900_000_000).expect("second end");
let first_range = TimeRange::new(china_day_start(first_end), first_end).expect("first range");
let second_range =
TimeRange::new(china_day_start(second_end), second_end).expect("second range");
let first_range = TimeRange::new(
gateway_core::time::DeploymentTimeZone::default()
.day_start(first_end)
.unwrap(),
first_end,
)
.expect("first range");
let second_range = TimeRange::new(
gateway_core::time::DeploymentTimeZone::default()
.day_start(second_end)
.unwrap(),
second_end,
)
.expect("second range");
let store = Arc::new(FixtureObservabilityStore::new(first_range));
store.set_dashboard_delay(StdDuration::from_millis(20));
let services = observability_services(store.clone()).await;
@@ -323,9 +336,20 @@ async fn dashboard_summary_should_reload_after_the_short_window_changes() {
let end_bucket = Utc::now().timestamp().div_euclid(2) * 2;
let first_end = DateTime::from_timestamp(end_bucket, 100_000_000).expect("first end");
let second_end = first_end + Duration::seconds(2);
let first_range = TimeRange::new(china_day_start(first_end), first_end).expect("first range");
let second_range =
TimeRange::new(china_day_start(second_end), second_end).expect("second range");
let first_range = TimeRange::new(
gateway_core::time::DeploymentTimeZone::default()
.day_start(first_end)
.unwrap(),
first_end,
)
.expect("first range");
let second_range = TimeRange::new(
gateway_core::time::DeploymentTimeZone::default()
.day_start(second_end)
.unwrap(),
second_end,
)
.expect("second range");
let store = Arc::new(FixtureObservabilityStore::new(first_range));
let services = observability_services(store.clone()).await;
let observability = services.observability();
@@ -912,6 +936,7 @@ impl SettingsStore for FixtureSettingsStore {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
@@ -190,6 +190,7 @@ async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
] {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
credits: None,
plan_type: cached.map(str::to_owned),
observed_at: None,
refresh_token_expires_at: None,
@@ -285,6 +285,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
@@ -111,7 +111,9 @@ struct AccountGroupView {
capacity: AccountGroupCapacityView,
usage: AccountGroupUsageView,
created_at: DateTime<Utc>,
created_at_display: String,
updated_at: DateTime<Utc>,
updated_at_display: String,
}
#[derive(Debug, Clone, Serialize)]
@@ -136,8 +138,8 @@ struct AccountGroupUsageView {
retained_total_usd: String,
}
impl From<AccountGroupRecord> for AccountGroupView {
fn from(record: AccountGroupRecord) -> Self {
impl From<(AccountGroupRecord, crate::time::TimePresenter)> for AccountGroupView {
fn from((record, time): (AccountGroupRecord, crate::time::TimePresenter)) -> Self {
Self {
id: record.id.to_string(),
name: record.name,
@@ -151,7 +153,9 @@ impl From<AccountGroupRecord> for AccountGroupView {
account_summary: account_summary_view(record.account_summary),
capacity: capacity_view(record.capacity),
usage: usage_view(record.usage),
created_at_display: time.datetime(&record.created_at),
created_at: record.created_at,
updated_at_display: time.datetime(&record.updated_at),
updated_at: record.updated_at,
}
}
@@ -187,15 +191,19 @@ struct AccountGroupPageData {
config_revision: u64,
}
impl From<AccountGroupPage> for AccountGroupPageData {
fn from(page: AccountGroupPage) -> Self {
impl From<(AccountGroupPage, crate::time::TimePresenter)> for AccountGroupPageData {
fn from((page, time): (AccountGroupPage, crate::time::TimePresenter)) -> Self {
let total_pages = if page.total == 0 {
0
} else {
page.total.div_ceil(u64::from(page.page_size))
};
Self {
items: page.items.into_iter().map(Into::into).collect(),
items: page
.items
.into_iter()
.map(|value| AccountGroupView::from((value, time)))
.collect(),
page: PageMeta::new(
page.page,
u32::from(page.page_size),
@@ -215,11 +223,13 @@ struct AccountGroupMutationData {
config_revision: u64,
}
impl From<AccountGroupMutation> for AccountGroupMutationData {
fn from(mutation: AccountGroupMutation) -> Self {
impl From<(AccountGroupMutation, crate::time::TimePresenter)> for AccountGroupMutationData {
fn from((mutation, time): (AccountGroupMutation, crate::time::TimePresenter)) -> Self {
Self {
id: mutation.id.to_string(),
record: mutation.record.map(Into::into),
record: mutation
.record
.map(|value| AccountGroupView::from((value, time))),
config_revision: mutation.config_revision.get(),
}
}
@@ -247,6 +257,7 @@ async fn list<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let result = state
.admin_services()
.account_groups()
@@ -255,7 +266,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountGroupPageData::from(result)),
AdminEnvelope::ok(AccountGroupPageData::from((result, time))),
))
}
@@ -283,6 +294,7 @@ where
},
)
.await,
crate::time::TimePresenter::new(state.admin_services().timezone()),
)
}
@@ -311,6 +323,7 @@ where
},
)
.await,
crate::time::TimePresenter::new(state.admin_services().timezone()),
)
}
@@ -358,6 +371,7 @@ where
},
)
.await,
crate::time::TimePresenter::new(state.admin_services().timezone()),
)
}
@@ -381,17 +395,19 @@ where
},
)
.await,
crate::time::TimePresenter::new(state.admin_services().timezone()),
)
}
fn mutation_response(
status: StatusCode,
result: Result<AccountGroupMutation, gateway_admin::model::AdminError>,
time: crate::time::TimePresenter,
) -> Result<AdminResponse<AdminEnvelope<AccountGroupMutationData>>, AdminError> {
let result = result.map_err(map_service_error)?;
Ok(AdminResponse::new(
status,
AdminEnvelope::ok(AccountGroupMutationData::from(result)),
AdminEnvelope::ok(AccountGroupMutationData::from((result, time))),
))
}
@@ -96,6 +96,7 @@ async fn list_accounts<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let command = query.validate().map_err(map_wire_error)?;
let page = command.page;
let page_size = command.page_size.get();
@@ -105,7 +106,7 @@ where
.list(command)
.await
.map_err(map_service_error)?;
let data = account_page_data(result, page, page_size, Utc::now());
let data = account_page_data(result, page, page_size, Utc::now(), time);
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -117,6 +118,7 @@ async fn account_detail<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
@@ -131,7 +133,7 @@ where
.await
.map_err(map_service_error)?;
let data = AccountDetailData {
account: account_view(result, Utc::now()),
account: account_view(result, Utc::now(), time),
credential_configuration: configuration.map(provider_document_value),
};
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
@@ -145,6 +147,7 @@ async fn export_accounts<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let ids = query.into_ids().map_err(map_wire_error)?;
let result = state
.admin_services()
@@ -152,7 +155,7 @@ where
.export(&auth.context().mutation_context(), ids)
.await
.map_err(map_service_error)?;
let data = AccountExportData::from_result(result);
let data = AccountExportData::from_result(result, time);
let mut response = AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)).into_response();
response
.headers_mut()
@@ -313,6 +316,7 @@ async fn refresh_account<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let account_id = request.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
@@ -320,7 +324,7 @@ where
.refresh(&auth.context().mutation_context(), account_id)
.await
.map_err(map_service_error)?;
let data = account_refresh_data(result, Utc::now());
let data = account_refresh_data(result, Utc::now(), time);
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -332,6 +336,7 @@ async fn recover_account<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let account_id = request.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
@@ -339,7 +344,7 @@ where
.recover(&auth.context().mutation_context(), account_id)
.await
.map_err(map_service_error)?;
let data = account_refresh_data(result, Utc::now());
let data = account_refresh_data(result, Utc::now(), time);
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -351,6 +356,7 @@ async fn account_quota<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
@@ -359,7 +365,7 @@ where
.await
.map_err(map_service_error)?;
let data = AccountQuotaData {
account: account_view(result, Utc::now()),
account: account_view(result, Utc::now(), time),
};
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -381,7 +387,10 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountQuotaForecastData::from(result)),
AdminEnvelope::ok(AccountQuotaForecastData::from((
result,
crate::time::TimePresenter::new(state.admin_services().timezone()),
))),
))
}
@@ -393,6 +402,7 @@ async fn account_personal_info<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
@@ -402,7 +412,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountPersonalInfoData::from(result)),
AdminEnvelope::ok(AccountPersonalInfoData::from((result, time))),
))
}
@@ -477,6 +487,7 @@ async fn refresh_account_quota<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let account_id = request.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
@@ -485,7 +496,7 @@ where
.await
.map_err(map_service_error)?;
let data = AccountQuotaData {
account: account_view(result, Utc::now()),
account: account_view(result, Utc::now(), time),
};
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -498,6 +509,7 @@ async fn account_reset_credits<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
@@ -507,7 +519,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountResetCreditsData::from(result)),
AdminEnvelope::ok(AccountResetCreditsData::from((result, time))),
))
}
@@ -519,6 +531,7 @@ async fn consume_account_reset_credit<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let command = request.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
@@ -528,7 +541,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountResetCreditResultData::from(result)),
AdminEnvelope::ok(AccountResetCreditResultData::from((result, time))),
))
}
@@ -597,6 +610,7 @@ async fn test_account_connection<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let (account_id, upstream_model) = query.into_command().map_err(map_wire_error)?;
let stream = state
.admin_services()
@@ -604,8 +618,8 @@ where
.test_connection(account_id, upstream_model)
.await
.map_err(map_service_error)?
.map(|event| {
let event = AccountConnectionTestEvent::from(event);
.map(move |event| {
let event = AccountConnectionTestEvent::from((event, time));
let data = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".to_owned());
Ok(Event::default().data(data))
});
@@ -41,7 +41,9 @@ struct TaskId {
struct SummaryView {
task_id: String,
created_at: String,
created_at_display: String,
finished_at: Option<String>,
finished_at_display: Option<String>,
stop_requested: bool,
total: usize,
counts: CountsView,
@@ -59,12 +61,16 @@ struct CountsView {
imported_accounts: usize,
}
impl From<ImportTaskSummary> for SummaryView {
fn from(task: ImportTaskSummary) -> Self {
impl From<(ImportTaskSummary, crate::time::TimePresenter)> for SummaryView {
fn from((task, time): (ImportTaskSummary, crate::time::TimePresenter)) -> Self {
let counts = task.counts;
Self {
task_id: task.task_id.to_string(),
created_at_display: time.label(task.created_at, "%m-%d %H:%M:%S"),
created_at: task.created_at.to_rfc3339(),
finished_at_display: task
.finished_at
.map(|value| time.label(value, "%m-%d %H:%M:%S")),
finished_at: task.finished_at.map(|at| at.to_rfc3339()),
stop_requested: task.stop_requested,
total: task.total,
@@ -98,10 +104,10 @@ struct ItemView {
message: Option<String>,
}
impl From<ImportTaskDetail> for DetailView {
fn from(task: ImportTaskDetail) -> Self {
impl From<(ImportTaskDetail, crate::time::TimePresenter)> for DetailView {
fn from((task, time): (ImportTaskDetail, crate::time::TimePresenter)) -> Self {
Self {
summary: task.summary.into(),
summary: SummaryView::from((task.summary, time)),
items: task
.items
.into_iter()
@@ -134,6 +140,7 @@ async fn submit<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let submission_id = Uuid::parse_str(&request.submission_id)
.map_err(|_| map_wire_error(WireValidationError::new("submissionId")))?;
if submission_id.is_nil() {
@@ -168,7 +175,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::ACCEPTED,
AdminEnvelope::ok(SummaryView::from(task)),
AdminEnvelope::ok(SummaryView::from((task, time))),
))
}
@@ -176,12 +183,13 @@ async fn list<S>(auth: AdminAuth, State(state): State<S>) -> impl IntoResponse
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let items = state
.admin_services()
.import_tasks()
.list(&auth.context().mutation_context())
.into_iter()
.map(SummaryView::from)
.map(|value| SummaryView::from((value, time)))
.collect();
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(ListView { items }))
}
@@ -194,6 +202,7 @@ async fn detail<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let task = state
.admin_services()
.import_tasks()
@@ -204,7 +213,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(DetailView::from(task)),
AdminEnvelope::ok(DetailView::from((task, time))),
))
}
@@ -216,6 +225,7 @@ async fn stop<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let task = state
.admin_services()
.import_tasks()
@@ -226,7 +236,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(DetailView::from(task)),
AdminEnvelope::ok(DetailView::from((task, time))),
))
}
@@ -14,7 +14,7 @@ use axum::{
routing::{get, post},
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, FixedOffset, Utc};
use chrono::{DateTime, Utc};
use futures::{Stream, StreamExt as _};
use gateway_admin::model::{
AdminError as AdminServiceError, PageSize,
@@ -8,6 +8,7 @@ pub(super) fn account_page_data(
page: u32,
page_size: u16,
now: DateTime<Utc>,
time: crate::time::TimePresenter,
) -> AccountPageData {
let total_pages = if result.total == 0 {
0
@@ -18,7 +19,7 @@ pub(super) fn account_page_data(
items: result
.items
.into_iter()
.map(|item| account_view(item, now))
.map(|item| account_view(item, now, time))
.collect(),
page: PageMeta::new(page, u32::from(page_size), result.total, total_pages),
summary: AccountSummaryView {
@@ -35,9 +36,10 @@ pub(super) fn account_page_data(
pub(super) fn account_refresh_data(
result: AccountRefreshResult,
now: DateTime<Utc>,
time: crate::time::TimePresenter,
) -> AccountRefreshData {
AccountRefreshData {
account: account_view(result.account, now),
account: account_view(result.account, now, time),
}
}
@@ -57,7 +59,11 @@ pub(super) fn account_models_data(result: ProviderModels) -> AccountModelsData {
}
}
pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
pub(super) fn account_view(
item: AccountDirectoryItem,
now: DateTime<Utc>,
time: crate::time::TimePresenter,
) -> AccountView {
let AccountDirectoryItem {
account,
capacity,
@@ -69,15 +75,18 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
} = item;
let status = projection.status.as_str().to_owned();
let cooldown = projection.cooldown;
let expires_at = account.access_token_expires_at.as_ref().map(china_rfc3339);
let added_at = china_rfc3339(&account.created_at);
let updated_at = china_rfc3339(&account.updated_at);
let expires_at = account
.access_token_expires_at
.as_ref()
.map(|value| time.rfc3339(value));
let added_at = time.rfc3339(&account.created_at);
let updated_at = time.rfc3339(&account.updated_at);
let usage_period = quota.usage_window().map(|(_, period)| period);
let mut usage = account_usage_view(usage, usage_period, now);
let mut usage = account_usage_view(usage, usage_period, now, time);
if account.authentication_kind == "api_key" {
usage.window_label_display = "通用额度".to_owned();
}
let (quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now);
let (quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now, time);
AccountView {
capabilities: capabilities.into(),
id: account.id.clone(),
@@ -124,33 +133,37 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
access_token_expires_at_display: account
.access_token_expires_at
.as_ref()
.map(china_datetime),
.map(|value| time.datetime(value)),
refresh_token_expires_at,
next_refresh_at: account.next_refresh_at.map(|value| china_rfc3339(&value)),
next_refresh_at: account.next_refresh_at.map(|value| time.rfc3339(&value)),
next_refresh_at_display: account.next_refresh_at.map(|value| time.datetime(&value)),
added_at,
added_at_display: china_datetime(&account.created_at),
added_at_display: time.datetime(&account.created_at),
updated_at,
updated_at_display: china_datetime(&account.updated_at),
updated_at_display: time.datetime(&account.updated_at),
quota,
usage,
}
}
impl From<AccountQuotaForecastReport> for AccountQuotaForecastData {
fn from(report: AccountQuotaForecastReport) -> Self {
impl From<(AccountQuotaForecastReport, crate::time::TimePresenter)> for AccountQuotaForecastData {
fn from((report, time): (AccountQuotaForecastReport, crate::time::TimePresenter)) -> Self {
Self {
account_id: report.account_id,
generated_at: china_rfc3339(&report.generated_at),
generated_at: time.rfc3339(&report.generated_at),
forecasts: report
.forecasts
.into_iter()
.map(quota_forecast_view)
.map(|value| quota_forecast_view(value, time))
.collect(),
}
}
}
fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastView {
fn quota_forecast_view(
forecast: AccountQuotaForecast,
time: crate::time::TimePresenter,
) -> AccountQuotaForecastView {
AccountQuotaForecastView {
period: match forecast.period {
AccountUsagePeriod::Weekly => "weekly",
@@ -164,11 +177,11 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi
used_percent_display: source
.used_percent
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
observed_at: source.observed_at.map(|value| china_rfc3339(&value)),
observed_at: source.observed_at.map(|value| time.rfc3339(&value)),
observed_at_display: source
.observed_at
.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
reset_at: china_rfc3339(&source.reset_at),
.map_or_else(|| "—".to_owned(), |value| time.datetime(&value)),
reset_at: time.rfc3339(&source.reset_at),
tokens_display: display_optional_tokens(source.tokens),
usd_display: forecast_usd_display(source.usd),
}),
@@ -194,16 +207,34 @@ pub(super) fn account_quota_view(
mut quota: ProviderQuota,
cooldown: Option<gateway_core::account::AccountCooldown>,
now: DateTime<Utc>,
time: crate::time::TimePresenter,
) -> (AccountQuotaView, Option<String>) {
quota.apply_limit_reached_display();
let refresh_token_expires_at = quota
.refresh_token_expires_at
.map(|value| china_rfc3339(&value));
.map(|value| time.rfc3339(&value));
let refreshed_at_display = quota
.observed_at
.map_or_else(|| "—".to_owned(), |value| relative_time(value, now));
let windows = quota.windows.into_iter().map(quota_window_view).collect();
let rate_limited_until = cooldown.map(|value| china_datetime(&value.until.into()));
.map_or_else(|| "—".to_owned(), |value| time.relative(value, now));
let windows = quota
.windows
.into_iter()
.map(|value| quota_window_view(value, time))
.collect();
let rate_limited_until = cooldown.map(|value| time.rfc3339(&value.until.into()));
let rate_limit_recovery_display = cooldown.map(|value| {
let until = DateTime::<Utc>::from(value.until);
if value.kind.requires_probe() && until <= now {
return "等待探测成功".to_owned();
}
let minutes = (until - now).num_seconds().saturating_add(30) / 60;
match minutes {
..1 => time.datetime(&until),
1..60 => format!("剩余 {minutes} 分钟"),
_ if minutes % 60 == 0 => format!("剩余 {} 小时", minutes / 60),
_ => format!("剩余 {} 小时 {} 分", minutes / 60, minutes % 60),
}
});
let rate_limit_reason = cooldown.map(|value| {
if value.kind.is_capacity_freeze() {
"capacity_freeze".to_owned()
@@ -217,15 +248,24 @@ pub(super) fn account_quota_view(
refreshed_at_display,
limit_reached: quota.limit_reached,
rate_limited_until,
rate_limit_recovery_display,
rate_limit_reason,
recovery_probe_required,
windows,
credits: quota.credits.map(|credits| AccountQuotaCreditsView {
has_credits: credits.has_credits,
unlimited: credits.unlimited,
balance: credits.balance,
}),
},
refresh_token_expires_at,
)
}
pub(crate) fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
pub(crate) fn quota_window_view(
window: ProviderQuotaWindow,
time: crate::time::TimePresenter,
) -> AccountQuotaWindowView {
let ProviderQuotaWindow {
key,
group,
@@ -257,12 +297,14 @@ pub(crate) fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWind
used_percent_display: used_percent
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
limit_reached,
local_usage: local_usage.as_ref().map(quota_local_usage),
reset_at_display: reset_at.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
local_usage: local_usage
.as_ref()
.map(|value| quota_local_usage(value, time)),
reset_at_display: reset_at.map_or_else(|| "—".to_owned(), |value| time.datetime(&value)),
}
}
pub(super) fn quota_local_usage(usage: &AccountUsage) -> Value {
pub(super) fn quota_local_usage(usage: &AccountUsage, time: crate::time::TimePresenter) -> Value {
let total_tokens = usage.total_tokens.unwrap_or_default();
serde_json::json!({
"requestCount": usage.request_count,
@@ -281,10 +323,7 @@ pub(super) fn quota_local_usage(usage: &AccountUsage) -> Value {
"imageRequestFailedCount": usage.image_request_failed_count,
"totalTokens": total_tokens,
"totalTokensDisplay": format_compact_number(total_tokens),
"requestBuckets": usage.request_buckets.iter().map(|bucket| serde_json::json!({
"bucketStart": bucket.bucket_start,
"requestCount": bucket.request_count,
})).collect::<Vec<_>>(),
"requestBuckets": time.request_buckets(usage.request_buckets.iter().map(|bucket| (bucket.bucket_start, bucket.request_count))),
})
}
@@ -292,6 +331,7 @@ pub(super) fn account_usage_view(
usage: Option<AccountUsage>,
period: Option<AccountUsagePeriod>,
now: DateTime<Utc>,
time: crate::time::TimePresenter,
) -> AccountUsageView {
let Some(usage) = usage else {
return empty_account_usage();
@@ -338,10 +378,11 @@ pub(super) fn account_usage_view(
created_tokens_display: display_optional_tokens(usage.cache_write_tokens),
read_tokens: usage.cached_tokens,
read_tokens_display: display_optional_tokens(usage.cached_tokens),
last_used_at: usage.last_used_at.map(|value| china_rfc3339(&value)),
last_used_at: usage.last_used_at.map(|value| time.rfc3339(&value)),
last_used_at_full_display: usage.last_used_at.map(|value| time.datetime(&value)),
last_used_at_display: usage
.last_used_at
.map_or_else(|| "—".to_owned(), |value| relative_time(value, now)),
.map_or_else(|| "—".to_owned(), |value| time.relative(value, now)),
cost_estimate_status: cost_estimate_status.to_owned(),
known_cost_count: Some(known_count),
partial_cost_count: Some(u64::from(cost_estimate_status == "partial")),
@@ -350,7 +391,7 @@ pub(super) fn account_usage_view(
models: usage
.models
.into_iter()
.map(|model| account_model_usage_view(model, now))
.map(|model| account_model_usage_view(model, now, time))
.collect(),
}
}
@@ -358,6 +399,7 @@ pub(super) fn account_usage_view(
pub(super) fn account_model_usage_view(
usage: AccountModelUsage,
now: DateTime<Utc>,
time: crate::time::TimePresenter,
) -> ModelUsageView {
let known_count = usage
.cost_coverage
@@ -414,8 +456,9 @@ pub(super) fn account_model_usage_view(
partial_cost_count: u64::from(cost_estimate_status == "partial"),
unknown_cost_count: usage.cost_coverage.unavailable_count,
costs: usage.costs.iter().map(account_currency_cost_view).collect(),
last_used_at: china_rfc3339(&usage.last_used_at),
last_used_at_display: relative_time(usage.last_used_at, now),
last_used_at: time.rfc3339(&usage.last_used_at),
last_used_at_full_display: Some(time.datetime(&usage.last_used_at)),
last_used_at_display: time.relative(usage.last_used_at, now),
}
}
@@ -459,6 +502,7 @@ pub(super) fn empty_account_usage() -> AccountUsageView {
read_tokens: None,
read_tokens_display: "—".to_owned(),
last_used_at: None,
last_used_at_full_display: None,
last_used_at_display: "—".to_owned(),
cost_estimate_status: "unavailable".to_owned(),
known_cost_count: None,
@@ -469,38 +513,6 @@ pub(super) fn empty_account_usage() -> AccountUsageView {
}
}
pub(super) fn relative_time(value: DateTime<Utc>, now: DateTime<Utc>) -> String {
let elapsed = now.signed_duration_since(value);
if elapsed.num_seconds() < 0 {
return china_datetime(&value);
}
if elapsed.num_seconds() < 60 {
return "刚刚".to_owned();
}
if elapsed.num_minutes() < 60 {
return format!("{} 分钟前", elapsed.num_minutes());
}
if elapsed.num_hours() < 24 {
return format!("{} 小时前", elapsed.num_hours());
}
format!("{} 天前", elapsed.num_days())
}
pub(super) fn china_offset() -> FixedOffset {
FixedOffset::east_opt(8 * 60 * 60).expect("UTC+8 is a valid fixed offset")
}
pub(super) fn china_rfc3339(value: &DateTime<Utc>) -> String {
value.with_timezone(&china_offset()).to_rfc3339()
}
pub(super) fn china_datetime(value: &DateTime<Utc>) -> String {
value
.with_timezone(&china_offset())
.format("%Y-%m-%d %H:%M:%S")
.to_string()
}
pub(super) fn map_wire_error(error: WireValidationError) -> AdminError {
AdminError::bad_request(format!("{} 字段不合法", error.field()))
}
@@ -297,6 +297,7 @@ pub struct AccountView {
pub access_token_expires_at_display: Option<String>,
pub refresh_token_expires_at: Option<String>,
pub next_refresh_at: Option<String>,
pub next_refresh_at_display: Option<String>,
pub added_at: String,
pub added_at_display: String,
pub updated_at: String,
@@ -394,9 +395,20 @@ pub struct AccountQuotaView {
pub limit_reached: bool,
/// 冷却到期或可开始恢复探测的时间;非限流中为 `null`。
pub rate_limited_until: Option<String>,
pub rate_limit_recovery_display: Option<String>,
pub rate_limit_reason: Option<String>,
pub recovery_probe_required: bool,
pub windows: Vec<AccountQuotaWindowView>,
pub credits: Option<AccountQuotaCreditsView>,
}
/// 上游点数安全视图,不透出额度响应中的其他 Provider 字段。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountQuotaCreditsView {
pub has_credits: bool,
pub unlimited: bool,
pub balance: Option<String>,
}
/// 一个 quota 时间窗口。
@@ -450,6 +462,7 @@ pub struct AccountUsageView {
pub read_tokens_display: String,
pub last_used_at: Option<String>,
pub last_used_at_display: String,
pub last_used_at_full_display: Option<String>,
pub cost_estimate_status: String,
pub known_cost_count: Option<u64>,
pub partial_cost_count: Option<u64>,
@@ -492,6 +505,7 @@ pub struct ModelUsageView {
pub costs: Vec<CurrencyCostView>,
pub last_used_at: String,
pub last_used_at_display: String,
pub last_used_at_full_display: Option<String>,
}
/// 单一货币的可查询成本。
@@ -731,7 +745,7 @@ impl TryFrom<ProviderModelCatalogDocument> for AccountModelCatalogData {
.map_err(|_| UnsupportedModelCatalogDocument)?;
Ok(Self {
model_count: result.model_count,
observed_at: china_rfc3339(&result.observed_at),
observed_at: result.observed_at.to_rfc3339(),
catalog,
})
}
@@ -765,16 +779,21 @@ pub struct AccountPersonalInfoData {
pub subscription: Option<AccountSubscriptionData>,
}
impl From<AccountPersonalInfo> for AccountPersonalInfoData {
fn from(info: AccountPersonalInfo) -> Self {
impl From<(AccountPersonalInfo, crate::time::TimePresenter)> for AccountPersonalInfoData {
fn from((info, time): (AccountPersonalInfo, crate::time::TimePresenter)) -> Self {
let (profile, profile_error) = match info.profile {
Ok(profile) => (Some(AccountProfileStatisticsData::from(profile)), None),
Ok(profile) => (
Some(AccountProfileStatisticsData::from((profile, time))),
None,
),
Err(error) => (None, Some(error.message().to_owned())),
};
Self {
profile,
profile_error,
subscription: info.subscription.map(AccountSubscriptionData::from),
subscription: info
.subscription
.map(|value| AccountSubscriptionData::from((value, time))),
}
}
}
@@ -784,21 +803,30 @@ impl From<AccountPersonalInfo> for AccountPersonalInfoData {
#[serde(rename_all = "camelCase")]
pub struct AccountSubscriptionData {
pub starts_at: Option<String>,
pub starts_at_display: Option<String>,
pub expires_at: String,
pub expires_at_display: String,
pub will_renew: Option<bool>,
pub billing_period: Option<String>,
pub billing_currency: Option<String>,
pub observed_at: String,
pub observed_at_display: String,
}
impl From<ProviderSubscription> for AccountSubscriptionData {
fn from(subscription: ProviderSubscription) -> Self {
impl From<(ProviderSubscription, crate::time::TimePresenter)> for AccountSubscriptionData {
fn from((subscription, time): (ProviderSubscription, crate::time::TimePresenter)) -> Self {
Self {
starts_at_display: subscription
.starts_at
.as_ref()
.map(|value| time.datetime(value)),
starts_at: subscription.starts_at.map(|value| value.to_rfc3339()),
expires_at_display: time.datetime(&subscription.expires_at),
expires_at: subscription.expires_at.to_rfc3339(),
will_renew: subscription.will_renew,
billing_period: subscription.billing_period,
billing_currency: subscription.billing_currency,
observed_at_display: time.datetime(&subscription.observed_at),
observed_at: subscription.observed_at.to_rfc3339(),
}
}
@@ -808,6 +836,7 @@ impl From<ProviderSubscription> for AccountSubscriptionData {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileStatisticsData {
pub activity_calendar: Option<ProfileActivityCalendar>,
pub display_name: Option<String>,
pub username: Option<String>,
pub image_url: Option<String>,
@@ -858,9 +887,15 @@ pub struct AccountProfileInvocationView {
pub usage_count: Option<u64>,
}
impl From<ProviderProfileStatistics> for AccountProfileStatisticsData {
fn from(statistics: ProviderProfileStatistics) -> Self {
impl From<(ProviderProfileStatistics, crate::time::TimePresenter)>
for AccountProfileStatisticsData
{
fn from((statistics, time): (ProviderProfileStatistics, crate::time::TimePresenter)) -> Self {
Self {
activity_calendar: statistics
.daily_usage
.as_deref()
.and_then(|daily| profile_activity_calendar(daily, time.today())),
display_name: statistics.display_name,
username: statistics.username,
image_url: statistics.image_url,
@@ -939,6 +974,7 @@ pub struct AccountResetCreditView {
pub status: Option<String>,
pub title: Option<String>,
pub expires_at: Option<String>,
pub expires_at_display: Option<String>,
pub reset_type: Option<String>,
}
@@ -950,36 +986,41 @@ pub struct AccountResetCreditResultData {
pub credit: Option<AccountResetCreditView>,
}
impl From<ProviderResetCredit> for AccountResetCreditView {
fn from(credit: ProviderResetCredit) -> Self {
impl From<(ProviderResetCredit, crate::time::TimePresenter)> for AccountResetCreditView {
fn from((credit, time): (ProviderResetCredit, crate::time::TimePresenter)) -> Self {
Self {
id: credit.id,
status: credit.status,
title: credit.title,
expires_at_display: credit.expires_at.as_ref().map(|value| time.datetime(value)),
expires_at: credit.expires_at.map(|value| value.to_rfc3339()),
reset_type: credit.reset_type,
}
}
}
impl From<ProviderResetCredits> for AccountResetCreditsData {
fn from(credits: ProviderResetCredits) -> Self {
impl From<(ProviderResetCredits, crate::time::TimePresenter)> for AccountResetCreditsData {
fn from((credits, time): (ProviderResetCredits, crate::time::TimePresenter)) -> Self {
Self {
available_count: credits.available_count,
credits: credits
.credits
.into_iter()
.map(AccountResetCreditView::from)
.map(|value| AccountResetCreditView::from((value, time)))
.collect(),
}
}
}
impl From<ProviderResetCreditResult> for AccountResetCreditResultData {
fn from(result: ProviderResetCreditResult) -> Self {
impl From<(ProviderResetCreditResult, crate::time::TimePresenter)>
for AccountResetCreditResultData
{
fn from((result, time): (ProviderResetCreditResult, crate::time::TimePresenter)) -> Self {
Self {
code: result.code,
credit: result.credit.map(AccountResetCreditView::from),
credit: result
.credit
.map(|value| AccountResetCreditView::from((value, time))),
}
}
}
@@ -995,7 +1036,15 @@ impl AccountExportData {
Self(value)
}
pub(super) fn from_result(bundle: AccountExportBundle) -> Self {
pub(super) fn from_result(
bundle: AccountExportBundle,
time: crate::time::TimePresenter,
) -> Self {
let filename = format!(
"cpr-accounts-selected-{}-{}.json",
bundle.documents.len(),
time.label(bundle.exported_at, "%Y-%m-%d")
);
let documents = bundle
.documents
.into_iter()
@@ -1008,6 +1057,7 @@ impl AccountExportData {
.collect::<Vec<_>>();
Self::new(serde_json::json!({
"exportedAt": bundle.exported_at.to_rfc3339(),
"fileName": filename,
"documents": documents,
}))
}
@@ -1023,9 +1073,9 @@ pub struct AccountConnectionTestEvent {
pub data: Value,
}
impl From<DomainConnectionTestEvent> for AccountConnectionTestEvent {
fn from(event: DomainConnectionTestEvent) -> Self {
let data = match event {
impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountConnectionTestEvent {
fn from((event, time): (DomainConnectionTestEvent, crate::time::TimePresenter)) -> Self {
let mut data = match event {
DomainConnectionTestEvent::Started { model } => serde_json::json!({
"type": "test_start",
"model": model,
@@ -1078,6 +1128,75 @@ impl From<DomainConnectionTestEvent> for AccountConnectionTestEvent {
"upstreamBody": upstream_body
}),
};
let now = Utc::now();
data["occurredAt"] = now.to_rfc3339().into();
data["occurredAtDisplay"] = time.datetime(&now).into();
data["timeDisplay"] = time.time(&now).into();
Self { data }
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileActivityCalendar {
pub range_label: String,
pub weeks: Vec<ProfileActivityWeek>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileActivityWeek {
pub key: String,
pub month_label: Option<String>,
pub cells: Vec<ProfileActivityCell>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileActivityCell {
pub date: String,
pub date_display: String,
pub tokens: u64,
pub is_future: bool,
}
fn profile_activity_calendar(
daily: &[ProviderProfileDailyUsage],
today: chrono::NaiveDate,
) -> Option<ProfileActivityCalendar> {
use chrono::{Datelike as _, Days};
let week_start =
today.checked_sub_days(Days::new(u64::from(today.weekday().num_days_from_sunday())))?;
let start = week_start.checked_sub_days(Days::new(51 * 7))?;
let mut counts = std::collections::BTreeMap::<_, u64>::new();
for entry in daily {
if entry.date >= start && entry.date <= today {
let value = counts.entry(entry.date).or_default();
*value = value.saturating_add(entry.tokens);
}
}
let mut weeks = Vec::with_capacity(52);
for index in 0..52 {
let mut cells = Vec::with_capacity(7);
let mut month_label = None;
let week = start.checked_add_days(Days::new(index * 7))?;
for day in 0..7 {
let date = week.checked_add_days(Days::new(day))?;
if date.day() == 1 {
month_label = Some(format!("{}月", date.month()));
}
cells.push(ProfileActivityCell {
date: date.to_string(),
date_display: format!("{}年{}月{}日", date.year(), date.month(), date.day()),
tokens: counts.get(&date).copied().unwrap_or_default(),
is_future: date > today,
});
}
weeks.push(ProfileActivityWeek {
key: week.to_string(),
month_label,
cells,
});
}
Some(ProfileActivityCalendar {
range_label: format!("{start} 至 {today}"),
weeks,
})
}
+46 -14
View File
@@ -40,16 +40,18 @@ pub struct BackupSettingsView {
pub verified: bool,
pub schedule_enabled: bool,
pub cron_expression: Option<String>,
pub schedule_timezone: Option<String>,
pub retention_days: u32,
pub retention_count: u32,
pub next_run_at: Option<DateTime<Utc>>,
pub next_run_at_display: Option<String>,
pub last_verified_at: Option<DateTime<Utc>>,
pub last_verified_at_display: Option<String>,
pub updated_at: DateTime<Utc>,
pub updated_at_display: String,
}
impl From<BackupSettings> for BackupSettingsView {
fn from(settings: BackupSettings) -> Self {
impl From<(BackupSettings, crate::time::TimePresenter)> for BackupSettingsView {
fn from((settings, time): (BackupSettings, crate::time::TimePresenter)) -> Self {
let verified = settings.storage_verified();
Self {
storage_revision: settings.storage_revision,
@@ -65,11 +67,19 @@ impl From<BackupSettings> for BackupSettingsView {
verified,
schedule_enabled: settings.schedule_enabled,
cron_expression: settings.cron_expression,
schedule_timezone: settings.schedule_timezone,
retention_days: settings.retention_days,
retention_count: settings.retention_count,
next_run_at_display: settings
.next_run_at
.as_ref()
.map(|value| time.datetime(value)),
next_run_at: settings.next_run_at,
last_verified_at_display: settings
.last_verified_at
.as_ref()
.map(|value| time.datetime(value)),
last_verified_at: settings.last_verified_at,
updated_at_display: time.datetime(&settings.updated_at),
updated_at: settings.updated_at,
}
}
@@ -95,7 +105,6 @@ pub struct UpdateBackupStorageRequest {
pub struct UpdateBackupScheduleRequest {
pub schedule_enabled: bool,
pub cron_expression: String,
pub schedule_timezone: String,
pub retention_days: u32,
pub retention_count: u32,
}
@@ -108,6 +117,7 @@ pub struct BackupRecordView {
pub trigger_kind: String,
pub status: String,
pub scheduled_at: Option<DateTime<Utc>>,
pub scheduled_at_display: Option<String>,
pub object_key: String,
pub size_bytes: Option<u64>,
pub sha256: Option<String>,
@@ -115,18 +125,27 @@ pub struct BackupRecordView {
pub error_code: Option<String>,
pub error_message: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub started_at_display: Option<String>,
pub completed_at: Option<DateTime<Utc>>,
pub completed_at_display: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
pub expires_at_display: Option<String>,
pub created_at: DateTime<Utc>,
pub created_at_display: String,
pub updated_at: DateTime<Utc>,
pub updated_at_display: String,
}
impl From<BackupRecord> for BackupRecordView {
fn from(record: BackupRecord) -> Self {
impl From<(BackupRecord, crate::time::TimePresenter)> for BackupRecordView {
fn from((record, time): (BackupRecord, crate::time::TimePresenter)) -> Self {
Self {
id: record.id,
trigger_kind: record.trigger_kind.to_string(),
status: record.status.to_string(),
scheduled_at_display: record
.scheduled_at
.as_ref()
.map(|value| time.datetime(value)),
scheduled_at: record.scheduled_at,
object_key: record.object_key,
size_bytes: record.size_bytes,
@@ -134,10 +153,18 @@ impl From<BackupRecord> for BackupRecordView {
attempt_count: record.attempt_count,
error_code: record.error_code,
error_message: record.error_message,
started_at_display: record.started_at.as_ref().map(|value| time.datetime(value)),
started_at: record.started_at,
completed_at_display: record
.completed_at
.as_ref()
.map(|value| time.datetime(value)),
completed_at: record.completed_at,
expires_at_display: record.expires_at.as_ref().map(|value| time.datetime(value)),
expires_at: record.expires_at,
created_at_display: time.datetime(&record.created_at),
created_at: record.created_at,
updated_at_display: time.datetime(&record.updated_at),
updated_at: record.updated_at,
}
}
@@ -244,6 +271,7 @@ async fn backup_settings<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let settings = state
.admin_services()
.backups()
@@ -252,7 +280,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(BackupSettingsView::from(settings)),
AdminEnvelope::ok(BackupSettingsView::from((settings, time))),
))
}
@@ -264,6 +292,7 @@ async fn update_backup_storage<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let command = UpdateBackupStorageCommand {
endpoint: request.endpoint,
region: request.region,
@@ -281,7 +310,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(BackupSettingsView::from(settings)),
AdminEnvelope::ok(BackupSettingsView::from((settings, time))),
))
}
@@ -312,10 +341,10 @@ async fn update_backup_schedule<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let command = UpdateBackupScheduleCommand {
schedule_enabled: request.schedule_enabled,
cron_expression: request.cron_expression,
schedule_timezone: request.schedule_timezone,
retention_days: request.retention_days,
retention_count: request.retention_count,
};
@@ -327,7 +356,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(BackupSettingsView::from(settings)),
AdminEnvelope::ok(BackupSettingsView::from((settings, time))),
))
}
@@ -339,6 +368,7 @@ async fn backup_records<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let page_size = gateway_admin::model::PageSize::new(query.page_size.unwrap_or(20))
.map_err(|_| AdminError::bad_request("pageSize 不合法"))?;
let page = query.page.unwrap_or(1);
@@ -378,7 +408,7 @@ where
let items: Vec<BackupRecordView> = page_result
.items
.into_iter()
.map(BackupRecordView::from)
.map(|value| BackupRecordView::from((value, time)))
.collect();
Ok(AdminResponse::new(
StatusCode::OK,
@@ -394,6 +424,7 @@ async fn create_backup<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let expires_at = body
.and_then(|body| body.0.expires_in_days)
.filter(|&days| days > 0)
@@ -406,7 +437,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::ACCEPTED,
AdminEnvelope::ok(BackupRecordView::from(record)),
AdminEnvelope::ok(BackupRecordView::from((record, time))),
))
}
@@ -445,6 +476,7 @@ async fn delete_backup<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let record = state
.admin_services()
.backups()
@@ -453,7 +485,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(BackupRecordView::from(record)),
AdminEnvelope::ok(BackupRecordView::from((record, time))),
))
}
@@ -353,10 +353,16 @@ pub struct ClientKeyView {
daily_used_usd: String,
weekly_used_usd: String,
daily_resets_at: Option<DateTime<Utc>>,
daily_resets_at_display: Option<String>,
weekly_resets_at: Option<DateTime<Utc>>,
weekly_resets_at_display: Option<String>,
created_at: DateTime<Utc>,
created_at_display: String,
updated_at: DateTime<Utc>,
updated_at_display: String,
last_used_at: Option<DateTime<Utc>>,
last_used_at_display: String,
last_used_at_full_display: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
@@ -368,8 +374,8 @@ pub struct ClientKeyGroupView {
enabled: bool,
}
impl From<ClientKeyRecord> for ClientKeyView {
fn from(record: ClientKeyRecord) -> Self {
impl From<(ClientKeyRecord, crate::time::TimePresenter)> for ClientKeyView {
fn from((record, time): (ClientKeyRecord, crate::time::TimePresenter)) -> Self {
let routing_scope = if record.groups.is_empty() {
"all"
} else {
@@ -413,10 +419,25 @@ impl From<ClientKeyRecord> for ClientKeyView {
weekly_limit_usd: record.budget.limits.weekly_usd.canonical(),
daily_used_usd: record.budget.daily_used_usd.canonical(),
weekly_used_usd: record.budget.weekly_used_usd.canonical(),
daily_resets_at_display: record
.budget
.daily_resets_at
.map(|value| time.datetime(&value.into())),
daily_resets_at: record.budget.daily_resets_at.map(DateTime::from),
weekly_resets_at_display: record
.budget
.weekly_resets_at
.map(|value| time.datetime(&value.into())),
weekly_resets_at: record.budget.weekly_resets_at.map(DateTime::from),
created_at_display: time.datetime(&record.created_at),
created_at: record.created_at,
updated_at_display: time.datetime(&record.updated_at),
updated_at: record.updated_at,
last_used_at_display: time.relative_optional(record.last_used_at, time.now()),
last_used_at_full_display: record
.last_used_at
.as_ref()
.map(|value| time.datetime(value)),
last_used_at: record.last_used_at,
}
}
@@ -443,10 +464,12 @@ impl ClientKeyListData {
}
}
impl TryFrom<ClientKeyPage> for ClientKeyListData {
impl TryFrom<(ClientKeyPage, crate::time::TimePresenter)> for ClientKeyListData {
type Error = WireValidationError;
fn try_from(page: ClientKeyPage) -> Result<Self, Self::Error> {
fn try_from(
(page, time): (ClientKeyPage, crate::time::TimePresenter),
) -> Result<Self, Self::Error> {
let next_cursor = page
.next_cursor
.map(wire_cursor)
@@ -455,7 +478,10 @@ impl TryFrom<ClientKeyPage> for ClientKeyListData {
.map(encode_client_key_cursor)
.transpose()?;
Ok(Self::new(
page.items.into_iter().map(Into::into).collect(),
page.items
.into_iter()
.map(|value| ClientKeyView::from((value, time)))
.collect(),
next_cursor,
page.total,
))
@@ -888,13 +914,14 @@ async fn list_client_keys<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let result = state
.admin_services()
.client_keys()
.list(query.into_command().map_err(map_wire_error)?)
.await
.map_err(map_service_error)?;
let data = ClientKeyListData::try_from(result).map_err(|_| AdminError::internal())?;
let data = ClientKeyListData::try_from((result, time)).map_err(|_| AdminError::internal())?;
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -3,7 +3,7 @@
use std::collections::BTreeMap;
use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
use chrono::{DateTime, Duration, Utc};
use chrono::{DateTime, Utc};
use gateway_admin::model::{PageSize as DomainPageSize, observability as domain};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -1,4 +1,4 @@
//! 过渡期展示格式化;目标迁移到 Vue presenter。
//! 观测领域事实到管理页面的展示投影。
use super::*;
@@ -44,16 +44,6 @@ fn basis_points_rate(value: u64) -> f64 {
value as f64 / 10_000.0
}
pub(crate) fn china_datetime(value: &DateTime<Utc>) -> String {
(*value + Duration::hours(8))
.format("%Y-%m-%d %H:%M:%S")
.to_string()
}
pub(crate) fn china_label(value: DateTime<Utc>, format: &str) -> String {
(value + Duration::hours(8)).format(format).to_string()
}
pub(crate) fn outcome_name(outcome: &domain::RequestOutcome) -> &str {
outcome.as_str()
}
@@ -270,10 +260,13 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<Bil
}
}
pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageListRecordView {
pub(crate) fn usage_list_record_view(
record: domain::UsageListRecord,
time: crate::time::TimePresenter,
) -> UsageListRecordView {
let token_details = usage_list_token_details(&record);
let billing = billing_view(record.billing.as_ref());
let created_at_display = china_datetime(&record.started_at);
let created_at_display = time.datetime(&record.started_at);
let model = record
.upstream_model_id
.clone()
@@ -326,7 +319,10 @@ pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageLi
}
}
pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView {
pub(crate) fn usage_record_view(
record: domain::UsageRecord,
time: crate::time::TimePresenter,
) -> UsageRecordView {
let tokens = token_details(&record);
let billing = billing_view(record.billing.as_ref());
let costs = record
@@ -432,7 +428,7 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
message,
metadata,
created_at: record.started_at,
created_at_display: china_datetime(&record.started_at),
created_at_display: time.datetime(&record.started_at),
client_ip: record.client_ip,
user_agent: record.user_agent,
reasoning_effort: record.reasoning_effort,
@@ -535,11 +531,14 @@ pub(crate) fn usage_attempt_view(attempt: domain::UsageAttempt) -> UsageAttemptV
}
}
pub(crate) fn usage_detail_view(detail: domain::UsageDetail) -> UsageRecordDetailView {
pub(crate) fn usage_detail_view(
detail: domain::UsageDetail,
time: crate::time::TimePresenter,
) -> UsageRecordDetailView {
UsageRecordDetailView {
trace: detail.trace,
related_requests: detail.related_requests,
request: usage_record_view(detail.request),
request: usage_record_view(detail.request, time),
attempts: detail
.attempts
.into_iter()
@@ -549,16 +548,26 @@ pub(crate) fn usage_detail_view(detail: domain::UsageDetail) -> UsageRecordDetai
}
}
pub(crate) fn usage_page_view(page: domain::UsagePage) -> PageData<UsageListRecordView> {
pub(crate) fn usage_page_view(
page: domain::UsagePage,
time: crate::time::TimePresenter,
) -> PageData<UsageListRecordView> {
PageData {
items: page.items.into_iter().map(usage_list_record_view).collect(),
items: page
.items
.into_iter()
.map(|value| usage_list_record_view(value, time))
.collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
}
}
pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
pub(crate) fn ops_error_view(
error: domain::OpsError,
time: crate::time::TimePresenter,
) -> OpsErrorView {
let account_label = error
.provider_account_email
.as_ref()
@@ -583,6 +592,8 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
account_id: error.provider_account_ref,
account_name: error.provider_account_name,
account_email: error.provider_account_email,
account_plan_type: error.provider_account_plan_type,
account_plan_type_display: error.provider_account_plan_type_display,
route: error.endpoint.unwrap_or_else(|| "—".to_owned()),
model,
requested_model: error.requested_model_id,
@@ -626,22 +637,32 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
recovery_total_latency_ms: error.recovery_total_latency_ms,
},
created_at: error.occurred_at,
created_at_display: china_datetime(&error.occurred_at),
created_at_display: time.datetime(&error.occurred_at),
}
}
pub(crate) fn ops_page_view(page: domain::OpsErrorPage) -> PageData<OpsErrorView> {
pub(crate) fn ops_page_view(
page: domain::OpsErrorPage,
time: crate::time::TimePresenter,
) -> PageData<OpsErrorView> {
PageData {
items: page.items.into_iter().map(ops_error_view).collect(),
items: page
.items
.into_iter()
.map(|value| ops_error_view(value, time))
.collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
}
}
pub(crate) fn trend_point_view(point: domain::TrendPoint) -> TrendPointView {
let local_time = china_label(point.bucket_start, "%H:%M");
let label = china_label(point.bucket_start, "%m-%d %H:%M");
pub(crate) fn trend_point_view(
point: domain::TrendPoint,
time: crate::time::TimePresenter,
) -> TrendPointView {
let local_time = time.label(point.bucket_start, "%H:%M");
let label = time.label(point.bucket_start, "%m-%d %H:%M %:z");
let success_rate_value = point.success_rate.map(|value| value * 100.0);
TrendPointView {
time: local_time,
@@ -764,11 +785,19 @@ pub(crate) fn trend_summary_view(
}
}
pub(crate) fn trend_view(trend: domain::Trend, kind: TrendKind) -> TrendData {
pub(crate) fn trend_view(
trend: domain::Trend,
kind: TrendKind,
time: crate::time::TimePresenter,
) -> TrendData {
TrendData {
kind,
summary: trend_summary_view(kind, &trend.summary),
points: trend.points.into_iter().map(trend_point_view).collect(),
points: trend
.points
.into_iter()
.map(|value| trend_point_view(value, time))
.collect(),
}
}
@@ -787,7 +816,10 @@ pub(crate) fn reliability_display(value: Option<f64>) -> String {
value.map_or_else(|| "-".to_owned(), |value| format!("{value:.1}%"))
}
pub(crate) fn health_timeline_view(timeline: domain::HealthTimeline) -> HealthTimelineView {
pub(crate) fn health_timeline_view(
timeline: domain::HealthTimeline,
time: crate::time::TimePresenter,
) -> HealthTimelineView {
HealthTimelineView {
title: "请求健康时间线".to_owned(),
description: "有效请求可用性".to_owned(),
@@ -801,25 +833,25 @@ pub(crate) fn health_timeline_view(timeline: domain::HealthTimeline) -> HealthTi
points: timeline
.points
.into_iter()
.enumerate()
.map(|(index, point)| {
let elapsed_minutes = i64::try_from(index).unwrap_or(i64::MAX).saturating_mul(15);
HealthTimelinePointView {
time: format!("{:02}:{:02}", elapsed_minutes / 60, elapsed_minutes % 60),
status: health_status_name(point.status).to_owned(),
reliability_display: reliability_display(point.reliability_percent),
success_requests: point.success_requests,
failed_requests: point.failed_requests,
cancelled_requests: point.cancelled_requests,
incomplete_requests: point.incomplete_requests,
caller_error_requests: point.caller_error_requests,
}
.map(|point| HealthTimelinePointView {
bucket_start: point.bucket_start,
time: time.label(point.bucket_start, "%H:%M %:z"),
status: health_status_name(point.status).to_owned(),
reliability_display: reliability_display(point.reliability_percent),
success_requests: point.success_requests,
failed_requests: point.failed_requests,
cancelled_requests: point.cancelled_requests,
incomplete_requests: point.incomplete_requests,
caller_error_requests: point.caller_error_requests,
})
.collect(),
}
}
pub(crate) fn wire_profile_view(profile: domain::DashboardWireProfile) -> DashboardWireProfileView {
pub(crate) fn wire_profile_view(
profile: domain::DashboardWireProfile,
time: crate::time::TimePresenter,
) -> DashboardWireProfileView {
DashboardWireProfileView {
provider: profile.provider,
product: profile.product,
@@ -840,9 +872,11 @@ pub(crate) fn wire_profile_view(profile: domain::DashboardWireProfile) -> Dashbo
value: attribute.value,
})
.collect(),
verified_at_display: profile.verified_at.map(|value| time.datetime(&value)),
verified_at: profile.verified_at,
release: profile.release.map(|release| DashboardDesktopReleaseView {
status: release.status.into(),
checked_at_display: release.checked_at.map(|value| time.datetime(&value)),
checked_at: release.checked_at,
latest_version: release.latest_version,
latest_build: release.latest_build,
@@ -851,29 +885,10 @@ pub(crate) fn wire_profile_view(profile: domain::DashboardWireProfile) -> Dashbo
}
}
pub(crate) fn relative_time(value: Option<DateTime<Utc>>, now: DateTime<Utc>) -> String {
let Some(value) = value else {
return "从未使用".to_owned();
};
let elapsed = now.signed_duration_since(value);
if elapsed.num_seconds() < 0 {
return china_datetime(&value);
}
if elapsed.num_seconds() < 60 {
return "刚刚".to_owned();
}
if elapsed.num_minutes() < 60 {
return format!("{} 分钟前", elapsed.num_minutes());
}
if elapsed.num_hours() < 24 {
return format!("{} 小时前", elapsed.num_hours());
}
format!("{} 天前", elapsed.num_days())
}
pub(crate) fn dashboard_view(
result: domain::DashboardResult,
kind: TrendKind,
time: crate::time::TimePresenter,
) -> DashboardDataView {
let domain::DashboardResult {
observation,
@@ -937,21 +952,19 @@ pub(crate) fn dashboard_view(
.total_tokens
.map_or_else(|| "—".to_owned(), format_compact_number),
request_count: credential.request_count,
request_buckets: credential
.request_buckets
.iter()
.map(|bucket| DashboardAccountRequestBucketView {
bucket_start: bucket.bucket_start,
request_count: bucket.request_count,
})
.collect(),
request_buckets: time.request_buckets(
credential
.request_buckets
.iter()
.map(|bucket| (bucket.bucket_start, bucket.request_count)),
),
quota_used_percent: credential.quota_used_percent,
usage_window: credential
.quota_window
.map(crate::admin::accounts::quota_window_view),
.map(|window| crate::admin::accounts::quota_window_view(window, time)),
metric_label,
metric_value,
last_used: relative_time(credential.last_used_at, range.end),
last_used: time.relative_optional(credential.last_used_at, range.end),
});
}
let unavailable_accounts = provider_accounts
@@ -959,6 +972,8 @@ pub(crate) fn dashboard_view(
.saturating_sub(provider_accounts.normal);
DashboardDataView {
as_of: range.end,
as_of_display: time.datetime(&range.end),
cards: DashboardCardsView {
credentials: DashboardCredentialsCardView {
total: provider_accounts.total.to_string(),
@@ -992,13 +1007,16 @@ pub(crate) fn dashboard_view(
average_first_token_latency_ms: display_duration(average_first_token_latency_ms),
},
},
trend: trend_view(trend, kind),
health_timeline: health_timeline_view(health_timeline),
wire_profiles: wire_profiles.into_iter().map(wire_profile_view).collect(),
trend: trend_view(trend, kind, time),
health_timeline: health_timeline_view(health_timeline, time),
wire_profiles: wire_profiles
.into_iter()
.map(|value| wire_profile_view(value, time))
.collect(),
account_usage: account_usage_views,
usage_records: recent_requests
.into_iter()
.map(usage_list_record_view)
.map(|value| usage_list_record_view(value, time))
.collect(),
pool_summary: DashboardPoolSummaryView {
total: provider_accounts.total,
@@ -1033,14 +1051,17 @@ pub(crate) fn usage_summary_view(summary: domain::UsageSummary) -> UsageSummaryV
}
}
pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsightsOverviewView {
pub(crate) fn usage_insights_view(
insights: domain::UsageInsights,
time: crate::time::TimePresenter,
) -> UsageInsightsOverviewView {
let health_points = insights
.health
.points
.iter()
.map(|point| OverviewHealthPointView {
bucket: point.bucket_start,
label: china_label(point.bucket_start, "%m-%d %H:%M"),
label: time.label(point.bucket_start, "%m-%d %H:%M %:z"),
total_requests: point.total_requests,
success_requests: point.success_requests,
failed_requests: point.failed_requests,
@@ -1056,7 +1077,7 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
.iter()
.map(|point| OverviewPerformancePointView {
bucket: point.bucket_start,
label: china_label(point.bucket_start, "%m-%d %H:%M"),
label: time.label(point.bucket_start, "%m-%d %H:%M %:z"),
latency_p50_ms: point.latency_percentiles.p50_ms.map(|value| value.as_f64()),
latency_p95_ms: point.latency_percentiles.p95_ms.map(|value| value.as_f64()),
latency_p99_ms: point.latency_percentiles.p99_ms.map(|value| value.as_f64()),
@@ -1105,7 +1126,7 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
.iter()
.map(|point| OverviewCostPointView {
bucket: point.bucket_start,
label: china_label(point.bucket_start, "%m-%d %H:%M"),
label: time.label(point.bucket_start, "%m-%d %H:%M %:z"),
input_tokens: point.input_tokens,
output_tokens: point.output_tokens,
cached_tokens: point.cached_tokens,
@@ -12,6 +12,8 @@ pub const MAX_PAGE_SIZE: u16 = 100;
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DashboardQuery {
pub kind: Option<String>,
pub period: Option<String>,
pub as_of: Option<i64>,
pub start_time: Option<String>,
pub end_time: Option<String>,
}
@@ -43,6 +45,8 @@ pub struct UsageQuery {
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub search: Option<String>,
pub period: Option<String>,
pub as_of: Option<i64>,
pub start_time: Option<String>,
pub end_time: Option<String>,
}
@@ -81,6 +85,8 @@ impl DetailQuery {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DiagnosticsQuery {
pub dimension: Option<String>,
pub period: Option<String>,
pub as_of: Option<i64>,
pub start_time: Option<String>,
pub end_time: Option<String>,
pub provider: Option<String>,
@@ -116,6 +122,8 @@ pub struct OpsQuery {
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub search: Option<String>,
pub period: Option<String>,
pub as_of: Option<i64>,
pub start_time: Option<String>,
pub end_time: Option<String>,
}
@@ -279,19 +287,21 @@ pub(crate) fn request_outcome(
pub(crate) fn usage_range(
start: Option<&str>,
end: Option<&str>,
period: Option<&str>,
as_of: Option<i64>,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<domain::TimeRange, WireValidationError> {
let end = parse_datetime(end)?.unwrap_or_else(Utc::now);
let start = parse_datetime(start)?.unwrap_or(end - Duration::days(7));
domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"))
crate::time::query_range(start, end, period, as_of, timezone, "7d")
}
pub(crate) fn dashboard_today_range(
start: Option<&str>,
end: Option<&str>,
period: Option<&str>,
as_of: Option<i64>,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<domain::TimeRange, WireValidationError> {
let end = parse_datetime(end)?.unwrap_or_else(Utc::now);
let start = parse_datetime(start)?.unwrap_or_else(|| domain::china_day_start(end));
domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"))
crate::time::query_range(start, end, period, as_of, timezone, "today")
}
pub(crate) fn usage_filter(query: &UsageQuery) -> Result<domain::UsageFilter, WireValidationError> {
@@ -320,19 +330,31 @@ pub(crate) fn usage_filter(query: &UsageQuery) -> Result<domain::UsageFilter, Wi
})
}
pub(crate) fn usage_command(query: &UsageQuery) -> Result<domain::UsageQuery, WireValidationError> {
pub(crate) fn usage_command(
query: &UsageQuery,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<domain::UsageQuery, WireValidationError> {
let (current_page, page_size) = query.validate_pagination()?;
let page_size_value =
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
Ok(domain::UsageQuery {
range: usage_range(query.start_time.as_deref(), query.end_time.as_deref())?,
range: usage_range(
query.start_time.as_deref(),
query.end_time.as_deref(),
query.period.as_deref(),
query.as_of,
timezone,
)?,
filter: usage_filter(query)?,
current_page,
page_size: page_size_value,
})
}
pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, WireValidationError> {
pub(crate) fn ops_command(
query: &OpsQuery,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<domain::OpsErrorQuery, WireValidationError> {
let (current_page, page_size) = query.validate_pagination()?;
let page_size_value =
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
@@ -343,7 +365,13 @@ pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, Wir
.or(query.status_code),
)?;
Ok(domain::OpsErrorQuery {
range: usage_range(query.start_time.as_deref(), query.end_time.as_deref())?,
range: usage_range(
query.start_time.as_deref(),
query.end_time.as_deref(),
query.period.as_deref(),
query.as_of,
timezone,
)?,
filter: domain::OpsErrorFilter {
client_api_key_ref: non_empty(query.client_api_key_id.clone()),
request_id: non_empty(query.request_id.clone()),
@@ -39,10 +39,17 @@ pub(crate) async fn dashboard_summary<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let kind = query.trend_kind().map_err(map_wire_error)?;
// 概览默认按中国时区当日统计,与单独趋势接口保持同一口径。
let range = dashboard_today_range(query.start_time.as_deref(), query.end_time.as_deref())
.map_err(map_wire_error)?;
// 概览与独立趋势使用同一部署日界。
let range = dashboard_today_range(
query.start_time.as_deref(),
query.end_time.as_deref(),
query.period.as_deref(),
query.as_of,
state.admin_services().timezone(),
)
.map_err(map_wire_error)?;
let result = state
.admin_services()
.observability()
@@ -51,7 +58,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(dashboard_view(result, kind)),
AdminEnvelope::ok(dashboard_view(result, kind, time)),
))
}
@@ -63,9 +70,16 @@ pub(crate) async fn dashboard_trend<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let kind = query.trend_kind().map_err(map_wire_error)?;
let range = dashboard_today_range(query.start_time.as_deref(), query.end_time.as_deref())
.map_err(map_wire_error)?;
let range = dashboard_today_range(
query.start_time.as_deref(),
query.end_time.as_deref(),
query.period.as_deref(),
query.as_of,
state.admin_services().timezone(),
)
.map_err(map_wire_error)?;
let result = state
.admin_services()
.observability()
@@ -74,7 +88,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(trend_view(result, kind)),
AdminEnvelope::ok(trend_view(result, kind, time)),
))
}
@@ -86,14 +100,16 @@ pub(crate) async fn usage_records<S>(
where
S: SessionState + Send + Sync,
{
let command = usage_command(&query).map_err(map_wire_error)?;
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let command =
usage_command(&query, state.admin_services().timezone()).map_err(map_wire_error)?;
let result = state
.admin_services()
.observability()
.usage_records(command)
.await
.map_err(map_service_error)?;
let data = usage_page_view(result);
let data = usage_page_view(result, time);
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -105,6 +121,7 @@ pub(crate) async fn usage_record_detail<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
query.validate().map_err(map_wire_error)?;
let result = state
.admin_services()
@@ -114,7 +131,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(usage_detail_view(result)),
AdminEnvelope::ok(usage_detail_view(result, time)),
))
}
@@ -126,8 +143,14 @@ pub(crate) async fn usage_records_summary<S>(
where
S: SessionState + Send + Sync,
{
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
.map_err(map_wire_error)?;
let range = usage_range(
query.start_time.as_deref(),
query.end_time.as_deref(),
query.period.as_deref(),
query.as_of,
state.admin_services().timezone(),
)
.map_err(map_wire_error)?;
let filter = usage_filter(&query).map_err(map_wire_error)?;
let result = state
.admin_services()
@@ -149,8 +172,15 @@ pub(crate) async fn usage_insights_overview<S>(
where
S: SessionState + Send + Sync,
{
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
.map_err(map_wire_error)?;
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let range = usage_range(
query.start_time.as_deref(),
query.end_time.as_deref(),
query.period.as_deref(),
query.as_of,
state.admin_services().timezone(),
)
.map_err(map_wire_error)?;
let filter = usage_filter(&query).map_err(map_wire_error)?;
let result = state
.admin_services()
@@ -160,7 +190,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(usage_insights_view(result)),
AdminEnvelope::ok(usage_insights_view(result, time)),
))
}
@@ -173,8 +203,14 @@ where
S: SessionState + Send + Sync,
{
let dimension = query.dimension().map_err(map_wire_error)?;
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
.map_err(map_wire_error)?;
let range = usage_range(
query.start_time.as_deref(),
query.end_time.as_deref(),
query.period.as_deref(),
query.as_of,
state.admin_services().timezone(),
)
.map_err(map_wire_error)?;
let filter = domain::UsageFilter {
provider_kind: non_empty(query.provider),
model: non_empty(query.model),
@@ -202,13 +238,14 @@ pub(crate) async fn ops_errors<S>(
where
S: SessionState + Send + Sync,
{
let command = ops_command(&query).map_err(map_wire_error)?;
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let command = ops_command(&query, state.admin_services().timezone()).map_err(map_wire_error)?;
let result = state
.admin_services()
.observability()
.ops_errors(command)
.await
.map_err(map_service_error)?;
let data = ops_page_view(result);
let data = ops_page_view(result, time);
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -394,7 +394,7 @@ pub struct DashboardAccountUsageView {
pub plan_type_display: String,
pub tokens: String,
pub request_count: u64,
pub request_buckets: Vec<DashboardAccountRequestBucketView>,
pub request_buckets: Vec<crate::time::RequestBucketView>,
pub quota_used_percent: Option<f64>,
pub usage_window: Option<crate::admin::accounts::AccountQuotaWindowView>,
pub metric_label: String,
@@ -402,14 +402,6 @@ pub struct DashboardAccountUsageView {
pub last_used: String,
}
/// Dashboard 账号单小时请求数。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardAccountRequestBucketView {
pub bucket_start: DateTime<Utc>,
pub request_count: u64,
}
/// Provider 账号池的持久事实汇总。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -470,6 +462,7 @@ pub struct AttemptMetricsView {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthTimelinePointView {
pub bucket_start: DateTime<Utc>,
pub time: String,
pub status: String,
pub reliability_display: String,
@@ -510,6 +503,7 @@ pub struct DashboardWireProfileView {
pub attributes: Vec<DashboardWireAttributeView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verified_at: Option<DateTime<Utc>>,
pub verified_at_display: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub release: Option<DashboardDesktopReleaseView>,
}
@@ -537,6 +531,7 @@ pub struct DashboardDesktopReleaseView {
pub status: DashboardDesktopReleaseStatusView,
#[serde(skip_serializing_if = "Option::is_none")]
pub checked_at: Option<DateTime<Utc>>,
pub checked_at_display: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -570,6 +565,8 @@ impl From<domain::DesktopReleaseStatus> for DashboardDesktopReleaseStatusView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardDataView {
pub as_of: DateTime<Utc>,
pub as_of_display: String,
pub cards: DashboardCardsView,
pub trend: TrendData,
pub health_timeline: HealthTimelineView,
@@ -792,6 +789,8 @@ pub struct OpsErrorView {
pub account_id: Option<String>,
pub account_name: Option<String>,
pub account_email: Option<String>,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub route: String,
pub model: Option<String>,
pub requested_model: Option<String>,
@@ -153,13 +153,16 @@ struct ProxyView {
revision: u64,
account_count: u64,
last_test_at: Option<String>,
last_test_at_display: Option<String>,
last_test: Option<ProxyTestView>,
created_at: String,
created_at_display: String,
updated_at: String,
updated_at_display: String,
}
impl From<ProxyRecord> for ProxyView {
fn from(record: ProxyRecord) -> Self {
impl From<(ProxyRecord, crate::time::TimePresenter)> for ProxyView {
fn from((record, time): (ProxyRecord, crate::time::TimePresenter)) -> Self {
let endpoint = record.proxy.endpoint();
Self {
auto_location: record.auto_location,
@@ -171,9 +174,15 @@ impl From<ProxyRecord> for ProxyView {
endpoint,
revision: record.revision.get(),
account_count: record.account_count,
last_test_at_display: record
.last_test_at
.as_ref()
.map(|value| time.datetime(value)),
last_test_at: record.last_test_at.map(|at| at.to_rfc3339()),
last_test: record.last_test.map(Into::into),
created_at_display: time.datetime(&record.created_at),
created_at: record.created_at.to_rfc3339(),
updated_at_display: time.datetime(&record.updated_at),
updated_at: record.updated_at.to_rfc3339(),
}
}
@@ -198,10 +207,10 @@ struct MutationView {
config_revision: u64,
}
impl From<ProxyMutation> for MutationView {
fn from(value: ProxyMutation) -> Self {
impl From<(ProxyMutation, crate::time::TimePresenter)> for MutationView {
fn from((value, time): (ProxyMutation, crate::time::TimePresenter)) -> Self {
Self {
record: value.record.into(),
record: ProxyView::from((value.record, time)),
config_revision: value.config_revision.get(),
}
}
@@ -244,6 +253,7 @@ async fn list<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let result = state
.admin_services()
.proxies()
@@ -259,7 +269,11 @@ where
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyPageView {
items: result.items.into_iter().map(Into::into).collect(),
items: result
.items
.into_iter()
.map(|value| ProxyView::from((value, time)))
.collect(),
page: PageMeta::new(
result.page,
u32::from(result.page_size),
@@ -336,6 +350,7 @@ async fn create<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let proxy = request
.proxy_url
.0
@@ -357,7 +372,7 @@ where
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(MutationView::from(result)),
AdminEnvelope::ok(MutationView::from((result, time))),
))
}
@@ -393,6 +408,7 @@ async fn update<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let proxy = request
.proxy_url
.map(|value| {
@@ -420,7 +436,7 @@ where
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(MutationView::from(result)),
AdminEnvelope::ok(MutationView::from((result, time))),
))
}
@@ -480,6 +496,7 @@ async fn test<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let result = state
.admin_services()
.proxies()
@@ -493,6 +510,6 @@ where
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyView::from(result)),
AdminEnvelope::ok(ProxyView::from((result, time))),
))
}
@@ -54,6 +54,7 @@ pub struct RuntimeSettingsView {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
@@ -73,6 +74,7 @@ pub struct RuntimeSettingsView {
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
pub updated_at: DateTime<Utc>,
pub updated_at_display: String,
}
/// 原子替换全局运行参数的请求。
@@ -98,6 +100,7 @@ pub struct UpdateRuntimeSettingsRequest {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
@@ -233,6 +236,7 @@ impl UpdateRuntimeSettingsRequest {
max_waiting_per_key: self.max_waiting_per_key,
max_waiting_per_account: self.max_waiting_per_account,
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: self.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: self.responses_max_decompressed_body_bytes,
smart_scheduling: self.smart_scheduling,
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
@@ -260,8 +264,8 @@ impl UpdateRuntimeSettingsRequest {
}
}
impl From<RuntimeSettings> for RuntimeSettingsView {
fn from(settings: RuntimeSettings) -> Self {
impl From<(RuntimeSettings, crate::time::TimePresenter)> for RuntimeSettingsView {
fn from((settings, time): (RuntimeSettings, crate::time::TimePresenter)) -> Self {
let provider_request_profiles = settings
.request_profiles
.into_iter()
@@ -282,6 +286,7 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
smart_scheduling_defaults: gateway_core::account::SmartSchedulingConfig::default(),
@@ -302,6 +307,7 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
updated_at_display: time.datetime(&settings.updated_at),
updated_at: settings.updated_at,
}
}
@@ -351,10 +357,11 @@ struct ClientDownloadPackageView {
size_bytes: Option<u64>,
download_url: String,
expires_at: Option<DateTime<Utc>>,
expires_at_display: Option<String>,
}
impl From<ClientDownloadPackage> for ClientDownloadPackageView {
fn from(package: ClientDownloadPackage) -> Self {
impl From<(ClientDownloadPackage, crate::time::TimePresenter)> for ClientDownloadPackageView {
fn from((package, time): (ClientDownloadPackage, crate::time::TimePresenter)) -> Self {
Self {
architecture: package.architecture.as_str().to_owned(),
source: package.source.as_str().to_owned(),
@@ -362,6 +369,10 @@ impl From<ClientDownloadPackage> for ClientDownloadPackageView {
file_name: package.file_name,
size_bytes: package.size_bytes,
download_url: package.download_url,
expires_at_display: package
.expires_at
.as_ref()
.map(|value| time.datetime(value)),
expires_at: package.expires_at,
}
}
@@ -371,18 +382,26 @@ impl From<ClientDownloadPackage> for ClientDownloadPackageView {
#[serde(rename_all = "camelCase")]
struct CodexDesktopWindowsDownloadsView {
resolved_at: DateTime<Utc>,
resolved_at_display: String,
cached: bool,
warning: Option<String>,
packages: Vec<ClientDownloadPackageView>,
}
impl From<CodexDesktopWindowsDownloads> for CodexDesktopWindowsDownloadsView {
fn from(downloads: CodexDesktopWindowsDownloads) -> Self {
impl From<(CodexDesktopWindowsDownloads, crate::time::TimePresenter)>
for CodexDesktopWindowsDownloadsView
{
fn from((downloads, time): (CodexDesktopWindowsDownloads, crate::time::TimePresenter)) -> Self {
Self {
resolved_at_display: time.datetime(&downloads.resolved_at),
resolved_at: downloads.resolved_at,
cached: downloads.cached,
warning: downloads.warning,
packages: downloads.packages.into_iter().map(Into::into).collect(),
packages: downloads
.packages
.into_iter()
.map(|value| ClientDownloadPackageView::from((value, time)))
.collect(),
}
}
}
@@ -447,6 +466,7 @@ async fn codex_desktop_windows_downloads<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let downloads = state
.admin_services()
.client_distribution()
@@ -454,7 +474,7 @@ where
.await;
AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(CodexDesktopWindowsDownloadsView::from(downloads)),
AdminEnvelope::ok(CodexDesktopWindowsDownloadsView::from((downloads, time))),
)
}
@@ -498,6 +518,7 @@ where
AdminEnvelope::ok(serde_json::json!({
"defaults": pricing.defaults, "overrides": pricing.overrides,
"synced": pricing.synced, "syncedAt": pricing.synced_at,
"syncedAtDisplay": pricing.synced_at.as_ref().map(|value| crate::time::TimePresenter::new(state.admin_services().timezone()).datetime(value)),
})),
))
}
@@ -584,6 +605,7 @@ async fn settings<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let result = state
.admin_services()
.settings()
@@ -592,7 +614,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(RuntimeSettingsView::from(result)),
AdminEnvelope::ok(RuntimeSettingsView::from((result, time))),
))
}
@@ -604,6 +626,7 @@ async fn update_settings<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let command = request.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
@@ -613,7 +636,7 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(RuntimeSettingsView::from(result)),
AdminEnvelope::ok(RuntimeSettingsView::from((result, time))),
))
}
@@ -835,7 +858,10 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result.into_inner()),
AdminEnvelope::ok(client_profile_preview_view(
result.into_inner(),
crate::time::TimePresenter::new(state.admin_services().timezone()),
)),
))
}
@@ -865,6 +891,23 @@ where
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result.into_inner()),
AdminEnvelope::ok(client_profile_preview_view(
result.into_inner(),
crate::time::TimePresenter::new(state.admin_services().timezone()),
)),
))
}
fn client_profile_preview_view(
mut profile: serde_json::Map<String, serde_json::Value>,
time: crate::time::TimePresenter,
) -> serde_json::Map<String, serde_json::Value> {
for (raw, display) in [
("verifiedAt", "verifiedAtDisplay"),
("checkedAt", "checkedAtDisplay"),
] {
let value = time.rfc_display(profile.get(raw).and_then(serde_json::Value::as_str));
profile.insert(display.to_owned(), serde_json::json!(value));
}
profile
}
+27 -11
View File
@@ -151,11 +151,13 @@ struct SystemOperationStateView {
message: Option<String>,
error: Option<String>,
started_at: Option<String>,
started_at_display: Option<String>,
finished_at: Option<String>,
finished_at_display: Option<String>,
}
impl From<SystemOperationState> for SystemOperationStateView {
fn from(operation: SystemOperationState) -> Self {
impl From<(SystemOperationState, crate::time::TimePresenter)> for SystemOperationStateView {
fn from((operation, time): (SystemOperationState, crate::time::TimePresenter)) -> Self {
Self {
operation_id: operation.operation_id,
kind: operation.kind.map(operation_kind_name),
@@ -163,7 +165,15 @@ impl From<SystemOperationState> for SystemOperationStateView {
target_version: operation.target_version,
message: operation.message,
error: operation.error,
started_at_display: operation
.started_at
.as_ref()
.map(|value| time.datetime(value)),
started_at: operation.started_at.map(|value| value.to_rfc3339()),
finished_at_display: operation
.finished_at
.as_ref()
.map(|value| time.datetime(value)),
finished_at: operation.finished_at.map(|value| value.to_rfc3339()),
}
}
@@ -178,13 +188,13 @@ struct SystemUpdateStatusView {
operation: SystemOperationStateView,
}
impl From<SystemUpdateStatus> for SystemUpdateStatusView {
fn from(status: SystemUpdateStatus) -> Self {
impl From<(SystemUpdateStatus, crate::time::TimePresenter)> for SystemUpdateStatusView {
fn from((status, time): (SystemUpdateStatus, crate::time::TimePresenter)) -> Self {
Self {
previous_version: status.previous_version,
current_version: status.current_version,
need_restart: status.need_restart,
operation: status.operation.into(),
operation: SystemOperationStateView::from((status.operation, time)),
}
}
}
@@ -224,6 +234,7 @@ struct SystemUpdateEventView {
terminal: bool,
progress_percent: Option<u8>,
at: String,
at_display: String,
}
/// 构造固定 GET/POST 系统管理路由。
@@ -291,13 +302,16 @@ async fn update_event_stream<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let stream = state
.admin_services()
.system()
.update_events()
.map(|message| {
.map(move |message| {
let id = message.id.clone();
let data = SystemUpdateEventView::from(message).into_json().to_string();
let data = SystemUpdateEventView::from((message, time))
.into_json()
.to_string();
Ok(Event::default().event("update").id(id).data(data))
});
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
@@ -347,6 +361,7 @@ async fn update_status<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let status = state
.admin_services()
.system()
@@ -355,7 +370,7 @@ where
.map_err(map_system_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(SystemUpdateStatusView::from(status)),
AdminEnvelope::ok(SystemUpdateStatusView::from((status, time))),
))
}
@@ -445,8 +460,8 @@ where
))
}
impl From<SystemUpdateEvent> for SystemUpdateEventView {
fn from(event: SystemUpdateEvent) -> Self {
impl From<(SystemUpdateEvent, crate::time::TimePresenter)> for SystemUpdateEventView {
fn from((event, time): (SystemUpdateEvent, crate::time::TimePresenter)) -> Self {
Self {
id: event.id,
operation_id: event.operation_id,
@@ -455,6 +470,7 @@ impl From<SystemUpdateEvent> for SystemUpdateEventView {
message: event.message,
terminal: event.terminal,
progress_percent: event.progress_percent,
at_display: time.time(&event.occurred_at),
at: event.occurred_at.to_rfc3339(),
}
}
@@ -470,7 +486,7 @@ impl SystemUpdateEventView {
"message": self.message,
"terminal": self.terminal,
"progressPercent": self.progress_percent,
"at": self.at,
"at": self.at, "atDisplay": self.at_display,
})
}
}
@@ -83,19 +83,20 @@ async fn overview<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let overview = state
.admin_services()
.key_usage()
.overview(
session_cookie::value(&headers).as_deref(),
query.into_domain()?,
query.into_domain(state.admin_services().timezone())?,
)
.await
.map_err(map_admin_service_error)?
.ok_or_else(AdminError::session_required)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(presenter::overview(overview)),
AdminEnvelope::ok(presenter::overview(overview, time)),
))
}
@@ -107,19 +108,20 @@ async fn records<S>(
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let records = state
.admin_services()
.key_usage()
.records(
session_cookie::value(&headers).as_deref(),
query.into_domain()?,
query.into_domain(state.admin_services().timezone())?,
)
.await
.map_err(map_admin_service_error)?
.ok_or_else(AdminError::session_required)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(presenter::records(records)),
AdminEnvelope::ok(presenter::records(records, time)),
))
}
@@ -50,6 +50,7 @@ pub(super) fn config(secret: ClientKeySecret) -> ConfigView {
#[serde(rename_all = "camelCase")]
pub(super) struct OverviewView {
as_of: DateTime<Utc>,
as_of_display: String,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
key: KeyView,
@@ -68,9 +69,11 @@ struct KeyView {
daily_limit_usd: String,
daily_used_usd: String,
daily_resets_at: Option<DateTime<Utc>>,
daily_resets_at_display: Option<String>,
weekly_limit_usd: String,
weekly_used_usd: String,
weekly_resets_at: Option<DateTime<Utc>>,
weekly_resets_at_display: Option<String>,
}
#[derive(Serialize)]
@@ -110,14 +113,16 @@ fn metrics(value: &RequestMetrics, costs: &[CurrencyCost], coverage: &CostCovera
struct TrendPointView {
time: DateTime<Utc>,
bucket_seconds: u32,
label: String,
#[serde(flatten)]
metrics: MetricsView,
}
pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
pub(super) fn overview(value: KeyUsageOverview, time: crate::time::TimePresenter) -> OverviewView {
let key = value.key;
OverviewView {
as_of: Utc::now(),
as_of: value.overview.range.end,
as_of_display: time.time(&value.overview.range.end),
start_time: value.overview.range.start,
end_time: value.overview.range.end,
key: KeyView {
@@ -127,9 +132,17 @@ pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
requests_per_minute: key.limits.requests_per_minute,
daily_limit_usd: key.budget.limits.daily_usd.canonical(),
daily_used_usd: key.budget.daily_used_usd.canonical(),
daily_resets_at_display: key
.budget
.daily_resets_at
.map(|value| time.datetime(&value.into())),
daily_resets_at: key.budget.daily_resets_at.map(DateTime::from),
weekly_limit_usd: key.budget.limits.weekly_usd.canonical(),
weekly_used_usd: key.budget.weekly_used_usd.canonical(),
weekly_resets_at_display: key
.budget
.weekly_resets_at
.map(|value| time.datetime(&value.into())),
weekly_resets_at: key.budget.weekly_resets_at.map(DateTime::from),
},
summary: metrics(
@@ -142,6 +155,14 @@ pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
.into_iter()
.map(|point| TrendPointView {
time: point.bucket_start,
label: time.label(
point.bucket_start,
if point.granularity == Granularity::Day {
"%m-%d"
} else {
"%m-%d %H:%M %:z"
},
),
bucket_seconds: match point.granularity {
Granularity::FifteenMinutes => 900,
Granularity::Hour => 3600,
@@ -150,7 +171,7 @@ pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
metrics: metrics(&point.metrics, &point.costs, &point.cost_coverage),
})
.collect(),
health_timeline: health_timeline_view(value.health_timeline),
health_timeline: health_timeline_view(value.health_timeline, time),
}
}
@@ -159,6 +180,7 @@ pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
pub(super) struct RecordView {
id: String,
created_at: DateTime<Utc>,
created_at_display: String,
model: Option<String>,
route: Option<String>,
reasoning_effort: Option<String>,
@@ -187,12 +209,13 @@ struct OutputTimingView {
first_text_ms: Option<u64>,
}
fn success_record(value: UsageListRecord) -> RecordView {
fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView {
let token_details = Some(usage_list_token_details(&value));
let billing = billing_view(value.billing.as_ref());
RecordView {
id: value.id,
created_at: value.started_at,
created_at_display: time.datetime(&value.started_at),
model: value.requested_model_id,
route: Some(value.endpoint),
reasoning_effort: value.reasoning_effort,
@@ -215,10 +238,11 @@ fn success_record(value: UsageListRecord) -> RecordView {
}
}
fn error_record(value: OpsError) -> RecordView {
fn error_record(value: OpsError, time: crate::time::TimePresenter) -> RecordView {
RecordView {
id: value.event_id,
created_at: value.occurred_at,
created_at_display: time.datetime(&value.occurred_at),
model: value.requested_model_id,
route: value.endpoint,
reasoning_effort: value.reasoning_effort,
@@ -236,16 +260,27 @@ fn error_record(value: OpsError) -> RecordView {
}
}
pub(super) fn records(value: KeyUsageRecords) -> PageData<RecordView> {
pub(super) fn records(
value: KeyUsageRecords,
time: crate::time::TimePresenter,
) -> PageData<RecordView> {
match value {
KeyUsageRecords::Success(page) => PageData {
items: page.items.into_iter().map(success_record).collect(),
items: page
.items
.into_iter()
.map(|value| success_record(value, time))
.collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
},
KeyUsageRecords::Error(page) => PageData {
items: page.items.into_iter().map(error_record).collect(),
items: page
.items
.into_iter()
.map(|value| error_record(value, time))
.collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
@@ -1,11 +1,10 @@
//! 查询不接受 Key、账号或 Provider 范围;禁止未知字段穿透权限边界。
use axum::http::StatusCode;
use chrono::{DateTime, Duration};
use chrono::Duration;
use gateway_admin::model::{
PageSize,
key_usage::{KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecordsQuery},
observability::TimeRange,
};
use serde::Deserialize;
@@ -18,25 +17,27 @@ pub(super) struct EmptyQuery {}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct OverviewQuery {
start_time: String,
end_time: String,
period: Option<String>,
as_of: Option<i64>,
start_time: Option<String>,
end_time: Option<String>,
model: Option<String>,
}
impl OverviewQuery {
pub(super) fn into_domain(self) -> Result<KeyUsageQuery, AdminError> {
let parse = |value: &str| {
DateTime::parse_from_rfc3339(value)
.map(|value| value.to_utc())
.map_err(|_| {
AdminError::invalid_request(
StatusCode::BAD_REQUEST,
"时间必须使用 RFC3339 格式",
)
})
};
let range = TimeRange::new(parse(&self.start_time)?, parse(&self.end_time)?)
.map_err(|_| AdminError::invalid_request(StatusCode::BAD_REQUEST, "时间范围不合法"))?;
pub(super) fn into_domain(
self,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<KeyUsageQuery, AdminError> {
let range = crate::time::query_range(
self.start_time.as_deref(),
self.end_time.as_deref(),
self.period.as_deref(),
self.as_of,
timezone,
"today",
)
.map_err(|_| AdminError::invalid_request(StatusCode::BAD_REQUEST, "时间范围不合法"))?;
if range.end - range.start > Duration::days(31) {
return Err(AdminError::invalid_request(
StatusCode::BAD_REQUEST,
@@ -71,8 +72,10 @@ enum RecordKind {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct RecordsQuery {
start_time: String,
end_time: String,
period: Option<String>,
as_of: Option<i64>,
start_time: Option<String>,
end_time: Option<String>,
model: Option<String>,
current_page: Option<u32>,
page_size: Option<u16>,
@@ -81,7 +84,10 @@ pub(super) struct RecordsQuery {
}
impl RecordsQuery {
pub(super) fn into_domain(self) -> Result<KeyUsageRecordsQuery, AdminError> {
pub(super) fn into_domain(
self,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<KeyUsageRecordsQuery, AdminError> {
let current_page = self.current_page.unwrap_or(1);
let page_size = self.page_size.unwrap_or(20);
if current_page == 0 || !(1..=100).contains(&page_size) {
@@ -92,11 +98,13 @@ impl RecordsQuery {
}
Ok(KeyUsageRecordsQuery {
usage: OverviewQuery {
period: self.period,
as_of: self.as_of,
start_time: self.start_time,
end_time: self.end_time,
model: self.model,
}
.into_domain()?,
.into_domain(timezone)?,
kind: match self.kind {
RecordKind::Success => KeyUsageRecordKind::Success,
RecordKind::Error => KeyUsageRecordKind::Error,
+3
View File
@@ -35,6 +35,9 @@ mod middleware;
pub mod openai;
mod provider;
mod session_cookie;
mod time;
pub use time::{RequestBucketView, TimePresenter};
/// API-owned HTTP 与静态资源配置。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
+136
View File
@@ -0,0 +1,136 @@
//! 管理与自助页面共用的时间展示投影。
use chrono::{DateTime, Utc};
use gateway_core::time::DeploymentTimeZone;
pub(crate) fn query_range(
start: Option<&str>,
end: Option<&str>,
period: Option<&str>,
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};
use gateway_admin::model::observability::{CalendarPeriod, TimeRange};
if start.is_some() || end.is_some() {
if period.is_some() || as_of.is_some() {
return Err(WireValidationError::new("timeRange"));
}
let end = parse_datetime(end)?.unwrap_or_else(Utc::now);
let start = match parse_datetime(start)? {
Some(start) => start,
None => {
let period = CalendarPeriod::parse(default_period)
.map_err(|_| WireValidationError::new("period"))?;
return TimeRange::calendar_at(period, end, timezone)
.map_err(|_| WireValidationError::new("timeRange"));
}
};
return TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"));
}
let end = match as_of {
Some(value) => DateTime::from_timestamp_millis(value)
.ok_or_else(|| WireValidationError::new("asOf"))?,
None => Utc::now(),
};
let period = CalendarPeriod::parse(period.unwrap_or(default_period))
.map_err(|_| WireValidationError::new("period"))?;
TimeRange::calendar_at(period, end, timezone).map_err(|_| WireValidationError::new("timeRange"))
}
#[derive(Clone, Copy)]
pub struct TimePresenter {
timezone: DeploymentTimeZone,
now: DateTime<Utc>,
}
impl TimePresenter {
pub(crate) fn today(self) -> chrono::NaiveDate {
self.timezone.local(self.now).date_naive()
}
pub(crate) fn now(self) -> DateTime<Utc> {
self.now
}
pub(crate) fn rfc_display(self, value: Option<&str>) -> Option<String> {
value
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|value| self.datetime(&value.to_utc()))
}
pub(crate) fn request_buckets(
self,
buckets: impl Iterator<Item = (DateTime<Utc>, u64)>,
) -> Vec<RequestBucketView> {
buckets
.map(|(start, count)| {
let end = start + chrono::Duration::hours(1);
RequestBucketView {
bucket_start: start,
request_count: count,
label: format!(
"{}–{} · {count} 次请求",
self.label(start, "%m-%d %H:%M %:z"),
self.label(end, "%H:%M %:z")
),
}
})
.collect()
}
#[must_use]
pub fn new(timezone: DeploymentTimeZone) -> Self {
Self {
timezone,
now: Utc::now(),
}
}
pub(crate) fn datetime(self, value: &DateTime<Utc>) -> String {
self.label(*value, "%Y-%m-%d %H:%M:%S")
}
pub(crate) fn time(self, value: &DateTime<Utc>) -> String {
self.label(*value, "%H:%M:%S")
}
pub(crate) fn label(self, value: DateTime<Utc>, format: &str) -> String {
self.timezone.local(value).format(format).to_string()
}
pub(crate) fn rfc3339(self, value: &DateTime<Utc>) -> String {
self.timezone.local(*value).to_rfc3339()
}
pub(crate) fn relative(self, value: DateTime<Utc>, now: DateTime<Utc>) -> String {
let elapsed = now.signed_duration_since(value);
if elapsed.num_seconds() < 0 {
return self.datetime(&value);
}
if elapsed.num_seconds() < 60 {
return "刚刚".to_owned();
}
if elapsed.num_minutes() < 60 {
return format!("{} 分钟前", elapsed.num_minutes());
}
if elapsed.num_hours() < 24 {
return format!("{} 小时前", elapsed.num_hours());
}
format!("{} 天前", elapsed.num_days())
}
pub(crate) fn relative_optional(
self,
value: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> String {
value.map_or_else(|| "—".to_owned(), |value| self.relative(value, now))
}
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestBucketView {
pub(crate) bucket_start: DateTime<Utc>,
pub(crate) request_count: u64,
pub(crate) label: String,
}
@@ -7,6 +7,135 @@ use tower::ServiceExt as _;
use super::super::{AdminTestFixture, AdminTestState};
#[tokio::test]
async fn account_cooldown_returns_an_instant_and_server_formatted_recovery() {
use chrono::{DateTime, Duration, Utc};
use gateway_admin::model::{
Revision,
accounts::{AccountCapacity, AccountPageItem, AccountRecord},
};
use gateway_core::account::{
AccountCooldown, AccountCooldownKind, AccountStatusFacts, CredentialState, QuotaState,
resolve_account_status,
};
for zone in ["Asia/Shanghai", "UTC", "Asia/Kathmandu", "America/New_York"] {
let zone = zone
.parse::<gateway_core::time::DeploymentTimeZone>()
.unwrap();
let fixture = AdminTestFixture::with_timezone(zone).await;
fixture.auth.insert_session("valid-session");
for (seconds, kind, expected) in [
(
Some(1200),
AccountCooldownKind::RateLimit,
Some("剩余 20 分钟"),
),
(
Some(7200),
AccountCooldownKind::CapacityFreeze,
Some("剩余 2 小时"),
),
(
Some(7500),
AccountCooldownKind::CapacityFreezeProbe,
Some("剩余 2 小时 5 分"),
),
(
Some(-60),
AccountCooldownKind::CapacityFreezeProbe,
Some("等待探测成功"),
),
(Some(20), AccountCooldownKind::RateLimit, None),
(None, AccountCooldownKind::RateLimit, None),
] {
let now = Utc::now();
let until = seconds.map(|seconds| now + Duration::seconds(seconds));
let facts = AccountStatusFacts {
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
quota: QuotaState::unknown(),
cooldown: until.map(|until| AccountCooldown {
until: until.into(),
kind,
}),
last_error_reason: None,
last_error_message: None,
};
*fixture.account.lock().unwrap() = Some(AccountPageItem {
account: AccountRecord {
id: "acct_cooldown".to_owned(),
provider_kind: gateway_core::routing::ProviderKind::new("openai").unwrap(),
groups: Vec::new(),
name: "synthetic cooldown account".to_owned(),
notes: None,
email: None,
upstream_user_id: None,
upstream_account_id: None,
plan_type: None,
authentication_kind: "oauth".to_owned(),
credential_revision: Revision::new(1).unwrap(),
has_refresh_token: true,
access_token_expires_at: None,
next_refresh_at: None,
enabled: true,
concurrency_limit: None,
weight: Default::default(),
model_access: Default::default(),
outbound_proxy: None,
credential_state: facts.credential_state,
credential_observed_at: now,
quota: facts.quota,
last_error_reason: None,
last_error_message: None,
created_at: now,
updated_at: now,
},
capacity: AccountCapacity {
used_slots: None,
total_slots: None,
},
projection: resolve_account_status(&facts, now.into()),
});
let response = admin::accounts::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/accounts")
.header("x-request-id", "req_cooldown_timezone")
.header(header::COOKIE, "cpr_session=valid-session")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 32768).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
let quota = &value["data"]["items"][0]["quota"];
match until {
Some(until) => {
assert_eq!(
DateTime::parse_from_rfc3339(quota["rateLimitedUntil"].as_str().unwrap())
.unwrap()
.to_utc(),
until
);
let expected = expected.map(str::to_owned).unwrap_or_else(|| {
zone.local(until).format("%Y-%m-%d %H:%M:%S").to_string()
});
assert_eq!(quota["rateLimitRecoveryDisplay"], expected);
}
None => {
assert!(quota["rateLimitedUntil"].is_null());
assert!(quota["rateLimitRecoveryDisplay"].is_null());
}
}
}
}
}
#[tokio::test]
async fn standalone_credential_rotation_route_is_not_exposed() {
let fixture = AdminTestFixture::new().await;
@@ -14,20 +14,24 @@ mod personal_info {
#[test]
fn subscription_response_exposes_only_display_fields_and_preserves_unknowns() {
let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap();
let response = AccountSubscriptionData::from(ProviderSubscription {
starts_at: None,
expires_at: observed,
will_renew: None,
billing_period: None,
billing_currency: Some("USD".to_owned()),
observed_at: observed,
});
let response = AccountSubscriptionData::from((
ProviderSubscription {
starts_at: None,
expires_at: observed,
will_renew: None,
billing_period: None,
billing_currency: Some("USD".to_owned()),
observed_at: observed,
},
gateway_api::TimePresenter::new(Default::default()),
));
assert_eq!(
serde_json::to_value(response).unwrap(),
json!({
"startsAt": null, "expiresAt": "2026-09-14T00:00:00+00:00",
"startsAt": null, "startsAtDisplay": null,
"expiresAt": "2026-09-14T00:00:00+00:00", "expiresAtDisplay": "2026-09-14 08:00:00",
"willRenew": null, "billingPeriod": null, "billingCurrency": "USD",
"observedAt": "2026-09-14T00:00:00+00:00"
"observedAt": "2026-09-14T00:00:00+00:00", "observedAtDisplay": "2026-09-14 08:00:00"
})
);
}
@@ -35,17 +39,20 @@ mod personal_info {
#[test]
fn personal_info_response_preserves_subscription_when_profile_fails() {
let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap();
let response = AccountPersonalInfoData::from(AccountPersonalInfo {
profile: Err(AdminError::bad_gateway("上游服务请求失败")),
subscription: Some(ProviderSubscription {
starts_at: None,
expires_at: observed,
will_renew: None,
billing_period: None,
billing_currency: None,
observed_at: observed,
}),
});
let response = AccountPersonalInfoData::from((
AccountPersonalInfo {
profile: Err(AdminError::bad_gateway("上游服务请求失败")),
subscription: Some(ProviderSubscription {
starts_at: None,
expires_at: observed,
will_renew: None,
billing_period: None,
billing_currency: None,
observed_at: observed,
}),
},
gateway_api::TimePresenter::new(Default::default()),
));
let value = serde_json::to_value(response).unwrap();
assert!(value["profile"].is_null());
assert_eq!(value["profileError"], "上游服务请求失败");
@@ -58,10 +65,13 @@ mod personal_info {
#[test]
fn personal_info_response_preserves_unknown_subscription_and_profile_error() {
let response = AccountPersonalInfoData::from(AccountPersonalInfo {
profile: Err(AdminError::unavailable("Provider 服务暂不可用")),
subscription: None,
});
let response = AccountPersonalInfoData::from((
AccountPersonalInfo {
profile: Err(AdminError::unavailable("Provider 服务暂不可用")),
subscription: None,
},
gateway_api::TimePresenter::new(Default::default()),
));
assert_eq!(
serde_json::to_value(response).unwrap(),
json!({
@@ -184,39 +194,42 @@ mod profile_statistics {
#[test]
fn profile_statistics_response_preserves_nullable_official_fields() {
let response = AccountProfileStatisticsData::from(ProviderProfileStatistics {
display_name: Some("Ada".to_owned()),
username: Some("ada".to_owned()),
image_url: Some("https://example.test/avatar.png".to_owned()),
has_stats_error: false,
summary: ProviderProfileStatisticsSummary {
total_text_tokens: Some(409_500_000),
peak_tokens: Some(267_000_000),
longest_task_duration_ms: Some(51_900_000),
current_streak_days: Some(16),
longest_streak_days: Some(32),
},
daily_usage: Some(vec![ProviderProfileDailyUsage {
date: NaiveDate::from_ymd_opt(2026, 8, 25).expect("usage date"),
tokens: 42,
}]),
activity_insights: ProviderProfileActivityInsights {
fast_mode_percent: Some(0.0),
reasoning_effort: Some("high".to_owned()),
reasoning_effort_percent: Some(48.0),
skills_explored: Some(8),
total_skills_used: Some(61),
total_threads: Some(2_391),
invocations: Some(vec![ProviderProfileInvocation {
invocation_type: "plugin".to_owned(),
plugin_id: Some("plugin_1".to_owned()),
plugin_name: Some("example".to_owned()),
skill_id: None,
skill_name: None,
usage_count: Some(29),
let response = AccountProfileStatisticsData::from((
ProviderProfileStatistics {
display_name: Some("Ada".to_owned()),
username: Some("ada".to_owned()),
image_url: Some("https://example.test/avatar.png".to_owned()),
has_stats_error: false,
summary: ProviderProfileStatisticsSummary {
total_text_tokens: Some(409_500_000),
peak_tokens: Some(267_000_000),
longest_task_duration_ms: Some(51_900_000),
current_streak_days: Some(16),
longest_streak_days: Some(32),
},
daily_usage: Some(vec![ProviderProfileDailyUsage {
date: NaiveDate::from_ymd_opt(2026, 8, 25).expect("usage date"),
tokens: 42,
}]),
activity_insights: ProviderProfileActivityInsights {
fast_mode_percent: Some(0.0),
reasoning_effort: Some("high".to_owned()),
reasoning_effort_percent: Some(48.0),
skills_explored: Some(8),
total_skills_used: Some(61),
total_threads: Some(2_391),
invocations: Some(vec![ProviderProfileInvocation {
invocation_type: "plugin".to_owned(),
plugin_id: Some("plugin_1".to_owned()),
plugin_name: Some("example".to_owned()),
skill_id: None,
skill_name: None,
usage_count: Some(29),
}]),
},
},
});
gateway_api::TimePresenter::new(Default::default()),
));
let value = serde_json::to_value(response).expect("serialize profile statistics");
assert_eq!(value["displayName"], "Ada");
@@ -488,6 +501,7 @@ mod response {
read_tokens: None,
read_tokens_display: "-".to_owned(),
last_used_at: None,
last_used_at_full_display: None,
last_used_at_display: "-".to_owned(),
cost_estimate_status: "unknown".to_owned(),
known_cost_count: None,
@@ -861,7 +875,7 @@ mod actions {
#[test]
fn connection_test_events_should_preserve_the_existing_frontend_contract() {
let events = [
let mut events = [
DomainConnectionTestEvent::Started {
model: "grok-4.5".to_owned(),
},
@@ -887,7 +901,32 @@ mod actions {
upstream_body: Some(r#"{"error":{"type":"usage_limit_reached"}}"#.to_owned()),
},
]
.map(|event| AccountConnectionTestEvent::from(event).data);
.map(|event| {
AccountConnectionTestEvent::from((
event,
gateway_api::TimePresenter::new(Default::default()),
))
.data
});
let timezone = gateway_core::time::DeploymentTimeZone::default();
for event in &mut events {
let at = event["occurredAt"]
.as_str()
.unwrap()
.parse::<chrono::DateTime<chrono::Utc>>()
.unwrap();
let local = timezone.local(at);
assert_eq!(
event["occurredAtDisplay"],
local.format("%Y-%m-%d %H:%M:%S").to_string()
);
assert_eq!(event["timeDisplay"], local.format("%H:%M:%S").to_string());
let fields = event.as_object_mut().unwrap();
fields.remove("occurredAt");
fields.remove("occurredAtDisplay");
fields.remove("timeDisplay");
}
assert_eq!(
events,
@@ -36,11 +36,14 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
monthly.target_seconds = 30 * 86_400;
monthly.estimated_tokens = Some(0);
monthly.estimated_usd = Some(0.0);
let view = AccountQuotaForecastData::from(AccountQuotaForecastReport {
account_id: "acct_forecast".to_owned(),
generated_at: now,
forecasts: [forecast, monthly],
});
let view = AccountQuotaForecastData::from((
AccountQuotaForecastReport {
account_id: "acct_forecast".to_owned(),
generated_at: now,
forecasts: [forecast, monthly],
},
gateway_api::TimePresenter::new(Default::default()),
));
let value = serde_json::to_value(view).unwrap();
assert_eq!(value["accountId"], "acct_forecast");
assert_eq!(value["generatedAt"], "2026-09-12T08:00:00+08:00");
@@ -107,7 +110,7 @@ fn model_catalog_projection_keeps_upstream_document_and_rejects_non_codex_wire()
.expect("codex catalog is projectable");
assert_eq!(data.model_count, 1);
assert_eq!(data.catalog, body);
assert_eq!(data.observed_at, "2026-09-12T16:00:00+08:00");
assert_eq!(data.observed_at, "2026-09-12T08:00:00+00:00");
// 只有模型 ID 的 API 目录拼不出合法的 model_catalog_json,不能降格返回给客户端。
let adapted = RawJsonPayload::new("openai", Bytes::from_static(br#"{"models":[]}"#))
@@ -431,26 +431,29 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
.with_ymd_and_hms(2026, 7, 18, 8, 0, 0)
.single()
.expect("valid time");
let view = ClientKeyView::from(gateway_admin::model::client_keys::ClientKeyRecord {
request_profile_overrides: Default::default(),
budget: Default::default(),
id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"),
name: "visible".to_owned(),
label: None,
groups: Vec::new(),
provider_kinds: vec![
gateway_core::routing::ProviderKind::new("openai").expect("Provider kind"),
],
prefix: "sk_visible12".to_owned(),
enabled: true,
limits: gateway_core::policy::RateLimits {
max_concurrency: 2,
requests_per_minute: 60,
let view = ClientKeyView::from((
gateway_admin::model::client_keys::ClientKeyRecord {
request_profile_overrides: Default::default(),
budget: Default::default(),
id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"),
name: "visible".to_owned(),
label: None,
groups: Vec::new(),
provider_kinds: vec![
gateway_core::routing::ProviderKind::new("openai").expect("Provider kind"),
],
prefix: "sk_visible12".to_owned(),
enabled: true,
limits: gateway_core::policy::RateLimits {
max_concurrency: 2,
requests_per_minute: 60,
},
created_at,
updated_at: created_at,
last_used_at: Some(created_at),
},
created_at,
updated_at: created_at,
last_used_at: Some(created_at),
});
gateway_api::TimePresenter::new(Default::default()),
));
let list =
serde_json::to_value(ClientKeyListData::new(vec![view], None, 1)).expect("serialize list");
assert!(list.get("configRevision").is_none());
+30 -2
View File
@@ -114,19 +114,24 @@ impl AdminTestFixture {
}
pub async fn with_system(system: Arc<dyn SystemOperations>) -> Self {
Self::with_dependencies(system, None).await
Self::with_dependencies(system, None, Default::default()).await
}
pub async fn with_key_verifier(
verifier: Arc<dyn ClientKeyVerifier>,
system: Arc<dyn SystemOperations>,
) -> Self {
Self::with_dependencies(system, Some(verifier)).await
Self::with_dependencies(system, Some(verifier), Default::default()).await
}
pub async fn with_timezone(timezone: gateway_core::time::DeploymentTimeZone) -> Self {
Self::with_dependencies(Arc::new(UnusedSystem), None, timezone).await
}
async fn with_dependencies(
system: Arc<dyn SystemOperations>,
verifier: Option<Arc<dyn ClientKeyVerifier>>,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Self {
let api_key = Arc::new(Mutex::new(None));
let auth = Arc::new(MemoryAuthStore::new(api_key.clone()));
@@ -184,6 +189,7 @@ impl AdminTestFixture {
ClientConfig::default(),
stores,
gateway_admin::AdminRuntimePorts {
timezone,
service_middleware: std::sync::Arc::new(|| None),
plugin_preparation: plugin_ports.clone(),
plugin_management: plugin_ports.clone(),
@@ -564,6 +570,7 @@ impl SettingsStore for MemorySettingsStore {
max_waiting_per_key: command.max_waiting_per_key,
max_waiting_per_account: command.max_waiting_per_account,
concurrency_wait_timeout_seconds: command.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: command.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: command.responses_max_decompressed_body_bytes,
smart_scheduling: command.smart_scheduling,
rotation_strategy: command.rotation_strategy,
@@ -1029,6 +1036,26 @@ impl AccountStore for UnusedStore {
_: AccountListQuery,
_: AccountRuntimeSnapshot,
) -> AdminStoreResult<AccountPage> {
if let Some(account) = self.account.lock().expect("account").as_ref() {
let status = account.projection.status;
return Ok(AccountPage {
config_revision: Revision::new(1).unwrap(),
items: vec![account.clone()],
total: 1,
summary: gateway_admin::model::accounts::AccountSummary {
total: 1,
normal: u64::from(status == gateway_core::account::AccountStatus::Normal),
quota_exhausted: u64::from(
status == gateway_core::account::AccountStatus::QuotaExhausted,
),
rate_limited: u64::from(
status == gateway_core::account::AccountStatus::RateLimited,
),
disabled: u64::from(status == gateway_core::account::AccountStatus::Disabled),
error: u64::from(status == gateway_core::account::AccountStatus::Error),
},
});
}
Err(unavailable("account list"))
}
@@ -1552,6 +1579,7 @@ fn test_runtime_settings() -> RuntimeSettings {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
@@ -157,14 +157,14 @@ async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() {
}
#[tokio::test]
async fn dashboard_summary_should_default_to_current_china_day() {
async fn dashboard_summary_should_default_to_current_deployment_day() {
use axum::{
body::Body,
http::{Request, StatusCode, header},
};
use chrono::{Duration, Utc};
use gateway_admin::model::observability::{
AccountPoolMetrics, DashboardObservation, TimeRange, china_day_start,
AccountPoolMetrics, DashboardObservation, TimeRange,
};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
@@ -206,7 +206,12 @@ async fn dashboard_summary_should_default_to_current_china_day() {
.expect("recorded dashboard summary range");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(range.start, china_day_start(range.end));
assert_eq!(
range.start,
gateway_core::time::DeploymentTimeZone::default()
.day_start(range.end)
.unwrap()
);
}
#[tokio::test]
@@ -447,6 +452,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
provider_account_ref: Some("acct_err".to_owned()),
provider_account_name: None,
provider_account_email: Some("err@example.invalid".to_owned()),
provider_account_plan_type: Some("pro".to_owned()),
provider_account_plan_type_display: Some("Pro".to_owned()),
provider_account_authentication_kind: Some("api_key".to_owned()),
upstream_model_id: Some("upstream-err".to_owned()),
upstream_transport: Some("http_sse".to_owned()),
@@ -509,6 +516,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
"kind": value["data"]["items"][0]["kind"],
"accountId": value["data"]["items"][0]["accountId"],
"accountLabel": value["data"]["items"][0]["metadata"]["accountLabel"],
"accountPlanType": value["data"]["items"][0]["accountPlanType"],
"accountPlanTypeDisplay": value["data"]["items"][0]["accountPlanTypeDisplay"],
"clientStatusCode": value["data"]["items"][0]["clientStatusCode"],
"route": value["data"]["items"][0]["route"],
"requestedModel": value["data"]["items"][0]["requestedModel"],
@@ -528,6 +537,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
"kind": "model_request",
"accountId": "acct_err",
"accountLabel": "err@example.invalid",
"accountPlanType": "pro",
"accountPlanTypeDisplay": "Pro",
"clientStatusCode": 502,
"route": "/v1/responses",
"requestedModel": "gpt-5.4",
@@ -975,3 +986,147 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
"$0.007"
);
}
#[tokio::test]
async fn calendar_queries_and_display_follow_deployment_timezone_with_one_anchor() {
use crate::admin::{AdminTestFixture, AdminTestState};
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use gateway_admin::model::observability::{CalendarPeriod, DashboardObservation, TimeRange};
use gateway_api::admin::observability;
use gateway_core::time::DeploymentTimeZone;
use tower::ServiceExt as _;
for (name, anchor, start, display, slots) in [
(
"Asia/Shanghai",
"2026-10-01T00:00:00Z",
"2026-09-30T16:00:00Z",
"2026-10-01 08:00:00",
96,
),
(
"UTC",
"2026-10-01T00:00:00Z",
"2026-10-01T00:00:00Z",
"2026-10-01 00:00:00",
96,
),
(
"Asia/Kathmandu",
"2026-10-01T00:00:00Z",
"2026-09-30T18:15:00Z",
"2026-10-01 05:45:00",
96,
),
(
"America/New_York",
"2026-03-09T03:59:00Z",
"2026-03-08T05:00:00Z",
"2026-03-08 23:59:00",
92,
),
(
"America/New_York",
"2026-11-02T04:59:00Z",
"2026-11-01T04:00:00Z",
"2026-11-01 23:59:00",
100,
),
] {
let timezone: DeploymentTimeZone = name.parse().unwrap();
let end: chrono::DateTime<Utc> = anchor.parse().unwrap();
let range = TimeRange::calendar_at(CalendarPeriod::Today, end, timezone).unwrap();
let fixture = AdminTestFixture::with_timezone(timezone).await;
fixture.auth.insert_session("valid-session");
*fixture.dashboard_observation.lock().unwrap() = Some(DashboardObservation {
range,
totals: Default::default(),
provider_accounts: Default::default(),
trend: Vec::new(),
account_usage: Vec::new(),
recent_requests: Vec::new(),
});
let router = observability::router::<AdminTestState>().with_state(fixture.state());
let response = router
.clone()
.oneshot(
Request::builder()
.uri(format!(
"/api/admin/dashboard/summary?period=today&asOf={}",
end.timestamp_millis()
))
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_timezone_calendar")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
assert_eq!(
status,
StatusCode::OK,
"{name}: {}",
String::from_utf8_lossy(&bytes)
);
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let queried = fixture.dashboard_summary_range.lock().unwrap().unwrap();
assert_eq!(
queried.start,
start.parse::<chrono::DateTime<Utc>>().unwrap()
);
assert_eq!(queried.end, end);
assert_eq!(value["data"]["asOf"], anchor);
assert_eq!(value["data"]["asOfDisplay"], display);
let points = value["data"]["healthTimeline"]["points"]
.as_array()
.unwrap();
assert_eq!(points.len(), slots);
let labels = points
.iter()
.map(|p| p["time"].as_str().unwrap())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(labels.len(), slots, "fold labels remain distinct");
let invalid = router.oneshot(Request::builder()
.uri(format!("/api/admin/dashboard/summary?period=today&asOf={}&startTime=2026-01-01T00%3A00%3A00Z", end.timestamp_millis()))
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_timezone_mixed_range")
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
}
}
#[tokio::test]
async fn calendar_query_rejects_an_anchor_outside_the_local_calendar() {
use crate::admin::{AdminTestFixture, AdminTestState};
use axum::{
body::Body,
http::{Request, StatusCode, header},
};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
let fixture = AdminTestFixture::with_timezone(Default::default()).await;
fixture.auth.insert_session("valid-session");
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri(format!(
"/api/admin/dashboard/summary?period=today&asOf={}",
chrono::DateTime::<chrono::Utc>::MAX_UTC.timestamp_millis()
))
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_timezone_out_of_range")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(fixture.dashboard_summary_range.lock().unwrap().is_none());
}
@@ -50,6 +50,7 @@ fn update_body() -> Value {
"requestIntervalMs": 25,
"maxWaitingPerKey": 0,
"maxWaitingPerAccount": 0,
"openaiGuardianReservedConcurrency": 0,
"concurrencyWaitTimeoutSeconds": 30,
"responsesMaxDecompressedBodyBytes": 67108864,
"rotationStrategy": "round_robin",
@@ -220,6 +221,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::RoundRobin,
@@ -244,7 +246,11 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
.expect("timestamp"),
};
let value = serde_json::to_value(RuntimeSettingsView::from(settings)).expect("serialize view");
let value = serde_json::to_value(RuntimeSettingsView::from((
settings,
gateway_api::TimePresenter::new(Default::default()),
)))
.expect("serialize view");
assert_eq!(
value,
json!({
@@ -264,6 +270,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
"requestIntervalMs": 25,
"maxWaitingPerKey": 0,
"maxWaitingPerAccount": 0,
"openaiGuardianReservedConcurrency": 0,
"concurrencyWaitTimeoutSeconds": 30,
"responsesMaxDecompressedBodyBytes": 67108864,
"rotationStrategy": "round_robin",
@@ -284,7 +291,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
"accountWarmupEnabled": false,
"accountWarmupScheduleTime": "08:00",
"accountWarmupModel": null,
"updatedAt": "2026-08-02T10:30:00Z"
"updatedAt": "2026-08-02T10:30:00Z",
"updatedAtDisplay": "2026-08-02 18:30:00"
})
);
}
@@ -332,6 +340,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: request.smart_scheduling,
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
@@ -354,19 +363,22 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
updated_at: chrono::Utc::now(),
};
let response_fields: BTreeSet<String> =
serde_json::to_value(RuntimeSettingsView::from(settings))
.expect("serialize view")
.as_object()
.expect("view object")
.keys()
.cloned()
.collect();
let response_fields: BTreeSet<String> = serde_json::to_value(RuntimeSettingsView::from((
settings,
gateway_api::TimePresenter::new(Default::default()),
)))
.expect("serialize view")
.as_object()
.expect("view object")
.keys()
.cloned()
.collect();
let mut expected_fields = request_fields;
expected_fields.insert("providerRequestProfiles".to_owned());
expected_fields.insert("openaiClientProfile".to_owned());
expected_fields.insert("xaiClientProfile".to_owned());
expected_fields.insert("updatedAt".to_owned());
expected_fields.insert("updatedAtDisplay".to_owned());
expected_fields.insert("smartSchedulingDefaults".to_owned());
assert_eq!(response_fields, expected_fields);
@@ -1268,3 +1280,50 @@ async fn settings_update_rejects_a_stale_version_without_replacing_the_saved_val
9999
);
}
#[tokio::test]
async fn guardian_reservation_round_trips_and_rejects_invalid_values() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let response = app(fixture.state())
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.unwrap();
let mut revision = response_json(response).await["data"]["configRevision"].clone();
for reserved in [1_u32, u32::MAX, 0] {
let mut body = update_body();
body["configRevision"] = revision;
body["openaiGuardianReservedConcurrency"] = json!(reserved);
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response_json(response).await["data"]["openaiGuardianReservedConcurrency"],
reserved
);
let response = app(fixture.state())
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.unwrap();
let data = response_json(response).await["data"].clone();
revision = data["configRevision"].clone();
assert_eq!(data["openaiGuardianReservedConcurrency"], reserved);
}
for invalid in [json!(-1), json!(1.5), json!(4294967296_u64)] {
let mut body = update_body();
body["openaiGuardianReservedConcurrency"] = invalid;
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
}
let mut omitted = update_body();
omitted
.as_object_mut()
.unwrap()
.remove("openaiGuardianReservedConcurrency");
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(omitted).is_err());
}
@@ -98,6 +98,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/openai/usage.rs",
"src/provider.rs",
"src/session_cookie.rs",
"src/time.rs",
];
expected.sort_unstable();
@@ -168,6 +168,8 @@ fn error_record() -> OpsError {
provider_account_name: Some("private-sentinel".to_owned()),
provider_account_email: Some("private-sentinel".to_owned()),
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
provider_account_plan_type: Some("private-sentinel".to_owned()),
provider_account_plan_type_display: Some("private-sentinel".to_owned()),
upstream_model_id: Some("private-sentinel".to_owned()),
upstream_transport: Some("websocket".to_owned()),
failure_kind: "private-sentinel".to_owned(),
@@ -7,8 +7,8 @@ use axum::{
http::{Method, StatusCode, header},
response::Response,
};
use chrono::{Duration, Utc};
use gateway_admin::model::observability::{RequestMetrics, china_day_start};
use chrono::Utc;
use gateway_admin::model::observability::RequestMetrics;
use serde_json::{Value, json};
use tower::ServiceExt as _;
@@ -172,6 +172,7 @@ async fn overview_scopes_every_query_and_projects_only_key_visible_fields() {
&data,
&[
"asOf",
"asOfDisplay",
"startTime",
"endTime",
"key",
@@ -190,9 +191,11 @@ async fn overview_scopes_every_query_and_projects_only_key_visible_fields() {
"dailyLimitUsd",
"dailyUsedUsd",
"dailyResetsAt",
"dailyResetsAtDisplay",
"weeklyLimitUsd",
"weeklyUsedUsd",
"weeklyResetsAt",
"weeklyResetsAtDisplay",
],
);
assert_eq!(data["key"]["dailyUsedUsd"], "0.640001");
@@ -226,8 +229,20 @@ async fn overview_scopes_every_query_and_projects_only_key_visible_fields() {
.iter()
.find(|(_, filter)| filter.model.is_none())
.unwrap();
assert_eq!(health.0.start, china_day_start(health.0.end));
assert!((Utc::now() - health.0.end) < Duration::seconds(5));
assert_eq!(
health.0.start,
gateway_core::time::DeploymentTimeZone::default()
.day_start(health.0.end)
.unwrap()
);
assert_eq!(
health.0.end,
data["asOf"]
.as_str()
.unwrap()
.parse::<chrono::DateTime<Utc>>()
.unwrap()
);
}
#[tokio::test]
@@ -254,6 +269,7 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
&[
"id",
"createdAt",
"createdAtDisplay",
"model",
"route",
"reasoningEffort",
+1
View File
@@ -15,6 +15,7 @@ path = "tests/main.rs"
async-trait = "0.1.89"
base64 = "0.23.0"
bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["serde"] }
chrono-tz = { version = "0.10", features = ["serde"] }
futures = "0.3.32"
http = "1.5.0"
@@ -90,6 +90,18 @@ impl AccountConcurrency {
Self::Limited(limit) => Some(limit),
}
}
/// 扣除当前请求不可占用的预留名额;不限并发不受影响,有限上限至少保留 1 个名额。
#[must_use]
pub const fn excluding_reserved(self, reserved: u32) -> Self {
match self {
Self::Unlimited => Self::Unlimited,
Self::Limited(limit) => match NonZeroU32::new(limit.get().saturating_sub(reserved)) {
Some(remaining) => Self::Limited(remaining),
None => Self::Limited(NonZeroU32::MIN),
},
}
}
}
impl From<NonZeroU32> for AccountConcurrency {
@@ -54,6 +54,7 @@ pub struct AccountSelectionPolicy {
max_concurrent_per_account: AccountConcurrency,
request_interval: Duration,
queue_policy: ConcurrencyQueuePolicy,
openai_guardian_reserved_concurrency: u32,
}
impl AccountSelectionPolicy {
@@ -68,6 +69,7 @@ impl AccountSelectionPolicy {
smart_scheduling: SmartSchedulingConfig::default(),
max_concurrent_per_account: max_concurrent_per_account.into(),
request_interval,
openai_guardian_reserved_concurrency: 0,
queue_policy: ConcurrencyQueuePolicy {
max_waiting: 0,
timeout: Duration::ZERO,
@@ -75,6 +77,18 @@ impl AccountSelectionPolicy {
}
}
/// 只传递冻结的运行设置,Guardian 分类与预留策略由 OpenAI Provider 解释。
#[must_use]
pub const fn with_openai_guardian_reserved_concurrency(mut self, reserved: u32) -> Self {
self.openai_guardian_reserved_concurrency = reserved;
self
}
#[must_use]
pub const fn openai_guardian_reserved_concurrency(self) -> u32 {
self.openai_guardian_reserved_concurrency
}
#[must_use]
pub const fn with_queue(mut self, policy: ConcurrencyQueuePolicy) -> Self {
self.queue_policy = policy;
@@ -433,6 +447,18 @@ pub struct AccountSelectionContext {
pub round_robin_cursor: u64,
pub eligibility: AccountEligibilityPolicy,
pub account_scope: Option<std::sync::Arc<crate::account::scope::FrozenAccountScope>>,
/// 本请求不可占用的每账号预留并发名额,由 Provider 按请求类别决定;0 表示不预留。
pub reserved_concurrency: u32,
}
impl AccountSelectionContext {
/// 本请求在该账号上可使用的并发上限;资格判断、租约与策略投影必须共用这一口径。
#[must_use]
pub fn concurrency_limit(&self, account: &ProviderAccount) -> AccountConcurrency {
account
.effective_concurrency(self.policy.max_concurrent_per_account())
.excluding_reserved(self.reserved_concurrency)
}
}
/// 选择账号时是否执行本地调度资格投影。
@@ -568,13 +594,8 @@ impl AccountSelector {
)
})
.try_fold((0_u64, 0_u64), |(used, total), candidate| {
let capacity = u64::from(
candidate
.account
.effective_concurrency(context.policy.max_concurrent_per_account())
.limit()?
.get(),
);
let capacity =
u64::from(context.concurrency_limit(&candidate.account).limit()?.get());
Some((
used.saturating_add(u64::from(candidate.signals.in_flight)),
total.saturating_add(capacity),
@@ -789,9 +810,8 @@ impl AccountSelector {
if context.excluded_accounts.contains(candidate.account.id()) {
return Some(AccountSchedulingBlocker::Excluded);
}
if candidate
.account
.effective_concurrency(context.policy.max_concurrent_per_account())
if context
.concurrency_limit(&candidate.account)
.limit()
.is_some_and(|limit| candidate.signals.in_flight >= limit.get())
{
+91 -24
View File
@@ -43,8 +43,21 @@ pub enum QueueRejection {
Timeout,
}
/// 同一队列内的等待类别:高优先级排在已有高优先级等待者之后、全部普通等待者之前。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WaitPriority {
#[default]
Normal,
High,
}
struct Waiter {
waker: Arc<AtomicWaker>,
priority: WaitPriority,
}
struct QueueState<K> {
queues: HashMap<K, VecDeque<Arc<AtomicWaker>>>,
queues: HashMap<K, VecDeque<Waiter>>,
total: usize,
}
@@ -75,6 +88,20 @@ impl<K: Clone + Eq + Hash> ConcurrencyWaitQueue<K> {
.is_some_and(|queue| !queue.is_empty())
}
/// 是否存在排在该类别请求之前的等待者;普通请求让位于全部等待者,高优先级只让位于同类。
fn has_waiters_ahead_of(&self, key: &K, priority: WaitPriority) -> bool {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.queues
.get(key)
.is_some_and(|queue| {
queue
.iter()
.any(|waiter| priority == WaitPriority::Normal || waiter.priority == priority)
})
}
pub fn enqueue(
&self,
keys: &[K],
@@ -91,6 +118,18 @@ impl<K: Clone + Eq + Hash> ConcurrencyWaitQueue<K> {
keys: &[K],
max_waiting: u32,
deadline: Instant,
priority: impl FnMut(&K, usize) -> f64,
) -> Result<WaitTicket<K>, QueueRejection> {
self.enqueue_ranked(keys, max_waiting, deadline, WaitPriority::Normal, priority)
}
/// 高优先级等待者不受单队列 `max_waiting` 限制,但仍要求排队已开启并受全局容量约束。
fn enqueue_ranked(
&self,
keys: &[K],
max_waiting: u32,
deadline: Instant,
waiter_priority: WaitPriority,
mut priority: impl FnMut(&K, usize) -> f64,
) -> Result<WaitTicket<K>, QueueRejection> {
if Instant::now() >= deadline {
@@ -107,17 +146,30 @@ impl<K: Clone + Eq + Hash> ConcurrencyWaitQueue<K> {
.iter()
.filter_map(|key| {
let len = state.queues.get(key).map_or(0, VecDeque::len);
(len < max_waiting as usize).then(|| (key, priority(key, len)))
let admitted = max_waiting > 0
&& (waiter_priority == WaitPriority::High || len < max_waiting as usize);
admitted.then(|| (key, priority(key, len)))
})
.min_by(|(_, left), (_, right)| right.total_cmp(left))
.map(|(key, _)| key.clone())
.ok_or(QueueRejection::Full)?;
let waker = Arc::new(AtomicWaker::new());
state
.queues
.entry(key.clone())
.or_default()
.push_back(Arc::clone(&waker));
let queue = state.queues.entry(key.clone()).or_default();
let position = match waiter_priority {
WaitPriority::Normal => queue.len(),
WaitPriority::High => queue
.iter()
.position(|waiter| waiter.priority == WaitPriority::Normal)
.unwrap_or(queue.len()),
};
// 插到队首时原队首失去位置,它的有界重查会自然让出;新队首随后自行轮询到位。
queue.insert(
position,
Waiter {
waker: Arc::clone(&waker),
priority: waiter_priority,
},
);
state.total += 1;
Ok(WaitTicket {
state: Arc::clone(&self.state),
@@ -142,19 +194,20 @@ impl<K: Eq + Hash> WaitTicket<K> {
&self.key
}
fn is_head(&self) -> bool {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.queues
.get(&self.key)
.and_then(VecDeque::front)
.is_some_and(|head| Arc::ptr_eq(&head.waker, &self.waker))
}
pub async fn turn(&self) -> Result<(), QueueRejection> {
let ready = poll_fn(|cx| {
self.waker.register(cx.waker());
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state
.queues
.get(&self.key)
.and_then(VecDeque::front)
.is_some_and(|head| Arc::ptr_eq(head, &self.waker))
{
if self.is_head() {
Poll::Ready(())
} else {
Poll::Pending
@@ -188,11 +241,14 @@ impl<K: Eq + Hash> Drop for WaitTicket<K> {
let Some(queue) = state.queues.get_mut(&self.key) else {
return;
};
let Some(index) = queue.iter().position(|item| Arc::ptr_eq(item, &self.waker)) else {
let Some(index) = queue
.iter()
.position(|item| Arc::ptr_eq(&item.waker, &self.waker))
else {
return;
};
queue.remove(index);
let next = queue.front().cloned();
let next = queue.front().map(|waiter| Arc::clone(&waiter.waker));
if queue.is_empty() {
state.queues.remove(&self.key);
}
@@ -212,6 +268,7 @@ pub struct CapacityWait<'a, K: Eq + Hash> {
policy: ConcurrencyQueuePolicy,
request_deadline: SystemTime,
budget: &'a ConcurrencyWaitBudget,
priority: WaitPriority,
started_at: Option<Instant>,
ticket: Option<WaitTicket<K>>,
}
@@ -229,17 +286,26 @@ impl<'a, K: Clone + Eq + Hash> CapacityWait<'a, K> {
policy,
request_deadline,
budget,
priority: WaitPriority::Normal,
started_at: None,
ticket: None,
}
}
#[must_use]
pub const fn with_priority(mut self, priority: WaitPriority) -> Self {
self.priority = priority;
self
}
#[must_use]
pub fn can_try(&self, key: &K) -> bool {
self.ticket
.as_ref()
.is_some_and(|ticket| ticket.key() == key)
|| !self.queue.has_waiters(key)
if let Some(ticket) = self.ticket.as_ref().filter(|ticket| ticket.key() == key) {
// 重读容量期间可能被高优先级请求插队,持有 ticket 不代表仍有选号资格。
ticket.is_head()
} else {
!self.queue.has_waiters_ahead_of(key, self.priority)
}
}
#[must_use]
@@ -271,10 +337,11 @@ impl<'a, K: Clone + Eq + Hash> CapacityWait<'a, K> {
self.ticket = None;
}
if self.ticket.is_none() {
self.ticket = Some(self.queue.enqueue_with_priority(
self.ticket = Some(self.queue.enqueue_ranked(
keys,
self.policy.max_waiting,
deadline,
self.priority,
priority,
)?);
}
@@ -63,6 +63,7 @@ impl TraceContext {
"preferredAccountId": context.preferred_account.as_ref().map(|id| id.as_str()),
"preferredResult": selection.as_ref().map(|s| format!("{:?}", s.preferred())),
"roundRobinCursor": context.round_robin_cursor,
"reservedConcurrency": context.reserved_concurrency,
"smartScoreTolerance": smart.then(|| context.policy.smart_scheduling().score_tolerance()),
"candidateCount": candidates.len(),
"omittedCandidates": candidates.len().saturating_sub(observations.len()),
@@ -427,17 +427,13 @@ impl RequestPolicyContext {
if eligible.is_empty() {
return Ok(None);
}
let policy = context.policy;
let projected = eligible
.iter()
.map(|candidate| AccountScheduleCandidate {
account_id: candidate.account.id().clone(),
weight: candidate.account.weight().get(),
in_flight: candidate.signals.in_flight,
maximum_concurrency: candidate
.account
.effective_concurrency(policy.max_concurrent_per_account())
.get(),
maximum_concurrency: context.concurrency_limit(&candidate.account).get(),
last_started_at: candidate.signals.last_started_at,
quota_reset_at: candidate.signals.quota_reset_at,
quota_remaining_rank: candidate.signals.quota_remaining_rank,
+1
View File
@@ -21,6 +21,7 @@ pub mod routing;
pub mod runtime;
pub mod settings;
pub mod task;
pub mod time;
pub mod upstream;
pub mod validation;
@@ -936,6 +936,19 @@ fn invalid_refresh_policy(operation: &'static str) -> ProviderStoreError {
}
pub trait ProviderRuntimePolicyPort: Send + Sync {
/// 原子推进预热执行游标,重启或时钟回拨后不重复领取已消费的时刻。
fn claim_warmup_slot<'a>(
&'a self,
_timezone: crate::time::DeploymentTimeZone,
_slot: chrono::NaiveDateTime,
) -> BoxFuture<'a, Result<bool, ProviderStoreError>> {
Box::pin(async {
Err(ProviderStoreError::new(
ProviderStoreErrorKind::Unavailable,
"claim warmup slot",
))
})
}
/// 仅首次启动写入该 Provider 的默认选择;已保存的管理配置始终优先。
fn initialize_request_profile<'a>(
&'a self,
@@ -55,6 +55,7 @@ impl CompiledSettings {
AccountConcurrency::new(settings.max_concurrent_per_account),
Duration::from_millis(settings.request_interval_ms),
)
.with_openai_guardian_reserved_concurrency(settings.openai_guardian_reserved_concurrency)
.with_smart_scheduling(settings.smart_scheduling)
.with_queue(ConcurrencyQueuePolicy {
max_waiting: settings.max_waiting_per_account,
@@ -14,6 +14,7 @@ pub struct SettingsValues {
pub(crate) max_waiting_per_key: u32,
pub(crate) max_waiting_per_account: u32,
pub(crate) concurrency_wait_timeout_seconds: u32,
pub(crate) openai_guardian_reserved_concurrency: u32,
pub(crate) responses_max_decompressed_body_bytes: u64,
pub(crate) request_interval_ms: u64,
pub(crate) smart_scheduling: crate::account::SmartSchedulingConfig,
@@ -24,6 +25,12 @@ pub struct SettingsValues {
}
impl SettingsValues {
#[must_use]
pub const fn with_openai_guardian_reserved_concurrency(mut self, reserved: u32) -> Self {
self.openai_guardian_reserved_concurrency = reserved;
self
}
#[must_use]
pub fn request_profiles(&self) -> &BTreeMap<ProviderKind, crate::account::OpaqueProviderData> {
&self.request_profiles
@@ -104,6 +111,7 @@ impl SettingsValues {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
request_interval_ms,
smart_scheduling: crate::account::SmartSchedulingConfig::default(),
+88
View File
@@ -0,0 +1,88 @@
//! 部署时区的日历换算,持久化和比较始终使用 UTC 时间点。
use std::str::FromStr;
use chrono::{DateTime, Days, NaiveDate, NaiveDateTime, Offset as _, TimeZone as _, Utc};
use chrono_tz::{GapInfo, Tz};
use serde::{Deserialize, Serialize};
/// 由组合根传递的不可变 IANA 时区。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(transparent)]
pub struct DeploymentTimeZone(Tz);
impl Default for DeploymentTimeZone {
fn default() -> Self {
Self(chrono_tz::Asia::Shanghai)
}
}
impl FromStr for DeploymentTimeZone {
type Err = chrono_tz::ParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
value.parse().map(Self)
}
}
impl DeploymentTimeZone {
#[must_use]
pub fn name(self) -> &'static str {
self.0.name()
}
#[must_use]
pub const fn iana(self) -> Tz {
self.0
}
#[must_use]
pub fn local(self, value: DateTime<Utc>) -> DateTime<Tz> {
value.with_timezone(&self.0)
}
/// 重复本地时刻只取较早时间点,缺失时刻由调用方决定是否跳过。
#[must_use]
pub fn resolve_local(self, value: NaiveDateTime) -> Option<DateTime<Utc>> {
self.0
.from_local_datetime(&value)
.earliest()
.map(|value| value.to_utc())
}
/// 午夜跳时取缺口结束点,包含整日跳过的历史时区变更。
#[must_use]
pub fn date_start(self, date: NaiveDate) -> Option<DateTime<Utc>> {
let midnight = date.and_hms_opt(0, 0, 0)?;
self.resolve_local(midnight).or_else(|| {
GapInfo::new(&midnight, &self.0)?
.end
.map(|value| value.to_utc())
})
}
#[must_use]
pub fn day_start(self, value: DateTime<Utc>) -> Option<DateTime<Utc>> {
self.date_start(self.local_date(value)?)
}
/// 先移动本地日期再解析时间点,避免把自然日写成固定 24 小时。
#[must_use]
pub fn days_before(self, value: DateTime<Utc>, days: u64) -> Option<DateTime<Utc>> {
self.date_start(self.local_date(value)?.checked_sub_days(Days::new(days))?)
}
#[must_use]
pub fn days_after(self, value: DateTime<Utc>, days: u64) -> Option<DateTime<Utc>> {
self.date_start(self.local_date(value)?.checked_add_days(Days::new(days))?)
}
fn local_date(self, value: DateTime<Utc>) -> Option<NaiveDate> {
let local = self.local(value);
// UTC 可表示的端点在叠加时区偏移后仍可能超出本地日历范围。
local
.naive_utc()
.checked_add_offset(local.offset().fix())
.map(|value| value.date())
}
}
@@ -13,12 +13,13 @@ use std::time::{Duration, SystemTime};
use serde_json::{Map, Value};
use gateway_core::account::{
AccountAttemptFeedback, AccountCandidate, AccountConcurrencyLimit, AccountEligibilityPolicy,
AccountFeedbackStats, AccountQuotaSignals, AccountRuntimeSignals, AccountSchedulingBlocker,
AccountSelectionContext, AccountSelectionPolicy, AccountSelector, AccountStatus, AccountWeight,
CredentialCasUpdate, CredentialRevision, CredentialState, OpaqueProviderData,
PlaintextCredential, PreferredAccountSelection, ProviderAccount, ProviderAccountId,
ProviderAccountIdentity, ProviderAccountUpdate, QuotaEvidence, QuotaState, RotationStrategy,
AccountAttemptFeedback, AccountCandidate, AccountConcurrency, AccountConcurrencyLimit,
AccountEligibilityPolicy, AccountFeedbackStats, AccountQuotaSignals, AccountRuntimeSignals,
AccountSchedulingBlocker, AccountSelectionContext, AccountSelectionPolicy, AccountSelector,
AccountStatus, AccountWeight, CredentialCasUpdate, CredentialRevision, CredentialState,
OpaqueProviderData, PlaintextCredential, PreferredAccountSelection, ProviderAccount,
ProviderAccountId, ProviderAccountIdentity, ProviderAccountUpdate, QuotaEvidence, QuotaState,
RotationStrategy,
};
use gateway_core::routing::{
ClientRoutingScope, FrozenAccountScope, ProviderKind, RuntimeAccount, RuntimeAccountDirectory,
@@ -109,6 +110,7 @@ fn context(strategy: RotationStrategy) -> AccountSelectionContext {
round_robin_cursor: 0,
eligibility: AccountEligibilityPolicy::Enforce,
account_scope: None,
reserved_concurrency: 0,
}
}
@@ -1093,3 +1095,42 @@ fn due_probe_freeze_blocks_scheduling_and_remains_visible_as_rate_limited() {
.expect("available account");
assert_eq!(selected.candidate().account.id().as_str(), "acct_ready");
}
#[test]
fn reserved_concurrency_only_blocks_requests_that_cannot_use_the_reserved_slots() {
let candidates = [candidate_with_concurrency("acct_reserved", 4, 5)];
let mut normal = context(RotationStrategy::Smart);
normal.reserved_concurrency = 1;
assert_eq!(normal.concurrency_limit(&candidates[0].account).get(), 4);
assert!(AccountSelector.select(&candidates, &normal).is_none());
let capacity = AccountSelector
.capacity_snapshot(&candidates, &normal)
.unwrap();
assert_eq!((capacity.used_slots(), capacity.total_slots()), (4, 4));
// 预留造成的暂满仍是可等待的容量约束,不能被当成不可用账号。
assert_eq!(
AccountSelector.wait_candidates(&candidates, &normal).len(),
1
);
let prioritized = context(RotationStrategy::Smart);
let capacity = AccountSelector
.capacity_snapshot(&candidates, &prioritized)
.unwrap();
assert_eq!((capacity.used_slots(), capacity.total_slots()), (4, 5));
let selected = AccountSelector
.select(&candidates, &prioritized)
.expect("reserved slot available");
assert_eq!(selected.candidate().account.id().as_str(), "acct_reserved");
let full = [candidate_with_concurrency("acct_reserved", 5, 5)];
assert!(AccountSelector.select(&full, &prioritized).is_none());
// 预留不能让普通请求失去全部名额,也不改变不限并发。
let mut oversized = context(RotationStrategy::Smart);
oversized.reserved_concurrency = 10;
assert_eq!(oversized.concurrency_limit(&candidates[0].account).get(), 1);
assert_eq!(
AccountConcurrency::Unlimited.excluding_reserved(2),
AccountConcurrency::Unlimited
);
}
@@ -3,7 +3,7 @@ use std::time::{Duration, Instant, SystemTime};
use futures::{FutureExt, executor::block_on};
use gateway_core::concurrency::{
CapacityWait, ConcurrencyQueuePolicy, ConcurrencyWaitBudget, ConcurrencyWaitQueue,
QueueRejection,
QueueRejection, WaitPriority,
};
#[test]
@@ -231,3 +231,97 @@ fn changed_scores_keep_existing_position_until_account_becomes_ineligible() {
assert!(!queue.has_waiters(&"b"));
});
}
#[test]
fn high_priority_waiters_go_ahead_of_normal_waiters_and_keep_fifo_among_themselves() {
block_on(async {
let queue = ConcurrencyWaitQueue::default();
let deadline = Instant::now() + Duration::from_secs(2);
let policy = ConcurrencyQueuePolicy {
max_waiting: 2,
timeout: Duration::from_secs(2),
};
let request_deadline = SystemTime::now() + Duration::from_secs(2);
let first_normal = queue.enqueue(&["a"], 2, deadline).unwrap();
let second_normal = queue.enqueue(&["a"], 2, deadline).unwrap();
let normal_budget = ConcurrencyWaitBudget::default();
let normal = CapacityWait::new(&queue, policy, request_deadline, &normal_budget);
let first_budget = ConcurrencyWaitBudget::default();
let mut first_high = CapacityWait::new(&queue, policy, request_deadline, &first_budget)
.with_priority(WaitPriority::High);
// 只有普通等待者时,高优先级请求可以直接尝试租约,普通请求仍须排队。
assert!(first_high.can_try(&"a"));
assert!(!normal.can_try(&"a"));
// 普通等待者已占满单队列上限,高优先级仍可入队并直接成为队首。
first_high.wait(&["a"]).await.unwrap();
let second_budget = ConcurrencyWaitBudget::default();
let mut second_high = CapacityWait::new(&queue, policy, request_deadline, &second_budget)
.with_priority(WaitPriority::High);
assert!(!second_high.can_try(&"a"));
assert!(second_high.wait(&["a"]).now_or_never().is_none());
assert!(first_normal.turn().now_or_never().is_none());
drop(first_high);
second_high.wait(&["a"]).await.unwrap();
assert!(first_normal.turn().now_or_never().is_none());
drop(second_high);
first_normal.turn().await.unwrap();
drop(first_normal);
second_normal.turn().await.unwrap();
});
}
#[test]
fn displaced_normal_head_yields_its_lease_attempt_until_high_priority_waiters_leave() {
block_on(async {
let queue = ConcurrencyWaitQueue::default();
let policy = ConcurrencyQueuePolicy {
max_waiting: 2,
timeout: Duration::from_secs(2),
};
let deadline = SystemTime::now() + Duration::from_secs(2);
let normal_budget = ConcurrencyWaitBudget::default();
let high_budget = ConcurrencyWaitBudget::default();
let mut normal = CapacityWait::new(&queue, policy, deadline, &normal_budget);
normal.wait(&["account"]).await.unwrap();
assert!(normal.can_try(&"account"));
// 普通队首已经开始重读容量,尚未取得租约时被高优先级请求插队。
let mut high = CapacityWait::new(&queue, policy, deadline, &high_budget)
.with_priority(WaitPriority::High);
high.wait(&["account"]).await.unwrap();
assert!(!normal.can_try(&"account"));
assert!(high.can_try(&"account"));
assert!(normal.wait(&["account"]).now_or_never().is_none());
// 审批取消也必须恢复原队首的资格,不能丢失其等待位置。
drop(high);
normal.wait(&["account"]).await.unwrap();
assert!(normal.can_try(&"account"));
drop(normal);
assert!(!queue.has_waiters(&"account"));
});
}
#[test]
fn high_priority_waiters_still_require_queueing_to_be_enabled() {
block_on(async {
let queue = ConcurrencyWaitQueue::<&str>::default();
let policy = ConcurrencyQueuePolicy {
max_waiting: 0,
timeout: Duration::from_secs(2),
};
let budget = ConcurrencyWaitBudget::default();
let mut waiting = CapacityWait::new(
&queue,
policy,
SystemTime::now() + Duration::from_secs(2),
&budget,
)
.with_priority(WaitPriority::High);
assert_eq!(waiting.wait(&["a"]).await, Err(QueueRejection::Full));
assert!(!queue.has_waiters(&"a"));
});
}
@@ -1921,6 +1921,7 @@ impl Provider for PolicySelectingProvider {
round_robin_cursor: 0,
eligibility: AccountEligibilityPolicy::Enforce,
account_scope: attempt.account_scope().cloned(),
reserved_concurrency: 0,
};
match attempt
.select_account(
@@ -116,6 +116,7 @@ fn context() -> AccountSelectionContext {
round_robin_cursor: 0,
eligibility: AccountEligibilityPolicy::Enforce,
account_scope: None,
reserved_concurrency: 0,
}
}
@@ -16,3 +16,5 @@ mod runtime;
mod settings;
mod task;
mod upstream;
mod time;
@@ -364,7 +364,7 @@ fn compiler_accepts_unlimited_default_account_concurrency() {
}
#[test]
fn compiled_plans_keep_their_smart_config_after_a_new_snapshot_is_built() {
fn compiled_plans_keep_their_scheduling_config_after_a_new_snapshot_is_built() {
use gateway_core::account::SmartSchedulingConfig;
let configs = [
SmartSchedulingConfig::default(),
@@ -376,7 +376,8 @@ fn compiled_plans_keep_their_smart_config_after_a_new_snapshot_is_built() {
revision(index as u64 + 1),
revision(index as u64 + 1),
SettingsValues::new(3, 0, "smart", BTreeMap::new(), None, None)
.with_smart_scheduling(config),
.with_smart_scheduling(config)
.with_openai_guardian_reserved_concurrency(index as u32),
vec![],
vec![],
vec![SnapshotProviderAccountFacts::new(
@@ -401,7 +402,12 @@ fn compiled_plans_keep_their_smart_config_after_a_new_snapshot_is_built() {
.unwrap(),
);
}
for (plan, config) in plans.iter().zip(configs) {
for (index, (plan, config)) in plans.iter().zip(configs).enumerate() {
assert_eq!(
plan.account_selection_policy()
.openai_guardian_reserved_concurrency(),
index as u32
);
assert_eq!(plan.account_selection_policy().smart_scheduling(), config);
}
}
+106
View File
@@ -0,0 +1,106 @@
use chrono::{DateTime, NaiveDate, Utc};
use gateway_core::time::DeploymentTimeZone;
fn utc(value: &str) -> DateTime<Utc> {
value.parse().unwrap()
}
#[test]
fn calendar_boundaries_follow_offsets_and_dst_instead_of_fixed_hours() {
for (zone, at, start, end, hours) in [
(
"Asia/Shanghai",
"2026-01-01T00:00:00Z",
"2025-12-31T16:00:00Z",
"2026-01-01T16:00:00Z",
24,
),
(
"UTC",
"2026-01-01T05:00:00Z",
"2026-01-01T00:00:00Z",
"2026-01-02T00:00:00Z",
24,
),
(
"Asia/Kathmandu",
"2026-01-01T00:00:00Z",
"2025-12-31T18:15:00Z",
"2026-01-01T18:15:00Z",
24,
),
(
"America/New_York",
"2026-03-08T16:00:00Z",
"2026-03-08T05:00:00Z",
"2026-03-09T04:00:00Z",
23,
),
(
"America/New_York",
"2026-11-01T16:00:00Z",
"2026-11-01T04:00:00Z",
"2026-11-02T05:00:00Z",
25,
),
] {
let zone: DeploymentTimeZone = zone.parse().unwrap();
let at = utc(at);
assert_eq!(zone.day_start(at), Some(utc(start)));
assert_eq!(zone.days_after(at, 1), Some(utc(end)));
assert_eq!((utc(end) - utc(start)).num_hours(), hours);
}
let zone: DeploymentTimeZone = "America/New_York".parse().unwrap();
let start = utc("2026-03-06T05:00:00Z");
assert_eq!(zone.days_after(start, 7), Some(utc("2026-03-13T04:00:00Z")));
}
#[test]
fn local_slots_skip_gaps_and_pick_only_the_first_fold_instant() {
let zone: DeploymentTimeZone = "America/New_York".parse().unwrap();
assert!(
zone.resolve_local("2026-03-08T02:30:00".parse().unwrap())
.is_none()
);
assert_eq!(
zone.resolve_local("2026-11-01T01:30:00".parse().unwrap()),
Some(utc("2026-11-01T05:30:00Z"))
);
let zone: DeploymentTimeZone = "America/Sao_Paulo".parse().unwrap();
assert_eq!(
zone.date_start(NaiveDate::from_ymd_opt(2018, 11, 4).unwrap()),
Some(utc("2018-11-04T03:00:00Z"))
);
let zone: DeploymentTimeZone = "Pacific/Apia".parse().unwrap();
assert_eq!(
zone.date_start(NaiveDate::from_ymd_opt(2011, 12, 30).unwrap()),
Some(utc("2011-12-30T10:00:00Z"))
);
}
#[test]
fn deployment_timezone_rejects_invalid_names_and_types() {
assert_eq!(DeploymentTimeZone::default().name(), "Asia/Shanghai");
for value in [
serde_json::json!(""),
serde_json::json!("UTC+8"),
serde_json::json!("Not/AZone"),
serde_json::json!(null),
serde_json::json!(8),
] {
assert!(serde_json::from_value::<DeploymentTimeZone>(value).is_err());
}
}
#[test]
fn calendar_boundaries_reject_unrepresentable_local_dates() {
for (zone, value) in [
("Asia/Shanghai", DateTime::<Utc>::MAX_UTC),
("America/New_York", DateTime::<Utc>::MIN_UTC),
] {
let zone: DeploymentTimeZone = zone.parse().unwrap();
assert_eq!(zone.day_start(value), None);
assert_eq!(zone.days_before(value, 0), None);
assert_eq!(zone.days_after(value, 0), None);
}
}
+2
View File
@@ -34,6 +34,7 @@ semver = "1.0.27"
secrecy = "0.10.3"
serde = { version = "1.0.228", features = ["derive"] }
serde_ignored = "0.1.14"
serde_path_to_error = "0.1.20"
serde_json = "1.0.150"
sha2 = "0.11.0"
tar = "0.4.44"
@@ -47,6 +48,7 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
[dev-dependencies]
filetime = "0.2.27"
hyper = { version = "=1.11.1", default-features = false, features = ["server"] }
tempfile = "3.27.0"
tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-thread", "test-util"] }
wiremock = "0.6.5"
+18 -9
View File
@@ -35,16 +35,21 @@ pub fn load_config<T: LoadableConfig>() -> Result<T, ConfigError> {
.build()
.map_err(|_| ConfigError::InvalidDocument { path: path.clone() })?;
let mut unused = std::collections::BTreeSet::new();
let mut value: T = serde_ignored::deserialize(document, |field| {
let mut ignored = |field: serde_ignored::Path<'_>| {
unused.insert(field.to_string());
})
.map_err(|error| match missing_config_field(&error) {
Some(field) => ConfigError::MissingField {
path: path.clone(),
field,
},
None => ConfigError::InvalidDocument { path: path.clone() },
})?;
};
let mut value: T =
serde_path_to_error::deserialize(serde_ignored::Deserializer::new(document, &mut ignored))
.map_err(|error| match missing_config_field(error.inner()) {
Some(field) => ConfigError::MissingField {
path: path.clone(),
field,
},
None => ConfigError::InvalidValue {
path: path.clone(),
field: error.path().to_string(),
},
})?;
// 此时日志尚未初始化;只报告字段路径,不回显可能包含凭据的配置值。
for field in unused {
if !T::EXTERNAL_SECTIONS.contains(&field.as_str()) {
@@ -69,6 +74,8 @@ fn missing_config_field(error: &config::ConfigError) -> Option<String> {
/// Host 唯一拥有的进程配置。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct HostConfig {
#[serde(default)]
pub timezone: gateway_core::time::DeploymentTimeZone,
pub listen: ListenConfig,
pub runtime_data_dir: PathBuf,
pub logging: LoggingConfig,
@@ -216,6 +223,8 @@ pub struct FileLoggingConfig {
#[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")]
+1 -1
View File
@@ -56,7 +56,7 @@ pub async fn initialize(config: HostConfig) -> Result<HostBundle, HostError> {
.official_plugins_dir()
.map_err(|_| HostError::OfficialPluginRelease)?,
));
let log_guard = initialize_logging(&config.logging)?;
let log_guard = initialize_logging(&config.logging, config.timezone)?;
let cancellation = CancellationToken::new();
let connections = Arc::new(ConnectionTracker::new(cancellation.clone()));
let workers = WorkerSupervisor::new(cancellation.clone());
+25 -1
View File
@@ -48,7 +48,10 @@ pub enum LogError {
}
/// 初始化日志;文件按完整日期留存,不以分片数量淘汰窗口内记录。
pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError> {
pub fn initialize_logging(
config: &LoggingConfig,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<LogGuard, LogError> {
let directive = env::var("RUST_LOG").unwrap_or_else(|_| config.level.clone());
let file_filter = application_file_filter(&directive)?;
let recovery_file_filter = oauth_recovery_file_filter();
@@ -70,6 +73,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
let file_writer = if config.file.enabled {
let (writer, guard) = create_file_writer(
config,
timezone,
config.file.retention_days,
APPLICATION_LOG_FILE_PREFIX,
"gateway-log-application-file",
@@ -83,6 +87,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
let recovery_file_writer = if config.oauth_recovery {
let (writer, guard) = create_file_writer(
config,
timezone,
config.file.retention_days,
OAUTH_RECOVERY_LOG_FILE_PREFIX,
"gateway-log-oauth-recovery-file",
@@ -96,6 +101,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
let request_dump_file_writer = if config.request_dump {
let (writer, guard) = create_file_writer(
config,
timezone,
config.request_dump_retention_days,
REQUEST_DUMP_LOG_FILE_PREFIX,
"gateway-log-request-dump-file",
@@ -110,6 +116,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
let stdout_layer = stdout_writer.map(|writer| {
tracing_subscriber::fmt::layer()
.compact()
.with_timer(DeploymentClock(timezone))
.with_writer(writer)
.with_target(false)
.with_ansi(false)
@@ -118,6 +125,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
let file_layer = file_writer.map(|writer| {
tracing_subscriber::fmt::layer()
.json()
.with_timer(DeploymentClock(timezone))
.with_writer(writer)
.with_target(true)
.with_file(true)
@@ -131,6 +139,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
let recovery_file_layer = recovery_file_writer.map(|writer| {
tracing_subscriber::fmt::layer()
.json()
.with_timer(DeploymentClock(timezone))
.with_writer(writer)
.with_target(true)
.with_file(true)
@@ -144,6 +153,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
let request_dump_file_layer = request_dump_file_writer.map(|writer| {
tracing_subscriber::fmt::layer()
.json()
.with_timer(DeploymentClock(timezone))
.with_writer(writer)
.with_target(true)
.with_file(true)
@@ -209,6 +219,7 @@ impl LogGuard {
fn create_file_writer(
config: &LoggingConfig,
timezone: gateway_core::time::DeploymentTimeZone,
retention_days: usize,
prefix: &'static str,
thread_name: &'static str,
@@ -225,6 +236,19 @@ fn create_file_writer(
maximum_bytes,
retention_days,
Arc::clone(&health),
timezone,
)?;
Ok(FileLogSink::spawn(writer, thread_name, health)?)
}
#[derive(Clone, Copy)]
struct DeploymentClock(gateway_core::time::DeploymentTimeZone);
impl tracing_subscriber::fmt::time::FormatTime for DeploymentClock {
fn format_time(
&self,
writer: &mut tracing_subscriber::fmt::format::Writer<'_>,
) -> std::fmt::Result {
write!(writer, "{}", self.0.local(chrono::Utc::now()).to_rfc3339())
}
}
@@ -1,4 +1,4 @@
//! 按大小轮转、压缩已关闭分片、按完整 UTC 日期组清理。
//! 按大小轮转、压缩已关闭分片、按部署时区自然日期组清理。
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
@@ -12,6 +12,7 @@ use flate2::{Compression, write::GzEncoder};
use super::sink::LogHealth;
pub(super) struct RotatingLogWriter {
timezone: gateway_core::time::DeploymentTimeZone,
directory: PathBuf,
prefix: &'static str,
maximum_bytes: u64,
@@ -30,10 +31,11 @@ impl RotatingLogWriter {
maximum_bytes: u64,
retention_days: usize,
health: Arc<LogHealth>,
timezone: gateway_core::time::DeploymentTimeZone,
) -> io::Result<Self> {
fs::create_dir_all(&directory)?;
let date = Utc::now().date_naive();
cleanup_log_files(&directory, prefix, date, retention_days)?;
let date = timezone.local(Utc::now()).date_naive();
cleanup_log_files(&directory, prefix, date, retention_days, timezone)?;
let latest = managed_log_files(&directory, prefix)?
.into_iter()
.filter(|entry| entry.date == date)
@@ -48,6 +50,7 @@ impl RotatingLogWriter {
let path = directory.join(log_file_name(prefix, date, segment));
let file = open_log_segment(&path)?;
let writer = Self {
timezone,
directory,
prefix,
maximum_bytes,
@@ -71,7 +74,7 @@ impl RotatingLogWriter {
}
fn rotate_if_required(&mut self, incoming_bytes: usize) -> io::Result<()> {
let date = Utc::now().date_naive();
let date = self.timezone.local(Utc::now()).date_naive();
let day_changed = date != self.date;
let size_exceeded = self.bytes_written > 0
&& self.bytes_written.saturating_add(incoming_bytes as u64) > self.maximum_bytes;
@@ -105,9 +108,13 @@ impl RotatingLogWriter {
self.health.maintenance_failed(error.kind());
}
// Maintenance errors must not discard the record that triggered rotation.
if let Err(error) =
cleanup_log_files(&self.directory, self.prefix, date, self.retention_days)
{
if let Err(error) = cleanup_log_files(
&self.directory,
self.prefix,
date,
self.retention_days,
self.timezone,
) {
self.health.maintenance_failed(error.kind());
}
Ok(())
@@ -197,6 +204,7 @@ fn cleanup_log_files(
prefix: &str,
today: NaiveDate,
retention_days: usize,
timezone: gateway_core::time::DeploymentTimeZone,
) -> io::Result<()> {
let days =
u64::try_from(retention_days).map_err(|_| io::Error::other("log retention overflow"))?;
@@ -211,12 +219,11 @@ fn cleanup_log_files(
if date >= cutoff {
continue;
}
// Keep a whole date if any segment was written more recently than its filename.
// This also protects archives recovered after clock corrections or manual restoration.
// 用较近的文件名日期或实际写入日期保护整组,避免时区切换后提前清理。
let mut latest = date;
for path in &paths {
let modified: chrono::DateTime<Utc> = path.metadata()?.modified()?.into();
latest = latest.max(modified.date_naive());
latest = latest.max(timezone.local(modified).date_naive());
}
if latest >= cutoff {
continue;
@@ -263,18 +263,18 @@ impl HttpClient {
.as_str()
.parse()
.map_err(|_| HttpError::invalid("URL"))?;
let authority = uri
.authority()
.ok_or_else(|| HttpError::invalid("host"))?
.as_str();
headers.insert(
http::header::HOST,
HeaderValue::from_str(authority).map_err(|_| HttpError::invalid("host"))?,
);
if url.scheme() == "http"
&& let Some(proxy) = proxy.filter(|proxy| proxy.expose_url().starts_with("http"))
{
// 正向代理接收数字地址的绝对 URI;Host 保留原始域名,禁止代理再次解析目标。
let authority = uri
.authority()
.ok_or_else(|| HttpError::invalid("host"))?
.as_str();
headers.insert(
http::header::HOST,
HeaderValue::from_str(authority).map_err(|_| HttpError::invalid("host"))?,
);
let address = addresses
.first()
.ok_or_else(|| HttpError::invalid("address"))?;
@@ -129,6 +129,7 @@ impl Downloads {
continue;
}
if !(200..300).contains(&response.status) {
tracing::warn!(status = response.status, "插件来源返回非成功状态");
return Err(source_error());
}
if header(&response.headers, "content-length")
@@ -201,6 +201,7 @@ fn valid_config() -> HostConfig {
..SystemUpdateConfig::default()
};
HostConfig {
timezone: Default::default(),
listen: ListenConfig {
host: "127.0.0.1".to_owned(),
port: 8080,
@@ -10,6 +10,7 @@ use gateway_host::system_update::SystemUpdateConfig;
mod sink;
mod writer;
const LOG_TIMEZONE_ENV: &str = "CPR_LOGGING_TEST_TIMEZONE";
const LOG_DIRECTORY_ENV: &str = "CPR_LOGGING_TEST_DIRECTORY";
const CHILD_PROCESS_ENV: &str = "CPR_LOGGING_TEST_CHILD";
const REQUEST_DUMP_ENABLED_ENV: &str = "CPR_LOGGING_TEST_REQUEST_DUMP_ENABLED";
@@ -28,6 +29,7 @@ const REQUEST_DUMP_SECRET: &str = "unredacted-authorization-value";
#[test]
fn logging_requires_at_least_one_sink() {
let mut config = HostConfig {
timezone: Default::default(),
listen: ListenConfig {
host: "127.0.0.1".to_owned(),
port: 8080,
@@ -302,6 +304,10 @@ fn with_logging(config: HostConfig, write: impl FnOnce()) {
fn logging_config(directory: PathBuf, request_dump: bool) -> HostConfig {
HostConfig {
timezone: env::var(LOG_TIMEZONE_ENV)
.ok()
.map(|value| value.parse().unwrap())
.unwrap_or_default(),
listen: ListenConfig {
host: "127.0.0.1".to_owned(),
port: 8080,
@@ -373,3 +379,49 @@ fn log_file_set_exists(directory: &Path, file_prefix: &str) -> bool {
fn json_target_field(target: &str) -> String {
format!(r#""target":"{target}""#)
}
#[test]
fn deployment_timezone_controls_log_timestamp_and_file_date() {
for name in ["Asia/Shanghai", "UTC", "America/New_York", "Asia/Kathmandu"] {
let directory = tempfile::tempdir().unwrap();
let output = Command::new(env::current_exe().unwrap())
.args([
"--exact",
"logging::sensitive_file_logging_is_separate_and_overrides_global_log_level",
])
.env(CHILD_PROCESS_ENV, "1")
.env(LOG_DIRECTORY_ENV, directory.path())
.env(LOG_TIMEZONE_ENV, name)
.env(REQUEST_DUMP_ENABLED_ENV, "true")
.env("RUST_LOG", "off,logging_test_application=info")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let timezone: gateway_core::time::DeploymentTimeZone = name.parse().unwrap();
for prefix in [
APPLICATION_LOG_FILE_PREFIX,
OAUTH_RECOVERY_LOG_FILE_PREFIX,
REQUEST_DUMP_LOG_FILE_PREFIX,
] {
let body = read_log_file_set(directory.path(), prefix);
let record: serde_json::Value =
serde_json::from_str(body.lines().next().unwrap()).unwrap();
let timestamp =
chrono::DateTime::parse_from_rfc3339(record["timestamp"].as_str().unwrap())
.unwrap();
assert_eq!(
timestamp.to_rfc3339(),
timezone.local(timestamp.to_utc()).to_rfc3339()
);
let path = directory.path().join(format!(
"{prefix}{}.log",
timezone.local(timestamp.to_utc()).date_naive()
));
assert!(path.exists(), "{name}: {}", path.display());
}
}
}
@@ -64,7 +64,9 @@ fn write_failure_marks_log_completeness_unhealthy_even_after_writes_recover() {
assert_eq!(runtime.block_on(health.check()), HealthState::Healthy);
let blocked = directory.join(format!(
"{REQUEST_DUMP_LOG_FILE_PREFIX}{}.1.log",
chrono::Utc::now().date_naive()
gateway_core::time::DeploymentTimeZone::default()
.local(chrono::Utc::now())
.date_naive()
));
fs::create_dir(&blocked).unwrap();
let payload = "x".repeat(1024 * 1024);
@@ -22,7 +22,9 @@ fn retention_preserves_complete_dates_across_compression_rotation_and_restart()
return;
}
let directory = tempfile::tempdir().unwrap();
let today = chrono::Utc::now().date_naive();
let today = gateway_core::time::DeploymentTimeZone::default()
.local(chrono::Utc::now())
.date_naive();
let mut retained = Vec::new();
let mut expired = Vec::new();
for (prefix, days) in [
@@ -42,7 +44,7 @@ fn retention_preserves_complete_dates_across_compression_rotation_and_restart()
let path = directory.path().join(format!("{prefix}{old_date}.log"));
seed_log(&path, old_date, "expired date\n");
expired.push(path);
// If any old segment was recently written, protect its whole UTC date.
// 时区切换或恢复的文件按较近写入日期保护整个分段组。
let restored_date = old_date - chrono::Days::new(1);
for segment in 0..=1 {
let path = directory
@@ -139,10 +141,9 @@ fn retention_preserves_complete_dates_across_compression_rotation_and_restart()
fn seed_log(path: &Path, date: chrono::NaiveDate, body: &str) {
fs::write(path, body).unwrap();
let modified = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
date.and_hms_opt(0, 0, 0).unwrap(),
chrono::Utc,
);
let modified = gateway_core::time::DeploymentTimeZone::default()
.date_start(date)
.unwrap();
fs::File::open(path)
.unwrap()
.set_modified(SystemTime::from(modified))
@@ -17,6 +17,37 @@ fn local_network() -> NetworkPolicy {
NetworkPolicy::new(&["127.0.0.0/8".into(), "::1/128".into()]).unwrap()
}
#[tokio::test]
async fn managed_https_http2_uses_authority_without_an_extra_host_header() {
use std::sync::Arc;
use crate::support::network::{FixedDns, Mode, ROOT, Server};
let mut server = Server::start(Mode::Http2Origin, true, "127.0.0.1:0").await;
let url = format!("https://upstream.test:{}/query", server.address.port());
let client = HttpClient::with_root_certificates(vec![ROOT.to_vec()])
.unwrap()
.with_resolver(Arc::new(FixedDns(server.address.ip())));
let mut response = client
.open(
request(url.clone()),
None,
&local_network(),
Duration::from_secs(3),
)
.await
.unwrap();
assert_eq!(response.status, 200);
assert_eq!(response.body.read(64).await.unwrap().unwrap(), "secured");
let observation = server.next().await;
assert!(
observation
.head
.starts_with(&format!("GET {url} HTTP/2.0\r\n"))
);
assert_eq!(observation.sni.as_deref(), Some("upstream.test"));
}
#[tokio::test]
async fn managed_http_does_not_follow_redirects_or_fall_back_from_proxy() {
let server = MockServer::start().await;

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