diff --git a/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs b/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs
index d5ad3c92c..bdd61a702 100644
--- a/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs
+++ b/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs
@@ -1600,6 +1600,7 @@ mod tests {
user_is_active: true,
user_is_deleted: false,
user_rate_limit: None,
+ user_daily_usage_limit_usd: None,
user_allowed_providers: None,
user_allowed_api_formats: None,
user_allowed_models: None,
@@ -1609,6 +1610,7 @@ mod tests {
api_key_is_locked: false,
api_key_is_standalone: false,
api_key_rate_limit: None,
+ api_key_daily_usage_limit_usd: None,
api_key_concurrent_limit: None,
api_key_expires_at_unix_secs: None,
api_key_allowed_providers: None,
diff --git a/apps/aether-gateway/src/ai_serving/planner/decision_input.rs b/apps/aether-gateway/src/ai_serving/planner/decision_input.rs
index 784106113..ee0bb0882 100644
--- a/apps/aether-gateway/src/ai_serving/planner/decision_input.rs
+++ b/apps/aether-gateway/src/ai_serving/planner/decision_input.rs
@@ -1125,6 +1125,7 @@ mod tests {
user_is_active: true,
user_is_deleted: false,
user_rate_limit: None,
+ user_daily_usage_limit_usd: None,
user_allowed_providers: None,
user_allowed_api_formats: None,
user_allowed_models: None,
@@ -1134,6 +1135,7 @@ mod tests {
api_key_is_locked: false,
api_key_is_standalone: false,
api_key_rate_limit: None,
+ api_key_daily_usage_limit_usd: None,
api_key_concurrent_limit: None,
api_key_expires_at_unix_secs: None,
api_key_allowed_providers: None,
diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/family/payload.rs b/apps/aether-gateway/src/ai_serving/planner/standard/family/payload.rs
index cbb556095..62434a106 100644
--- a/apps/aether-gateway/src/ai_serving/planner/standard/family/payload.rs
+++ b/apps/aether-gateway/src/ai_serving/planner/standard/family/payload.rs
@@ -340,6 +340,7 @@ mod tests {
user_is_active: true,
user_is_deleted: false,
user_rate_limit: None,
+ user_daily_usage_limit_usd: None,
user_allowed_providers: None,
user_allowed_api_formats: None,
user_allowed_models: None,
@@ -349,6 +350,7 @@ mod tests {
api_key_is_locked: false,
api_key_is_standalone: false,
api_key_rate_limit: None,
+ api_key_daily_usage_limit_usd: None,
api_key_concurrent_limit: None,
api_key_expires_at_unix_secs: None,
api_key_allowed_providers: None,
diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs
index 00c22018e..35fe649f6 100644
--- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs
+++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs
@@ -2158,6 +2158,7 @@ mod tests {
user_is_active: true,
user_is_deleted: false,
user_rate_limit: None,
+ user_daily_usage_limit_usd: None,
user_allowed_providers: None,
user_allowed_api_formats: None,
user_allowed_models: None,
@@ -2167,6 +2168,7 @@ mod tests {
api_key_is_locked: false,
api_key_is_standalone: false,
api_key_rate_limit: None,
+ api_key_daily_usage_limit_usd: None,
api_key_concurrent_limit: None,
api_key_expires_at_unix_secs: None,
api_key_allowed_providers: None,
diff --git a/apps/aether-gateway/src/api/response.rs b/apps/aether-gateway/src/api/response.rs
index 403037c7a..dd407e125 100644
--- a/apps/aether-gateway/src/api/response.rs
+++ b/apps/aether-gateway/src/api/response.rs
@@ -10,6 +10,7 @@ use crate::ai_serving::{build_core_error_body_for_client_format, LocalCoreSyncEr
use crate::constants::*;
use crate::control::GatewayControlDecision;
use crate::control::GatewayLocalAuthRejection;
+use crate::daily_usage_limit::{DailyUsageLimitedResponse, FrontdoorDailyUsageRejection};
use crate::headers::should_skip_response_header;
use crate::rate_limit::FrontdoorUserRpmRejection;
use crate::{insert_header_if_missing, GatewayError};
@@ -258,6 +259,74 @@ pub(crate) fn build_local_user_rpm_limited_response(
)
}
+pub(crate) fn build_local_daily_usage_limited_response(
+ trace_id: &str,
+ control_decision: Option<&GatewayControlDecision>,
+ rejection: &FrontdoorDailyUsageRejection,
+) -> Result, GatewayError> {
+ let message = "已达到每日使用上限,请在额度重置后重试";
+ let fallback_payload = json!({
+ "error": {
+ "type": "daily_usage_limit_exceeded",
+ "message": message,
+ "details": {
+ "limit_usd": rejection.limit_usd,
+ "used_usd": rejection.used_usd,
+ "remaining_usd": rejection.remaining_usd,
+ "scope": rejection.scope,
+ "reset_at": rejection.reset_at_unix_secs,
+ "timezone": rejection.timezone,
+ }
+ }
+ });
+ let payload = if local_error_uses_claude_format(control_decision, None) {
+ build_core_error_body_for_client_format(
+ "claude:messages",
+ message,
+ Some("daily_usage_limit_exceeded"),
+ LocalCoreSyncErrorKind::RateLimit,
+ )
+ .unwrap_or(fallback_payload)
+ } else {
+ fallback_payload
+ };
+ let body =
+ serde_json::to_vec(&payload).map_err(|err| GatewayError::Internal(err.to_string()))?;
+ let headers = BTreeMap::from([
+ ("content-type".to_string(), "application/json".to_string()),
+ ("Retry-After".to_string(), rejection.retry_after.to_string()),
+ (
+ "X-Daily-Usage-Limit-USD".to_string(),
+ format!("{:.8}", rejection.limit_usd),
+ ),
+ (
+ "X-Daily-Usage-Used-USD".to_string(),
+ format!("{:.8}", rejection.used_usd),
+ ),
+ (
+ "X-Daily-Usage-Remaining-USD".to_string(),
+ format!("{:.8}", rejection.remaining_usd),
+ ),
+ (
+ "X-Daily-Usage-Scope".to_string(),
+ rejection.scope.to_string(),
+ ),
+ (
+ "X-Daily-Usage-Reset".to_string(),
+ rejection.reset_at_unix_secs.to_string(),
+ ),
+ ]);
+ let mut response = build_client_response_from_parts(
+ StatusCode::TOO_MANY_REQUESTS.as_u16(),
+ &headers,
+ Body::from(body),
+ trace_id,
+ control_decision,
+ )?;
+ response.extensions_mut().insert(DailyUsageLimitedResponse);
+ Ok(response)
+}
+
pub(crate) fn build_local_http_error_response(
trace_id: &str,
control_decision: Option<&GatewayControlDecision>,
@@ -455,10 +524,12 @@ fn local_error_kind_for_status(status: StatusCode) -> LocalCoreSyncErrorKind {
mod tests {
use super::{
build_client_response_from_parts, build_local_auth_rejection_response,
+ build_local_daily_usage_limited_response,
build_local_http_error_response_with_request_path, build_local_overloaded_response,
build_local_user_rpm_limited_response,
};
use crate::control::{GatewayControlDecision, GatewayLocalAuthRejection};
+ use crate::daily_usage_limit::FrontdoorDailyUsageRejection;
use crate::rate_limit::FrontdoorUserRpmRejection;
use axum::body::{to_bytes, Body};
use std::collections::BTreeMap;
@@ -547,6 +618,39 @@ mod tests {
assert_eq!(overloaded["error"]["type"], "overloaded_error");
}
+ #[tokio::test]
+ async fn daily_usage_limit_response_has_dedicated_error_and_reset_headers() {
+ let response = build_local_daily_usage_limited_response(
+ "trace-daily-limit",
+ None,
+ &FrontdoorDailyUsageRejection {
+ scope: "user",
+ limit_usd: 10.0,
+ used_usd: 10.25,
+ remaining_usd: 0.0,
+ retry_after: 120,
+ reset_at_unix_secs: 1_800_000_000,
+ timezone: "Asia/Shanghai".to_string(),
+ },
+ )
+ .expect("daily limit response should build");
+
+ assert_eq!(response.status(), http::StatusCode::TOO_MANY_REQUESTS);
+ assert_eq!(response.headers()["retry-after"], "120");
+ assert_eq!(response.headers()["x-daily-usage-limit-usd"], "10.00000000");
+ assert_eq!(response.headers()["x-daily-usage-used-usd"], "10.25000000");
+ assert_eq!(
+ response.headers()["x-daily-usage-remaining-usd"],
+ "0.00000000"
+ );
+ assert_eq!(response.headers()["x-daily-usage-scope"], "user");
+ assert_eq!(response.headers()["x-daily-usage-reset"], "1800000000");
+
+ let payload = response_json(response).await;
+ assert_eq!(payload["error"]["type"], "daily_usage_limit_exceeded");
+ assert_eq!(payload["error"]["details"]["timezone"], "Asia/Shanghai");
+ }
+
#[tokio::test]
async fn claude_path_shapes_pre_control_http_errors_and_413() {
for path in ["/v1/messages", "/v1/messages/count_tokens"] {
diff --git a/apps/aether-gateway/src/app_timezone.rs b/apps/aether-gateway/src/app_timezone.rs
new file mode 100644
index 000000000..09103e963
--- /dev/null
+++ b/apps/aether-gateway/src/app_timezone.rs
@@ -0,0 +1,104 @@
+use std::sync::LazyLock;
+
+use chrono::{DateTime, LocalResult, NaiveDate, TimeZone, Utc};
+use chrono_tz::Tz;
+use tracing::warn;
+
+pub(crate) const DEFAULT_APP_TIMEZONE: &str = "Asia/Shanghai";
+
+static APP_TIMEZONE: LazyLock = LazyLock::new(|| {
+ let configured = std::env::var("APP_TIMEZONE")
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ .unwrap_or_else(|| DEFAULT_APP_TIMEZONE.to_string());
+ configured.parse().unwrap_or_else(|_| {
+ warn!(
+ timezone = %configured,
+ fallback = DEFAULT_APP_TIMEZONE,
+ "gateway APP_TIMEZONE invalid; falling back"
+ );
+ DEFAULT_APP_TIMEZONE
+ .parse()
+ .expect("default application timezone should parse")
+ })
+});
+
+pub(crate) fn app_timezone() -> Tz {
+ *APP_TIMEZONE
+}
+
+pub(crate) fn local_day_window(
+ now_utc: DateTime,
+ timezone: Tz,
+) -> (NaiveDate, DateTime, DateTime) {
+ let local_date = now_utc.with_timezone(&timezone).date_naive();
+ let next_date = local_date
+ .succ_opt()
+ .expect("application local date should have a successor");
+ (
+ local_date,
+ local_midnight_utc(local_date, timezone),
+ local_midnight_utc(next_date, timezone),
+ )
+}
+
+fn local_midnight_utc(date: NaiveDate, timezone: Tz) -> DateTime {
+ let midnight = date
+ .and_hms_opt(0, 0, 0)
+ .expect("local midnight should be valid");
+ match timezone.from_local_datetime(&midnight) {
+ LocalResult::Single(value) => value.with_timezone(&Utc),
+ LocalResult::Ambiguous(first, second) => first.min(second).with_timezone(&Utc),
+ LocalResult::None => {
+ for minute in 1..=180 {
+ let candidate = midnight + chrono::Duration::minutes(minute);
+ match timezone.from_local_datetime(&candidate) {
+ LocalResult::Single(value) => return value.with_timezone(&Utc),
+ LocalResult::Ambiguous(first, second) => {
+ return first.min(second).with_timezone(&Utc)
+ }
+ LocalResult::None => {}
+ }
+ }
+ panic!("local day start should resolve within three hours")
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn default_timezone_uses_shanghai_natural_day() {
+ let timezone: Tz = DEFAULT_APP_TIMEZONE.parse().unwrap();
+ let (_, start, end) = local_day_window(
+ DateTime::parse_from_rfc3339("2026-08-03T12:00:00Z")
+ .unwrap()
+ .with_timezone(&Utc),
+ timezone,
+ );
+ assert_eq!(start.to_rfc3339(), "2026-08-02T16:00:00+00:00");
+ assert_eq!(end.to_rfc3339(), "2026-08-03T16:00:00+00:00");
+ }
+
+ #[test]
+ fn dst_days_use_natural_local_midnights() {
+ let timezone: Tz = "America/New_York".parse().unwrap();
+ let spring = local_day_window(
+ DateTime::parse_from_rfc3339("2026-03-08T12:00:00Z")
+ .unwrap()
+ .with_timezone(&Utc),
+ timezone,
+ );
+ assert_eq!((spring.2 - spring.1).num_hours(), 23);
+ let fall = local_day_window(
+ DateTime::parse_from_rfc3339("2026-11-01T12:00:00Z")
+ .unwrap()
+ .with_timezone(&Utc),
+ timezone,
+ );
+ assert_eq!((fall.2 - fall.1).num_hours(), 25);
+ }
+}
diff --git a/apps/aether-gateway/src/cache/auth_context.rs b/apps/aether-gateway/src/cache/auth_context.rs
index cc2c3e9dd..f9bc97319 100644
--- a/apps/aether-gateway/src/cache/auth_context.rs
+++ b/apps/aether-gateway/src/cache/auth_context.rs
@@ -417,8 +417,11 @@ mod tests {
access_allowed: true,
user_rate_limit: None,
api_key_rate_limit: None,
+ user_daily_usage_limit_usd: None,
+ api_key_daily_usage_limit_usd: None,
api_key_is_standalone: false,
admin_bypass_limits: false,
+ ip_bypass_limits: false,
local_rejection: None,
allowed_models: None,
ip_rules: None,
diff --git a/apps/aether-gateway/src/cache/candidate_page.rs b/apps/aether-gateway/src/cache/candidate_page.rs
index 6512fae8d..23b0e8d1a 100644
--- a/apps/aether-gateway/src/cache/candidate_page.rs
+++ b/apps/aether-gateway/src/cache/candidate_page.rs
@@ -542,6 +542,7 @@ mod tests {
user_is_active: true,
user_is_deleted: false,
user_rate_limit: None,
+ user_daily_usage_limit_usd: None,
user_allowed_providers: None,
user_allowed_api_formats: None,
user_allowed_models: None,
@@ -551,6 +552,7 @@ mod tests {
api_key_is_locked: false,
api_key_is_standalone: false,
api_key_rate_limit: None,
+ api_key_daily_usage_limit_usd: None,
api_key_concurrent_limit: None,
api_key_expires_at_unix_secs: None,
api_key_allowed_providers: None,
diff --git a/apps/aether-gateway/src/control/auth/gate.rs b/apps/aether-gateway/src/control/auth/gate.rs
index 208c13cf3..fe603d815 100644
--- a/apps/aether-gateway/src/control/auth/gate.rs
+++ b/apps/aether-gateway/src/control/auth/gate.rs
@@ -239,6 +239,28 @@ async fn estimate_execution_plan_cost_upper_bound_usd(
result
}
+pub(crate) async fn execution_plan_cost_is_proven_zero(
+ state: &AppState,
+ plan: &aether_contracts::ExecutionPlan,
+ report_context: Option<&serde_json::Value>,
+) -> bool {
+ let model_id = report_context_string_field(report_context, "model_id");
+ let global_model_name = report_context_string_field(report_context, "global_model_name");
+ if model_id.is_some() || global_model_name.is_some() {
+ if let Ok(Some(context)) =
+ load_execution_plan_billing_context(state, plan, model_id, global_model_name).await
+ {
+ if aether_billing::BillingModelPricingSnapshot::from(context).is_free_tier() {
+ return true;
+ }
+ }
+ }
+ matches!(
+ estimate_execution_plan_cost_upper_bound_usd(state, plan, report_context).await,
+ Ok(Some(cost)) if cost <= DAILY_QUOTA_EPSILON_USD
+ )
+}
+
async fn estimate_execution_plan_cost_upper_bound_usd_inner(
state: &AppState,
plan: &aether_contracts::ExecutionPlan,
@@ -832,9 +854,10 @@ mod tests {
use serde_json::json;
use super::{
- execution_plan_balance_capacity_rejection, execution_plan_cost_upper_bound_cache_key,
- max_output_tokens_from_request, openai_request_input_is_self_contained,
- output_choice_count_upper_bound, request_model_local_rejection, GatewayLocalAuthRejection,
+ execution_plan_balance_capacity_rejection, execution_plan_cost_is_proven_zero,
+ execution_plan_cost_upper_bound_cache_key, max_output_tokens_from_request,
+ openai_request_input_is_self_contained, output_choice_count_upper_bound,
+ request_model_local_rejection, GatewayLocalAuthRejection,
};
use crate::control::{GatewayControlAuthContext, GatewayControlDecision};
use crate::data::GatewayDataState;
@@ -919,8 +942,11 @@ mod tests {
access_allowed: true,
user_rate_limit: None,
api_key_rate_limit: None,
+ user_daily_usage_limit_usd: None,
+ api_key_daily_usage_limit_usd: None,
api_key_is_standalone: false,
admin_bypass_limits: false,
+ ip_bypass_limits: false,
local_rejection: None,
allowed_models: Some(allowed_models),
ip_rules: None,
@@ -1829,6 +1855,59 @@ mod tests {
assert_eq!(rejection, None);
}
+ #[tokio::test]
+ async fn daily_usage_zero_cost_proof_recognizes_free_images_but_not_unknown_paid_images() {
+ let free_context = billing_context_with_pricing(
+ Some(json!({
+ "tiers": [{
+ "up_to": null,
+ "input_price_per_1m": 100.0,
+ "output_price_per_1m": 100.0
+ }]
+ })),
+ None,
+ None,
+ Some("free_tier"),
+ );
+ let paid_context = billing_context_with_pricing(
+ Some(json!({
+ "tiers": [{
+ "up_to": null,
+ "input_price_per_1m": 1.0,
+ "output_price_per_1m": 1.0
+ }]
+ })),
+ None,
+ None,
+ None,
+ );
+ let plan = execution_plan(
+ json!({
+ "model": "gpt-image-1",
+ "prompt": "a small red circle"
+ }),
+ "openai:image",
+ );
+ let report_context = billing_report_context();
+
+ assert!(
+ execution_plan_cost_is_proven_zero(
+ &state_with_quota_and_wallet(quota_availability(0.0, false), free_context,),
+ &plan,
+ Some(&report_context),
+ )
+ .await
+ );
+ assert!(
+ !execution_plan_cost_is_proven_zero(
+ &state_with_quota_and_wallet(quota_availability(0.0, false), paid_context,),
+ &plan,
+ Some(&report_context),
+ )
+ .await
+ );
+ }
+
#[tokio::test]
async fn finalized_chat_output_fields_and_choice_count_bound_capacity() {
let context = billing_context_with_pricing(
diff --git a/apps/aether-gateway/src/control/auth/mod.rs b/apps/aether-gateway/src/control/auth/mod.rs
index 1aa699566..ca21ed804 100644
--- a/apps/aether-gateway/src/control/auth/mod.rs
+++ b/apps/aether-gateway/src/control/auth/mod.rs
@@ -7,8 +7,9 @@ mod types;
pub(crate) use credentials::extract_requested_model;
pub(super) use credentials::resolve_gateway_credential_carrier;
pub(crate) use gate::{
- execution_plan_balance_capacity_rejection, request_model_local_rejection,
- should_buffer_request_for_local_auth, trusted_auth_local_rejection, GatewayLocalAuthRejection,
+ execution_plan_balance_capacity_rejection, execution_plan_cost_is_proven_zero,
+ request_model_local_rejection, should_buffer_request_for_local_auth,
+ trusted_auth_local_rejection, GatewayLocalAuthRejection,
};
pub(crate) use resolution::{
refresh_execution_runtime_auth_context, resolve_execution_runtime_auth_context,
diff --git a/apps/aether-gateway/src/control/auth/resolution.rs b/apps/aether-gateway/src/control/auth/resolution.rs
index 6ebe21d03..71f8e9b19 100644
--- a/apps/aether-gateway/src/control/auth/resolution.rs
+++ b/apps/aether-gateway/src/control/auth/resolution.rs
@@ -84,10 +84,16 @@ pub(crate) struct GatewayControlAuthContext {
#[serde(skip)]
pub(crate) api_key_rate_limit: Option,
#[serde(skip)]
+ pub(crate) user_daily_usage_limit_usd: Option,
+ #[serde(skip)]
+ pub(crate) api_key_daily_usage_limit_usd: Option,
+ #[serde(skip)]
pub(crate) api_key_is_standalone: bool,
#[serde(skip)]
pub(crate) admin_bypass_limits: bool,
#[serde(skip)]
+ pub(crate) ip_bypass_limits: bool,
+ #[serde(skip)]
pub(crate) local_rejection: Option,
#[serde(skip)]
pub(crate) allowed_models: Option>,
@@ -922,8 +928,11 @@ pub(super) async fn resolve_data_backed_auth_context(
access_allowed: false,
user_rate_limit: None,
api_key_rate_limit: None,
+ user_daily_usage_limit_usd: None,
+ api_key_daily_usage_limit_usd: None,
api_key_is_standalone: false,
admin_bypass_limits: false,
+ ip_bypass_limits: false,
local_rejection: Some(GatewayLocalAuthRejection::InvalidApiKey),
allowed_models: None,
ip_rules: None,
@@ -1035,8 +1044,11 @@ async fn resolve_antigravity_bearer_bridge_auth_context(
access_allowed: false,
user_rate_limit: None,
api_key_rate_limit: None,
+ user_daily_usage_limit_usd: None,
+ api_key_daily_usage_limit_usd: None,
api_key_is_standalone: false,
admin_bypass_limits: false,
+ ip_bypass_limits: false,
local_rejection: Some(GatewayLocalAuthRejection::InvalidApiKey),
allowed_models: None,
ip_rules: None,
@@ -1094,8 +1106,11 @@ async fn resolve_trusted_auth_context(
access_allowed: false,
user_rate_limit: None,
api_key_rate_limit: None,
+ user_daily_usage_limit_usd: None,
+ api_key_daily_usage_limit_usd: None,
api_key_is_standalone: false,
admin_bypass_limits: false,
+ ip_bypass_limits: false,
local_rejection: Some(GatewayLocalAuthRejection::InvalidApiKey),
allowed_models: None,
ip_rules: None,
@@ -1191,9 +1206,12 @@ async fn build_data_backed_auth_context(
access_allowed: key_access_allowed && local_rejection.is_none(),
user_rate_limit: snapshot.user_rate_limit,
api_key_rate_limit: snapshot.api_key_rate_limit,
+ user_daily_usage_limit_usd: snapshot.user_daily_usage_limit_usd,
+ api_key_daily_usage_limit_usd: snapshot.api_key_daily_usage_limit_usd,
api_key_is_standalone: snapshot.api_key_is_standalone,
admin_bypass_limits: snapshot.user_role.eq_ignore_ascii_case("admin")
&& !snapshot.api_key_is_standalone,
+ ip_bypass_limits: false,
local_rejection,
allowed_models,
ip_rules: snapshot.api_key_ip_rules,
diff --git a/apps/aether-gateway/src/control/mod.rs b/apps/aether-gateway/src/control/mod.rs
index f62651d4a..c85023e80 100644
--- a/apps/aether-gateway/src/control/mod.rs
+++ b/apps/aether-gateway/src/control/mod.rs
@@ -8,8 +8,8 @@ mod public;
mod route;
pub(crate) use auth::{
- execution_plan_balance_capacity_rejection, extract_requested_model,
- refresh_execution_runtime_auth_context, request_model_local_rejection,
+ execution_plan_balance_capacity_rejection, execution_plan_cost_is_proven_zero,
+ extract_requested_model, refresh_execution_runtime_auth_context, request_model_local_rejection,
resolve_execution_runtime_auth_context, should_buffer_request_for_local_auth,
trusted_auth_local_rejection, GatewayAdminPrincipalContext, GatewayControlAuthContext,
GatewayCredentialCarrier, GatewayLocalAuthRejection,
diff --git a/apps/aether-gateway/src/daily_usage_limit.rs b/apps/aether-gateway/src/daily_usage_limit.rs
new file mode 100644
index 000000000..3abefabdc
--- /dev/null
+++ b/apps/aether-gateway/src/daily_usage_limit.rs
@@ -0,0 +1,878 @@
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use aether_cache::ExpiringMap;
+use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
+use aether_data_contracts::repository::usage::UsageDailyActualCostRollupQuery;
+use aether_runtime_state::{
+ DailyUsageLimitCountInput, DailyUsageLimitIncrementInput, DailyUsageLimitRestoreEntry,
+ DailyUsageLimitRestoreInput, RuntimeState,
+};
+use chrono::{DateTime, SecondsFormat, Utc};
+use tracing::warn;
+
+use crate::app_timezone::{app_timezone, local_day_window};
+use crate::control::GatewayControlDecision;
+use crate::stage_metrics::observe_gateway_stage_ms;
+use crate::{AppState, GatewayError};
+
+const SYSTEM_DAILY_USAGE_LIMIT_CONFIG_KEY: &str = "daily_usage_limit_usd";
+const SYSTEM_CONFIG_CACHE_TTL: Duration = Duration::from_secs(15);
+const LIMIT_EPSILON_USD: f64 = 0.000_000_01;
+const USD_UNITS_PER_DOLLAR: f64 = 100_000_000.0;
+const COUNTER_EXPIRY_GRACE_SECONDS: u64 = 60;
+const DAILY_USAGE_RUNTIME_STATE_KEY: &str = "daily_usage_limit:runtime_state";
+const DAILY_USAGE_RECOVERY_LOCK_KEY: &str = "daily_usage_limit:recovery";
+const DAILY_USAGE_RECOVERY_LOCK_OWNER: &str = "gateway-daily-usage-recovery";
+const DAILY_USAGE_RECOVERY_LOCK_TTL: Duration = Duration::from_secs(600);
+const DAILY_USAGE_RECOVERY_RETRY_DELAY: Duration = Duration::from_secs(30);
+
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) struct DailyUsageScopeStatus {
+ pub(crate) scope: &'static str,
+ pub(crate) limit_usd: f64,
+ pub(crate) used_usd: f64,
+ pub(crate) remaining_usd: f64,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) struct FrontdoorDailyUsageStatus {
+ pub(crate) available: bool,
+ pub(crate) timezone: String,
+ pub(crate) window_start: String,
+ pub(crate) window_end: String,
+ pub(crate) reset_at_unix_secs: u64,
+ pub(crate) user: Option,
+ pub(crate) key: Option,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) struct FrontdoorDailyUsageRejection {
+ pub(crate) scope: &'static str,
+ pub(crate) limit_usd: f64,
+ pub(crate) used_usd: f64,
+ pub(crate) remaining_usd: f64,
+ pub(crate) retry_after: u64,
+ pub(crate) reset_at_unix_secs: u64,
+ pub(crate) timezone: String,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) enum FrontdoorDailyUsageOutcome {
+ NotApplicable,
+ Allowed,
+ Rejected(FrontdoorDailyUsageRejection),
+}
+
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct DailyUsageLimitedResponse;
+
+#[derive(Debug, Clone)]
+pub(crate) struct FrontdoorDailyUsageLimiter {
+ system_default_cache: Arc>,
+ recovery_inflight: Arc,
+ runtime_failures: Arc,
+ #[cfg(test)]
+ system_default_override: Arc>>,
+}
+
+impl Default for FrontdoorDailyUsageLimiter {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl FrontdoorDailyUsageLimiter {
+ pub(crate) fn new() -> Self {
+ Self {
+ system_default_cache: Arc::new(ExpiringMap::default()),
+ recovery_inflight: Arc::new(AtomicBool::new(false)),
+ runtime_failures: Arc::new(AtomicU64::new(0)),
+ #[cfg(test)]
+ system_default_override: Arc::new(std::sync::Mutex::new(None)),
+ }
+ }
+
+ pub(crate) fn clear_system_default_cache(&self) {
+ self.system_default_cache.clear();
+ }
+
+ pub(crate) fn runtime_failure_count(&self) -> u64 {
+ self.runtime_failures.load(Ordering::Relaxed)
+ }
+
+ pub(crate) async fn check(
+ &self,
+ state: &AppState,
+ decision: &GatewayControlDecision,
+ ) -> FrontdoorDailyUsageOutcome {
+ let started_at = Instant::now();
+ let status_result = self.current_status(state, decision).await;
+ observe_gateway_stage_ms(
+ "daily_usage_limit_total",
+ started_at.elapsed().as_millis() as u64,
+ );
+ let status = match status_result {
+ Ok(Some(status)) => status,
+ Ok(None) => return FrontdoorDailyUsageOutcome::NotApplicable,
+ Err(err) => {
+ let failure_count = self.runtime_failures.fetch_add(1, Ordering::Relaxed) + 1;
+ let auth = decision.auth_context.as_ref();
+ warn!(
+ event_name = "frontdoor_daily_usage_check_failed",
+ log_type = "ops",
+ error = ?err,
+ runtime_failures_total = failure_count,
+ user_id = auth.map(|auth| auth.user_id.as_str()).unwrap_or("-"),
+ api_key_id = auth.map(|auth| auth.api_key_id.as_str()).unwrap_or("-"),
+ "daily usage limit check failed; allowing request"
+ );
+ return FrontdoorDailyUsageOutcome::Allowed;
+ }
+ };
+ if !status.available {
+ return FrontdoorDailyUsageOutcome::Allowed;
+ }
+ let exceeded = status
+ .user
+ .as_ref()
+ .filter(|scope| scope.used_usd + LIMIT_EPSILON_USD >= scope.limit_usd)
+ .or_else(|| {
+ status
+ .key
+ .as_ref()
+ .filter(|scope| scope.used_usd + LIMIT_EPSILON_USD >= scope.limit_usd)
+ });
+ let Some(exceeded) = exceeded else {
+ return FrontdoorDailyUsageOutcome::Allowed;
+ };
+ let now = Utc::now().timestamp().max(0) as u64;
+ FrontdoorDailyUsageOutcome::Rejected(FrontdoorDailyUsageRejection {
+ scope: exceeded.scope,
+ limit_usd: exceeded.limit_usd,
+ used_usd: exceeded.used_usd,
+ remaining_usd: exceeded.remaining_usd,
+ retry_after: status.reset_at_unix_secs.saturating_sub(now).max(1),
+ reset_at_unix_secs: status.reset_at_unix_secs,
+ timezone: status.timezone,
+ })
+ }
+
+ pub(crate) async fn current_status(
+ &self,
+ state: &AppState,
+ decision: &GatewayControlDecision,
+ ) -> Result
+
+
+
+
+ 0 表示默认不限制;未单独配置的用户和独立 Key 会跟随这里
+
+
+
@@ -477,6 +484,26 @@
+
+
+
newKeyDailyUsageLimitUsd = parseNumberInput(v, { allowFloat: true, min: 0 })"
+ />
+
+ 正数会进一步收窄账户额度限制;0 或留空不增加 Key 级限制。
+
+
+