mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
feat: unify user analytics and optimize overview aggregation
Merge user accounts and usage reporting into one page with a combined ranking and account table, shared precise time ranges, and simpler range labels. Parse overview metadata once through a schema-only view migration and disable JIT locally for bucket rebuilds. Preserve automatic backfills. Add redacted OAuth refresh diagnostics, bucket failure context, and regression coverage. Resolve strict Clippy warnings.
This commit is contained in:
@@ -112,6 +112,22 @@ pub(crate) fn build_cross_format_openai_chat_request_body(
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
pub(crate) fn build_cross_format_openai_chat_upstream_url(
|
||||
parts: &http::request::Parts,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<String> {
|
||||
crate::ai_serving::transport::build_cross_format_openai_chat_upstream_url(
|
||||
transport,
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
parts.uri.query(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod antigravity_schema_tests {
|
||||
use super::*;
|
||||
@@ -147,19 +163,3 @@ mod antigravity_schema_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_cross_format_openai_chat_upstream_url(
|
||||
parts: &http::request::Parts,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<String> {
|
||||
crate::ai_serving::transport::build_cross_format_openai_chat_upstream_url(
|
||||
transport,
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
parts.uri.query(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -275,6 +275,24 @@ pub(crate) fn build_local_openai_responses_upstream_url(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_cross_format_openai_responses_upstream_url(
|
||||
parts: &http::request::Parts,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
mapped_model: &str,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<String> {
|
||||
crate::ai_serving::transport::build_cross_format_openai_responses_upstream_url(
|
||||
transport,
|
||||
mapped_model,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
parts.uri.query(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod antigravity_schema_tests {
|
||||
use super::*;
|
||||
@@ -309,21 +327,3 @@ mod antigravity_schema_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_cross_format_openai_responses_upstream_url(
|
||||
parts: &http::request::Parts,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
mapped_model: &str,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<String> {
|
||||
crate::ai_serving::transport::build_cross_format_openai_responses_upstream_url(
|
||||
transport,
|
||||
mapped_model,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
parts.uri.query(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -305,13 +305,11 @@ pub(crate) fn spawn_worker(app: AppState) -> tokio::task::JoinHandle<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Mutex, OnceLock,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_runtime_state::{MemoryRuntimeStateConfig, RuntimeState};
|
||||
use tokio::sync::{Mutex, MutexGuard};
|
||||
|
||||
use super::{
|
||||
cached_version_to_restore, fixed_version_from, parse_cli_release, refresh_enabled_from,
|
||||
@@ -322,7 +320,7 @@ mod tests {
|
||||
set_codex_client_profile, CodexClientProfile,
|
||||
};
|
||||
|
||||
static PROFILE_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
static PROFILE_TEST_LOCK: Mutex<()> = Mutex::const_new(());
|
||||
|
||||
struct ProfileRestore(CodexClientProfile);
|
||||
|
||||
@@ -332,9 +330,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_restore_guard() -> (std::sync::MutexGuard<'static, ()>, ProfileRestore) {
|
||||
let lock = PROFILE_TEST_LOCK.get_or_init(|| Mutex::new(()));
|
||||
let guard = lock.lock().expect("profile test lock");
|
||||
async fn profile_restore_guard() -> (MutexGuard<'static, ()>, ProfileRestore) {
|
||||
let guard = PROFILE_TEST_LOCK.lock().await;
|
||||
let restore = ProfileRestore(codex_client_profile());
|
||||
(guard, restore)
|
||||
}
|
||||
@@ -397,7 +394,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_hit_is_restored_without_network_when_refresh_is_disabled() {
|
||||
let (_lock, _restore) = profile_restore_guard();
|
||||
let (_lock, _restore) = profile_restore_guard().await;
|
||||
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
|
||||
runtime
|
||||
.kv_set(
|
||||
@@ -424,7 +421,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_failure_keeps_previous_profile() {
|
||||
let (_lock, _restore) = profile_restore_guard();
|
||||
let (_lock, _restore) = profile_restore_guard().await;
|
||||
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
|
||||
let before = codex_client_profile();
|
||||
let result = refresh_once_with_fetch(&runtime, None, true, || async {
|
||||
@@ -438,7 +435,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn fixed_version_override_skips_network_and_publishes_profile() {
|
||||
let (_lock, _restore) = profile_restore_guard();
|
||||
let (_lock, _restore) = profile_restore_guard().await;
|
||||
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
|
||||
let fetch_called = AtomicBool::new(false);
|
||||
let result = refresh_once_with_fetch(&runtime, Some("0.220.0"), true, || async {
|
||||
@@ -455,7 +452,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn rollback_is_rejected_without_replacing_profile() {
|
||||
let (_lock, _restore) = profile_restore_guard();
|
||||
let (_lock, _restore) = profile_restore_guard().await;
|
||||
set_codex_cli_version("0.220.0").unwrap();
|
||||
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
|
||||
let result =
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::super::resolve_usage_user_group_scope;
|
||||
use super::range::{build_comparison_range, parse_bounded_u32};
|
||||
use super::range::{
|
||||
build_comparison_range, parse_bounded_u32, precise_admin_stats_time_range,
|
||||
resolve_precise_time_bounds,
|
||||
};
|
||||
use super::resolve_admin_usage_time_range;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::{
|
||||
@@ -285,12 +288,36 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response(
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||
};
|
||||
let time_range = match resolve_admin_usage_time_range(request_context.query_string()) {
|
||||
let legacy_time_range = match resolve_admin_usage_time_range(request_context.query_string())
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||
};
|
||||
if let Err(detail) = time_range.validate_for_time_series(granularity) {
|
||||
return Ok(Some(admin_stats_bad_request_response(detail)));
|
||||
let precise_bounds = match resolve_precise_time_bounds(request_context.query_string()) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||
};
|
||||
let precise_time_range = match precise_bounds {
|
||||
Some((from, to)) => {
|
||||
match precise_admin_stats_time_range(request_context.query_string(), from, to) {
|
||||
Ok(value) => Some(value),
|
||||
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let time_range = precise_time_range.as_ref().unwrap_or(&legacy_time_range);
|
||||
if precise_bounds.is_none() {
|
||||
if let Err(detail) = time_range.validate_for_time_series(granularity) {
|
||||
return Ok(Some(admin_stats_bad_request_response(detail)));
|
||||
}
|
||||
} else if precise_bounds
|
||||
.and_then(|(from, to)| to.checked_sub(from))
|
||||
.is_some_and(|seconds| seconds > 90 * 86_400)
|
||||
{
|
||||
return Ok(Some(admin_stats_bad_request_response(
|
||||
"Query range cannot exceed 90 days".to_string(),
|
||||
)));
|
||||
}
|
||||
if !state.has_usage_data_reader() {
|
||||
return Ok(Some(admin_stats_time_series_empty_response()));
|
||||
@@ -314,7 +341,8 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response(
|
||||
| AdminStatsGranularity::Week
|
||||
| AdminStatsGranularity::Month => UsageTimeSeriesGranularity::Day,
|
||||
};
|
||||
let Some((created_from_unix_secs, created_until_unix_secs)) = time_range.to_unix_bounds()
|
||||
let Some((created_from_unix_secs, created_until_unix_secs)) =
|
||||
precise_bounds.or_else(|| time_range.to_unix_bounds())
|
||||
else {
|
||||
return Ok(Some(admin_stats_time_series_empty_response()));
|
||||
};
|
||||
@@ -336,7 +364,7 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response(
|
||||
})
|
||||
.await?;
|
||||
return Ok(Some(build_admin_stats_time_series_response_from_summaries(
|
||||
&time_range,
|
||||
time_range,
|
||||
granularity,
|
||||
&buckets,
|
||||
)));
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::leaderboard::{
|
||||
build_user_leaderboard_items_from_summaries, compare_leaderboard_items,
|
||||
load_user_leaderboard_metadata, AdminStatsLeaderboardItem, AdminStatsLeaderboardNameMode,
|
||||
};
|
||||
use super::range::{parse_bounded_u32, parse_nonnegative_usize};
|
||||
use super::range::{parse_bounded_u32, parse_nonnegative_usize, resolve_precise_time_bounds};
|
||||
use super::resolve_admin_usage_time_range;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::{query_param_bool, query_param_value};
|
||||
@@ -228,6 +228,10 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||
};
|
||||
let precise_bounds = match resolve_precise_time_bounds(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||
};
|
||||
let metric = match AdminStatsLeaderboardMetric::parse(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||
@@ -272,7 +276,8 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
|
||||
"user_id is not supported for the user group leaderboard".to_string(),
|
||||
)));
|
||||
}
|
||||
let Some((created_from_unix_secs, created_until_unix_secs)) = time_range.to_unix_bounds()
|
||||
let Some((created_from_unix_secs, created_until_unix_secs)) =
|
||||
precise_bounds.or_else(|| time_range.to_unix_bounds())
|
||||
else {
|
||||
return Ok(Some(build_admin_stats_user_group_leaderboard_response(
|
||||
metric,
|
||||
@@ -402,6 +407,10 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||
};
|
||||
let precise_bounds = match resolve_precise_time_bounds(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||
};
|
||||
let metric = match AdminStatsLeaderboardMetric::parse(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||
@@ -442,7 +451,8 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))),
|
||||
};
|
||||
let Some((created_from_unix_secs, created_until_unix_secs)) = time_range.to_unix_bounds()
|
||||
let Some((created_from_unix_secs, created_until_unix_secs)) =
|
||||
precise_bounds.or_else(|| time_range.to_unix_bounds())
|
||||
else {
|
||||
return Ok(Some(admin_stats_leaderboard_empty_response(
|
||||
metric,
|
||||
|
||||
@@ -8,7 +8,10 @@ mod leaderboard;
|
||||
mod leaderboard_routes;
|
||||
mod provider_quota_routes;
|
||||
mod range;
|
||||
pub(crate) use self::range::{parse_bounded_u32, resolve_admin_usage_time_range};
|
||||
pub(crate) use self::range::{
|
||||
parse_bounded_u32, precise_admin_stats_time_range, resolve_admin_usage_time_range,
|
||||
resolve_precise_time_bounds, resolve_usage_time_bounds,
|
||||
};
|
||||
pub(crate) use aether_admin::observability::stats::{
|
||||
admin_stats_bad_request_response, aggregate_usage_stats, round_to, AdminStatsTimeRange,
|
||||
AdminStatsUsageFilter,
|
||||
|
||||
@@ -4,10 +4,14 @@ pub(super) use aether_admin::observability::stats::{
|
||||
admin_usage_default_days, build_comparison_range, build_time_range_from_days, parse_naive_date,
|
||||
parse_nonnegative_usize, parse_tz_offset_minutes, resolve_preset_dates, user_today,
|
||||
};
|
||||
use chrono::{DateTime, Offset, TimeZone, Utc};
|
||||
|
||||
pub(crate) fn resolve_admin_usage_time_range(
|
||||
query: Option<&str>,
|
||||
) -> Result<AdminStatsTimeRange, String> {
|
||||
if let Some((from, to)) = resolve_precise_time_bounds(query)? {
|
||||
return precise_admin_stats_time_range(query, from, to);
|
||||
}
|
||||
match AdminStatsTimeRange::resolve_optional(query)? {
|
||||
Some(time_range) => Ok(time_range),
|
||||
None => {
|
||||
@@ -20,3 +24,153 @@ pub(crate) fn resolve_admin_usage_time_range(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an exact UTC range supplied by the shared admin range picker.
|
||||
///
|
||||
/// The older stats handlers use `start_date`/`end_date` and fixed offsets. Keep
|
||||
/// that parser intact and only opt into this path when both RFC 3339 endpoints
|
||||
/// are present, so existing callers retain their behavior.
|
||||
pub(crate) fn resolve_precise_time_bounds(
|
||||
query: Option<&str>,
|
||||
) -> Result<Option<(u64, u64)>, String> {
|
||||
let entries =
|
||||
url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()).collect::<Vec<_>>();
|
||||
let from = entries
|
||||
.iter()
|
||||
.filter(|(key, _)| key == "from")
|
||||
.collect::<Vec<_>>();
|
||||
let to = entries
|
||||
.iter()
|
||||
.filter(|(key, _)| key == "to")
|
||||
.collect::<Vec<_>>();
|
||||
if from.is_empty() && to.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if from.len() != 1 || to.len() != 1 {
|
||||
return Err("from and to must each be provided once".into());
|
||||
}
|
||||
if entries
|
||||
.iter()
|
||||
.any(|(key, _)| matches!(key.as_ref(), "start_date" | "end_date" | "preset" | "days"))
|
||||
{
|
||||
return Err("precise from/to cannot be combined with date presets".into());
|
||||
}
|
||||
if let Some(zone) = query_param_value(query, "timezone") {
|
||||
zone.parse::<chrono_tz::Tz>()
|
||||
.map_err(|_| "invalid timezone".to_string())?;
|
||||
}
|
||||
let parse = |value: &str| -> Result<u64, String> {
|
||||
let value = DateTime::parse_from_rfc3339(value)
|
||||
.map_err(|_| "from/to must be RFC 3339 timestamps".to_string())?;
|
||||
if value.timestamp_subsec_nanos() != 0 {
|
||||
return Err("request records support second-aligned ranges".into());
|
||||
}
|
||||
u64::try_from(value.timestamp()).map_err(|_| "from/to must not precede Unix epoch".into())
|
||||
};
|
||||
let bounds = (parse(&from[0].1)?, parse(&to[0].1)?);
|
||||
if bounds.0 >= bounds.1 || bounds.1 - bounds.0 > 366 * 86_400 {
|
||||
return Err("from/to must define a nonempty range of at most 366 days".into());
|
||||
}
|
||||
Ok(Some(bounds))
|
||||
}
|
||||
|
||||
/// Return the exact range when present, otherwise preserve the legacy stats
|
||||
/// date/preset behavior.
|
||||
pub(crate) fn resolve_usage_time_bounds(query: Option<&str>) -> Result<Option<(u64, u64)>, String> {
|
||||
if let Some(bounds) = resolve_precise_time_bounds(query)? {
|
||||
return Ok(Some(bounds));
|
||||
}
|
||||
Ok(resolve_admin_usage_time_range(query)?.to_unix_bounds())
|
||||
}
|
||||
|
||||
/// Build the date metadata used by the existing stats response builders for an
|
||||
/// exact range. The data query still uses the exact UTC bounds; this metadata
|
||||
/// only supplies the local date labels and offset expected by old clients.
|
||||
pub(crate) fn precise_admin_stats_time_range(
|
||||
query: Option<&str>,
|
||||
from: u64,
|
||||
to: u64,
|
||||
) -> Result<AdminStatsTimeRange, String> {
|
||||
let timezone_name = query_param_value(query, "timezone");
|
||||
let (start_date, end_date, tz_offset_minutes) = if let Some(name) = timezone_name {
|
||||
let timezone = name
|
||||
.parse::<chrono_tz::Tz>()
|
||||
.map_err(|_| "invalid timezone".to_string())?;
|
||||
let start = Utc
|
||||
.timestamp_opt(
|
||||
i64::try_from(from).map_err(|_| "invalid from timestamp")?,
|
||||
0,
|
||||
)
|
||||
.single()
|
||||
.ok_or_else(|| "invalid from timestamp".to_string())?
|
||||
.with_timezone(&timezone);
|
||||
let end = Utc
|
||||
.timestamp_opt(
|
||||
i64::try_from(to.saturating_sub(1)).map_err(|_| "invalid to timestamp")?,
|
||||
0,
|
||||
)
|
||||
.single()
|
||||
.ok_or_else(|| "invalid to timestamp".to_string())?
|
||||
.with_timezone(&timezone);
|
||||
(
|
||||
start.date_naive(),
|
||||
end.date_naive(),
|
||||
start.offset().fix().local_minus_utc() / 60,
|
||||
)
|
||||
} else {
|
||||
let offset = parse_tz_offset_minutes(query)?;
|
||||
let fixed = chrono::FixedOffset::east_opt(offset * 60)
|
||||
.ok_or_else(|| "invalid timezone offset".to_string())?;
|
||||
let start = Utc
|
||||
.timestamp_opt(
|
||||
i64::try_from(from).map_err(|_| "invalid from timestamp")?,
|
||||
0,
|
||||
)
|
||||
.single()
|
||||
.ok_or_else(|| "invalid from timestamp".to_string())?
|
||||
.with_timezone(&fixed);
|
||||
let end = Utc
|
||||
.timestamp_opt(
|
||||
i64::try_from(to.saturating_sub(1)).map_err(|_| "invalid to timestamp")?,
|
||||
0,
|
||||
)
|
||||
.single()
|
||||
.ok_or_else(|| "invalid to timestamp".to_string())?
|
||||
.with_timezone(&fixed);
|
||||
(start.date_naive(), end.date_naive(), offset)
|
||||
};
|
||||
|
||||
Ok(AdminStatsTimeRange {
|
||||
start_date,
|
||||
end_date,
|
||||
tz_offset_minutes,
|
||||
})
|
||||
}
|
||||
|
||||
fn query_param_value(query: Option<&str>, key: &str) -> Option<String> {
|
||||
url::form_urlencoded::parse(query.unwrap_or_default().as_bytes())
|
||||
.find(|(name, _)| name == key)
|
||||
.map(|(_, value)| value.into_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{precise_admin_stats_time_range, resolve_precise_time_bounds};
|
||||
|
||||
#[test]
|
||||
fn precise_stats_range_preserves_subday_bounds_and_timezone_labels() {
|
||||
let query = "from=2026-09-01T23:45:00Z&to=2026-09-02T00:15:00Z&timezone=Asia%2FShanghai";
|
||||
let (from, to) = resolve_precise_time_bounds(Some(query)).unwrap().unwrap();
|
||||
assert_eq!(to - from, 30 * 60);
|
||||
let range = precise_admin_stats_time_range(Some(query), from, to).unwrap();
|
||||
assert_eq!(range.start_date.to_string(), "2026-09-02");
|
||||
assert_eq!(range.end_date.to_string(), "2026-09-02");
|
||||
assert_eq!(range.tz_offset_minutes, 480);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precise_stats_range_rejects_mixed_legacy_presets() {
|
||||
let query = "from=2026-09-01T00:00:00Z&to=2026-09-02T00:00:00Z&preset=today";
|
||||
assert!(resolve_precise_time_bounds(Some(query)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::super::resolve_usage_user_group_scope;
|
||||
use super::super::stats::resolve_admin_usage_time_range;
|
||||
use super::super::stats::resolve_usage_time_bounds;
|
||||
use super::analytics::admin_usage_api_key_names;
|
||||
use super::analytics::admin_usage_provider_key_names;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
@@ -37,45 +37,7 @@ const ADMIN_USAGE_ACTIVE_LIMIT: usize = 50;
|
||||
pub(super) fn resolve_record_time_bounds(
|
||||
query: Option<&str>,
|
||||
) -> Result<Option<(u64, u64)>, String> {
|
||||
let entries =
|
||||
url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()).collect::<Vec<_>>();
|
||||
let from = entries
|
||||
.iter()
|
||||
.filter(|(key, _)| key == "from")
|
||||
.collect::<Vec<_>>();
|
||||
let to = entries
|
||||
.iter()
|
||||
.filter(|(key, _)| key == "to")
|
||||
.collect::<Vec<_>>();
|
||||
if from.is_empty() && to.is_empty() {
|
||||
return resolve_admin_usage_time_range(query).map(|range| range.to_unix_bounds());
|
||||
}
|
||||
if from.len() != 1 || to.len() != 1 {
|
||||
return Err("from and to must each be provided once".into());
|
||||
}
|
||||
if entries
|
||||
.iter()
|
||||
.any(|(key, _)| matches!(key.as_ref(), "start_date" | "end_date" | "preset" | "days"))
|
||||
{
|
||||
return Err("precise from/to cannot be combined with date presets".into());
|
||||
}
|
||||
if let Some(zone) = query_param_value(query, "timezone") {
|
||||
zone.parse::<chrono_tz::Tz>()
|
||||
.map_err(|_| "invalid timezone".to_string())?;
|
||||
}
|
||||
let parse = |value: &str| -> Result<u64, String> {
|
||||
let value = chrono::DateTime::parse_from_rfc3339(value)
|
||||
.map_err(|_| "from/to must be RFC 3339 timestamps".to_string())?;
|
||||
if value.timestamp_subsec_nanos() != 0 {
|
||||
return Err("request records support second-aligned ranges".into());
|
||||
}
|
||||
u64::try_from(value.timestamp()).map_err(|_| "from/to must not precede Unix epoch".into())
|
||||
};
|
||||
let bounds = (parse(&from[0].1)?, parse(&to[0].1)?);
|
||||
if bounds.0 >= bounds.1 || bounds.1 - bounds.0 > 366 * 86_400 {
|
||||
return Err("from/to must define a nonempty range of at most 366 days".into());
|
||||
}
|
||||
Ok(Some(bounds))
|
||||
resolve_usage_time_bounds(query)
|
||||
}
|
||||
|
||||
async fn load_admin_usage_by_ids(
|
||||
|
||||
@@ -155,14 +155,17 @@ pub(crate) async fn perform_oauth_token_refresh_once(
|
||||
Ok(None) => {
|
||||
summary.skipped = summary.skipped.saturating_add(1);
|
||||
}
|
||||
Err(_) => {
|
||||
Err(err) => {
|
||||
summary.failed = summary.failed.saturating_add(1);
|
||||
warn!(
|
||||
event_name = "oauth_token_refresh_failed",
|
||||
log_type = "ops",
|
||||
worker = "oauth_token_refresh",
|
||||
provider_id = %provider.id,
|
||||
provider_type = %provider.provider_type,
|
||||
endpoint_id = %endpoint.id,
|
||||
key_id = %key.id,
|
||||
error = %crate::error::redact_error_debug(&err),
|
||||
"gateway oauth token auto refresh failed"
|
||||
);
|
||||
}
|
||||
@@ -440,6 +443,7 @@ mod tests {
|
||||
agent_identity_needs_task_recovery, auth_config_has_refresh_token,
|
||||
is_nonfatal_legacy_catalog_credential_error, oauth_refresh_candidate,
|
||||
};
|
||||
use crate::error::redact_error_debug;
|
||||
use crate::GatewayError;
|
||||
|
||||
#[test]
|
||||
@@ -543,4 +547,18 @@ mod tests {
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_refresh_failure_detail_preserves_context_without_credentials() {
|
||||
let error = GatewayError::Internal(
|
||||
r#"oauth request failed: status=503 token="refresh-secret" retry=2"#.to_string(),
|
||||
);
|
||||
|
||||
let detail = redact_error_debug(&error);
|
||||
|
||||
assert!(detail.contains("oauth request failed"));
|
||||
assert!(detail.contains("status=503"));
|
||||
assert!(detail.contains("[REDACTED]"));
|
||||
assert!(!detail.contains("refresh-secret"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,6 +412,17 @@ fn normalize_local_oauth_refresh_error_message(
|
||||
.unwrap_or_else(|| "Token 刷新失败".to_string())
|
||||
}
|
||||
|
||||
fn local_oauth_refresh_gateway_error(
|
||||
error: &provider_transport::LocalOAuthRefreshError,
|
||||
) -> GatewayError {
|
||||
// Keep a bounded, credential-redacted reason for internal diagnostics.
|
||||
// GatewayError::Internal still returns the generic error response to clients.
|
||||
GatewayError::Internal(format!(
|
||||
"local oauth refresh failed: {}",
|
||||
crate::error::redact_error_detail(error)
|
||||
))
|
||||
}
|
||||
|
||||
fn merge_local_oauth_refresh_failure_reason(
|
||||
current_reason: Option<&str>,
|
||||
refresh_reason: &str,
|
||||
@@ -1556,10 +1567,8 @@ impl AppState {
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(GatewayError::Internal(
|
||||
"local oauth refresh failed".to_string(),
|
||||
));
|
||||
Err(err) => {
|
||||
return Err(local_oauth_refresh_gateway_error(&err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3484,13 +3493,86 @@ mod tests {
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use super::{
|
||||
AgentIdentityAuthConfigFence, AppState, CodexRuntimeOAuthObservation,
|
||||
ProviderTransportSnapshotCacheKey, ProviderTransportSnapshotFlight,
|
||||
ProviderTransportSnapshotFlightResult, ProviderTransportSnapshotInflightRegistration,
|
||||
PROVIDER_TRANSPORT_SNAPSHOT_CACHE_STALE_TTL, PROVIDER_TRANSPORT_SNAPSHOT_CACHE_TTL,
|
||||
local_oauth_refresh_gateway_error, AgentIdentityAuthConfigFence, AppState,
|
||||
CodexRuntimeOAuthObservation, ProviderTransportSnapshotCacheKey,
|
||||
ProviderTransportSnapshotFlight, ProviderTransportSnapshotFlightResult,
|
||||
ProviderTransportSnapshotInflightRegistration, PROVIDER_TRANSPORT_SNAPSHOT_CACHE_STALE_TTL,
|
||||
PROVIDER_TRANSPORT_SNAPSHOT_CACHE_TTL,
|
||||
};
|
||||
use crate::data::GatewayDataState;
|
||||
|
||||
#[test]
|
||||
fn oauth_refresh_diagnostic_preserves_failure_reason_and_redacts_credentials() {
|
||||
for (message, secret) in [
|
||||
(
|
||||
"connection refused refresh_token=refresh-secret",
|
||||
"refresh-secret",
|
||||
),
|
||||
(
|
||||
"connection refused accessToken=access-secret",
|
||||
"access-secret",
|
||||
),
|
||||
(
|
||||
"connection refused client_secret=client-secret",
|
||||
"client-secret",
|
||||
),
|
||||
(
|
||||
"connection refused Authorization: Bearer bearer-secret",
|
||||
"bearer-secret",
|
||||
),
|
||||
(
|
||||
"connection refused https://proxy-user:[email protected]",
|
||||
"proxy-secret",
|
||||
),
|
||||
] {
|
||||
let error = crate::provider_transport::LocalOAuthRefreshError::TransportMessage {
|
||||
provider_type: "codex",
|
||||
message: message.to_string(),
|
||||
};
|
||||
let diagnostic = local_oauth_refresh_gateway_error(&error).into_message();
|
||||
assert!(diagnostic.contains("codex oauth refresh transport failed"));
|
||||
assert!(diagnostic.contains("connection refused"));
|
||||
assert!(!diagnostic.contains(secret), "diagnostic: {diagnostic}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_refresh_diagnostic_keeps_http_status_without_provider_body() {
|
||||
let error = crate::provider_transport::LocalOAuthRefreshError::HttpStatus {
|
||||
provider_type: "codex",
|
||||
status_code: 503,
|
||||
body_excerpt: "unstructured-provider-secret".to_string(),
|
||||
};
|
||||
let diagnostic = local_oauth_refresh_gateway_error(&error).into_message();
|
||||
|
||||
assert!(diagnostic.contains("codex oauth refresh returned HTTP 503"));
|
||||
assert!(!diagnostic.contains("unstructured-provider-secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_refresh_diagnostic_is_hidden_from_client_response() {
|
||||
use axum::body::to_bytes;
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
let error = crate::provider_transport::LocalOAuthRefreshError::TransportMessage {
|
||||
provider_type: "codex",
|
||||
message: "connection refused".to_string(),
|
||||
};
|
||||
let response = local_oauth_refresh_gateway_error(&error).into_response();
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("error response body should read");
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&body).expect("error should be JSON"),
|
||||
json!({"error": {"message": "internal server error"}}),
|
||||
);
|
||||
}
|
||||
|
||||
fn sample_provider() -> StoredProviderCatalogProvider {
|
||||
StoredProviderCatalogProvider::new(
|
||||
"provider-1".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user