mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-10 16:05:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fef52b3717 | ||
|
|
45e72682e6 | ||
|
|
90e241f74b | ||
|
|
a0e8f768dd | ||
|
|
318f8a748e | ||
|
|
0dcba14572 | ||
|
|
ab3a2e30c5 | ||
|
|
0490e28cfc | ||
|
|
9c10676764 | ||
|
|
caa6c8ffc8 | ||
|
|
9d651856be | ||
|
|
d10b8890e0 | ||
|
|
d97c6e064b | ||
|
|
f85801edb3 | ||
|
|
e30aad4755 | ||
|
|
945f7912ac | ||
|
|
19038f264a | ||
|
|
71c3b03878 | ||
|
|
70d1557b55 | ||
|
|
b4d11694ae |
@@ -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'
|
||||
|
||||
|
||||
@@ -177,6 +177,8 @@ jobs:
|
||||
host_path = "backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json"
|
||||
host = JSON.parse(File.read(host_path))
|
||||
abort "Invalid plugin host compatibility declaration: #{host_path}" unless host["schema_version"] == 2
|
||||
# 保留旧更新器可解析的传输格式,实际支持范围仍取自当前宿主合同。
|
||||
host = host.merge("schema_version" => 1, "permissions" => [])
|
||||
# 清单只依赖版本与兼容声明,平台归档和镜像消费同一份封口结果。
|
||||
manifest = {
|
||||
"schema_version" => 1,
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
+40
-199
@@ -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",
|
||||
]);
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,15 +421,18 @@ 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(),
|
||||
snapshot.clone(),
|
||||
));
|
||||
let system = Arc::new(DefaultSystemService::new(system, system_preflight));
|
||||
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
|
||||
@@ -430,6 +441,7 @@ async fn initialize_inner(
|
||||
store.client_keys(),
|
||||
store.observability(),
|
||||
system.clone(),
|
||||
timezone,
|
||||
));
|
||||
let credentials = Arc::new(CredentialsService::new(
|
||||
registry.clone(),
|
||||
@@ -451,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,
|
||||
@@ -491,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
|
||||
@@ -558,6 +586,9 @@ pub struct UsageListRecord {
|
||||
pub provider_account_email: Option<String>,
|
||||
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
|
||||
pub provider_account_notes: 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>,
|
||||
@@ -757,11 +788,20 @@ pub struct UsageSummary {
|
||||
pub average_latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 诊断聚合结果,分母包含截取展示项之前的全部匹配请求。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DiagnosticsObservation {
|
||||
pub total_request_count: u64,
|
||||
pub items: Vec<DiagnosticObservation>,
|
||||
}
|
||||
|
||||
/// 单个诊断维度值的聚合结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiagnosticObservation {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_provider_kind: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub failure_count: u64,
|
||||
@@ -772,6 +812,7 @@ pub struct DiagnosticObservation {
|
||||
pub first_token_p95_ms: Option<u64>,
|
||||
pub non_completion_count: u64,
|
||||
pub retry_count: u64,
|
||||
pub retried_request_count: u64,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
@@ -796,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>,
|
||||
@@ -1142,6 +1185,8 @@ pub struct UsageInsights {
|
||||
pub struct DiagnosticsItem {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub error_count: u64,
|
||||
|
||||
@@ -111,6 +111,7 @@ pub struct PluginInstanceRuntime {
|
||||
pub struct PluginInstanceView {
|
||||
pub instance: PluginInstance,
|
||||
pub configuration_required: bool,
|
||||
pub compatibility_warning: Option<String>,
|
||||
pub running: bool,
|
||||
pub published_revision: Option<u64>,
|
||||
pub runtime: PluginInstanceRuntime,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -28,9 +28,6 @@ pub struct AccountQuotaForecast {
|
||||
pub incomplete_tokens: bool,
|
||||
pub estimated_tokens: Option<u64>,
|
||||
pub estimated_usd: Option<f64>,
|
||||
/// 剩余估算始终属于源窗口,不随目标周期折算。
|
||||
pub remaining_tokens: Option<u64>,
|
||||
pub remaining_usd: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -81,8 +78,6 @@ pub fn account_quota_forecasts(
|
||||
incomplete_tokens: false,
|
||||
estimated_tokens: None,
|
||||
estimated_usd: None,
|
||||
remaining_tokens: None,
|
||||
remaining_usd: None,
|
||||
};
|
||||
if let Some((window, source_period)) = selected {
|
||||
forecast.project(
|
||||
@@ -191,14 +186,13 @@ impl AccountQuotaForecast {
|
||||
let remaining_tokens = Some(sample.usage.tokens)
|
||||
.filter(|tokens| *tokens > 0)
|
||||
.and_then(|value| estimate(value as f64, remaining_factor));
|
||||
self.remaining_tokens = remaining_tokens.and_then(|value| estimate_tokens(value, 1.0));
|
||||
self.estimated_tokens = remaining_tokens
|
||||
.and_then(|remaining| estimate_tokens(usage.tokens as f64 + remaining, factor));
|
||||
self.remaining_usd = Some(&sample.usage)
|
||||
let remaining_usd = Some(&sample.usage)
|
||||
.filter(|usage| usage.known_cost_count > 0 && usage.usd.is_finite() && usage.usd >= 0.0)
|
||||
.and_then(|usage| estimate(usage.usd, remaining_factor));
|
||||
self.estimated_usd = usd
|
||||
.zip(self.remaining_usd)
|
||||
.zip(remaining_usd)
|
||||
.and_then(|(used, remaining)| estimate(used + remaining, factor));
|
||||
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -162,3 +162,21 @@ impl SystemOperationAccepted {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 重启前的只读确认快照;目标发行与全局配置版本一起绑定用户确认。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SystemRestartPlan {
|
||||
pub target_version: Option<String>,
|
||||
pub release_manifest_sha256: Option<String>,
|
||||
pub config_revision: u64,
|
||||
pub incompatible_plugins: Vec<SystemIncompatiblePlugin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SystemIncompatiblePlugin {
|
||||
pub instance_id: String,
|
||||
pub name: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -219,6 +219,19 @@ pub trait PluginStore: Send + Sync {
|
||||
async fn configuration_versions(&self, _id: &str) -> AdminStoreResult<Vec<String>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
/// 在单个事务中停用确认快照中的实例,保留配置、密钥及私有状态。
|
||||
async fn disable_instances(
|
||||
&self,
|
||||
_ids: &[String],
|
||||
_expected_revision: Revision,
|
||||
_context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(super::store::AdminStoreError::new(
|
||||
super::store::AdminStoreErrorKind::Unavailable,
|
||||
"plugin",
|
||||
"atomic plugin disable is unavailable",
|
||||
))
|
||||
}
|
||||
async fn save_instance(
|
||||
&self,
|
||||
instance: PluginInstance,
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::model::{
|
||||
UpdateClientKey, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticsObservation,
|
||||
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageCalculatedBillingFact,
|
||||
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
@@ -444,7 +444,7 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>>;
|
||||
) -> AdminStoreResult<DiagnosticsObservation>;
|
||||
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
|
||||
}
|
||||
|
||||
@@ -77,10 +77,27 @@ pub trait SystemUpdatePreflight: Send + Sync {
|
||||
candidate: SystemUpdateCandidate,
|
||||
) -> Result<Revision, SystemOperationError>;
|
||||
|
||||
/// 回滚保持严格预检,不能把旧目标当成具有当前重启确认能力的版本。
|
||||
async fn validate_rollback(
|
||||
&self,
|
||||
candidate: SystemUpdateCandidate,
|
||||
) -> Result<Revision, SystemOperationError> {
|
||||
self.validate(candidate).await
|
||||
}
|
||||
|
||||
/// 在文件交换临界点复核全局配置 CAS,不能用较早快照掩盖实例变更。
|
||||
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError>;
|
||||
}
|
||||
|
||||
/// 重启临界区内复查候选,确认后停用不兼容插件,再允许进程退出。
|
||||
#[async_trait]
|
||||
pub trait SystemRestartPreflight: Send + Sync {
|
||||
async fn prepare(
|
||||
&self,
|
||||
candidate: Option<SystemUpdateCandidate>,
|
||||
) -> Result<(), SystemOperationError>;
|
||||
}
|
||||
|
||||
/// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host。
|
||||
#[async_trait]
|
||||
pub trait SystemOperations: Send + Sync {
|
||||
@@ -108,5 +125,15 @@ pub trait SystemOperations: Send + Sync {
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
/// 返回下一次启动实际使用的发行;源码运行没有封存发行清单。
|
||||
async fn restart_candidate(
|
||||
&self,
|
||||
) -> Result<Option<SystemUpdateCandidate>, SystemOperationError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn restart(
|
||||
&self,
|
||||
preflight: Arc<dyn SystemRestartPreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -161,7 +163,7 @@ impl DefaultObservabilityService {
|
||||
self.store.dashboard_runtime_slots(observed_at),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "dashboard"))?;
|
||||
self.enrich_list_billing(&mut observation.recent_requests);
|
||||
self.enrich_list_records(&mut observation.recent_requests);
|
||||
self.enrich_dashboard_quotas(&mut observation.account_usage)
|
||||
.await;
|
||||
for account in &mut observation.account_usage {
|
||||
@@ -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);
|
||||
@@ -268,7 +277,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
.list_usage_records(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage records"))?;
|
||||
self.enrich_list_billing(&mut page.items);
|
||||
self.enrich_list_records(&mut page.items);
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
@@ -334,29 +343,37 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
filter: UsageFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> Result<DiagnosticsResult, AdminError> {
|
||||
let items = self
|
||||
let observation = self
|
||||
.store
|
||||
.usage_diagnostics(range, filter, dimension)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
|
||||
let total_requests = items.iter().fold(0_u64, |total, item| {
|
||||
total.saturating_add(item.request_count)
|
||||
});
|
||||
let items = items
|
||||
let items = observation
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let error_rate = rate_or_zero(item.failure_count, item.request_count);
|
||||
let non_completion_rate =
|
||||
rate_or_zero(item.non_completion_count, item.request_count);
|
||||
let retry_rate = rate_or_zero(item.retry_count, item.request_count);
|
||||
let retry_rate = rate_or_zero(item.retried_request_count, item.request_count);
|
||||
DiagnosticsItem {
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
account_plan_type_display: item.account_provider_kind.as_deref().and_then(
|
||||
|provider| {
|
||||
self.providers
|
||||
.plan_type_display(provider, item.account_plan_type.as_deref())
|
||||
},
|
||||
),
|
||||
account_plan_type: item.account_plan_type,
|
||||
request_count: item.request_count,
|
||||
success_count: item.success_count,
|
||||
error_count: item.failure_count,
|
||||
error_rate,
|
||||
request_share: rate_or_zero(item.request_count, total_requests),
|
||||
request_share: rate_or_zero(
|
||||
item.request_count,
|
||||
observation.total_request_count,
|
||||
),
|
||||
average_latency_ms: item.average_latency_ms,
|
||||
latency_p95_ms: item.latency_p95_ms,
|
||||
first_token_p95_ms: item.first_token_p95_ms,
|
||||
@@ -374,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,10 +798,15 @@ impl DefaultObservabilityService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
|
||||
/// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
|
||||
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回。
|
||||
fn enrich_list_billing(&self, records: &mut [crate::model::observability::UsageListRecord]) {
|
||||
fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) {
|
||||
for record in records {
|
||||
record.provider_account_plan_type_display =
|
||||
record.provider_kind.as_deref().and_then(|provider| {
|
||||
self.providers
|
||||
.plan_type_display(provider, record.provider_account_plan_type.as_deref())
|
||||
});
|
||||
let Some(UsageBilling::Total { source, total }) = record.billing.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
@@ -839,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),
|
||||
@@ -859,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)
|
||||
@@ -875,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,
|
||||
@@ -896,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> {
|
||||
@@ -1160,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()))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use super::publish_committed;
|
||||
use crate::model::{
|
||||
MutationContext,
|
||||
system::{SystemIncompatiblePlugin, SystemRestartPlan},
|
||||
};
|
||||
use crate::ports::system::SystemRestartPreflight;
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use crate::{
|
||||
model::{AdminErrorKind, Revision},
|
||||
@@ -14,20 +22,26 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
use super::plugins::official::update_compatibility;
|
||||
use super::plugins::official::{update_compatibility, validate_update_release};
|
||||
|
||||
/// 只读取启用实例及其固定包体,检查目标宿主合同;不准备或执行插件。
|
||||
pub(crate) struct PluginSystemUpdatePreflight {
|
||||
store: Arc<dyn PluginStore>,
|
||||
inspector: Arc<dyn PluginPackageInspector>,
|
||||
snapshots: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl PluginSystemUpdatePreflight {
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn PluginStore>,
|
||||
inspector: Arc<dyn PluginPackageInspector>,
|
||||
snapshots: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self { store, inspector }
|
||||
Self {
|
||||
store,
|
||||
inspector,
|
||||
snapshots,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,59 +51,28 @@ impl SystemUpdatePreflight for PluginSystemUpdatePreflight {
|
||||
&self,
|
||||
candidate: SystemUpdateCandidate,
|
||||
) -> Result<Revision, SystemOperationError> {
|
||||
let target = semver::Version::parse(candidate.target_version.trim_start_matches('v'))
|
||||
.map_err(|_| invalid("目标网关版本不合法"))?;
|
||||
let compatibility = update_compatibility(
|
||||
candidate.release_manifest.as_ref(),
|
||||
candidate.target_version.trim_start_matches('v'),
|
||||
)
|
||||
.map_err(|_| invalid("目标发行物缺少有效的插件兼容声明"))?;
|
||||
let snapshot = self.store.load_instances().await.map_err(map_store_error)?;
|
||||
let mut inspected = BTreeMap::new();
|
||||
for instance in snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.filter(|instance| instance.enabled)
|
||||
{
|
||||
let requirements = if let Some(requirements) = inspected.get(&instance.artifact_sha256)
|
||||
{
|
||||
requirements
|
||||
} else {
|
||||
let artifact = self
|
||||
.store
|
||||
.load_artifact(&instance.artifact_sha256)
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
let requirements = self
|
||||
.inspector
|
||||
.compatibility(artifact.archive, instance.artifact_sha256.clone())
|
||||
.await
|
||||
.map_err(map_inspection_error)?;
|
||||
inspected.insert(instance.artifact_sha256.clone(), requirements);
|
||||
inspected
|
||||
.get(&instance.artifact_sha256)
|
||||
.ok_or_else(|| internal("插件兼容性检查结果不可用"))?
|
||||
};
|
||||
let host_versions = semver::VersionReq::parse(&requirements.host_version)
|
||||
.map_err(|_| incompatible(&instance.id))?;
|
||||
if !host_versions.matches(&target)
|
||||
|| !compatibility
|
||||
.manifest_schema_versions
|
||||
.contains(&requirements.manifest_schema_version)
|
||||
|| !compatibility
|
||||
.protocol_versions
|
||||
.contains(&requirements.protocol_version)
|
||||
|| requirements
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|(capability, version)| {
|
||||
!compatibility.supports_capability(capability, *version)
|
||||
})
|
||||
{
|
||||
return Err(incompatible(&instance.id));
|
||||
}
|
||||
let target_version = candidate.target_version.trim_start_matches('v');
|
||||
semver::Version::parse(target_version).map_err(|_| invalid("目标网关版本不合法"))?;
|
||||
// 下载阶段只校验发行身份;插件兼容性在用户点击重启时检查并确认。
|
||||
validate_update_release(candidate.release_manifest.as_ref(), target_version)
|
||||
.map_err(|_| invalid("目标发行清单不合法或版本不匹配"))?;
|
||||
Ok(self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(map_store_error)?
|
||||
.config_revision)
|
||||
}
|
||||
|
||||
async fn validate_rollback(
|
||||
&self,
|
||||
candidate: SystemUpdateCandidate,
|
||||
) -> Result<Revision, SystemOperationError> {
|
||||
let plan = self.plan(Some(candidate)).await?;
|
||||
if !plan.incompatible_plugins.is_empty() {
|
||||
return Err(conflict("启用插件与回滚目标不兼容,请先停用对应插件"));
|
||||
}
|
||||
Ok(snapshot.config_revision)
|
||||
Revision::new(plan.config_revision).map_err(|_| internal("插件配置版本不合法"))
|
||||
}
|
||||
|
||||
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError> {
|
||||
@@ -127,10 +110,6 @@ fn map_inspection_error(error: crate::model::AdminError) -> SystemOperationError
|
||||
}
|
||||
}
|
||||
|
||||
fn incompatible(instance_id: &str) -> SystemOperationError {
|
||||
conflict(format!("启用插件实例 {instance_id} 与目标网关版本不兼容"))
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> SystemOperationError {
|
||||
SystemOperationError::new(SystemOperationErrorKind::Invalid, message)
|
||||
}
|
||||
@@ -142,3 +121,158 @@ fn conflict(message: impl Into<String>) -> SystemOperationError {
|
||||
fn internal(message: impl Into<String>) -> SystemOperationError {
|
||||
SystemOperationError::new(SystemOperationErrorKind::Internal, message)
|
||||
}
|
||||
|
||||
impl PluginSystemUpdatePreflight {
|
||||
pub(crate) async fn plan(
|
||||
&self,
|
||||
candidate: Option<SystemUpdateCandidate>,
|
||||
) -> Result<SystemRestartPlan, SystemOperationError> {
|
||||
let snapshot = self.store.load_instances().await.map_err(map_store_error)?;
|
||||
let mut plan = SystemRestartPlan {
|
||||
target_version: candidate.as_ref().map(|item| item.target_version.clone()),
|
||||
release_manifest_sha256: candidate
|
||||
.as_ref()
|
||||
.map(|item| hex::encode(Sha256::digest(&item.release_manifest))),
|
||||
config_revision: snapshot.config_revision.get(),
|
||||
incompatible_plugins: Vec::new(),
|
||||
};
|
||||
let target = candidate
|
||||
.as_ref()
|
||||
.map(|item| semver::Version::parse(item.target_version.trim_start_matches('v')))
|
||||
.transpose()
|
||||
.map_err(|_| invalid("目标网关版本不合法"))?;
|
||||
if let Some(candidate) = &candidate {
|
||||
validate_update_release(
|
||||
&candidate.release_manifest,
|
||||
candidate.target_version.trim_start_matches('v'),
|
||||
)
|
||||
.map_err(|_| invalid("目标发行清单不合法或版本不匹配"))?;
|
||||
}
|
||||
// 未知目标合同不能证明兼容,列入待确认停用;不伪造目标支持范围。
|
||||
let host = candidate.as_ref().and_then(|item| {
|
||||
update_compatibility(
|
||||
&item.release_manifest,
|
||||
item.target_version.trim_start_matches('v'),
|
||||
)
|
||||
.ok()
|
||||
});
|
||||
let mut inspected: BTreeMap<String, Option<String>> = BTreeMap::new();
|
||||
for instance in snapshot.instances.iter().filter(|item| item.enabled) {
|
||||
let reason = if let Some(reason) = inspected.get(&instance.artifact_sha256) {
|
||||
reason.clone()
|
||||
} else {
|
||||
let artifact = self
|
||||
.store
|
||||
.load_artifact(&instance.artifact_sha256)
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
let reason = if let Some(target) = &target {
|
||||
match (
|
||||
&host,
|
||||
self.inspector
|
||||
.compatibility(artifact.archive, instance.artifact_sha256.clone())
|
||||
.await,
|
||||
) {
|
||||
(_, Err(error)) if error.kind() == AdminErrorKind::Invalid => {
|
||||
Some("插件包或协议与目标版本不兼容".to_owned())
|
||||
}
|
||||
(_, Err(error)) => return Err(map_inspection_error(error)),
|
||||
(None, _) => Some("无法确认插件与目标版本兼容".to_owned()),
|
||||
(Some(host), Ok(requirements)) => {
|
||||
if !semver::VersionReq::parse(&requirements.host_version)
|
||||
.is_ok_and(|versions| versions.matches(target))
|
||||
{
|
||||
Some(format!("要求网关版本 {}", requirements.host_version))
|
||||
} else if !host
|
||||
.manifest_schema_versions
|
||||
.contains(&requirements.manifest_schema_version)
|
||||
|| !host
|
||||
.protocol_versions
|
||||
.contains(&requirements.protocol_version)
|
||||
|| requirements
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|(capability, version)| {
|
||||
!host.supports_capability(capability, *version)
|
||||
})
|
||||
{
|
||||
Some("插件协议或能力与目标版本不兼容".to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match self
|
||||
.inspector
|
||||
.inspect(artifact.archive, Some(instance.artifact_sha256.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(_) => None,
|
||||
Err(error) if error.kind() == AdminErrorKind::Invalid => {
|
||||
Some("插件与当前宿主不兼容".to_owned())
|
||||
}
|
||||
Err(error) => return Err(map_inspection_error(error)),
|
||||
}
|
||||
};
|
||||
inspected.insert(instance.artifact_sha256.clone(), reason.clone());
|
||||
reason
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
plan.incompatible_plugins.push(SystemIncompatiblePlugin {
|
||||
instance_id: instance.id.clone(),
|
||||
name: instance.name.clone(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
plan.incompatible_plugins
|
||||
.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
|
||||
Ok(plan)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ConfirmedPluginRestart {
|
||||
pub preflight: Arc<PluginSystemUpdatePreflight>,
|
||||
pub confirmation: Option<SystemRestartPlan>,
|
||||
pub context: MutationContext,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemRestartPreflight for ConfirmedPluginRestart {
|
||||
async fn prepare(
|
||||
&self,
|
||||
candidate: Option<SystemUpdateCandidate>,
|
||||
) -> Result<(), SystemOperationError> {
|
||||
let plan = self.preflight.plan(candidate).await?;
|
||||
if self
|
||||
.confirmation
|
||||
.as_ref()
|
||||
.is_some_and(|confirmed| confirmed != &plan)
|
||||
{
|
||||
return Err(conflict("插件配置或目标版本已变化,请重新检查并确认重启"));
|
||||
}
|
||||
if !plan.incompatible_plugins.is_empty() && self.confirmation.is_none() {
|
||||
return Err(conflict("请先确认停用不兼容插件,再重启服务"));
|
||||
}
|
||||
let mut revision =
|
||||
Revision::new(plan.config_revision).map_err(|_| internal("插件配置版本不合法"))?;
|
||||
if !plan.incompatible_plugins.is_empty() {
|
||||
let ids = plan
|
||||
.incompatible_plugins
|
||||
.iter()
|
||||
.map(|item| item.instance_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
revision = self
|
||||
.preflight
|
||||
.store
|
||||
.disable_instances(&ids, revision, &self.context)
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
publish_committed(self.preflight.snapshots.as_ref(), revision)
|
||||
.await
|
||||
.map_err(|_| internal("插件已停用,但运行配置尚未发布,请重试重启"))?;
|
||||
}
|
||||
self.preflight.confirm_revision(revision).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
@@ -49,6 +52,7 @@ pub struct PluginsService {
|
||||
pub(super) preparation: Arc<dyn PluginPreparation>,
|
||||
pub(super) published: gateway_core::runtime::RuntimeSnapshotHandle,
|
||||
pub(super) state: PluginStateService,
|
||||
pub(super) compatibility: tokio::sync::Mutex<BTreeMap<String, Option<String>>>,
|
||||
}
|
||||
|
||||
impl PluginsService {
|
||||
@@ -70,6 +74,7 @@ impl PluginsService {
|
||||
preparation,
|
||||
published,
|
||||
state: PluginStateService::new(state),
|
||||
compatibility: tokio::sync::Mutex::new(BTreeMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use secrecy::ExposeSecret;
|
||||
use super::PluginsService;
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext, Revision,
|
||||
AdminError, AdminErrorKind, MutationContext, Revision,
|
||||
plugins::instances::{
|
||||
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
|
||||
PluginInstanceReplacement, PluginInstanceRuntime, PluginInstanceRuntimeStatus,
|
||||
@@ -18,6 +18,36 @@ use crate::{
|
||||
};
|
||||
|
||||
impl PluginsService {
|
||||
/// 只校验固定包体,不启动插件;停用的旧版本仍需向管理端说明不能启用的原因。
|
||||
async fn compatibility_warning(&self, digest: &str) -> Result<Option<String>, AdminError> {
|
||||
// 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包。
|
||||
// 只缓存有限数量的确定结果,暂时性错误仍可重试。
|
||||
let mut cache = self.compatibility.lock().await;
|
||||
if let Some(warning) = cache.get(digest) {
|
||||
return Ok(warning.clone());
|
||||
}
|
||||
let artifact = self
|
||||
.store
|
||||
.load_artifact(digest)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let warning = match self
|
||||
.inspector
|
||||
.inspect(artifact.archive, Some(digest.to_owned()))
|
||||
.await
|
||||
{
|
||||
Ok(_) => None,
|
||||
Err(error) if error.kind() == AdminErrorKind::Invalid => {
|
||||
Some(error.message().to_owned())
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if cache.len() < 128 {
|
||||
cache.insert(digest.to_owned(), warning.clone());
|
||||
}
|
||||
Ok(warning)
|
||||
}
|
||||
|
||||
pub async fn instances(&self) -> Result<Vec<PluginInstanceView>, AdminError> {
|
||||
let snapshot = self
|
||||
.store
|
||||
@@ -52,10 +82,14 @@ impl PluginsService {
|
||||
let metadata = artifacts
|
||||
.get(&instance.artifact_sha256)
|
||||
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
|
||||
let configuration_required = !self
|
||||
.preparation
|
||||
.configuration_ready(instance.clone(), metadata)
|
||||
let compatibility_warning = self
|
||||
.compatibility_warning(&instance.artifact_sha256)
|
||||
.await?;
|
||||
let configuration_required = compatibility_warning.is_none()
|
||||
&& !self
|
||||
.preparation
|
||||
.configuration_ready(instance.clone(), metadata)
|
||||
.await?;
|
||||
let runtime = diagnostics.remove(&instance.id).unwrap_or_else(|| {
|
||||
let running = instance.enabled
|
||||
&& published_ready
|
||||
@@ -76,6 +110,7 @@ impl PluginsService {
|
||||
});
|
||||
views.push(PluginInstanceView {
|
||||
configuration_required,
|
||||
compatibility_warning,
|
||||
running: runtime.status == PluginInstanceRuntimeStatus::Running,
|
||||
published_revision,
|
||||
runtime,
|
||||
@@ -162,6 +197,13 @@ impl PluginsService {
|
||||
if artifact.accepted_at.is_none() {
|
||||
return Err(AdminError::invalid("请先安装并接受该版本声明的权限"));
|
||||
}
|
||||
if input.enabled
|
||||
&& let Some(warning) = self.compatibility_warning(&input.artifact_sha256).await?
|
||||
{
|
||||
return Err(AdminError::invalid(format!(
|
||||
"{warning},无法启动,请安装兼容版本"
|
||||
)));
|
||||
}
|
||||
let same_plugin = |digest: &str| {
|
||||
artifacts.iter().any(|item| {
|
||||
item.metadata.sha256 == digest
|
||||
|
||||
@@ -34,7 +34,7 @@ struct SealedReleaseManifest {
|
||||
sealed: bool,
|
||||
gateway_version: String,
|
||||
gateway_git_sha: String,
|
||||
plugin_host: PluginHostCompatibility,
|
||||
plugin_host: serde_json::Value,
|
||||
plugins: Vec<OfficialPlugin>,
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ fn validate_manifest(
|
||||
identity: &OfficialPluginReleaseIdentity,
|
||||
) -> Result<ValidatedManifest, AdminError> {
|
||||
let manifest = parse_manifest(bytes)?;
|
||||
parse_host_compatibility(manifest.plugin_host)?;
|
||||
if manifest.gateway_version != identity.gateway_version
|
||||
|| manifest.gateway_git_sha != identity.gateway_git_sha
|
||||
{
|
||||
@@ -168,8 +169,39 @@ pub(crate) fn update_compatibility(
|
||||
target_version: &str,
|
||||
) -> Result<PluginHostCompatibility, AdminError> {
|
||||
let manifest = parse_manifest(bytes)?;
|
||||
(manifest.gateway_version == target_version)
|
||||
.then_some(manifest.plugin_host)
|
||||
if manifest.gateway_version != target_version {
|
||||
return Err(invalid_manifest());
|
||||
}
|
||||
parse_host_compatibility(manifest.plugin_host)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_update_release(
|
||||
bytes: &[u8],
|
||||
target_version: &str,
|
||||
) -> Result<(), AdminError> {
|
||||
let manifest = parse_manifest(bytes)?;
|
||||
if manifest.gateway_version != target_version {
|
||||
return Err(invalid_manifest());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_host_compatibility(
|
||||
mut value: serde_json::Value,
|
||||
) -> Result<PluginHostCompatibility, AdminError> {
|
||||
// 旧更新器要求 v1 与空权限字段,运行兼容性仍由清单、协议和能力版本表达。
|
||||
if value.get("schema_version") == Some(&serde_json::json!(1))
|
||||
&& value.get("permissions") == Some(&serde_json::json!([]))
|
||||
{
|
||||
let fields = value.as_object_mut().ok_or_else(invalid_manifest)?;
|
||||
fields.remove("permissions");
|
||||
fields.insert("schema_version".into(), serde_json::json!(2));
|
||||
}
|
||||
let compatibility: PluginHostCompatibility =
|
||||
serde_json::from_value(value).map_err(|_| invalid_manifest())?;
|
||||
compatibility
|
||||
.is_valid()
|
||||
.then_some(compatibility)
|
||||
.ok_or_else(invalid_manifest)
|
||||
}
|
||||
|
||||
@@ -183,7 +215,6 @@ fn parse_manifest(bytes: &[u8]) -> Result<SealedReleaseManifest, AdminError> {
|
||||
|| !manifest.sealed
|
||||
|| !valid_text(&manifest.gateway_version, MAXIMUM_VERSION_BYTES)
|
||||
|| !valid_git_sha(&manifest.gateway_git_sha)
|
||||
|| !manifest.plugin_host.is_valid()
|
||||
|| manifest.plugins.len() > MAXIMUM_PLUGINS
|
||||
{
|
||||
return Err(invalid_manifest());
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::plugin_update::{ConfirmedPluginRestart, PluginSystemUpdatePreflight};
|
||||
use crate::model::{MutationContext, system::SystemRestartPlan};
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
@@ -14,7 +16,6 @@ use crate::{
|
||||
},
|
||||
ports::system::{
|
||||
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateEventStream,
|
||||
SystemUpdatePreflight,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,20 +36,25 @@ pub trait SystemService: Send + Sync {
|
||||
) -> Result<SystemOperationAccepted, AdminError>;
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, AdminError>;
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError>;
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, AdminError>;
|
||||
async fn restart_plan(&self) -> Result<SystemRestartPlan, AdminError>;
|
||||
async fn restart(
|
||||
&self,
|
||||
confirmation: Option<SystemRestartPlan>,
|
||||
context: &MutationContext,
|
||||
) -> Result<SystemOperationAccepted, AdminError>;
|
||||
}
|
||||
|
||||
/// 保持 Host 能力窄边界的默认系统用例。
|
||||
pub(crate) struct DefaultSystemService {
|
||||
operations: Arc<dyn SystemOperations>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
preflight: Arc<PluginSystemUpdatePreflight>,
|
||||
}
|
||||
|
||||
impl DefaultSystemService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
operations: Arc<dyn SystemOperations>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
preflight: Arc<PluginSystemUpdatePreflight>,
|
||||
) -> Self {
|
||||
Self {
|
||||
operations,
|
||||
@@ -87,7 +93,7 @@ impl SystemService for DefaultSystemService {
|
||||
.map(|version| version.trim().to_owned())
|
||||
.filter(|version| !version.is_empty());
|
||||
self.operations
|
||||
.perform_update(target_version, channel, Arc::clone(&self.preflight))
|
||||
.perform_update(target_version, channel, self.preflight.clone())
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
@@ -101,13 +107,36 @@ impl SystemService for DefaultSystemService {
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError> {
|
||||
self.operations
|
||||
.rollback(Arc::clone(&self.preflight))
|
||||
.rollback(self.preflight.clone())
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, AdminError> {
|
||||
self.operations.restart().await.map_err(map_system_error)
|
||||
async fn restart_plan(&self) -> Result<SystemRestartPlan, AdminError> {
|
||||
let candidate = self
|
||||
.operations
|
||||
.restart_candidate()
|
||||
.await
|
||||
.map_err(map_system_error)?;
|
||||
self.preflight
|
||||
.plan(candidate)
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
|
||||
async fn restart(
|
||||
&self,
|
||||
confirmation: Option<SystemRestartPlan>,
|
||||
context: &MutationContext,
|
||||
) -> Result<SystemOperationAccepted, AdminError> {
|
||||
self.operations
|
||||
.restart(Arc::new(ConfirmedPluginRestart {
|
||||
preflight: self.preflight.clone(),
|
||||
confirmation,
|
||||
context: context.clone(),
|
||||
}))
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +149,7 @@ fn map_system_error(error: SystemOperationError) -> AdminError {
|
||||
};
|
||||
let message = match kind {
|
||||
AdminErrorKind::Invalid => "系统操作请求不合法",
|
||||
AdminErrorKind::Conflict => "系统当前状态不允许执行该操作",
|
||||
AdminErrorKind::Conflict => error.message(),
|
||||
AdminErrorKind::BadGateway => "系统更新服务请求失败",
|
||||
AdminErrorKind::Internal => "系统操作失败",
|
||||
_ => "系统操作失败",
|
||||
|
||||
@@ -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,
|
||||
@@ -114,18 +115,14 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecasts_use_actual_periods_and_do_not_extrapolate_remaining_capacity() {
|
||||
fn forecasts_use_actual_periods_and_extrapolate_total_capacity() {
|
||||
let [week, month] = forecast("a(vec![window("week", 7)]));
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert!(!week.extrapolated);
|
||||
assert!(month.extrapolated);
|
||||
assert_eq!(month.estimated_tokens, Some(21_429));
|
||||
assert!((month.estimated_usd.unwrap() - 10.0 * 30.0 / 7.0).abs() < 1e-10);
|
||||
assert_eq!(month.remaining_tokens, week.remaining_tokens);
|
||||
assert_eq!(month.remaining_usd, week.remaining_usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -203,8 +200,8 @@ fn low_samples_warn_and_exhaustion_uses_raw_not_display_percentages() {
|
||||
source.windows[0].used_percent = Some(100.0);
|
||||
let [week, _] = forecast(&source);
|
||||
assert!(!week.low_sample);
|
||||
assert_eq!(week.remaining_tokens, Some(0));
|
||||
assert_eq!(week.remaining_usd, Some(0.0));
|
||||
assert_eq!(week.estimated_tokens, Some(1_000));
|
||||
assert_eq!(week.estimated_usd, Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -257,8 +254,7 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
|
||||
let [week, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(1), now(), &[sample]);
|
||||
assert_eq!(week.estimated_tokens, Some(9_000));
|
||||
assert_eq!(week.remaining_tokens, Some(8_000));
|
||||
assert_eq!(month.remaining_tokens, Some(8_000));
|
||||
assert_eq!(month.estimated_tokens, Some(38_571));
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert!(!week.low_sample);
|
||||
}
|
||||
@@ -273,7 +269,6 @@ fn missing_tokens_keep_estimates_from_recorded_usage() {
|
||||
assert!(week.incomplete_tokens);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.source.unwrap().tokens, Some(1_000));
|
||||
}
|
||||
@@ -308,13 +303,12 @@ fn partial_costs_keep_estimates_from_known_amounts() {
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimates() {
|
||||
fn missing_tokens_and_costs_do_not_block_recorded_capacity_estimates() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
// 完整请求之外混有缺少计量的成功请求,不把已有用量和费用一并作废。
|
||||
@@ -331,11 +325,7 @@ fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimate
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(month.estimated_tokens, Some(21_429));
|
||||
assert_eq!(month.remaining_tokens, week.remaining_tokens);
|
||||
assert_eq!(month.remaining_usd, week.remaining_usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -352,7 +342,6 @@ fn entirely_unknown_costs_leave_only_money_estimates_unavailable() {
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -370,8 +359,6 @@ fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
|
||||
assert!(week.unavailable_reason.is_some());
|
||||
assert_eq!(week.estimated_tokens, None);
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_tokens, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -420,11 +407,8 @@ fn slower_recent_consumption_preserves_recorded_cycle_usage_in_total_forecast()
|
||||
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(1_084_786_565));
|
||||
assert_eq!(cycle.source.as_ref().unwrap().usd, Some(1_412.57957384));
|
||||
assert_eq!(cycle.remaining_tokens, Some(66_459_719));
|
||||
assert_eq!(cycle.estimated_tokens, Some(1_151_246_284));
|
||||
assert!((cycle.remaining_usd.unwrap() - 111.628272395294).abs() < 1e-8);
|
||||
assert!((cycle.estimated_usd.unwrap() - 1_524.207846235294).abs() < 1e-8);
|
||||
assert_eq!(month.remaining_tokens, cycle.remaining_tokens);
|
||||
assert!(
|
||||
(month.estimated_usd.unwrap() - cycle.estimated_usd.unwrap() * 30.0 / 7.0).abs() < 1e-8
|
||||
);
|
||||
@@ -445,6 +429,4 @@ fn exhausted_cycle_total_is_recorded_usage_even_when_recent_sample_is_small() {
|
||||
let [cycle, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert_eq!(cycle.estimated_tokens, Some(1_000));
|
||||
assert_eq!(cycle.estimated_usd, Some(2.0));
|
||||
assert_eq!(cycle.remaining_tokens, Some(0));
|
||||
assert_eq!(cycle.remaining_usd, Some(0.0));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -2430,7 +2437,6 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
|
||||
.unwrap();
|
||||
assert!(result.forecasts[0].unavailable_reason.is_none());
|
||||
assert_eq!(result.forecasts[0].estimated_tokens, Some(5_000));
|
||||
assert_eq!(result.forecasts[0].remaining_tokens, Some(3_000));
|
||||
assert_eq!(store.quota_window_queries()[0].range.start, added);
|
||||
store
|
||||
.quota_forecast_history
|
||||
@@ -2471,7 +2477,6 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
|
||||
let cycle = &result.forecasts[0];
|
||||
assert!(cycle.unavailable_reason.is_none());
|
||||
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(250));
|
||||
assert_eq!(cycle.remaining_tokens, Some(429));
|
||||
assert_eq!(cycle.estimated_tokens, Some(679));
|
||||
}
|
||||
|
||||
@@ -2526,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,
|
||||
@@ -2683,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,
|
||||
@@ -2984,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,
|
||||
|
||||
@@ -44,7 +44,7 @@ use gateway_admin::{
|
||||
observability::{
|
||||
DashboardDesktopRelease, DashboardObservation, DashboardWireAttribute,
|
||||
DashboardWireProfile, DashboardWireTarget, DesktopReleaseStatus, DiagnosticDimension,
|
||||
DiagnosticObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
|
||||
DiagnosticsObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
|
||||
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
provider_credentials::{
|
||||
@@ -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),
|
||||
@@ -770,7 +771,7 @@ impl ObservabilityStore for UnavailableStore {
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> AdminStoreResult<DiagnosticsObservation> {
|
||||
Err(unavailable("usage diagnostics"))
|
||||
}
|
||||
|
||||
@@ -1086,7 +1087,10 @@ impl SystemOperations for UnavailableSystem {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
async fn restart(
|
||||
&self,
|
||||
_preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,11 @@ use gateway_admin::{
|
||||
MutationContext, PageSize, Revision,
|
||||
observability::{
|
||||
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
|
||||
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation, Granularity,
|
||||
HealthStatus, LatencyPercentiles, OpsErrorPage, OpsErrorQuery, PercentileMilliseconds,
|
||||
RequestMetricPoint, RequestMetrics, TimeRange, TrendKind, UsageBilling,
|
||||
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageListRecord, UsageOverview,
|
||||
UsagePage, UsageQuery, china_day_start,
|
||||
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
|
||||
DiagnosticsObservation, Granularity, HealthStatus, LatencyPercentiles, OpsErrorPage,
|
||||
OpsErrorQuery, PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange,
|
||||
TrendKind, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
|
||||
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();
|
||||
@@ -421,7 +445,10 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
|
||||
.expect("calculated total"),
|
||||
},
|
||||
}]);
|
||||
store.replace_diagnostics(vec![diagnostic("openai", 3), diagnostic("xai", 1)]);
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 4,
|
||||
items: vec![diagnostic("openai", 3), diagnostic("xai", 1)],
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let insights = services
|
||||
@@ -513,7 +540,10 @@ async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
|
||||
let mut failure = diagnostic("single_failure", 1);
|
||||
failure.success_count = 0;
|
||||
failure.failure_count = 1;
|
||||
store.replace_diagnostics(vec![success, failure]);
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 101,
|
||||
items: vec![success, failure],
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
@@ -529,6 +559,68 @@ async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
|
||||
assert_eq!(result.items[1].error_rate, 1.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_use_the_total_before_truncating_groups() {
|
||||
let range = observation_range(Utc::now());
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 200,
|
||||
items: (0..100)
|
||||
.map(|index| diagnostic(&format!("model-{index}"), 1))
|
||||
.collect(),
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
|
||||
.await
|
||||
.expect("limited diagnostics");
|
||||
|
||||
assert!(result.items.iter().all(|item| item.request_share == 0.005));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_count_each_retried_request_once() {
|
||||
let range = observation_range(Utc::now());
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
let mut item = diagnostic("model", 8);
|
||||
item.attempt_count = 14;
|
||||
item.retry_count = 6;
|
||||
item.retried_request_count = 2;
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 8,
|
||||
items: vec![item],
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
|
||||
.await
|
||||
.expect("retry diagnostics");
|
||||
|
||||
assert_eq!(
|
||||
(result.items[0].retry_count, result.items[0].retry_rate),
|
||||
(6, 0.25)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_preserve_empty_results() {
|
||||
let range = observation_range(Utc::now());
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Account)
|
||||
.await
|
||||
.expect("empty diagnostics");
|
||||
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_records_should_tolerate_records_that_fail_billing_enrichment() {
|
||||
let now = Utc::now();
|
||||
@@ -632,7 +724,7 @@ struct FixtureObservabilityStore {
|
||||
overview: Mutex<UsageOverview>,
|
||||
calculated_billing_facts: Mutex<Vec<UsageCalculatedBillingFact>>,
|
||||
billing_stream_fails: AtomicBool,
|
||||
diagnostics: Mutex<Vec<DiagnosticObservation>>,
|
||||
diagnostics: Mutex<DiagnosticsObservation>,
|
||||
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
|
||||
summary_observed_at: Mutex<Option<DateTime<Utc>>>,
|
||||
slots_observed_at: Mutex<Option<DateTime<Utc>>>,
|
||||
@@ -653,7 +745,7 @@ impl FixtureObservabilityStore {
|
||||
}),
|
||||
calculated_billing_facts: Mutex::new(Vec::new()),
|
||||
billing_stream_fails: AtomicBool::new(false),
|
||||
diagnostics: Mutex::new(Vec::new()),
|
||||
diagnostics: Mutex::new(DiagnosticsObservation::default()),
|
||||
runtime_slots: Mutex::new(None),
|
||||
summary_observed_at: Mutex::new(None),
|
||||
slots_observed_at: Mutex::new(None),
|
||||
@@ -686,7 +778,7 @@ impl FixtureObservabilityStore {
|
||||
.expect("calculated billing facts") = facts;
|
||||
}
|
||||
|
||||
fn replace_diagnostics(&self, diagnostics: Vec<DiagnosticObservation>) {
|
||||
fn replace_diagnostics(&self, diagnostics: DiagnosticsObservation) {
|
||||
*self.diagnostics.lock().expect("diagnostics") = diagnostics;
|
||||
}
|
||||
|
||||
@@ -798,7 +890,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> AdminStoreResult<DiagnosticsObservation> {
|
||||
Ok(self.diagnostics.lock().expect("diagnostics").clone())
|
||||
}
|
||||
|
||||
@@ -844,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,
|
||||
@@ -948,6 +1041,8 @@ fn total_record(
|
||||
provider_account_name: None,
|
||||
provider_account_email: None,
|
||||
provider_account_notes: None,
|
||||
provider_account_plan_type: None,
|
||||
provider_account_plan_type_display: None,
|
||||
provider_account_authentication_kind: None,
|
||||
upstream_model_id: Some("gpt-5.5".to_owned()),
|
||||
upstream_transport: None,
|
||||
@@ -1010,6 +1105,9 @@ fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
|
||||
first_token_p95_ms: None,
|
||||
non_completion_count: 0,
|
||||
retry_count: 0,
|
||||
retried_request_count: 0,
|
||||
account_provider_kind: None,
|
||||
account_plan_type: None,
|
||||
key: name.to_owned(),
|
||||
name: name.to_owned(),
|
||||
request_count,
|
||||
|
||||
@@ -28,6 +28,8 @@ use gateway_admin::{
|
||||
struct Fixture {
|
||||
revisions: std::sync::Mutex<Vec<Revision>>,
|
||||
requirements: PluginCompatibilityRequirements,
|
||||
instances: Vec<PluginInstance>,
|
||||
disabled: std::sync::Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
@@ -40,6 +42,8 @@ impl Fixture {
|
||||
.collect(),
|
||||
),
|
||||
requirements,
|
||||
instances: vec![instance(true)],
|
||||
disabled: std::sync::Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,18 +99,17 @@ impl PluginStore for Fixture {
|
||||
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot> {
|
||||
Ok(PluginInstanceSnapshot {
|
||||
config_revision: self.next_revision(),
|
||||
instances: vec![PluginInstance {
|
||||
id: "enabled-fixture".into(),
|
||||
name: "Enabled fixture".into(),
|
||||
artifact_sha256: "a".repeat(64),
|
||||
enabled: true,
|
||||
trusted_process: true,
|
||||
configuration: serde_json::json!({}),
|
||||
secrets: BTreeMap::new(),
|
||||
|
||||
bindings: Vec::new(),
|
||||
revision: Revision::new(1).expect("revision"),
|
||||
}],
|
||||
instances: self
|
||||
.instances
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut instance| {
|
||||
if self.disabled.lock().unwrap().contains(&instance.id) {
|
||||
instance.enabled = false;
|
||||
}
|
||||
instance
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -135,6 +138,24 @@ impl PluginStore for Fixture {
|
||||
})
|
||||
}
|
||||
|
||||
async fn disable_instances(
|
||||
&self,
|
||||
ids: &[String],
|
||||
expected: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
let mut revisions = self.revisions.lock().unwrap();
|
||||
if revisions[0] != expected {
|
||||
return Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::StaleRevision,
|
||||
"plugin",
|
||||
"stale",
|
||||
));
|
||||
}
|
||||
revisions[0] = Revision::new(expected.get() + 1).unwrap();
|
||||
self.disabled.lock().unwrap().extend_from_slice(ids);
|
||||
Ok(revisions[0])
|
||||
}
|
||||
async fn save_instance(
|
||||
&self,
|
||||
_: PluginInstance,
|
||||
@@ -273,13 +294,31 @@ impl SystemOperations for PreflightingSystem {
|
||||
|
||||
async fn rollback(
|
||||
&self,
|
||||
_: Arc<dyn SystemUpdatePreflight>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
unreachable!()
|
||||
let revision = preflight.validate_rollback(self.candidate.clone()).await?;
|
||||
preflight.confirm_revision(revision).await?;
|
||||
Ok(SystemOperationAccepted::Rollback {
|
||||
operation_id: "fixture".into(),
|
||||
message: "accepted".into(),
|
||||
need_restart: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
unreachable!()
|
||||
async fn restart_candidate(
|
||||
&self,
|
||||
) -> Result<Option<SystemUpdateCandidate>, SystemOperationError> {
|
||||
Ok(Some(self.candidate.clone()))
|
||||
}
|
||||
async fn restart(
|
||||
&self,
|
||||
preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
preflight.prepare(Some(self.candidate.clone())).await?;
|
||||
Ok(SystemOperationAccepted::Restart {
|
||||
operation_id: "fixture".into(),
|
||||
message: "accepted".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +341,7 @@ async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_update_rejects_missing_target_capability_and_revision_changes() {
|
||||
async fn rollback_rejects_missing_target_capability() {
|
||||
let incompatible = Fixture::new(requirements("^1.0", "executor"), &[7]);
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(incompatible.clone(), incompatible)
|
||||
@@ -311,14 +350,11 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes()
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some("1.2.0".into()), None)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(services.system().rollback().await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_update_rejects_revision_changes() {
|
||||
let stale = Fixture::new(requirements("^1.0", "executor"), &[7, 8]);
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(stale.clone(), stale)
|
||||
@@ -336,6 +372,97 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_update_accepts_new_plugin_contracts_for_empty_disabled_and_enabled_plugins() {
|
||||
for instances in [vec![], vec![instance(false)], vec![instance(true)]] {
|
||||
let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
|
||||
Arc::get_mut(&mut fixture)
|
||||
.expect("exclusive fixture")
|
||||
.instances = instances;
|
||||
let mut candidate = candidate("models");
|
||||
let mut manifest: serde_json::Value =
|
||||
serde_json::from_slice(&candidate.release_manifest).unwrap();
|
||||
manifest["plugin_host"] =
|
||||
serde_json::json!({"schema_version": 99, "future_contract": true});
|
||||
candidate.release_manifest = serde_json::to_vec(&manifest).unwrap().into();
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(fixture.clone(), fixture)
|
||||
.system(Arc::new(PreflightingSystem { candidate }))
|
||||
.build()
|
||||
.await;
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some("1.2.0".into()), None)
|
||||
.await
|
||||
.expect("plugin confirmation is deferred until restart");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_update_keeps_release_identity_validation_without_plugins() {
|
||||
for (field, value) in [
|
||||
("gateway_version", serde_json::json!("1.3.0")),
|
||||
("gateway_git_sha", serde_json::json!("invalid")),
|
||||
("sealed", serde_json::json!(false)),
|
||||
("schema_version", serde_json::json!(99)),
|
||||
] {
|
||||
let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
|
||||
Arc::get_mut(&mut fixture).unwrap().instances.clear();
|
||||
let mut candidate = candidate("executor");
|
||||
let mut manifest: serde_json::Value =
|
||||
serde_json::from_slice(&candidate.release_manifest).unwrap();
|
||||
manifest[field] = value;
|
||||
candidate.release_manifest = serde_json::to_vec(&manifest).unwrap().into();
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(fixture.clone(), fixture)
|
||||
.system(Arc::new(PreflightingSystem { candidate }))
|
||||
.build()
|
||||
.await;
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some("1.2.0".into()), None)
|
||||
.await
|
||||
.is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_update_still_rechecks_revision_without_plugins() {
|
||||
let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7, 8]);
|
||||
Arc::get_mut(&mut fixture).unwrap().instances.clear();
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(fixture.clone(), fixture)
|
||||
.system(Arc::new(PreflightingSystem {
|
||||
candidate: candidate("executor"),
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some("1.2.0".into()), None)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn instance(enabled: bool) -> PluginInstance {
|
||||
PluginInstance {
|
||||
id: "enabled-fixture".into(),
|
||||
name: "Enabled fixture".into(),
|
||||
artifact_sha256: "a".repeat(64),
|
||||
enabled,
|
||||
trusted_process: true,
|
||||
configuration: serde_json::json!({}),
|
||||
secrets: BTreeMap::new(),
|
||||
bindings: Vec::new(),
|
||||
revision: Revision::new(1).expect("revision"),
|
||||
}
|
||||
}
|
||||
|
||||
fn requirements(host_version: &str, capability: &str) -> PluginCompatibilityRequirements {
|
||||
PluginCompatibilityRequirements {
|
||||
host_version: host_version.into(),
|
||||
@@ -365,3 +492,106 @@ fn candidate(capability: &str) -> SystemUpdateCandidate {
|
||||
release_manifest: manifest.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn context() -> MutationContext {
|
||||
MutationContext {
|
||||
actor: gateway_admin::model::MutationActor::AdminApiKey,
|
||||
request_id: "restart-test".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incompatible_restart_requires_exact_confirmation_before_disabling() {
|
||||
let fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(fixture.clone(), fixture.clone())
|
||||
.system(Arc::new(PreflightingSystem {
|
||||
candidate: candidate("models"),
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
let plan = services.system().restart_plan().await.unwrap();
|
||||
assert_eq!(plan.incompatible_plugins.len(), 1);
|
||||
assert_eq!(plan.incompatible_plugins[0].name, "Enabled fixture");
|
||||
assert!(
|
||||
fixture.disabled.lock().unwrap().is_empty(),
|
||||
"检查及取消不能停用插件"
|
||||
);
|
||||
assert!(services.system().restart(None, &context()).await.is_err());
|
||||
for altered in 0..3 {
|
||||
let mut changed = plan.clone();
|
||||
match altered {
|
||||
0 => changed.config_revision += 1,
|
||||
1 => changed.release_manifest_sha256 = Some("b".repeat(64)),
|
||||
_ => changed.incompatible_plugins.clear(),
|
||||
}
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.restart(Some(changed), &context())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(fixture.disabled.lock().unwrap().is_empty());
|
||||
}
|
||||
services
|
||||
.system()
|
||||
.restart(Some(plan), &context())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(*fixture.disabled.lock().unwrap(), ["enabled-fixture"]);
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.restart_plan()
|
||||
.await
|
||||
.unwrap()
|
||||
.incompatible_plugins
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compatible_restart_does_not_change_plugin_configuration() {
|
||||
let fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(fixture.clone(), fixture.clone())
|
||||
.system(Arc::new(PreflightingSystem {
|
||||
candidate: candidate("executor"),
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.restart_plan()
|
||||
.await
|
||||
.unwrap()
|
||||
.incompatible_plugins
|
||||
.is_empty()
|
||||
);
|
||||
services.system().restart(None, &context()).await.unwrap();
|
||||
assert!(fixture.disabled.lock().unwrap().is_empty());
|
||||
assert_eq!(fixture.next_revision().get(), 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changed_configuration_after_restart_check_requires_a_new_confirmation() {
|
||||
let fixture = Fixture::new(requirements("^1.0", "executor"), &[7, 8]);
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(fixture.clone(), fixture.clone())
|
||||
.system(Arc::new(PreflightingSystem {
|
||||
candidate: candidate("models"),
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
let plan = services.system().restart_plan().await.unwrap();
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.restart(Some(plan), &context())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(fixture.disabled.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ impl Fixture {
|
||||
let unused = Arc::new(TestPluginPorts);
|
||||
PluginsService::new(
|
||||
self.clone(),
|
||||
unused.clone(),
|
||||
self.clone(),
|
||||
PluginDistributionPorts::new(unused.clone(), unused.clone()),
|
||||
self.clone(),
|
||||
self.clone(),
|
||||
@@ -87,6 +87,20 @@ impl SnapshotControl for Fixture {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginPackageInspector for Fixture {
|
||||
async fn inspect(
|
||||
&self,
|
||||
archive: Arc<[u8]>,
|
||||
_: Option<String>,
|
||||
) -> Result<InspectedPluginArtifact, AdminError> {
|
||||
Ok(InspectedPluginArtifact {
|
||||
metadata: self.data.lock().unwrap().artifact.metadata.clone(),
|
||||
archive,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Lease;
|
||||
|
||||
impl ExtensionSetLease for Lease {
|
||||
@@ -260,7 +274,10 @@ impl PluginStore for Fixture {
|
||||
}
|
||||
|
||||
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
|
||||
Err(store_error(AdminStoreErrorKind::Unavailable))
|
||||
Ok(InspectedPluginArtifact {
|
||||
metadata: self.data.lock().unwrap().artifact.metadata.clone(),
|
||||
archive: Arc::from([1_u8]),
|
||||
})
|
||||
}
|
||||
|
||||
async fn install_artifact(
|
||||
|
||||
@@ -59,6 +59,28 @@ enum MigrationBehavior {
|
||||
ConcurrentChangeThenFail,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginPackageInspector for LifecycleFixture {
|
||||
async fn inspect(
|
||||
&self,
|
||||
archive: Arc<[u8]>,
|
||||
digest: Option<String>,
|
||||
) -> Result<InspectedPluginArtifact, AdminError> {
|
||||
let data = self.data.lock().unwrap();
|
||||
if let Some(kind) = data.inspection_error {
|
||||
return Err(AdminError::new(kind, "插件与当前宿主不兼容"));
|
||||
}
|
||||
let metadata = data
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|item| Some(&item.metadata.sha256) == digest.as_ref())
|
||||
.unwrap()
|
||||
.metadata
|
||||
.clone();
|
||||
Ok(InspectedPluginArtifact { metadata, archive })
|
||||
}
|
||||
}
|
||||
|
||||
struct SavedInstance {
|
||||
artifact_sha256: String,
|
||||
enabled: bool,
|
||||
@@ -69,6 +91,8 @@ struct FixtureData {
|
||||
snapshot: PluginInstanceSnapshot,
|
||||
artifacts: Vec<InstalledPluginArtifact>,
|
||||
artifact_reads: usize,
|
||||
archive_reads: usize,
|
||||
inspection_error: Option<AdminErrorKind>,
|
||||
change_during_artifact_read: bool,
|
||||
fail_preparation: bool,
|
||||
configuration_ready: bool,
|
||||
@@ -112,6 +136,8 @@ impl LifecycleFixture {
|
||||
artifact(NEW_ARTIFACT, "test.lifecycle", "1.0.0"),
|
||||
],
|
||||
artifact_reads: 0,
|
||||
archive_reads: 0,
|
||||
inspection_error: None,
|
||||
change_during_artifact_read: false,
|
||||
fail_preparation: false,
|
||||
configuration_ready: true,
|
||||
@@ -512,8 +538,20 @@ impl PluginStore for LifecycleFixture {
|
||||
Ok(data.artifacts.clone())
|
||||
}
|
||||
|
||||
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
|
||||
Err(admin_error(AdminStoreErrorKind::Unavailable))
|
||||
async fn load_artifact(&self, digest: &str) -> AdminStoreResult<InspectedPluginArtifact> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
data.archive_reads += 1;
|
||||
let metadata = data
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|item| item.metadata.sha256 == digest)
|
||||
.unwrap()
|
||||
.metadata
|
||||
.clone();
|
||||
Ok(InspectedPluginArtifact {
|
||||
metadata,
|
||||
archive: Arc::from([1_u8]),
|
||||
})
|
||||
}
|
||||
|
||||
async fn install_artifact(
|
||||
@@ -553,7 +591,7 @@ impl SnapshotControl for Published {
|
||||
fn service(fixture: Arc<LifecycleFixture>, published: Arc<Published>) -> PluginsService {
|
||||
PluginsService::new(
|
||||
fixture.clone(),
|
||||
Arc::new(TestPluginPorts),
|
||||
fixture.clone(),
|
||||
PluginDistributionPorts::new(Arc::new(TestPluginPorts), Arc::new(TestPluginPorts)),
|
||||
published,
|
||||
fixture.clone(),
|
||||
@@ -637,7 +675,7 @@ fn input() -> ConfigurePluginInstance {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_list_loads_metadata_once_for_shared_artifacts_without_loading_archives() {
|
||||
async fn instance_list_checks_shared_artifact_compatibility_once() {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
let mut second = fixture.snapshot().instances[0].clone();
|
||||
second.id = "00000000-0000-7000-8000-000000000002".into();
|
||||
@@ -1452,3 +1490,48 @@ async fn version_plan_preserves_observer_event_scopes_and_disabled_subscriptions
|
||||
assert!(fixture.data.lock().unwrap().saves.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incompatible_disabled_plugin_has_warning_and_cannot_be_enabled() {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
{
|
||||
let mut data = fixture.data.lock().unwrap();
|
||||
data.snapshot.instances[0].enabled = false;
|
||||
data.inspection_error = Some(AdminErrorKind::Invalid);
|
||||
}
|
||||
let service = service(fixture.clone(), Arc::new(Published::default()));
|
||||
let view = service.instances().await.unwrap().remove(0);
|
||||
assert!(view.compatibility_warning.is_some());
|
||||
assert!(!view.instance.enabled);
|
||||
let mut request = input();
|
||||
request.artifact_sha256 = OLD_ARTIFACT.into();
|
||||
let error = service
|
||||
.configure_instance(Some(&view.instance.id), request, &context())
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Invalid);
|
||||
assert!(fixture.data.lock().unwrap().saves.is_empty());
|
||||
assert_eq!(
|
||||
fixture.data.lock().unwrap().archive_reads,
|
||||
1,
|
||||
"固定摘要的静态检查结论可复用"
|
||||
);
|
||||
service.instances().await.unwrap();
|
||||
assert_eq!(fixture.data.lock().unwrap().archive_reads, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_compatibility_check_failure_can_be_retried() {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
fixture.data.lock().unwrap().inspection_error = Some(AdminErrorKind::Unavailable);
|
||||
let service = service(fixture.clone(), Arc::new(Published::default()));
|
||||
assert!(service.instances().await.is_err());
|
||||
fixture.data.lock().unwrap().inspection_error = None;
|
||||
assert!(
|
||||
service.instances().await.unwrap()[0]
|
||||
.compatibility_warning
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(fixture.data.lock().unwrap().archive_reads, 2);
|
||||
}
|
||||
|
||||
@@ -288,6 +288,40 @@ async fn official_release_validates_all_packages_then_uses_builtin_install_path(
|
||||
assert_eq!(fixture.publications.lock().unwrap().as_slice(), &[2]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_release_accepts_legacy_update_envelope_with_current_plugin_contracts() {
|
||||
let mut fixture = Fixture::new(&[]);
|
||||
let mut manifest: serde_json::Value =
|
||||
serde_json::from_slice(fixture.manifest.as_ref().unwrap()).unwrap();
|
||||
manifest["plugin_host"]["schema_version"] = serde_json::json!(1);
|
||||
manifest["plugin_host"]["permissions"] = serde_json::json!([]);
|
||||
Arc::get_mut(&mut fixture).unwrap().manifest =
|
||||
Some(serde_json::to_vec(&manifest).unwrap().into());
|
||||
let imported = fixture
|
||||
.service()
|
||||
.import_official_release(fixture.as_ref(), &identity(), &system_context())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(imported.artifacts, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_release_still_rejects_an_unknown_host_contract_at_startup() {
|
||||
let mut fixture = Fixture::new(&[]);
|
||||
let mut manifest: serde_json::Value =
|
||||
serde_json::from_slice(fixture.manifest.as_ref().unwrap()).unwrap();
|
||||
manifest["plugin_host"]["schema_version"] = serde_json::json!(99);
|
||||
Arc::get_mut(&mut fixture).unwrap().manifest =
|
||||
Some(serde_json::to_vec(&manifest).unwrap().into());
|
||||
assert!(
|
||||
fixture
|
||||
.service()
|
||||
.import_official_release(fixture.as_ref(), &identity(), &system_context())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_release_never_persists_a_valid_prefix_when_later_validation_fails() {
|
||||
let mut fixture = Fixture::new(&[("test.official-1", 1), ("test.official-2", 2)]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -106,7 +106,10 @@ impl SystemOperations for RecordingSystemOperations {
|
||||
})
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
async fn restart(
|
||||
&self,
|
||||
_preflight: std::sync::Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Ok(SystemOperationAccepted::Restart {
|
||||
operation_id: "operation-restart".to_owned(),
|
||||
message: "accepted".to_owned(),
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
@@ -180,10 +193,6 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi
|
||||
estimated_tokens_display: display_optional_tokens(forecast.estimated_tokens),
|
||||
estimated_usd: forecast.estimated_usd,
|
||||
estimated_usd_display: forecast_usd_display(forecast.estimated_usd),
|
||||
remaining_tokens: forecast.remaining_tokens,
|
||||
remaining_tokens_display: display_optional_tokens(forecast.remaining_tokens),
|
||||
remaining_usd: forecast.remaining_usd,
|
||||
remaining_usd_display: forecast_usd_display(forecast.remaining_usd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,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()
|
||||
@@ -221,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,
|
||||
@@ -261,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,
|
||||
@@ -285,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))),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -296,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();
|
||||
@@ -342,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")),
|
||||
@@ -354,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(),
|
||||
}
|
||||
}
|
||||
@@ -362,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
|
||||
@@ -418,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,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,
|
||||
@@ -473,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,
|
||||
@@ -362,10 +363,6 @@ pub struct AccountQuotaForecastView {
|
||||
pub estimated_tokens_display: String,
|
||||
pub estimated_usd: Option<f64>,
|
||||
pub estimated_usd_display: String,
|
||||
pub remaining_tokens: Option<u64>,
|
||||
pub remaining_tokens_display: String,
|
||||
pub remaining_usd: Option<f64>,
|
||||
pub remaining_usd_display: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -398,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 时间窗口。
|
||||
@@ -454,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>,
|
||||
@@ -496,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>,
|
||||
}
|
||||
|
||||
/// 单一货币的可查询成本。
|
||||
@@ -735,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,
|
||||
})
|
||||
}
|
||||
@@ -769,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))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -788,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(),
|
||||
}
|
||||
}
|
||||
@@ -812,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>,
|
||||
@@ -862,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,
|
||||
@@ -943,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>,
|
||||
}
|
||||
|
||||
@@ -954,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))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -999,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()
|
||||
@@ -1012,6 +1057,7 @@ impl AccountExportData {
|
||||
.collect::<Vec<_>>();
|
||||
Self::new(serde_json::json!({
|
||||
"exportedAt": bundle.exported_at.to_rfc3339(),
|
||||
"fileName": filename,
|
||||
"documents": documents,
|
||||
}))
|
||||
}
|
||||
@@ -1027,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,
|
||||
@@ -1082,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -287,6 +280,8 @@ pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageLi
|
||||
account_email: record.provider_account_email,
|
||||
account_name: record.provider_account_name,
|
||||
account_notes: record.provider_account_notes,
|
||||
account_plan_type: record.provider_account_plan_type,
|
||||
account_plan_type_display: record.provider_account_plan_type_display,
|
||||
route: record.endpoint,
|
||||
model,
|
||||
requested_model: record.requested_model_id,
|
||||
@@ -324,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
|
||||
@@ -430,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,
|
||||
@@ -533,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()
|
||||
@@ -547,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()
|
||||
@@ -581,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,
|
||||
@@ -624,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,
|
||||
@@ -762,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -785,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(),
|
||||
@@ -799,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,
|
||||
@@ -838,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,
|
||||
@@ -849,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,
|
||||
@@ -935,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
|
||||
@@ -957,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(),
|
||||
@@ -990,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,
|
||||
@@ -1031,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,
|
||||
@@ -1054,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()),
|
||||
@@ -1103,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,
|
||||
@@ -1283,6 +1306,8 @@ pub(crate) fn diagnostics_view(
|
||||
item.name
|
||||
},
|
||||
request_count: item.request_count,
|
||||
account_plan_type: item.account_plan_type,
|
||||
account_plan_type_display: item.account_plan_type_display,
|
||||
success_count: item.success_count,
|
||||
error_count: item.error_count,
|
||||
error_rate: item.error_rate,
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ pub struct UsageListRecordView {
|
||||
pub account_email: Option<String>,
|
||||
pub account_name: Option<String>,
|
||||
pub account_notes: 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>,
|
||||
@@ -392,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,
|
||||
@@ -400,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")]
|
||||
@@ -468,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,
|
||||
@@ -508,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>,
|
||||
}
|
||||
@@ -535,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")]
|
||||
@@ -568,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,
|
||||
@@ -746,6 +745,8 @@ pub struct UsageInsightsOverviewView {
|
||||
pub struct DiagnosticItemView {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub error_count: u64,
|
||||
@@ -788,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>,
|
||||
|
||||
@@ -52,6 +52,7 @@ struct InstanceView {
|
||||
artifact_sha256: String,
|
||||
enabled: bool,
|
||||
configuration_required: bool,
|
||||
compatibility_warning: Option<String>,
|
||||
configuration: serde_json::Value,
|
||||
secret_fields: Vec<String>,
|
||||
bindings: Vec<PluginCapabilityBinding>,
|
||||
@@ -146,6 +147,7 @@ impl From<PluginInstanceView> for InstanceView {
|
||||
artifact_sha256,
|
||||
enabled,
|
||||
configuration_required: value.configuration_required,
|
||||
compatibility_warning: value.compatibility_warning,
|
||||
configuration,
|
||||
secret_fields: secrets.into_keys().collect(),
|
||||
bindings,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ use axum::{
|
||||
use futures::{Stream, StreamExt};
|
||||
use gateway_admin::model::system::{
|
||||
SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus,
|
||||
SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel,
|
||||
SystemUpdatePolicy, SystemUpdateStatus, SystemVersion,
|
||||
SystemRestartPlan, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent,
|
||||
SystemUpdateEventLevel, SystemUpdatePolicy, SystemUpdateStatus, SystemVersion,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -114,6 +114,7 @@ struct SystemUpdateDetailView {
|
||||
notes: Option<String>,
|
||||
cached: bool,
|
||||
update_supported: bool,
|
||||
restart_confirmation_supported: bool,
|
||||
unsupported_reason: Option<String>,
|
||||
warning: Option<String>,
|
||||
}
|
||||
@@ -133,6 +134,7 @@ impl From<SystemUpdateDetail> for SystemUpdateDetailView {
|
||||
notes: detail.notes,
|
||||
cached: detail.cached,
|
||||
update_supported: detail.update_supported,
|
||||
restart_confirmation_supported: true,
|
||||
unsupported_reason: detail.unsupported_reason,
|
||||
warning: detail.warning,
|
||||
}
|
||||
@@ -149,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),
|
||||
@@ -161,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()),
|
||||
}
|
||||
}
|
||||
@@ -176,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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,6 +234,7 @@ struct SystemUpdateEventView {
|
||||
terminal: bool,
|
||||
progress_percent: Option<u8>,
|
||||
at: String,
|
||||
at_display: String,
|
||||
}
|
||||
|
||||
/// 构造固定 GET/POST 系统管理路由。
|
||||
@@ -239,6 +252,7 @@ where
|
||||
.route("/api/admin/system/update", post(perform_update::<S>))
|
||||
.route("/api/admin/system/update/status", get(update_status::<S>))
|
||||
.route("/api/admin/system/rollback", post(rollback::<S>))
|
||||
.route("/api/admin/system/restart/check", get(restart_plan::<S>))
|
||||
.route("/api/admin/system/restart", post(restart::<S>))
|
||||
}
|
||||
|
||||
@@ -288,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()))
|
||||
@@ -344,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()
|
||||
@@ -352,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))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -387,17 +405,43 @@ where
|
||||
))
|
||||
}
|
||||
|
||||
async fn restart<S>(
|
||||
async fn restart_plan<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let plan = state
|
||||
.admin_services()
|
||||
.system()
|
||||
.restart_plan()
|
||||
.await
|
||||
.map_err(map_system_error)?;
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(plan)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct RestartRequest {
|
||||
confirmation: Option<SystemRestartPlan>,
|
||||
}
|
||||
|
||||
async fn restart<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
payload: Option<AdminJson<RestartRequest>>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.system()
|
||||
.restart()
|
||||
.restart(
|
||||
payload.and_then(|AdminJson(request)| request.confirmation),
|
||||
&auth.context().mutation_context(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_system_error)?;
|
||||
let SystemOperationAccepted::Restart {
|
||||
@@ -416,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,
|
||||
@@ -426,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(),
|
||||
}
|
||||
}
|
||||
@@ -441,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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,18 +29,21 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
incomplete_tokens: false,
|
||||
estimated_tokens: Some(1_000_000),
|
||||
estimated_usd: None,
|
||||
remaining_tokens: Some(0),
|
||||
remaining_usd: None,
|
||||
};
|
||||
let mut monthly = forecast.clone();
|
||||
monthly.period = AccountUsagePeriod::Monthly;
|
||||
monthly.extrapolated = true;
|
||||
monthly.target_seconds = 30 * 86_400;
|
||||
let view = AccountQuotaForecastData::from(AccountQuotaForecastReport {
|
||||
account_id: "acct_forecast".to_owned(),
|
||||
generated_at: now,
|
||||
forecasts: [forecast, monthly],
|
||||
});
|
||||
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],
|
||||
},
|
||||
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");
|
||||
@@ -49,7 +52,16 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
assert_eq!(week["estimatedTokensDisplay"], "1M");
|
||||
assert!(week["estimatedUsd"].is_null());
|
||||
assert_eq!(week["estimatedUsdDisplay"], "—");
|
||||
assert_eq!(week["remainingTokensDisplay"], "0");
|
||||
for forecast in value["forecasts"].as_array().unwrap() {
|
||||
for field in [
|
||||
"remainingTokens",
|
||||
"remainingTokensDisplay",
|
||||
"remainingUsd",
|
||||
"remainingUsdDisplay",
|
||||
] {
|
||||
assert!(forecast.get(field).is_none(), "unexpected field: {field}");
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
week["source"],
|
||||
serde_json::json!({
|
||||
@@ -66,6 +78,10 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
assert!(week.get("method").is_none());
|
||||
assert!(week.get("methodDisplay").is_none());
|
||||
assert!(value.get("generatedAtDisplay").is_none());
|
||||
assert_eq!(value["forecasts"][1]["estimatedTokens"], 0);
|
||||
assert_eq!(value["forecasts"][1]["estimatedTokensDisplay"], "0");
|
||||
assert_eq!(value["forecasts"][1]["estimatedUsd"], 0.0);
|
||||
assert_eq!(value["forecasts"][1]["estimatedUsdDisplay"], "$0.00");
|
||||
assert_eq!(value["forecasts"][1]["period"], "monthly");
|
||||
assert_eq!(value["forecasts"][1]["targetDays"], 30.0);
|
||||
assert_eq!(value["forecasts"][1]["extrapolated"], true);
|
||||
@@ -94,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());
|
||||
|
||||
@@ -35,7 +35,7 @@ use gateway_admin::{
|
||||
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DecimalAmount, DiagnosticDimension, DiagnosticObservation,
|
||||
DashboardObservation, DecimalAmount, DiagnosticDimension, DiagnosticsObservation,
|
||||
OpsError, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageDetail,
|
||||
UsageFilter, UsageListRecord, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
@@ -100,7 +100,7 @@ pub(super) struct AdminTestFixture {
|
||||
pub settings: Arc<MemorySettingsStore>,
|
||||
pub usage_records: Arc<Mutex<Vec<UsageListRecord>>>,
|
||||
pub usage_detail: Arc<Mutex<Option<UsageDetail>>>,
|
||||
pub diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
|
||||
pub diagnostics: Arc<Mutex<DiagnosticsObservation>>,
|
||||
pub ops_errors: Arc<Mutex<Vec<OpsError>>>,
|
||||
pub dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
|
||||
pub dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
|
||||
@@ -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()));
|
||||
@@ -137,7 +142,7 @@ impl AdminTestFixture {
|
||||
let account_groups = Arc::new(MemoryAccountGroupStore::new());
|
||||
let usage_records = Arc::new(Mutex::new(Vec::new()));
|
||||
let usage_detail = Arc::new(Mutex::new(None));
|
||||
let diagnostics = Arc::new(Mutex::new(Vec::new()));
|
||||
let diagnostics = Arc::new(Mutex::new(DiagnosticsObservation::default()));
|
||||
let ops_errors = Arc::new(Mutex::new(Vec::new()));
|
||||
let dashboard_observation = Arc::new(Mutex::new(None));
|
||||
let dashboard_summary_range = Arc::new(Mutex::new(None));
|
||||
@@ -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,
|
||||
@@ -1000,7 +1007,7 @@ struct UnusedStore {
|
||||
observations: Arc<Mutex<MemoryObservations>>,
|
||||
usage_records: Arc<Mutex<Vec<UsageListRecord>>>,
|
||||
usage_detail: Arc<Mutex<Option<UsageDetail>>>,
|
||||
diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
|
||||
diagnostics: Arc<Mutex<DiagnosticsObservation>>,
|
||||
ops_errors: Arc<Mutex<Vec<OpsError>>>,
|
||||
dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
|
||||
dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -1312,7 +1339,7 @@ impl ObservabilityStore for UnusedStore {
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> AdminStoreResult<DiagnosticsObservation> {
|
||||
Ok(self.diagnostics.lock().expect("diagnostics").clone())
|
||||
}
|
||||
|
||||
@@ -1520,7 +1547,10 @@ impl SystemOperations for UnusedSystem {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
async fn restart(
|
||||
&self,
|
||||
_preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
}
|
||||
@@ -1549,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",
|
||||
@@ -550,7 +561,9 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use gateway_admin::model::observability::{CostCoverage, DiagnosticObservation};
|
||||
use gateway_admin::model::observability::{
|
||||
CostCoverage, DiagnosticObservation, DiagnosticsObservation,
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
@@ -558,26 +571,28 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
fixture
|
||||
.diagnostics
|
||||
.lock()
|
||||
.expect("diagnostics")
|
||||
.push(DiagnosticObservation {
|
||||
*fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation {
|
||||
total_request_count: 4,
|
||||
items: vec![DiagnosticObservation {
|
||||
key: "acct_diag".to_owned(),
|
||||
account_provider_kind: Some("openai".to_owned()),
|
||||
account_plan_type: Some("pro".to_owned()),
|
||||
name: "diag@example.invalid".to_owned(),
|
||||
request_count: 2,
|
||||
success_count: 2,
|
||||
failure_count: 0,
|
||||
attempt_count: 2,
|
||||
attempt_count: 5,
|
||||
total_tokens: 200,
|
||||
average_latency_ms: Some(100),
|
||||
latency_p95_ms: Some(3800),
|
||||
first_token_p95_ms: Some(1200),
|
||||
non_completion_count: 0,
|
||||
retry_count: 0,
|
||||
retry_count: 3,
|
||||
retried_request_count: 1,
|
||||
cost_coverage: CostCoverage::default(),
|
||||
costs: Vec::new(),
|
||||
});
|
||||
}],
|
||||
};
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
@@ -596,6 +611,11 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
.expect("diagnostics body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
|
||||
assert!(value["data"]["items"][0].get("impactScore").is_none());
|
||||
assert_eq!(value["data"]["items"][0]["requestShare"], 0.5);
|
||||
assert_eq!(value["data"]["items"][0]["retryCount"], 3);
|
||||
assert_eq!(value["data"]["items"][0]["retryRate"], 0.5);
|
||||
assert_eq!(value["data"]["items"][0]["accountPlanType"], "pro");
|
||||
assert_eq!(value["data"]["items"][0]["accountPlanTypeDisplay"], "Pro");
|
||||
assert_eq!(
|
||||
(
|
||||
&value["data"]["items"][0]["key"],
|
||||
@@ -766,6 +786,8 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
provider_account_name: Some("Snapshot Alpha".to_owned()),
|
||||
provider_account_email: Some("alpha@example.invalid".to_owned()),
|
||||
provider_account_notes: Some("Team workspace".to_owned()),
|
||||
provider_account_plan_type: Some("supergrok_plus".to_owned()),
|
||||
provider_account_plan_type_display: None,
|
||||
provider_account_authentication_kind: Some("oauth".to_owned()),
|
||||
upstream_model_id: Some("grok-4.5".to_owned()),
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
@@ -858,6 +880,14 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
|
||||
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
|
||||
assert_eq!(value["data"]["items"][0]["accountNotes"], "Team workspace");
|
||||
assert_eq!(
|
||||
value["data"]["items"][0]["accountPlanType"],
|
||||
"supergrok_plus"
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["items"][0]["accountPlanTypeDisplay"],
|
||||
"SupergrokPlus"
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["items"][0]["billing"]["longContextBillingApplied"],
|
||||
true
|
||||
@@ -956,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());
|
||||
}
|
||||
|
||||
@@ -891,6 +891,12 @@ impl PluginStore for TestPluginPorts {
|
||||
Ok(vec![accepted_artifact()])
|
||||
}
|
||||
async fn load_artifact(&self, digest: &str) -> AdminStoreResult<InspectedPluginArtifact> {
|
||||
if digest == "a".repeat(64) {
|
||||
return Ok(InspectedPluginArtifact {
|
||||
metadata: accepted_artifact().metadata,
|
||||
archive: Arc::from([1_u8]),
|
||||
});
|
||||
}
|
||||
if matches!(digest.as_bytes().first(), Some(b'e' | b'f')) && digest.len() == 64 {
|
||||
return Ok(icon_artifact(digest));
|
||||
}
|
||||
@@ -929,7 +935,10 @@ impl PluginPackageInspector for TestPluginPorts {
|
||||
_: Arc<[u8]>,
|
||||
_: Option<String>,
|
||||
) -> Result<InspectedPluginArtifact, AdminError> {
|
||||
Err(AdminError::invalid("unused plugin fixture"))
|
||||
Ok(InspectedPluginArtifact {
|
||||
metadata: accepted_artifact().metadata,
|
||||
archive: Arc::from([1_u8]),
|
||||
})
|
||||
}
|
||||
|
||||
async fn icon(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -204,7 +204,10 @@ impl SystemOperations for ProgressSystem {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
async fn restart(
|
||||
&self,
|
||||
_preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -100,6 +100,8 @@ fn usage_record() -> UsageListRecord {
|
||||
provider_account_name: Some("private-sentinel".to_owned()),
|
||||
provider_account_email: Some("private-sentinel".to_owned()),
|
||||
provider_account_notes: Some("private-sentinel".to_owned()),
|
||||
provider_account_plan_type: Some("private-sentinel".to_owned()),
|
||||
provider_account_plan_type_display: Some("private-sentinel".to_owned()),
|
||||
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
|
||||
upstream_model_id: Some("private-sentinel".to_owned()),
|
||||
upstream_transport: Some("websocket".to_owned()),
|
||||
@@ -166,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",
|
||||
|
||||
@@ -133,7 +133,10 @@ impl SystemOperations for VersionSystem {
|
||||
unreachable!("client route must not roll back")
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
async fn restart(
|
||||
&self,
|
||||
_preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
unreachable!("client route must not restart")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user