fix(public): 修复用户可见性、额度、验证与 Codex 探测

This commit is contained in:
Entropy.Xu
2026-05-16 00:51:44 +08:00
parent 8eb4c029b2
commit bbd3c30b0e
50 changed files with 2321 additions and 347 deletions

View File

@@ -17,6 +17,13 @@ use aether_data::repository::auth::{
read_resolved_auth_api_key_snapshot_by_user_api_key_ids,
};
#[derive(Debug, Clone, Default)]
pub(crate) struct GatewayUserEffectiveListPolicies {
pub(crate) allowed_providers: Option<Vec<String>>,
pub(crate) allowed_api_formats: Option<Vec<String>>,
pub(crate) allowed_models: Option<Vec<String>>,
}
impl GatewayDataState {
pub(crate) async fn is_other_user_auth_email_taken(
&self,
@@ -1732,29 +1739,9 @@ impl GatewayDataState {
apply_admin_unrestricted_auth_snapshot(&mut snapshot);
return Ok(Some(snapshot));
}
let mut groups = repository
.list_user_groups_for_user(&snapshot.user_id)
let groups = self
.effective_user_groups_for_user(&snapshot.user_id)
.await?;
let dynamic_group_ids = self
.active_membership_group_ids_for_user(&snapshot.user_id)
.await?;
if !dynamic_group_ids.is_empty() {
groups.extend(
repository
.list_user_groups_by_ids(&dynamic_group_ids)
.await?,
);
let mut deduped = std::collections::BTreeMap::new();
for group in groups {
deduped.insert(group.id.clone(), group);
}
groups = deduped.into_values().collect();
}
groups.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
let mut allowed_providers =
resolve_effective_list_policy(None, "unrestricted", &groups, |group| {
@@ -1798,6 +1785,80 @@ impl GatewayDataState {
Ok(Some(snapshot))
}
pub(crate) async fn resolve_user_effective_list_policies(
&self,
user: &StoredUserAuthRecord,
) -> Result<GatewayUserEffectiveListPolicies, DataLayerError> {
if user.role.eq_ignore_ascii_case("admin") {
return Ok(GatewayUserEffectiveListPolicies::default());
}
let groups = if self.user_reader.is_some() {
self.effective_user_groups_for_user(&user.id).await?
} else {
Vec::new()
};
Ok(GatewayUserEffectiveListPolicies {
allowed_providers: resolve_effective_list_policy(
user.allowed_providers.clone(),
&user.allowed_providers_mode,
&groups,
|group| {
(
&group.allowed_providers_mode,
group.allowed_providers.clone(),
)
},
),
allowed_api_formats: resolve_effective_list_policy(
user.allowed_api_formats.clone(),
&user.allowed_api_formats_mode,
&groups,
|group| {
(
&group.allowed_api_formats_mode,
group.allowed_api_formats.clone(),
)
},
),
allowed_models: resolve_effective_list_policy(
user.allowed_models.clone(),
&user.allowed_models_mode,
&groups,
|group| (&group.allowed_models_mode, group.allowed_models.clone()),
),
})
}
async fn effective_user_groups_for_user(
&self,
user_id: &str,
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, DataLayerError> {
let Some(repository) = self.user_reader.as_ref() else {
return Ok(Vec::new());
};
let mut groups = repository.list_user_groups_for_user(user_id).await?;
let dynamic_group_ids = self.active_membership_group_ids_for_user(user_id).await?;
if !dynamic_group_ids.is_empty() {
groups.extend(
repository
.list_user_groups_by_ids(&dynamic_group_ids)
.await?,
);
let mut deduped = std::collections::BTreeMap::new();
for group in groups {
deduped.insert(group.id.clone(), group);
}
groups = deduped.into_values().collect();
}
groups.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
Ok(groups)
}
async fn active_membership_group_ids_for_user(
&self,
user_id: &str,

View File

@@ -8,8 +8,7 @@ use self::invalid::{
codex_structured_invalid_reason,
};
use self::parse::{
build_codex_quota_exhausted_fallback_metadata, parse_codex_usage_headers,
parse_codex_wham_usage_response,
parse_codex_backend_me_response, parse_codex_usage_headers, parse_codex_wham_usage_response,
};
use self::plan::{build_codex_quota_request_spec, execute_codex_quota_plan};
use super::shared::{
@@ -111,7 +110,7 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": format!("wham/usage 请求执行失败: {detail}"),
"message": format!("backend-api/me 请求执行失败: {detail}"),
"status_code": 502,
}));
continue;
@@ -138,7 +137,9 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
.as_ref()
.and_then(|body| body.json_body.as_ref())
{
if let Some(parsed) = parse_codex_wham_usage_response(body_json, now_unix_secs) {
if let Some(parsed) = parse_codex_backend_me_response(body_json, now_unix_secs)
.or_else(|| parse_codex_wham_usage_response(body_json, now_unix_secs))
{
metadata_update = Some(json!({
"codex": merge_codex_quota_metadata(header_metadata.as_ref(), &parsed)
}));
@@ -151,21 +152,21 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
status = "success".to_string();
} else {
status = "no_metadata".to_string();
message = Some("响应中未包含限额信息".to_string());
message = Some("backend-api/me 响应中未包含账号信息".to_string());
}
} else {
message = Some("无法解析 wham/usage API 响应".to_string());
message = Some("无法解析 backend-api/me API 响应".to_string());
}
} else {
let err_msg = extract_execution_error_message(&result);
message = Some(match err_msg.as_deref() {
Some(detail) if !detail.is_empty() => {
format!(
"wham/usage API 返回状态码 {}: {}",
"backend-api/me API 返回状态码 {}: {}",
result.status_code, detail
)
}
_ => format!("wham/usage API 返回状态码 {}", result.status_code),
_ => format!("backend-api/me API 返回状态码 {}", result.status_code),
});
match result.status_code {
@@ -222,26 +223,14 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
oauth_invalid_reason = reason;
status = "workspace_deactivated".to_string();
} else {
let plan_type = transport
.key
.decrypted_auth_config
.as_deref()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok())
.and_then(|value| {
value
.get("plan_type")
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned)
});
metadata_update = Some(json!({
"codex": build_codex_quota_exhausted_fallback_metadata(
plan_type.as_deref(),
now_unix_secs,
)
}));
(oauth_invalid_at_unix_secs, oauth_invalid_reason) =
quota_refresh_success_invalid_state(&key);
status = "quota_exhausted".to_string();
let (at, reason) = codex_build_invalid_state(
&key,
codex_structured_invalid_reason(402, err_msg.as_deref()),
now_unix_secs,
);
oauth_invalid_at_unix_secs = at;
oauth_invalid_reason = reason;
status = "payment_required".to_string();
}
}
403 => {

View File

@@ -22,6 +22,13 @@ pub(super) fn parse_codex_wham_usage_response(
admin_provider_quota_pure::parse_codex_wham_usage_response(value, updated_at_unix_secs)
}
pub(super) fn parse_codex_backend_me_response(
value: &serde_json::Value,
updated_at_unix_secs: u64,
) -> Option<serde_json::Value> {
admin_provider_quota_pure::parse_codex_backend_me_response(value, updated_at_unix_secs)
}
pub(super) fn parse_codex_usage_headers(
headers: &BTreeMap<String, String>,
updated_at_unix_secs: u64,

View File

@@ -226,7 +226,7 @@ pub(crate) struct AdminProviderUpdateRequest {
pub(crate) type AdminProviderUpdatePatch = AdminTypedObjectPatch<AdminProviderUpdateRequest>;
pub(crate) const CODEX_WHAM_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
pub(crate) const CODEX_WHAM_USAGE_URL: &str = "https://chatgpt.com/backend-api/me";
pub(crate) const KIRO_USAGE_LIMITS_PATH: &str = "/getUsageLimits";
pub(crate) const KIRO_USAGE_SDK_VERSION: &str = "1.0.0";
pub(crate) const ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH: &str = "/v1internal:fetchAvailableModels";

View File

@@ -50,6 +50,28 @@ pub(crate) fn normalize_admin_base_url(base_url: &str) -> Result<String, String>
Ok(normalized.to_string())
}
pub(crate) fn sanitize_public_model_config_for_user(
config: Option<serde_json::Value>,
) -> Option<serde_json::Value> {
let Some(mut config) = config else {
return None;
};
if let Some(object) = config.as_object_mut() {
for key in [
"model_mappings",
"model_mapping",
"global_model_mappings",
"provider_model_mappings",
"provider_model_aliases",
"mapping_preview",
"model_mapping_preview",
] {
object.remove(key);
}
}
Some(config)
}
pub(crate) fn admin_requested_force_stream(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Bool(value) => *value,
@@ -165,21 +187,12 @@ pub(crate) async fn build_public_providers_payload(
.into_iter()
.map(|provider| {
let provider_id = provider.id.clone();
let description = provider
.config
.as_ref()
.and_then(|value| value.get("description"))
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned);
let model_count = models_by_provider
.get(&provider_id)
.map(BTreeSet::len)
.unwrap_or(0);
json!({
"id": provider_id.clone(),
"name": provider.name,
"description": description,
"website": provider.website,
"is_active": provider.is_active,
"provider_priority": provider.provider_priority,
"models_count": model_count,
@@ -196,8 +209,6 @@ pub(crate) async fn build_public_providers_payload(
fn serialize_public_catalog_model(model: StoredPublicCatalogModel) -> serde_json::Value {
json!({
"id": model.id,
"provider_id": model.provider_id,
"provider_name": model.provider_name,
"name": model.name,
"display_name": model.display_name,
"description": model.description,

View File

@@ -11,7 +11,7 @@ pub(crate) use self::catalog_helpers::{
build_public_catalog_models_payload, build_public_catalog_search_models_payload,
build_public_health_timeline, build_public_providers_payload, normalize_admin_base_url,
provider_key_api_formats, request_candidate_event_unix_ms, request_candidate_status_label,
ApiFormatHealthMonitorOptions,
sanitize_public_model_config_for_user, ApiFormatHealthMonitorOptions,
};
pub(crate) use self::system_modules_helpers::{
build_admin_keys_grouped_by_format_payload, build_public_auth_modules_status_payload,

View File

@@ -2,8 +2,8 @@ use super::{
build_api_format_health_monitor_payload, build_public_auth_modules_status_payload,
build_public_catalog_models_payload, build_public_catalog_search_models_payload,
build_public_providers_payload, capability_detail_by_name, ldap_module_config_is_valid,
serialize_public_capability, supported_capability_names, ApiFormatHealthMonitorOptions,
PUBLIC_CAPABILITY_DEFINITIONS,
sanitize_public_model_config_for_user, serialize_public_capability, supported_capability_names,
ApiFormatHealthMonitorOptions, PUBLIC_CAPABILITY_DEFINITIONS,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::shared::{
@@ -232,10 +232,17 @@ pub(crate) async fn maybe_build_local_public_support_response(
.flatten()
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.unwrap_or_else(|| "AI Gateway".to_string());
let show_github_link = state
.read_system_config_json_value("show_github_link")
.await
.ok()
.flatten();
let show_github_link = system_config_bool(show_github_link.as_ref(), true);
return Some(
Json(json!({
"site_name": site_name,
"site_subtitle": site_subtitle,
"show_github_link": show_github_link,
}))
.into_response(),
);
@@ -374,7 +381,7 @@ pub(crate) async fn maybe_build_local_public_support_response(
"default_price_per_request": model.default_price_per_request,
"default_tiered_pricing": model.default_tiered_pricing,
"supported_capabilities": model.supported_capabilities,
"config": model.config,
"config": sanitize_public_model_config_for_user(model.config),
"usage_count": model.usage_count,
})
})
@@ -576,15 +583,11 @@ pub(crate) async fn maybe_build_local_public_support_response(
.await
.ok()
.unwrap_or_default();
let current_provider = providers
.first()
.map(|provider| provider.name.clone())
.unwrap_or_else(|| "None".to_string());
return Some(
Json(json!({
"message": "AI Proxy with Modular Architecture v4.0.0",
"status": "running",
"current_provider": current_provider,
"current_provider": serde_json::Value::Null,
"available_providers": providers.len(),
"config": {},
"endpoints": {
@@ -647,10 +650,8 @@ pub(crate) async fn maybe_build_local_public_support_response(
.into_iter()
.map(|provider| {
let provider_id = provider.id.clone();
let provider_name = provider.name.clone();
let mut payload = json!({
"id": provider_id.clone(),
"name": provider_name,
"is_active": provider.is_active,
"provider_priority": provider.provider_priority,
});
@@ -661,7 +662,6 @@ pub(crate) async fn maybe_build_local_public_support_response(
.filter(|endpoint| endpoint.provider_id == provider_id)
.map(|endpoint| json!({
"id": endpoint.id,
"base_url": endpoint.base_url,
"api_format": endpoint.api_format,
"is_active": endpoint.is_active,
}))
@@ -709,19 +709,19 @@ pub(crate) async fn maybe_build_local_public_support_response(
};
let provider = match provider {
Some(provider) => provider,
None => state
.list_provider_catalog_providers(false)
.await
.ok()
.unwrap_or_default()
.into_iter()
.find(|provider| provider.name == provider_identifier)?,
None => {
return Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider not found" })),
)
.into_response(),
);
}
};
let provider_id = provider.id.clone();
let provider_name = provider.name.clone();
let mut payload = json!({
"id": provider_id.clone(),
"name": provider_name,
"is_active": provider.is_active,
"provider_priority": provider.provider_priority,
});
@@ -739,7 +739,6 @@ pub(crate) async fn maybe_build_local_public_support_response(
.map(|endpoint| {
json!({
"id": endpoint.id,
"base_url": endpoint.base_url,
"api_format": endpoint.api_format,
"is_active": endpoint.is_active,
})

View File

@@ -18,6 +18,10 @@ pub(super) use serde_json::json;
mod auth_helpers;
pub(crate) use auth_helpers::*;
#[path = "auth_turnstile.rs"]
mod auth_turnstile;
use auth_turnstile::*;
#[path = "auth_email.rs"]
mod auth_email;
use auth_email::*;

View File

@@ -1,7 +1,7 @@
use super::{
http, json, ldap_module_config_is_valid, module_available_from_env, system_config_bool,
system_config_string, AppState, Body, GatewayError, GatewayPublicRequestContext, IntoResponse,
Json, Response,
auth_turnstile_public_settings, http, json, ldap_module_config_is_valid,
module_available_from_env, system_config_bool, system_config_string, AppState, Body,
GatewayError, GatewayPublicRequestContext, IntoResponse, Json, Response,
};
pub(crate) async fn build_auth_registration_settings_payload(
@@ -40,12 +40,17 @@ pub(crate) async fn build_auth_registration_settings_payload(
Some(value) if matches!(value.as_str(), "weak" | "medium" | "strong") => value,
_ => "weak".to_string(),
};
let turnstile_settings = auth_turnstile_public_settings(state)
.await
.map_err(|err| GatewayError::Internal(err.detail))?;
Ok(json!({
"enable_registration": enable_registration,
"require_email_verification": require_email_verification,
"email_configured": email_configured,
"password_policy_level": password_policy_level,
"turnstile_enabled": turnstile_settings.enabled,
"turnstile_site_key": turnstile_settings.site_key,
}))
}

View File

@@ -5,7 +5,8 @@ use super::{
clear_auth_email_pending_code, clear_auth_email_verification, generate_auth_verification_code,
http, json, mark_auth_email_verified, read_auth_email_verification_code, read_auth_smtp_config,
send_auth_email, store_auth_email_verification_code, system_config_bool, system_config_f64,
system_config_string, system_config_string_list, AppState, Body, GatewayError, Regex, Response,
system_config_string, system_config_string_list, verify_auth_turnstile_token, AppState, Body,
GatewayError, Regex, Response,
};
use serde::Deserialize;
@@ -16,11 +17,13 @@ struct AuthRegisterRequest {
email: Option<String>,
username: String,
password: String,
turnstile_token: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AuthEmailRequest {
email: String,
turnstile_token: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -195,20 +198,6 @@ pub(super) async fn handle_auth_send_verification_code(
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "邮箱格式无效", false);
};
if state
.find_user_auth_by_identifier(&email)
.await
.ok()
.flatten()
.is_some()
{
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"该邮箱已被注册,请直接登录或使用其他邮箱",
false,
);
}
match validate_auth_email_suffix(state, &email).await {
Ok(Ok(())) => {}
Ok(Err(detail)) => {
@@ -223,6 +212,26 @@ pub(super) async fn handle_auth_send_verification_code(
}
}
if let Err(err) =
verify_auth_turnstile_token(state, payload.turnstile_token.as_deref(), None).await
{
return build_auth_error_response(err.status, err.detail, false);
}
if state
.find_user_auth_by_identifier(&email)
.await
.ok()
.flatten()
.is_some()
{
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"该邮箱已被注册,请直接登录或使用其他邮箱",
false,
);
}
let smtp_config = match read_auth_smtp_config(state).await {
Ok(Some(value)) => value,
Ok(None) => {
@@ -421,6 +430,10 @@ pub(super) async fn handle_auth_register(
);
}
}
} else if let Err(err) =
verify_auth_turnstile_token(state, payload.turnstile_token.as_deref(), None).await
{
return build_auth_error_response(err.status, err.detail, false);
}
if let Some(email) = email.as_deref() {
match validate_auth_email_suffix(state, email).await {

View File

@@ -0,0 +1,209 @@
use super::{
decrypt_catalog_secret_with_fallbacks, http, system_config_bool, system_config_string,
system_config_string_list, AppState,
};
use serde::Deserialize;
use std::time::Duration;
const TURNSTILE_SITEVERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
#[derive(Debug, Clone)]
pub(super) struct AuthTurnstilePublicSettings {
pub(super) enabled: bool,
pub(super) site_key: Option<String>,
}
#[derive(Debug)]
pub(super) struct AuthTurnstileError {
pub(super) status: http::StatusCode,
pub(super) detail: String,
}
#[derive(Debug)]
struct AuthTurnstileConfig {
enabled: bool,
site_key: Option<String>,
secret_key: Option<String>,
siteverify_url: String,
allowed_hostnames: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct TurnstileSiteverifyResponse {
success: bool,
hostname: Option<String>,
#[serde(default, rename = "error-codes")]
error_codes: Vec<String>,
}
fn turnstile_error(status: http::StatusCode, detail: impl Into<String>) -> AuthTurnstileError {
AuthTurnstileError {
status,
detail: detail.into(),
}
}
fn turnstile_config_error(detail: impl Into<String>) -> AuthTurnstileError {
turnstile_error(http::StatusCode::INTERNAL_SERVER_ERROR, detail)
}
async fn read_auth_turnstile_config(
state: &AppState,
) -> Result<AuthTurnstileConfig, AuthTurnstileError> {
let enabled = state
.read_system_config_json_value("turnstile_enabled")
.await
.map_err(|err| {
turnstile_config_error(format!("auth turnstile settings lookup failed: {err:?}"))
})?;
let site_key = state
.read_system_config_json_value("turnstile_site_key")
.await
.map_err(|err| {
turnstile_config_error(format!("auth turnstile settings lookup failed: {err:?}"))
})?;
let secret_key = state
.read_system_config_json_value("turnstile_secret_key")
.await
.map_err(|err| {
turnstile_config_error(format!("auth turnstile settings lookup failed: {err:?}"))
})?;
let siteverify_url = state
.read_system_config_json_value("turnstile_siteverify_url")
.await
.map_err(|err| {
turnstile_config_error(format!("auth turnstile settings lookup failed: {err:?}"))
})?;
let allowed_hostnames = state
.read_system_config_json_value("turnstile_allowed_hostnames")
.await
.map_err(|err| {
turnstile_config_error(format!("auth turnstile settings lookup failed: {err:?}"))
})?;
let secret_key = system_config_string(secret_key.as_ref()).map(|value| {
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), &value).unwrap_or(value)
});
Ok(AuthTurnstileConfig {
enabled: system_config_bool(enabled.as_ref(), false),
site_key: system_config_string(site_key.as_ref()),
secret_key,
siteverify_url: system_config_string(siteverify_url.as_ref())
.unwrap_or_else(|| TURNSTILE_SITEVERIFY_URL.to_string()),
allowed_hostnames: system_config_string_list(allowed_hostnames.as_ref()),
})
}
pub(super) async fn auth_turnstile_public_settings(
state: &AppState,
) -> Result<AuthTurnstilePublicSettings, AuthTurnstileError> {
let config = read_auth_turnstile_config(state).await?;
let enabled = config.enabled && config.site_key.is_some();
Ok(AuthTurnstilePublicSettings {
enabled,
site_key: enabled.then_some(config.site_key).flatten(),
})
}
fn turnstile_service_error(error_codes: &[String]) -> bool {
error_codes.iter().any(|code| {
matches!(
code.trim(),
"missing-input-secret" | "invalid-input-secret" | "internal-error"
)
})
}
fn turnstile_hostname_allowed(hostname: Option<&str>, allowed_hostnames: &[String]) -> bool {
if allowed_hostnames.is_empty() {
return true;
}
let Some(hostname) = hostname.map(str::trim).filter(|value| !value.is_empty()) else {
return false;
};
let hostname = hostname.to_ascii_lowercase();
allowed_hostnames.iter().any(|allowed| allowed == &hostname)
}
pub(super) async fn verify_auth_turnstile_token(
state: &AppState,
token: Option<&str>,
remote_ip: Option<&str>,
) -> Result<(), AuthTurnstileError> {
let config = read_auth_turnstile_config(state).await?;
if !config.enabled || config.site_key.is_none() {
return Ok(());
}
let token = token
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| turnstile_error(http::StatusCode::BAD_REQUEST, "请先完成人机验证"))?;
let secret_key = config.secret_key.as_deref().ok_or_else(|| {
turnstile_error(http::StatusCode::SERVICE_UNAVAILABLE, "人机验证服务未配置")
})?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.build()
.map_err(|err| {
turnstile_error(
http::StatusCode::SERVICE_UNAVAILABLE,
format!("人机验证服务暂不可用: {err}"),
)
})?;
let mut form = vec![
("secret", secret_key.to_string()),
("response", token.to_string()),
];
if let Some(remote_ip) = remote_ip.map(str::trim).filter(|value| !value.is_empty()) {
form.push(("remoteip", remote_ip.to_string()));
}
let response = client
.post(config.siteverify_url)
.form(&form)
.send()
.await
.map_err(|_| {
turnstile_error(
http::StatusCode::SERVICE_UNAVAILABLE,
"人机验证服务暂不可用",
)
})?;
if !response.status().is_success() {
return Err(turnstile_error(
http::StatusCode::SERVICE_UNAVAILABLE,
"人机验证服务暂不可用",
));
}
let payload = response
.json::<TurnstileSiteverifyResponse>()
.await
.map_err(|_| {
turnstile_error(
http::StatusCode::SERVICE_UNAVAILABLE,
"人机验证服务暂不可用",
)
})?;
if payload.success {
if turnstile_hostname_allowed(payload.hostname.as_deref(), &config.allowed_hostnames) {
return Ok(());
}
return Err(turnstile_error(
http::StatusCode::BAD_REQUEST,
"人机验证失败,请重试",
));
}
if turnstile_service_error(&payload.error_codes) {
return Err(turnstile_error(
http::StatusCode::SERVICE_UNAVAILABLE,
"人机验证服务暂不可用",
));
}
Err(turnstile_error(
http::StatusCode::BAD_REQUEST,
"人机验证失败,请重试",
))
}

View File

@@ -1,3 +1,4 @@
use super::super::support_wallet::build_wallet_balance_payload_for_user;
use super::{
build_auth_error_response, query_param_value, resolve_authenticated_local_user, AppState,
GatewayError, GatewayPublicRequestContext,
@@ -180,6 +181,35 @@ fn dashboard_format_usd(value: f64) -> String {
format!("${:.2}", dashboard_round_f64(value, 2))
}
fn dashboard_json_f64(value: Option<&serde_json::Value>) -> f64 {
value.and_then(serde_json::Value::as_f64).unwrap_or(0.0)
}
fn dashboard_wallet_card_value_and_subvalue(
wallet_payload: &serde_json::Value,
) -> (String, String) {
let unlimited = wallet_payload
.get("unlimited")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if unlimited {
return ("无限额度".to_string(), "无限额度".to_string());
}
let package_balance = dashboard_json_f64(wallet_payload.get("package_balance")).max(0.0);
let wallet_balance = dashboard_json_f64(wallet_payload.get("wallet_balance")).max(0.0);
let total_available =
dashboard_json_f64(wallet_payload.get("total_available_balance")).max(0.0);
(
dashboard_format_usd(total_available),
format!(
"套餐额度 {} · 钱包余额 {}",
dashboard_format_usd(package_balance),
dashboard_format_usd(wallet_balance)
),
)
}
fn dashboard_format_percentage(value: f64) -> String {
format!("{:.1}%", dashboard_round_f64(value, 1))
}
@@ -1050,26 +1080,10 @@ pub(super) async fn handle_dashboard_stats_get(
} else {
None
};
let wallet_value = wallet
.as_ref()
.map(|wallet| {
if wallet.limit_mode.eq_ignore_ascii_case("unlimited") {
"无限制".to_string()
} else {
dashboard_format_usd(wallet.balance)
}
})
.unwrap_or_else(|| dashboard_format_usd(0.0));
let wallet_sub_value = wallet
.as_ref()
.map(|wallet| {
if wallet.limit_mode.eq_ignore_ascii_case("unlimited") {
"无限额度".to_string()
} else {
format!("赠款 {}", dashboard_format_usd(wallet.gift_balance))
}
})
.unwrap_or_else(|| "暂无钱包".to_string());
let wallet_payload =
build_wallet_balance_payload_for_user(state, &auth.user.id, wallet.as_ref()).await;
let (wallet_value, wallet_sub_value) =
dashboard_wallet_card_value_and_subvalue(&wallet_payload);
let payload = json!({
"stats": [
{
@@ -1305,6 +1319,19 @@ pub(super) async fn handle_dashboard_provider_status_get(
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Response<Body> {
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
Ok(value) => value,
Err(response) => return response,
};
if !dashboard_role_is_admin(&auth.user.role) {
return build_auth_error_response(
http::StatusCode::FORBIDDEN,
"仅管理员可查看供应商状态",
false,
);
}
if !state.has_usage_data_reader() {
return dashboard_backend_unavailable_response("Usage data backend unavailable");
}
@@ -1312,16 +1339,7 @@ pub(super) async fn handle_dashboard_provider_status_get(
return dashboard_backend_unavailable_response("Provider catalog backend unavailable");
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
Ok(value) => value,
Err(response) => return response,
};
let cache_identity = if dashboard_role_is_admin(&auth.user.role) {
"admin"
} else {
auth.user.id.as_str()
};
let cache_identity = "admin";
let cache_key = format!("provider:{cache_identity}");
let cache_ttl = std::time::Duration::from_secs(20);
@@ -1398,11 +1416,7 @@ pub(super) async fn handle_dashboard_provider_status_get(
.cmp(right["name"].as_str().unwrap_or_default())
})
});
let limit = if dashboard_role_is_admin(&auth.user.role) {
10
} else {
5
};
let limit = 10;
if entries.len() > limit {
entries.truncate(limit);
}

View File

@@ -7,6 +7,8 @@ use axum::{
};
use serde_json::json;
const PUBLIC_MODELS_OWNER: &str = "aether";
pub(crate) fn build_models_auth_error_response(api_format: &str) -> Response<Body> {
match api_format {
"claude:messages" => (
@@ -109,7 +111,7 @@ pub(super) fn build_openai_models_list_response(
"id": row.global_model_name,
"object": "model",
"created": 0,
"owned_by": row.provider_name,
"owned_by": PUBLIC_MODELS_OWNER,
})
}).collect::<Vec<_>>(),
}))
@@ -123,7 +125,7 @@ pub(super) fn build_openai_model_detail_response(
"id": row.global_model_name,
"object": "model",
"created": 0,
"owned_by": row.provider_name,
"owned_by": PUBLIC_MODELS_OWNER,
}))
.into_response()
}

View File

@@ -338,7 +338,7 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
Some(
Json(json!({
"status": "success",
"provider": provider.name,
"provider_id": provider.id,
"endpoint_id": endpoint.id,
"api_format": format_value,
"timestamp": timestamp,

View File

@@ -8,12 +8,13 @@ pub(super) fn select_test_connection_provider(
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(provider_query) = provider_query {
if let Some(provider) = providers.iter().find(|provider| {
provider.id.eq_ignore_ascii_case(provider_query)
|| provider.name.eq_ignore_ascii_case(provider_query)
}) {
if let Some(provider) = providers
.iter()
.find(|provider| provider.id.eq_ignore_ascii_case(provider_query))
{
return Some(provider.clone());
}
return None;
}
providers.into_iter().next()
}

View File

@@ -2,7 +2,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,
handle_users_me_api_key_install_session_create, query_param_optional_bool, query_param_value,
resolve_authenticated_local_user, unix_secs_to_rfc3339,
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,
AuthenticatedLocalUserContext, GatewayPublicRequestContext, PUBLIC_CAPABILITY_DEFINITIONS,
};

View File

@@ -14,15 +14,23 @@ use serde_json::json;
use super::{
build_admin_endpoint_health_status_payload, build_auth_error_response, query_param_value,
resolve_authenticated_local_user, AppState, GatewayPublicRequestContext,
USERS_ME_AVAILABLE_MODELS_FETCH_LIMIT,
resolve_authenticated_local_user, sanitize_public_model_config_for_user, AppState,
GatewayPublicRequestContext, USERS_ME_AVAILABLE_MODELS_FETCH_LIMIT,
};
const USERS_ME_MODEL_CATALOG_UNAVAILABLE_DETAIL: &str = "用户模型目录暂不可用";
const USERS_ME_PROVIDER_CATALOG_UNAVAILABLE_DETAIL: &str = "用户提供商目录暂不可用";
const USERS_ME_ENDPOINT_STATUS_UNAVAILABLE_DETAIL: &str = "用户端点健康数据暂不可用";
fn build_users_me_available_model_payload(model: StoredPublicGlobalModel) -> serde_json::Value {
fn build_users_me_available_model_payload(
model: StoredPublicGlobalModel,
hide_mapping_config: bool,
) -> serde_json::Value {
let config = if hide_mapping_config {
sanitize_public_model_config_for_user(model.config)
} else {
model.config
};
json!({
"id": model.id,
"name": model.name,
@@ -31,7 +39,7 @@ fn build_users_me_available_model_payload(model: StoredPublicGlobalModel) -> ser
"default_price_per_request": model.default_price_per_request,
"default_tiered_pricing": model.default_tiered_pricing,
"supported_capabilities": model.supported_capabilities,
"config": model.config,
"config": config,
"usage_count": model.usage_count,
})
}
@@ -49,35 +57,27 @@ fn parse_users_me_available_models_query(query: Option<&str>) -> (usize, usize,
}
fn users_me_allowed_provider_names(
user: &aether_data::repository::users::StoredUserAuthRecord,
allowed_providers: Option<&[String]>,
) -> Option<BTreeSet<String>> {
if user.role.eq_ignore_ascii_case("admin") {
return None;
}
user.allowed_providers
.as_ref()
.map(|providers| {
providers
.iter()
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
.collect::<BTreeSet<_>>()
})
.filter(|providers| !providers.is_empty())
allowed_providers.map(|providers| {
providers
.iter()
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
.collect::<BTreeSet<_>>()
})
}
async fn resolve_users_me_allowed_global_model_ids(
state: &AppState,
user: &aether_data::repository::users::StoredUserAuthRecord,
allowed_providers: Option<&[String]>,
) -> Result<Option<BTreeSet<String>>, Response<Body>> {
let Some(allowed_providers) = user
.allowed_providers
.as_ref()
.filter(|providers| !providers.is_empty())
else {
let Some(allowed_providers) = allowed_providers else {
return Ok(None);
};
if allowed_providers.is_empty() {
return Ok(Some(BTreeSet::new()));
}
if !state.has_provider_catalog_data_reader() {
return Err(build_auth_error_response(
@@ -155,10 +155,35 @@ pub(super) async fn handle_users_me_available_models(
let (skip, limit, search) =
parse_users_me_available_models_query(request_context.request_query_string.as_deref());
let effective_policies = if auth.user.role.eq_ignore_ascii_case("admin") {
None
} else {
match state
.data
.resolve_user_effective_list_policies(&auth.user)
.await
{
Ok(value) => Some(value),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user policy lookup failed: {err:?}"),
false,
)
}
}
};
let provider_model_ids = if auth.user.role.eq_ignore_ascii_case("admin") {
None
} else {
match resolve_users_me_allowed_global_model_ids(state, &auth.user).await {
match resolve_users_me_allowed_global_model_ids(
state,
effective_policies
.as_ref()
.and_then(|policies| policies.allowed_providers.as_deref()),
)
.await
{
Ok(value) => value,
Err(response) => return response,
}
@@ -166,9 +191,9 @@ pub(super) async fn handle_users_me_available_models(
let allowed_models: Option<BTreeSet<String>> = if auth.user.role.eq_ignore_ascii_case("admin") {
None
} else {
auth.user
.allowed_models
effective_policies
.as_ref()
.and_then(|policies| policies.allowed_models.as_ref())
.map(|models: &Vec<String>| {
models
.iter()
@@ -176,9 +201,18 @@ pub(super) async fn handle_users_me_available_models(
.filter(|value: &String| !value.is_empty())
.collect::<BTreeSet<_>>()
})
.filter(|models: &BTreeSet<String>| !models.is_empty())
};
let hide_mapping_config = !auth.user.role.eq_ignore_ascii_case("admin");
let allowed_models: Option<BTreeSet<String>> = allowed_models.map(|models| {
models
.into_iter()
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
.collect::<BTreeSet<_>>()
});
let page = if provider_model_ids.is_none() && allowed_models.is_none() {
match state
.list_public_global_models(&PublicGlobalModelQuery {
@@ -247,7 +281,7 @@ pub(super) async fn handle_users_me_available_models(
"models": page
.items
.into_iter()
.map(build_users_me_available_model_payload)
.map(|model| build_users_me_available_model_payload(model, hide_mapping_config))
.collect::<Vec<_>>(),
"total": page.total,
}))
@@ -271,7 +305,26 @@ pub(super) async fn handle_users_me_providers_get(
Ok(value) => value,
Err(response) => return response,
};
let allowed_provider_names = users_me_allowed_provider_names(&auth.user);
let expose_provider_details = auth.user.role.eq_ignore_ascii_case("admin");
let allowed_provider_names = if expose_provider_details {
None
} else {
let effective_policies = match state
.data
.resolve_user_effective_list_policies(&auth.user)
.await
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user policy lookup failed: {err:?}"),
false,
)
}
};
users_me_allowed_provider_names(effective_policies.allowed_providers.as_deref())
};
let mut providers = match state.list_provider_catalog_providers(true).await {
Ok(value) => value,
@@ -315,15 +368,18 @@ pub(super) async fn handle_users_me_providers_get(
};
let mut endpoints_by_provider = BTreeMap::<String, Vec<serde_json::Value>>::new();
for endpoint in endpoints {
let mut endpoint_payload = json!({
"id": endpoint.id,
"api_format": endpoint.api_format,
"is_active": endpoint.is_active,
});
if expose_provider_details {
endpoint_payload["base_url"] = json!(endpoint.base_url);
}
endpoints_by_provider
.entry(endpoint.provider_id)
.or_default()
.push(json!({
"id": endpoint.id,
"api_format": endpoint.api_format,
"base_url": endpoint.base_url,
"is_active": endpoint.is_active,
}));
.push(endpoint_payload);
}
let mut models_by_provider = BTreeMap::<String, Vec<serde_json::Value>>::new();
@@ -375,20 +431,23 @@ pub(super) async fn handle_users_me_providers_get(
.into_iter()
.map(|provider| {
let provider_id = provider.id.clone();
let description = provider
.config
.as_ref()
.and_then(|value| value.get("description"))
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned);
json!({
let mut payload = json!({
"id": provider_id.clone(),
"name": provider.name,
"description": description,
"provider_priority": provider.provider_priority,
"endpoints": endpoints_by_provider.remove(&provider_id).unwrap_or_default(),
"models": models_by_provider.remove(&provider_id).unwrap_or_default(),
})
});
if expose_provider_details {
let description = provider
.config
.as_ref()
.and_then(|value| value.get("description"))
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned);
payload["name"] = json!(provider.name);
payload["description"] = json!(description);
}
payload
})
.collect::<Vec<_>>(),
)

View File

@@ -75,12 +75,12 @@ fn validate_user_model_capability_settings(
fn build_users_me_preferences_payload(
preferences: &GatewayUserPreferenceView,
expose_provider_details: bool,
) -> serde_json::Value {
json!({
let mut payload = json!({
"avatar_url": preferences.avatar_url,
"bio": preferences.bio,
"default_provider_id": preferences.default_provider_id,
"default_provider": preferences.default_provider_name,
"theme": preferences.theme,
"language": preferences.language,
"timezone": preferences.timezone,
@@ -89,7 +89,11 @@ fn build_users_me_preferences_payload(
"usage_alerts": preferences.usage_alerts,
"announcements": preferences.announcement_notifications,
},
})
});
if expose_provider_details {
payload["default_provider"] = json!(preferences.default_provider_name);
}
payload
}
fn parse_users_me_optional_string_field(
@@ -188,7 +192,11 @@ pub(super) async fn handle_users_me_preferences_get(
}
};
Json(build_users_me_preferences_payload(&preferences)).into_response()
Json(build_users_me_preferences_payload(
&preferences,
auth.user.role.eq_ignore_ascii_case("admin"),
))
.into_response()
}
pub(super) async fn handle_users_me_preferences_put(

View File

@@ -722,24 +722,26 @@ pub(super) async fn handle_users_me_usage_get(
);
}
};
summary_by_provider = match state
.summarize_usage_breakdown(&UsageBreakdownSummaryQuery {
created_from_unix_secs,
created_until_unix_secs,
user_id: Some(auth.user.id.clone()),
group_by: UsageBreakdownGroupBy::Provider,
})
.await
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user usage provider breakdown lookup failed: {err:?}"),
false,
);
}
};
if include_actual_cost {
summary_by_provider = match state
.summarize_usage_breakdown(&UsageBreakdownSummaryQuery {
created_from_unix_secs,
created_until_unix_secs,
user_id: Some(auth.user.id.clone()),
group_by: UsageBreakdownGroupBy::Provider,
})
.await
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user usage provider breakdown lookup failed: {err:?}"),
false,
);
}
};
}
summary_by_api_format = match state
.summarize_usage_breakdown(&UsageBreakdownSummaryQuery {
created_from_unix_secs,
@@ -936,7 +938,6 @@ pub(super) async fn handle_users_me_usage_get(
"avg_response_time": avg_response_time,
"billing": build_auth_wallet_summary_payload(wallet.as_ref()),
"summary_by_model": build_users_me_usage_summary_by_model(&summary_by_model, include_actual_cost),
"summary_by_provider": build_users_me_usage_summary_by_provider(&summary_by_provider),
"summary_by_api_format": build_users_me_usage_summary_by_api_format(&summary_by_api_format),
"pagination": {
"total": total_record_count,
@@ -948,6 +949,9 @@ pub(super) async fn handle_users_me_usage_get(
});
if include_actual_cost {
payload["total_actual_cost"] = json!(total_actual_cost);
payload["summary_by_provider"] = json!(build_users_me_usage_summary_by_provider(
&summary_by_provider
));
}
Json(payload).into_response()
}

View File

@@ -28,6 +28,7 @@ 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;
use self::reads::{
build_wallet_daily_usage_payload, build_wallet_payload, build_wallet_zero_today_entry,
handle_wallet_balance, handle_wallet_today_cost, handle_wallet_transactions,

View File

@@ -49,7 +49,7 @@ fn build_wallet_balance_payload(
payload
}
async fn build_wallet_balance_payload_for_user(
pub(in crate::handlers::public::support) async fn build_wallet_balance_payload_for_user(
state: &AppState,
user_id: &str,
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,

View File

@@ -29,6 +29,7 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
struct SeenExecutionRuntimeRequest {
url: String,
authorization: String,
accept: String,
provider_api_format: String,
total_ms: Option<u64>,
}
@@ -68,6 +69,7 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
.get("authorization")
.cloned()
.unwrap_or_default(),
accept: plan.headers.get("accept").cloned().unwrap_or_default(),
provider_api_format: plan.provider_api_format.clone(),
total_ms: plan
.timeouts
@@ -78,41 +80,22 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
request_id: plan.request_id,
candidate_id: None,
status_code: 200,
headers: BTreeMap::from([
(
"x-codex-primary-reset-after-seconds".to_string(),
"18000".to_string(),
),
(
"x-codex-primary-reset-at".to_string(),
"1900000000".to_string(),
),
(
"x-codex-secondary-reset-after-seconds".to_string(),
"604800".to_string(),
),
(
"x-codex-secondary-reset-at".to_string(),
"1900500000".to_string(),
),
]),
headers: BTreeMap::new(),
body: Some(aether_contracts::ResponseBody {
json_body: Some(json!({
"plan_type": "plus",
"rate_limit": {
"primary_window": {
"used_percent": 12.5,
"window_minutes": 300
},
"secondary_window": {
"used_percent": 55.0,
"window_minutes": 10080
}
"user": {
"id": "user-codex-123",
"email": "codex@example.com",
"name": "Codex User"
},
"credits": {
"has_credits": true,
"balance": 42.0,
"unlimited": false
"account": {
"id": "acct-codex-123",
"name": "Personal",
"plan_type": "plus"
},
"plan": {
"type": "Plus",
"title": "ChatGPT Plus"
}
})),
body_bytes_b64: None,
@@ -147,7 +130,7 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
)],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let (_upstream_url, upstream_handle) = start_server(upstream).await;
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
let gateway = build_router_with_state(
build_state_with_execution_runtime_override(execution_runtime_url.clone())
@@ -184,18 +167,22 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
);
assert_eq!(payload["results"][0]["quota_snapshot"]["plan_type"], "plus");
assert_eq!(
payload["results"][0]["quota_snapshot"]["reset_at"],
1_900_000_000u64
);
assert_eq!(
payload["results"][0]["quota_snapshot"]["credits"]["balance"],
json!(42.0)
payload["results"][0]["quota_snapshot"]["exhausted"],
json!(false)
);
assert_eq!(
payload["results"][0]["quota_snapshot"]["windows"]
.as_array()
.map(Vec::len),
Some(2usize)
Some(0usize)
);
assert_eq!(
payload["results"][0]["metadata"]["email"],
"codex@example.com"
);
assert_eq!(
payload["results"][0]["metadata"]["account_id"],
"acct-codex-123"
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
@@ -206,12 +193,13 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
.expect("execution runtime request should be captured");
assert_eq!(
seen_execution_runtime_request.url,
"https://chatgpt.com/backend-api/wham/usage"
"https://chatgpt.com/backend-api/me"
);
assert_eq!(
seen_execution_runtime_request.authorization,
"Bearer sk-codex-123"
);
assert_eq!(seen_execution_runtime_request.accept, "application/json");
assert_eq!(
seen_execution_runtime_request.provider_api_format,
"openai:responses"
@@ -237,32 +225,130 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
.upstream_metadata
.as_ref()
.and_then(|value| value.get("codex"))
.and_then(|value| value.get("primary_used_percent")),
Some(&json!(55.0))
.and_then(|value| value.get("email")),
Some(&json!("codex@example.com"))
);
assert_eq!(
reloaded[0]
.upstream_metadata
.as_ref()
.and_then(|value| value.get("codex"))
.and_then(|value| value.get("primary_reset_at")),
Some(&json!(1_900_500_000u64))
.and_then(|value| value.get("account_id")),
Some(&json!("acct-codex-123"))
);
assert_eq!(
reloaded[0]
.upstream_metadata
.as_ref()
.and_then(|value| value.get("codex"))
.and_then(|value| value.get("secondary_used_percent")),
Some(&json!(12.5))
assert!(reloaded[0]
.upstream_metadata
.as_ref()
.and_then(|value| value.get("codex"))
.and_then(|value| value.get("primary_used_percent"))
.is_none());
gateway_handle.abort();
execution_runtime_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_marks_codex_key_invalid_when_backend_me_returns_payment_required() {
let upstream = Router::new().route(
"/api/admin/endpoints/providers/provider-codex/refresh-quota",
any(move |_request: Request| async move {
(StatusCode::OK, Body::from("unexpected upstream hit"))
}),
);
let execution_runtime = Router::new().route(
"/v1/execute/sync",
any(move |request: Request| async move {
let plan: aether_contracts::ExecutionPlan = serde_json::from_slice(
&to_bytes(request.into_body(), usize::MAX)
.await
.expect("body should read"),
)
.expect("plan should parse");
let result = aether_contracts::ExecutionResult {
request_id: plan.request_id,
candidate_id: None,
status_code: 402,
headers: BTreeMap::new(),
body: Some(aether_contracts::ResponseBody {
json_body: Some(json!({
"error": {
"message": "payment required"
}
})),
body_bytes_b64: None,
}),
telemetry: None,
error: None,
};
(StatusCode::OK, Json(result))
}),
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![StoredProviderCatalogProvider::new(
"provider-codex".to_string(),
"codex".to_string(),
Some("https://example.com".to_string()),
"codex".to_string(),
)
.expect("provider should build")],
vec![sample_endpoint(
"endpoint-codex-cli",
"provider-codex",
"openai:responses",
"https://chatgpt.com/backend-api",
)],
vec![sample_key(
"key-codex-a",
"provider-codex",
"openai:responses",
"sk-codex-123",
)],
));
let (_upstream_url, upstream_handle) = start_server(upstream).await;
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
let gateway = build_router_with_state(
build_state_with_execution_runtime_override(execution_runtime_url.clone())
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(
provider_catalog_repository.clone(),
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.post(format!(
"{gateway_url}/api/admin/endpoints/providers/provider-codex/refresh-quota"
))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.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["success"], 0);
assert_eq!(payload["failed"], 1);
assert_eq!(payload["results"][0]["status"], "payment_required");
assert_eq!(payload["results"][0]["status_code"], 402);
let reloaded = provider_catalog_repository
.list_keys_by_ids(&["key-codex-a".to_string()])
.await
.expect("keys should read");
assert_eq!(reloaded.len(), 1);
assert!(reloaded[0].oauth_invalid_at_unix_secs.is_some());
assert_eq!(
reloaded[0]
.upstream_metadata
.as_ref()
.and_then(|value| value.get("codex"))
.and_then(|value| value.get("secondary_reset_at")),
Some(&json!(1_900_000_000u64))
reloaded[0].oauth_invalid_reason.as_deref(),
Some("[ACCOUNT_BLOCK] payment required")
);
gateway_handle.abort();
@@ -1139,7 +1225,7 @@ async fn gateway_reports_codex_quota_runtime_failures_locally_without_falling_ba
assert!(payload["results"][0]["message"]
.as_str()
.expect("message should be string")
.contains("wham/usage 请求执行失败: execution runtime returned HTTP 500"));
.contains("backend-api/me 请求执行失败: execution runtime returned HTTP 500"));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
let reloaded = provider_catalog_repository

View File

@@ -194,16 +194,17 @@ fn codex_quota_execution_result(request_id: &str) -> serde_json::Value {
"headers": {},
"body": {
"json_body": {
"plan_type": "plus",
"rate_limit": {
"primary_window": {
"used_percent": 10.0,
"window_minutes": 300
},
"secondary_window": {
"used_percent": 20.0,
"window_minutes": 10080
}
"user": {
"id": "user-codex-123",
"email": "alice@example.com"
},
"account": {
"id": "acct-codex-123",
"plan_type": "plus"
},
"plan": {
"type": "Plus",
"title": "ChatGPT Plus"
}
}
}
@@ -2453,8 +2454,8 @@ async fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_p
.as_str()
.expect("account_state_recheck_error should be string when recheck is attempted");
assert!(
account_state_recheck_error == "wham/usage API 返回状态码 401"
|| account_state_recheck_error.starts_with("wham/usage 请求执行失败:"),
account_state_recheck_error == "backend-api/me API 返回状态码 401"
|| account_state_recheck_error.starts_with("backend-api/me 请求执行失败:"),
"unexpected account_state_recheck_error: {account_state_recheck_error}"
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
@@ -4924,8 +4925,8 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
.as_str()
.expect("account_state_recheck_error should be string when attempted");
assert!(
account_state_recheck_error == "wham/usage API 返回状态码 401"
|| account_state_recheck_error.starts_with("wham/usage 请求执行失败:"),
account_state_recheck_error == "backend-api/me API 返回状态码 401"
|| account_state_recheck_error.starts_with("backend-api/me 请求执行失败:"),
"unexpected account_state_recheck_error: {account_state_recheck_error}"
);
} else {
@@ -4943,7 +4944,7 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
.expect("execution runtime request should be captured");
assert_eq!(
seen_execution_runtime_request.url,
"https://chatgpt.com/backend-api/wham/usage"
"https://chatgpt.com/backend-api/me"
);
assert_eq!(
seen_execution_runtime_request.authorization,
@@ -4967,7 +4968,7 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
.expect("refreshed api key should decrypt");
assert_eq!(decrypted_api_key, "refreshed-codex-access-token");
if account_state_recheck_attempted
&& payload["account_state_recheck_error"] == "wham/usage API 返回状态码 401"
&& payload["account_state_recheck_error"] == "backend-api/me API 返回状态码 401"
{
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
assert_eq!(

View File

@@ -167,7 +167,7 @@ async fn gateway_handles_public_openai_models_without_hitting_fallback_probe() {
assert_eq!(payload["object"], "list");
assert_eq!(payload["data"][0]["id"], "gpt-4.1");
assert_eq!(payload["data"][1]["id"], "gpt-5");
assert_eq!(payload["data"][0]["owned_by"], "openai");
assert_eq!(payload["data"][0]["owned_by"], "aether");
assert_eq!(*fallback_probe_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -231,7 +231,7 @@ async fn gateway_handles_public_openai_models_with_cross_format_candidates_witho
list_response.json().await.expect("json body should parse");
assert_eq!(list_payload["object"], "list");
assert_eq!(list_payload["data"][0]["id"], "claude-3-7-sonnet");
assert_eq!(list_payload["data"][0]["owned_by"], "claude");
assert_eq!(list_payload["data"][0]["owned_by"], "aether");
let detail_response = client
.get(format!("{gateway_url}/v1/models/claude-3-7-sonnet"))
@@ -245,7 +245,7 @@ async fn gateway_handles_public_openai_models_with_cross_format_candidates_witho
.await
.expect("json body should parse");
assert_eq!(detail_payload["id"], "claude-3-7-sonnet");
assert_eq!(detail_payload["owned_by"], "claude");
assert_eq!(detail_payload["owned_by"], "aether");
assert_eq!(*fallback_probe_hits.lock().expect("mutex should lock"), 0);

View File

@@ -669,6 +669,7 @@ async fn gateway_handles_public_catalog_site_info_without_proxying_upstream() {
vec![
("site_name".to_string(), json!("Aether Local")),
("site_subtitle".to_string(), json!("Rust Only")),
("show_github_link".to_string(), json!(false)),
],
),
),
@@ -685,6 +686,7 @@ async fn gateway_handles_public_catalog_site_info_without_proxying_upstream() {
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["site_name"], "Aether Local");
assert_eq!(payload["site_subtitle"], "Rust Only");
assert_eq!(payload["show_github_link"], false);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -757,7 +759,9 @@ async fn gateway_handles_public_catalog_providers_without_proxying_upstream() {
let providers = payload.as_array().expect("providers should be an array");
assert_eq!(providers.len(), 2);
assert_eq!(providers[0]["id"], "provider-openai");
assert_eq!(providers[0]["name"], "openai");
assert!(providers[0].get("name").is_none());
assert!(providers[0].get("description").is_none());
assert!(providers[0].get("website").is_none());
assert_eq!(providers[0]["provider_priority"], 10);
assert_eq!(providers[0]["endpoints_count"], 1);
assert_eq!(providers[0]["active_endpoints_count"], 1);
@@ -834,7 +838,8 @@ async fn gateway_handles_public_catalog_models_without_proxying_upstream() {
let models = payload.as_array().expect("models should be an array");
assert_eq!(models.len(), 1);
assert_eq!(models[0]["id"], "model-openai-gpt5");
assert_eq!(models[0]["provider_name"], "openai");
assert!(models[0].get("provider_id").is_none());
assert!(models[0].get("provider_name").is_none());
assert_eq!(models[0]["name"], "gpt-5");
assert_eq!(models[0]["display_name"], "GPT 5");
assert_eq!(models[0]["tags"], serde_json::Value::Null);
@@ -993,7 +998,8 @@ async fn gateway_handles_public_catalog_search_models_without_proxying_upstream(
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let models = payload.as_array().expect("models should be an array");
assert_eq!(models.len(), 1);
assert_eq!(models[0]["provider_name"], "claude");
assert!(models[0].get("provider_id").is_none());
assert!(models[0].get("provider_name").is_none());
assert_eq!(models[0]["name"], "claude-sonnet-4-5");
assert_eq!(models[0]["display_name"], "Claude Sonnet 4.5");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
@@ -1101,10 +1107,16 @@ async fn gateway_handles_public_global_models_without_proxying_upstream() {
}),
);
let mut gpt_model = sample_public_global_model("gm-3", "gpt-5", "GPT 5", true);
gpt_model.config = Some(json!({
"description": "Public description",
"model_mappings": ["gpt-5-upstream"],
"provider_model_mappings": [{"name": "provider-gpt-5"}],
}));
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
sample_public_global_model("gm-1", "claude-sonnet-4-5", "Claude Sonnet 4.5", true),
sample_public_global_model("gm-2", "disabled-model", "Disabled Model", false),
sample_public_global_model("gm-3", "gpt-5", "GPT 5", true),
gpt_model,
]));
let (upstream_url, upstream_handle) = start_server(upstream).await;
@@ -1131,6 +1143,16 @@ async fn gateway_handles_public_global_models_without_proxying_upstream() {
assert_eq!(payload["models"][0]["name"], "gpt-5");
assert_eq!(payload["models"][0]["display_name"], "GPT 5");
assert_eq!(payload["models"][0]["usage_count"], 0);
assert_eq!(
payload["models"][0]["config"]["description"],
"Public description"
);
assert!(payload["models"][0]["config"]
.get("model_mappings")
.is_none());
assert!(payload["models"][0]["config"]
.get("provider_model_mappings")
.is_none());
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -1409,6 +1431,8 @@ async fn gateway_handles_auth_registration_settings_without_proxying_upstream()
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("noreply@example.com")),
("password_policy_level".to_string(), json!("strong")),
("turnstile_enabled".to_string(), json!(true)),
("turnstile_site_key".to_string(), json!("site-key-123")),
]);
let (upstream_url, upstream_handle) = start_server(upstream).await;
@@ -1434,6 +1458,8 @@ async fn gateway_handles_auth_registration_settings_without_proxying_upstream()
"require_email_verification": true,
"email_configured": true,
"password_policy_level": "strong",
"turnstile_enabled": true,
"turnstile_site_key": "site-key-123",
})
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
@@ -1689,9 +1715,9 @@ async fn gateway_handles_public_providers_without_proxying_upstream() {
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["providers"][0]["name"], "openai");
assert!(payload["providers"][0].get("name").is_none());
assert_eq!(payload["providers"][0]["provider_priority"], 10);
assert_eq!(payload["providers"][1]["name"], "anthropic");
assert!(payload["providers"][1].get("name").is_none());
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -1739,7 +1765,7 @@ async fn gateway_handles_public_provider_detail_without_proxying_upstream() {
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["id"], "provider-1");
assert_eq!(payload["name"], "openai");
assert!(payload.get("name").is_none());
assert_eq!(payload["provider_priority"], 10);
let response = reqwest::Client::new()
@@ -1748,11 +1774,9 @@ async fn gateway_handles_public_provider_detail_without_proxying_upstream() {
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["id"], "provider-1");
assert_eq!(payload["name"], "openai");
assert_eq!(payload["provider_priority"], 10);
assert_eq!(payload["detail"], "Provider not found");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -1805,6 +1829,9 @@ async fn gateway_handles_public_providers_with_endpoints_without_proxying_upstre
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["providers"][0]["endpoints"][0]["id"], "endpoint-1");
assert!(payload["providers"][0]["endpoints"][0]
.get("base_url")
.is_none());
assert_eq!(
payload["providers"][0]["endpoints"][0]["api_format"],
"openai:chat"
@@ -1926,7 +1953,7 @@ async fn gateway_handles_public_test_connection_without_hitting_fallback_probe()
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/v1/test-connection?provider=openai&model=gpt-5&api_format=openai:chat"
"{gateway_url}/v1/test-connection?provider=provider-1&model=gpt-5&api_format=openai:chat"
))
.send()
.await
@@ -1935,7 +1962,8 @@ async fn gateway_handles_public_test_connection_without_hitting_fallback_probe()
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["status"], "success");
assert_eq!(payload["provider"], "openai");
assert!(payload.get("provider").is_none());
assert_eq!(payload["provider_id"], "provider-1");
assert_eq!(payload["api_format"], "openai:chat");
assert_eq!(payload["response_id"], "resp_local_test");
assert_eq!(*provider_hits.lock().expect("mutex should lock"), 1);
@@ -4771,7 +4799,13 @@ async fn gateway_handles_users_me_preferences_locally_without_proxying_upstream(
"refresh-token-placeholder",
now,
)],
std::iter::empty::<crate::data::state::StoredUserPreferenceRecord>(),
{
let mut preference =
crate::data::state::StoredUserPreferenceRecord::default_for_user("user-auth-1");
preference.default_provider_id = Some("provider-openai".to_string());
preference.default_provider_name = Some("openai".to_string());
vec![preference]
},
)
.await;
@@ -4791,6 +4825,8 @@ async fn gateway_handles_users_me_preferences_locally_without_proxying_upstream(
assert_eq!(get_payload["language"], "zh-CN");
assert_eq!(get_payload["timezone"], "Asia/Shanghai");
assert_eq!(get_payload["notifications"]["email"], true);
assert_eq!(get_payload["default_provider_id"], "provider-openai");
assert!(get_payload.get("default_provider").is_none());
let put_response = client
.put(format!("{gateway_url}/api/users/me/preferences"))
@@ -5041,6 +5077,7 @@ async fn gateway_handles_users_me_usage_locally_without_proxying_upstream() {
105
);
assert_eq!(payload["summary_by_model"][0]["total_input_context"], 120);
assert!(payload.get("summary_by_provider").is_none());
assert_eq!(payload["billing"]["id"], "wallet-auth-1");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
@@ -7552,8 +7589,10 @@ async fn gateway_handles_users_me_providers_locally_without_proxying_upstream()
let providers = payload.as_array().expect("providers should be array");
assert_eq!(providers.len(), 1);
assert_eq!(providers[0]["id"], "provider-openai");
assert_eq!(providers[0]["name"], "openai");
assert!(providers[0].get("name").is_none());
assert!(providers[0].get("description").is_none());
assert_eq!(providers[0]["endpoints"][0]["id"], "endpoint-openai-1");
assert!(providers[0]["endpoints"][0].get("base_url").is_none());
assert_eq!(providers[0]["models"][0]["name"], "gpt-5");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
@@ -7990,6 +8029,127 @@ async fn gateway_handles_auth_send_verification_code_locally_without_proxying_up
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_requires_turnstile_token_before_auth_send_verification_code() {
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::disabled().with_system_config_values_for_tests(
vec![
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("noreply@example.com")),
("turnstile_enabled".to_string(), json!(true)),
("turnstile_site_key".to_string(), json!("site-key-123")),
("turnstile_secret_key".to_string(), json!("secret-key-123")),
],
),
)
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/send-verification-code"))
.json(&json!({ "email": "alice@example.com" }))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "请先完成人机验证");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_verifies_turnstile_token_before_auth_send_verification_code() {
let seen_siteverify_body = Arc::new(Mutex::new(None::<String>));
let seen_siteverify_body_clone = Arc::clone(&seen_siteverify_body);
let siteverify = Router::new().route(
"/turnstile/siteverify",
any(move |request: Request| {
let seen_siteverify_body_inner = Arc::clone(&seen_siteverify_body_clone);
async move {
let body = String::from_utf8(
to_bytes(request.into_body(), usize::MAX)
.await
.expect("body should read")
.to_vec(),
)
.expect("siteverify body should be utf8");
*seen_siteverify_body_inner
.lock()
.expect("mutex should lock") = Some(body);
(
StatusCode::OK,
Json(json!({
"success": true,
"hostname": "localhost"
})),
)
}
}),
);
let (siteverify_url, siteverify_handle) = start_server(siteverify).await;
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::disabled().with_system_config_values_for_tests(
vec![
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("noreply@example.com")),
("smtp_from_name".to_string(), json!("Aether Mail")),
("turnstile_enabled".to_string(), json!(true)),
("turnstile_site_key".to_string(), json!("site-key-123")),
("turnstile_secret_key".to_string(), json!("secret-key-123")),
(
"turnstile_siteverify_url".to_string(),
json!(format!("{siteverify_url}/turnstile/siteverify")),
),
(
"turnstile_allowed_hostnames".to_string(),
json!(["localhost"]),
),
],
),
)
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/send-verification-code"))
.json(&json!({
"email": "alice@example.com",
"turnstile_token": "turnstile-token-123"
}))
.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["success"], true);
let siteverify_body = seen_siteverify_body
.lock()
.expect("mutex should lock")
.clone()
.expect("siteverify should be called");
assert!(siteverify_body.contains("secret=secret-key-123"));
assert!(siteverify_body.contains("response=turnstile-token-123"));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
siteverify_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_auth_verification_status_locally_without_proxying_upstream() {
let now = Utc::now() - chrono::Duration::seconds(10);
@@ -8209,6 +8369,203 @@ async fn gateway_handles_users_me_available_models_locally_without_proxying_upst
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_filters_users_me_available_models_by_group_policy_and_hides_model_mappings() {
let now = Utc::now();
let mut user = sample_auth_user(now);
user.allowed_providers = None;
user.allowed_providers_mode = "unrestricted".to_string();
user.allowed_models = None;
user.allowed_models_mode = "unrestricted".to_string();
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-group-models"),
),
]),
now + chrono::Duration::hours(1),
);
let mut allowed_model =
sample_public_global_model("gm-2", "claude-sonnet-4-5", "Claude Sonnet 4.5", true);
allowed_model.config = Some(json!({
"description": "Claude detail",
"model_mappings": ["claude-upstream"]
}));
let mut blocked_model = sample_public_global_model("gm-1", "gpt-5", "GPT 5", true);
blocked_model.config = Some(json!({
"description": "GPT detail",
"model_mappings": ["gpt-upstream"]
}));
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
blocked_model,
allowed_model,
]));
let user_repository: Arc<dyn UserReadRepository> =
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let group = user_repository
.create_user_group(UpsertUserGroupRecord {
name: "Claude only".to_string(),
description: None,
priority: 0,
allowed_providers: None,
allowed_providers_mode: "unrestricted".to_string(),
allowed_api_formats: None,
allowed_api_formats_mode: "unrestricted".to_string(),
allowed_models: Some(vec!["claude-sonnet-4-5".to_string()]),
allowed_models_mode: "specific".to_string(),
rate_limit: None,
rate_limit_mode: "system".to_string(),
})
.await
.expect("group should create")
.expect("group should exist");
user_repository
.add_user_to_group(&group.id, "user-auth-1")
.await
.expect("group membership should create");
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::with_global_model_reader_for_tests(
global_model_repository,
)
.with_user_reader(Arc::clone(&user_repository));
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-group-models",
"device-users-me-group-models",
"refresh-token-placeholder",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/available-models"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-group-models")
.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");
let models = payload["models"]
.as_array()
.expect("models should be an array");
assert_eq!(payload["total"], 1);
assert_eq!(models.len(), 1);
assert_eq!(models[0]["name"], "claude-sonnet-4-5");
assert_eq!(models[0]["config"]["description"], "Claude detail");
assert!(models[0]["config"].get("model_mappings").is_none());
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_no_users_me_available_models_when_group_denies_all_models() {
let now = Utc::now();
let mut user = sample_auth_user(now);
user.allowed_providers = None;
user.allowed_providers_mode = "unrestricted".to_string();
user.allowed_models = None;
user.allowed_models_mode = "unrestricted".to_string();
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-deny-all-models"),
),
]),
now + chrono::Duration::hours(1),
);
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
sample_public_global_model("gm-1", "gpt-5", "GPT 5", true),
sample_public_global_model("gm-2", "claude-sonnet-4-5", "Claude Sonnet 4.5", true),
]));
let user_repository: Arc<dyn UserReadRepository> =
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let group = user_repository
.create_user_group(UpsertUserGroupRecord {
name: "No models".to_string(),
description: None,
priority: 0,
allowed_providers: None,
allowed_providers_mode: "unrestricted".to_string(),
allowed_api_formats: None,
allowed_api_formats_mode: "unrestricted".to_string(),
allowed_models: None,
allowed_models_mode: "deny_all".to_string(),
rate_limit: None,
rate_limit_mode: "system".to_string(),
})
.await
.expect("group should create")
.expect("group should exist");
user_repository
.add_user_to_group(&group.id, "user-auth-1")
.await
.expect("group membership should create");
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::with_global_model_reader_for_tests(
global_model_repository,
)
.with_user_reader(Arc::clone(&user_repository));
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-deny-all-models",
"device-users-me-deny-all-models",
"refresh-token-placeholder",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/available-models"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-deny-all-models")
.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["total"], 0);
assert_eq!(payload["models"].as_array().map(Vec::len), Some(0));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_users_me_available_models_without_provider_catalog(
) {

View File

@@ -7,6 +7,40 @@ use super::{
StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot, StoredUserAuthRecord,
StoredUserExportRow, Utc,
};
use aether_data_contracts::repository::billing::{
BillingReadRepository, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
};
use aether_data_contracts::DataLayerError;
#[derive(Debug)]
struct StaticDailyQuotaBillingRepository {
user_id: String,
quota: UserDailyQuotaAvailabilityRecord,
}
#[async_trait::async_trait]
impl BillingReadRepository for StaticDailyQuotaBillingRepository {
async fn find_model_context(
&self,
provider_id: &str,
provider_api_key_id: Option<&str>,
global_model_name: &str,
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
let _ = (provider_id, provider_api_key_id, global_model_name);
Ok(None)
}
async fn find_user_daily_quota_availability(
&self,
user_id: &str,
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, DataLayerError> {
if user_id == self.user_id {
Ok(Some(self.quota.clone()))
} else {
Ok(None)
}
}
}
fn stable_dashboard_now() -> chrono::DateTime<Utc> {
Utc::now()
@@ -173,6 +207,93 @@ async fn gateway_handles_dashboard_stats_locally_without_proxying_upstream() {
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_dashboard_stats_user_wallet_card_uses_wallet_center_balance_breakdown() {
let now = stable_dashboard_now();
let user = sample_auth_user(now);
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-dashboard-wallet")),
]),
chrono::Utc::now() + chrono::Duration::hours(1),
);
let session = sample_auth_session(
"user-auth-1",
"session-dashboard-wallet",
"device-dashboard-wallet",
"refresh-dashboard-wallet",
now,
);
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![]));
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
user.clone()
]));
let mut wallet = sample_auth_wallet("user-auth-1", now);
wallet.balance = 7.0;
wallet.gift_balance = 3.0;
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![wallet]));
let billing_repository: Arc<dyn BillingReadRepository> =
Arc::new(StaticDailyQuotaBillingRepository {
user_id: "user-auth-1".to_string(),
quota: UserDailyQuotaAvailabilityRecord {
has_active_daily_quota: true,
total_quota_usd: 120.0,
used_usd: 20.0,
remaining_usd: 100.0,
allow_wallet_overage: true,
},
});
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(Vec::<(
Option<String>,
StoredAuthApiKeySnapshot,
)>::new()));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = GatewayDataState::with_usage_billing_and_wallet_for_tests(
usage_repository,
Arc::clone(&billing_repository),
wallet_repository,
)
.with_user_reader(user_repository)
.with_auth_api_key_reader(auth_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([session])
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/dashboard/stats?days=30"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-dashboard-wallet")
.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["stats"][2]["name"], "钱包余额");
assert_eq!(payload["stats"][2]["value"], "$110.00");
assert_eq!(
payload["stats"][2]["subValue"],
"套餐额度 $100.00 · 钱包余额 $10.00"
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_dashboard_stats_include_end_of_day_boundary() {
let now = stable_dashboard_now();
@@ -1071,16 +1192,9 @@ async fn gateway_handles_dashboard_provider_status_locally_without_proxying_upst
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let providers = payload["providers"].as_array().expect("array");
assert_eq!(providers.len(), 3);
assert_eq!(providers[0]["name"], "openai");
assert_eq!(providers[0]["requests"], 2);
assert_eq!(providers[1]["name"], "claude");
assert_eq!(providers[1]["requests"], 1);
assert_eq!(providers[2]["name"], "gemini");
assert_eq!(providers[2]["requests"], 0);
assert_eq!(payload["detail"], "仅管理员可查看供应商状态");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();