Merge remote-tracking branch 'entropy-xu/codex/ccswitch-import'

This commit is contained in:
elky
2026-05-31 20:52:26 +08:00
23 changed files with 1720 additions and 48 deletions
@@ -195,6 +195,9 @@ fn select_primary_credential(
if signature.starts_with("openai:") {
return select_openai_credential(bundle);
}
if signature.starts_with("aether:") {
return select_openai_credential(bundle);
}
select_generic_credential(bundle)
}
@@ -826,8 +826,9 @@ async fn build_data_backed_auth_context(
.map(|(provider, _)| provider)
.unwrap_or(auth_endpoint_signature)
.trim();
let requested_provider_allowed =
auth_snapshot_allows_requested_provider(state, &snapshot, auth_endpoint_signature).await;
let identity_only = auth_gate_identity_only(auth_endpoint_signature);
let requested_provider_allowed = identity_only
|| auth_snapshot_allows_requested_provider(state, &snapshot, auth_endpoint_signature).await;
let local_rejection = if invalid_api_key {
Some(GatewayLocalAuthRejection::InvalidApiKey)
} else if locked_api_key {
@@ -845,14 +846,15 @@ async fn build_data_backed_auth_context(
Some(GatewayLocalAuthRejection::ProviderNotAllowed {
provider: requested_provider.to_string(),
})
} else if snapshot
.effective_allowed_api_formats()
.is_some_and(|allowed| {
!contains_api_format_or_alias(
allowed,
auth_gate_api_format(auth_endpoint_signature).as_str(),
)
})
} else if !identity_only
&& snapshot
.effective_allowed_api_formats()
.is_some_and(|allowed| {
!contains_api_format_or_alias(
allowed,
auth_gate_api_format(auth_endpoint_signature).as_str(),
)
})
{
Some(GatewayLocalAuthRejection::ApiFormatNotAllowed {
api_format: auth_endpoint_signature.to_string(),
@@ -896,6 +898,13 @@ fn auth_gate_api_format(auth_endpoint_signature: &str) -> String {
}
}
fn auth_gate_identity_only(auth_endpoint_signature: &str) -> bool {
matches!(
auth_endpoint_signature.trim().to_ascii_lowercase().as_str(),
"aether:ccswitch_usage"
)
}
fn api_format_matches(left: &str, right: &str) -> bool {
aether_scheduler_core::api_format_matches_allowed_value(left, right)
}
@@ -505,6 +505,19 @@ pub(super) fn classify_public_support_route(
"public:payment",
false,
))
} else if method == http::Method::GET
&& matches!(
normalized_path,
"/api/ccswitch/usage" | "/api/ccswitch/usage/"
)
{
Some(classified(
"public_support",
"ccswitch",
"usage",
"aether:ccswitch_usage",
false,
))
} else if method == http::Method::GET
&& matches!(
normalized_path,
@@ -517,6 +530,7 @@ pub(super) fn classify_public_support_route(
| "/api/users/me/usage/heatmap"
| "/api/users/me/providers"
| "/api/users/me/available-models"
| "/api/users/me/client-config"
| "/api/users/me/endpoint-status"
| "/api/users/me/preferences"
| "/api/users/me/referral"
@@ -533,6 +547,7 @@ pub(super) fn classify_public_support_route(
"/api/users/me/usage/heatmap" => "usage_heatmap",
"/api/users/me/providers" => "providers",
"/api/users/me/available-models" => "available_models",
"/api/users/me/client-config" => "client_config",
"/api/users/me/endpoint-status" => "endpoint_status",
"/api/users/me/preferences" => "preferences",
"/api/users/me/referral" => "referral",
@@ -463,6 +463,23 @@ fn classifies_users_me_routes_as_public_support_route() {
}
}
#[test]
fn classifies_ccswitch_usage_as_api_key_public_support_route() {
let headers = headers(&[]);
let uri: Uri = "/api/ccswitch/usage".parse().expect("uri should parse");
let decision =
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
assert_eq!(decision.route_class.as_deref(), Some("public_support"));
assert_eq!(decision.route_family.as_deref(), Some("ccswitch"));
assert_eq!(decision.route_kind.as_deref(), Some("usage"));
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("aether:ccswitch_usage")
);
assert!(!decision.is_execution_runtime_candidate());
}
#[test]
fn user_api_key_install_session_create_buffers_request_body() {
let headers = headers(&[]);
@@ -29,6 +29,8 @@ mod support_announcements;
mod support_auth;
#[path = "support/billing.rs"]
mod support_billing;
#[path = "support/ccswitch.rs"]
mod support_ccswitch;
#[path = "support/dashboard.rs"]
mod support_dashboard;
#[path = "support/install.rs"]
@@ -66,10 +68,11 @@ use self::support_auth::{
build_auth_settings_payload, extract_client_device_id, maybe_build_local_auth_response,
};
use self::support_billing::maybe_build_local_billing_response;
use self::support_ccswitch::maybe_build_local_ccswitch_response;
use self::support_dashboard::maybe_build_local_dashboard_response;
pub(crate) use self::support_install::{
build_api_key_install_session_response, build_proxy_node_install_session_response,
CreateApiKeyInstallSessionRequest,
base_url_from_request, build_api_key_install_session_response,
build_proxy_node_install_session_response, CreateApiKeyInstallSessionRequest,
};
use self::support_install::{
handle_users_me_api_key_install_session_create, maybe_build_local_install_response,
@@ -84,7 +87,10 @@ use self::support_payment::maybe_build_local_payment_callback_response;
use self::support_test_connection::maybe_build_local_test_connection_response;
use self::support_user_me::maybe_build_local_users_me_response;
use self::support_wallet::{
direct_gateway_channels, maybe_build_local_wallet_response, sanitize_wallet_gateway_response,
build_wallet_balance_payload_for_auth_scope, build_wallet_balance_payload_for_user,
build_wallet_live_today_usage_payload_for_api_key,
build_wallet_live_today_usage_payload_for_user, direct_gateway_channels,
maybe_build_local_wallet_response, sanitize_wallet_gateway_response,
wallet_normalize_optional_string_field,
};
@@ -171,6 +177,13 @@ pub(crate) async fn maybe_build_local_public_support_response(
return Some(build_unhandled_public_support_response(request_context));
}
if decision.route_family.as_deref() == Some("ccswitch") {
if let Some(response) = maybe_build_local_ccswitch_response(state, request_context).await {
return Some(response);
}
return Some(build_unhandled_public_support_response(request_context));
}
if decision.route_family.as_deref() == Some("users_me") {
return maybe_build_local_users_me_response(state, request_context, headers, request_body)
.await;
@@ -0,0 +1,176 @@
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use crate::control::GatewayLocalAuthRejection;
use crate::handlers::shared::round_to;
use super::{
build_auth_error_response, build_wallet_balance_payload_for_auth_scope,
build_wallet_live_today_usage_payload_for_api_key,
build_wallet_live_today_usage_payload_for_user, AppState, GatewayPublicRequestContext,
};
fn ccswitch_usage_auth_error_response(
rejection: Option<&GatewayLocalAuthRejection>,
) -> Response<Body> {
match rejection {
Some(GatewayLocalAuthRejection::InvalidApiKey) | None => {
build_auth_error_response(http::StatusCode::UNAUTHORIZED, "无效的 API Key", false)
}
Some(GatewayLocalAuthRejection::LockedApiKey) => {
build_auth_error_response(http::StatusCode::FORBIDDEN, "API Key 已被锁定", false)
}
Some(GatewayLocalAuthRejection::ProviderNotAllowed { .. })
| Some(GatewayLocalAuthRejection::ApiFormatNotAllowed { .. })
| Some(GatewayLocalAuthRejection::ModelNotAllowed { .. })
| Some(GatewayLocalAuthRejection::IpNotAllowed { .. }) => {
build_auth_error_response(http::StatusCode::FORBIDDEN, "API Key 无权查询用量", false)
}
Some(GatewayLocalAuthRejection::WalletUnavailable) => build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
"钱包数据暂不可用",
false,
),
Some(GatewayLocalAuthRejection::BalanceDenied { .. }) => {
build_auth_error_response(http::StatusCode::FORBIDDEN, "API Key 余额不足", false)
}
}
}
fn json_f64(value: &serde_json::Value, key: &str) -> Option<f64> {
value.get(key).and_then(serde_json::Value::as_f64)
}
fn json_bool(value: &serde_json::Value, key: &str) -> bool {
value
.get(key)
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
fn format_usd(value: f64) -> String {
format!("${:.4}", round_to(value, 4))
}
fn build_ccswitch_usage_extra(
wallet_payload: &serde_json::Value,
today_payload: Option<&serde_json::Value>,
) -> String {
let mut parts = Vec::new();
if let Some(today_cost) = today_payload.and_then(|payload| json_f64(payload, "total_cost")) {
parts.push(format!("今日消耗 {}", format_usd(today_cost)));
}
if let Some(wallet_balance) = json_f64(wallet_payload, "wallet_balance") {
parts.push(format!("钱包 {}", format_usd(wallet_balance)));
}
if let Some(package_balance) = json_f64(wallet_payload, "package_balance") {
if package_balance > 0.0 {
parts.push(format!("套餐 {}", format_usd(package_balance)));
}
}
parts.join(" · ")
}
pub(super) async fn maybe_build_local_ccswitch_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?;
if decision.route_family.as_deref() != Some("ccswitch") {
return None;
}
if decision.route_kind.as_deref() != Some("usage")
|| request_context.request_path.trim_end_matches('/') != "/api/ccswitch/usage"
{
return None;
}
let auth_context = match decision.auth_context.as_ref() {
Some(auth_context) if !auth_context.user_id.trim().is_empty() => auth_context,
_ => {
return Some(ccswitch_usage_auth_error_response(
decision.local_auth_rejection.as_ref(),
))
}
};
match auth_context.local_rejection.as_ref() {
None | Some(GatewayLocalAuthRejection::BalanceDenied { .. }) => {}
rejection => return Some(ccswitch_usage_auth_error_response(rejection)),
}
let wallet = match state
.read_wallet_snapshot_for_auth(
&auth_context.user_id,
&auth_context.api_key_id,
auth_context.api_key_is_standalone,
)
.await
{
Ok(value) => value,
Err(err) => {
return Some(build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("ccswitch usage wallet lookup failed: {err:?}"),
false,
))
}
};
let wallet_payload = build_wallet_balance_payload_for_auth_scope(
state,
&auth_context.user_id,
auth_context.api_key_is_standalone,
wallet.as_ref(),
)
.await;
let today_payload = match if auth_context.api_key_is_standalone {
build_wallet_live_today_usage_payload_for_api_key(state, &auth_context.api_key_id).await
} else {
build_wallet_live_today_usage_payload_for_user(state, &auth_context.user_id).await
} {
Ok(value) => value,
Err(err) => {
return Some(build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
err,
false,
))
}
};
let unlimited = json_bool(&wallet_payload, "unlimited");
let remaining = json_f64(&wallet_payload, "total_available_balance")
.or_else(|| json_f64(&wallet_payload, "wallet_balance"));
let used_today = today_payload
.as_ref()
.and_then(|payload| json_f64(payload, "total_cost"))
.unwrap_or(0.0);
let mut extra = build_ccswitch_usage_extra(&wallet_payload, today_payload.as_ref());
if extra.is_empty() && unlimited {
extra = "无限额度".to_string();
}
Some(
Json(json!({
"is_valid": true,
"plan_name": if unlimited { "Aether Unlimited" } else { "Aether" },
"remaining": remaining.map(|value| round_to(value.max(0.0), 6)),
"used": round_to(used_today.max(0.0), 6),
"unit": wallet_payload
.get("currency")
.and_then(serde_json::Value::as_str)
.unwrap_or("USD"),
"extra": extra,
"unlimited": unlimited,
"wallet": wallet_payload,
"today": today_payload,
}))
.into_response(),
)
}
@@ -1,6 +1,7 @@
use super::{
auth_password_policy_level, build_auth_error_response, build_auth_wallet_summary_payload,
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks, handle_auth_me,
auth_password_policy_level, base_url_from_request, build_auth_error_response,
build_auth_wallet_summary_payload, decrypt_catalog_secret_with_fallbacks,
encrypt_catalog_secret_with_fallbacks, handle_auth_me,
handle_users_me_api_key_install_session_create, query_param_optional_bool, query_param_value,
resolve_authenticated_local_user, sanitize_public_model_config_for_user, unix_secs_to_rfc3339,
users_me_api_key_install_sessions_path_matches, validate_auth_register_password, AppState,
@@ -12,8 +12,9 @@ use crate::handlers::shared::{
};
use super::{
auth_password_policy_level, build_auth_error_response, resolve_authenticated_local_user,
validate_auth_register_password, AppState, GatewayPublicRequestContext,
auth_password_policy_level, base_url_from_request, build_auth_error_response,
resolve_authenticated_local_user, validate_auth_register_password, AppState,
GatewayPublicRequestContext,
};
const USERS_ME_PROFILE_STORAGE_UNAVAILABLE_DETAIL: &str = "用户资料存储暂不可用";
@@ -40,6 +41,30 @@ fn normalize_users_me_optional_non_empty_string(value: Option<String>) -> Option
value.filter(|value| !value.is_empty())
}
pub(super) async fn handle_users_me_client_config_get(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Response<Body> {
if let Err(response) = resolve_authenticated_local_user(state, request_context, headers).await {
return response;
}
let site_name = state
.read_system_config_json_value("site_name")
.await
.ok()
.flatten()
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.unwrap_or_else(|| "Aether".to_string());
Json(json!({
"base_url": base_url_from_request(headers, request_context),
"site_name": site_name,
}))
.into_response()
}
pub(super) async fn handle_users_me_detail_put(
state: &AppState,
request_context: &GatewayPublicRequestContext,
@@ -7,20 +7,20 @@ use super::{
handle_users_me_api_key_install_session_create, handle_users_me_api_key_patch,
handle_users_me_api_key_providers_put, handle_users_me_api_key_update,
handle_users_me_api_keys_get, handle_users_me_available_models,
handle_users_me_delete_other_sessions, handle_users_me_delete_session,
handle_users_me_detail_put, handle_users_me_endpoint_status_get,
handle_users_me_management_token_create, handle_users_me_management_token_delete,
handle_users_me_management_token_detail_get, handle_users_me_management_token_regenerate,
handle_users_me_management_token_toggle, handle_users_me_management_token_update,
handle_users_me_management_tokens_list, handle_users_me_model_capabilities_get,
handle_users_me_model_capabilities_put, handle_users_me_password_patch,
handle_users_me_preferences_get, handle_users_me_preferences_put,
handle_users_me_providers_get, handle_users_me_referral_get, handle_users_me_sessions_get,
handle_users_me_update_session, handle_users_me_usage_active_get, handle_users_me_usage_get,
handle_users_me_usage_heatmap_get, handle_users_me_usage_interval_timeline_get,
users_me_api_key_capabilities_path_matches, users_me_api_key_detail_path_matches,
users_me_api_key_install_sessions_path_matches, users_me_api_key_providers_path_matches,
users_me_management_token_detail_path_matches,
handle_users_me_client_config_get, handle_users_me_delete_other_sessions,
handle_users_me_delete_session, handle_users_me_detail_put,
handle_users_me_endpoint_status_get, handle_users_me_management_token_create,
handle_users_me_management_token_delete, handle_users_me_management_token_detail_get,
handle_users_me_management_token_regenerate, handle_users_me_management_token_toggle,
handle_users_me_management_token_update, handle_users_me_management_tokens_list,
handle_users_me_model_capabilities_get, handle_users_me_model_capabilities_put,
handle_users_me_password_patch, handle_users_me_preferences_get,
handle_users_me_preferences_put, handle_users_me_providers_get, handle_users_me_referral_get,
handle_users_me_sessions_get, handle_users_me_update_session, handle_users_me_usage_active_get,
handle_users_me_usage_get, handle_users_me_usage_heatmap_get,
handle_users_me_usage_interval_timeline_get, users_me_api_key_capabilities_path_matches,
users_me_api_key_detail_path_matches, users_me_api_key_install_sessions_path_matches,
users_me_api_key_providers_path_matches, users_me_management_token_detail_path_matches,
users_me_management_token_regenerate_path_matches,
users_me_management_token_toggle_path_matches, users_me_management_tokens_root,
users_me_session_detail_path_matches, AppState, GatewayPublicRequestContext,
@@ -220,6 +220,9 @@ pub(crate) async fn maybe_build_local_users_me_response(
{
Some(handle_users_me_available_models(state, request_context, headers).await)
}
Some("client_config") if request_context.request_path == "/api/users/me/client-config" => {
Some(handle_users_me_client_config_get(state, request_context, headers).await)
}
Some("model_capabilities")
if request_context.request_path == "/api/users/me/model-capabilities" =>
{
@@ -28,12 +28,16 @@ mod redeem;
#[path = "wallet/refunds.rs"]
mod refunds;
use self::flow::handle_wallet_flow;
pub(in crate::handlers::public::support) use self::reads::build_wallet_balance_payload_for_user;
pub(in crate::handlers::public::support) use self::reads::{
build_wallet_balance_payload_for_auth_scope, build_wallet_balance_payload_for_user,
build_wallet_live_today_usage_payload_for_api_key,
build_wallet_live_today_usage_payload_for_user,
};
use self::reads::{
build_wallet_daily_usage_payload, build_wallet_live_today_usage_payload_for_user,
build_wallet_payload, build_wallet_zero_today_entry, handle_wallet_balance,
handle_wallet_today_cost, handle_wallet_transactions, parse_wallet_limit, parse_wallet_offset,
wallet_fixed_offset, wallet_transaction_payload_from_record,
build_wallet_daily_usage_payload, build_wallet_payload, build_wallet_zero_today_entry,
handle_wallet_balance, handle_wallet_today_cost, handle_wallet_transactions,
parse_wallet_limit, parse_wallet_offset, wallet_fixed_offset,
wallet_transaction_payload_from_record,
};
pub(crate) use self::recharge::{direct_gateway_channels, sanitize_wallet_gateway_response};
use self::recharge::{
@@ -53,16 +53,41 @@ pub(in crate::handlers::public::support) async fn build_wallet_balance_payload_f
state: &AppState,
user_id: &str,
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
) -> serde_json::Value {
build_wallet_balance_payload_for_quota_user(state, Some(user_id), wallet).await
}
pub(in crate::handlers::public::support) async fn build_wallet_balance_payload_for_auth_scope(
state: &AppState,
user_id: &str,
api_key_is_standalone: bool,
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
) -> serde_json::Value {
let quota_user_id = if api_key_is_standalone {
None
} else {
Some(user_id)
};
build_wallet_balance_payload_for_quota_user(state, quota_user_id, wallet).await
}
async fn build_wallet_balance_payload_for_quota_user(
state: &AppState,
quota_user_id: Option<&str>,
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
) -> serde_json::Value {
let mut payload = build_wallet_balance_payload(wallet);
let wallet_balance = wallet
.map(|value| value.balance + value.gift_balance)
.unwrap_or(0.0);
let daily_quota = state
.find_user_daily_quota_availability(user_id)
.await
.ok()
.flatten();
let daily_quota = match quota_user_id {
Some(user_id) => state
.find_user_daily_quota_availability(user_id)
.await
.ok()
.flatten(),
None => None,
};
let (has_active_daily_quota, total_quota_usd, used_usd, remaining_usd, allow_wallet_overage) =
daily_quota
.map(|quota| {
@@ -213,9 +238,10 @@ pub(super) fn build_wallet_zero_today_entry() -> serde_json::Value {
)
}
pub(super) async fn build_wallet_live_today_usage_payload_for_user(
async fn build_wallet_live_today_usage_payload_for_auth_scope(
state: &AppState,
user_id: &str,
user_id: Option<&str>,
api_key_id: Option<&str>,
) -> Result<Option<serde_json::Value>, String> {
if !state.has_usage_data_reader() {
return Ok(None);
@@ -225,7 +251,8 @@ pub(super) async fn build_wallet_live_today_usage_payload_for_user(
.summarize_usage_settled_cost(&UsageSettledCostSummaryQuery {
created_from_unix_secs: start_unix_secs,
created_until_unix_secs: end_unix_secs,
user_id: Some(user_id.to_string()),
user_id: user_id.map(ToOwned::to_owned),
api_key_id: api_key_id.map(ToOwned::to_owned),
})
.await
.map_err(|err| format!("wallet today cost lookup failed: {err:?}"))?;
@@ -250,6 +277,20 @@ pub(super) async fn build_wallet_live_today_usage_payload_for_user(
)))
}
pub(in crate::handlers::public::support) async fn build_wallet_live_today_usage_payload_for_user(
state: &AppState,
user_id: &str,
) -> Result<Option<serde_json::Value>, String> {
build_wallet_live_today_usage_payload_for_auth_scope(state, Some(user_id), None).await
}
pub(in crate::handlers::public::support) async fn build_wallet_live_today_usage_payload_for_api_key(
state: &AppState,
api_key_id: &str,
) -> Result<Option<serde_json::Value>, String> {
build_wallet_live_today_usage_payload_for_auth_scope(state, None, Some(api_key_id)).await
}
pub(super) fn wallet_transaction_payload_from_record(
record: &aether_data::repository::wallet::StoredAdminWalletTransaction,
) -> serde_json::Value {
@@ -1,7 +1,7 @@
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::{
sample_endpoint, sample_key, sample_models_candidate_row, sample_provider,
hash_api_key, sample_endpoint, sample_key, sample_models_candidate_row, sample_provider,
sample_public_catalog_model, sample_public_global_model,
sample_public_global_model_with_capabilities, sample_request_candidate,
InMemoryAnnouncementReadRepository, InMemoryGlobalModelReadRepository,
@@ -2372,6 +2372,28 @@ fn sample_auth_wallet(user_id: &str, now: chrono::DateTime<chrono::Utc>) -> Stor
.expect("wallet should build")
}
fn sample_standalone_auth_wallet(
api_key_id: &str,
now: chrono::DateTime<chrono::Utc>,
) -> StoredWalletSnapshot {
StoredWalletSnapshot::new(
"wallet-standalone-1".to_string(),
None,
Some(api_key_id.to_string()),
2.0,
0.5,
"finite".to_string(),
"USD".to_string(),
"active".to_string(),
5.0,
2.5,
0.0,
0.0,
now.timestamp(),
)
.expect("standalone wallet should build")
}
fn wallet_today_usage_test_time() -> chrono::DateTime<chrono::Utc> {
let offset =
chrono::FixedOffset::east_opt(8 * 3600).expect("Asia/Shanghai test offset should be valid");
@@ -4389,6 +4411,147 @@ async fn gateway_handles_wallet_balance_locally_without_proxying_upstream() {
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_ccswitch_usage_with_api_key_without_proxying_upstream() {
let now = wallet_today_usage_test_time();
let user = sample_auth_user(now);
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
sample_user_usage_audit(
"usage-ccswitch-1",
"req-ccswitch-1",
"user-auth-1",
"gpt-5",
"Aether",
"completed",
now,
),
]));
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-ccswitch-usage")),
sample_usage_auth_snapshot("api-key-user-1", "user-auth-1", "ccswitch"),
)]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = GatewayDataState::with_user_wallet_and_usage_for_tests(
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
user.clone()
])),
Arc::new(InMemoryWalletRepository::seed(vec![sample_auth_wallet(
"user-auth-1",
now,
)])),
Arc::clone(&usage_repository),
)
.with_auth_api_key_reader(auth_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/ccswitch/usage"))
.header("authorization", "Bearer sk-ccswitch-usage")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["is_valid"], true);
assert_eq!(payload["plan_name"], "Aether");
assert_eq!(payload["remaining"], 15.5);
assert_eq!(payload["used"], 1.25);
assert_eq!(payload["unit"], "USD");
assert_eq!(payload["wallet"]["wallet_balance"], 15.5);
assert_eq!(payload["today"]["total_requests"], 1);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_ccswitch_usage_for_standalone_key_without_owner_usage() {
let now = wallet_today_usage_test_time();
let user = sample_auth_user(now);
let mut owner_usage = sample_user_usage_audit(
"usage-ccswitch-owner",
"req-ccswitch-owner",
"user-auth-1",
"gpt-5",
"Aether",
"completed",
now,
);
owner_usage.api_key_id = Some("api-key-user-1".to_string());
owner_usage.total_cost_usd = 1.25;
owner_usage.actual_total_cost_usd = 1.25;
let mut standalone_usage = sample_user_usage_audit(
"usage-ccswitch-standalone",
"req-ccswitch-standalone",
"user-auth-1",
"gpt-5",
"Aether",
"completed",
now,
);
standalone_usage.api_key_id = Some("api-key-standalone-1".to_string());
standalone_usage.api_key_name = Some("standalone".to_string());
standalone_usage.total_cost_usd = 0.5;
standalone_usage.actual_total_cost_usd = 0.5;
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
owner_usage,
standalone_usage,
]));
let mut snapshot =
sample_usage_auth_snapshot("api-key-standalone-1", "user-auth-1", "standalone");
snapshot.api_key_is_standalone = true;
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-ccswitch-standalone")),
snapshot,
)]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = GatewayDataState::with_user_wallet_and_usage_for_tests(
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
user.clone()
])),
Arc::new(InMemoryWalletRepository::seed(vec![
sample_auth_wallet("user-auth-1", now),
sample_standalone_auth_wallet("api-key-standalone-1", now),
])),
Arc::clone(&usage_repository),
)
.with_auth_api_key_reader(auth_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/ccswitch/usage"))
.header("authorization", "Bearer sk-ccswitch-standalone")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["is_valid"], true);
assert_eq!(payload["remaining"], 2.5);
assert_eq!(payload["used"], 0.5);
assert_eq!(payload["wallet"]["wallet"]["id"], "wallet-standalone-1");
assert_eq!(payload["today"]["total_requests"], 1);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_wallet_today_cost_locally_without_proxying_upstream() {
let auth_now = Utc::now();
@@ -6403,6 +6566,70 @@ async fn gateway_handles_users_me_api_keys_locally_without_proxying_upstream() {
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_client_config_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let _public_base_url_guard =
set_test_env_var("AETHER_PUBLIC_BASE_URL", "https://aether.example.com/");
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-client-config"),
),
]),
now + chrono::Duration::hours(1),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository)
.with_system_config_values_for_tests(vec![(
"site_name".to_string(),
json!("Aether Local"),
)]);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-client-config",
"device-users-me-client-config",
"refresh-token-users-me-client-config",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/client-config"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-client-config")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["base_url"], "https://aether.example.com");
assert_eq!(payload["site_name"], "Aether Local");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_invalid_users_me_api_key_detail_path_as_local_not_found_without_hitting_upstream(
) {