mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Merge remote-tracking branch 'entropy-xu/codex/ccswitch-import'
This commit is contained in:
@@ -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(
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user